Skip to content

Commit

Permalink
Merge branch 'main' into henryiii/fix/tomldef
Browse files Browse the repository at this point in the history
  • Loading branch information
henryiii authored Jan 31, 2025
2 parents 62d1bd7 + beab6c3 commit b4f71dd
Show file tree
Hide file tree
Showing 10 changed files with 344 additions and 13 deletions.
13 changes: 11 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,15 @@ 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``. You can also set
``--script-venv-backend``/``tool.nox.script-venv-backend``/``NOX_SCRIPT_VENV_BACKEND``
to control the backend used; the default is ``"uv|virtualenv"``.

Next steps
----------
Expand Down
126 changes: 125 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,88 @@ 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], venv_backend: str
) -> NoReturn:
envdir.mkdir(exist_ok=True)
noxenv = envdir.joinpath("_nox_script_mode")
venv = nox.virtualenv.get_virtualenv(
*venv_backend.split("|"),
reuse_existing=reuse,
envdir=str(noxenv),
)
venv.create()
env = {k: v for k, v in venv._get_env({}).items() if v is not None}
env["NOX_SCRIPT_MODE"] = "none"
cmd = (
[nox.virtualenv.UV, "pip", "install"]
if venv.venv_backend == "uv"
else ["pip", "install"]
)
subprocess.run([*cmd, *dependencies], env=env, check=True)
nox_cmd = shutil.which("nox", path=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=env,
stdout=None,
stderr=None,
encoding="utf-8",
text=True,
check=False,
).returncode
)
os.execle(nox_cmd, nox_cmd, *sys.argv[1:], env) # pragma: nocover # noqa: S606


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

Expand All @@ -65,6 +161,34 @@ 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
venv_backend = (
os.environ.get("NOX_SCRIPT_VENV_BACKEND")
or args.script_venv_backend
or (
toml_config.get("tool", {})
.get("nox", {})
.get("script-venv-backend", "uv|virtualenv")
)
)

envdir = Path(args.envdir or ".nox")
run_script_mode(
envdir,
reuse=nox_script_mode == "reuse",
dependencies=dependencies,
venv_backend=venv_backend,
)

exit_code = execute_workflow(args)

Expand Down
12 changes: 12 additions & 0 deletions nox/_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,18 @@ 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(
"script_venv_backend",
"--script-venv-backend",
group=options.groups["general"],
),
_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 = "pyproject.toml") -> dict[str, Any]:
def load_toml(
filename: os.PathLike[str] | str = "pyproject.toml", *, 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. The default is ``"pyproject.toml"``.
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
20 changes: 15 additions & 5 deletions nox/virtualenv.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,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 @@ -365,8 +365,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 @@ -453,6 +452,7 @@ class VirtualEnv(ProcessEnv):
be ``py -3.6-32``
* ``X.Y.Z``, e.g. ``3.4.9``
* ``pythonX.Y``, e.g. ``python2.7``
* ``pypyX.Y``, e.g. ``pypy3.10`` (also ``pypy-3.10`` allowed)
* A path in the filesystem to a Python executable
If not specified, this will use the currently running Python.
Expand All @@ -472,6 +472,10 @@ def __init__(
venv_backend: str = "virtualenv",
venv_params: Sequence[str] = (),
):
# "pypy-" -> "pypy"
if interpreter and interpreter.startswith("pypy-"):
interpreter = interpreter[:4] + interpreter[5:]

self.location_name = location
self.location = os.path.abspath(location)
self.interpreter = interpreter
Expand All @@ -493,7 +497,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 Expand Up @@ -663,7 +667,13 @@ def create(self) -> bool:
return False

if self.venv_backend == "virtualenv":
cmd = [sys.executable, "-m", "virtualenv", self.location]
cmd = [
sys.executable,
"-m",
"virtualenv",
self.location,
"--no-periodic-update",
]
if self.interpreter:
cmd.extend(["-p", self._resolved_interpreter])
elif self.venv_backend == "uv":
Expand Down
5 changes: 3 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ def docs(session: nox.Session) -> None:
# The following sessions are only to be run in CI to check the nox GHA action
def _check_python_version(session: nox.Session) -> None:
if session.python.startswith("pypy"):
python_version = session.python[4:]
# Drop starting "pypy" and maybe "-"
python_version = session.python.lstrip("py-")
implementation = "pypy"
else:
python_version = session.python
Expand All @@ -170,7 +171,7 @@ def _check_python_version(session: nox.Session) -> None:
@nox.session(
python=[
*ALL_PYTHONS,
"pypy3.10",
"pypy-3.10",
],
default=False,
)
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"))
Loading

0 comments on commit b4f71dd

Please sign in to comment.