|
16 | 16 | import os |
17 | 17 | import re |
18 | 18 | import semantic_version |
| 19 | +import shutil |
19 | 20 | import site |
20 | 21 | import socket |
21 | 22 | import subprocess |
@@ -126,14 +127,123 @@ def get_executable_path(penv_dir, executable_name): |
126 | 127 | return str(Path(penv_dir) / scripts_dir / f"{executable_name}{exe_suffix}") |
127 | 128 |
|
128 | 129 |
|
| 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 | + |
129 | 226 | def setup_pipenv_in_package(env, penv_dir): |
130 | 227 | """ |
131 | 228 | 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. |
132 | 230 | First tries to create with uv, falls back to python -m venv if uv is not available. |
133 | 231 | |
134 | 232 | Returns: |
135 | 233 | str or None: Path to uv executable if uv was used, None if python -m venv was used |
136 | 234 | """ |
| 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 | + |
137 | 247 | if not os.path.isfile(get_executable_path(penv_dir, "python")): |
138 | 248 | # Attempt virtual environment creation using uv package manager |
139 | 249 | uv_success = False |
@@ -187,16 +297,38 @@ def setup_pipenv_in_package(env, penv_dir): |
187 | 297 |
|
188 | 298 |
|
189 | 299 | 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) |
200 | 332 |
|
201 | 333 |
|
202 | 334 | def get_packages_to_install(deps, installed_packages): |
@@ -555,13 +687,26 @@ def _setup_python_environment_core(env, platform, platformio_dir, should_install |
555 | 687 | def _setup_pipenv_minimal(penv_dir): |
556 | 688 | """ |
557 | 689 | Setup virtual environment without SCons dependencies. |
| 690 | + Recreates the penv if the Python version does not match the running interpreter. |
558 | 691 | |
559 | 692 | Args: |
560 | 693 | penv_dir (str): Path to virtual environment directory |
561 | 694 | |
562 | 695 | Returns: |
563 | 696 | str or None: Path to uv executable if uv was used, None if python -m venv was used |
564 | 697 | """ |
| 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 | + |
565 | 710 | if not os.path.isfile(get_executable_path(penv_dir, "python")): |
566 | 711 | # Attempt virtual environment creation using uv package manager |
567 | 712 | uv_success = False |
|
0 commit comments