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
517from __future__ import annotations
618
19+ import glob
720import os
821import subprocess
922import sys
1023from 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+
13146def 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
78207if __name__ == "__main__" :
0 commit comments