Skip to content

Commit

Permalink
feat: support PEP 723 directly
Browse files Browse the repository at this point in the history
Signed-off-by: Henry Schreiner <[email protected]>

fix: version based on pipx

Signed-off-by: Henry Schreiner <[email protected]>

fix: subprocess on Windows

Signed-off-by: Henry Schreiner <[email protected]>

fix: uv or virtualenv, test

Signed-off-by: Henry Schreiner <[email protected]>

fix: windows

Signed-off-by: Henry Schreiner <[email protected]>

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

fix: resolve nox path on Windows

Signed-off-by: Henry Schreiner <[email protected]>
  • Loading branch information
henryiii committed Nov 12, 2024
1 parent bdd84a4 commit 588320d
Show file tree
Hide file tree
Showing 8 changed files with 254 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
103 changes: 102 additions & 1 deletion nox/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()

Expand All @@ -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)

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

Expand Down Expand Up @@ -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:
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"))
68 changes: 68 additions & 0 deletions tests/test__cli.py
Original file line number Diff line number Diff line change
@@ -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()
43 changes: 43 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -951,3 +951,46 @@ def test_symlink_sym_not(monkeypatch):
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():
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

0 comments on commit 588320d

Please sign in to comment.