Skip to content

Commit

Permalink
fix: rebuild env if making an incompatible change (#781)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
henryiii and cjolowicz authored Feb 28, 2024
1 parent ff259ce commit d59e1ac
Show file tree
Hide file tree
Showing 2 changed files with 181 additions and 71 deletions.
104 changes: 66 additions & 38 deletions nox/virtualenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
["PIP_RESPECT_VIRTUALENV", "PIP_REQUIRE_VIRTUALENV", "__PYVENV_LAUNCHER__"]
)
_SYSTEM = platform.system()
_ENABLE_STALENESS_CHECK = "NOX_ENABLE_STALENESS_CHECK" in os.environ


class InterpreterNotFound(OSError):
Expand Down Expand Up @@ -214,9 +213,12 @@ def __init__(

def _clean_location(self) -> bool:
"""Deletes existing conda environment"""
is_conda = os.path.isdir(os.path.join(self.location, "conda-meta"))
if os.path.exists(self.location):
if self.reuse_existing:
if self.reuse_existing and is_conda:
return False
if not is_conda:
shutil.rmtree(self.location)
else:
cmd = [
self.conda_cmd,
Expand All @@ -227,9 +229,9 @@ def _clean_location(self) -> bool:
"--all",
]
nox.command.run(cmd, silent=True, log=False)
# Make sure that location is clean
with contextlib.suppress(FileNotFoundError):
shutil.rmtree(self.location)
# Make sure that location is clean
with contextlib.suppress(FileNotFoundError):
shutil.rmtree(self.location)

return True

Expand Down Expand Up @@ -330,45 +332,78 @@ def __init__(
self.reuse_existing = reuse_existing
self.venv_backend = venv_backend
self.venv_params = venv_params or []
if venv_backend not in {"virtualenv", "venv", "uv"}:
msg = f"venv_backend {venv_backend} not recognized"
raise ValueError(msg)
super().__init__(env={"VIRTUAL_ENV": self.location})

def _clean_location(self) -> bool:
"""Deletes any existing virtual environment"""
if os.path.exists(self.location):
if self.reuse_existing and not _ENABLE_STALENESS_CHECK:
return False
if (
self.reuse_existing
and self._check_reused_environment_type()
and self._check_reused_environment_interpreter()
):
return False
else:
shutil.rmtree(self.location)

shutil.rmtree(self.location)
return True

def _read_pyvenv_cfg(self) -> dict[str, str] | None:
"""Read a pyvenv.cfg file into dict, returns None if missing."""
path = os.path.join(self.location, "pyvenv.cfg")
with contextlib.suppress(FileNotFoundError), open(path) as fp:
parts = (x.partition("=") for x in fp if "=" in x)
return {k.strip(): v.strip() for k, _, v in parts}
return None

def _check_reused_environment_type(self) -> bool:
"""Check if reused environment type is the same."""
try:
with open(os.path.join(self.location, "pyvenv.cfg")) as fp:
parts = (x.partition("=") for x in fp if "=" in x)
config = {k.strip(): v.strip() for k, _, v in parts}
if "uv" in config or "gourgeist" in config:
old_env = "uv"
elif "virtualenv" in config:
old_env = "virtualenv"
else:
old_env = "venv"
except FileNotFoundError: # pragma: no cover
# virtualenv < 20.0 does not create pyvenv.cfg
"""Check if reused environment type is the same or equivalent."""

config = self._read_pyvenv_cfg()
# virtualenv < 20.0 does not create pyvenv.cfg
if config is None:
old_env = "virtualenv"
elif "uv" in config or "gourgeist" in config:
old_env = "uv"
elif "virtualenv" in config:
old_env = "virtualenv"
else:
old_env = "venv"

# Can't detect mamba separately, but shouldn't matter
if os.path.isdir(os.path.join(self.location, "conda-meta")):
return False

return old_env == self.venv_backend
# Matching is always true
if old_env == self.venv_backend:
return True

# venv family with pip installed
if {old_env, self.venv_backend} <= {"virtualenv", "venv"}:
return True

# Switching to "uv" is safe, but not the other direction (no pip)
if old_env in {"virtualenv", "venv"} and self.venv_backend == "uv":
return True

return False

def _check_reused_environment_interpreter(self) -> bool:
"""Check if reused environment interpreter is the same."""
original = self._read_base_prefix_from_pyvenv_cfg()
"""
Check if reused environment interpreter is the same. Currently only checks if
NOX_ENABLE_STALENESS_CHECK is set in the environment. See
* https://github.com/wntrblm/nox/issues/449#issuecomment-860030890
* https://github.com/wntrblm/nox/issues/441
* https://github.com/pypa/virtualenv/issues/2130
"""
if not os.environ.get("NOX_ENABLE_STALENESS_CHECK", ""):
return True

config = self._read_pyvenv_cfg() or {}
original = config.get("base-prefix", None)

program = (
"import sys; sys.stdout.write(getattr(sys, 'real_prefix', sys.base_prefix))"
)
Expand All @@ -384,18 +419,11 @@ def _check_reused_environment_interpreter(self) -> bool:
["python", "-c", program], silent=True, log=False, paths=self.bin_paths
)

return original == created

def _read_base_prefix_from_pyvenv_cfg(self) -> str | None:
"""Return the base-prefix entry from pyvenv.cfg, if present."""
path = os.path.join(self.location, "pyvenv.cfg")
if os.path.isfile(path):
with open(path) as io:
for line in io:
key, _, value = line.partition("=")
if key.strip() == "base-prefix":
return value.strip()
return None
return (
os.path.exists(original)
and os.path.exists(created)
and os.path.samefile(original, created)
)

@property
def _resolved_interpreter(self) -> str:
Expand Down
Loading

0 comments on commit d59e1ac

Please sign in to comment.