diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 29b9e621..9ad44c0e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -107,7 +107,7 @@ jobs: - name: Install the pinned version of uv uses: astral-sh/setup-uv@v5 with: - python-version: 3.13 + python-version: 3.12 pyproject-file: "${{ github.workspace }}/pyproject.toml" - name: Install Nox diff --git a/noxfile.py b/noxfile.py index ef2961f1..9e99e43c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,10 +1,12 @@ """Nox sessions.""" import os +import shlex import shutil import sys from pathlib import Path from tempfile import NamedTemporaryFile +from textwrap import dedent import nox from nox import Session @@ -25,50 +27,147 @@ ) +def session_install_uv( + session: Session, + install_project: bool = True, + install_dev: bool = False, + install_docs: bool = False, +) -> None: + """Install root project into the session's virtual environment using uv.""" + env = os.environ.copy() + env.update( + { + "UV_PROJECT_ENVIRONMENT": session.virtualenv.location, + "UV_PYTHON": sys.executable, # Force uv to use the session's interpreter + } + ) + + args = ["uv", "sync", "--frozen"] + if not install_project: + args.append("--no-install-project") + if not install_dev: + args.append("--no-dev") + if install_docs: + args.extend(["--group", "docs"]) + + session.run_install(*args, silent=True, env=env) + + +def session_install_uv_package(session: Session, packages: list[str]) -> None: + """Install packages into the session's virtual environment using uv lockfile.""" + env = os.environ.copy() + env.update( + { + "UV_PROJECT_ENVIRONMENT": session.virtualenv.location, + "UV_PYTHON": sys.executable, + } + ) + + # Export requirements.txt to session temp dir using uv with locked dependencies + requirements_tmp = str(Path(session.create_tmp()) / "requirements.txt") + export_args = ["uv", "export", "--only-dev", "--no-hashes", "-o", requirements_tmp] + session.run_install(*export_args, silent=True, env=env) + + # Install requested packages with requirements.txt constraints + session.install(*packages, "--constraint", requirements_tmp) + + +def activate_virtualenv_in_precommit_hooks(session: Session) -> None: + """Activate virtualenv in hooks installed by pre-commit. + + This function patches git hooks installed by pre-commit to activate the + session's virtual environment. This allows pre-commit to locate hooks in + that environment when invoked from git. + + Args: + session: The Session object. + """ + assert session.bin is not None # noqa: S101 + + # Only patch hooks containing a reference to this session's bindir. Support + # quoting rules for Python and bash, but strip the outermost quotes so we + # can detect paths within the bindir, like /python. + bindirs = [ + bindir[1:-1] if bindir[0] in "'\"" else bindir + for bindir in (repr(session.bin), shlex.quote(session.bin)) + ] + + virtualenv = session.env.get("VIRTUAL_ENV") + if virtualenv is None: + return + + headers = { + # pre-commit < 2.16.0 + "python": f"""\ + import os + os.environ["VIRTUAL_ENV"] = {virtualenv!r} + os.environ["PATH"] = os.pathsep.join(( + {session.bin!r}, + os.environ.get("PATH", ""), + )) + """, + # pre-commit >= 2.16.0 + "bash": f"""\ + VIRTUAL_ENV={shlex.quote(virtualenv)} + PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" + """, + # pre-commit >= 2.17.0 on Windows forces sh shebang + "/bin/sh": f"""\ + VIRTUAL_ENV={shlex.quote(virtualenv)} + PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" + """, + } + + hookdir = Path(".git") / "hooks" + if not hookdir.is_dir(): + return + + for hook in hookdir.iterdir(): + if hook.name.endswith(".sample") or not hook.is_file(): + continue + + if not hook.read_bytes().startswith(b"#!"): + continue + + text = hook.read_text() + + if not any( + Path("A") == Path("a") and bindir.lower() in text.lower() or bindir in text + for bindir in bindirs + ): + continue + + lines = text.splitlines() + + for executable, header in headers.items(): + if executable in lines[0].lower(): + lines.insert(1, dedent(header)) + hook.write_text("\n".join(lines)) + break + + @session(name="pre-commit", python=python_versions[0]) def precommit(session: Session) -> None: """Lint using pre-commit.""" + session_install_uv(session, install_dev=True) args = session.posargs or [ "run", "--all-files", "--hook-stage=manual", "--show-diff-on-failure", ] - session.run( - "uv", - "pip", - "install", - "black", - "darglint", - "flake8", - "flake8-bandit", - "flake8-bugbear", - "flake8-docstrings", - "flake8-rst-docstrings", - "isort", - "pep8-naming", - "pre-commit", - "pre-commit-hooks", - "pyupgrade", - external=True, - ) + session_install_uv_package(session, ["pre-commit"]) session.run("pre-commit", *args) + if args and args[0] == "install": + activate_virtualenv_in_precommit_hooks(session) @session(python=python_versions[0]) def safety(session: Session) -> None: """Scan dependencies for insecure packages.""" with NamedTemporaryFile(delete=False) as requirements: - session.run( - "uv", - "pip", - "compile", - "pyproject.toml", - "--output-file", - requirements.name, - external=True, - ) - session.run("uv", "pip", "install", "safety", external=True) + session_install_uv(session, install_dev=True) + session_install_uv_package(session, ["safety"]) # TODO(Altay): Remove the CVE ignore once its resolved. # It's not critical, so ignoring now. ignore = ["70612"] @@ -88,8 +187,8 @@ def safety(session: Session) -> None: def mypy(session: Session) -> None: """Type-check using mypy.""" args = session.posargs or ["src", "tests", "docs/conf.py"] - session.run_always("uv", "pip", "install", "-e", ".", external=True) - session.run("uv", "pip", "install", "mypy", "pytest", external=True) + session_install_uv(session, install_dev=True) + session_install_uv_package(session, ["mypy", "pytest"]) session.run("mypy", *args) if not session.posargs: session.run("mypy", f"--python-executable={sys.executable}", "noxfile.py") @@ -98,7 +197,10 @@ def mypy(session: Session) -> None: @session(python=python_versions) def tests(session: Session) -> None: """Run the test suite.""" - session.run_always("uv", "pip", "install", "-e", ".[cloud]", external=True) + session_install_uv(session, install_project=True, install_dev=True) + session_install_uv_package( + session, ["coverage", "pytest", "pygments", "pytest-dependency", "s3fs"] + ) session.run( "uv", "pip", @@ -122,7 +224,8 @@ def coverage(session: Session) -> None: """Produce the coverage report.""" args = session.posargs or ["report"] - session.run("uv", "pip", "install", "coverage[toml]", external=True) + session_install_uv(session, install_project=True, install_dev=True) + session_install_uv_package(session, ["coverage"]) if not session.posargs and any(Path().glob(".coverage.*")): session.run("coverage", "combine", external=True) @@ -133,10 +236,8 @@ def coverage(session: Session) -> None: @session(python=python_versions[0]) def typeguard(session: Session) -> None: """Runtime type checking using Typeguard.""" - session.run_always("uv", "pip", "install", "-e", ".", external=True) - session.run( - "uv", "pip", "install", "pytest", "typeguard", "pygments", external=True - ) + session_install_uv(session, install_project=True, install_dev=True) + session_install_uv_package(session, ["pytest", "typeguard", "pygments"]) session.run("pytest", f"--typeguard-packages={package}", *session.posargs) @@ -150,8 +251,8 @@ def xdoctest(session: Session) -> None: if "FORCE_COLOR" in os.environ: args.append("--colored=1") - session.run_always("uv", "pip", "install", "-e", ".", external=True) - session.run("uv", "pip", "install", "xdoctest[colors]", external=True) + session_install_uv(session, install_project=True, install_dev=True) + session_install_uv_package(session, ["xdoctest"]) session.run("python", "-m", "xdoctest", *args) @@ -162,18 +263,17 @@ def docs_build(session: Session) -> None: if not session.posargs and "FORCE_COLOR" in os.environ: args.insert(0, "--color") - session.run_always("uv", "pip", "install", "-e", ".", external=True) - session.run( - "uv", - "pip", - "install", - "sphinx", - "sphinx-click", - "sphinx-copybutton", - "furo", - "myst-nb", - "linkify-it-py", - external=True, + session_install_uv(session, install_project=True, install_dev=True) + session_install_uv_package( + session, + [ + "sphinx", + "sphinx-click", + "sphinx-copybutton", + "furo", + "myst-nb", + "linkify-it-py", + ], ) build_dir = Path("docs", "_build") @@ -187,19 +287,18 @@ def docs_build(session: Session) -> None: def docs(session: Session) -> None: """Build and serve the documentation with live reloading on file changes.""" args = session.posargs or ["--open-browser", "docs", "docs/_build"] - session.run_always("uv", "pip", "install", "-e", ".", external=True) - session.run( - "uv", - "pip", - "install", - "sphinx", - "sphinx-autobuild", - "sphinx-click", - "sphinx-copybutton", - "furo", - "myst-nb", - "linkify-it-py", - external=True, + session_install_uv(session, install_project=True, install_dev=True) + session_install_uv_package( + session, + [ + "sphinx", + "sphinx-autobuild", + "sphinx-click", + "sphinx-copybutton", + "furo", + "myst-nb", + "linkify-it-py", + ], ) build_dir = Path("docs", "_build")