Skip to content

Commit d59e1ac

Browse files
henryiiicjolowicz
andauthored
fix: rebuild env if making an incompatible change (#781)
* fix: rebuild env if making an incompatible change Signed-off-by: Henry Schreiner <[email protected]> * tests: restore windows skip * fix: support conda switching properly Signed-off-by: Henry Schreiner <[email protected]> * fix: make stale Python check opt-in again Signed-off-by: Henry Schreiner <[email protected]> * tests: increase coverage of check again Signed-off-by: Henry Schreiner <[email protected]> * refactor: unify function Signed-off-by: Henry Schreiner <[email protected]> * Update tests/test_virtualenv.py Co-authored-by: Claudio Jolowicz <[email protected]> --------- Signed-off-by: Henry Schreiner <[email protected]> Co-authored-by: Claudio Jolowicz <[email protected]>
1 parent ff259ce commit d59e1ac

2 files changed

Lines changed: 181 additions & 71 deletions

File tree

nox/virtualenv.py

Lines changed: 66 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
["PIP_RESPECT_VIRTUALENV", "PIP_REQUIRE_VIRTUALENV", "__PYVENV_LAUNCHER__"]
3636
)
3737
_SYSTEM = platform.system()
38-
_ENABLE_STALENESS_CHECK = "NOX_ENABLE_STALENESS_CHECK" in os.environ
3938

4039

4140
class InterpreterNotFound(OSError):
@@ -214,9 +213,12 @@ def __init__(
214213

215214
def _clean_location(self) -> bool:
216215
"""Deletes existing conda environment"""
216+
is_conda = os.path.isdir(os.path.join(self.location, "conda-meta"))
217217
if os.path.exists(self.location):
218-
if self.reuse_existing:
218+
if self.reuse_existing and is_conda:
219219
return False
220+
if not is_conda:
221+
shutil.rmtree(self.location)
220222
else:
221223
cmd = [
222224
self.conda_cmd,
@@ -227,9 +229,9 @@ def _clean_location(self) -> bool:
227229
"--all",
228230
]
229231
nox.command.run(cmd, silent=True, log=False)
230-
# Make sure that location is clean
231-
with contextlib.suppress(FileNotFoundError):
232-
shutil.rmtree(self.location)
232+
# Make sure that location is clean
233+
with contextlib.suppress(FileNotFoundError):
234+
shutil.rmtree(self.location)
233235

234236
return True
235237

@@ -330,45 +332,78 @@ def __init__(
330332
self.reuse_existing = reuse_existing
331333
self.venv_backend = venv_backend
332334
self.venv_params = venv_params or []
335+
if venv_backend not in {"virtualenv", "venv", "uv"}:
336+
msg = f"venv_backend {venv_backend} not recognized"
337+
raise ValueError(msg)
333338
super().__init__(env={"VIRTUAL_ENV": self.location})
334339

335340
def _clean_location(self) -> bool:
336341
"""Deletes any existing virtual environment"""
337342
if os.path.exists(self.location):
338-
if self.reuse_existing and not _ENABLE_STALENESS_CHECK:
339-
return False
340343
if (
341344
self.reuse_existing
342345
and self._check_reused_environment_type()
343346
and self._check_reused_environment_interpreter()
344347
):
345348
return False
346-
else:
347-
shutil.rmtree(self.location)
348-
349+
shutil.rmtree(self.location)
349350
return True
350351

352+
def _read_pyvenv_cfg(self) -> dict[str, str] | None:
353+
"""Read a pyvenv.cfg file into dict, returns None if missing."""
354+
path = os.path.join(self.location, "pyvenv.cfg")
355+
with contextlib.suppress(FileNotFoundError), open(path) as fp:
356+
parts = (x.partition("=") for x in fp if "=" in x)
357+
return {k.strip(): v.strip() for k, _, v in parts}
358+
return None
359+
351360
def _check_reused_environment_type(self) -> bool:
352-
"""Check if reused environment type is the same."""
353-
try:
354-
with open(os.path.join(self.location, "pyvenv.cfg")) as fp:
355-
parts = (x.partition("=") for x in fp if "=" in x)
356-
config = {k.strip(): v.strip() for k, _, v in parts}
357-
if "uv" in config or "gourgeist" in config:
358-
old_env = "uv"
359-
elif "virtualenv" in config:
360-
old_env = "virtualenv"
361-
else:
362-
old_env = "venv"
363-
except FileNotFoundError: # pragma: no cover
364-
# virtualenv < 20.0 does not create pyvenv.cfg
361+
"""Check if reused environment type is the same or equivalent."""
362+
363+
config = self._read_pyvenv_cfg()
364+
# virtualenv < 20.0 does not create pyvenv.cfg
365+
if config is None:
365366
old_env = "virtualenv"
367+
elif "uv" in config or "gourgeist" in config:
368+
old_env = "uv"
369+
elif "virtualenv" in config:
370+
old_env = "virtualenv"
371+
else:
372+
old_env = "venv"
373+
374+
# Can't detect mamba separately, but shouldn't matter
375+
if os.path.isdir(os.path.join(self.location, "conda-meta")):
376+
return False
366377

367-
return old_env == self.venv_backend
378+
# Matching is always true
379+
if old_env == self.venv_backend:
380+
return True
381+
382+
# venv family with pip installed
383+
if {old_env, self.venv_backend} <= {"virtualenv", "venv"}:
384+
return True
385+
386+
# Switching to "uv" is safe, but not the other direction (no pip)
387+
if old_env in {"virtualenv", "venv"} and self.venv_backend == "uv":
388+
return True
389+
390+
return False
368391

369392
def _check_reused_environment_interpreter(self) -> bool:
370-
"""Check if reused environment interpreter is the same."""
371-
original = self._read_base_prefix_from_pyvenv_cfg()
393+
"""
394+
Check if reused environment interpreter is the same. Currently only checks if
395+
NOX_ENABLE_STALENESS_CHECK is set in the environment. See
396+
397+
* https://github.com/wntrblm/nox/issues/449#issuecomment-860030890
398+
* https://github.com/wntrblm/nox/issues/441
399+
* https://github.com/pypa/virtualenv/issues/2130
400+
"""
401+
if not os.environ.get("NOX_ENABLE_STALENESS_CHECK", ""):
402+
return True
403+
404+
config = self._read_pyvenv_cfg() or {}
405+
original = config.get("base-prefix", None)
406+
372407
program = (
373408
"import sys; sys.stdout.write(getattr(sys, 'real_prefix', sys.base_prefix))"
374409
)
@@ -384,18 +419,11 @@ def _check_reused_environment_interpreter(self) -> bool:
384419
["python", "-c", program], silent=True, log=False, paths=self.bin_paths
385420
)
386421

387-
return original == created
388-
389-
def _read_base_prefix_from_pyvenv_cfg(self) -> str | None:
390-
"""Return the base-prefix entry from pyvenv.cfg, if present."""
391-
path = os.path.join(self.location, "pyvenv.cfg")
392-
if os.path.isfile(path):
393-
with open(path) as io:
394-
for line in io:
395-
key, _, value = line.partition("=")
396-
if key.strip() == "base-prefix":
397-
return value.strip()
398-
return None
422+
return (
423+
os.path.exists(original)
424+
and os.path.exists(created)
425+
and os.path.samefile(original, created)
426+
)
399427

400428
@property
401429
def _resolved_interpreter(self) -> str:

0 commit comments

Comments
 (0)