diff --git a/docs/tutorial.rst b/docs/tutorial.rst index bb4e20e3..3a094dd3 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -597,8 +597,8 @@ the tags, so all three sessions: * flake8 -Running without the nox command -------------------------------- +Running without the nox command or adding dependencies +------------------------------------------------------ With a few small additions to your noxfile, you can support running using only a generalized Python runner, such as ``pipx run noxfile.py``, ``uv run @@ -618,6 +618,13 @@ And the following block of code: if __name__ == "__main__": nox.main() +If this comment block is present, nox will also read it, and run a custom +environment (``_nox_script_mode``) if the dependencies are not met in the +current environment. This allows you to specify dependencies for your noxfile +or a minimum version of nox here (``requires-python`` version setting not +supported yet, but planned). You can control this with +``--script-mode``/``NOX_SCRIPT_MODE``; ``none`` will deactivate it, and +``fresh`` will rebuild it; the default is ``reuse``. Next steps ---------- diff --git a/nox/_cli.py b/nox/_cli.py index f25dad2a..0e4196c0 100644 --- a/nox/_cli.py +++ b/nox/_cli.py @@ -16,12 +16,24 @@ from __future__ import annotations +import importlib.metadata +import os +import shutil +import subprocess import sys -from typing import Any +from collections.abc import Generator +from pathlib import Path +from typing import Any, NoReturn +import packaging.requirements +import packaging.utils + +import nox.command +import nox.virtualenv from nox import _options, tasks, workflow from nox._version import get_nox_version from nox.logger import setup_logging +from nox.project import load_toml def execute_workflow(args: Any) -> int: @@ -45,6 +57,82 @@ def execute_workflow(args: Any) -> int: ) +def get_dependencies( + req: packaging.requirements.Requirement, +) -> Generator[packaging.requirements.Requirement, None, None]: + """ + Gets all dependencies. Raises ModuleNotFoundError if a package is not installed. + """ + info = importlib.metadata.metadata(req.name) + yield req + + dist_list = info.get_all("requires-dist") or [] + extra_list = [packaging.requirements.Requirement(mk) for mk in dist_list] + for extra in req.extras: + for ireq in extra_list: + if ireq.marker and not ireq.marker.evaluate({"extra": extra}): + continue + yield from get_dependencies(ireq) + + +def check_dependencies(dependencies: list[str]) -> bool: + """ + Checks to see if a list of dependencies is currently installed. + """ + itr_deps = (packaging.requirements.Requirement(d) for d in dependencies) + deps = [d for d in itr_deps if not d.marker or d.marker.evaluate()] + + # Select the one nox dependency (required) + nox_dep = [d for d in deps if packaging.utils.canonicalize_name(d.name) == "nox"] + if not nox_dep: + msg = "Must have a nox dependency in TOML script dependencies" + raise ValueError(msg) + + try: + expanded_deps = {d for req in deps for d in get_dependencies(req)} + except ModuleNotFoundError: + return False + + for dep in expanded_deps: + if dep.specifier: + version = importlib.metadata.version(dep.name) + if not dep.specifier.contains(version): + return False + + return True + + +def run_script_mode(envdir: Path, reuse: bool, dependencies: list[str]) -> NoReturn: + envdir.mkdir(exist_ok=True) + noxenv = envdir.joinpath("_nox_script_mode") + venv = nox.virtualenv.get_virtualenv( + "uv", + "virtualenv", + reuse_existing=reuse, + envdir=str(noxenv), + ) + venv.create() + venv.env["NOX_SCRIPT_MODE"] = "none" + cmd = ["uv", "pip", "install"] if venv.venv_backend == "uv" else ["pip", "install"] + subprocess.run([*cmd, *dependencies], env=venv.env, check=True) + nox_cmd = shutil.which("nox", path=venv.env["PATH"]) + assert nox_cmd is not None, "Nox must be discoverable when installed" + # The os.exec functions don't work properly on Windows + if sys.platform.startswith("win"): + raise SystemExit( + subprocess.run( + [nox_cmd, *sys.argv[1:]], + env=venv.env, + stdout=None, + stderr=None, + encoding="utf-8", + text=True, + check=False, + ).returncode + ) + os.execle(nox_cmd, nox_cmd, *sys.argv[1:], venv.env) # pragma: nocover + + def main() -> None: args = _options.options.parse_args() @@ -59,6 +147,19 @@ def main() -> None: setup_logging( color=args.color, verbose=args.verbose, add_timestamp=args.add_timestamp ) + nox_script_mode = os.environ.get("NOX_SCRIPT_MODE", "") or args.script_mode + if nox_script_mode not in {"none", "reuse", "fresh"}: + msg = f"Invalid NOX_SCRIPT_MODE: {nox_script_mode!r}, must be one of 'none', 'reuse', or 'fresh'" + raise SystemExit(msg) + if nox_script_mode != "none": + toml_config = load_toml(os.path.expandvars(args.noxfile), missing_ok=True) + dependencies = toml_config.get("dependencies") + if dependencies is not None: + valid_env = check_dependencies(dependencies) + # Coverage misses this, but it's covered via subprocess call + if not valid_env: # pragma: nocover + envdir = Path(args.envdir or ".nox") + run_script_mode(envdir, nox_script_mode == "reuse", dependencies) exit_code = execute_workflow(args) diff --git a/nox/_options.py b/nox/_options.py index 2dc26f21..90e2e151 100644 --- a/nox/_options.py +++ b/nox/_options.py @@ -321,6 +321,13 @@ def _tag_completer( action="store_true", help="Show the Nox version and exit.", ), + _option_set.Option( + "script_mode", + "--script-mode", + group=options.groups["general"], + choices=["none", "fresh", "reuse"], + default="reuse", + ), _option_set.Option( "list_sessions", "-l", diff --git a/nox/project.py b/nox/project.py index 61c63152..3acbb65c 100644 --- a/nox/project.py +++ b/nox/project.py @@ -34,7 +34,9 @@ def __dir__() -> list[str]: ) -def load_toml(filename: os.PathLike[str] | str) -> dict[str, Any]: +def load_toml( + filename: os.PathLike[str] | str, *, missing_ok: bool = False +) -> dict[str, Any]: """ Load a toml file or a script with a PEP 723 script block. @@ -42,6 +44,9 @@ def load_toml(filename: os.PathLike[str] | str) -> dict[str, Any]: ``.py`` extension / no extension to be considered a script. Other file extensions are not valid in this function. + If ``missing_ok``, this will return an empty dict if a script block was not + found, otherwise it will raise a error. + Example: .. code-block:: python @@ -55,7 +60,7 @@ def myscript(session): if filepath.suffix == ".toml": return _load_toml_file(filepath) if filepath.suffix in {".py", ""}: - return _load_script_block(filepath) + return _load_script_block(filepath, missing_ok=missing_ok) msg = f"Extension must be .py or .toml, got {filepath.suffix}" raise ValueError(msg) @@ -65,12 +70,14 @@ def _load_toml_file(filepath: Path) -> dict[str, Any]: return tomllib.load(f) -def _load_script_block(filepath: Path) -> dict[str, Any]: +def _load_script_block(filepath: Path, *, missing_ok: bool) -> dict[str, Any]: name = "script" script = filepath.read_text(encoding="utf-8") matches = list(filter(lambda m: m.group("type") == name, REGEX.finditer(script))) if not matches: + if missing_ok: + return {} raise ValueError(f"No {name} block found in {filepath}") if len(matches) > 1: raise ValueError(f"Multiple {name} blocks found in {filepath}") diff --git a/nox/virtualenv.py b/nox/virtualenv.py index 8b9525c6..eb5b085a 100644 --- a/nox/virtualenv.py +++ b/nox/virtualenv.py @@ -306,7 +306,7 @@ def _clean_location(self) -> bool: if self.reuse_existing and is_conda: return False if not is_conda: - shutil.rmtree(self.location) + shutil.rmtree(self.location, ignore_errors=True) else: cmd = [ self.conda_cmd, @@ -318,8 +318,7 @@ def _clean_location(self) -> bool: ] nox.command.run(cmd, silent=True, log=False) # Make sure that location is clean - with contextlib.suppress(FileNotFoundError): - shutil.rmtree(self.location) + shutil.rmtree(self.location, ignore_errors=True) return True @@ -446,7 +445,7 @@ def _clean_location(self) -> bool: and self._check_reused_environment_interpreter() ): return False - shutil.rmtree(self.location) + shutil.rmtree(self.location, ignore_errors=True) return True def _read_pyvenv_cfg(self) -> dict[str, str] | None: diff --git a/tests/resources/noxfile_script_mode.py b/tests/resources/noxfile_script_mode.py new file mode 100644 index 00000000..d95d0bb2 --- /dev/null +++ b/tests/resources/noxfile_script_mode.py @@ -0,0 +1,12 @@ +# /// script +# dependencies = ["nox", "cowsay"] +# /// + +import cowsay + +import nox + + +@nox.session +def example(session: nox.Session) -> None: + print(cowsay.cow("hello_world")) diff --git a/tests/test__cli.py b/tests/test__cli.py new file mode 100644 index 00000000..21b618c8 --- /dev/null +++ b/tests/test__cli.py @@ -0,0 +1,68 @@ +import importlib.metadata +import importlib.util +import sys + +import packaging.requirements +import packaging.version +import pytest + +import nox._cli + + +def test_get_dependencies(): + if importlib.util.find_spec("tox") is None: + with pytest.raises(ModuleNotFoundError): + list( + nox._cli.get_dependencies( + packaging.requirements.Requirement("nox[tox_to_nox]") + ) + ) + else: + deps = nox._cli.get_dependencies( + packaging.requirements.Requirement("nox[tox_to_nox]") + ) + dep_list = { + "nox", + "argcomplete", + "colorlog", + "dependency-groups", + "packaging", + "virtualenv", + "jinja2", + "tox", + } + if sys.version_info < (3, 11): + dep_list.add("tomli") + assert {d.name for d in deps} == dep_list + + +def test_version_check(): + current_version = packaging.version.Version(importlib.metadata.version("nox")) + + assert nox._cli.check_dependencies([f"nox>={current_version}"]) + assert not nox._cli.check_dependencies([f"nox>{current_version}"]) + + plus_one = packaging.version.Version( + f"{current_version.major}.{current_version.minor}.{current_version.micro+1}" + ) + assert not nox._cli.check_dependencies([f"nox>={plus_one}"]) + + +def test_nox_check(): + with pytest.raises(ValueError, match="Must have a nox"): + nox._cli.check_dependencies(["packaging"]) + + with pytest.raises(ValueError, match="Must have a nox"): + nox._cli.check_dependencies([]) + + +def test_unmatched_specifier(): + assert not nox._cli.check_dependencies(["packaging<1", "nox"]) + + +def test_invalid_mode(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("NOX_SCRIPT_MODE", "invalid") + monkeypatch.setattr(sys, "argv", ["nox"]) + + with pytest.raises(SystemExit, match="Invalid NOX_SCRIPT_MODE"): + nox._cli.main() diff --git a/tests/test_main.py b/tests/test_main.py index 0cb737a8..faada025 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -16,6 +16,7 @@ import contextlib import os +import subprocess import sys from importlib import metadata from pathlib import Path @@ -928,3 +929,46 @@ def test_noxfile_options_cant_be_set(): def test_noxfile_options_cant_be_set_long(): with pytest.raises(AttributeError, match="i_am_clearly_not_an_option"): nox.options.i_am_clearly_not_an_option = True + + +def test_noxfile_script_mode(): + job = subprocess.run( + [ + sys.executable, + "-m", + "nox", + "-f", + Path(RESOURCES) / "noxfile_script_mode.py", + "-s", + "example", + ], + check=False, + capture_output=True, + text=True, + ) + print(job.stdout) + print(job.stderr) + assert job.returncode == 0 + assert "hello_world" in job.stdout + + +def test_noxfile_no_script_mode(): + env = os.environ.copy() + env["NOX_SCRIPT_MODE"] = "none" + job = subprocess.run( + [ + sys.executable, + "-m", + "nox", + "-f", + Path(RESOURCES) / "noxfile_script_mode.py", + "-s", + "example", + ], + env=env, + check=False, + capture_output=True, + text=True, + ) + assert job.returncode == 1 + assert "No module named 'cowsay'" in job.stderr