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..987f17ae 100644 --- a/nox/_cli.py +++ b/nox/_cli.py @@ -16,12 +16,23 @@ from __future__ import annotations +import importlib.metadata +import os +import shutil +import subprocess import sys -from typing import Any +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 +56,80 @@ def execute_workflow(args: Any) -> int: ) +def get_dependencies(name: str, extras: set[str]) -> list[str]: + """ + Gets all dependencies. Raises ModuleNotFoundError if a package is not installed. + """ + + info = importlib.metadata.metadata(name) + pkglist = [name] + dist_list = info.get_all("requires-dist") or [] + extra_list = [packaging.requirements.Requirement(mk) for mk in dist_list] + for extra in extras: + extra_pkgs = [ + get_dependencies(req.name, req.extras) + for req in extra_list + if req.marker is None or req.marker.evaluate({"extra": extra}) + ] + pkglist.extend(p for plist in extra_pkgs for p in plist) + return pkglist + + +def check_dependencies(dependencies: list[str]) -> bool: + """ + Checks to see if a list of dependencies is currently installed. + """ + deps = [packaging.requirements.Requirement(d) for d in dependencies] + + # Select the one nox dependency (required) + nox_dep = [ + d + for d in deps + if packaging.utils.canonicalize_name(d.name) == "nox" + and (d.marker is None or d.marker.evaluate()) + ] + if not nox_dep: + msg = "Must have a nox dependency in TOML script dependencies" + raise ValueError(msg) + + try: + for req in deps: + get_dependencies(req.name, req.extras) + except ModuleNotFoundError: + 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"]) + # 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.execlpe("nox", *sys.argv, venv.env) + + def main() -> None: args = _options.options.parse_args() @@ -59,6 +144,18 @@ 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) + if not valid_env: + 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_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