Skip to content

Commit 07e8cef

Browse files
new .py script to run task within multiple python env context (winpython, venv, venv*,...)
1 parent cfc2ddb commit 07e8cef

2 files changed

Lines changed: 218 additions & 1 deletion

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: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2+
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+
"""
16+
17+
from __future__ import annotations
18+
19+
import glob
20+
import os
21+
import subprocess
22+
import sys
23+
from pathlib import Path
24+
25+
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+
146+
def load_env_file(env_path: str | None = None) -> None:
147+
"""Load environment variables from a .env file."""
148+
if env_path is None:
149+
env_path = Path.cwd() / ".env"
150+
if not Path(env_path).is_file():
151+
raise FileNotFoundError(f"Environment file not found: {env_path}")
152+
print(f"Loading environment variables from: {env_path}")
153+
with open(env_path, encoding="utf-8") as f:
154+
for line in f:
155+
line = line.strip()
156+
if not line or line.startswith("#") or "=" not in line:
157+
continue
158+
key, value = line.split("=", 1)
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)
187+
print("Executing command:")
188+
print(" ".join(resolved))
189+
print("")
190+
result = subprocess.call(resolved)
191+
print(f"Process exited with code {result}")
192+
return result
193+
194+
195+
def main() -> None:
196+
"""Main function to load environment variables and execute a command."""
197+
if len(sys.argv) < 2:
198+
print("Usage: python run_with_env.py <command> [args ...]")
199+
sys.exit(1)
200+
print("🏃 Running with environment variables")
201+
project_root = Path.cwd()
202+
load_env_file()
203+
python_exe = resolve_python(project_root)
204+
return execute_command(sys.argv[1:], python_exe)
205+
206+
207+
if __name__ == "__main__":
208+
main()

0 commit comments

Comments
 (0)