Skip to content

Commit

Permalink
feat: support PEP 723 directly
Browse files Browse the repository at this point in the history
fix: version based on pipx

fix: subprocess on Windows

fix: uv or virtualenv, test

fix: windows

fix: ignore errors in rmtree (3.8+ should be fine on Windows now)

fix: resolve nox path on Windows

chore: update for recent linting/typing additions

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
  • Loading branch information
henryiii committed Dec 13, 2024
1 parent 28bbaa5 commit 28d6334
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 10 deletions.
11 changes: 9 additions & 2 deletions docs/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
----------
Expand Down
107 changes: 106 additions & 1 deletion nox/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,26 @@

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 TYPE_CHECKING, 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

if TYPE_CHECKING:
from collections.abc import Generator

__all__ = ["execute_workflow", "main"]

Expand Down Expand Up @@ -51,6 +65,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 # noqa: S606


def main() -> None:
args = _options.options.parse_args()

Expand All @@ -65,6 +155,21 @@ 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, reuse=nox_script_mode == "reuse", dependencies=dependencies
)

exit_code = execute_workflow(args)

Expand Down
7 changes: 7 additions & 0 deletions nox/_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,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",
Expand Down
13 changes: 10 additions & 3 deletions nox/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,19 @@ 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.
The file must have a ``.toml`` extension to be considered a toml file or a
``.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
Expand All @@ -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)

Expand All @@ -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 {}
msg = f"No {name} block found in {filepath}"
raise ValueError(msg)
if len(matches) > 1:
Expand Down
7 changes: 3 additions & 4 deletions nox/virtualenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,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,
Expand All @@ -350,8 +350,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

Expand Down Expand Up @@ -478,7 +477,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:
Expand Down
12 changes: 12 additions & 0 deletions tests/resources/noxfile_script_mode.py
Original file line number Diff line number Diff line change
@@ -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"))
71 changes: 71 additions & 0 deletions tests/test__cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import importlib.metadata
import importlib.util
import sys

import packaging.requirements
import packaging.version
import pytest

import nox._cli


def test_get_dependencies() -> None:
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 = {
"argcomplete",
"attrs",
"colorlog",
"dependency-groups",
"jinja2",
"nox",
"packaging",
"tox",
"virtualenv",
}
if sys.version_info < (3, 9):
dep_list.add("importlib-resources")
if sys.version_info < (3, 11):
dep_list.add("tomli")
assert {d.name for d in deps} == dep_list


def test_version_check() -> None:
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() -> None:
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() -> None:
assert not nox._cli.check_dependencies(["packaging<1", "nox"])


def test_invalid_mode(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NOX_SCRIPT_MODE", "invalid")
monkeypatch.setattr(sys, "argv", ["nox"])

with pytest.raises(SystemExit, match="Invalid NOX_SCRIPT_MODE"):
nox._cli.main()
43 changes: 43 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1020,3 +1020,46 @@ def test_symlink_sym_not(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(Path(RESOURCES) / "sym_dir")
res = subprocess.run([sys.executable, "-m", "nox", "-s", "orig"], check=False)
assert res.returncode == 1


def test_noxfile_script_mode() -> None:
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() -> None:
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

0 comments on commit 28d6334

Please sign in to comment.