Skip to content

Commit e920f89

Browse files
authored
Recreate penv when Python version changed
1 parent f277f27 commit e920f89

File tree

1 file changed

+155
-10
lines changed

1 file changed

+155
-10
lines changed

builder/penv_setup.py

Lines changed: 155 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import os
1717
import re
1818
import semantic_version
19+
import shutil
1920
import site
2021
import socket
2122
import subprocess
@@ -126,14 +127,123 @@ def get_executable_path(penv_dir, executable_name):
126127
return str(Path(penv_dir) / scripts_dir / f"{executable_name}{exe_suffix}")
127128

128129

130+
def _get_penv_python_version(penv_dir):
131+
"""
132+
Detect the Python version used to create an existing penv.
133+
134+
Reads the ``version`` key from ``pyvenv.cfg`` which is always
135+
written by both ``python -m venv`` and ``uv venv``. This avoids
136+
spawning a subprocess (which can fail when the penv Python is
137+
corrupted) and works identically on all platforms.
138+
139+
Falls back to inspecting ``lib/pythonX.Y/`` directories on POSIX
140+
if ``pyvenv.cfg`` is missing or unparseable.
141+
142+
Returns:
143+
tuple[int, int] | None: (major, minor) of the penv Python, or
144+
None if the penv does not exist or its version cannot be
145+
determined.
146+
"""
147+
penv_path = Path(penv_dir)
148+
149+
# Primary: parse pyvenv.cfg (cross-platform, no subprocess)
150+
cfg_file = penv_path / "pyvenv.cfg"
151+
if cfg_file.is_file():
152+
try:
153+
for line in cfg_file.read_text(encoding="utf-8").splitlines():
154+
key, _, value = line.partition("=")
155+
if key.strip().lower() == "version":
156+
parts = value.strip().split(".")
157+
if len(parts) >= 2:
158+
return (int(parts[0]), int(parts[1]))
159+
except Exception:
160+
pass
161+
162+
# Fallback (POSIX only): inspect lib/pythonX.Y/ directories
163+
if not IS_WINDOWS:
164+
lib_dir = penv_path / "lib"
165+
if lib_dir.is_dir():
166+
for entry in sorted(lib_dir.iterdir(), reverse=True):
167+
if entry.is_dir() and entry.name.startswith("python") and (entry / "site-packages").is_dir():
168+
ver_str = entry.name[len("python"):]
169+
try:
170+
major, minor = ver_str.split(".")
171+
return (int(major), int(minor))
172+
except (ValueError, TypeError):
173+
continue
174+
175+
return None
176+
177+
178+
def _penv_version_matches(penv_dir):
179+
"""
180+
Check whether the existing penv was created with the same Python
181+
major.minor version as the currently running interpreter.
182+
183+
Returns True if versions match or if the penv does not exist yet.
184+
"""
185+
penv_ver = _get_penv_python_version(penv_dir)
186+
if penv_ver is None:
187+
return True # no penv yet — nothing to mismatch
188+
return penv_ver == (sys.version_info.major, sys.version_info.minor)
189+
190+
191+
def _get_penv_site_packages(penv_dir):
192+
"""
193+
Locate the actual site-packages directory inside a penv.
194+
195+
Instead of constructing the path from ``sys.version_info`` (which
196+
reflects the *host* interpreter and may differ from the penv's
197+
Python version), this function inspects the penv's directory
198+
structure and returns the first valid site-packages path found.
199+
200+
Returns:
201+
str | None: Absolute path to the site-packages directory, or
202+
None if it cannot be found.
203+
"""
204+
penv_path = Path(penv_dir)
205+
206+
# Windows: Lib/site-packages (no version directory)
207+
if IS_WINDOWS:
208+
sp = penv_path / "Lib" / "site-packages"
209+
if sp.is_dir():
210+
return str(sp)
211+
return None
212+
213+
# POSIX: lib/pythonX.Y/site-packages
214+
lib_dir = penv_path / "lib"
215+
if not lib_dir.is_dir():
216+
return None
217+
# Prefer the newest python version directory
218+
for entry in sorted(lib_dir.iterdir(), key=lambda e: tuple(int(x) for x in e.name[6:].split('.') if x.isdigit()), reverse=True):
219+
if entry.is_dir() and entry.name.startswith("python"):
220+
sp = entry / "site-packages"
221+
if sp.is_dir():
222+
return str(sp)
223+
return None
224+
225+
129226
def setup_pipenv_in_package(env, penv_dir):
130227
"""
131228
Checks if 'penv' folder exists in platformio dir and creates virtual environment if not.
229+
Recreates the penv if the Python version does not match the running interpreter.
132230
First tries to create with uv, falls back to python -m venv if uv is not available.
133231
134232
Returns:
135233
str or None: Path to uv executable if uv was used, None if python -m venv was used
136234
"""
235+
# Recreate penv when Python version changed (e.g. Homebrew upgraded 3.13→3.14)
236+
penv_python_path = get_executable_path(penv_dir, "python")
237+
if os.path.isfile(penv_python_path) and not _penv_version_matches(penv_dir):
238+
penv_ver = _get_penv_python_version(penv_dir)
239+
current_ver = (sys.version_info.major, sys.version_info.minor)
240+
print(
241+
f"Python version mismatch: penv has {penv_ver[0]}.{penv_ver[1]}, "
242+
f"current interpreter is {current_ver[0]}.{current_ver[1]}. "
243+
f"Recreating penv..."
244+
)
245+
shutil.rmtree(penv_dir, ignore_errors=True)
246+
137247
if not os.path.isfile(get_executable_path(penv_dir, "python")):
138248
# Attempt virtual environment creation using uv package manager
139249
uv_success = False
@@ -187,16 +297,38 @@ def setup_pipenv_in_package(env, penv_dir):
187297

