Skip to content

Commit 9eaaa1a

Browse files
new .py script to run task within multiple python env context (winpython, venv, venv*,...)
1 parent 288491d commit 9eaaa1a

2 files changed

Lines changed: 177 additions & 39 deletions

File tree

.env.template

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
1-
PYTHONPATH=.
1+
# Python interpreter (explicit path, e.g. WinPython). Auto-detected if empty.
2+
PYTHON=
3+
# WinPython base directory (legacy, prefer PYTHON instead)
4+
# WINPYDIRBASE=
5+
# Virtual environment directory (e.g. .venv39). Auto-discovered if empty.
6+
VENV_DIR=
7+
# Python path for development (sibling packages)
8+
PYTHONPATH=.
9+
# Locale (e.g. fr)
10+
LANG=

scripts/run_with_env.py

Lines changed: 167 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,151 @@
11
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
22

3-
"""Run a command with environment variables loaded from a .env file."""
3+
"""Run a command with environment variables loaded from a .env file.
4+
5+
This script automatically detects the best Python interpreter to use:
6+
7+
1. ``PYTHON`` variable in ``.env`` file (e.g. for WinPython distributions)
8+
2. ``WINPYDIRBASE`` variable (legacy WinPython base directory)
9+
3. ``VENV_DIR`` variable (explicit virtual environment directory)
10+
4. A local virtual environment (``.venv*`` directory in the project root)
11+
5. Falls back to ``sys.executable`` (the Python that launched this script)
12+
13+
This ensures that VS Code tasks always use the correct Python environment
14+
regardless of which interpreter is configured globally or in VS Code.
15+
"""
416

517
from __future__ import annotations
618

19+
import glob
720
import os
821
import subprocess
922
import sys
1023
from pathlib import Path
1124

1225

