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 <henryschreineriii@gmail.com>

fix: version based on pipx

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>

fix: subprocess on Windows

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>

fix: uv or virtualenv, test

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>

fix: windows

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>

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

fix: resolve nox path on Windows

Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
  • Loading branch information
henryiii committed Nov 9, 2024
1 parent f4a91df commit 91c4999
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 8 deletions.
99 changes: 98 additions & 1 deletion nox/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()

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

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"))
44 changes: 44 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import contextlib
import os
import subprocess
import sys
from importlib import metadata
from pathlib import Path
Expand Down Expand Up @@ -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

0 comments on commit 91c4999

Please sign in to comment.