188298

189299
def setup_python_paths(penv_dir):
190-
"""Setup Python module search paths using the penv_dir."""
191-
# Add site-packages directory
192-
python_ver = f"python{sys.version_info.major}.{sys.version_info.minor}"
193-
site_packages = (
194-
str(Path(penv_dir) / "Lib" / "site-packages") if IS_WINDOWS
195-
else str(Path(penv_dir) / "lib" / python_ver / "site-packages")
196-
)
197-
198-
if os.path.isdir(site_packages):
199-
site.addsitedir(site_packages)
300+
"""Setup Python module search paths using the penv_dir.
301+
302+
Dynamically locates the penv's site-packages directory instead of
303+
deriving it from ``sys.version_info``, which reflects the *host*
304+
interpreter and may differ from the Python version used to create
305+
the penv. The penv's site-packages is inserted at the front of
306+
``sys.path`` and conflicting system site-packages entries are
307+
removed so that packages installed in the penv always take
308+
precedence.
309+
"""
310+
site_packages = _get_penv_site_packages(penv_dir)
311+
if not site_packages:
312+
return
313+
314+
penv_dir_resolved = os.path.realpath(penv_dir) + os.sep
315+
316+
# Remove system site-packages entries that are not part of the penv
317+
sys.path[:] = [
318+
p for p in sys.path
319+
if "site-packages" not in p.lower()
320+
or os.path.realpath(p).startswith(penv_dir_resolved)
321+
]
322+
323+
# Add penv site-packages at the beginning
324+
if site_packages not in sys.path:
325+
sys.path.insert(0, site_packages)
326+
327+
site.addsitedir(site_packages)
328+
# Re-ensure penv is still first after addsitedir may have appended it
329+
if sys.path[0] != site_packages:
330+
sys.path.remove(site_packages)
331+
sys.path.insert(0, site_packages)
200332

201333

202334
def get_packages_to_install(deps, installed_packages):
@@ -555,13 +687,26 @@ def _setup_python_environment_core(env, platform, platformio_dir, should_install
555687
def _setup_pipenv_minimal(penv_dir):
556688
"""
557689
Setup virtual environment without SCons dependencies.
690+
Recreates the penv if the Python version does not match the running interpreter.
558691
559692
Args:
560693
penv_dir (str): Path to virtual environment directory
561694
562695
Returns:
563696
str or None: Path to uv executable if uv was used, None if python -m venv was used
564697
"""
698+
# Recreate penv when Python version changed (e.g. Homebrew upgraded 3.13→3.14)
699+
penv_python_path = get_executable_path(penv_dir, "python")
700+
if os.path.isfile(penv_python_path) and not _penv_version_matches(penv_dir):
701+
penv_ver = _get_penv_python_version(penv_dir)
702+
current_ver = (sys.version_info.major, sys.version_info.minor)
703+
print(
704+
f"Python version mismatch: penv has {penv_ver[0]}.{penv_ver[1]}, "
705+
f"current interpreter is {current_ver[0]}.{current_ver[1]}. "
706+
f"Recreating penv..."
707+
)
708+
shutil.rmtree(penv_dir, ignore_errors=True)
709+
565710
if not os.path.isfile(get_executable_path(penv_dir, "python")):
566711
# Attempt virtual environment creation using uv package manager
567712
uv_success = False

0 commit comments

Comments
 (0)