26+
def _find_venv_python(project_root: Path) -> str | None:
27+
"""Find a Python executable in a ``.venv*`` directory.
28+
29+
Searches for directories matching ``.venv*`` in the project root and
30+
returns the first valid Python executable found.
31+
32+
Args:
33+
project_root: The root directory of the project.
34+
35+
Returns:
36+
Absolute path to the venv Python executable, or None if not found.
37+
"""
38+
# Sort to prefer ".venv" over ".venv-xyz" etc.
39+
venv_dirs = sorted(glob.glob(str(project_root / ".venv*")))
40+
for venv_dir in venv_dirs:
41+
venv_path = Path(venv_dir)
42+
if not venv_path.is_dir():
43+
continue
44+
result = _get_venv_python(venv_path)
45+
if result:
46+
return result
47+
return None
48+
49+
50+
def _get_venv_python(venv_dir: Path) -> str | None:
51+
"""Get the Python executable from a specific venv directory.
52+
53+
Args:
54+
venv_dir: Path to the virtual environment directory.
55+
56+
Returns:
57+
Absolute path to the Python executable, or None if not found.
58+
"""
59+
if not venv_dir.is_dir():
60+
return None
61+
# Windows: Scripts/python.exe — Unix: bin/python
62+
candidates = [
63+
venv_dir / "Scripts" / "python.exe",
64+
venv_dir / "bin" / "python",
65+
]
66+
for candidate in candidates:
67+
if candidate.is_file():
68+
# Keep the venv-local executable path without resolving symlinks:
69+
# on Linux/WSL, ``bin/python`` is often a symlink to a global
70+
# interpreter (e.g. /usr/bin/python3.x). Resolving it would lose
71+
# venv context and site-packages selection.
72+
return str(candidate.absolute())
73+
return None
74+
75+
76+
def resolve_python(project_root: Path) -> str:
77+
"""Resolve the best Python interpreter for the project.
78+
79+
Priority order:
80+
81+
1. ``PYTHON`` environment variable (set in ``.env`` or externally)
82+
2. ``WINPYDIRBASE`` environment variable (legacy WinPython base directory)
83+
3. ``VENV_DIR`` environment variable (explicit venv directory)
84+
4. ``.venv*`` directory in *project_root* (auto-discovery)
85+
5. ``sys.executable`` (the interpreter running this script)
86+
87+
Args:
88+
project_root: The root directory of the project.
89+
90+
Returns:
91+
Absolute path to the Python executable to use.
92+
"""
93+
# 1. Explicit PYTHON variable (e.g. WinPython distribution)
94+
python_env = os.environ.get("PYTHON")
95+
if python_env:
96+
python_path = Path(python_env)
97+
if python_path.is_file():
98+
# Do not resolve symlinks for the same reason as in
99+
# ``_get_venv_python``.
100+
resolved = str(python_path.absolute())
101+
print(f" 🐍 Using PYTHON from .env: {resolved}")
102+
return resolved
103+
print(f" ⚠️ PYTHON variable set but not found: {python_env}")
104+
105+
# 2. Legacy WINPYDIRBASE variable (WinPython distribution)
106+
winpy_base = os.environ.get("WINPYDIRBASE")
107+
if winpy_base and Path(winpy_base).is_dir():
108+
# Search for python.exe in the WinPython directory structure
109+
# (e.g. WINPYDIRBASE/python-3.11.5.amd64/python.exe)
110+
for candidate in sorted(Path(winpy_base).glob("python-*/python.exe")):
111+
if candidate.is_file():
112+
resolved = str(candidate.absolute())
113+
print(f" 🐍 Using WINPYDIRBASE (legacy): {resolved}")
114+
return resolved
115+
# Also try direct python.exe in the base directory
116+
direct = Path(winpy_base) / "python.exe"
117+
if direct.is_file():
118+
resolved = str(direct.absolute())
119+
print(f" 🐍 Using WINPYDIRBASE (legacy): {resolved}")
120+
return resolved
121+
print(f" ⚠️ WINPYDIRBASE set but no Python found in: {winpy_base}")
122+
123+
# 3. Explicit VENV_DIR variable (e.g. for multiple local venvs)
124+
venv_dir_env = os.environ.get("VENV_DIR")
125+
if venv_dir_env:
126+
venv_dir = Path(venv_dir_env)
127+
if not venv_dir.is_absolute():
128+
venv_dir = project_root / venv_dir
129+
venv_python = _get_venv_python(venv_dir)
130+
if venv_python:
131+
print(f" 🐍 Using VENV_DIR from .env: {venv_python}")
132+
return venv_python
133+
print(f" ⚠️ VENV_DIR set but no Python found in: {venv_dir}")
134+
135+
# 4. Auto-discover local venv
136+
venv_python = _find_venv_python(project_root)
137+
if venv_python:
138+
print(f" 🐍 Using venv Python: {venv_python}")
139+
return venv_python
140+
141+
# 5. Fallback
142+
print(f" 🐍 Using caller Python: {sys.executable}")
143+
return sys.executable
144+
145+
13146
def load_env_file(env_path: str | None = None) -> None:
14147
"""Load environment variables from a .env file."""
15-
# Set a flag to indicate that the environment has been loaded by this script
16-
# This prevents batch scripts (like utils.bat) from reloading .env and overwriting variables
17-
18148
if env_path is None:
19-
# Get ".env" file from the current directory
20149
env_path = Path.cwd() / ".env"
21150
if not Path(env_path).is_file():
22151
raise FileNotFoundError(f"Environment file not found: {env_path}")
@@ -27,40 +156,38 @@ def load_env_file(env_path: str | None = None) -> None:
27156
if not line or line.startswith("#") or "=" not in line:
28157
continue
29158
key, value = line.split("=", 1)
30-
value = os.path.expandvars(value.strip())
31-
32-
# Handle PATH variable specifically:
33-
# 1. Convert relative paths to absolute paths
34-
# 2. Normalize path separators
35-
if key.strip().upper() == "PATH":
36-
paths = value.split(os.pathsep)
37-
abs_paths = []
38-
for p in paths:
39-
p = p.strip()
40-
if not p:
41-
continue
42-
# Check if it looks like a relative path component
43-
# (not starting with drive or root)
44-
# Note: This simple check assumes standard usage in .env
45-
if not os.path.isabs(p) and not p.startswith("%"):
46-
try:
47-
# Resolve relative to .env file directory
48-
p = str((Path(env_path).parent / p).resolve())
49-
except Exception:
50-
pass # Keep as is if resolution fails
51-
abs_paths.append(os.path.normpath(p))
52-
value = os.pathsep.join(abs_paths)
53-
54-
os.environ[key.strip()] = value
55-
print(f" Loaded variable: {key.strip()}={value}")
56-
57-
58-
def execute_command(command: list[str]) -> int:
59-
"""Execute a command with the loaded environment variables."""
159+
os.environ[key.strip()] = value.strip()
160+
print(f" Loaded variable: {key.strip()}={value.strip()}")
161+
162+
163+
def execute_command(command: list[str], python_exe: str) -> int:
164+
"""Execute a command, replacing ``python`` placeholders.
165+
166+
Any argument that is the bare word ``python`` or that points to a Python
167+
executable (checked via filename) is replaced by *python_exe* so that the
168+
subprocess uses the resolved interpreter rather than the global one.
169+
170+
Args:
171+
command: The command and its arguments.
172+
python_exe: The resolved Python interpreter path.
173+
174+
Returns:
175+
The subprocess exit code.
176+
"""
177+
resolved: list[str] = []
178+
for arg in command:
179+
if arg.lower() == "python" or (
180+
Path(arg).name.lower().startswith("python")
181+
and Path(arg).is_file()
182+
and arg.lower() != python_exe.lower()
183+
):
184+
resolved.append(python_exe)
185+
else:
186+
resolved.append(arg)
60187
print("Executing command:")
61-
print(" ".join(command))
188+
print(" ".join(resolved))
62189
print("")
63-
result = subprocess.call(command)
190+
result = subprocess.call(resolved)
64191
print(f"Process exited with code {result}")
65192
return result
66193

@@ -71,8 +198,10 @@ def main() -> None:
71198
print("Usage: python run_with_env.py <command> [args ...]")
72199
sys.exit(1)
73200
print("🏃 Running with environment variables")
201+
project_root = Path.cwd()
74202
load_env_file()
75-
return execute_command(sys.argv[1:])
203+
python_exe = resolve_python(project_root)
204+
return execute_command(sys.argv[1:], python_exe)
76205

77206

78207
if __name__ == "__main__":

0 commit comments

Comments
 (0)