"
+labels:
+ - "bug"
+body:
+ - type: textarea
+ attributes:
+ label: Current Behavior
+ description: A concise description of what you're experiencing.
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Expected Behavior
+ description: A concise description of what you expected to happen.
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Steps To Reproduce
+ description: Steps to reproduce the behavior.
+ placeholder: |
+ 1. With this Nox session/file...
+ 2. Run '...'
+ 3. See error...
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Environment
+ description: |
+ examples:
+ - **OS**: Ubuntu 24.04
+ - **Python**: 3.14.3
+ - **Nox**: 2026.04.10
+ value: |
+ - OS:
+ - Python:
+ - Nox:
+ render: Markdown
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Anything else?
+ description: |
+ Links? References? Anything that will give us more context about the issue you are encountering!
+
+ Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..63042273
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,4 @@
+contact_links:
+ - name: Winterbloom Discord server
+ url: https://discord.gg/C5EH3N3fsk
+ about: You can ask questions and chat about Nox under this server's '#nox' channel.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 143b301d..00000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-name: Feature request
-about: Suggest an idea or new feature for this project
-
----
-
-**How would this feature be useful?**
-
-
-**Describe the solution you'd like**
-
-
-**Describe alternatives you've considered**
-
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 00000000..bcf994d6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,33 @@
+name: 🚀 Feature Request
+description: Request a new feature or enhancement
+title: ""
+labels:
+ - "enhancement"
+body:
+ - type: textarea
+ attributes:
+ label: How would this feature be useful?
+ description: Describe any use cases this solves or frustrations it alleviates.
+ validations:
+ required: false
+
+ - type: textarea
+ attributes:
+ label: Describe the solution you'd like
+ description: If you have an idea on how to do this, let us know here!
+ validations:
+ required: false
+
+ - type: textarea
+ attributes:
+ label: Describe alternatives you've considered
+ description: If there's some workaround or alternative solutions, let us know here!
+ validations:
+ required: false
+
+ - type: textarea
+ attributes:
+ label: Anything else?
+ description: Any other relevant information or background.
+ validations:
+ required: false
diff --git a/.github/action_helper.py b/.github/action_helper.py
new file mode 100644
index 00000000..e9a2f5df
--- /dev/null
+++ b/.github/action_helper.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import json
+import platform
+import sys
+
+
+def filter_version(version: str) -> str:
+ """return python 'major.minor'"""
+
+ # remove interpreter prefix
+ if version.startswith("pypy-"):
+ version_ = version[5:]
+ elif version.startswith("pypy"):
+ version_ = version[4:]
+ elif version.startswith("~"):
+ version_ = version[1:]
+ else:
+ version_ = version
+
+ # remove extra specifier e.g. "3.12-dev" => "3.12", "~3.12.0-0" => "3.12"
+ version_ = version_.split("-")[0]
+
+ # Handle free-threaded
+ free_threaded = version_.endswith("t")
+ if free_threaded:
+ version_ = version_[:-1]
+
+ version_parts = version_.split(".")
+ if len(version_parts) < 2:
+ msg = f"invalid version: {version}"
+ raise ValueError(msg)
+ if not version_parts[0].isdigit():
+ msg = f"invalid major python version: {version}"
+ raise ValueError(msg)
+ if not version_parts[1].isdigit():
+ msg = f"invalid minor python version: {version}"
+ raise ValueError(msg)
+ return ".".join(version_parts[:2]) + ("t" if free_threaded else "")
+
+
+def valid_version(version: str) -> bool:
+ if sys.platform.startswith("win32") and platform.machine().lower() in {
+ "arm64",
+ "aarch64",
+ }:
+ if version in {"2.7", "3.6", "3.7", "3.8", "3.9", "3.10"}:
+ return False
+ return True
+
+
+def setup_action(input_: str, *, self_version: str = "3.12") -> None:
+ versions = [version.strip() for version in input_.split(",") if version.strip()]
+
+ pypy_versions = [version for version in versions if version.startswith("pypy")]
+ pypy_versions_filtered = [filter_version(version) for version in pypy_versions]
+ if len(pypy_versions) != len(set(pypy_versions_filtered)):
+ msg = (
+ "multiple versions specified for the same 'major.minor' PyPy interpreter:"
+ f" {pypy_versions}"
+ )
+ raise ValueError(msg)
+
+ cpython_versions = [version for version in versions if version not in pypy_versions]
+ cpython_versions_filtered = [
+ filter_version(version) for version in cpython_versions
+ ]
+ if len(cpython_versions) != len(set(cpython_versions_filtered)):
+ msg = (
+ "multiple versions specified for the same 'major.minor' CPython"
+ f" interpreter: {cpython_versions}"
+ )
+ raise ValueError(msg)
+
+ # cpython shall be installed last because
+ # other interpreters also define pythonX.Y symlinks.
+ versions = [v for v in pypy_versions + cpython_versions if valid_version(v)]
+
+ # we want to install our own self version last to ease nox set-up
+ if self_version in cpython_versions_filtered:
+ index = cpython_versions_filtered.index(self_version)
+ index = versions.index(cpython_versions[index])
+ cpython_nox = versions.pop(index)
+ versions.append(cpython_nox)
+ else:
+ # add this to install nox
+ versions.append(self_version)
+
+ print(f"interpreters={json.dumps(versions)}")
+
+
+if __name__ == "__main__":
+ if len(sys.argv) != 2:
+ msg = f"invalid arguments: {sys.argv}"
+ raise AssertionError(msg)
+ setup_action(sys.argv[1])
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..0756fe47
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,24 @@
+version: 2
+
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ groups:
+ github-actions:
+ patterns:
+ - "*"
+ cooldown:
+ default-days: 7
+
+ - package-ecosystem: "pre-commit"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ groups:
+ pre-commit:
+ patterns:
+ - "*"
+ cooldown:
+ default-days: 7
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 00000000..8c7b0994
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,4 @@
+changelog:
+ exclude:
+ authors:
+ - dependabot[bot]
diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml
new file mode 100644
index 00000000..5bc6cd01
--- /dev/null
+++ b/.github/workflows/action.yml
@@ -0,0 +1,53 @@
+name: Action
+on:
+ push:
+ branches:
+ - "main"
+ - "**action**"
+ pull_request:
+ paths:
+ - ".github/workflows/action.yml"
+ - ".github/action_helper.py"
+ - "action.yml"
+ workflow_dispatch:
+ # allow manual runs on branches without a PR
+
+env:
+ FORCE_COLOR: "1"
+
+permissions: {}
+
+jobs:
+ action-default-tests:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os:
+ - ubuntu-24.04
+ - ubuntu-24.04-arm
+ - windows-2022
+ - windows-2025
+ - windows-11-arm
+ - macos-15-intel
+ - macos-15
+ steps:
+ - uses: actions/checkout@v6.0.3
+ with:
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@v8.2.0
+ - uses: ./
+ - run: nox --session github_actions_default_tests
+
+ action-all-tests:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v6.0.3
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@v6
+ with:
+ python-version: 3.12
+ - uses: ./
+ with:
+ python-versions: "pypy-2.7, pypy-3.8, pypy-3.9, pypy-3.10, pypy-3.11, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13, 3.14, 3.13t, 3.14t"
+ - run: nox --session github_actions_all_tests
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
new file mode 100644
index 00000000..396f2742
--- /dev/null
+++ b/.github/workflows/cd.yml
@@ -0,0 +1,49 @@
+name: CD
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+ release:
+ types: [published]
+
+env:
+ FORCE_COLOR: "1"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions: {}
+
+jobs:
+ dist:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6.0.3
+ with:
+ persist-credentials: false
+ - uses: hynek/build-and-inspect-python-package@v2
+
+ deploy:
+ needs: [dist]
+ environment:
+ name: pypi
+ url: https://pypi.org/project/nox
+ permissions:
+ id-token: write
+ attestations: write
+ runs-on: ubuntu-latest
+ if: github.event_name == 'release' && github.event.action == 'published'
+ steps:
+ - uses: actions/download-artifact@v8
+ with:
+ name: Packages
+ path: dist
+
+ - name: Generate artifact attestation for sdist and wheel
+ uses: actions/attest@v4
+ with:
+ subject-path: "dist/*"
+
+ - uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9d9d7b42..3409ddee 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,69 +1,180 @@
name: CI
-on: [push, pull_request]
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+ # allow manual runs on branches without a PR
+
+env:
+ FORCE_COLOR: "1"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions: {}
+
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
+ fail-fast: false
matrix:
- os: [ubuntu-20.04, windows-2019]
- python-version: [3.6, 3.7, 3.8, 3.9]
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ python-version:
+ - "3.10"
+ - "3.12"
+ - "3.14"
+ include:
+ - os: ubuntu-22.04
+ python-version: "3.11"
+ - os: ubuntu-22.04
+ python-version: "3.13"
+ - os: macos-15-intel
+ python-version: "3.12"
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v6.0.3
+ with:
+ persist-credentials: false
+ - name: Set up non-default Pythons
+ uses: actions/setup-python@v6
+ with:
+ python-version: |
+ 3.10
+ 3.11
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v2
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
+ allow-prereleases: true
+ - name: Setup uv
+ uses: astral-sh/setup-uv@v8.2.0
- name: Set up Miniconda
- uses: conda-incubator/setup-miniconda@v2
+ uses: conda-incubator/setup-miniconda@v4
+ if: matrix.python-version == '3.14'
with:
- auto-update-conda: true
- python-version: ${{ matrix.python-version }}
+ channels: conda-forge
+ conda-remove-defaults: true
+ conda-version: '26.5.2'
+ activate-environment:
+ - name: Install Nox-under-test (uv)
+ run: uv pip install --system .
+ - name: Run tests on ${{ matrix.os }}
+ run: nox --session "tests-${{ matrix.python-version }}" -- --durations=10
+ - name: Run min-version tests on ${{ matrix.os }}
+ run: nox --session minimums --force-python="${{ matrix.python-version }}" -- --durations=10
+ - name: Run Conda tests
+ if: matrix.python-version == '3.14'
+ run: nox --session "conda_tests" -- --durations=5
+ - name: Save coverage report
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-${{ github.job }}-${{ strategy.job-index }}
+ path: .coverage.*
+ include-hidden-files: true
+ if-no-files-found: error
+
+ # Experimental: exercise the MinGW-specific code paths (nox.virtualenv._IS_MINGW)
+ # on a real MSYS2 runner. Non-blocking until proven stable.
+ # No uv CLI exists in any MSYS2 repo (the msys `uv` package only ships the
+ # uv_build backend), so the uv backend is exercised via the standalone uv
+ # binary from setup-uv, which lands on the Windows PATH the MINGW64 shell sees.
+ msys2:
+ runs-on: windows-latest
+ continue-on-error: true
+ defaults:
+ run:
+ shell: msys2 {0}
+ steps:
+ - uses: actions/checkout@v6.0.3
+ with:
+ persist-credentials: false
+ - uses: msys2/setup-msys2@v2.32.0
+ with:
+ msystem: MINGW64
+ update: true
+ # inherit the runner PATH so the standalone uv (added to GITHUB_PATH
+ # by setup-uv, which runs after this step) is visible to MinGW Python
+ path-type: inherit
+ install: >-
+ mingw-w64-x86_64-python
+ mingw-w64-x86_64-python-pip
+ mingw-w64-x86_64-python-virtualenv
+ mingw-w64-x86_64-gcc
+ - name: Setup uv
+ uses: astral-sh/setup-uv@v8.2.0
- name: Install Nox-under-test
+ run: python -m pip install .
+ - name: Run tests under MinGW
run: |
- python -m pip install --disable-pip-version-check .
- - name: Run tests on ${{ matrix.os }}
- run: nox --non-interactive --session "tests-${{ matrix.python-version }}" -- --full-trace
+ pyver=$(python -c "import sys; print('{}.{}'.format(*sys.version_info))")
+ echo "MSYS2 MinGW Python: $pyver"
+ python -c "import shutil; print('uv discovered at:', shutil.which('uv'))"
+ # Force virtualenv so the session venv stays a MinGW interpreter
+ # (_IS_MINGW=True). uv stays on PATH, so the uv-backend tests still
+ # build real uv venvs from within MinGW, exercising #1117.
+ nox --session "tests-$pyver" --force-venv-backend virtualenv -- --durations=10
+ - name: Save coverage report
+ uses: actions/upload-artifact@v7
+ with:
+ name: coverage-${{ github.job }}
+ path: .coverage.*
+ include-hidden-files: true
+ if-no-files-found: error
+
+ coverage:
+ needs: [build, msys2]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6.0.3
+ with:
+ persist-credentials: false
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ - name: Setup uv
+ uses: astral-sh/setup-uv@v8.2.0
+ - name: Install Nox-under-test
+ run: uv pip install --system .
+ - name: Download individual coverage reports
+ uses: actions/download-artifact@v8
+ with:
+ pattern: coverage-*
+ - name: Display structure of downloaded files
+ run: ls -aR
+ - name: Run coverage
+ run: nox --session "cover"
+
lint:
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - name: Set up Python 3.8
- uses: actions/setup-python@v2
+ - uses: actions/checkout@v6.0.3
+ with:
+ persist-credentials: false
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v6
with:
- python-version: 3.8
+ python-version: "3.12"
- name: Install Nox-under-test
- run: |
- python -m pip install --disable-pip-version-check .
+ run: python -m pip install --disable-pip-version-check .
- name: Lint
- run: nox --non-interactive --session "lint"
+ run: nox --session "lint"
+
docs:
- runs-on: ubuntu-20.04
+ runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - name: Set up Python 3.8
- uses: actions/setup-python@v2
+ - uses: actions/checkout@v6.0.3
with:
- python-version: 3.8
+ persist-credentials: false
+ - name: Set up Python 3.12
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ - name: Setup uv
+ uses: astral-sh/setup-uv@v8.2.0
- name: Install Nox-under-test
- run: |
- python -m pip install --disable-pip-version-check .
+ run: uv pip install --system .
- name: Docs
- run: nox --non-interactive --session "docs"
- deploy:
- needs: build
- runs-on: ubuntu-20.04
- if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
- steps:
- - uses: actions/checkout@v2
- - name: Set up Python 3.9
- uses: actions/setup-python@v2
- with:
- python-version: 3.9
- - name: Install setuptools and wheel
- run: python -m pip install --upgrade --user setuptools wheel
- - name: Build sdist and wheel
- run: python setup.py sdist bdist_wheel
- - name: Publish distribution PyPI
- uses: pypa/gh-action-pypi-publish@master
- with:
- password: ${{ secrets.PYPI_API_TOKEN }}
+ run: nox --session "docs"
diff --git a/.github/zizmor.yml b/.github/zizmor.yml
new file mode 100644
index 00000000..2fe494c6
--- /dev/null
+++ b/.github/zizmor.yml
@@ -0,0 +1,3 @@
+rules:
+ unpinned-uses:
+ disable: true
diff --git a/.gitignore b/.gitignore
index 98979c46..7f915a57 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
.mypy_cache/
.vscode/
+.DS_Store
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -76,3 +77,13 @@ target/
# PyCharm
.idea/
+
+# Standard venv location
+.venv
+
+# Lockfiles
+uv.lock
+*pylock.toml
+
+# OS files
+.DS_Store
diff --git a/.isort.cfg b/.isort.cfg
deleted file mode 100644
index b9fb3f3e..00000000
--- a/.isort.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[settings]
-profile=black
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 00000000..2a264ab7
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,77 @@
+ci:
+ autoupdate_commit_msg: "chore: update pre-commit hooks"
+ autoupdate_schedule: weekly
+ autofix_commit_msg: "style: pre-commit fixes"
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ - id: check-added-large-files
+ - id: check-case-conflict
+ - id: check-merge-conflict
+ - id: check-symlinks
+ - id: check-yaml
+ - id: debug-statements
+ - id: end-of-file-fixer
+ - id: mixed-line-ending
+ - id: requirements-txt-fixer
+ - id: trailing-whitespace
+
+ - repo: https://github.com/henryiii/flake8-lazy
+ rev: v0.8.2
+ hooks:
+ - id: flake8-lazy
+ args: ["--apply=set"]
+ files: '^nox/'
+
+ - repo: https://github.com/tox-dev/pyproject-fmt
+ rev: "v2.24.1"
+ hooks:
+ - id: pyproject-fmt
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.15.17
+ hooks:
+ - id: ruff-check
+ args: ["--fix", "--show-fixes"]
+ - id: ruff-format
+
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: v2.1.0
+ hooks:
+ - id: mypy
+ files: ^(nox/|tests/)
+ exclude: ^tests/resources/
+ args: []
+ additional_dependencies:
+ - argcomplete
+ - attrs
+ - colorlog
+ - dependency-groups>=1.3.1
+ - humanize
+ - jinja2
+ - packaging
+ - pbs_installer
+ - pytest
+ - tomli
+ - uv
+
+ - repo: https://github.com/codespell-project/codespell
+ rev: v2.4.2
+ hooks:
+ - id: codespell
+
+ - repo: https://github.com/pre-commit/pygrep-hooks
+ rev: v1.10.0
+ hooks:
+ - id: python-no-eval
+ exclude: ^nox/manifest.py$
+ - id: rst-backticks
+ - id: rst-directive-colons
+ - id: rst-inline-touching-normal
+
+ - repo: https://github.com/zizmorcore/zizmor-pre-commit
+ rev: v1.25.2
+ hooks:
+ - id: zizmor
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 85dfcbd3..e39fc4fe 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -1,10 +1,10 @@
-formats: []
-
+version: 2
build:
- image: latest
-
-python:
- version: 3.6
- pip_install: true
-
-requirements_file: requirements-test.txt
+ os: ubuntu-22.04
+ tools:
+ python: "3.12"
+ commands:
+ - asdf plugin add uv
+ - asdf install uv latest
+ - asdf global uv latest
+ - uv run --group docs sphinx-build -T -b html -d docs/_build/doctrees -D language=en docs $READTHEDOCS_OUTPUT/html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e6c9d50..e9fa9cce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,647 @@
# Changelog
+## 2026.04.10
+
+This release drops Python 3.8 and adds a `--usage` command for full docstrings.
+Our `.nox` dir is now ignored by default, virtualenvs are recreated if symlinks
+are broken (such as after a Python upgrade), and `-t` now selects from all
+available sessions.
+
+We'd like to thank the following folks who contributed to this release:
+
+* @henryiii
+* @agriyakhetarpal
+* @scop
+
+
+Features:
+
+* Drop Python 3.8 (reapply #1004) by @henryiii in https://github.com/wntrblm/nox/pull/1062
+* Add a `nox --usage ` command to print full docstrings for provided sessions by @agriyakhetarpal in https://github.com/wntrblm/nox/pull/1064
+* Write out `.gitignore`/`CACHEDIR.TAG` to `.nox` dir by @henryiii in https://github.com/wntrblm/nox/pull/1072
+
+Fixes:
+
+* Recreate venv if broken symlinks are present by @henryiii in https://github.com/wntrblm/nox/pull/1078
+* Ignore default selection for tags and keywords by @henryiii in https://github.com/wntrblm/nox/pull/1057
+* More uv variables set by @henryiii in https://github.com/wntrblm/nox/pull/1056
+* Ignore forcecolor falsy by @henryiii in https://github.com/wntrblm/nox/pull/1073
+* Fully pin actions in composite action by @henryiii in https://github.com/wntrblm/nox/pull/1080
+
+Internal changes:
+
+* Pin CI to working conda version by @henryiii in https://github.com/wntrblm/nox/pull/1079
+* Use prek by @agriyakhetarpal in https://github.com/wntrblm/nox/pull/1065
+* Switch artifact attestations to actions/attest by @scop in https://github.com/wntrblm/nox/pull/1070
+* Use zizmor by @henryiii in https://github.com/wntrblm/nox/pull/1082
+
+
+## 2026.02.09
+
+This small release supports uv 0.10's new requirement that `--clear` be passed
+to clear an environment. Python 3.8 support was temporarily re-added since uv
+0.10 still supports 3.8, so nox on 3.8 was affected.
+
+We'd like to thank the following folks who contributed to this release:
+
+* @henryiii
+* @kai687 (first contribution)
+* @wu-zhao-min (first contribution)
+
+
+Bugfixes:
+
+* Support uv 0.10.0 by @henryiii in https://github.com/wntrblm/nox/pull/1055
+* Show tags by @henryiii in https://github.com/wntrblm/nox/pull/1058
+* Better support for multiple session decorators by @henryiii in https://github.com/wntrblm/nox/pull/1048
+* Normalize extra `tox-to-nox` by @henryiii in https://github.com/wntrblm/nox/pull/1059
+* Better typing for `.run` by @henryiii in https://github.com/wntrblm/nox/pull/1037
+
+Internal changes:
+
+* Fix conda CI job by @henryiii in https://github.com/wntrblm/nox/pull/1035
+* Drop Python 3.8 by @henryiii in https://github.com/wntrblm/nox/pull/1004 and revert in https://github.com/wntrblm/nox/pull/1060
+* Fix action test by @henryiii in https://github.com/wntrblm/nox/pull/1036
+* Bump packaging dep to remove pyparsing by @henryiii in https://github.com/wntrblm/nox/pull/1047
+* Avoid newest docutils until sphinx-tabs updates by @henryiii in https://github.com/wntrblm/nox/pull/1052
+* Optimize the naming of constants to enhance readability by @wu-zhao-min in https://github.com/wntrblm/nox/pull/1041
+
+Documentation:
+
+* Duplicated session name in cookbook by @kai687 in https://github.com/wntrblm/nox/pull/1050
+
+
+## 2025.11.12
+
+This is a small release to fix a warning when running in script mode before we
+drop Python 3.8.
+
+We'd like to thank the following folks who contributed to this release:
+
+* @henryiii
+
+Bugfixes:
+
+* Avoid warnings when running in script mode by @henryiii in https://github.com/wntrblm/nox/pull/1025
+* `nox --report ` should support `Path` by @henryiii in https://github.com/wntrblm/nox/pull/1026
+* Install env was typed differently by @henryiii in https://github.com/wntrblm/nox/pull/1028
+
+Internal changes:
+
+* pytest `log_level` is better than `log_cli_level` by @henryiii in https://github.com/wntrblm/nox/pull/1029
+* Use last intel macOS image by @henryiii in https://github.com/wntrblm/nox/pull/1031
+* Add a few more passing ruff checks by @henryiii in https://github.com/wntrblm/nox/pull/1030
+
+## 2025.10.16
+
+This is a quick release to make our new dependency, pbs-installer, optional.
+This is only needed to install Python if you are not using the uv backend.
+We've also added the time taken to the output when it's over a second.
+
+We'd like to thank the following folks who contributed to this release:
+
+* @henryiii
+
+Changes:
+
+* Make pbs-installer an optional dependency by @henryiii in https://github.com/wntrblm/nox/pull/1017
+* Include time on longer runs (adds `humanize` dependency) by @henryiii in https://github.com/wntrblm/nox/pull/1014
+
+Internal changes:
+
+* Run conda on Windows/Linux again by @henryiii in https://github.com/wntrblm/nox/pull/1015
+
+
+
+## 2025.10.14
+
+This release updates the default for the GitHub Action to target the current range of recommended Pythons (3.10-3.14). There's now a mechanism to control if nox downloads Python (even when not using uv). Several fixes include better free-threading support, custom filenames in script mode, and support for GitHub Actions Windows ARM runners.
+
+We'd like to thank the following folks who contributed to this release:
+
+* @agriyakhetarpal (first contribution)
+* @henryiii
+* @IvanIsCoding (first contribution)
+* @jbdyn (first contribution)
+* @johnthagen
+* @saucoide
+* @shenxianpeng (first contribution)
+* @Spacetown (first contribution)
+* @zzzeek (first contribution)
+
+Features:
+
+* Add `--download-python` python option by @saucoide in https://github.com/wntrblm/nox/pull/989
+* Add `session.env_dir` to get the Path to the environment by @jbdyn in https://github.com/wntrblm/nox/pull/974
+
+Changes:
+
+* GitHub Action 3.10-3.14 default by @henryiii in https://github.com/wntrblm/nox/pull/1003
+* Percolate the `verbose` global option to the `silent` argument for session installation commands, and document it by @agriyakhetarpal in https://github.com/wntrblm/nox/pull/983
+* Disallow abbreviated options by @henryiii in https://github.com/wntrblm/nox/pull/973
+* Log output of failed process by @jbdyn in https://github.com/wntrblm/nox/pull/974
+* Use a separate logging level (`SESSION_INFO`) for session info instead of warning by @Spacetown in https://github.com/wntrblm/nox/pull/990
+
+Bugfixes:
+
+* Support scripts with custom names by @henryiii in https://github.com/wntrblm/nox/pull/1007
+* Correctly match free-threaded python versions by @zzzeek in https://github.com/wntrblm/nox/pull/999
+* Let uv replace the directory instead of deleting it ourselves by @henryiii in https://github.com/wntrblm/nox/pull/981
+* Tighten type for `venv_backend` by @henryiii in https://github.com/wntrblm/nox/pull/967
+* GitHub Actions Windows ARM support by @henryiii in https://github.com/wntrblm/nox/pull/1002
+* Show a warning (eventually error) if a duplicate session is encountered by @henryiii in https://github.com/wntrblm/nox/pull/1013
+* Fix validation error for `nox.options.keywords` by @henryiii in https://github.com/wntrblm/nox/pull/1011
+
+Documentation:
+
+* Add `--script` to `uv run` inline metadata for nox-uv example by @johnthagen in https://github.com/wntrblm/nox/pull/984
+* Document `nox-uv` third party package by @johnthagen in https://github.com/wntrblm/nox/pull/978
+* Recommend `--locked` over `--frozen` for `uv sync` by @johnthagen in https://github.com/wntrblm/nox/pull/972
+* Tweak the grammar in a comment in tasks.py by @brettcannon in https://github.com/wntrblm/nox/pull/987
+* Add rustworkx as an example user by @IvanIsCoding in https://github.com/wntrblm/nox/pull/975
+* Update badge from black to ruff by @shenxianpeng in https://github.com/wntrblm/nox/pull/971
+
+Internal changes:
+
+* Hide new coverage warning on 3.14 by @henryiii in https://github.com/wntrblm/nox/pull/980
+* Correct minimum versions and test by @henryiii in https://github.com/wntrblm/nox/pull/962
+* CI ensure there are no duplicate log handlers by @saucoide in https://github.com/wntrblm/nox/pull/991
+* Add 3.14 beta 1 support by @henryiii in https://github.com/wntrblm/nox/pull/970
+* Add better reprs for some internal classes by @henryiii in https://github.com/wntrblm/nox/pull/966
+* Drop Windows 2019 by @henryiii in https://github.com/wntrblm/nox/pull/985
+* Modernize and remove rc conda channel by @henryiii in https://github.com/wntrblm/nox/pull/964
+* Restore minimums test by @henryiii in https://github.com/wntrblm/nox/pull/982
+* Use dict instead of OrderedDict by @henryiii in https://github.com/wntrblm/nox/pull/963
+* Add conda marker to tests by @henryiii in https://github.com/wntrblm/nox/pull/969
+* Clear the registry in tests by @henryiii in https://github.com/wntrblm/nox/pull/968
+
+
+## 2025.05.01
+
+This is a bugfix release that primarily adds support for uv 0.7+. A few other
+small fixes were made.
+
+We'd like to thank the following folks who contributed to this release:
+
+* @chirizxc
+* @gschaffner
+* @henryiii
+* @living180
+* @Spectre5 (first contribution)
+
+Bugfixes:
+
+* `uv version` is now `uv self version`, respect `UV` by @henryiii and @Spectre5 in https://github.com/wntrblm/nox/pull/955
+* Add `UV_PYTHON` to disallowed vars by @henryiii in https://github.com/wntrblm/nox/pull/959
+* Never ignore URL dependencies in PEP 723 noxfiles by @gschaffner in https://github.com/wntrblm/nox/pull/935
+* Support forcing Python on parametrized session by @henryiii in https://github.com/wntrblm/nox/pull/958
+* Fix `conda_install` issue with newer conda (only Unix) by @henryiii in https://github.com/wntrblm/nox/pull/957
+* Show skip reason by default by @chirizxc in https://github.com/wntrblm/nox/pull/941
+* Support `Path` for envdir by @henryiii in https://github.com/wntrblm/nox/pull/932
+* Use Python 3.12 for action, allow 3.13, drop 3.8 from auto versions by @henryiii in https://github.com/wntrblm/nox/pull/946
+
+Documentation:
+
+* Fix a typo in the changelog by @gschaffner in https://github.com/wntrblm/nox/pull/936
+* Update uv recipe by @henryiii in https://github.com/wntrblm/nox/pull/933
+* Fix parametrized session tagging example by @living180 in https://github.com/wntrblm/nox/pull/942
+* uv now supports `pip install .` reinstallation by @henryiii in https://github.com/wntrblm/nox/pull/947
+
+Internal changes:
+
+* Use PEP 639 license info by @henryiii in https://github.com/wntrblm/nox/pull/956
+* Make test skips a bit smarter by @henryiii in https://github.com/wntrblm/nox/pull/929
+* Add our own requirements to conda too by @henryiii in https://github.com/wntrblm/nox/pull/945
+
+## 2025.02.09
+
+This release improves PEP 723 support, including adding dependencies to the noxfile itself ("plugins"). It adds the long-awaited "requires" option, allowing sessions to require other sessions. And it brings further improvements to the `pyproject.toml` support, including helpers for dependency-groups and Python version lists.
+
+We'd like to thank the following folks who contributed to this release:
+
+* @btemplep (first contribution)
+* @chirizxc (first contribution)
+* @davidhewitt (first contribution)
+* @gschaffner (first contribution)
+* @henryiii
+* @oliversen (first contribution)
+
+New features:
+
+* Support PEP 723 noxfiles by @henryiii in https://github.com/wntrblm/nox/pull/881
+* Expose main as `nox.main` by @henryiii in https://github.com/wntrblm/nox/pull/878 (followup fix: https://github.com/wntrblm/nox/pull/884)
+* Support session dependencies (`requires`) by @gschaffner in https://github.com/wntrblm/nox/pull/631
+* Helper to get dependency-groups by @henryiii in https://github.com/wntrblm/nox/pull/876
+* Helper to get the Python listing by @henryiii in https://github.com/wntrblm/nox/pull/877
+* Add a `"pyproject.toml"` default for `load_toml` by @henryiii in https://github.com/wntrblm/nox/pull/917
+
+Bugfixes:
+
+* Correct virtualenv bin dir under mingw python by @davidhewitt in https://github.com/wntrblm/nox/pull/901
+* Allow `pypy-*` to be used as well for `pypy*` (matching GHA) by @henryiii in https://github.com/wntrblm/nox/pull/913
+* Don't trigger a background update process for virtualenv by @henryiii in https://github.com/wntrblm/nox/pull/918
+* Include encoding for consistent behavior (default in Python 3.15+) by @henryiii in https://github.com/wntrblm/nox/pull/891
+* Outer env issues fixed by @henryiii in https://github.com/wntrblm/nox/pull/874
+* Support noxfile being a symlink by @henryiii in https://github.com/wntrblm/nox/pull/829
+* Drop PyPy from the default list for the GitHub Action by @henryiii in https://github.com/wntrblm/nox/pull/916
+
+Bugfixes related to uv support:
+
+* Catch `PermissionError` from popen when UV is not installed by @btemplep in https://github.com/wntrblm/nox/pull/908
+* Use `uv python install` only with uv backend by @oliversen in https://github.com/wntrblm/nox/pull/900
+* Handle `"uvx"` like `"uv"` by @henryiii in https://github.com/wntrblm/nox/pull/920
+* Support broken uv (via pyenv) by @henryiii in https://github.com/wntrblm/nox/pull/922
+
+Tox-to-nox script:
+
+* Drop support for tox 3 by @henryiii in https://github.com/wntrblm/nox/pull/910
+* Switch pkgutil for importlib-resources by @henryiii in https://github.com/wntrblm/nox/pull/887
+* Correctly separate command-line arguments by @chirizxc in https://github.com/wntrblm/nox/pull/906
+
+Improved noxfile validation:
+
+* Error if invalid `reuse_venv` set by @henryiii in https://github.com/wntrblm/nox/pull/872
+* Error with helpful message if invalid option is set via `nox.options` by @henryiii in https://github.com/wntrblm/nox/pull/871 (followup fix: https://github.com/wntrblm/nox/pull/921)
+* Validate entries in `nox.config`, too, using attrs by @henryiii in https://github.com/wntrblm/nox/pull/880
+
+Internal changes:
+
+* Add more Ruff checks and fixes by @henryiii in https://github.com/wntrblm/nox/pull/893
+* Cleanup symlink noxfile code by @henryiii in https://github.com/wntrblm/nox/pull/865
+* Drop some type ignores for colorlog by @henryiii in https://github.com/wntrblm/nox/pull/888
+* Limit the visible items for tab completion by @henryiii in https://github.com/wntrblm/nox/pull/889
+* More typing and test improvements by @henryiii in https://github.com/wntrblm/nox/pull/890
+* Some extra simplifications from Ruff by @henryiii in https://github.com/wntrblm/nox/pull/870
+* Use dependency-groups by @henryiii in https://github.com/wntrblm/nox/pull/873
+* Pull out env creation into helper method by @henryiii in https://github.com/wntrblm/nox/pull/912
+* Pulled out `get_virtualenv` & better typing by @henryiii in https://github.com/wntrblm/nox/pull/882
+* Fix broken mock on CPython 3.12.8+ in tests by @henryiii in https://github.com/wntrblm/nox/pull/903
+* Statically type tests by @henryiii in https://github.com/wntrblm/nox/pull/894
+* Use `tmp_path` instead of `tmpdir` in tests by @henryiii in https://github.com/wntrblm/nox/pull/895
+
+## 2024.10.09
+
+
+This release adds explicit support for Python 3.13 and drops support for running Nox itself under Python 3.7. Note that you can still use 3.7 in your Nox sessions, we just dropped support for installing & running `nox` itself in 3.7.
+
+We'd like to thank the following folks who contributed to this release:
+- @edgarrmondragon
+- @ember91
+- @henryiii
+- @hmd101
+- @KasperZutterman
+- @living180
+- @mayeut
+- @saucoide
+- @Wurstnase
+
+New features:
+* Allow setting tags on parametrized sessions by @living180 in https://github.com/wntrblm/nox/pull/832
+* Added support for `uv`-installed pythons by @saucoide in https://github.com/wntrblm/nox/pull/842
+* Added `session.install_and_run_script` by @henryiii in https://github.com/wntrblm/nox/pull/847
+
+Bugfixes:
+* Updated type annotation of `stderr` parameter to make it optional by @edgarrmondragon in https://github.com/wntrblm/nox/pull/835
+* Removed `add_timestamp` from `noxfile.options` by @Wurstnase in https://github.com/wntrblm/nox/pull/856
+
+Documentation:
+* Added warning about uv and local packages by @henryiii in https://github.com/wntrblm/nox/pull/830
+* Fixed contribution guidelines by @Wurstnase in https://github.com/wntrblm/nox/pull/850
+* Fixed typos by @ember91 in https://github.com/wntrblm/nox/pull/839
+* Fixed typos in cookbook by @hmd101 in https://github.com/wntrblm/nox/pull/837
+* Added missing cookbook recipe snippet imports by @KasperZutterman in https://github.com/wntrblm/nox/pull/853
+* Cleaned up `dev` recipe by @henryiii in https://github.com/wntrblm/nox/pull/862
+* Added note about `uv reinstall` by @henryiii in https://github.com/wntrblm/nox/pull/863
+* Added `uv sync` example by @henryiii in https://github.com/wntrblm/nox/pull/864
+
+Internal changes:
+* Use default action tests for macos-14 by @mayeut in https://github.com/wntrblm/nox/pull/824
+* Added 3.13 to the action defaults by @henryiii in https://github.com/wntrblm/nox/pull/846
+* Dropped Python 3.7 by @henryiii in https://github.com/wntrblm/nox/pull/822
+* Included 3.13 in classifiers by @henryiii in https://github.com/wntrblm/nox/pull/851
+* Use `uv` if available in action by @henryiii in https://github.com/wntrblm/nox/pull/831
+* Run tests with Python 3.13 by @edgarrmondragon in https://github.com/wntrblm/nox/pull/834
+* Updated `macos-latest` to `macos-14` by @henryiii in https://github.com/wntrblm/nox/pull/821
+* Use `miniforge` by @henryiii in https://github.com/wntrblm/nox/pull/854
+* Use `astral-sh/setup-uv` by @henryiii in https://github.com/wntrblm/nox/pull/859
+* Dropped PyPy 3.9 from test matrix by @henryiii in https://github.com/wntrblm/nox/pull/858
+
+
+## 2024.04.15
+
+We'd like to thank the following folks who contributed to this release:
+- @cjolowicz
+- @henryiii
+- @mayeut
+
+New features:
+* Added support for [PEP 723](https://peps.python.org/pep-0723/) (inline script metadata) with `nox.project.load_toml` by @henryiii in https://github.com/wntrblm/nox/pull/811
+* Added support for `micromamba` by @henryiii in https://github.com/wntrblm/nox/pull/807
+* Added `venv_backend` property to sessions by @henryiii in https://github.com/wntrblm/nox/pull/798
+* Added the ability to use `None` to remove environment variables by @henryiii in https://github.com/wntrblm/nox/pull/812
+* Added support for skipping sessions by default using `default=False` by @henryiii in https://github.com/wntrblm/nox/pull/810
+
+Bugfixes:
+* Use static arguments instead of `**kwargs` by @henryiii in https://github.com/wntrblm/nox/pull/815
+* Do not depend on `pipx` in Nox GitHub action by @mayeut in https://github.com/wntrblm/nox/pull/768
+* Disallow `UV_SYSTEM_PYTHON` by @henryiii in https://github.com/wntrblm/nox/pull/817
+* Ensure 'uv' always works in a uv venv by @henryiii in https://github.com/wntrblm/nox/pull/818
+* Look for `uv` next to `python` if it's not on `PATH` by @cjolowicz in https://github.com/wntrblm/nox/pull/795
+* Fixed missing f-string in `--help` message by @cjolowicz in https://github.com/wntrblm/nox/pull/790
+* Don't error if not installing to passthrough by @henryiii in https://github.com/wntrblm/nox/pull/809
+* Avoid mixing `venv` and `conda` from environment by @henryiii in https://github.com/wntrblm/nox/pull/804
+* Skip test for conda env when `conda` isn't installed by @cjolowicz in https://github.com/wntrblm/nox/pull/794
+
+
+## 2024.03.02
+
+We'd like to thank the following folks who contributed to this release:
+- @DiddiLeija
+- @MicaelJarniac
+- @chrysle
+- @edgarrmondragon
+- @fazledyn-or
+- @franekmagiera
+- @frenzymadness
+- @henryiii
+- @johnthagen
+- @mayeut
+- @patrick91
+- @q0w
+- @samypr100
+- @scop
+- @stasfilin
+- @stefanv
+
+New Features:
+* Add `uv` backend by @henryiii in https://github.com/wntrblm/nox/pull/762
+* Add venv backend fallback by @henryiii in https://github.com/wntrblm/nox/pull/787
+* Add option `--reuse-venv {yes,no,never,always}` by @samypr100 in https://github.com/wntrblm/nox/pull/730
+* Add environment variable `NOX_DEFAULT_VENV_BACKEND` for default backend by @edgarrmondragon in https://github.com/wntrblm/nox/pull/780
+* Rename `session.run_always` to `session.run_install` by @henryiii in https://github.com/wntrblm/nox/pull/770
+* Add more option argument completions by @scop in https://github.com/wntrblm/nox/pull/707
+* Implement `tox-to-nox` for tox 4 by @frenzymadness in https://github.com/wntrblm/nox/pull/687
+* Allow `--force-python` on unparametrized sessions by @chrysle in https://github.com/wntrblm/nox/pull/756
+* Add `include_outer_env` parameter to `session.run` and friends by @franekmagiera in https://github.com/wntrblm/nox/pull/652
+* GitHub Action: Add support for the `~` version specifier by @mayeut in https://github.com/wntrblm/nox/pull/712
+
+Bugfixes:
+* Rebuild environment when changing to an incompatible backend type by @henryiii in https://github.com/wntrblm/nox/pull/781
+* Warn user when first argument to `session.run` is a list by @stefanv in https://github.com/wntrblm/nox/pull/786
+* Allow overriding `nox.options.sessions` with `--tags` by @q0w in https://github.com/wntrblm/nox/pull/684
+* Allow overriding `NO_COLOR` with `--force-color` by @stasfilin in https://github.com/wntrblm/nox/pull/723
+* Fix `nox.options.error_on_missing_interpreters` when running in CI by @samypr100 in https://github.com/wntrblm/nox/pull/725
+
+Documentation Improvements:
+* Create an official Nox badge by @johnthagen in https://github.com/wntrblm/nox/pull/714 and https://github.com/wntrblm/nox/pull/715
+* Add recipe for generating a matrix with GitHub Actions by @henryiii in https://github.com/wntrblm/nox/pull/696
+* Update some links by @henryiii in https://github.com/wntrblm/nox/pull/774
+
+Internal Changes:
+* fix: always pull versions from metadata by @henryiii in https://github.com/wntrblm/nox/pull/782
+* chore: ruff moved to astral-sh by @henryiii in https://github.com/wntrblm/nox/pull/722
+* Use double quotes instead of single in github actions examples by @patrick91 in https://github.com/wntrblm/nox/pull/724
+* tests: fixes when running locally by @henryiii in https://github.com/wntrblm/nox/pull/721
+* chore: modernize Ruff config, bump pre-commit by @henryiii in https://github.com/wntrblm/nox/pull/744
+* chore(deps): bump actions/checkout from 3 to 4 by @dependabot in https://github.com/wntrblm/nox/pull/738
+* chore: ruff-format by @henryiii in https://github.com/wntrblm/nox/pull/745
+* chore(action): update default python-versions by @mayeut in https://github.com/wntrblm/nox/pull/767
+* chore(ci): allow manual runs on branches without a PR by @mayeut in https://github.com/wntrblm/nox/pull/766
+* chore(ci): bump actions/setup-python & conda-incubator/setup-miniconda by @mayeut in https://github.com/wntrblm/nox/pull/765
+* ci: group dependabot updates by @henryiii in https://github.com/wntrblm/nox/pull/755
+* fix(types): improve typing by @henryiii in https://github.com/wntrblm/nox/pull/720
+* ci: fix coverage combine for different OS's by @henryiii in https://github.com/wntrblm/nox/pull/778
+* ci: update to artifacts v2 by @henryiii in https://github.com/wntrblm/nox/pull/772
+* ci: remove skipped job & combine (faster) by @henryiii in https://github.com/wntrblm/nox/pull/771
+* chore: cleanup Ruff a bit by @henryiii in https://github.com/wntrblm/nox/pull/783
+* chore(deps): bump the actions group with 2 updates by @dependabot in https://github.com/wntrblm/nox/pull/784
+* style: add type hints, update few functions by @stasfilin in https://github.com/wntrblm/nox/pull/728
+* Include Python 3.12 in GHA by @DiddiLeija in https://github.com/wntrblm/nox/pull/743
+* Allow tests to pass in environments where NO_COLOR=1 is set by @edgarrmondragon in https://github.com/wntrblm/nox/pull/777
+* tests: support running when the Python launcher for UNIX is present by @henryiii in https://github.com/wntrblm/nox/pull/775
+* chore: drop unneeded config option by @henryiii in https://github.com/wntrblm/nox/pull/773
+* Bump minimum virtualenv to 20.14.1 by @johnthagen in https://github.com/wntrblm/nox/pull/747
+* chore: save session name in `Func` by @MicaelJarniac in https://github.com/wntrblm/nox/pull/718
+* Removed the problematic Python 2.7.18 version by @stasfilin in https://github.com/wntrblm/nox/pull/726
+* Fixed Improper Method Call: Replaced `NotImplementedError` by @fazledyn-or in https://github.com/wntrblm/nox/pull/749
+
+
+## 2023.04.22
+
+We'd like to thank the following folks who contributed to this release:
+- @crwilcox
+- @dcermak
+- @edgarrmondragon
+- @FollowTheProcess
+- @henryiii
+- @reaperhulk
+- @scop
+
+New Features:
+- Add support for `NOXPYTHON`, `NOXEXTRAPYTHON` and `NOXFORCEPYTHON` by @edgarrmondragon in https://github.com/wntrblm/nox/pull/688
+- feat: --json --list-sessions by @henryiii in https://github.com/wntrblm/nox/pull/665
+
+Documentation Improvements:
+- style: spelling and grammar fixes by @scop in https://github.com/wntrblm/nox/pull/682
+- Add invite link to the discord server to CONTRIBUTING.md by @dcermak in https://github.com/wntrblm/nox/pull/679
+
+Internal Changes:
+- chore: update pre-commit hooks by @edgarrmondragon in https://github.com/wntrblm/nox/pull/690
+- chore: move to using Ruff by @henryiii in https://github.com/wntrblm/nox/pull/691
+- Fix assertion in GHA tests by @FollowTheProcess in https://github.com/wntrblm/nox/pull/670
+- ci: some minor fixes by @henryiii in https://github.com/wntrblm/nox/pull/675
+- Constrain tox to <4.0.0 and minor fixes by @FollowTheProcess in https://github.com/wntrblm/nox/pull/677
+- chore: long term fix for bugbear opinionated checks by @henryiii in https://github.com/wntrblm/nox/pull/678
+- chore: switch to hatchling by @henryiii in https://github.com/wntrblm/nox/pull/659
+- Don't run python 2.7 virtualenv tests for newer versions of virtualenv by @crwilcox in https://github.com/wntrblm/nox/pull/702
+- allow the use of argcomplete 3 by @reaperhulk in https://github.com/wntrblm/nox/pull/700
+- fix: enable `list_sessions` for session completion by @scop in https://github.com/wntrblm/nox/pull/699
+- chore: remove 3.6 tests, min version is 3.7 by @crwilcox in https://github.com/wntrblm/nox/pull/703
+
+
+## 2022.11.21
+
+We'd like to thank the following folks who contributed to this release:
+- @airtower-luna
+- @DiddiLeija
+- @FollowTheProcess
+- @henryiii
+- @hynek
+- @Julian
+- @nhtsai
+- @paw-lu
+
+New features:
+- Include Python 3.11 classifier & testing (#655)
+
+Improvements:
+- Fixed a few typos (#661, #660)
+- Drop dependency on `py` (#647)
+- `nox.session.run` now accepts a `pathlib.Path` for the command (#649)
+- Document `nox.session.run`'s `stdout` and `stderr` arguments and add example of capturing output (#651)
+
+Bugfixes:
+- GitHub Action: replace deprecated set-output command (#668)
+- GitHub Action: point docs to 2022.8.7 not latest (#664)
+- Docs: fix argument passing in `session.posargs` example (#653)
+- Include GitHub action helper in `MANIFEST.in` (#645)
+
+Internal changes:
+- GitHub Action: move to 3.11 final (#667)
+- Cleanup Python 2 style code (#657)
+- Update tools used in pre-commit (#646, #656)
+
+
+## 2022.8.7
+
+We'd like to thank the following folks who contributed to this release:
+- @CN-M
+- @crwilcox
+- @DiddiLeija
+- @edgarrmondragon
+- @FollowTheProcess
+- @hauntsaninja
+- @henryiii
+- @johnthagen
+- @jwodder
+- @ktbarrett
+- @mayeut
+- @meowmeowmeowcat
+- @NickleDave
+- @raddessi
+- @zhanpon
+
+Removals:
+- Drop support for Python 3.6 (#526)
+- Disable running `session.install` outside a venv (#580)
+
+New features:
+- Official Nox GitHub Action (#594, #606, #609, #620, #629, #637, #632, #633)
+- Missing interpreters now error the session on CI by default (#567)
+- Allow configurable child shutdown timeouts (#565)
+- Add session tags (#627)
+- Add short `-N` alias for `--no-reuse-existing-virtualenvs` (#639)
+- Export session name in `NOX_CURRENT_SESSION` environment variable (#641)
+
+Improvements:
+- Add `VENV_DIR` to `dev` session in cookbook (#591)
+- Fix typo in `tutorial.rst` (#586)
+- Use consistent spelling for Nox in documentation (#581)
+- Support descriptions in `tox-to-nox` (#575)
+- Document that `silent=True` returns the command output (#578)
+- Support argcomplete v2 (#564)
+
+Bugfixes:
+- Fix incorrect `FileNotFoundError` in `load_nox_module` (#571)
+
+Internal changes:
+- Update the classifiers, documentation, and more to point to the new Winterbloom location (#587)
+- Support PEP 621 (`pyproject.toml`) (#616, #619)
+- Configure language code to avoid warning on sphinx build (#626)
+- Use latest GitHub action runners and include macOS (#613)
+- Jazz up the README with some badges/logo etc. (#605, #614)
+- Prefer type checking against Jinja2 (#610)
+- Introduce GitHub issue forms (#600, #603, #608)
+- Full strictness checking on mypy (#595, #596)
+- Drop 99% coverage threshold flag for 3.10 in noxfile (#593)
+- Create a `requirements-dev.txt` (#582)
+- Use `myst-parser` for Markdown docs (#561)
+
+## 2022.1.7
+
+Claudio Jolowicz, Diego Ramirez, and Tom Fleet have become maintainers of Nox. We'd like to thank the following folks who contributed to this release:
+
+- @brettcannon
+- @cjolowicz
+- @dhermes
+- @DiddiLeija
+- @FollowTheProcess
+- @franekmagiera
+- @henryiii
+- @jugmac00
+- @maciej-lech
+- @nawatts
+- @Tolker-KU
+
+New features:
+- Add `mamba` backend (#444, #448, #546, #551)
+- Add `session.debug` to show debug-level messages (#489)
+- Add cookbook page to the documentation (#483)
+- Add support for the `FORCE_COLOR` environment variable (#524, #548)
+- Allow using `session.chdir()` as a context manager (#543)
+- Deprecate use of `session.install()` without a valid backend (#537)
+
+Improvements:
+- Test against Python 3.10 (#495, #502, #506)
+- Add support for the `channel` option when using the `conda` backend (#522)
+- Show more specific error message when the `--keywords` expression contains a syntax error (#493)
+- Include reference to `session.notify()` in tutorial page (#500)
+- Document how `session.run()` fails and how to handle failures (#533)
+- Allow the list of sessions to be empty (#523)
+
+Bugfixes:
+- Fix broken temporary directory when using `session.chdir()` (#555, #556)
+- Set the `CONDA_PREFIX` environment variable (#538)
+- Fix `bin` directory for the `conda` backend on Windows (#535)
+
+Internal changes:
+- Replace deprecated `load_module` with `exec_module` (#498)
+- Include tests with source distributions (#552)
+- Add missing copyright notices (#509)
+- Use the new ReadTheDocs configurations (#527)
+- Bump the Python version used by ReadTheDocs to 3.8 (#496)
+- Improve the Sphinx config file (#499)
+- Update all linter versions (#528)
+- Add pre-commit and new checks (#530, #539)
+- Check `MANIFEST.in` during CI (#552)
+- Remove redundant `LICENSE` from `MANIFEST.in` (#505)
+- Make `setuptools` use the standard library's `distutils` to work around `virtualenv` bug. (#547, #549)
+- Use `shlex.join()` when logging a command (#490)
+- Use `shutil.rmtree()` over shelling out to `rm -rf` in noxfile (#519)
+- Fix missing Python 3.9 CI session (#529)
+- Unpin docs session and add `--error-on-missing-interpreter` to CI (#532)
+- Enable color output from Nox, pytest, and pre-commit during CI (#542)
+- Only run `conda_tests` session by default if user has conda installed (#521)
+- Update dependencies in `requirements-conda-test.txt` (#536)
+
+
+## 2021.10.1
+
+New features:
+- Add `session.warn` to output warnings (#482)
+- Add a shared session cache directory (#476)
+- Add `session.invoked_from` (#472)
+
+Improvements:
+- Conda logs now respect `nox.options.verbose` (#466)
+- Add `session.notify` example to docs (#467)
+- Add friendlier message if no `noxfile.py` is found (#463)
+- Show the `noxfile.py` docstring when using `nox -l` (#459)
+- Mention more projects that use Nox in the docs (#460)
+
+Internal changes:
+- Move configs into pyproject.toml or setup.cfg (flake8) (#484)
+- Decouple `test_session_completer` from project level noxfile (#480)
+- Run Flynt to convert str.format to f-strings (#464)
+- Add python 3.10.0-rc2 to GitHub Actions (#475, #479)
+- Simplify CI build (#461)
+- Use PEP 517 build system, remove `setup.py`, use `setup.cfg` (#456, #457, #458)
+- Upgrade to mypy 0.902 (#455)
+
+Special thanks to our contributors:
+- @henryiii
+- @cjolowicz
+- @FollowTheProcess
+- @franekmagiera
+- @DiddiLeija
+
+## 2021.6.12
+
+- Fix crash on Python 2 when reusing environments. (#450)
+- Hide staleness check behind a feature flag. (#451)
+- Group command-line options in `--help` message by function. (#442)
+- Avoid polluting tests with a .nox directory. (#445)
+
## 2021.6.6
- Add option `--no-install` to skip install commands in reused environments. (#432)
@@ -31,7 +673,7 @@
- Support users specifying an undeclared parametrization of python via `--extra-python` (#361)
- Support double-digit minor version in `python` keyword (#367)
- Add `py.typed` to `manifest.in` (#360)
-- Update nox to latest supported python versions. (#362)
+- Update Nox to latest supported python versions. (#362)
- Decouple merging of `--python` with `nox.options` from `--sessions` and `--keywords` (#359)
- Do not merge command-line options in place (#357)
@@ -85,7 +727,7 @@
- Add `venv` as an option for `venv_backend`. (#231)
- Add gdbgui to list of projects. (#235)
- Add mypy to Nox's lint. (#230)
-- Add pipx to projects that use nox. (#225)
+- Add pipx to projects that use Nox. (#225)
- Add `session(venv_backend='conda')` option to use Conda environments. (#217, #221)
- Document how to call builtins on Windows. (#223)
- Replace `imp.load_source()` with `importlib`. (#214)
@@ -235,7 +877,7 @@ Other changes:
## v0.19.0
* Add missing parameter in docs (#89)
-* Don't skip install commands when re-using existing virtualenvs. (#86)
+* Don't skip install commands when reusing existing virtualenvs. (#86)
* Add --nocolor and --forcecolor options (#85)
* Simulating `unittest.mock` backport in the Python 2 standard library. (#81)
* Fixing tox-to-nox docs reference. (#80)
@@ -250,7 +892,7 @@ Other changes:
## v0.18.1
-* Fix nox not returning a non-zero exit code on failure. (#55)
+* Fix Nox not returning a non-zero exit code on failure. (#55)
* Restore result and report output. (#57)
## v0.18.0
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 9c0865ad..12d19bc6 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -6,7 +6,7 @@ In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
-level of experience, education, socio-economic status, nationality, personal
+level of experience, education, socioeconomic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a8a4069b..2448c5d0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -3,15 +3,17 @@
Thank you for your interest in improving Nox. Nox is open-source under the
[Apache License Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) and welcomes contributions in the form of bug reports, feature requests, and pull requests.
-Nox is hosted on [GitHub](https://github.com/theacodes/nox).
+Nox is hosted on [GitHub](https://github.com/wntrblm/nox).
## Support, questions, and feature requests
-Feel free to file a bug on [GitHub](https://github.com/theacodes/nox).
+Feel free to file a bug or feature request on [GitHub](https://github.com/wntrblm/nox). If your question is more general or does not fit neatly into one of those categories, we also have a Nox channel on the [Winterbloom Discord server](https://discord.com/invite/UpfqghQ).
+
+You should find a permalink to the invite when you raise a new issue on GitHub.
## Reporting issues
-File a bug on [GitHub](https://github.com/theacodes/nox). To help us figure out what's going on, please err on the
+File a bug on [GitHub](https://github.com/wntrblm/nox). To help us figure out what's going on, please err on the
side of including lots of information, such as:
* Operating system.
@@ -24,12 +26,13 @@ side of including lots of information, such as:
chance to talk it over with the owners and validate your approach.
* Nox maintains 100% test coverage. All pull requests must maintain this.
* Follow [pep8](https://pep8.org).
-* Update documentation, if relevant.
+* Update documentation and tests if relevant.
+* Ensure your changes pass Nox's tests and lint suites before pushing.
## Running tests
Nox runs its own tests (it's recursive!). The best thing to do is start with
-a known-good nox installation, e.g. from PyPI:
+a known-good Nox installation, e.g. from PyPI:
pip install --pre --upgrade nox
@@ -39,19 +42,22 @@ To just check for lint errors, run:
To run against a particular Python version:
- nox --session tests-3.6
- nox --session tests-3.7
- nox --session tests-3.8
- nox --session tests-3.9
-
+ nox --session tests-3.12
+ nox --session conda_tests
+ nox --session mamba_tests
+ nox --session micromamba_tests
When you send a pull request the CI will handle running everything, but it is
recommended to test as much as possible locally before pushing.
+You can list all possible tests with:
+
+ nox --list-sessions
+
## Getting a sticker
If you've contributed to Nox, you can get a cute little Nox sticker. Reach out to Thea at me@thea.codes to request one.
## Getting paid
-Contributions to Nox can be expensed through [our Open Collective](https://opencollective.com/python-nox). The maintainers will let you know when and for how much you can expense contributions, but always feel free to ask.
+Contributions to Nox can be expensed through [our Open Collective](https://opencollective.com/python-nox). The maintainers will let you know when and for how much you can expense contributions, but always feel free to ask.
diff --git a/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index 053167fa..00000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,3 +0,0 @@
-include LICENSE
-recursive-include nox *.jinja2
-include nox/py.typed
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..d4e50345
--- /dev/null
+++ b/README.md
@@ -0,0 +1,99 @@
+
+
+
+
+# Nox
+
+[](https://github.com/wntrblm/nox)
+[](https://github.com/wntrblm/nox)
+[](https://pypi.python.org/pypi/nox)
+[](https://github.com/wntrblm/nox)
+[](https://github.com/wntrblm/nox)
+[](https://github.com/wntrblm/nox/actions?query=workflow%3ACI)
+[](https://pepy.tech/project/nox)
+
+*Flexible test automation with Python*
+
+* **Documentation:** [https://nox.readthedocs.io](https://nox.readthedocs.io)
+
+* **Source Code:** [https://github.com/wntrblm/nox](https://github.com/wntrblm/nox)
+
+## Overview
+
+`nox` is a command-line tool that automates testing in multiple Python environments, similar to [tox][]. Unlike tox, Nox uses a standard Python file for configuration:
+
+```python
+import nox
+
+
+@nox.session
+def tests(session: nox.Session) -> None:
+ session.install("pytest")
+ session.run("pytest")
+
+@nox.session
+def lint(session: nox.Session) -> None:
+ session.install("flake8")
+ session.run("flake8", "--import-order-style", "google")
+```
+
+## Installation
+
+Nox is designed to be installed globally (not in a project virtual environment), the recommended way of doing this is via [pipx], a tool designed to install python CLI programs whilst keeping them separate from your global or system python.
+
+To install Nox with [pipx][]:
+
+```shell
+pipx install nox
+```
+
+You can also use [pip][] in your global python:
+
+```shell
+python3 -m pip install nox
+```
+
+You may want to use the [user-site][] to avoid messing with your Global python install:
+
+```shell
+python3 -m pip install --user nox
+```
+
+## Usage
+
+### List all sessions
+
+```shell
+nox -l/--list
+```
+
+### Get help on a particular session (if it has a docstring)
+
+```shell
+nox --usage test
+```
+
+### Run all sessions
+
+```shell
+nox
+```
+
+### Run a particular session
+
+```shell
+nox -s/--session test
+```
+
+Checkout the [docs](https://nox.readthedocs.io) for more! 🎉
+
+## Contributing
+
+Nox is an open source project and welcomes contributions of all kinds, checkout the [contributing guide](CONTRIBUTING.md) for help on how to help us out!
+
+All contributors must follow the [code of conduct](CODE_OF_CONDUCT.md) and be nice to one another! 😃
+
+[tox]: https://tox.readthedocs.io
+[pipx]: https://pypa.github.io/pipx/
+[pip]: https://pip.pypa.io/en/stable/
+[user-site]: https://packaging.python.org/en/latest/tutorials/installing-packages/#installing-to-the-user-site
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 01ddf2d9..00000000
--- a/README.rst
+++ /dev/null
@@ -1,15 +0,0 @@
-Nox - Flexible test automation for Python
-=========================================
-
-``nox`` is a command-line tool that automates testing in multiple Python
-environments, similar to `tox`_. Unlike tox, Nox uses a standard Python
-file for configuration.
-
-The latest documentation is available on `Read the Docs`_.
-
-The source code is available on `GitHub`_.
-
-
-.. _tox: https://tox.readthedocs.io
-.. _Read the Docs: https://nox.readthedocs.io
-.. _GitHub: https://github.com/theacodes/nox
diff --git a/action.yml b/action.yml
new file mode 100644
index 00000000..8e3d554f
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,81 @@
+name: Setup Nox
+description: "Prepares all python versions for nox"
+inputs:
+ python-versions:
+ description: "comma-separated list of python versions to install"
+ required: false
+ default: "3.10, 3.11, 3.12, 3.13, 3.14"
+branding:
+ icon: package
+ color: blue
+
+runs:
+ using: composite
+ steps:
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ id: localpython
+ with:
+ python-version: "3.10 - 3.14"
+ update-environment: false
+
+ - name: "Validate input"
+ id: helper
+ run: >
+ "${STEPS_LOCALPYTHON_OUTPUTS_PYTHON_PATH}" "${{ github.action_path }}/.github/action_helper.py" "${INPUTS_PYTHON_VERSIONS}" >> "${GITHUB_OUTPUT}"
+ shell: bash
+ env:
+ STEPS_LOCALPYTHON_OUTPUTS_PYTHON_PATH: ${{ steps.localpython.outputs.python-path }}
+ INPUTS_PYTHON_VERSIONS: ${{ inputs.python-versions }}
+
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ id: allpython
+ with:
+ python-version: "${{ join(fromJSON(steps.helper.outputs.interpreters), '\n') }}"
+ allow-prereleases: true
+
+ - name: "Install nox"
+ run: |
+ import os
+ import shutil
+ import sys
+ import venv
+
+ from pathlib import Path
+ from subprocess import run
+
+
+ class EnvBuilder(venv.EnvBuilder):
+ def __init__(self):
+ super().__init__()
+
+ def setup_scripts(self, context):
+ pass
+
+ def post_setup(self, context):
+ super().post_setup(context)
+ self.bin_path = Path(context.env_exe).parent
+ if shutil.which("uv") is None:
+ run([sys.executable, "-m", "pip", "--python", context.env_exe, "install", r"${{ github.action_path }}"], check=True)
+ else:
+ run(["uv", "pip", "install", "--python", context.env_exe, r"${{ github.action_path }}"], check=True)
+
+
+ print("::group::Install nox")
+ nox_bin_path = Path(r"${{ runner.temp }}") / "nox"
+ if nox_bin_path.exists():
+ shutil.rmtree(nox_bin_path)
+ venv_path = Path(r"${{ runner.temp }}") / "nox-venv"
+ if venv_path.exists():
+ shutil.rmtree(venv_path)
+ builder = EnvBuilder()
+ builder.create(venv_path)
+ nox_path = [path for path in builder.bin_path.glob("nox*") if path.stem == "nox"][0]
+ nox_bin_path.mkdir()
+ try:
+ os.symlink(nox_path, nox_bin_path / nox_path.name)
+ except OSError:
+ shutil.copyfile(nox_path, nox_bin_path / nox_path.name)
+ with open(os.environ["GITHUB_PATH"], "at") as f:
+ f.write(f"{nox_bin_path}\n")
+ print("::endgroup::")
+ shell: "${{ steps.allpython.outputs.python-path }} -u {0}"
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
index 101a1333..399bb9c3 100644
--- a/docs/_static/custom.css
+++ b/docs/_static/custom.css
@@ -120,7 +120,7 @@ div.footer::before {
div.footer {
text-align: center;
- color: #029be2;
+ color: #029be2;
}
div.footer a {
diff --git a/docs/conf.py b/docs/conf.py
index a7029aaf..91af37e2 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,6 +1,5 @@
-# -*- coding: utf-8 -*-
#
-# nox documentation build configuration file, created by
+# Nox documentation build configuration file, created by
# sphinx-quickstart on Sun Feb 28 00:05:25 2016.
#
# This file is execfile()d with the current directory set to its
@@ -12,62 +11,60 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
+from __future__ import annotations
+
import os
import sys
-
-try:
- import importlib.metadata as metadata
-except ImportError:
- import importlib_metadata as metadata
+from importlib import metadata
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
-# Note: even though nox is installed when the docs are built, there's a
+# Note: even though Nox is installed when the docs are built, there's a
# possibility it's installed as a bytecode-compiled binary (.egg). So,
# include the source anyway.
-sys.path.insert(0, os.path.abspath('..'))
-sys.path.insert(0, os.path.abspath('.'))
+sys.path.insert(0, os.path.abspath(".."))
+sys.path.insert(0, os.path.abspath("."))
# -- General configuration ------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
-#needs_sphinx = '1.0'
+# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
- 'sphinx.ext.autodoc',
- 'sphinx.ext.napoleon',
- 'recommonmark',
+ "sphinx.ext.autodoc",
+ "sphinx.ext.napoleon",
+ "myst_parser",
+ "sphinx_tabs.tabs",
]
# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
-# source_suffix = ['.rst', '.md']
-source_suffix = ['.rst', '.md']
+source_suffix = [".rst", ".md"]
# The encoding of source files.
-#source_encoding = 'utf-8-sig'
+# source_encoding = 'utf-8-sig'
# The master toctree document.
-master_doc = 'index'
+master_doc = "index"
# General information about the project.
-project = u'Nox'
-copyright = u'2016, Alethea Katherine Flowers'
-author = u'Alethea Katherine Flowers'
+project = "Nox"
+copyright = "2016, Alethea Katherine Flowers"
+author = "Alethea Katherine Flowers"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
-version = metadata.version('nox')
+version = metadata.version("nox")
# The full version, including alpha/beta/rc tags.
release = version
@@ -76,24 +73,24 @@
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
-language = None
+language = "en"
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
-#today = ''
+# today = ''
# Else, today_fmt is used as the format for a strftime call.
-#today_fmt = '%B %d, %Y'
+# today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
-exclude_patterns = ['_build']
+exclude_patterns = ["_build"]
# The reST default role (used for this markup: `text`) to use for all
# documents.
-#default_role = None
+# default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
-#add_function_parentheses = True
+# add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
@@ -101,16 +98,16 @@
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
-#show_authors = False
+# show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'witchhazel.WitchHazelStyle'
+pygments_style = "witchhazel.WitchHazelStyle"
# A list of ignored prefixes for module index sorting.
-#modindex_common_prefix = []
+# modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
-#keep_warnings = False
+# keep_warnings = False
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = False
@@ -120,179 +117,172 @@
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
-html_theme = 'alabaster'
+html_theme = "alabaster"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
html_theme_options = {
- 'logo': 'alice.png',
- 'logo_name': True,
- 'description': 'Flexible test automation',
- 'github_user': 'theacodes',
- 'github_repo': 'nox',
- 'github_banner': True,
- 'github_button': False,
- 'travis_button': False,
- 'codecov_button': False,
- 'analytics_id': False, # TODO
- 'font_family': "'Roboto', Georgia, sans",
- 'head_font_family': "'Roboto', Georgia, serif",
- 'code_font_family': "'Roboto Mono', 'Consolas', monospace",
- 'pre_bg': '#433e56'
+ "logo": "alice.png",
+ "logo_name": True,
+ "description": "Flexible test automation",
+ "github_user": "wntrblm",
+ "github_repo": "nox",
+ "github_banner": True,
+ "github_button": False,
+ "travis_button": False,
+ "codecov_button": False,
+ "analytics_id": False, # TODO
+ "font_family": "'Roboto', Georgia, sans",
+ "head_font_family": "'Roboto', Georgia, serif",
+ "code_font_family": "'Roboto Mono', 'Consolas', monospace",
+ "pre_bg": "#433e56",
}
# Add any paths that contain custom themes here, relative to this directory.
-#html_theme_path = []
+# html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# " v documentation".
-#html_title = None
+# html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
-#html_short_title = None
+# html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
-#html_logo = None
+# html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
-#html_favicon = None
+# html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation.
-#html_extra_path = []
+# html_extra_path = []
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
-#html_last_updated_fmt = '%b %d, %Y'
+# html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
-#html_use_smartypants = True
+# html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
html_sidebars = {
- '**': [
- 'about.html',
- 'navigation.html',
- 'relations.html',
- 'searchbox.html',
- 'donate.html',
+ "**": [
+ "about.html",
+ "navigation.html",
+ "relations.html",
+ "searchbox.html",
+ "donate.html",
]
}
# Additional templates that should be rendered to pages, maps page names to
# template names.
-#html_additional_pages = {}
+# html_additional_pages = {}
# If false, no module index is generated.
-#html_domain_indices = True
+# html_domain_indices = True
# If false, no index is generated.
-#html_use_index = True
+# html_use_index = True
# If true, the index is split into individual pages for each letter.
-#html_split_index = False
+# html_split_index = False
# If true, links to the reST sources are added to the pages.
-#html_show_sourcelink = True
+# html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-#html_show_sphinx = True
+# html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-#html_show_copyright = True
+# html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
-#html_use_opensearch = ''
+# html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
-#html_file_suffix = None
+# html_file_suffix = None
# Language to be used for generating the HTML full-text search index.
# Sphinx supports the following languages:
# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja'
# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr'
-#html_search_language = 'en'
+# html_search_language = 'en'
# A dictionary with options for the search language support, empty by default.
# Now only 'ja' uses this config value
-#html_search_options = {'type': 'default'}
+# html_search_options = {'type': 'default'}
# The name of a javascript file (relative to the configuration directory) that
# implements a search results scorer. If empty, the default will be used.
-#html_search_scorer = 'scorer.js'
+# html_search_scorer = 'scorer.js'
# Output file base name for HTML help builder.
-htmlhelp_basename = 'noxdoc'
+htmlhelp_basename = "noxdoc"
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
-# The paper size ('letterpaper' or 'a4paper').
-#'papersize': 'letterpaper',
-
-# The font size ('10pt', '11pt' or '12pt').
-#'pointsize': '10pt',
-
-# Additional stuff for the LaTeX preamble.
-#'preamble': '',
-
-# Latex figure (float) alignment
-#'figure_align': 'htbp',
+ # The paper size ('letterpaper' or 'a4paper').
+ # 'papersize': 'letterpaper',
+ # The font size ('10pt', '11pt' or '12pt').
+ # 'pointsize': '10pt',
+ # Additional stuff for the LaTeX preamble.
+ # 'preamble': '',
+ # Latex figure (float) alignment
+ # 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
- (master_doc, 'nox.tex', u'nox Documentation',
- u'Alethea Katherine Flowers', 'manual'),
+ (master_doc, "nox.tex", "Nox Documentation", "Alethea Katherine Flowers", "manual"),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
-#latex_logo = None
+# latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
-#latex_use_parts = False
+# latex_use_parts = False
# If true, show page references after internal links.
-#latex_show_pagerefs = False
+# latex_show_pagerefs = False
# If true, show URL addresses after external links.
-#latex_show_urls = False
+# latex_show_urls = False
# Documents to append as an appendix to all manuals.
-#latex_appendices = []
+# latex_appendices = []
# If false, no module index is generated.
-#latex_domain_indices = True
+# latex_domain_indices = True
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
-man_pages = [
- (master_doc, 'nox', u'nox Documentation',
- [author], 1)
-]
+man_pages = [(master_doc, "nox", "Nox Documentation", [author], 1)]
# If true, show URL addresses after external links.
-#man_show_urls = False
+# man_show_urls = False
# -- Options for Texinfo output -------------------------------------------
@@ -301,19 +291,28 @@
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
- (master_doc, 'nox', u'nox Documentation',
- author, 'nox', 'One line description of project.',
- 'Miscellaneous'),
+ (
+ master_doc,
+ "nox",
+ "Nox Documentation",
+ author,
+ "nox",
+ "One line description of project.",
+ "Miscellaneous",
+ ),
]
# Documents to append as an appendix to all manuals.
-#texinfo_appendices = []
+# texinfo_appendices = []
# If false, no module index is generated.
-#texinfo_domain_indices = True
+# texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
-#texinfo_show_urls = 'footnote'
+# texinfo_show_urls = 'footnote'
# If true, do not generate a @detailmenu in the "Top" node's menu.
-#texinfo_no_detailmenu = False
+# texinfo_no_detailmenu = False
+
+# Disable tab closing by selecting the open tab
+sphinx_tabs_disable_tab_closing = True
diff --git a/docs/config.rst b/docs/config.rst
index d64d2f88..3c75b8f1 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -26,6 +26,10 @@ with ``@nox.session``. For example:
You can also configure sessions to run against multiple Python versions as described in :ref:`virtualenv config` and parametrize sessions as described in :ref:`parametrized sessions `.
+By default, all sessions will be run if no sessions are specified. You can
+remove sessions from this default list by passing ``default=False`` in the
+``@nox.session(...)`` decorator. You can also specify a list of sessions to run by
+default using the ``nox.options.sessions = [...]`` configuration option.
Session description
-------------------
@@ -50,6 +54,17 @@ The ``nox --list`` command will show:
Available sessions:
* tests -> Run the test suite.
+The ``--list`` command shows the first line of the docstring as the session's description. You can also
+show the full docstring of a session using the ``--usage`` option, especially if it has multiple lines.
+For example:
+
+.. code-block:: console
+
+ $ nox --usage tests
+ Run the test suite.
+
+ The test suite consists of all tests in tests/.
+
Session name
------------
@@ -99,8 +114,8 @@ You can tell Nox to use a different Python interpreter/version by specifying the
.. note::
The Python binaries on Windows are found via the Python `Launcher`_ for
- Windows (``py``). For example, Python 3.9 can be found by determining which
- executable is invoked by ``py -3.9``. If a given test needs to use the 32-bit
+ Windows (``py``). For example, Python 3.10 can be found by determining which
+ executable is invoked by ``py -3.10``. If a given test needs to use the 32-bit
version of a given Python, then ``X.Y-32`` should be used as the version.
.. _Launcher: https://docs.python.org/3/using/windows.html#python-launcher-for-windows
@@ -121,11 +136,13 @@ When you provide a version number, Nox automatically prepends python to determin
def tests(session):
pass
+If the specified python interpreter is not found, Nox can automatically download it when ``--download-python`` is set to ``auto`` (the default) or ``always``. ``never`` avoids the download. This requires the ``[pbs]`` extra when not using uv as a backend.
+
When collecting your sessions, Nox will create a separate session for each interpreter. You can see these sessions when running ``nox --list``. For example this Noxfile:
.. code-block:: python
- @nox.session(python=['2.7', '3.6', '3.7', '3.8', '3.9'])
+ @nox.session(python=['3.10', '3.11', '3.12', '3.13', '3.14'])
def tests(session):
pass
@@ -133,11 +150,11 @@ Will produce these sessions:
.. code-block:: console
- * tests-2.7
- * tests-3.6
- * tests-3.7
- * tests-3.8
- * tests-3.9
+ * tests-3.10
+ * tests-3.11
+ * tests-3.12
+ * tests-3.13
+ * tests-3.14
Note that this expansion happens *before* parameterization occurs, so you can still parametrize sessions with multiple interpreters.
@@ -149,7 +166,15 @@ If you want to disable virtualenv creation altogether, you can set ``python`` to
def tests(session):
pass
-You can also specify that the virtualenv should *always* be reused instead of recreated every time:
+Use of :func:`session.install()` is deprecated without a virtualenv since it modifies the global Python environment. If this is what you really want, use :func:`session.run()` and pip instead.
+
+.. code-block:: python
+
+ @nox.session(python=False)
+ def tests(session):
+ session.run("pip", "install", "nox")
+
+You can also specify that the virtualenv should *always* be reused instead of recreated every time unless ``--reuse-venv=never``:
.. code-block:: python
@@ -159,7 +184,7 @@ You can also specify that the virtualenv should *always* be reused instead of re
def tests(session):
pass
-You are not limited to virtualenv, there is a selection of backends you can choose from as venv, conda or virtualenv (default):
+You are not limited to virtualenv, there is a selection of backends you can choose from as venv, uv, conda, mamba, micromamba, or virtualenv (default):
.. code-block:: python
@@ -167,6 +192,27 @@ You are not limited to virtualenv, there is a selection of backends you can choo
def tests(session):
pass
+You can chain together optional backends with ``|``, such as ``uv|virtualenv``
+or ``micromamba|mamba|conda``, and the first available backend will be selected.
+You cannot put anything after a backend that can't be missing like ``venv`` or
+``virtualenv``.
+
+.. tip::
+
+ If a session targets an end-of-life Python version while Nox itself runs on
+ a newer Python, prefer the ``venv`` backend for that session. The
+ ``virtualenv`` backend runs from Nox's interpreter and may no longer support
+ bootstrapping older target interpreters after they reach end of life, which
+ can seed incompatible packages into the session environment. The ``venv``
+ backend uses the target interpreter's standard library ``venv`` module
+ instead.
+
+ .. code-block:: python
+
+ @nox.session(python='3.7', venv_backend='venv')
+ def tests(session):
+ pass
+
Finally, custom backend parameters are supported:
.. code-block:: python
@@ -175,6 +221,9 @@ Finally, custom backend parameters are supported:
def tests(session):
pass
+If you need to check to see which backend was selected, you can access it via
+``session.venv_backend``.
+
Passing arguments into sessions
-------------------------------
@@ -348,11 +397,11 @@ These two examples are equivalent:
.. code-block:: python
@nox.session
- @nox.parametrize("python", ["3.6", "3.7", "3.8"])
+ @nox.parametrize("python", ["3.10", "3.11", "3.12"])
def tests(session):
...
- @nox.session(python=["3.6", "3.7", "3.8"])
+ @nox.session(python=["3.10", "3.11", "3.12"])
def tests(session):
...
@@ -368,27 +417,89 @@ Pythons:
"python,dependency",
[
(python, dependency)
- for python in ("3.6", "3.7", "3.8")
+ for python in ("3.10", "3.11", "3.12")
for dependency in ("1.0", "2.0")
- if (python, dependency) != ("3.6", "2.0")
+ if (python, dependency) != ("3.10", "2.0")
],
)
def tests(session, dependency):
...
+Assigning tags to parametrized sessions
+---------------------------------------
+
+Just as tags can be :ref:`assigned to normal sessions `, they can also be assigned to parametrized sessions. The following examples are both equivalent:
+
+.. code-block:: python
+
+ @nox.session
+ @nox.parametrize('dependency',
+ ['1.0', '2.0'],
+ tags=[['old'], ['new']])
+ @nox.parametrize('database'
+ ['postgres', 'mysql'],
+ tags=[['psql'], ['mysql']])
+ def tests(session, dependency, database):
+ ...
+
+.. code-block:: python
+
+ @nox.session
+ @nox.parametrize('dependency', [
+ nox.param('1.0', tags=['old']),
+ nox.param('2.0', tags=['new']),
+ ])
+ @nox.parametrize('database', [
+ nox.param('postgres', tags=['psql']),
+ nox.param('mysql', tags=['mysql']),
+ ])
+ def tests(session, dependency, database):
+ ...
+
+In either case, running ``nox --tags old`` will run the tests using version 1.0 of the dependency against both database backends, while running ``nox --tags psql`` will run the tests using both versions of the dependency, but only against PostgreSQL.
+
+More sophisticated tag assignment can be performed by passing a generator to the ``@nox.parametrize`` decorator, as seen in the following example:
+
+.. code-block:: python
+
+ def generate_params():
+ for dependency in ["1.0", "1.1", "2.0"]:
+ for database in ["sqlite", "postgresql", "mysql"]:
+ tags = []
+ if dependency == "2.0" and database == "sqlite":
+ tags.append("quick")
+ if dependency == "2.0" or database == "sqlite":
+ tags.append("standard")
+ yield nox.param(dependency, database, tags=tags)
+
+ @nox.session
+ @nox.parametrize(["dependency", "database"], generate_params())
+ def tests(session, dependency, database):
+ ...
+
+In this example, the ``quick`` tag is assigned to the single combination of the latest version of the dependency along with the SQLite database backend, allowing a developer to run the tests in a single configuration as a basic sanity test. The ``standard`` tag, in contrast, selects combinations targeting either the latest version of the dependency *or* the SQLite database backend. If the developer runs ``tox --tags standard``, the tests will be run against all supported versions of the dependency with the SQLite backend, as well as against all supported database backends under the latest version of the dependency, giving much more comprehensive test coverage while using only five of the potential nine test matrix combinations.
+
+
The session object
------------------
.. module:: nox.sessions
-Nox will call your session functions with a an instance of the :class:`Session`
+Nox will call your session functions with an instance of the :class:`Session`
class.
.. autoclass:: Session
:members:
:undoc-members:
+The pyproject.toml helpers
+--------------------------
+
+Nox provides helpers for ``pyproject.toml`` projects in the ``nox.project`` namespace.
+
+.. automodule:: nox.project
+ :members:
Modifying Nox's behavior in the Noxfile
---------------------------------------
@@ -405,7 +516,7 @@ Nox has various :doc:`command line arguments ` that can be used to modify
def tests(session):
...
-Or, if you wanted to provide a set of sessions that are run by default:
+Or, if you wanted to provide a set of sessions that are run by default (this overrides the ``default=`` argument to sessions):
.. code-block:: python
@@ -418,16 +529,20 @@ Or, if you wanted to provide a set of sessions that are run by default:
The following options can be specified in the Noxfile:
* ``nox.options.envdir`` is equivalent to specifying :ref:`--envdir `.
-* ``nox.options.sessions`` is equivalent to specifying :ref:`-s or --sessions `.
+* ``nox.options.sessions`` is equivalent to specifying :ref:`-s or --sessions `. If set to an empty list, no sessions will be run if no sessions were given on the command line, and the list of available sessions will be shown instead.
* ``nox.options.pythons`` is equivalent to specifying :ref:`-p or --pythons `.
* ``nox.options.keywords`` is equivalent to specifying :ref:`-k or --keywords `.
+* ``nox.options.tags`` is equivalent to specifying :ref:`-t or --tags `.
* ``nox.options.default_venv_backend`` is equivalent to specifying :ref:`-db or --default-venv-backend `.
* ``nox.options.force_venv_backend`` is equivalent to specifying :ref:`-fb or --force-venv-backend `.
-* ``nox.options.reuse_existing_virtualenvs`` is equivalent to specifying :ref:`--reuse-existing-virtualenvs `. You can force this off by specifying ``--no-reuse-existing-virtualenvs`` during invocation.
+* ``nox.options.reuse_venv`` is equivalent to specifying :ref:`--reuse-venv `. Preferred over using ``nox.options.reuse_existing_virtualenvs``.
+* ``nox.options.reuse_existing_virtualenvs`` is equivalent to specifying :ref:`--reuse-existing-virtualenvs `. You can force this off by specifying ``--no-reuse-existing-virtualenvs`` during invocation. Alias of ``nox.options.reuse_venv=yes|no``.
* ``nox.options.stop_on_first_error`` is equivalent to specifying :ref:`--stop-on-first-error `. You can force this off by specifying ``--no-stop-on-first-error`` during invocation.
* ``nox.options.error_on_missing_interpreters`` is equivalent to specifying :ref:`--error-on-missing-interpreters `. You can force this off by specifying ``--no-error-on-missing-interpreters`` during invocation.
* ``nox.options.error_on_external_run`` is equivalent to specifying :ref:`--error-on-external-run `. You can force this off by specifying ``--no-error-on-external-run`` during invocation.
+* ``nox.options.download_python`` is equivalent to specifying ``--download-python``.
* ``nox.options.report`` is equivalent to specifying :ref:`--report `.
+* ``nox.options.verbose`` is equivalent to specifying :ref:`-v or --verbose `. You can force this off by specifying ``--no-verbose`` during invocation.
When invoking ``nox``, any options specified on the command line take precedence over the options specified in the Noxfile. If either ``--sessions`` or ``--keywords`` is specified on the command line, *both* options specified in the Noxfile will be ignored.
diff --git a/docs/cookbook.rst b/docs/cookbook.rst
new file mode 100644
index 00000000..e2356059
--- /dev/null
+++ b/docs/cookbook.rst
@@ -0,0 +1,214 @@
+The Nox Cookbook
+================
+
+The What?
+---------
+
+A lot of people and a lot of projects use Nox for their python automation powers.
+
+Some of these sessions are the classic "run pytest and linting", some are more unique and more interesting!
+
+The Nox cookbook is a collection of these sessions.
+
+Nox is super easy to get started with, and super powerful right out of the box. But when things get complex or you want to chain together some more powerful tasks, often the only examples can be found hunting around GitHub for novel sessions.
+
+The kind of sessions that make you think "I didn't know you could do that!"
+
+This cookbook is intended to be a centralized, community-driven repository of awesome Nox sessions to act as a source of inspiration and a reference guide for Nox's users. If you're doing something cool with Nox, why not add your session here?
+
+
+Contributing a Session
+----------------------
+
+Anyone can contribute sessions to the cookbook. However, there are a few guiding principles you should keep in mind:
+
+* Your session should be interesting or unique, it should do something out of the ordinary or otherwise interesting.
+* You should explain briefly what it does and why it's interesting.
+
+For general advice on how to contribute to Nox see our :doc:`CONTRIBUTING` guide
+
+Recipes
+-------
+
+Instant Dev Environment
+^^^^^^^^^^^^^^^^^^^^^^^
+
+A common sticking point in contributing to python projects (especially for beginners) is the problem of wrangling virtual environments and installing dependencies.
+
+Enter the ``dev`` nox session:
+
+.. code-block:: python
+
+ import nox
+
+
+ # It's a good idea to keep your dev session out of the default list
+ # so it's not run twice accidentally
+ @nox.session(default=False)
+ def dev(session: nox.Session) -> None:
+ """
+ Set up a python development environment for the project at ".venv".
+ """
+
+ session.install("virtualenv")
+
+ session.run("virtualenv", ".venv", silent=True)
+
+ # Use the venv's interpreter to install the project along with
+ # all it's dev dependencies, this ensures it's installed in the right way
+ session.run(".venv/bin/pip", "install", "-e", ".[dev]", external=True)
+
+With this, a user can simply run ``nox -s dev`` and have their entire environment set up automatically!
+
+
+The Auto-Release
+^^^^^^^^^^^^^^^^
+
+Releasing a new version of an open source project can be a real pain, with lots of intricate steps. Tools like `Bump2Version `_ really help here.
+
+Even more so with a sprinkling of Nox:
+
+.. code-block:: python
+
+ import argparse
+ import nox
+
+ @nox.session
+ def release(session: nox.Session) -> None:
+ """
+ Kicks off an automated release process by creating and pushing a new tag.
+
+ Invokes bump2version with the posarg setting the version.
+
+ Usage:
+ $ nox -s release -- [major|minor|patch]
+ """
+ parser = argparse.ArgumentParser(description="Release a semver version.")
+ parser.add_argument(
+ "version",
+ type=str,
+ nargs=1,
+ help="The type of semver release to make.",
+ choices={"major", "minor", "patch"},
+ )
+ args: argparse.Namespace = parser.parse_args(args=session.posargs)
+ version: str = args.version.pop()
+
+ # If we get here, we should be good to go
+ # Let's do a final check for safety
+ confirm = input(
+ f"You are about to bump the {version!r} version. Are you sure? [y/n]: "
+ )
+
+ # Abort on anything other than 'y'
+ if confirm.lower().strip() != "y":
+ session.error(f"You said no when prompted to bump the {version!r} version.")
+
+
+ session.install("bump2version")
+
+ session.log(f"Bumping the {version!r} version")
+ session.run("bump2version", version)
+
+ session.log("Pushing the new tag")
+ session.run("git", "push", external=True)
+ session.run("git", "push", "--tags", external=True)
+
+Now a simple ``nox -s release -- patch`` will automate your release (provided you have Bump2Version set up to change your files). This is especially powerful if you have a CI/CD pipeline set up!
+
+
+Using a lockfile
+^^^^^^^^^^^^^^^^
+
+If you use a tool like ``uv`` to lock your dependencies, you can use that inside a nox session. Here's an example:
+
+.. code-block:: python
+
+ @nox.session(venv_backend="uv")
+ def tests(session: nox.Session) -> None:
+ """
+ Run the unit and regular tests.
+ """
+ session.run_install(
+ "uv",
+ "sync",
+ "--extra=test",
+ "--no-default-extras",
+ f"--python={session.virtualenv.location}",
+ env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location},
+ )
+ session.run("pytest", *session.posargs)
+
+
+Here we run ``uv sync`` on the nox virtual environment. Other useful flags might include ``--locked`` (validate lockfile is up-to-date) and ``--inexact`` (will allow you to install other packages as well).
+
+The `nox-uv `_ package can be used to reduce the boilerplate needed to ``uv sync`` specific dependency groups or extras into the nox virtual environment.
+By default, ``nox-uv`` also validates that the lockfile is up-to-date.
+
+.. code-block:: python
+
+ #!/usr/bin/env -S uv run --script --quiet
+
+ # /// script
+ # dependencies = ["nox", "nox-uv"]
+ # ///
+
+ import nox
+ import nox_uv
+
+ nox.options.default_venv_backend = "uv"
+
+ @nox_uv.session(
+ python=["3.10", "3.11", "3.12", "3.13"],
+ uv_groups=["test"],
+ )
+ def test(s: nox.Session) -> None:
+ """`uv sync` main dependencies and the `test` dependency group."""
+ s.run("python", "-m", "pytest")
+
+ @nox_uv.session(uv_groups=["type_check"])
+ def type_check(s: nox.Session) -> None:
+ """`uv sync` main dependencies and the `type_check` dependency group."""
+ s.run("mypy", "src")
+
+ @nox_uv.session(uv_only_groups=["lint"])
+ def lint(s: nox.Session) -> None:
+ """`uv sync` only the `lint` dependency group."""
+ s.run("ruff", "check", ".")
+ s.run("ruff", "format", "--check", ".")
+
+
+ if __name__ == "__main__":
+ nox.main()
+
+
+Generating a matrix with GitHub Actions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Nox knows what sessions it needs to run. Why not tell GitHub Actions what jobs to run dynamically? Using the ``--json`` flag and a bit of json processing, it's easy:
+
+.. code-block:: yaml
+
+ jobs:
+ generate-jobs:
+ runs-on: ubuntu-latest
+ outputs:
+ session: ${{ steps.set-matrix.outputs.session }}
+ steps:
+ - uses: actions/checkout@v3
+ - uses: wntrblm/nox@main
+ - id: set-matrix
+ shell: bash
+ run: echo session=$(nox --json -l | jq -c '[.[].session]') | tee --append $GITHUB_OUTPUT
+ checks:
+ name: Session ${{ matrix.session }}
+ needs: [generate-jobs]
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ session: ${{ fromJson(needs.generate-jobs.outputs.session) }}
+ steps:
+ - uses: actions/checkout@v3
+ - uses: wntrblm/nox@main
+ - run: nox -s "${{ matrix.session }}"
diff --git a/docs/index.rst b/docs/index.rst
index 0071cbf6..74b0a438 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -8,14 +8,19 @@ Welcome to Nox
tutorial
config
usage
+ cookbook
CONTRIBUTING
CHANGELOG
``nox`` is a command-line tool that automates testing in multiple Python environments, similar to `tox`_. Unlike tox, Nox uses a standard Python file for configuration.
-Install nox via `pip`_::
+To install Nox with `pipx`_::
- pip install --user --upgrade nox
+ pipx install nox
+
+You can also use `pip`_ in your global python::
+
+ python3 -m pip install nox
Nox is configured via a ``noxfile.py`` file in your project's directory. Here's a simple noxfile that runs lint and some tests::
@@ -36,32 +41,43 @@ To run both of these sessions, just run::
nox
-For each session, Nox will automatically create `virtualenv`_ with the appropriate interpreter, install the specified dependencies, and run the commands in order.
+For each session, Nox will automatically create a `virtualenv`_ with the appropriate interpreter, install the specified dependencies, and run the commands in order.
To learn how to install and use Nox, see the :doc:`tutorial`. For documentation on configuring sessions, see :doc:`config`. For documentation on running ``nox``, see :doc:`usage`.
.. _tox: https://tox.readthedocs.org
-.. _pip: https://pip.readthedocs.org
+.. _pip: https://pip.pypa.io
+.. _pipx: https://pipx.pypa.io
.. _pytest: http://pytest.org
-.. _virtualenv: https://virtualenv.readthedocs.org
+.. _virtualenv: https://virtualenv.pypa.io
Projects that use Nox
---------------------
-Nox is lucky to have several wonderful projects that use it and provide feedback and contributions.
+Nox is lucky to have `over 3,000 `__ wonderful projects that use it and provide feedback and contributions. A few of them are:
- `Bézier `__
+- `cibuildwheel `__
- `gapic-generator-python `__
- `gdbgui `__
- `Google Assistant SDK `__
- `google-cloud-python `__
- `google-resumable-media-python `__
- `Hydra `__
+- `Jupyter `__
+- `manylinux `__
- `OmegaConf `__
- `OpenCensus Python `__
-- `packaging.python.org `__
-- `pipx `__
+- `packaging `__
+- `packaging.python.org `__
+- `pip `__
+- `pipx `__
+- `pybind11 `__
+- `rustworkx `__
- `Salt `__
+- `Scikit-build `__
+- `Scikit-HEP `__
+- `Scientific Python `__
- `Subpar `__
- `Urllib3 `__
- `Zazo `__
@@ -73,6 +89,7 @@ Nox is not the only tool of its kind. If Nox doesn't quite fit your needs or you
- `tox `__ is the de-facto standard for managing multiple Python test environments, and is the direct spiritual ancestor to Nox.
- `Invoke `__ is a general-purpose task execution library, similar to Make. Nox can be thought of as if Invoke were tailored specifically to Python testing, so Invoke is a great choice for scripts that need to encompass far more than Nox's specialization.
+- `Hatch `__ A modern, extensible Python project manager using ``pyproject.toml`` configuration and a scripts + environments approach.
Maintainers & contributors
@@ -83,9 +100,13 @@ Nox is free & open-source software and is made possible by community maintainers
Our maintainers are (in alphabetical order):
* `Chris Wilcox `__
+* `Claudio Jolowicz `__
* `Danny Hermes `__
+* `Diego Ramirez `__
+* `Henry Schreiner `__
* `Luke Sneeringer `__
* `Santos Gallegos `__
* `Thea Flowers `__
+* `Tom Fleet `__
-Nox also exists due to the various patches and work contributed by `the community `__. If you'd like to get involved, see :doc:`CONTRIBUTING`. We pay our contributors using `Open Collective `__.
+Nox also exists due to the various patches and work contributed by `the community `__. If you'd like to get involved, see :doc:`CONTRIBUTING`. We pay our contributors using `Open Collective `__.
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 0eead976..7b5d6ca6 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -27,9 +27,26 @@ Or you can be extra fancy and use `pipx`_:
Either way, Nox is usually installed *globally*, similar to ``tox``, ``pip``, and other similar tools.
-If you're interested in running ``nox`` within `docker`_, you can use the `thekevjames/nox images`_ on DockerHub which contain builds for all ``nox`` versions and all supported ``python`` versions.
+If you're interested in running ``nox`` within `docker`_, you can use the `thekevjames/nox images`_ on DockerHub which contain builds for all ``nox`` versions and all supported ``python`` versions. Nox is also supported via ``pipx run nox`` in the `manylinux images`_.
-If you want to run ``nox`` within `GitHub Actions`_, you can use the `excitedleigh/setup-nox action`_, which installs the latest ``nox`` and makes available all Python versions provided by the GitHub Actions environment.
+If you want to run ``nox`` within `GitHub Actions`_, you can use the ``wntrblm/nox`` action, which installs the latest ``nox`` and makes available all active CPython and PyPY versions provided by the GitHub Actions environment:
+
+.. code-block:: yaml
+
+ # setup nox with all active CPython and PyPY versions provided by
+ # the GitHub Actions environment i.e.
+ # python-versions: "3.8, 3.9, 3.10, 3.11, 3.12, pypy-3.8, pypy-3.9, pypy-3.10"
+ # Any Nox tag will work here
+ - uses: wntrblm/nox@2024.04.15
+
+ # setup nox only for a given list of python versions
+ # Limitations:
+ # - Version specifiers shall be supported by actions/setup-python
+ # - There can only be one "major.minor" per interpreter i.e. "3.12.0, 3.12.1" is invalid
+ # Any Nox tag will work here
+ - uses: wntrblm/nox@2024.04.15
+ with:
+ python-versions: "2.7, 3.5, 3.11, pypy-3.9"
.. _pip: https://pip.readthedocs.org
.. _user site: https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site
@@ -37,7 +54,7 @@ If you want to run ``nox`` within `GitHub Actions`_, you can use the `excitedlei
.. _docker: https://www.docker.com/
.. _thekevjames/nox images: https://hub.docker.com/r/thekevjames/nox
.. _GitHub Actions: https://github.com/features/actions
-.. _excitedleigh/setup-nox action: https://github.com/marketplace/actions/setup-nox
+.. _manylinux images: https://github.com/pypa/manylinux
Writing the configuration file
------------------------------
@@ -126,20 +143,20 @@ If your project is a Python package and you want to install it:
In some cases such as Python binary extensions, your package may depend on
code compiled outside of the Python ecosystem. To make sure a low-level
-dependency (e.g. ``libfoo``) is available during installation
+dependency (e.g. ``libfoo``) is available during installation:
.. code-block:: python
@nox.session
def tests(session):
...
- session.run_always(
+ session.run_install(
"cmake", "-DCMAKE_BUILD_TYPE=Debug",
"-S", libfoo_src_dir,
"-B", build_dir,
external=True,
)
- session.run_always(
+ session.run_install(
"cmake",
"--build", build_dir,
"--config", "Debug",
@@ -149,6 +166,61 @@ dependency (e.g. ``libfoo``) is available during installation
session.install(".")
...
+These commands will run even if you are only installing, and will not run if
+the environment is being reused without reinstallation.
+
+
+Loading dependencies from pyproject.toml or scripts
+---------------------------------------------------
+
+One common need is loading a dependency list from a ``pyproject.toml`` file
+(say to prepare an environment without installing the package) or supporting
+`PEP 723 `_ scripts. Nox provides a helper to
+load these with ``nox.project.load_toml``. It can be passed a filepath to a toml
+file or to a script file following PEP 723. For example, if you have the
+following ``peps.py``:
+
+
+.. code-block:: python
+
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "requests<3",
+ # "rich",
+ # ]
+ # ///
+
+ import requests
+ from rich.pretty import pprint
+
+ resp = requests.get("https://peps.python.org/api/peps.json")
+ data = resp.json()
+ pprint([(k, v["title"]) for k, v in data.items()][:10])
+
+You can make a session for it like this:
+
+.. code-block:: python
+
+ @nox.session
+ def peps(session):
+ requirements = nox.project.load_toml("peps.py")["dependencies"]
+ session.install(*requirements)
+ session.run("peps.py")
+
+This is a common structure for scripts following this PEP, so a helper for it
+is provided:
+
+.. code-block:: python
+
+ @nox.session
+ def peps(session):
+ session.install_and_run_script("peps.py")
+
+
+Other helpers for ``pyproject.toml`` based projects are also available in
+``nox.project``.
+
Running commands
----------------
@@ -181,7 +253,7 @@ You can also pass environment variables:
@nox.session
def tests(session):
- session.install("black")
+ session.install("pytest")
session.run(
"pytest",
env={
@@ -192,7 +264,6 @@ You can also pass environment variables:
See :func:`nox.sessions.Session.run` for more options and examples for running
programs.
-
Selecting which sessions to run
-------------------------------
@@ -259,9 +330,101 @@ And if you run ``nox --sessions lint`` Nox will just run the lint session:
nox > ...
nox > Session lint was successful.
+
+In the Noxfile, you can specify a default set of sessions to run. If so, a plain
+``nox`` call will only trigger certain sessions:
+
+.. code-block:: python
+
+ import nox
+
+ nox.options.sessions = ["lint", "test"]
+
+If you set this to an empty list, Nox will not run any sessions by default, and
+will print a helpful message with the ``--list`` output when a user does not
+specify a session to run.
+
There are many more ways to select and run sessions! You can read more about
invoking Nox in :doc:`usage`.
+Queuing sessions
+-----------------
+
+If you want to queue up (or "notify") another session from the current one, you
+can use the ``session.notify`` function. Pass the session name string, the same
+name you would use with ``nox --session``:
+
+.. code-block:: python
+
+ @nox.session
+ def tests(session):
+ session.install("pytest")
+ session.run("pytest")
+ # Here we queue up the test coverage session to run next
+ session.notify("coverage")
+
+ @nox.session
+ def coverage(session):
+ session.install("coverage")
+ session.run("coverage")
+
+You can queue up any session you want, not just test and coverage sessions, but this is a very commonly
+used pattern.
+
+Now running ``nox --session tests`` will run the tests session and then the coverage session.
+
+You can also pass the notified session positional arguments:
+
+.. code-block:: python
+
+ @nox.session
+ def prepare_thing(session):
+ thing_path = "./path/to/thing"
+ session.run("prepare", "thing", thing_path)
+ session.notify("consume_thing", posargs=[thing_path])
+
+ @nox.session
+ def consume_thing(session):
+ # The 'consume' command has the arguments
+ # sent to it from the 'prepare_thing' session
+ session.run("consume", "thing", *session.posargs)
+
+Note that this will only have the desired effect if selecting sessions to run via the ``--session/-s`` flag. If you simply run ``nox``, all selected sessions will be run.
+
+Requiring sessions
+------------------
+
+You can also request sessions be run before your session runs. This is done with the ``requires=`` keyword:
+
+
+.. code-block:: python
+
+ @nox.session
+ def tests(session):
+ session.install("pytest")
+ session.run("pytest")
+
+ @nox.session(requires=["tests"])
+ def coverage(session):
+ session.install("coverage")
+ session.run("coverage")
+
+The required sessions will be stably topologically sorted and run. Parametrized
+sessions are supported. You can also get the current Python version with
+``{python}``, though arbitrary parametrizations are not supported.
+
+
+.. code-block:: python
+
+ @nox.session(python=["3.10", "3.13"])
+ def tests(session):
+ session.install("pytest")
+ session.run("pytest")
+
+ @nox.session(python=["3.10", "3.13"], requires=["tests-{python}"])
+ def coverage(session):
+ session.install("coverage")
+ session.run("coverage")
Testing against different and multiple Pythons
----------------------------------------------
@@ -274,7 +437,7 @@ If you want your session to specifically run against a single version of Python
.. code-block:: python
- @nox.session(python="3.7")
+ @nox.session(python="3.12")
def test(session):
...
@@ -282,7 +445,7 @@ If you want your session to run against multiple versions of Python:
.. code-block:: python
- @nox.session(python=["2.7", "3.6", "3.7"])
+ @nox.session(python=["3.10", "3.11", "3.12"])
def test(session):
...
@@ -293,13 +456,13 @@ been expanded into three distinct sessions:
Sessions defined in noxfile.py:
- * test-2.7
- * test-3.6
- * test-3.7
+ * test-3.10
+ * test-3.11
+ * test-3.12
You can run all of the ``test`` sessions using ``nox --sessions test`` or run
an individual one using the full name as displayed in the list, for example,
-``nox --sessions test-3.5``. More details on selecting sessions can be found
+``nox --sessions test-3.12``. More details on selecting sessions can be found
over in the :doc:`usage` documentation.
You can read more about configuring the virtual environment used by your
@@ -323,18 +486,25 @@ Install packages with conda:
.. code-block:: python
- session.conda_install("pytest")
+ session.conda_install("pytest", channels=["conda-forge"])
It is possible to install packages with pip into the conda environment, but
it's a best practice only install pip packages with the ``--no-deps`` option.
-This prevents pip from breaking the conda environment by installing
-incompatible versions of packages already installed with conda.
+This prevents pip from breaking the conda environment by installing incompatible
+versions of packages already installed with conda. You should always specify
+channels for consistency; default channels can vary (and ``micromamba`` has none).
.. code-block:: python
session.install("contexter", "--no-deps")
session.install("-e", ".", "--no-deps")
+``"mamba"`` is also allowed as a choice for ``venv_backend``, which will
+use/require `mamba `_ instead of conda.
+
+``"micromamba"`` is also allowed as a choice for ``venv_backend``, which will
+use/require `micromamba `_
+instead of conda.
Parametrization
---------------
@@ -375,6 +545,91 @@ read more about parametrization and see more examples over at
.. _pytest's parametrize: https://pytest.org/latest/parametrize.html#_pytest.python.Metafunc.parametrize
+.. _session tags:
+
+Session tags
+------------
+
+You can add tags to your sessions to help you organize your development tasks:
+
+.. code-block:: python
+
+ @nox.session(tags=["style", "fix"])
+ def black(session):
+ session.install("black")
+ session.run("black", "my_package")
+
+ @nox.session(tags=["style", "fix"])
+ def isort(session):
+ session.install("isort")
+ session.run("isort", "my_package")
+
+ @nox.session(tags=["style"])
+ def flake8(session):
+ session.install("flake8")
+ session.run("flake8", "my_package")
+
+
+If you run ``nox -t style``, Nox will run all three sessions:
+
+.. code-block:: console
+
+ * black
+ * isort
+ * flake8
+
+
+If you run ``nox -t fix``, Nox will only run the ``black`` and ``isort``
+sessions:
+
+.. code-block:: console
+
+ * black
+ * isort
+ - flake8
+
+
+If you run ``nox -t style fix``, Nox will run all sessions that match *any* of
+the tags, so all three sessions:
+
+.. code-block:: console
+
+ * black
+ * isort
+ * flake8
+
+
+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
+noxfile.py``, ``pdm run noxfile.py``, or ``hatch run noxfile.py``. You need to
+have the following comment in your noxfile:
+
+.. code-block:: python
+
+ # /// script
+ # dependencies = ["nox"]
+ # ///
+
+And the following block of code:
+
+.. code-block:: python
+
+ 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
----------
@@ -385,4 +640,27 @@ For this point you can:
* Read more docs, such as :doc:`usage` and :doc:`config`.
* Give us feedback or contribute, see :doc:`CONTRIBUTING`.
+For any projects using Nox, you may add its official badge somewhere prominent
+like the README.
+
+.. image:: https://img.shields.io/badge/%F0%9F%A6%8A-Nox-D85E00.svg
+ :alt: Nox
+ :target: https://github.com/wntrblm/nox
+
+.. tabs::
+
+ .. tab:: Markdown
+
+ .. code-block:: markdown
+
+ [](https://github.com/wntrblm/nox)
+
+ .. tab:: reStructuredText
+
+ .. code-block:: rst
+
+ .. image:: https://img.shields.io/badge/%F0%9F%A6%8A-Nox-D85E00.svg
+ :alt: Nox
+ :target: https://github.com/wntrblm/nox
+
Have fun! 💜
diff --git a/docs/usage.rst b/docs/usage.rst
index 8103cb2a..ecc177c6 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -30,6 +30,16 @@ To list all available sessions, including parametrized sessions:
nox --list
nox --list-sessions
+If you'd like to use the output in later processing, you can add ``--json`` to
+get json output for the selected session. Fields include ``session`` (pretty
+name), ``name``, ``description``, ``python`` (null if not specified), ``tags``,
+and ``call_spec`` (for parametrized sessions).
+
+To get more information about one particular session at a time, you can use ``--usage``:
+
+.. code-block:: console
+
+ nox --usage tests
.. _session_execution_order:
@@ -50,36 +60,47 @@ The order that sessions are executed is the order that they appear in the Noxfil
Specifying one or more sessions
-------------------------------
-By default Nox will run all sessions defined in the noxfile. However, you can choose to run a particular set of them using ``--session``, ``-s``, or ``-e``:
+By default Nox will run all sessions defined in the Noxfile. However, you can choose to run a particular set of them using ``--session``, ``-s``, or ``-e``:
-.. code-block:: console
+.. tabs::
- nox --session tests
- nox -s lint tests
- nox -e lint
+ .. code-tab:: console CLI options
-You can also use the ``NOXSESSION`` environment variable:
+ nox --session tests
+ nox -s lint tests
+ nox -e lint
-.. code-block:: console
+ .. code-tab:: console Environment variables
- NOXSESSION=lint nox
- NOXSESSION=lint,tests nox
+ NOXSESSION=tests nox
+ NOXSESSION=lint nox
+ NOXSESSION=lint,tests nox
Nox will run these sessions in the same order they are specified.
If you have a :ref:`configured session's virtualenv `, you can choose to run only sessions with given Python versions:
-.. code-block:: console
+.. tabs::
- nox --python 3.8
- nox -p 3.7 3.8
+ .. code-tab:: console CLI options
-You can also use `pytest-style keywords`_ to filter test sessions:
+ nox --python 3.12
+ nox -p 3.11 3.12
+
+ .. code-tab:: console Environment variables
+
+ NOXPYTHON=3.12 nox
+ NOXPYTHON=3.11,3.12 nox
+
+You can also use `pytest-style keywords`_ using ``-k`` or ``--keywords``, and
+tags using ``-t`` or ``--tags`` to filter test sessions:
.. code-block:: console
nox -k "not lint"
nox -k "tests and not lint"
+ nox -k "not my_tag"
+ nox -t "my_tag" "my_other_tag"
.. _pytest-style keywords: https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests
@@ -110,25 +131,47 @@ Then running ``nox --session tests`` will actually run all parametrized versions
Changing the sessions default backend
-------------------------------------
-By default nox uses ``virtualenv`` as the virtual environment backend for the sessions, but it also supports ``conda`` and ``venv`` as well as no backend (passthrough to whatever python environment nox is running on). You can change the default behaviour by using ``-db `` or ``--default-venv-backend ``. Supported names are ``('none', 'virtualenv', 'conda', 'venv')``.
+By default Nox uses ``virtualenv`` as the virtual environment backend for the sessions, but it also supports ``uv``, ``conda``, ``mamba``, ``micromamba``, and ``venv`` as well as no backend (passthrough to whatever python environment Nox is running on). You can change the default behaviour by using ``-db `` or ``--default-venv-backend ``. Supported names are ``('none', 'uv', 'virtualenv', 'conda', 'mamba', 'micromamba', 'venv')``.
-.. code-block:: console
- nox -db conda
- nox --default-venv-backend conda
+.. tabs::
+
+ .. code-tab:: console CLI options
+
+ nox -db conda
+ nox --default-venv-backend conda
+
+ .. code-tab:: console Environment variables
+ NOX_DEFAULT_VENV_BACKEND=conda
-You can also set this option in the Noxfile with ``nox.options.default_venv_backend``. In case both are provided, the commandline argument takes precedence.
+.. note::
+
+ The ``uv``, ``conda``, ``mamba``, and ``micromamba`` backends require their
+ respective programs be pre-installed. ``uv`` is distributed as a Python
+ package and can be installed with the ``nox[uv]`` extra.
+
+You can also set this option with the ``NOX_DEFAULT_VENV_BACKEND`` environment variable, or in the Noxfile with ``nox.options.default_venv_backend``. In case more than one is provided, the command line argument overrides the environment variable, which in turn overrides the Noxfile configuration.
Note that using this option does not change the backend for sessions where ``venv_backend`` is explicitly set.
+.. warning::
+
+ The ``uv`` backend does not install anything by default, including ``pip``,
+ as ``uv pip`` is used to install programs instead. If you need to manually
+ interact with pip, you should install it with ``session.install("pip")``.
+
+Backends that could be missing (``uv``, ``conda``, ``mamba``, and ``micromamba``) can have a fallback using ``|``, such as ``uv|virtualenv`` or ``micromamba|mamba|conda``. This will use the first item that is available on the users system.
+
+If you need to check to see which backend was selected, you can access it via
+``session.venv_backend`` in your noxfile.
.. _opt-force-venv-backend:
Forcing the sessions backend
----------------------------
-You might work in a different environment than a project's default continuous integration setttings, and might wish to get a quick way to execute the same tasks but on a different venv backend. For this purpose, you can temporarily force the backend used by **all** sessions in the current nox execution by using ``-fb `` or ``--force-venv-backend ``. No exceptions are made, the backend will be forced for all sessions run whatever the other options values and nox file configuration. Supported names are ``('none', 'virtualenv', 'conda', 'venv')``.
+You might work in a different environment than a project's default continuous integration settings, and might wish to get a quick way to execute the same tasks but on a different venv backend. For this purpose, you can temporarily force the backend used by **all** sessions in the current Nox execution by using ``-fb `` or ``--force-venv-backend ``. No exceptions are made, the backend will be forced for all sessions run whatever the other options values and Noxfile configuration. Supported names are ``('none', 'uv', 'virtualenv', 'conda', 'mamba', 'micromamba', 'venv')``.
.. code-block:: console
@@ -138,41 +181,58 @@ You might work in a different environment than a project's default continuous in
You can also set this option in the Noxfile with ``nox.options.force_venv_backend``. In case both are provided, the commandline argument takes precedence.
-Finally note that the ``--no-venv`` flag is a shortcut for ``--force-venv-backend none`` and allows to temporarily run all selected sessions on the current python interpreter (the one running nox).
+Finally note that the ``--no-venv`` flag is a shortcut for ``--force-venv-backend none`` and allows to temporarily run all selected sessions on the current python interpreter (the one running Nox).
.. code-block:: console
nox --no-venv
.. _opt-reuse-existing-virtualenvs:
+.. _opt-reuse-venv:
-Re-using virtualenvs
---------------------
+Reusing virtualenvs
+-------------------
-By default, Nox deletes and recreates virtualenvs every time it is run. This is usually fine for most projects and continuous integration environments as `pip's caching `_ makes re-install rather quick. However, there are some situations where it is advantageous to re-use the virtualenvs between runs. Use ``-r`` or ``--reuse-existing-virtualenvs``:
+By default, Nox deletes and recreates virtualenvs every time it is run. This is
+usually fine for most projects and continuous integration environments as
+`pip's caching `_ makes
+re-install rather quick. However, there are some situations where it is
+advantageous to reuse the virtualenvs between runs. Use ``-r`` or
+``--reuse-existing-virtualenvs`` or for fine-grained control use
+``--reuse-venv=yes|no|always|never``:
.. code-block:: console
nox -r
nox --reuse-existing-virtualenvs
-
+ nox --reuse-venv=yes # preferred
If the Noxfile sets ``nox.options.reuse_existing_virtualenvs``, you can override the Noxfile setting from the command line by using ``--no-reuse-existing-virtualenvs``.
+Similarly you can override ``nox.options.reuse_venvs`` from the Noxfile via the command line by using ``--reuse-venv=yes|no|always|never``.
+
+.. note::
+
+ ``--reuse-existing-virtualenvs`` is a alias for ``--reuse-venv=yes`` and ``--no-reuse-existing-virtualenvs`` is an alias for ``--reuse-venv=no``.
-Additionally, you can skip the re-installation of packages when a virtualenv is reused. Use ``-R`` or ``--reuse-existing-virtualenvs --no-install``:
+Additionally, you can skip the re-installation of packages when a virtualenv is reused.
+Use ``-R`` or ``--reuse-existing-virtualenvs --no-install`` or ``--reuse-venv=yes --no-install``:
.. code-block:: console
nox -R
nox --reuse-existing-virtualenvs --no-install
+ nox --reuse-venv=yes --no-install
The ``--no-install`` option causes the following session methods to return early:
- :func:`session.install `
- :func:`session.conda_install `
-- :func:`session.run_always `
+- :func:`session.run_install `
-This option has no effect if the virtualenv is not being reused.
+The ``never`` and ``always`` options in ``--reuse-venv`` gives you more fine-grained control
+as it ignores when a ``@nox.session`` has ``reuse_venv=True|False`` defined.
+
+These options have no effect if the virtualenv is not being reused.
.. _opt-running-extra-pythons:
@@ -181,32 +241,78 @@ Running additional Python versions
In addition to Nox supporting executing single sessions, it also supports running Python versions that aren't specified using ``--extra-pythons``.
-.. code-block:: console
+.. tabs::
+
+ .. code-tab:: console CLI options
+
+ nox --extra-pythons 3.12 3.13 3.14
+
+ .. code-tab:: console Environment variables
+
+ NOXEXTRAPYTHON=3.12,3.13,3.14 nox
- nox --extra-pythons 3.8 3.9
This will, in addition to specified Python versions in the Noxfile, also create sessions for the specified versions.
-This option can be combined with ``--python`` to replace, instead of appending, the Python interpreter for a given session::
+This option can be combined with ``--python`` to replace, instead of appending, the Python interpreter for a given session:
+
+.. tabs::
+
+ .. code-tab:: console CLI options
+
+ nox --python 3.11 --extra-python 3.11 -s lint
+
+ .. code-tab:: console Environment variables
- nox --python 3.10 --extra-python 3.10 -s lint
+ NOXPYTHON=3.11 NOXEXTRAPYTHON=3.11 NOXSESSION=lint nox
-Instead of passing both options, you can use the ``--force-python`` shorthand::
+Instead of passing both options, you can use the ``--force-python`` shorthand:
- nox --force-python 3.10 -s lint
+.. tabs::
+
+ .. code-tab:: console CLI options
+
+ nox --force-python 3.11 -s lint
+
+ .. code-tab:: console Environment variables
+
+ NOXFORCEPYTHON=3.11 NOXSESSION=lint nox
Also, you can specify ``python`` in place of a specific version. This will run the session
-using the ``python`` specified for the current ``PATH``::
+using the ``python`` specified for the current ``PATH``:
+
+.. tabs::
+
+ .. code-tab:: console CLI options
- nox --force-python python -s lint
+ nox --force-python python -s lint
+ .. code-tab:: console Environment variables
+
+ NOXFORCEPYTHON=python NOXSESSION=lint nox
+
+Downloading Python interpreters
+-------------------------------
+
+Nox can download Python interpreters, either via uv or directly from
+python-build-standalone (requires the ``[pbs]`` extra), by using
+``--download-python``:
+
+.. code-block:: console
+
+ nox --download-python auto # Download if interpreter not found (default)
+ nox --download-python never # Never download interpreters
+ nox --download-python always # Always download interpreters
+
+You can also set this option with the ``NOX_DOWNLOAD_PYTHON`` environment
+variable.
.. _opt-stop-on-first-error:
Stopping if any session fails
-----------------------------
-By default nox will continue to run all sessions even if one fails. You can use ``--stop-on-first-error`` to make nox abort as soon as the first session fails::
+By default Nox will continue to run all sessions even if one fails. You can use ``--stop-on-first-error`` to make Nox abort as soon as the first session fails::
nox --stop-on-first-error
@@ -218,7 +324,7 @@ If the Noxfile sets ``nox.options.stop_on_first_error``, you can override the No
Failing sessions when the interpreter is missing
------------------------------------------------
-By default, Nox will skip sessions where the Python interpreter can't be found. If you want Nox to mark these sessions as failed, you can use ``--error-on-missing-interpreters``:
+By default, when not on CI, Nox will skip sessions where the Python interpreter can't be found. If you want Nox to mark these sessions as failed, you can use ``--error-on-missing-interpreters``:
.. code-block:: console
@@ -226,6 +332,8 @@ By default, Nox will skip sessions where the Python interpreter can't be found.
If the Noxfile sets ``nox.options.error_on_missing_interpreters``, you can override the Noxfile setting from the command line by using ``--no-error-on-missing-interpreters``.
+If being run on Continuous Integration (CI) systems, Nox will treat missing interpreters as errors by default to avoid sessions silently passing when the requested python interpreter is not installed. Nox does this by looking for an environment variable called ``CI`` which is a convention used by most CI providers.
+
.. _opt-error-on-external-run:
Disallowing external programs
@@ -242,7 +350,7 @@ If the Noxfile sets ``nox.options.error_on_external_run``, you can override the
Specifying a different configuration file
-----------------------------------------
-If for some reason your noxfile is not named *noxfile.py*, you can use ``--noxfile`` or ``-f``:
+If for some reason your Noxfile is not named *noxfile.py*, you can use ``--noxfile`` or ``-f``:
.. code-block:: console
@@ -255,7 +363,7 @@ If for some reason your noxfile is not named *noxfile.py*, you can use ``--noxfi
Storing virtualenvs in a different directory
--------------------------------------------
-By default nox stores virtualenvs in ``./.nox``, however, you can change this using ``--envdir``:
+By default Nox stores virtualenvs in ``./.nox``, however, you can change this using ``--envdir``:
.. code-block:: console
@@ -290,7 +398,7 @@ Would run both ``install`` commands, but skip the ``run`` command:
.. code-block:: console
nox > Running session tests
- nox > Creating virtualenv using python3.7 in ./.nox/tests
+ nox > Creating virtualenv using python3.12 in ./.nox/tests
nox > python -m pip install pytest
nox > python -m pip install .
nox > Skipping pytest run, as --install-only is set.
@@ -329,7 +437,8 @@ Controlling color output
By default, Nox will output colorful logs if you're using in an interactive
terminal. However, if you are redirecting ``stderr`` to a file or otherwise
not using an interactive terminal, or the environment variable ``NO_COLOR`` is
-set, nox will output in plaintext.
+set, Nox will output in plaintext. If this is not set, and ``FORCE_COLOR`` is
+present, color will be forced.
You can manually control Nox's output using the ``--nocolor`` and ``--forcecolor`` flags.
@@ -346,14 +455,18 @@ However, this will never output colorful logs:
nox --nocolor
-.. _opt-report:
+.. _opt-verbose:
Controlling commands verbosity
------------------------------
By default, Nox will only show output of commands that fail, or, when the commands get passed ``silent=False``.
-By passing ``--verbose`` to Nox, all output of all commands run is shown, regardless of the silent argument.
+By either passing ``--verbose`` to Nox or setting ``nox.options.verbose = True``, all output of all commands
+run is shown, regardless of the silent argument.
+
+
+.. _opt-report:
Outputting a machine-readable report
@@ -371,11 +484,11 @@ Converting from tox
Nox has experimental support for converting ``tox.ini`` files into ``noxfile.py`` files. This doesn't support every feature of tox and is intended to just do most of the mechanical work of converting over- you'll likely still need to make a few changes to the converted ``noxfile.py``.
-To use the converter, install ``nox`` with the ``tox_to_nox`` extra:
+To use the converter, install ``nox`` with the ``tox-to-nox`` extra:
.. code-block:: console
- pip install --upgrade nox[tox_to_nox]
+ pip install --upgrade nox[tox-to-nox]
Then, just run ``tox-to-nox`` in the directory where your ``tox.ini`` resides:
@@ -414,7 +527,7 @@ zsh
autoload -U bashcompinit
bashcompinit
- # Afterwards you can enable completion for nox:
+ # Afterwards you can enable completion for Nox:
eval "$(register-python-argcomplete nox)"
tcsh
diff --git a/nox/__init__.py b/nox/__init__.py
index 78c5283d..230d4e9f 100644
--- a/nox/__init__.py
+++ b/nox/__init__.py
@@ -12,14 +12,44 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Optional
+from __future__ import annotations
+__lazy_modules__ = {
+ "nox._cli",
+ "nox._options",
+ "nox._parametrize",
+ "nox.registry",
+ "nox.sessions",
+ "types",
+}
+
+from types import ModuleType
+
+from nox import project
+from nox._cli import main
from nox._options import noxfile_options as options
-from nox._parametrize import Param as param
+from nox._parametrize import Param as param # noqa: N813
from nox._parametrize import parametrize_decorator as parametrize
from nox.registry import session_decorator as session
from nox.sessions import Session
-needs_version: Optional[str] = None
+needs_version: str | None = None
+
+__all__ = [
+ "Session",
+ "main",
+ "needs_version",
+ "options",
+ "param",
+ "parametrize",
+ "project",
+ "session",
+]
+
-__all__ = ["needs_version", "parametrize", "param", "session", "options", "Session"]
+def __dir__() -> list[str]:
+ # Only nox modules are imported here, so we can safely use globals() to
+ # find nox modules only. Other modules, like types and __future__, are imported
+ # from, so don't populate the module globals with surprising entries.
+ modules = {k for k, v in globals().items() if isinstance(v, ModuleType)}
+ return sorted(set(__all__) | modules)
diff --git a/nox/__main__.py b/nox/__main__.py
index d6043ac0..5bb22540 100644
--- a/nox/__main__.py
+++ b/nox/__main__.py
@@ -12,54 +12,22 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""The nox `main` module.
+"""The Nox `main` module.
This is the entrypoint for the ``nox`` command (specifically, the ``main``
function). This module primarily loads configuration, and then passes
control to :meth:``nox.workflow.execute``.
"""
-import sys
+from __future__ import annotations # pragma: no cover
-from nox import _options, tasks, workflow
-from nox._version import get_nox_version
-from nox.logger import setup_logging
+from nox._cli import nox_main as main # pragma: no cover
+__all__ = ["main"] # pragma: no cover
-def main() -> None:
- args = _options.options.parse_args()
- if args.help:
- _options.options.print_help()
- return
-
- if args.version:
- print(get_nox_version(), file=sys.stderr)
- return
-
- setup_logging(
- color=args.color, verbose=args.verbose, add_timestamp=args.add_timestamp
- )
-
- # Execute the appropriate tasks.
- exit_code = workflow.execute(
- global_config=args,
- workflow=(
- tasks.load_nox_module,
- tasks.merge_noxfile_options,
- tasks.discover_manifest,
- tasks.filter_manifest,
- tasks.honor_list_request,
- tasks.verify_manifest_nonempty,
- tasks.run_manifest,
- tasks.print_summary,
- tasks.create_report,
- tasks.final_reduce,
- ),
- )
-
- # Done; exit.
- sys.exit(exit_code)
+def __dir__() -> list[str]:
+ return __all__
if __name__ == "__main__": # pragma: no cover
diff --git a/nox/_cli.py b/nox/_cli.py
new file mode 100644
index 00000000..ecbabf06
--- /dev/null
+++ b/nox/_cli.py
@@ -0,0 +1,322 @@
+# Copyright 2016 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""The Nox `main` function and helpers."""
+
+from __future__ import annotations
+
+__lazy_modules__ = {
+ "importlib",
+ "importlib.metadata",
+ "nox._options",
+ "nox._version",
+ "nox.command",
+ "nox.logger",
+ "nox.project",
+ "nox.registry",
+ "nox.virtualenv",
+ "packaging",
+ "packaging.requirements",
+ "packaging.utils",
+ "pathlib",
+ "shutil",
+ "subprocess",
+ "urllib",
+ "urllib.parse",
+}
+
+import importlib.metadata
+import os
+import shutil
+import subprocess
+import sys
+import urllib.parse
+from pathlib import Path
+from typing import TYPE_CHECKING, Literal, NoReturn, cast
+
+import packaging.requirements
+import packaging.utils
+
+import nox.command
+import nox.registry
+import nox.virtualenv
+from nox import _options, tasks, workflow
+from nox._options import DefaultStr
+from nox._version import get_nox_version
+from nox.logger import logger, setup_logging
+from nox.project import load_toml
+
+if TYPE_CHECKING:
+ from argparse import Namespace
+ from collections.abc import Iterator
+
+__all__ = ["execute_workflow", "main", "nox_main"]
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+
+def execute_workflow(args: Namespace) -> int:
+ """
+ Execute the appropriate tasks.
+ """
+
+ return workflow.execute(
+ global_config=args,
+ workflow=(
+ tasks.load_nox_module,
+ tasks.merge_noxfile_options,
+ tasks.discover_manifest,
+ tasks.filter_manifest,
+ tasks.honor_list_request,
+ tasks.honor_usage_request,
+ tasks.run_manifest,
+ tasks.print_summary,
+ tasks.create_report,
+ tasks.final_reduce,
+ ),
+ )
+
+
+def get_dependencies(
+ req: packaging.requirements.Requirement,
+) -> Iterator[packaging.requirements.Requirement]:
+ """
+ Gets all dependencies. Raises ModuleNotFoundError if a package is not installed.
+ """
+ seen: set[tuple[packaging.utils.NormalizedName, frozenset[str]]] = set()
+
+ def expand(
+ req: packaging.requirements.Requirement,
+ ) -> Iterator[packaging.requirements.Requirement]:
+ # Skip the metadata read and re-expansion for requirements already
+ # visited with the same extras; this avoids rescanning shared
+ # dependencies reachable through multiple extras and guards against
+ # dependency cycles. The requirement itself is still yielded so that
+ # every specifier is checked downstream.
+ key = (packaging.utils.canonicalize_name(req.name), frozenset(req.extras))
+ if key in seen:
+ yield req
+ return
+ seen.add(key)
+
+ 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 expand(ireq)
+
+ yield from expand(req)
+
+
+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
+ if dep.url:
+ dist = importlib.metadata.distribution(dep.name)
+ if not check_url_dependency(dep.url, dist):
+ return False
+
+ return True
+
+
+def check_url_dependency(dep_url: str, dist: importlib.metadata.Distribution) -> bool:
+ """
+ Check to see if a url matches an installed distribution object. Returns false if
+ this is not a clear match.
+ """
+
+ # The .origin property added in Python 3.13
+ origin = getattr(dist, "origin", None)
+ if origin is None:
+ return False
+
+ dep_purl = urllib.parse.urlparse(dep_url)
+
+ if hasattr(origin, "requested_revision"):
+ origin_purl = urllib.parse.urlparse(f"{origin.url}@{origin.requested_revision}")
+ else:
+ origin_purl = urllib.parse.urlparse(origin.url)
+
+ return dep_purl.netloc == origin_purl.netloc and dep_purl.path == origin_purl.path
+
+
+def get_main_filename() -> str | None:
+ main_module = sys.modules.get("__main__")
+ if (
+ main_module
+ and (fname := getattr(main_module, "__file__", ""))
+ and os.path.exists(main_filename := os.path.abspath(fname))
+ ):
+ return main_filename
+ return None
+
+
+def run_script_mode(
+ noxfile: str,
+ envdir: Path,
+ *,
+ reuse: bool,
+ dependencies: list[str],
+ venv_backend: str,
+ download_python: Literal["auto", "never", "always"],
+) -> NoReturn:
+ envdir.mkdir(exist_ok=True)
+ noxenv = envdir.joinpath("_nox_script_mode")
+ venv = nox.virtualenv.get_virtualenv(
+ *venv_backend.split("|"),
+ download_python=download_python,
+ 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"
+ if venv.venv_backend == "uv":
+ cmd = [nox.virtualenv.UV, "pip", "install"]
+ else:
+ # On Windows, subprocess resolves the executable against the parent
+ # process's PATH, not the child env's, so resolve pip explicitly.
+ pip_cmd = shutil.which("pip", path=env["PATH"])
+ assert pip_cmd is not None, "pip must be discoverable in the environment"
+ cmd = [pip_cmd, "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"
+ args = [nox_cmd, "-f", noxfile, *sys.argv[1:]]
+ # The os.exec functions don't work properly on Windows
+ if sys.platform.startswith("win"):
+ raise SystemExit(
+ subprocess.run(
+ args,
+ env=env,
+ stdout=None,
+ stderr=None,
+ encoding="utf-8",
+ text=True,
+ check=False,
+ ).returncode
+ )
+ os.execle(nox_cmd, *args, env) # pragma: nocover # noqa: S606
+
+
+def main() -> None:
+ _main(main_ep=False)
+
+
+def nox_main() -> None:
+ _main(main_ep=True)
+
+
+def _main(*, main_ep: bool) -> None:
+ args = _options.options.parse_args()
+
+ if args.help:
+ _options.options.print_help()
+ return
+
+ if args.version:
+ print(get_nox_version(), file=sys.stderr)
+ return
+
+ 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":
+ noxfile = (
+ args.noxfile
+ if main_ep or not isinstance(args.noxfile, DefaultStr)
+ else (get_main_filename() or args.noxfile)
+ )
+ toml_config = load_toml(os.path.expandvars(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")
+ )
+ )
+
+ download_python = (
+ os.environ.get("NOX_SCRIPT_DOWNLOAD_PYTHON")
+ or (
+ toml_config.get("tool", {})
+ .get("nox", {})
+ .get("script-download-python")
+ )
+ or args.download_python
+ or "auto"
+ )
+
+ if download_python not in ("auto", "never", "always"):
+ logger.warning(
+ f"Invalid parameter for {download_python=}. Defaulting to 'auto'"
+ )
+ download_python = "auto"
+ download_python = cast(
+ "Literal['auto', 'never', 'always']", download_python
+ )
+
+ envdir = Path(args.envdir or ".nox")
+ run_script_mode(
+ noxfile,
+ envdir,
+ reuse=nox_script_mode == "reuse",
+ dependencies=dependencies,
+ venv_backend=venv_backend,
+ download_python=download_python,
+ )
+
+ nox.registry.reset()
+ exit_code = execute_workflow(args)
+
+ # Done; exit.
+ sys.exit(exit_code)
diff --git a/nox/_decorators.py b/nox/_decorators.py
index 153e331b..30e769bf 100644
--- a/nox/_decorators.py
+++ b/nox/_decorators.py
@@ -1,59 +1,105 @@
+# Copyright 2020 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+__lazy_modules__ = {"copy", "functools", "inspect", "types"}
+
import copy
import functools
import inspect
import types
-from typing import Any, Callable, Dict, Iterable, List, Optional, cast
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast
-from . import _typing
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Mapping, Sequence
-if _typing.TYPE_CHECKING:
+ from . import _typing
from ._parametrize import Param
+T = TypeVar("T", bound=Callable[..., Any])
-class FunctionDecorator:
- def __new__(
- cls, func: Callable[..., Any], *args: Any, **kwargs: Any
- ) -> "FunctionDecorator":
- obj = super().__new__(cls)
- return cast("FunctionDecorator", functools.wraps(func)(obj))
+__all__ = ["Call", "Func", "_copy_func"]
+
+
+def __dir__() -> list[str]:
+ return __all__
-def _copy_func(src: Callable, name: str = None) -> Callable:
+def _copy_func(src: T, name: str | None = None) -> T:
+ """This function copies another function, optionally with a new name."""
+
dst = types.FunctionType(
src.__code__,
- src.__globals__, # type: ignore
+ src.__globals__,
name=name or src.__name__,
- argdefs=src.__defaults__, # type: ignore
- closure=src.__closure__, # type: ignore
+ argdefs=src.__defaults__,
+ closure=src.__closure__,
)
dst.__dict__.update(copy.deepcopy(src.__dict__))
- dst = functools.update_wrapper(dst, src)
- dst.__kwdefaults__ = src.__kwdefaults__ # type: ignore
- return dst
+ dst = functools.update_wrapper(dst, src) # type: ignore[assignment]
+ dst.__kwdefaults__ = src.__kwdefaults__
+ return cast("T", dst)
+
+
+class Func:
+ """This is a function decorator that adds additional Nox-specific metadata."""
+ def __new__( # noqa: PYI034
+ cls, func: Callable[..., Any], *args: Any, **kwargs: Any
+ ) -> Func:
+ self = super().__new__(cls)
+ functools.update_wrapper(self, func)
+ return self
-class Func(FunctionDecorator):
def __init__(
self,
- func: Callable,
+ func: Callable[..., Any],
python: _typing.Python = None,
- reuse_venv: Optional[bool] = None,
- name: Optional[str] = None,
- venv_backend: Any = None,
- venv_params: Any = None,
- should_warn: Dict[str, Any] = None,
- ):
+ reuse_venv: bool | None = None, # noqa: FBT001
+ name: str | None = None,
+ venv_backend: str | None = None,
+ venv_params: Sequence[str] = (),
+ should_warn: Mapping[str, Any] | None = None,
+ tags: Sequence[str] | None = None,
+ *,
+ default: bool = True,
+ requires: Sequence[str] | None = None,
+ download_python: Literal["auto", "never", "always"] | None = None,
+ ) -> None:
self.func = func
self.python = python
self.reuse_venv = reuse_venv
+ self.name = name
self.venv_backend = venv_backend
self.venv_params = venv_params
- self.should_warn = should_warn or dict()
+ self.should_warn = dict(should_warn or {})
+ self.tags = list(tags or [])
+ self.default = default
+ self.requires = list(requires or [])
+ self.download_python = download_python
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}(name={self.name!r})"
def __call__(self, *args: Any, **kwargs: Any) -> Any:
return self.func(*args, **kwargs)
- def copy(self, name: str = None) -> "Func":
+ def copy(self, name: str | None = None) -> Func:
+ """Copy this function with a new name."""
+
return Func(
_copy_func(self.func, name),
self.python,
@@ -62,13 +108,41 @@ def copy(self, name: str = None) -> "Func":
self.venv_backend,
self.venv_params,
self.should_warn,
+ self.tags,
+ default=self.default,
+ requires=self._requires,
+ download_python=self.download_python,
)
+ @property
+ def requires(self) -> list[str]:
+ # Compute dynamically on lookup since ``self.python`` can be modified after
+ # creation (e.g. on an instance from ``self.copy``).
+ return list(map(self.format_dependency, self._requires))
+
+ @requires.setter
+ def requires(self, value: Sequence[str]) -> None:
+ self._requires = list(value)
+
+ def format_dependency(self, dependency: str) -> str:
+ if isinstance(self.python, (bool, str)) or self.python is None:
+ formatted = dependency.format(python=self.python, py=self.python)
+ if (
+ self.python is None or isinstance(self.python, bool)
+ ) and formatted != dependency:
+ msg = "Cannot parametrize requires with {python} when python is None or a bool."
+ raise ValueError(msg)
+ return formatted
+ msg = "The requires of a not-yet-parametrized session cannot be parametrized." # pragma: no cover
+ raise TypeError(msg) # pragma: no cover
+
class Call(Func):
- def __init__(self, func: Func, param_spec: "Param") -> None:
+ """This represents a call of a function with a particular set of arguments."""
+
+ def __init__(self, func: Func, param_spec: Param) -> None:
call_spec = param_spec.call_spec
- session_signature = "({})".format(param_spec)
+ session_signature = f"({param_spec})"
# Determine the Python interpreter for the session using either @session
# or @parametrize. For backwards compatibility, we only use a "python"
@@ -90,14 +164,25 @@ def __init__(self, func: Func, param_spec: "Param") -> None:
func.venv_backend,
func.venv_params,
func.should_warn,
+ func.tags + param_spec.tags,
+ default=func.default,
+ requires=func.requires,
+ download_python=func.download_python,
)
self.call_spec = call_spec
self.session_signature = session_signature
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}(name={self.name!r}, call_spec={self.call_spec!r}, session_signature={self.session_signature!r})"
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
kwargs.update(self.call_spec)
return super().__call__(*args, **kwargs)
@classmethod
- def generate_calls(cls, func: Func, param_specs: "Iterable[Param]") -> "List[Call]":
+ def generate_calls(
+ cls: type[Call], func: Func, param_specs: Iterable[Param]
+ ) -> list[Call]:
+ """Generates a list of calls based on the function and parameters."""
+
return [cls(func, param_spec) for param_spec in param_specs]
diff --git a/nox/_option_set.py b/nox/_option_set.py
index 4bcbb357..953429fb 100644
--- a/nox/_option_set.py
+++ b/nox/_option_set.py
@@ -13,17 +13,72 @@
# limitations under the License.
"""High-level options interface. This allows defining options just once that
-can be specified from the command line and the noxfile, easily used in tests,
+can be specified from the command line and the Noxfile, easily used in tests,
and surfaced in documentation."""
+from __future__ import annotations
+
+__lazy_modules__ = {"argcomplete", "argparse", "functools"}
import argparse
-import collections
import functools
+import os
from argparse import ArgumentError, ArgumentParser, Namespace
-from typing import Any, Callable, List, Optional, Tuple, Union
+from typing import TYPE_CHECKING, Any, Literal
import argcomplete
+import attrs
+import attrs.validators as av
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterable, Sequence
+
+__all__ = [
+ "ArgumentError",
+ "NoxOptions",
+ "Option",
+ "OptionGroup",
+ "OptionSet",
+ "make_flag_pair",
+]
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+
+av_opt_str = av.optional(av.instance_of(str))
+av_opt_path = av.optional(av.or_(av.instance_of(str), av.instance_of(os.PathLike))) # type: ignore[type-abstract]
+av_opt_list_str = av.optional(
+ av.deep_iterable(
+ member_validator=av.instance_of(str),
+ iterable_validator=av.not_(av.instance_of(str)),
+ )
+)
+av_bool = av.instance_of(bool)
+
+
+@attrs.define(slots=True, kw_only=True)
+class NoxOptions:
+ default_venv_backend: None | str = attrs.field(validator=av_opt_str)
+ download_python: None | Literal["auto", "never", "always"] = attrs.field(
+ default=None, validator=av.optional(av.in_(["auto", "never", "always"]))
+ )
+ envdir: None | str | os.PathLike[str] = attrs.field(validator=av_opt_path)
+ error_on_external_run: bool = attrs.field(validator=av_bool)
+ error_on_missing_interpreters: bool = attrs.field(validator=av_bool)
+ force_venv_backend: None | str = attrs.field(validator=av_opt_str)
+ keywords: None | str = attrs.field(validator=av_opt_str)
+ pythons: None | Sequence[str] = attrs.field(validator=av_opt_list_str)
+ report: None | str = attrs.field(validator=av_opt_str)
+ reuse_existing_virtualenvs: bool = attrs.field(validator=av_bool)
+ reuse_venv: None | Literal["no", "yes", "never", "always"] = attrs.field(
+ validator=av.optional(av.in_(["no", "yes", "never", "always"]))
+ )
+ sessions: None | Sequence[str] = attrs.field(validator=av_opt_list_str)
+ stop_on_first_error: bool = attrs.field(validator=av_bool)
+ tags: None | Sequence[str] = attrs.field(validator=av_opt_list_str)
+ verbose: bool = attrs.field(validator=av_bool)
class OptionGroup:
@@ -35,6 +90,8 @@ class OptionGroup:
kwargs: Passed through to``ArgumentParser.add_argument_group``.
"""
+ __slots__ = ("args", "kwargs", "name")
+
def __init__(self, name: str, *args: Any, **kwargs: Any) -> None:
self.name = name
self.args = args
@@ -54,7 +111,7 @@ class Option:
help (str): The help string pass to argparse.
noxfile (bool): Whether or not this option can be set in the
configuration file.
- merge_func (Callable[[Namespace, Namespace], Any]): A function that
+ merge_func (Callable[[Namespace, NoxOptions], Any]): A function that
can define custom behavior when merging the command-line options
with the configuration file options. The first argument is the
command-line options, the second is the configuration file options.
@@ -76,15 +133,17 @@ def __init__(
self,
name: str,
*flags: str,
- group: OptionGroup,
- help: Optional[str] = None,
+ group: OptionGroup | None,
+ help: str | None = None,
noxfile: bool = False,
- merge_func: Optional[Callable[[Namespace, Namespace], Any]] = None,
- finalizer_func: Optional[Callable[[Any, Namespace], Any]] = None,
- default: Union[Any, Callable[[], Any]] = None,
+ merge_func: Callable[[Namespace, NoxOptions], Any] | None = None,
+ finalizer_func: Callable[[Any, Namespace], Any] | None = None,
+ default: (
+ bool | str | None | list[str] | Callable[[], bool | str | None | list[str]]
+ ) = None,
hidden: bool = False,
- completer: Optional[Callable[..., List[str]]] = None,
- **kwargs: Any
+ completer: Callable[..., Iterable[str]] | None = None,
+ **kwargs: Any,
) -> None:
self.name = name
self.flags = flags
@@ -99,7 +158,7 @@ def __init__(
self._default = default
@property
- def default(self) -> Optional[Union[bool, str]]:
+ def default(self) -> bool | str | None | list[str]:
if callable(self._default):
return self._default()
return self._default
@@ -107,11 +166,12 @@ def default(self) -> Optional[Union[bool, str]]:
def flag_pair_merge_func(
enable_name: str,
+ enable_default: bool | Callable[[], bool], # noqa: FBT001
disable_name: str,
command_args: Namespace,
- noxfile_args: Namespace,
+ noxfile_args: NoxOptions,
) -> bool:
- """Merge function for flag pairs. If the flag is set in the noxfile or
+ """Merge function for flag pairs. If the flag is set in the Noxfile or
the command line params, return ``True`` *unless* the disable flag has been
specified on the command-line.
@@ -147,35 +207,40 @@ def flag_pair_merge_func(
noxfile_value = getattr(noxfile_args, enable_name)
command_value = getattr(command_args, enable_name)
disable_value = getattr(command_args, disable_name)
+ default_value = enable_default() if callable(enable_default) else enable_default
+ if default_value and disable_value is None and noxfile_value != default_value:
+ # Makes sure make_flag_pair with default=true can be overridden via noxfile
+ disable_value = True
return (command_value or noxfile_value) and not disable_value
def make_flag_pair(
name: str,
- enable_flags: Union[Tuple[str, str], Tuple[str]],
- disable_flags: Tuple[str],
- **kwargs: Any
-) -> Tuple[Option, Option]:
+ enable_flags: tuple[str, str] | tuple[str],
+ disable_flags: tuple[str, str] | tuple[str],
+ *,
+ default: bool | Callable[[], bool] = False,
+ **kwargs: Any,
+) -> tuple[Option, Option]:
"""Returns two options - one to enable a behavior and another to disable it.
- The positive option is considered to be available to the noxfile, as
+ The positive option is considered to be available to the Noxfile, as
there isn't much point in doing flag pairs without it.
"""
- disable_name = "no_{}".format(name)
+ disable_name = f"no_{name}"
kwargs["action"] = "store_true"
enable_option = Option(
name,
*enable_flags,
noxfile=True,
- merge_func=functools.partial(flag_pair_merge_func, name, disable_name),
- **kwargs
+ merge_func=functools.partial(flag_pair_merge_func, name, default, disable_name),
+ default=default,
+ **kwargs,
)
- kwargs["help"] = "Disables {} if it is enabled in the Noxfile.".format(
- enable_flags[-1]
- )
+ kwargs["help"] = f"Disables {enable_flags[-1]} if it is enabled in the Noxfile."
disable_option = Option(disable_name, *disable_flags, **kwargs)
return enable_option, disable_option
@@ -192,12 +257,8 @@ class OptionSet:
def __init__(self, *args: Any, **kwargs: Any) -> None:
self.parser_args = args
self.parser_kwargs = kwargs
- self.options = (
- collections.OrderedDict()
- ) # type: collections.OrderedDict[str, Option]
- self.groups = (
- collections.OrderedDict()
- ) # type: collections.OrderedDict[str, OptionGroup]
+ self.options: dict[str, Option] = {}
+ self.groups: dict[str, OptionGroup] = {}
def add_options(self, *args: Option) -> None:
"""Adds a sequence of Options to the OptionSet.
@@ -223,7 +284,8 @@ def parser(self) -> ArgumentParser:
Generally, you won't use this directly. Instead, use
:func:`parse_args`.
"""
- parser = argparse.ArgumentParser(*self.parser_args, **self.parser_kwargs)
+ parser_kwargs = {"allow_abbrev": False, **self.parser_kwargs}
+ parser = argparse.ArgumentParser(*self.parser_args, **parser_kwargs)
groups = {
name: parser.add_argument_group(*option_group.args, **option_group.kwargs)
@@ -234,11 +296,16 @@ def parser(self) -> ArgumentParser:
if option.hidden:
continue
+ # Every option must have a group (except for hidden options)
+ if option.group is None:
+ msg = f"Option {option.name} must either have a group or be hidden."
+ raise ValueError(msg)
+
argument = groups[option.group.name].add_argument(
*option.flags, help=option.help, default=option.default, **option.kwargs
)
- if getattr(option, "completer"):
- setattr(argument, "completer", option.completer)
+ if option.completer:
+ argument.completer = option.completer # type: ignore[attr-defined]
return parser
@@ -284,26 +351,27 @@ def namespace(self, **kwargs: Any) -> argparse.Namespace:
# used in tests.
for key, value in kwargs.items():
if key not in args:
- raise KeyError("{} is not an option.".format(key))
+ msg = f"{key} is not an option."
+ raise KeyError(msg)
args[key] = value
return argparse.Namespace(**args)
- def noxfile_namespace(self) -> Namespace:
+ def noxfile_namespace(self) -> NoxOptions:
"""Returns a namespace of options that can be set in the configuration
file."""
- return argparse.Namespace(
+ return NoxOptions(
**{
option.name: option.default
for option in self.options.values()
if option.noxfile
- }
+ } # type: ignore[arg-type]
)
def merge_namespaces(
- self, command_args: Namespace, noxfile_args: Namespace
+ self, command_args: Namespace, noxfile_args: NoxOptions
) -> None:
- """Merges the command-line options with the noxfile options."""
+ """Merges the command-line options with the Noxfile options."""
command_args_copy = Namespace(**vars(command_args))
for name, option in self.options.items():
if option.merge_func:
diff --git a/nox/_options.py b/nox/_options.py
index 770b8438..0f85525f 100644
--- a/nox/_options.py
+++ b/nox/_options.py
@@ -12,16 +12,44 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+"""All of Nox's configuration options."""
+
+from __future__ import annotations
+
+__lazy_modules__ = {"itertools", "nox.tasks"}
+
import argparse
import functools
+import itertools
import os
import sys
-from typing import Any, List, Optional, Sequence, Union
+from typing import TYPE_CHECKING, Any, Literal
+
+import argcomplete
from nox import _option_set
from nox.tasks import discover_manifest, filter_manifest, load_nox_module
+from nox.virtualenv import ALL_VENVS
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterable, Sequence
+
+ from nox._option_set import NoxOptions
+
+
+__all__ = ["ReuseVenvType", "noxfile_options", "options"]
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+
+# User-specified arguments will be a regular string
+class DefaultStr(str):
+ __slots__ = ()
+
-"""All of nox's configuration options."""
+ReuseVenvType = Literal["no", "yes", "never", "always"]
options = _option_set.OptionSet(
description="Nox is a Python automation toolkit.", add_help=False
@@ -29,23 +57,44 @@
options.add_groups(
_option_set.OptionGroup(
- "primary",
- "Primary arguments",
- "These are the most common arguments used when invoking Nox.",
+ "general",
+ "General options",
+ "These are general arguments used when invoking Nox.",
),
_option_set.OptionGroup(
- "secondary",
- "Additional arguments & flags",
- "These arguments are used to control Nox's behavior or control advanced features.",
+ "sessions",
+ "Sessions options",
+ "These arguments are used to control which Nox session(s) to execute.",
+ ),
+ _option_set.OptionGroup(
+ "python",
+ "Python options",
+ "These arguments are used to control which Python version(s) to use.",
+ ),
+ _option_set.OptionGroup(
+ "environment",
+ "Environment options",
+ "These arguments are used to control Nox's creation and usage of virtual"
+ " environments.",
+ ),
+ _option_set.OptionGroup(
+ "execution",
+ "Execution options",
+ "These arguments are used to control execution of sessions.",
+ ),
+ _option_set.OptionGroup(
+ "reporting",
+ "Reporting options",
+ "These arguments are used to control Nox's reporting during execution.",
),
)
-def _sessions_and_keywords_merge_func(
- key: str, command_args: argparse.Namespace, noxfile_args: argparse.Namespace
-) -> List[str]:
- """Only return the Noxfile value for sessions/keywords if neither sessions
- or keywords are specified on the command-line.
+def _sessions_merge_func(
+ key: str, command_args: argparse.Namespace, noxfile_args: NoxOptions
+) -> list[str]:
+ """Only return the Noxfile value for sessions/keywords if neither sessions,
+ keywords or tags are specified on the command-line.
Args:
key (str): This function is used for both the "sessions" and "keywords"
@@ -53,22 +102,26 @@ def _sessions_and_keywords_merge_func(
same function for both options.
command_args (_option_set.Namespace): The options specified on the
command-line.
- noxfile_Args (_option_set.Namespace): The options specified in the
+ noxfile_Args (NoxOptions): The options specified in the
Noxfile."""
- if not command_args.sessions and not command_args.keywords:
- return getattr(noxfile_args, key)
- return getattr(command_args, key)
+ if (
+ not command_args.sessions
+ and not command_args.keywords
+ and not command_args.tags
+ ):
+ return getattr(noxfile_args, key) # type: ignore[no-any-return]
+ return getattr(command_args, key) # type: ignore[no-any-return]
def _default_venv_backend_merge_func(
- command_args: argparse.Namespace, noxfile_args: argparse.Namespace
+ command_args: argparse.Namespace, noxfile_args: NoxOptions
) -> str:
- """Merge default_venv_backend from command args and nox file. Default is "virtualenv".
+ """Merge default_venv_backend from command args and Noxfile. Default is "virtualenv".
Args:
command_args (_option_set.Namespace): The options specified on the
command-line.
- noxfile_Args (_option_set.Namespace): The options specified in the
+ noxfile_Args (NoxOptions): The options specified in the
Noxfile.
"""
return (
@@ -79,14 +132,14 @@ def _default_venv_backend_merge_func(
def _force_venv_backend_merge_func(
- command_args: argparse.Namespace, noxfile_args: argparse.Namespace
+ command_args: argparse.Namespace, noxfile_args: NoxOptions
) -> str:
- """Merge force_venv_backend from command args and nox file. Default is None.
+ """Merge force_venv_backend from command args and Noxfile. Default is None.
Args:
command_args (_option_set.Namespace): The options specified on the
command-line.
- noxfile_Args (_option_set.Namespace): The options specified in the
+ noxfile_Args (NoxOptions): The options specified in the
Noxfile.
"""
if command_args.no_venv:
@@ -94,37 +147,68 @@ def _force_venv_backend_merge_func(
command_args.force_venv_backend is not None
and command_args.force_venv_backend != "none"
):
- raise ValueError(
- "You can not use `--no-venv` with a non-none `--force-venv-backend`"
- )
- else:
- return "none"
- else:
- return command_args.force_venv_backend or noxfile_args.force_venv_backend
+ msg = "You can not use `--no-venv` with a non-none `--force-venv-backend`"
+ raise ValueError(msg)
+ return "none"
+ return command_args.force_venv_backend or noxfile_args.force_venv_backend # type: ignore[return-value]
def _envdir_merge_func(
- command_args: argparse.Namespace, noxfile_args: argparse.Namespace
+ command_args: argparse.Namespace, noxfile_args: NoxOptions
) -> str:
"""Ensure that there is always some envdir.
Args:
command_args (_option_set.Namespace): The options specified on the
command-line.
- noxfile_Args (_option_set.Namespace): The options specified in the
+ noxfile_Args (NoxOptions): The options specified in the
+ Noxfile.
+ """
+ return os.fspath(command_args.envdir or noxfile_args.envdir or ".nox")
+
+
+def _reuse_venv_merge_func(
+ command_args: argparse.Namespace, noxfile_args: NoxOptions
+) -> ReuseVenvType:
+ """Merge reuse_venv from command args and Noxfile while maintaining
+ backwards compatibility with reuse_existing_virtualenvs. Default is "no".
+
+ Args:
+ command_args (_option_set.Namespace): The options specified on the
+ command-line.
+ noxfile_Args (NoxOptions): The options specified in the
Noxfile.
"""
- return command_args.envdir or noxfile_args.envdir or ".nox"
+ # back-compat scenario with no_reuse_existing_virtualenvs/reuse_existing_virtualenvs
+ if command_args.no_reuse_existing_virtualenvs:
+ return "no"
+ if (
+ command_args.reuse_existing_virtualenvs
+ or noxfile_args.reuse_existing_virtualenvs
+ ):
+ return "yes"
+ # regular option behavior
+ return command_args.reuse_venv or noxfile_args.reuse_venv or "no"
-def _sessions_default() -> Optional[List[str]]:
- """Looks at the NOXSESSION env var to set the default value for sessions."""
- nox_env = os.environ.get("NOXSESSION")
- env_sessions = nox_env.split(",") if nox_env else None
- return env_sessions
+def default_env_var_list_factory(env_var: str) -> Callable[[], list[str] | None]:
+ """Looks at the env var to set the default value for a list of env vars.
+ Args:
+ env_var (str): The name of the environment variable to look up.
-def _color_finalizer(value: bool, args: argparse.Namespace) -> bool:
+ Returns:
+ A callback that retrieves a list from a comma-delimited environment variable.
+ """
+
+ def _default_list() -> list[str] | None:
+ env_value = os.environ.get(env_var)
+ return env_value.split(",") if env_value else None
+
+ return _default_list
+
+
+def _color_finalizer(_value: bool, args: argparse.Namespace) -> bool: # noqa: FBT001
"""Figures out the correct value for "color" based on the two color flags.
Args:
@@ -134,15 +218,15 @@ def _color_finalizer(value: bool, args: argparse.Namespace) -> bool:
Returns:
The new value for the "color" option.
"""
- if args.forcecolor is True and args.nocolor is True:
+ if args.forcecolor and args.nocolor:
raise _option_set.ArgumentError(
None, "Can not specify both --no-color and --force-color."
)
- if args.forcecolor is True:
+ if args.forcecolor:
return True
- if args.nocolor is True:
+ if args.nocolor or "NO_COLOR" in os.environ:
return False
return sys.stdout.isatty()
@@ -157,16 +241,27 @@ def _force_pythons_finalizer(
return value
-def _R_finalizer(value: bool, args: argparse.Namespace) -> bool:
- """Propagate -R to --reuse-existing-virtualenvs and --no-install."""
+def _R_finalizer(value: bool, args: argparse.Namespace) -> bool: # noqa: FBT001
+ """Propagate -R to --reuse-existing-virtualenvs and --no-install and --reuse-venv=yes."""
if value:
+ args.reuse_venv = "yes"
args.reuse_existing_virtualenvs = args.no_install = value
return value
+def _reuse_existing_virtualenvs_finalizer(
+ value: bool, # noqa: FBT001
+ args: argparse.Namespace,
+) -> bool:
+ """Propagate --reuse-existing-virtualenvs to --reuse-venv=yes."""
+ if value:
+ args.reuse_venv = "yes"
+ return value
+
+
def _posargs_finalizer(
- value: Sequence[Any], args: argparse.Namespace
-) -> Union[Sequence[Any], List[Any]]:
+ value: Sequence[Any], _args: argparse.Namespace
+) -> Sequence[Any] | list[Any]:
"""Removes the leading "--"s in the posargs array (if any) and asserts that
remaining arguments came after a "--".
"""
@@ -177,31 +272,61 @@ def _posargs_finalizer(
if "--" not in posargs:
unexpected_posargs = posargs
raise _option_set.ArgumentError(
- None, "Unknown argument(s) '{}'.".format(" ".join(unexpected_posargs))
+ None, f"Unknown argument(s) '{' '.join(unexpected_posargs)}'."
)
dash_index = posargs.index("--")
if dash_index != 0:
unexpected_posargs = posargs[0:dash_index]
raise _option_set.ArgumentError(
- None, "Unknown argument(s) '{}'.".format(" ".join(unexpected_posargs))
+ None, f"Unknown argument(s) '{' '.join(unexpected_posargs)}'."
)
return posargs[dash_index + 1 :]
+def _python_completer(
+ prefix: str, # noqa: ARG001
+ parsed_args: argparse.Namespace,
+ **kwargs: Any,
+) -> Iterable[str]:
+ module = load_nox_module(parsed_args)
+ manifest = discover_manifest(module, parsed_args)
+ return filter(
+ None,
+ (
+ session.func.python # type:ignore[misc] # str sequences flattened, other non-strs falsey and filtered out
+ for session, _ in manifest.list_all_sessions()
+ ),
+ )
+
+
def _session_completer(
- prefix: str, parsed_args: argparse.Namespace, **kwargs: Any
-) -> List[str]:
- global_config = parsed_args
- module = load_nox_module(global_config)
- manifest = discover_manifest(module, global_config)
- filtered_manifest = filter_manifest(manifest, global_config)
+ prefix: str, # noqa: ARG001
+ parsed_args: argparse.Namespace,
+ **kwargs: Any,
+) -> Iterable[str]:
+ parsed_args.list_sessions = True
+ module = load_nox_module(parsed_args)
+ manifest = discover_manifest(module, parsed_args)
+ filtered_manifest = filter_manifest(manifest, parsed_args)
if isinstance(filtered_manifest, int):
return []
- return [
+ return (
session.friendly_name for session, _ in filtered_manifest.list_all_sessions()
- ]
+ )
+
+
+def _tag_completer(
+ prefix: str, # noqa: ARG001
+ parsed_args: argparse.Namespace,
+ **kwargs: Any,
+) -> Iterable[str]:
+ module = load_nox_module(parsed_args)
+ manifest = discover_manifest(module, parsed_args)
+ return itertools.chain.from_iterable(
+ filter(None, (session.tags for session, _ in manifest.list_all_sessions()))
+ )
options.add_options(
@@ -209,38 +334,67 @@ def _session_completer(
"help",
"-h",
"--help",
- group=options.groups["primary"],
+ group=options.groups["general"],
action="store_true",
help="Show this help message and exit.",
),
_option_set.Option(
"version",
"--version",
- group=options.groups["primary"],
+ group=options.groups["general"],
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",
"--list-sessions",
"--list",
- group=options.groups["primary"],
+ group=options.groups["sessions"],
action="store_true",
help="List all available sessions and exit.",
),
+ _option_set.Option(
+ "usage",
+ "--usage",
+ group=options.groups["sessions"],
+ nargs=1,
+ help="Print the full docstring of a given session and exit. Raises if there is no docstring.",
+ ),
+ _option_set.Option(
+ "json",
+ "--json",
+ group=options.groups["sessions"],
+ action="store_true",
+ help="JSON output formatting. Requires list-sessions currently.",
+ ),
_option_set.Option(
"sessions",
"-s",
"-e",
"--sessions",
"--session",
- group=options.groups["primary"],
+ group=options.groups["sessions"],
noxfile=True,
- merge_func=functools.partial(_sessions_and_keywords_merge_func, "sessions"),
+ merge_func=functools.partial(_sessions_merge_func, "sessions"),
nargs="*",
- default=_sessions_default,
- help="Which sessions to run. By default, all sessions will run.",
+ default=default_env_var_list_factory("NOXSESSION"),
+ help=(
+ "Which sessions to run. By default, all sessions will run."
+ " Environment variable: NOXSESSION"
+ ),
completer=_session_completer,
),
_option_set.Option(
@@ -248,93 +402,130 @@ def _session_completer(
"-p",
"--pythons",
"--python",
- group=options.groups["primary"],
+ group=options.groups["python"],
noxfile=True,
nargs="*",
- help="Only run sessions that use the given python interpreter versions.",
+ default=default_env_var_list_factory("NOXPYTHON"),
+ help=(
+ "Only run sessions that use the given python interpreter versions."
+ " Environment variable: NOXPYTHON"
+ ),
+ completer=_python_completer,
),
_option_set.Option(
"keywords",
"-k",
"--keywords",
- group=options.groups["primary"],
+ group=options.groups["sessions"],
noxfile=True,
- merge_func=functools.partial(_sessions_and_keywords_merge_func, "keywords"),
+ merge_func=functools.partial(_sessions_merge_func, "keywords"),
help="Only run sessions that match the given expression.",
+ completer=argcomplete.completers.ChoicesCompleter(()), # type: ignore[no-untyped-call]
+ ),
+ _option_set.Option(
+ "tags",
+ "-t",
+ "--tags",
+ group=options.groups["sessions"],
+ noxfile=True,
+ merge_func=functools.partial(_sessions_merge_func, "tags"),
+ nargs="*",
+ help="Only run sessions with the given tags.",
+ completer=_tag_completer,
),
_option_set.Option(
"posargs",
"posargs",
- group=options.groups["primary"],
+ group=options.groups["general"],
nargs=argparse.REMAINDER,
help="Arguments following ``--`` that are passed through to the session(s).",
finalizer_func=_posargs_finalizer,
),
- _option_set.Option(
+ *_option_set.make_flag_pair(
"verbose",
- "-v",
- "--verbose",
- group=options.groups["secondary"],
- action="store_true",
+ ("-v", "--verbose"),
+ ("--no-verbose",),
+ group=options.groups["reporting"],
help="Logs the output of all commands run including commands marked silent.",
- noxfile=True,
),
_option_set.Option(
"add_timestamp",
"-ts",
"--add-timestamp",
- group=options.groups["secondary"],
+ group=options.groups["reporting"],
action="store_true",
help="Adds a timestamp to logged output.",
- noxfile=True,
),
_option_set.Option(
"default_venv_backend",
"-db",
"--default-venv-backend",
- group=options.groups["secondary"],
+ group=options.groups["environment"],
noxfile=True,
+ default=lambda: os.environ.get("NOX_DEFAULT_VENV_BACKEND"),
merge_func=_default_venv_backend_merge_func,
- help="Virtual environment backend to use by default for nox sessions, this is ``'virtualenv'`` by default but "
- "any of ``('virtualenv', 'conda', 'venv')`` are accepted.",
- choices=["none", "virtualenv", "conda", "venv"],
+ help=(
+ "Virtual environment backend to use by default for Nox sessions, this is"
+ f" ``'virtualenv'`` by default but any of ``{list(ALL_VENVS)!r}`` are accepted."
+ ),
+ choices=list(ALL_VENVS),
),
_option_set.Option(
"force_venv_backend",
"-fb",
"--force-venv-backend",
- group=options.groups["secondary"],
+ group=options.groups["environment"],
noxfile=True,
merge_func=_force_venv_backend_merge_func,
- help="Virtual environment backend to force-use for all nox sessions in this run, overriding any other venv "
- "backend declared in the nox file and ignoring the default backend. Any of ``('virtualenv', 'conda', 'venv')`` "
- "are accepted.",
- choices=["none", "virtualenv", "conda", "venv"],
+ help=(
+ "Virtual environment backend to force-use for all Nox sessions in this run,"
+ " overriding any other venv backend declared in the Noxfile and ignoring"
+ f" the default backend. Any of ``{list(ALL_VENVS)!r}`` are accepted."
+ ),
+ choices=list(ALL_VENVS),
),
_option_set.Option(
"no_venv",
"--no-venv",
- group=options.groups["secondary"],
+ group=options.groups["environment"],
default=False,
action="store_true",
- help="Runs the selected sessions directly on the current interpreter, without creating a venv. This is an alias "
- "for '--force-venv-backend none'.",
+ help=(
+ "Runs the selected sessions directly on the current interpreter, without"
+ " creating a venv. This is an alias for '--force-venv-backend none'."
+ ),
+ ),
+ _option_set.Option(
+ "reuse_venv",
+ "--reuse-venv",
+ group=options.groups["environment"],
+ noxfile=True,
+ merge_func=_reuse_venv_merge_func,
+ help=(
+ "Controls existing virtualenvs recreation. This is ``'no'`` by"
+ " default, but any of ``('yes', 'no', 'always', 'never')`` are accepted."
+ ),
+ choices=["yes", "no", "always", "never"],
),
*_option_set.make_flag_pair(
"reuse_existing_virtualenvs",
("-r", "--reuse-existing-virtualenvs"),
- ("--no-reuse-existing-virtualenvs",),
- group=options.groups["secondary"],
- help="Re-use existing virtualenvs instead of recreating them.",
+ (
+ "-N",
+ "--no-reuse-existing-virtualenvs",
+ ),
+ group=options.groups["environment"],
+ help="This is an alias for '--reuse-venv=yes|no'.",
+ finalizer_func=_reuse_existing_virtualenvs_finalizer,
),
_option_set.Option(
"R",
"-R",
default=False,
- group=options.groups["secondary"],
+ group=options.groups["environment"],
action="store_true",
help=(
- "Re-use existing virtualenvs and skip package re-installation."
+ "Reuse existing virtualenvs and skip package re-installation."
" This is an alias for '--reuse-existing-virtualenvs --no-install'."
),
finalizer_func=_R_finalizer,
@@ -343,63 +534,91 @@ def _session_completer(
"noxfile",
"-f",
"--noxfile",
- group=options.groups["secondary"],
- default="noxfile.py",
- help="Location of the Python file containing nox sessions.",
+ group=options.groups["general"],
+ default=DefaultStr("noxfile.py"),
+ help="Location of the Python file containing Nox sessions.",
),
_option_set.Option(
"envdir",
"--envdir",
noxfile=True,
merge_func=_envdir_merge_func,
- group=options.groups["secondary"],
- help="Directory where nox will store virtualenvs, this is ``.nox`` by default.",
+ group=options.groups["environment"],
+ help="Directory where Nox will store virtualenvs, this is ``.nox`` by default.",
+ completer=argcomplete.completers.DirectoriesCompleter(), # type: ignore[no-untyped-call]
+ ),
+ _option_set.Option(
+ "download_python",
+ "--download-python",
+ noxfile=True,
+ group=options.groups["python"],
+ default=lambda: os.getenv("NOX_DOWNLOAD_PYTHON"),
+ help=(
+ "When should nox download python standalone builds to run the sessions,"
+ " defaults to 'auto' which will download when the version requested can't"
+ " be found in the running environment. Environment variable: NOX_DOWNLOAD_PYTHON"
+ ),
+ choices=["auto", "never", "always"],
),
_option_set.Option(
"extra_pythons",
"--extra-pythons",
"--extra-python",
- group=options.groups["secondary"],
+ group=options.groups["python"],
nargs="*",
- help="Additionally, run sessions using the given python interpreter versions.",
+ default=default_env_var_list_factory("NOXEXTRAPYTHON"),
+ help=(
+ "Additionally, run sessions using the given python interpreter versions."
+ " Environment variable: NOXEXTRAPYTHON"
+ ),
+ completer=_python_completer,
),
_option_set.Option(
"force_pythons",
+ "-P",
"--force-pythons",
"--force-python",
- group=options.groups["secondary"],
+ group=options.groups["python"],
nargs="*",
+ default=default_env_var_list_factory("NOXFORCEPYTHON"),
help=(
- "Run sessions with the given interpreters instead of those listed in the Noxfile."
- " This is a shorthand for ``--python=X.Y --extra-python=X.Y``."
+ "Run sessions with the given interpreters instead of those listed in the"
+ " Noxfile. This is a shorthand for ``--python=X.Y --extra-python=X.Y``."
+ " It will also work on sessions that don't have any interpreter parametrized."
+ " Environment variable: NOXFORCEPYTHON"
),
finalizer_func=_force_pythons_finalizer,
+ completer=_python_completer,
),
*_option_set.make_flag_pair(
"stop_on_first_error",
("-x", "--stop-on-first-error"),
("--no-stop-on-first-error",),
- group=options.groups["secondary"],
+ group=options.groups["execution"],
help="Stop after the first error.",
),
*_option_set.make_flag_pair(
"error_on_missing_interpreters",
("--error-on-missing-interpreters",),
("--no-error-on-missing-interpreters",),
- group=options.groups["secondary"],
+ group=options.groups["execution"],
help="Error instead of skipping sessions if an interpreter can not be located.",
+ default=lambda: "CI" in os.environ,
),
*_option_set.make_flag_pair(
"error_on_external_run",
("--error-on-external-run",),
("--no-error-on-external-run",),
- group=options.groups["secondary"],
- help="Error if run() is used to execute a program that isn't installed in a session's virtualenv.",
+ group=options.groups["execution"],
+ help=(
+ "Error if run() is used to execute a program that isn't installed in a"
+ " session's virtualenv."
+ ),
),
_option_set.Option(
"install_only",
"--install-only",
- group=options.groups["secondary"],
+ group=options.groups["execution"],
action="store_true",
help="Skip session.run invocations in the Noxfile.",
),
@@ -407,53 +626,68 @@ def _session_completer(
"no_install",
"--no-install",
default=False,
- group=options.groups["secondary"],
+ group=options.groups["execution"],
action="store_true",
help=(
"Skip invocations of session methods for installing packages"
- " (session.install, session.conda_install, session.run_always)"
+ " (session.install, session.conda_install, session.run_install)"
" when a virtualenv is being reused."
),
),
_option_set.Option(
"report",
"--report",
- group=options.groups["secondary"],
+ group=options.groups["reporting"],
noxfile=True,
help="Output a report of all sessions to the given filename.",
+ completer=argcomplete.completers.FilesCompleter(("json",)), # type: ignore[no-untyped-call]
),
_option_set.Option(
"non_interactive",
"--non-interactive",
- group=options.groups["secondary"],
+ group=options.groups["execution"],
action="store_true",
- help="Force session.interactive to always be False, even in interactive sessions.",
+ help=(
+ "Force session.interactive to always be False, even in interactive"
+ " sessions."
+ ),
),
_option_set.Option(
"nocolor",
"--nocolor",
"--no-color",
- group=options.groups["secondary"],
+ group=options.groups["reporting"],
default=lambda: "NO_COLOR" in os.environ,
action="store_true",
- help="Disable all color output.",
+ help="Disable all color output. Environment variable: NO_COLOR",
),
_option_set.Option(
"forcecolor",
"--forcecolor",
"--force-color",
- group=options.groups["secondary"],
- default=False,
+ group=options.groups["reporting"],
+ default=lambda: (
+ os.environ.get("FORCE_COLOR", "").lower()
+ not in {"", "0", "false", "no", "off"}
+ ),
action="store_true",
help="Force color output, even if stdout is not an interactive terminal.",
),
_option_set.Option(
"color",
"--color",
- group=options.groups["secondary"],
+ group=options.groups["reporting"],
hidden=True,
finalizer_func=_color_finalizer,
),
+ # Stores the original working directory that Nox was invoked from,
+ # since it could be different from the Noxfile's directory.
+ _option_set.Option(
+ "invoked_from",
+ group=None,
+ hidden=True,
+ default=os.getcwd,
+ ),
)
diff --git a/nox/_parametrize.py b/nox/_parametrize.py
index be226e46..d4ef69bd 100644
--- a/nox/_parametrize.py
+++ b/nox/_parametrize.py
@@ -12,9 +12,23 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
+__lazy_modules__ = {"functools", "itertools"}
+
import functools
import itertools
-from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, Union
+from collections.abc import Iterable
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Sequence
+
+__all__ = ["Param", "parametrize_decorator", "update_param_specs"]
+
+
+def __dir__() -> list[str]:
+ return __all__
class Param:
@@ -26,15 +40,18 @@ class Param:
arg_names (Sequence[str]): The names of the args.
id (str): An optional ID for this set of parameters. If unspecified,
it will be generated from the parameters.
+ tags (Sequence[str]): Optional tags to associate with this set of
+ parameters.
"""
def __init__(
self,
*args: Any,
- arg_names: Optional[Sequence[str]] = None,
- id: Optional[str] = None
+ arg_names: Sequence[str] | None = None,
+ id: str | None = None,
+ tags: Sequence[str] | None = None,
) -> None:
- self.args = tuple(args)
+ self.args = args
self.id = id
if arg_names is None:
@@ -42,28 +59,33 @@ def __init__(
self.arg_names = tuple(arg_names)
+ if tags is None:
+ tags = []
+
+ self.tags = list(tags)
+
@property
- def call_spec(self) -> Dict[str, Any]:
- return dict(zip(self.arg_names, self.args))
+ def call_spec(self) -> dict[str, Any]:
+ return dict(zip(self.arg_names, self.args, strict=True))
def __str__(self) -> str:
if self.id:
return self.id
- else:
- call_spec = self.call_spec
- args = ["{}={}".format(k, repr(call_spec[k])) for k in call_spec.keys()]
- return ", ".join(args)
+ return ", ".join(f"{k}={v!r}" for k, v in self.call_spec.items())
__repr__ = __str__
+ __hash__ = None # type: ignore[assignment]
- def copy(self) -> "Param":
- new = self.__class__(*self.args, arg_names=self.arg_names, id=self.id)
- return new
+ def copy(self) -> Param:
+ return self.__class__(
+ *self.args, arg_names=self.arg_names, id=self.id, tags=self.tags
+ )
- def update(self, other: "Param") -> None:
+ def update(self, other: Param) -> None:
self.id = ", ".join([str(self), str(other)])
- self.args = self.args + other.args
- self.arg_names = self.arg_names + other.arg_names
+ self.args += other.args
+ self.arg_names += other.arg_names
+ self.tags += other.tags
def __eq__(self, other: object) -> bool:
if isinstance(other, self.__class__):
@@ -71,34 +93,36 @@ def __eq__(self, other: object) -> bool:
self.args == other.args
and self.arg_names == other.arg_names
and self.id == other.id
+ and self.tags == other.tags
)
- elif isinstance(other, dict):
- return dict(zip(self.arg_names, self.args)) == other
+ if isinstance(other, dict):
+ return dict(zip(self.arg_names, self.args, strict=True)) == other
- raise NotImplementedError
+ return NotImplemented
-def _apply_param_specs(param_specs: List[Param], f: Any) -> Any:
+def _apply_param_specs(param_specs: Iterable[Param], f: Any) -> Any:
previous_param_specs = getattr(f, "parametrize", None)
new_param_specs = update_param_specs(previous_param_specs, param_specs)
- setattr(f, "parametrize", new_param_specs)
+ f.parametrize = new_param_specs
return f
-ArgValue = Union[Param, Iterable[Any]]
+ArgValue = Param | Iterable[Any]
def parametrize_decorator(
- arg_names: Union[str, List[str], Tuple[str]],
- arg_values_list: Union[Iterable[ArgValue], ArgValue],
- ids: Optional[Iterable[Optional[str]]] = None,
+ arg_names: str | Sequence[str],
+ arg_values_list: Iterable[ArgValue] | ArgValue,
+ ids: Iterable[str | None] | None = None,
+ tags: Iterable[Sequence[str]] | None = None,
) -> Callable[[Any], Any]:
"""Parametrize a session.
Add new invocations to the underlying session function using the list of
``arg_values_list`` for the given ``arg_names``. Parametrization is
performed during session discovery and each invocation appears as a
- separate session to nox.
+ separate session to Nox.
Args:
arg_names (Sequence[str]): A list of argument names.
@@ -111,15 +135,17 @@ def parametrize_decorator(
argument name, for example ``[(1, 'a'), (2, 'b')]``.
ids (Sequence[str]): Optional sequence of test IDs to use for the
parametrized arguments.
+ tags (Iterable[Sequence[str]]): Optional iterable of tags to associate
+ with the parametrized arguments.
"""
# Allow args names to be specified as any of 'arg', 'arg,arg2' or ('arg', 'arg2')
- if not isinstance(arg_names, (list, tuple)):
+ if isinstance(arg_names, str):
arg_names = list(filter(None, [arg.strip() for arg in arg_names.split(",")]))
# If there's only one arg_name, arg_values_list should be a single item
# or list. Transform it so it'll work with the combine step.
- _arg_values_list = [] # type: List[Union[Param, Iterable[Union[Any, ArgValue]]]]
+ _arg_values_list: list[Param | Iterable[Any | ArgValue]] = []
if len(arg_names) == 1:
# In this case, the arg_values_list can also just be a single item.
# Must be mutable for the transformation steps
@@ -140,14 +166,21 @@ def parametrize_decorator(
if not ids:
ids = []
+ if tags is None:
+ tags = []
+
# Generate params for each item in the param_args_values list.
- param_specs = [] # type: List[Param]
- for param_arg_values, param_id in itertools.zip_longest(_arg_values_list, ids):
+ param_specs: list[Param] = []
+ for param_arg_values, param_id, param_tags in itertools.zip_longest(
+ _arg_values_list, ids, tags
+ ):
if isinstance(param_arg_values, Param):
param_spec = param_arg_values
param_spec.arg_names = tuple(arg_names)
else:
- param_spec = Param(*param_arg_values, arg_names=arg_names, id=param_id)
+ param_spec = Param(
+ *param_arg_values, arg_names=arg_names, id=param_id, tags=param_tags
+ )
param_specs.append(param_spec)
@@ -155,17 +188,17 @@ def parametrize_decorator(
def update_param_specs(
- param_specs: Iterable[Param], new_specs: List[Param]
-) -> List[Param]:
+ param_specs: Iterable[Param] | None, new_specs: Iterable[Param]
+) -> list[Param]:
"""Produces all combinations of the given sets of specs."""
if not param_specs:
- return new_specs
+ return list(new_specs)
# New specs must be combined with old specs by *multiplying* them.
combined_specs = []
for new_spec in new_specs:
for spec in param_specs:
- spec = spec.copy()
- spec.update(new_spec)
- combined_specs.append(spec)
+ spec_copy = spec.copy()
+ spec_copy.update(new_spec)
+ combined_specs.append(spec_copy)
return combined_specs
diff --git a/nox/_resolver.py b/nox/_resolver.py
new file mode 100644
index 00000000..64a84964
--- /dev/null
+++ b/nox/_resolver.py
@@ -0,0 +1,207 @@
+# Copyright 2022 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+__lazy_modules__ = {"itertools"}
+
+import itertools
+from collections.abc import Hashable, Iterable, Iterator, Mapping
+from typing import TypeVar
+
+__all__ = ["CycleError", "lazy_stable_topo_sort"]
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+
+Node = TypeVar("Node", bound=Hashable)
+
+
+class CycleError(ValueError):
+ """An exception indicating that a cycle was encountered in a graph."""
+
+
+def lazy_stable_topo_sort(
+ dependencies: Mapping[Node, Iterable[Node]],
+ root: Node,
+ *,
+ drop_root: bool = True,
+) -> Iterator[Node]:
+ """Returns the "lazy, stable" topological sort of a dependency graph.
+
+ The sort returned will be a topological sort of the subgraph containing only
+ ``root`` and its (recursive) dependencies. ``root`` will not be included in the
+ output sort if ``drop_root`` is ``True``.
+
+ The sort returned is "lazy" in the sense that a node will not appear any earlier in
+ the output sort than is necessitated by its dependents.
+
+ The sort returned is "stable" in the sense that the relative order of two nodes in
+ ``dependencies[node]`` is preserved in the output sort, except when doing so would
+ prevent the output sort from being either topological or lazy. The order of nodes in
+ ``dependencies[node]`` allows the caller to exert a preference on the order of the
+ output sort.
+
+ For example, consider:
+
+ >>> list(
+ ... lazy_stable_topo_sort(
+ ... dependencies = {
+ ... "a": ["c", "b"],
+ ... "b": [],
+ ... "c": [],
+ ... "d": ["e"],
+ ... "e": ["c"],
+ ... "root": ["a", "d"],
+ ... },
+ ... "root",
+ ... drop_root=False,
+ ... )
+ ... )
+ ["c", "b", "a", "e", "d", "root"]
+
+ Notice that:
+
+ 1. This is a topological sort of the dependency graph. That is, nodes only
+ occur in the sort after all of their dependencies occur.
+
+ 2. Had we also included a node ``"f": ["b"]`` but kept ``dependencies["root"]``
+ the same, the output would not have changed. This is because ``"f"`` was not
+ requested directly by including it in ``dependencies["root"]`` or
+ transitively as a (recursive) dependency of a node in
+ ``dependencies["root"]``.
+
+ 3. ``"e"`` occurs no earlier than was required by its dependents ``{"d"}``.
+ This is an example of the sort being "lazy". If ``"e"`` had occurred in the
+ output any earlier---for example, just before ``"a"``---the sort would not
+ have been lazy, but (in this example) the output would still have been a
+ topological sort.
+
+ 4. Because the topological order between ``"a"`` and ``"d"`` is undefined and
+ because it is possible to do so without making the output sort non-lazy,
+ ``"a"`` and ``"d"`` are kept in the relative order that they have in
+ ``dependencies["root"]``. This is an example of the sort being stable
+ between pairs in ``dependencies[node]`` whenever possible. If ``"a"``'s
+ dependency list was instead ``["d"]``, however, the relative order between
+ ``"a"`` and ``"d"`` in ``dependencies["root"]`` would have been ignored to
+ satisfy this dependency.
+
+ Similarly, ``"b"`` and ``"c"`` are kept in the relative order that they have
+ in ``dependencies["a"]``. If ``"c"``'s dependency list was instead
+ ``["b"]``, however, the relative order between ``"b"`` and ``"c"`` in
+ ``dependencies["a"]`` would have been ignored to satisfy this dependency.
+
+ This implementation of this function is recursive and thus should not be used on
+ large dependency graphs, but it is suitable for noxfile-sized dependency graphs.
+
+ Args:
+ dependencies (Mapping[~nox._resolver.Node, Iterable[~nox._resolver.Node]]):
+ A mapping from each node in the graph to the (ordered) list of nodes that it
+ depends on. Using a mapping type with O(1) lookup (e.g. `dict`) is strongly
+ suggested.
+ root (~nox._resolver.Node):
+ The root node to start the sort at. If ``drop_root`` is not ``True``,
+ ``root`` will be the last element of the output.
+ drop_root (bool):
+ If ``True``, ``root`` will be not be included in the output sort. Defaults
+ to ``True``.
+
+
+ Returns:
+ Iterator[~nox._resolver.Node]: The "lazy, stable" topological sort of the
+ subgraph containing ``root`` and its dependencies.
+
+ Raises:
+ ~nox._resolver.CycleError: If a dependency cycle is encountered.
+ """
+
+ visited = dict.fromkeys(dependencies, False)
+
+ def prepended_by_dependencies(
+ node: Node,
+ walk: dict[Node, None] | None = None,
+ ) -> Iterator[Node]:
+ """Yields a node's dependencies depth-first, followed by the node itself.
+
+ A dependency will be skipped if has already been yielded by another call of
+ ``prepended_by_dependencies``. Since ``prepended_by_dependencies`` is recursive,
+ this means that each node will only be yielded once, and only the deepest
+ occurrence of a node will be yielded.
+
+ Args:
+ node (~nox._resolver.Node):
+ A node in the dependency graph.
+ walk (dict[~nox._resolver.Node, None] | None):
+ An ordered ``dict`` whose keys are the nodes traversed when walking a
+ path leading up to ``node`` on the reversed-edge dependency graph.
+ Defaults to ``{}``.
+
+ Yields:
+ ~nox._resolver.Node: ``node``'s direct dependencies, each
+ prepended by their own direct dependencies, and so forth recursively,
+ depth-first, followed by ``node``.
+
+ Raises:
+ ValueError: If a dependency cycle is encountered.
+ """
+ # We would like for ``walk`` to be an ordered set so that we get (a) O(1) ``node
+ # in walk`` and (b) so that we can use the order to report to the user what the
+ # dependency cycle is, if one is encountered. The standard library does not have
+ # an ordered set type, so we instead use the keys of an ``dict[Node, None]`` as
+ # an ordered set.
+ walk = walk or {}
+ walk = extend_walk(walk, node)
+ if not visited[node]:
+ visited[node] = True
+ # Recurse for each node in dependencies[node] in order so that we adhere to
+ # the ``dependencies[node]`` order preference if doing so is possible.
+ yield from itertools.chain.from_iterable(
+ prepended_by_dependencies(dependency, walk)
+ for dependency in dependencies[node]
+ )
+ yield node
+
+ def extend_walk(walk: dict[Node, None], node: Node) -> dict[Node, None]:
+ """Extend a walk by a node, checking for dependency cycles.
+
+ Args:
+ walk (dict[~nox._resolver.Node, None]):
+ See ``prepended_by_dependencies``.
+ nodes (~nox._resolver.Node):
+ A node to extend the walk with.
+
+ Returns:
+ dict[~nox._resolver.Node, None]: ``walk``, extended by
+ ``node``.
+
+ Raises:
+ ValueError: If extending ``walk`` by ``node`` introduces a cycle into the
+ represented walk on the dependency graph.
+ """
+ walk = walk.copy()
+ if node in walk:
+ # Dependency cycle found.
+ walk_list = list(walk)
+ cycle = [*walk_list[walk_list.index(node) :], node]
+ msg = "Nodes are in a dependency cycle"
+ raise CycleError(msg, tuple(cycle))
+ walk[node] = None
+ return walk
+
+ sort = prepended_by_dependencies(root)
+ if drop_root:
+ return (node for node in sort if node != root)
+ return sort
diff --git a/nox/_typing.py b/nox/_typing.py
index d323f04a..3e1c96fd 100644
--- a/nox/_typing.py
+++ b/nox/_typing.py
@@ -1,30 +1,26 @@
-__all__ = ["TYPE_CHECKING", "ClassVar", "NoReturn", "Python"]
+# Copyright 2020 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
-import typing as _typing
+from __future__ import annotations
-try:
- from typing import TYPE_CHECKING
-except ImportError:
- try:
- from typing_extensions import TYPE_CHECKING
- except ImportError:
- TYPE_CHECKING = False
+from collections.abc import Sequence
-try:
- from typing import NoReturn
-except ImportError:
- try:
- from typing_extensions import NoReturn
- except ImportError:
- pass
+__all__ = ["Python"]
-try:
- from typing import ClassVar
-except ImportError:
- try:
- from typing_extensions import ClassVar
- except ImportError:
- pass
+def __dir__() -> list[str]:
+ return __all__
-Python = _typing.Optional[_typing.Union[str, _typing.Sequence[str], bool]]
+
+Python = str | Sequence[str] | bool | None
diff --git a/nox/_version.py b/nox/_version.py
index 740b7fd3..909e96ca 100644
--- a/nox/_version.py
+++ b/nox/_version.py
@@ -12,18 +12,29 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
+__lazy_modules__ = {
+ "ast",
+ "contextlib",
+ "importlib",
+ "packaging",
+ "packaging.specifiers",
+ "packaging.version",
+}
+
import ast
import contextlib
-import sys
-from typing import Optional
+from importlib import metadata
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
-if sys.version_info >= (3, 8): # pragma: no cover
- import importlib.metadata as metadata
-else: # pragma: no cover
- import importlib_metadata as metadata
+__all__ = ["InvalidVersionSpecifier", "VersionCheckFailed", "check_nox_version"]
+
+
+def __dir__() -> list[str]:
+ return __all__
class VersionCheckFailed(Exception):
@@ -39,36 +50,29 @@ def get_nox_version() -> str:
return metadata.version("nox")
-def _parse_string_constant(node: ast.AST) -> Optional[str]: # pragma: no cover
- """Return the value of a string constant."""
- if sys.version_info < (3, 8):
- if isinstance(node, ast.Str) and isinstance(node.s, str):
- return node.s
- elif isinstance(node, ast.Constant) and isinstance(node.value, str):
- return node.value
- return None
-
-
-def _parse_needs_version(source: str, filename: str = "") -> Optional[str]:
- """Parse ``nox.needs_version`` from the user's noxfile."""
- value: Optional[str] = None
+def _parse_needs_version(source: str, filename: str = "") -> str | None:
+ """Parse ``nox.needs_version`` from the user's Noxfile."""
+ value: str | None = None
module: ast.Module = ast.parse(source, filename=filename)
for statement in module.body:
- if isinstance(statement, ast.Assign):
- for target in statement.targets:
- if (
- isinstance(target, ast.Attribute)
- and isinstance(target.value, ast.Name)
- and target.value.id == "nox"
- and target.attr == "needs_version"
- ):
- value = _parse_string_constant(statement.value)
+ match statement:
+ case ast.Assign(
+ targets=[
+ ast.Attribute(
+ value=ast.Name(id="nox"),
+ attr="needs_version",
+ ),
+ *_,
+ ],
+ value=ast.Constant(value=str() as constant_value),
+ ):
+ value = constant_value
return value
-def _read_needs_version(filename: str) -> Optional[str]:
- """Read ``nox.needs_version`` from the user's noxfile."""
- with open(filename) as io:
+def _read_needs_version(filename: str) -> str | None:
+ """Read ``nox.needs_version`` from the user's Noxfile."""
+ with open(filename, encoding="utf-8") as io:
source = io.read()
return _parse_needs_version(source, filename=filename)
@@ -85,21 +89,20 @@ def _check_nox_version_satisfies(needs_version: str) -> None:
with contextlib.suppress(InvalidVersion):
Version(needs_version)
message += f", did you mean '>= {needs_version}'?"
- raise InvalidVersionSpecifier(message)
+ raise InvalidVersionSpecifier(message) from error
if not specifiers.contains(version, prereleases=True):
- raise VersionCheckFailed(
- f"The Noxfile requires Nox {specifiers}, you have {version}"
- )
+ msg = f"The Noxfile requires Nox {specifiers}, you have {version}"
+ raise VersionCheckFailed(msg)
def check_nox_version(filename: str) -> None:
- """Check if ``nox.needs_version`` in the user's noxfile is satisfied.
+ """Check if ``nox.needs_version`` in the user's Noxfile is satisfied.
Args:
- filename: The location of the user's noxfile. ``nox.needs_version`` is
- read from the noxfile by parsing the AST.
+ filename: The location of the user's Noxfile. ``nox.needs_version`` is
+ read from the Noxfile by parsing the AST.
Raises:
VersionCheckFailed: The Nox version does not satisfy what
diff --git a/nox/command.py b/nox/command.py
index 8d915054..66af1b52 100644
--- a/nox/command.py
+++ b/nox/command.py
@@ -12,124 +12,207 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
+__lazy_modules__ = {"nox.logger", "shlex", "shutil"}
+
import os
+import shlex
+import shutil
+import subprocess
import sys
-from typing import Any, Iterable, List, Optional, Sequence, Union
-
-import py
+from typing import TYPE_CHECKING, Literal, overload
from nox.logger import logger
-from nox.popen import popen
+from nox.popen import DEFAULT_INTERRUPT_TIMEOUT, DEFAULT_TERMINATE_TIMEOUT, popen
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Mapping, Sequence
+ from typing import IO
+
+__all__ = ["CommandFailed", "ExternalType", "run", "which"]
+
+_PLATFORM = sys.platform
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+
+ExternalType = Literal["error", True, False]
class CommandFailed(Exception):
"""Raised when an executed command returns a non-success status code."""
- def __init__(self, reason: str = None) -> None:
- super(CommandFailed, self).__init__(reason)
+ def __init__(self, reason: str | None = None) -> None:
+ super().__init__(reason)
self.reason = reason
-def which(program: str, paths: Optional[List[str]]) -> str:
+def which(
+ program: str | os.PathLike[str], paths: Sequence[str | os.PathLike[str]] | None
+) -> str:
"""Finds the full path to an executable."""
- full_path = None
-
- if paths:
- full_path = py.path.local.sysfind(program, paths=paths)
+ if paths is not None:
+ full_path = shutil.which(program, path=os.pathsep.join(str(p) for p in paths))
+ if full_path:
+ return os.fspath(full_path)
+ full_path = shutil.which(program)
if full_path:
- return full_path.strpath
+ return os.fspath(full_path)
- full_path = py.path.local.sysfind(program)
+ logger.error(f"Program {program} not found.")
+ msg = f"Program {program} not found"
+ raise CommandFailed(msg)
- if full_path:
- return full_path.strpath
- logger.error("Program {} not found.".format(program))
- raise CommandFailed("Program {} not found".format(program))
-
-
-def _clean_env(env: Optional[dict]) -> Optional[dict]:
+def _clean_env(env: Mapping[str, str | None] | None = None) -> dict[str, str] | None:
if env is None:
return None
- clean_env = {}
+ clean_env = {k: v for k, v in env.items() if v is not None}
# Ensure systemroot is passed down, otherwise Windows will explode.
- clean_env["SYSTEMROOT"] = os.environ.get("SYSTEMROOT", "")
+ if _PLATFORM.startswith("win"):
+ clean_env.setdefault("SYSTEMROOT", os.environ.get("SYSTEMROOT", ""))
- clean_env.update(env)
return clean_env
+def _shlex_join(args: Sequence[str | os.PathLike[str]]) -> str:
+ return " ".join(shlex.quote(os.fspath(arg)) for arg in args)
+
+
+@overload
+def run(
+ args: Sequence[str | os.PathLike[str]],
+ *,
+ env: Mapping[str, str | None] | None = ...,
+ silent: Literal[True],
+ paths: Sequence[str | os.PathLike[str]] | None = ...,
+ success_codes: Iterable[int] | None = ...,
+ log: bool = ...,
+ external: ExternalType = ...,
+ stdout: int | IO[str] | None = ...,
+ stderr: int | IO[str] | None = ...,
+ interrupt_timeout: float | None = ...,
+ terminate_timeout: float | None = ...,
+) -> str: ...
+
+
+@overload
+def run(
+ args: Sequence[str | os.PathLike[str]],
+ *,
+ env: Mapping[str, str | None] | None = ...,
+ silent: Literal[False] = ...,
+ paths: Sequence[str | os.PathLike[str]] | None = ...,
+ success_codes: Iterable[int] | None = ...,
+ log: bool = ...,
+ external: ExternalType = ...,
+ stdout: int | IO[str] | None = ...,
+ stderr: int | IO[str] | None = ...,
+ interrupt_timeout: float | None = ...,
+ terminate_timeout: float | None = ...,
+) -> bool: ...
+
+
+@overload
def run(
- args: Sequence[str],
+ args: Sequence[str | os.PathLike[str]],
*,
- env: Optional[dict] = None,
+ env: Mapping[str, str | None] | None = ...,
+ silent: bool,
+ paths: Sequence[str | os.PathLike[str]] | None = ...,
+ success_codes: Iterable[int] | None = ...,
+ log: bool = ...,
+ external: ExternalType = ...,
+ stdout: int | IO[str] | None = ...,
+ stderr: int | IO[str] | None = ...,
+ interrupt_timeout: float | None = ...,
+ terminate_timeout: float | None = ...,
+) -> str | bool: ...
+
+
+def run(
+ args: Sequence[str | os.PathLike[str]],
+ *,
+ env: Mapping[str, str | None] | None = None,
silent: bool = False,
- paths: Optional[List[str]] = None,
- success_codes: Optional[Iterable[int]] = None,
+ paths: Sequence[str | os.PathLike[str]] | None = None,
+ success_codes: Iterable[int] | None = None,
log: bool = True,
- external: bool = False,
- **popen_kws: Any
-) -> Union[str, bool]:
+ external: ExternalType = False,
+ stdout: int | IO[str] | None = None,
+ stderr: int | IO[str] | None = subprocess.STDOUT,
+ interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
+ terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
+) -> str | bool:
"""Run a command-line program."""
if success_codes is None:
success_codes = [0]
cmd, args = args[0], args[1:]
- full_cmd = "{} {}".format(cmd, " ".join(args))
+ full_cmd = f"{cmd} {_shlex_join(args)}"
- cmd_path = which(cmd, paths)
+ cmd_path = which(os.fspath(cmd), paths)
+ str_args = [os.fspath(arg) for arg in args]
if log:
logger.info(full_cmd)
- is_external_tool = paths is not None and not any(
- cmd_path.startswith(path) for path in paths
- )
- if is_external_tool:
- if external == "error":
- logger.error(
- "Error: {} is not installed into the virtualenv, it is located at {}. "
- "Pass external=True into run() to explicitly allow this.".format(
- cmd, cmd_path
- )
- )
- raise CommandFailed("External program disallowed.")
- elif external is False:
- logger.warning(
- "Warning: {} is not installed into the virtualenv, it is located at {}. This might cause issues! "
- "Pass external=True into run() to silence this message.".format(
- cmd, cmd_path
- )
- )
+ is_external_tool = paths is not None and not any(
+ cmd_path.startswith(str(path)) for path in paths
+ )
+ if is_external_tool:
+ if external == "error":
+ logger.error(
+ f"Error: {cmd} is not installed into the virtualenv, it is located"
+ f" at {cmd_path}. Pass external=True into run() to explicitly allow"
+ " this."
+ )
+ msg = "External program disallowed."
+ raise CommandFailed(msg)
+ if external is False and log:
+ logger.warning(
+ f"Warning: {cmd} is not installed into the virtualenv, it is"
+ f" located at {cmd_path}. This might cause issues! Pass"
+ " external=True into run() to silence this message."
+ )
env = _clean_env(env)
try:
return_code, output = popen(
- [cmd_path] + list(args), silent=silent, env=env, **popen_kws
+ [cmd_path, *str_args],
+ silent=silent,
+ env=env,
+ stdout=stdout,
+ stderr=stderr,
+ interrupt_timeout=interrupt_timeout,
+ terminate_timeout=terminate_timeout,
)
if return_code not in success_codes:
+ suffix = ":" if (silent and output) else ""
logger.error(
- "Command {} failed with exit code {}{}".format(
- full_cmd, return_code, ":" if silent else ""
- )
+ f"Command {full_cmd} failed with exit code {return_code}{suffix}"
)
- if silent:
- sys.stderr.write(output)
+ if silent and output:
+ logger.error(output)
- raise CommandFailed("Returned code {}".format(return_code))
+ msg = f"Returned code {return_code}"
+ raise CommandFailed(msg)
if output:
logger.output(output)
- return output if silent else True
-
except KeyboardInterrupt:
logger.error("Interrupted...")
raise
+
+ return output if silent else True
diff --git a/nox/logger.py b/nox/logger.py
index 599cc238..c7b37c2e 100644
--- a/nox/logger.py
+++ b/nox/logger.py
@@ -12,42 +12,61 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
import logging
from typing import Any, cast
from colorlog import ColoredFormatter
-SUCCESS = 25
+__all__ = ["OUTPUT", "SESSION_INFO", "SUCCESS", "logger", "setup_logging"]
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+
+SESSION_INFO = logging.WARNING - 1
+SUCCESS = logging.INFO + 5
OUTPUT = logging.DEBUG - 1
+logging.addLevelName(SESSION_INFO, "SESSION_INFO")
+logging.addLevelName(SUCCESS, "SUCCESS")
+logging.addLevelName(OUTPUT, "OUTPUT")
+
-def _get_format(colorlog: bool, add_timestamp: bool) -> str:
+def _get_format(*, colorlog: bool, add_timestamp: bool) -> str:
if colorlog:
if add_timestamp:
return "%(cyan)s%(name)s > [%(asctime)s] %(log_color)s%(message)s"
- else:
- return "%(cyan)s%(name)s > %(log_color)s%(message)s"
- else:
- if add_timestamp:
- return "%(name)s > [%(asctime)s] %(message)s"
- else:
- return "%(name)s > %(message)s"
+ return "%(cyan)s%(name)s > %(log_color)s%(message)s"
+ if add_timestamp:
+ return "%(name)s > [%(asctime)s] %(message)s"
-class NoxFormatter(logging.Formatter):
- def __init__(self, add_timestamp: bool = False) -> None:
- super().__init__(fmt=_get_format(colorlog=False, add_timestamp=add_timestamp))
- self._simple_fmt = logging.Formatter("%(message)s")
+ return "%(name)s > %(message)s"
+
+
+class _SimpleOutputMixin(logging.Formatter):
+ """Format ``OUTPUT``-level records as plain, unprefixed messages."""
+
+ _simple_fmt = logging.Formatter("%(message)s")
- def format(self, record: Any) -> str:
+ def format(self, record: logging.LogRecord) -> str:
if record.levelname == "OUTPUT":
return self._simple_fmt.format(record)
return super().format(record)
-class NoxColoredFormatter(ColoredFormatter):
+class NoxFormatter(_SimpleOutputMixin, logging.Formatter):
+ def __init__(self, *, add_timestamp: bool = False) -> None:
+ super().__init__(fmt=_get_format(colorlog=False, add_timestamp=add_timestamp))
+
+
+class NoxColoredFormatter(_SimpleOutputMixin, ColoredFormatter):
def __init__(
self,
+ *,
datefmt: Any = None,
style: Any = None,
log_colors: Any = None,
@@ -63,44 +82,34 @@ def __init__(
reset=reset,
secondary_log_colors=secondary_log_colors,
)
- self._simple_fmt = logging.Formatter("%(message)s")
- def format(self, record: Any) -> str:
- if record.levelname == "OUTPUT":
- return self._simple_fmt.format(record)
- return super().format(record)
-
-class LoggerWithSuccessAndOutput(logging.getLoggerClass()): # type: ignore
- def __init__(self, name: str, level: int = logging.NOTSET):
- super().__init__(name, level)
- logging.addLevelName(SUCCESS, "SUCCESS")
- logging.addLevelName(OUTPUT, "OUTPUT")
+class LoggerWithSuccessAndOutput(logging.getLoggerClass()): # type: ignore[misc]
+ def session_info(self, msg: str, *args: Any, **kwargs: Any) -> None:
+ if self.isEnabledFor(SESSION_INFO): # pragma: no cover
+ self._log(SESSION_INFO, msg, args, **kwargs)
def success(self, msg: str, *args: Any, **kwargs: Any) -> None:
- if self.isEnabledFor(SUCCESS):
+ if self.isEnabledFor(SUCCESS): # pragma: no cover
self._log(SUCCESS, msg, args, **kwargs)
- else: # pragma: no cover
- pass
def output(self, msg: str, *args: Any, **kwargs: Any) -> None:
- if self.isEnabledFor(OUTPUT):
+ if self.isEnabledFor(OUTPUT): # pragma: no cover
self._log(OUTPUT, msg, args, **kwargs)
- else: # pragma: no cover
- pass
logging.setLoggerClass(LoggerWithSuccessAndOutput)
-logger = cast(LoggerWithSuccessAndOutput, logging.getLogger("nox"))
+logger = cast("LoggerWithSuccessAndOutput", logging.getLogger("nox"))
-def _get_formatter(color: bool, add_timestamp: bool) -> logging.Formatter:
- if color is True:
+def _get_formatter(*, color: bool, add_timestamp: bool) -> logging.Formatter:
+ if color:
return NoxColoredFormatter(
reset=True,
log_colors={
"DEBUG": "cyan",
"INFO": "blue",
+ "SESSION_INFO": "purple",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "red,bg_white",
@@ -110,12 +119,11 @@ def _get_formatter(color: bool, add_timestamp: bool) -> logging.Formatter:
secondary_log_colors=None,
add_timestamp=add_timestamp,
)
- else:
- return NoxFormatter(add_timestamp=add_timestamp)
+ return NoxFormatter(add_timestamp=add_timestamp)
def setup_logging(
- color: bool, verbose: bool = False, add_timestamp: bool = False
+ *, color: bool, verbose: bool = False, add_timestamp: bool = False
) -> None: # pragma: no cover
"""Setup logging.
@@ -128,10 +136,24 @@ def setup_logging(
root_logger.setLevel(OUTPUT)
else:
root_logger.setLevel(logging.DEBUG)
- handler = logging.StreamHandler()
- handler.setFormatter(_get_formatter(color, add_timestamp))
+ active_handlers = [
+ handler
+ for handler in root_logger.handlers
+ if handler.get_name() == "nox-stream-handler"
+ ]
+ for handler in active_handlers:
+ # Avoid duplicate handlers by removing all we've previously created
+ # this causes trouble in tests where setup_logging is called multiple
+ # times
+ root_logger.removeHandler(handler)
+
+ handler = logging.StreamHandler()
+ handler.set_name("nox-stream-handler")
+ handler.setFormatter(_get_formatter(color=color, add_timestamp=add_timestamp))
root_logger.addHandler(handler)
# Silence noisy loggers
logging.getLogger("sh").setLevel(logging.WARNING)
+ logging.getLogger("httpx").setLevel(logging.WARNING)
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
diff --git a/nox/manifest.py b/nox/manifest.py
index dbc3304e..0a783940 100644
--- a/nox/manifest.py
+++ b/nox/manifest.py
@@ -12,40 +12,45 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import argparse
+from __future__ import annotations
+
+__lazy_modules__ = {"ast", "itertools", "nox._resolver", "nox.sessions", "operator"}
+
import ast
-import collections.abc
+import functools
import itertools
-from collections import OrderedDict
-from typing import (
- Any,
- Iterable,
- Iterator,
- List,
- Mapping,
- Optional,
- Sequence,
- Set,
- Tuple,
- Union,
-)
+import operator
+from collections.abc import Mapping
+from typing import TYPE_CHECKING, Any, cast
from nox._decorators import Call, Func
+from nox._resolver import CycleError, lazy_stable_topo_sort
from nox.sessions import Session, SessionRunner
+if TYPE_CHECKING:
+ import argparse
+ from collections.abc import Iterable, Iterator, Sequence
+
+__all__ = ["WARN_PYTHONS_IGNORED", "Manifest", "keyword_match"]
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+
WARN_PYTHONS_IGNORED = "python_ignored"
-def _unique_list(*args: str) -> List[str]:
+def _unique_list(*args: str) -> list[str]:
"""Return a list without duplicates, while preserving order."""
- return list(OrderedDict.fromkeys(args))
+ return list(dict.fromkeys(args))
class Manifest:
"""Session manifest.
The session manifest provides the source of truth for the sequence of
- sessions that should be run by nox.
+ sessions that should be run by Nox.
It is possible for this to be mutated during execution. This allows for
useful use cases, such as for one session to "notify" another or
@@ -55,34 +60,42 @@ class Manifest:
session_functions (Mapping[str, function]): The registry of discovered
session functions.
global_config (.nox.main.GlobalConfig): The global configuration.
+ module_docstring (Optional[str]): The user noxfile.py docstring.
+ Defaults to `None`.
"""
def __init__(
- self, session_functions: Mapping[str, "Func"], global_config: argparse.Namespace
+ self,
+ session_functions: Mapping[str, Func],
+ global_config: argparse.Namespace,
+ module_docstring: str | None = None,
) -> None:
- self._all_sessions = [] # type: List[SessionRunner]
- self._queue = [] # type: List[SessionRunner]
- self._consumed = [] # type: List[SessionRunner]
- self._config = global_config # type: argparse.Namespace
+ self._all_sessions: list[SessionRunner] = []
+ self._queue: list[SessionRunner] = []
+ self._consumed: list[SessionRunner] = []
+ self._config: argparse.Namespace = global_config
+ self.module_docstring: str | None = module_docstring
# Create the sessions based on the provided session functions.
for name, func in session_functions.items():
for session in self.make_session(name, func):
self.add_session(session)
- def __contains__(self, needle: Union[str, SessionRunner]) -> bool:
- if needle in self._queue or needle in self._consumed:
- return True
- for session in self._queue + self._consumed:
- if session.name == needle or needle in session.signatures:
- return True
- return False
+ def __contains__(self, needle: str | SessionRunner) -> bool:
+ return (
+ needle in self._queue
+ or needle in self._consumed
+ or any(
+ session.name == needle or needle in session.signatures
+ for session in itertools.chain(self._queue, self._consumed)
+ )
+ )
- def __iter__(self) -> "Manifest":
+ def __iter__(self) -> Manifest:
return self
def __getitem__(self, key: str) -> SessionRunner:
- for session in self._queue + self._consumed:
+ for session in itertools.chain(self._queue, self._consumed):
if session.name == key or key in session.signatures:
return session
raise KeyError(key)
@@ -93,7 +106,7 @@ def __next__(self) -> SessionRunner:
Raises:
StopIteration: If the queue has been entirely consumed.
"""
- if not len(self._queue):
+ if not self._queue:
raise StopIteration
session = self._queue.pop(0)
self._consumed.append(session)
@@ -102,11 +115,37 @@ def __next__(self) -> SessionRunner:
def __len__(self) -> int:
return len(self._queue) + len(self._consumed)
- def list_all_sessions(self) -> Iterator[Tuple[SessionRunner, bool]]:
+ def list_all_sessions(self) -> Iterator[tuple[SessionRunner, bool]]:
"""Yields all sessions and whether or not they're selected."""
for session in self._all_sessions:
yield session, session in self._queue
+ @property
+ def all_sessions_by_signature(self) -> dict[str, SessionRunner]:
+ return {
+ signature: session
+ for session in self._all_sessions
+ for signature in session.signatures
+ }
+
+ @property
+ def parametrized_sessions_by_name(self) -> dict[str, list[SessionRunner]]:
+ """Returns a mapping from names to all sessions that are parameterizations of
+ the ``@session`` with each name.
+
+ The sessions in each returned list will occur in the same order as they occur in
+ ``self._all_sessions``.
+ """
+ parametrized_sessions = filter(operator.attrgetter("multi"), self._all_sessions)
+ key = operator.attrgetter("name")
+ # Note that ``sorted`` uses a stable sorting algorithm.
+ return {
+ name: list(sessions_parametrizing_name)
+ for name, sessions_parametrizing_name in itertools.groupby(
+ sorted(parametrized_sessions, key=key), key
+ )
+ }
+
def add_session(self, session: SessionRunner) -> None:
"""Add the given session to the manifest.
@@ -131,12 +170,12 @@ def filter_by_name(self, specified_sessions: Iterable[str]) -> None:
"""
# Filter the sessions remaining in the queue based on
# whether they are individually specified.
- queue = []
- for session_name in specified_sessions:
- for session in self._queue:
- if _normalized_session_match(session_name, session):
- queue.append(session)
- self._queue = queue
+ self._queue = [
+ session
+ for session_name in specified_sessions
+ for session in self._queue
+ if _normalized_session_match(session_name, session)
+ ]
# If a session was requested and was not found, complain loudly.
all_sessions = set(
@@ -156,7 +195,13 @@ def filter_by_name(self, specified_sessions: Iterable[str]) -> None:
if _normalize_arg(session_name) not in all_sessions
]
if missing_sessions:
- raise KeyError("Sessions not found: {}".format(", ".join(missing_sessions)))
+ msg = f"Sessions not found: {', '.join(missing_sessions)}"
+ raise KeyError(msg)
+
+ def filter_by_default(self) -> None:
+ """Filter sessions in the queue based on the default flag."""
+
+ self._queue = [x for x in self._queue if x.func.default]
def filter_by_python_interpreter(self, specified_pythons: Sequence[str]) -> None:
"""Filter sessions in the queue based on the user-specified
@@ -176,12 +221,82 @@ def filter_by_keywords(self, keywords: str) -> None:
session names are checked against.
"""
self._queue = [
- x for x in self._queue if keyword_match(keywords, x.signatures + [x.name])
+ x
+ for x in self._queue
+ if keyword_match(keywords, [*x.signatures, *x.tags, x.name])
+ ]
+
+ def filter_by_tags(self, tags: Iterable[str]) -> None:
+ """Filter sessions by their tags.
+
+ Args:
+ tags (list[str]): A list of tags which session names
+ are checked against.
+ """
+ self._queue = [x for x in self._queue if set(x.tags).intersection(tags)]
+
+ def add_dependencies(self) -> None:
+ """Add direct and recursive dependencies to the queue.
+
+ Raises:
+ KeyError: If any depended-on sessions are not found.
+ ~nox._resolver.CycleError: If a dependency cycle is encountered.
+ """
+ sessions_by_id = self.all_sessions_by_signature
+
+ # For each session that was parametrized from a list of Pythons, create a fake
+ # parent session that depends on it.
+ parent_sessions: set[SessionRunner] = set()
+ for (
+ parent_name,
+ parametrized_sessions,
+ ) in self.parametrized_sessions_by_name.items():
+ parent_func = _null_session_func.copy()
+ parent_func.requires = [
+ session.signatures[0] for session in parametrized_sessions
+ ]
+ parent_session = SessionRunner(
+ parent_name, [], parent_func, self._config, self, multi=False
+ )
+ parent_sessions.add(parent_session)
+ sessions_by_id[parent_name] = parent_session
+
+ # Construct the dependency graph. Note that this is done lazily with iterators
+ # so that we won't raise if a session that doesn't actually need to run declares
+ # missing/improper dependencies.
+ dependency_graph = {
+ session: session.get_direct_dependencies(sessions_by_id)
+ for session in sessions_by_id.values()
+ }
+
+ # Sessions without any signatures (e.g. the placeholder session created
+ # when a parametrized session has an empty list of parameters) are not
+ # in ``sessions_by_id``, so add any missing queued sessions to the graph.
+ for session in self._queue:
+ dependency_graph.setdefault(
+ session, session.get_direct_dependencies(sessions_by_id)
+ )
+
+ # Resolve the dependency graph.
+ root = cast("SessionRunner", object()) # sentinel
+ try:
+ resolved_graph = list(
+ lazy_stable_topo_sort({**dependency_graph, root: self._queue}, root)
+ )
+ except CycleError as exc:
+ raise CycleError(
+ "Sessions are in a dependency cycle: "
+ + " -> ".join(session.name for session in exc.args[1])
+ ) from exc
+
+ # Remove fake parent sessions from the resolved graph.
+ self._queue = [
+ session for session in resolved_graph if session not in parent_sessions
]
def make_session(
- self, name: str, func: "Func", multi: bool = False
- ) -> List[SessionRunner]:
+ self, name: str, func: Func, *, multi: bool = False
+ ) -> list[SessionRunner]:
"""Create a session object from the session function.
Args:
@@ -196,7 +311,7 @@ def make_session(
"""
sessions = []
- # if backend is none we wont parametrize the pythons
+ # If the backend is "none", we won't parametrize `python`.
backend = (
self._config.force_venv_backend
or func.venv_backend
@@ -211,7 +326,7 @@ def make_session(
if self._config.extra_pythons:
# If extra python is provided, expand the func.python list to
# include additional python interpreters
- extra_pythons = self._config.extra_pythons # type: List[str]
+ extra_pythons: list[str] = self._config.extra_pythons
if isinstance(func.python, (list, tuple, set)):
func.python = _unique_list(*func.python, *extra_pythons)
elif not multi and func.python:
@@ -222,11 +337,14 @@ def make_session(
# Otherwise, add the extra specified python.
assert isinstance(func.python, str)
func.python = _unique_list(func.python, *extra_pythons)
+ elif not func.python and self._config.force_pythons:
+ # If a python is forced by the user, but the underlying function
+ # has no version parametrised, add it as sole occupant to func.python
+ func.python = _unique_list(*extra_pythons)
# If the func has the python attribute set to a list, we'll need
# to expand them.
if isinstance(func.python, (list, tuple, set)):
-
for python in func.python:
single_func = func.copy()
single_func.python = python
@@ -235,48 +353,52 @@ def make_session(
return sessions
# Simple case: If this function is not parametrized, then make
- # a simple session
+ # a simple session.
if not hasattr(func, "parametrize"):
long_names = []
if not multi:
long_names.append(name)
if func.python:
- long_names.append("{}-{}".format(name, func.python))
+ long_names.append(f"{name}-{func.python}")
- return [SessionRunner(name, long_names, func, self._config, self)]
+ return [
+ SessionRunner(name, long_names, func, self._config, self, multi=multi)
+ ]
# Since this function is parametrized, we need to add a distinct
# session for each permutation.
- parametrize = func.parametrize # type: ignore
+ parametrize = func.parametrize
calls = Call.generate_calls(func, parametrize)
for call in calls:
long_names = []
- if not multi:
- long_names.append("{}{}".format(name, call.session_signature))
+ if not multi or (
+ self._config.force_pythons and call.python in self._config.extra_pythons
+ ):
+ long_names.append(f"{name}{call.session_signature}")
+
if func.python:
- long_names.append(
- "{}-{}{}".format(name, func.python, call.session_signature)
- )
+ long_names.append(f"{name}-{func.python}{call.session_signature}")
# Ensure that specifying session-python will run all parameterizations.
- long_names.append("{}-{}".format(name, func.python))
+ long_names.append(f"{name}-{func.python}")
- sessions.append(SessionRunner(name, long_names, call, self._config, self))
+ sessions.append(
+ SessionRunner(name, long_names, call, self._config, self, multi=multi)
+ )
# Edge case: If the parameters made it such that there were no valid
# calls, add an empty, do-nothing session.
if not calls:
sessions.append(
- SessionRunner(name, [], _null_session_func, self._config, self)
+ SessionRunner(
+ name, [], _null_session_func, self._config, self, multi=multi
+ )
)
# Return the list of sessions.
return sessions
- def next(self) -> SessionRunner:
- return self.__next__()
-
def notify(
- self, session: Union[str, SessionRunner], posargs: Optional[List[str]] = None
+ self, session: str | SessionRunner, posargs: Iterable[str] | None = None
) -> bool:
"""Enqueue the specified session in the queue.
@@ -305,17 +427,18 @@ def notify(
# Locate the session in the list of all sessions, and place it at
# the end of the queue.
for s in self._all_sessions:
- if s == session or s.name == session or session in s.signatures:
+ if s == session or s.name == session or session in s.signatures: # noqa: PLR1714
if posargs is not None:
- s.posargs = posargs
+ s.posargs = list(posargs)
self._queue.append(s)
return True
# The session was not found in the list of sessions.
- raise ValueError("Session {} not found.".format(session))
+ msg = f"Session {session} not found."
+ raise ValueError(msg)
-class KeywordLocals(collections.abc.Mapping):
+class KeywordLocals(Mapping[str, bool]):
"""Eval locals using keywords.
When looking up a local variable the variable name is compared against
@@ -324,14 +447,11 @@ class KeywordLocals(collections.abc.Mapping):
returns False.
"""
- def __init__(self, keywords: Set[str]) -> None:
- self._keywords = keywords
+ def __init__(self, keywords: Iterable[str]) -> None:
+ self._keywords = frozenset(keywords)
def __getitem__(self, variable_name: str) -> bool:
- for keyword in self._keywords:
- if variable_name in keyword:
- return True
- return False
+ return any(variable_name in keyword for keyword in self._keywords)
def __iter__(self) -> Iterator[str]:
return iter(self._keywords)
@@ -342,12 +462,13 @@ def __len__(self) -> int:
def keyword_match(expression: str, keywords: Iterable[str]) -> Any:
"""See if an expression matches the given set of keywords."""
- locals = KeywordLocals(set(keywords))
- return eval(expression, {}, locals)
+ # TODO: see if we can use ast.literal_eval here.
+ my_locals = KeywordLocals(set(keywords))
+ return eval(expression, {}, my_locals) # noqa: S307
def _null_session_func_(session: Session) -> None:
- """A no-op session for patemetrized sessions with no available params."""
+ """A no-op session for parametrized sessions with no available params."""
session.skip("This session had no parameters available.")
@@ -355,15 +476,17 @@ def _normalized_session_match(session_name: str, session: SessionRunner) -> bool
"""Checks if session_name matches session."""
if session_name == session.name or session_name in session.signatures:
return True
+ normalized_name = _normalize_arg(session_name)
for name in session.signatures:
- equal_rep = _normalize_arg(session_name) == _normalize_arg(name)
+ equal_rep = normalized_name == _normalize_arg(name)
if equal_rep:
return True
# Exhausted
return False
-def _normalize_arg(arg: str) -> Union[str]:
+@functools.cache
+def _normalize_arg(arg: str) -> str:
"""Normalize arg for comparison."""
try:
return str(ast.dump(ast.parse(arg)))
diff --git a/nox/popen.py b/nox/popen.py
index 9bd1c115..e419e93d 100644
--- a/nox/popen.py
+++ b/nox/popen.py
@@ -12,23 +12,48 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
+__lazy_modules__ = {"contextlib", "locale"}
+
import contextlib
import locale
import subprocess
import sys
-from typing import IO, Mapping, Sequence, Tuple, Union
+from typing import IO, TYPE_CHECKING
+if TYPE_CHECKING:
+ from collections.abc import Mapping, Sequence
-def shutdown_process(proc: subprocess.Popen) -> Tuple[bytes, bytes]:
- """Gracefully shutdown a child process."""
+__all__ = [
+ "DEFAULT_INTERRUPT_TIMEOUT",
+ "DEFAULT_TERMINATE_TIMEOUT",
+ "decode_output",
+ "popen",
+]
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+DEFAULT_INTERRUPT_TIMEOUT = 0.3
+DEFAULT_TERMINATE_TIMEOUT = 0.2
+
+
+def shutdown_process(
+ proc: subprocess.Popen[bytes],
+ interrupt_timeout: float | None,
+ terminate_timeout: float | None,
+) -> tuple[bytes, bytes]:
+ """Gracefully shutdown a child process."""
with contextlib.suppress(subprocess.TimeoutExpired):
- return proc.communicate(timeout=0.3)
+ return proc.communicate(timeout=interrupt_timeout)
proc.terminate()
with contextlib.suppress(subprocess.TimeoutExpired):
- return proc.communicate(timeout=0.2)
+ return proc.communicate(timeout=terminate_timeout)
proc.kill()
@@ -46,7 +71,7 @@ def decode_output(output: bytes) -> str:
return output.decode("utf-8")
except UnicodeDecodeError:
second_encoding = locale.getpreferredencoding()
- if second_encoding.casefold() in ("utf8", "utf-8"):
+ if second_encoding.casefold() in {"utf8", "utf-8"}:
raise
return output.decode(second_encoding)
@@ -54,15 +79,20 @@ def decode_output(output: bytes) -> str:
def popen(
args: Sequence[str],
- env: Mapping[str, str] = None,
+ *,
+ env: Mapping[str, str] | None = None,
silent: bool = False,
- stdout: Union[int, IO] = None,
- stderr: Union[int, IO] = subprocess.STDOUT,
-) -> Tuple[int, str]:
+ stdout: int | IO[str] | None = None,
+ stderr: int | IO[str] | None = subprocess.STDOUT,
+ interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
+ terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
+) -> tuple[int, str]:
if silent and stdout is not None:
- raise ValueError(
- "Can not specify silent and stdout; passing a custom stdout always silences the commands output in Nox's log."
+ msg = (
+ "Can not specify silent and stdout; passing a custom stdout always silences"
+ " the commands output in Nox's log."
)
+ raise ValueError(msg)
if silent:
stdout = subprocess.PIPE
@@ -70,11 +100,11 @@ def popen(
proc = subprocess.Popen(args, env=env, stdout=stdout, stderr=stderr)
try:
- out, err = proc.communicate()
+ out, _err = proc.communicate()
sys.stdout.flush()
except KeyboardInterrupt:
- out, err = shutdown_process(proc)
+ out, _err = shutdown_process(proc, interrupt_timeout, terminate_timeout)
if proc.returncode != 0:
raise
diff --git a/nox/project.py b/nox/project.py
new file mode 100644
index 00000000..1d1b0e6d
--- /dev/null
+++ b/nox/project.py
@@ -0,0 +1,176 @@
+from __future__ import annotations
+
+__lazy_modules__ = {
+ "dependency_groups",
+ "packaging",
+ "packaging.requirements",
+ "packaging.specifiers",
+ "pathlib",
+ "tomllib",
+}
+
+import re
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import packaging.requirements
+import packaging.specifiers
+from dependency_groups import resolve
+
+if TYPE_CHECKING:
+ import os
+ from typing import Any
+
+if sys.version_info < (3, 11):
+ import tomli as tomllib
+else:
+ import tomllib
+
+
+__all__ = ["dependency_groups", "load_toml", "python_versions"]
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+
+# Note: the implementation (including this regex) taken from PEP 723
+# https://peps.python.org/pep-0723
+
+REGEX = re.compile(
+ r"(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$"
+)
+
+
+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
+
+ @nox.session
+ def myscript(session):
+ myscript_options = nox.project.load_toml("myscript.py")
+ session.install(*myscript_options["dependencies"])
+ """
+ filepath = Path(filename)
+ match filepath.suffix:
+ case ".toml":
+ return _load_toml_file(filepath)
+ case ".py" | "":
+ return _load_script_block(filepath, missing_ok=missing_ok)
+ case suffix:
+ msg = f"Extension must be .py or .toml, got {suffix}"
+ raise ValueError(msg)
+
+
+def _load_toml_file(filepath: Path) -> dict[str, Any]:
+ with filepath.open("rb") as f:
+ return tomllib.load(f)
+
+
+def _load_script_block(filepath: Path, *, missing_ok: bool) -> dict[str, Any]:
+ name = "script"
+ try:
+ script = filepath.read_text(encoding="utf-8")
+ except FileNotFoundError:
+ if missing_ok:
+ return {}
+ raise
+ matches = [m for m in REGEX.finditer(script) if m.group("type") == name]
+
+ if not matches:
+ if missing_ok:
+ return {}
+ msg = f"No {name} block found in {filepath}"
+ raise ValueError(msg)
+ if len(matches) > 1:
+ msg = f"Multiple {name} blocks found in {filepath}"
+ raise ValueError(msg)
+
+ content = "".join(
+ line[2:] if line.startswith("# ") else line[1:]
+ for line in matches[0].group("content").splitlines(keepends=True)
+ )
+ return tomllib.loads(content)
+
+
+def python_versions(
+ pyproject: dict[str, Any], *, max_version: str | None = None
+) -> list[str]:
+ """
+ Read a list of supported Python versions. Without ``max_version``, this
+ will read the trove classifiers (recommended). With a ``max_version``, it
+ will read the requires-python setting for a lower bound, and will use the
+ value of ``max_version`` as the upper bound. (Reminder: you should never
+ set an upper bound in ``requires-python``).
+
+ Example:
+
+ .. code-block:: python
+
+ import nox
+
+ PYPROJECT = nox.project.load_toml("pyproject.toml")
+ # From classifiers
+ PYTHON_VERSIONS = nox.project.python_versions(PYPROJECT)
+ # Or from requires-python
+ PYTHON_VERSIONS = nox.project.python_versions(PYPROJECT, max_version="3.13")
+ """
+ if max_version is None:
+ # Classifiers are a list of every Python version
+ from_classifiers = [
+ c.split()[-1]
+ for c in pyproject.get("project", {}).get("classifiers", [])
+ if c.startswith("Programming Language :: Python :: 3.")
+ ]
+ if from_classifiers:
+ return from_classifiers
+ msg = 'No Python version classifiers found in "project.classifiers"'
+ raise ValueError(msg)
+
+ requires_python_str = pyproject.get("project", {}).get("requires-python", "")
+ if not requires_python_str:
+ msg = 'No "project.requires-python" value set'
+ raise ValueError(msg)
+
+ for spec in packaging.specifiers.SpecifierSet(requires_python_str):
+ if spec.operator in {">", ">=", "~="}:
+ min_minor_version = int(spec.version.split(".")[1])
+ break
+ else:
+ msg = 'No minimum version found in "project.requires-python"'
+ raise ValueError(msg)
+
+ max_minor_version = int(max_version.split(".")[1])
+
+ return [f"3.{v}" for v in range(min_minor_version, max_minor_version + 1)]
+
+
+def dependency_groups(pyproject: dict[str, Any], *groups: str) -> tuple[str, ...]:
+ """
+ Get a list of dependencies from a ``[dependency-groups]`` section(s).
+
+ Example:
+
+ .. code-block:: python
+
+ @nox.session
+ def test(session):
+ pyproject = nox.project.load_toml("pyproject.toml")
+ session.install(*nox.project.dependency_groups(pyproject, "dev"))
+ """
+ dep_groups = pyproject["dependency-groups"]
+ return resolve(dep_groups, *groups)
diff --git a/nox/py.typed b/nox/py.typed
index ec7a0d73..73a0fc3c 100644
--- a/nox/py.typed
+++ b/nox/py.typed
@@ -1 +1 @@
-# Marker file for PEP 561. The nox package uses inline types.
\ No newline at end of file
+# Marker file for PEP 561. The Nox package uses inline types.
diff --git a/nox/registry.py b/nox/registry.py
index f08b188a..50506ce9 100644
--- a/nox/registry.py
+++ b/nox/registry.py
@@ -12,46 +12,76 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import collections
+from __future__ import annotations
+
+__lazy_modules__ = {"copy", f"{__spec__.parent}._decorators", "functools", "warnings"}
+
import copy
import functools
-from typing import Any, Callable, Optional, TypeVar, Union, overload
+import warnings
+from collections.abc import Callable
+from typing import TYPE_CHECKING, Any, Literal, overload
from ._decorators import Func
-from ._typing import Python
-F = TypeVar("F", bound=Callable[..., Any])
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ from ._typing import Python
+
+__all__ = ["get", "reset", "session_decorator"]
+
+
+def __dir__() -> list[str]:
+ return __all__
-_REGISTRY = collections.OrderedDict() # type: collections.OrderedDict[str, Func]
+
+RawFunc = Callable[..., Any]
+
+_REGISTRY: dict[str, Func] = {}
+
+
+def reset() -> None:
+ _REGISTRY.clear()
@overload
-def session_decorator(__func: F) -> F:
- ...
+def session_decorator(func: RawFunc | Func, /) -> Func: ...
@overload
def session_decorator(
- __func: None = ...,
- python: Python = ...,
- py: Python = ...,
- reuse_venv: Optional[bool] = ...,
- name: Optional[str] = ...,
- venv_backend: Any = ...,
- venv_params: Any = ...,
-) -> Callable[[F], F]:
- ...
+ func: None = ...,
+ /,
+ python: Python | None = ...,
+ py: Python | None = ...,
+ reuse_venv: bool | None = ..., # noqa: FBT001
+ name: str | None = ...,
+ venv_backend: str | None = ...,
+ venv_params: Sequence[str] = ...,
+ tags: Sequence[str] | None = ...,
+ *,
+ default: bool = ...,
+ requires: Sequence[str] | None = ...,
+ download_python: Literal["auto", "never", "always"] | None = None,
+) -> Callable[[RawFunc | Func], Func]: ...
def session_decorator(
- func: Optional[F] = None,
- python: Python = None,
- py: Python = None,
- reuse_venv: Optional[bool] = None,
- name: Optional[str] = None,
- venv_backend: Any = None,
- venv_params: Any = None,
-) -> Union[F, Callable[[F], F]]:
+ func: Callable[..., Any] | Func | None = None,
+ /,
+ python: Python | None = None,
+ py: Python | None = None,
+ reuse_venv: bool | None = None, # noqa: FBT001
+ name: str | None = None,
+ venv_backend: str | None = None,
+ venv_params: Sequence[str] = (),
+ tags: Sequence[str] | None = None,
+ *,
+ default: bool = True,
+ requires: Sequence[str] | None = None,
+ download_python: Literal["auto", "never", "always"] | None = None,
+) -> Func | Callable[[RawFunc | Func], Func]:
"""Designate the decorated function as a session."""
# If `func` is provided, then this is the decorator call with the function
# being sent as part of the Python syntax (`@nox.session`).
@@ -69,23 +99,51 @@ def session_decorator(
name=name,
venv_backend=venv_backend,
venv_params=venv_params,
+ tags=tags,
+ default=default,
+ requires=requires,
+ download_python=download_python,
)
+ if isinstance(func, Func):
+ func = func.func
+
if py is not None and python is not None:
- raise ValueError(
+ msg = (
"The py argument to nox.session is an alias for the python "
"argument, please only specify one."
)
+ raise ValueError(msg)
if python is None:
python = py
- fn = Func(func, python, reuse_venv, name, venv_backend, venv_params)
- _REGISTRY[name or func.__name__] = fn
+ reg_name = name or func.__name__
+
+ fn = Func(
+ func,
+ python,
+ reuse_venv,
+ reg_name,
+ venv_backend,
+ venv_params,
+ tags=tags,
+ default=default,
+ requires=requires,
+ download_python=download_python,
+ )
+ if reg_name in _REGISTRY:
+ msg = (
+ f"The session {reg_name!r} has already been registered; "
+ "this will be an error in a future version of nox. "
+ "Overriding the old session for now."
+ )
+ warnings.warn(msg, FutureWarning, stacklevel=2)
+ _REGISTRY[reg_name] = fn
return fn
-def get() -> "collections.OrderedDict[str, Func]":
+def get() -> dict[str, Func]:
"""Return a shallow copy of the registry.
This ensures that the registry is not accidentally modified by
diff --git a/nox/sessions.py b/nox/sessions.py
index cabaae12..da534f47 100644
--- a/nox/sessions.py
+++ b/nox/sessions.py
@@ -12,90 +12,147 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import argparse
+from __future__ import annotations
+
+__lazy_modules__ = {
+ "datetime",
+ "hashlib",
+ "humanize",
+ "inspect",
+ "nox.logger",
+ "nox.popen",
+ "nox.virtualenv",
+ "pathlib",
+ "re",
+ "shutil",
+ "subprocess",
+ "unicodedata",
+}
+
+import contextlib
+import datetime
import enum
import hashlib
+import inspect
import os
+import pathlib
import re
+import shutil
+import subprocess
import sys
+import time
+import typing
import unicodedata
-from typing import (
- Any,
- Callable,
- Dict,
- Iterable,
- List,
- Mapping,
- Optional,
- Sequence,
- Tuple,
- Union,
-)
-import py
+import humanize
import nox.command
-from nox import _typing
-from nox._decorators import Func
+import nox.virtualenv
from nox.logger import logger
-from nox.virtualenv import CondaEnv, PassthroughEnv, ProcessEnv, VirtualEnv
+from nox.popen import DEFAULT_INTERRUPT_TIMEOUT, DEFAULT_TERMINATE_TIMEOUT
+from nox.virtualenv import (
+ CondaEnv,
+ PassthroughEnv,
+ ProcessEnv,
+ VirtualEnv,
+ get_virtualenv,
+)
-if _typing.TYPE_CHECKING:
+if typing.TYPE_CHECKING:
+ import argparse
+ from collections.abc import (
+ Callable,
+ Generator,
+ Iterable,
+ Iterator,
+ Mapping,
+ Sequence,
+ )
+ from types import TracebackType
+ from typing import (
+ IO,
+ Any,
+ Literal,
+ NoReturn,
+ )
+
+ from nox._decorators import Func
+ from nox.command import ExternalType
from nox.manifest import Manifest
+__all__ = ["Result", "Session", "SessionRunner", "Status", "nox"]
-def _normalize_path(envdir: str, path: Union[str, bytes]) -> str:
- """Normalizes a string to be a "safe" filesystem path for a virtualenv."""
- if isinstance(path, bytes):
- path = path.decode("utf-8")
- path = unicodedata.normalize("NFKD", path).encode("ascii", "ignore")
- path = path.decode("ascii")
+def __dir__() -> list[str]:
+ return __all__
+
+
+@contextlib.contextmanager
+def _chdir(path: str) -> Generator[None, None, None]:
+ """
+ Change the current working directory to the given path.
+ Follows python 3.11's chdir behaviour.
+ """
+ cwd = os.getcwd()
+ try:
+ os.chdir(path)
+ yield
+ finally:
+ os.chdir(cwd)
+
+
+def _normalize_path(envdir: str, path: str) -> str:
+ """Normalizes a string to be a "safe" filesystem path for a virtualenv."""
+ orig_path = path
+ path = unicodedata.normalize("NFKD", path).encode("ascii", "ignore").decode("ascii")
path = re.sub(r"[^\w\s-]", "-", path).strip().lower()
path = re.sub(r"[-\s]+", "-", path)
path = path.strip("-")
+ if not path:
+ path = hashlib.sha1(orig_path.encode("utf-8")).hexdigest()[:8] # noqa: S324
+ logger.warning(
+ "The virtualenv name was hashed because it contains no usable "
+ "ASCII characters."
+ )
+
full_path = os.path.join(envdir, path)
if len(full_path) > 100 - len("bin/pythonX.Y"):
if len(envdir) < 100 - 9:
- path = hashlib.sha1(path.encode("ascii")).hexdigest()[:8]
+ path = hashlib.sha1(path.encode("ascii")).hexdigest()[:8] # noqa: S324
full_path = os.path.join(envdir, path)
logger.warning("The virtualenv name was hashed to avoid being too long.")
else:
logger.error(
- "The virtualenv path {} is too long and will cause issues on "
+ f"The virtualenv path {full_path} is too long and will cause issues on "
"some environments. Use the --envdir path to modify where "
- "nox stores virtualenvs.".format(full_path)
+ "Nox stores virtualenvs."
)
return full_path
-def _dblquote_pkg_install_args(args: Tuple[str, ...]) -> Tuple[str, ...]:
+def _dblquote_pkg_install_args(args: Iterable[str]) -> tuple[str, ...]:
"""Double-quote package install arguments in case they contain '>' or '<' symbols"""
# routine used to handle a single arg
def _dblquote_pkg_install_arg(pkg_req_str: str) -> str:
# sanity check: we need an even number of double-quotes
if pkg_req_str.count('"') % 2 != 0:
- raise ValueError(
- "ill-formated argument with odd number of quotes: %s" % pkg_req_str
- )
+ msg = f"ill-formatted argument with odd number of quotes: {pkg_req_str}"
+ raise ValueError(msg)
if "<" in pkg_req_str or ">" in pkg_req_str:
- if pkg_req_str[0] == '"' and pkg_req_str[-1] == '"':
+ if pkg_req_str[0] == pkg_req_str[-1] == '"':
# already double-quoted string
return pkg_req_str
- else:
- # need to double-quote string
- if '"' in pkg_req_str:
- raise ValueError(
- "Cannot escape requirement string: %s" % pkg_req_str
- )
- return '"%s"' % pkg_req_str
- else:
- # no dangerous char: no need to double-quote string
- return pkg_req_str
+ # need to double-quote string
+ if '"' in pkg_req_str:
+ msg = f"Cannot escape requirement string: {pkg_req_str}"
+ raise ValueError(msg)
+ return f'"{pkg_req_str}"'
+ # no dangerous char: no need to double-quote string
+ return pkg_req_str
# double-quote all args that need to be and return the result
return tuple(_dblquote_pkg_install_arg(a) for a in args)
@@ -116,6 +173,23 @@ class Status(enum.Enum):
SKIPPED = 2
+class _WorkingDirContext:
+ def __init__(self, dir: str | os.PathLike[str]) -> None:
+ self._prev_working_dir = os.getcwd()
+ os.chdir(dir)
+
+ def __enter__(self) -> _WorkingDirContext: # noqa: PYI034
+ return self
+
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ traceback: TracebackType | None,
+ ) -> None:
+ os.chdir(self._prev_working_dir)
+
+
class Session:
"""The Session object is passed into each user-defined session function.
@@ -125,11 +199,11 @@ class Session:
__slots__ = ("_runner",)
- def __init__(self, runner: "SessionRunner") -> None:
+ def __init__(self, runner: SessionRunner) -> None:
self._runner = runner
@property
- def __dict__(self) -> "Dict[str, SessionRunner]": # type: ignore
+ def __dict__(self) -> dict[str, SessionRunner]: # type: ignore[override]
"""Attribute dictionary for object inspection.
This is needed because ``__slots__`` turns off ``__dict__`` by
@@ -144,12 +218,17 @@ def name(self) -> str:
return self._runner.friendly_name
@property
- def env(self) -> dict:
+ def env(self) -> dict[str, str | None]:
"""A dictionary of environment variables to pass into all commands."""
return self.virtualenv.env
@property
- def posargs(self) -> List[str]:
+ def env_dir(self) -> pathlib.Path:
+ """The path to the environment of this session."""
+ return pathlib.Path(self._runner.envdir)
+
+ @property
+ def posargs(self) -> list[str]:
"""Any extra arguments from the ``nox`` commandline or :class:`Session.notify`."""
return self._runner.posargs
@@ -158,16 +237,25 @@ def virtualenv(self) -> ProcessEnv:
"""The virtualenv that all commands are run in."""
venv = self._runner.venv
if venv is None:
- raise ValueError("A virtualenv has not been created for this session")
+ msg = "A virtualenv has not been created for this session"
+ raise ValueError(msg)
return venv
@property
- def python(self) -> Optional[Union[str, Sequence[str], bool]]:
+ def venv_backend(self) -> str:
+ """The venv_backend selected."""
+ venv = self._runner.venv
+ if venv is None:
+ return "none"
+ return venv.venv_backend
+
+ @property
+ def python(self) -> str | Sequence[str] | bool | None:
"""The python version passed into ``@nox.session``."""
return self._runner.func.python
@property
- def bin_paths(self) -> Optional[List[str]]:
+ def bin_paths(self) -> list[str] | None:
"""The bin directories for the virtualenv."""
return self.virtualenv.bin_paths
@@ -176,43 +264,167 @@ def bin(self) -> str:
"""The first bin directory for the virtualenv."""
paths = self.bin_paths
if paths is None:
- raise ValueError("The environment does not have a bin directory.")
+ msg = "The environment does not have a bin directory."
+ raise ValueError(msg)
return paths[0]
def create_tmp(self) -> str:
"""Create, and return, a temporary directory."""
tmpdir = os.path.join(self._runner.envdir, "tmp")
os.makedirs(tmpdir, exist_ok=True)
- self.env["TMPDIR"] = tmpdir
+ self.env["TMPDIR"] = os.path.abspath(tmpdir)
return tmpdir
+ @property
+ def cache_dir(self) -> pathlib.Path:
+ """Create and return a 'shared cache' directory to be used across sessions."""
+ path = pathlib.Path(self._runner.global_config.envdir).joinpath(".cache")
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
@property
def interactive(self) -> bool:
"""Returns True if Nox is being run in an interactive session or False otherwise."""
return not self._runner.global_config.non_interactive and sys.stdin.isatty()
- def chdir(self, dir: Union[str, os.PathLike]) -> None:
- """Change the current working directory."""
- self.log("cd {}".format(dir))
- os.chdir(dir)
+ def install_and_run_script(
+ self,
+ script: str | os.PathLike[str],
+ *args: str | os.PathLike[str],
+ env: Mapping[str, str | None] | None = None,
+ include_outer_env: bool = True,
+ silent: bool = False,
+ success_codes: Iterable[int] | None = None,
+ log: bool = True,
+ stdout: int | IO[str] | None = None,
+ stderr: int | IO[str] | None = subprocess.STDOUT,
+ interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
+ terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
+ ) -> str | bool | None:
+ """
+ Install dependencies and run a Python script.
+ """
+ deps = nox.project.load_toml(script).get("dependencies", [])
+ self.install(*deps)
+
+ return self.run(
+ "python",
+ script,
+ *args,
+ env=env,
+ include_outer_env=include_outer_env,
+ silent=silent,
+ success_codes=success_codes,
+ external=None,
+ log=log,
+ stdout=stdout,
+ stderr=stderr,
+ interrupt_timeout=interrupt_timeout,
+ terminate_timeout=terminate_timeout,
+ )
+
+ @property
+ def invoked_from(self) -> str:
+ """The directory that Nox was originally invoked from.
+
+ Since you can use the ``--noxfile / -f`` command-line
+ argument to run a Noxfile in a location different from your shell's
+ current working directory, Nox automatically changes the working directory
+ to the Noxfile's directory before running any sessions. This gives
+ you the original working directory that Nox was invoked form.
+ """
+ return self._runner.global_config.invoked_from # type: ignore[no-any-return]
+
+ def chdir(self, dir: str | os.PathLike[str]) -> _WorkingDirContext:
+ """Change the current working directory.
+
+ Can be used as a context manager to automatically restore the working directory::
+
+ with session.chdir("somewhere/deep/in/monorepo"):
+ # Runs in "/somewhere/deep/in/monorepo"
+ session.run("pytest")
+
+ # Runs in original working directory
+ session.run("flake8")
+
+ """
+ self.log(f"cd {dir}")
+ return _WorkingDirContext(dir)
cd = chdir
"""An alias for :meth:`chdir`."""
- def _run_func(
- self, func: Callable, args: Iterable[Any], kwargs: Mapping[str, Any]
- ) -> Any:
+ def _run_func(self, func: Callable[..., Any], args: Iterable[Any]) -> Any:
"""Legacy support for running a function through :func`run`."""
- self.log("{}(args={!r}, kwargs={!r})".format(func, args, kwargs))
+ self.log(f"{func}(args={args!r})")
try:
- return func(*args, **kwargs)
+ return func(*args)
except Exception as e:
- logger.exception("Function {!r} raised {!r}.".format(func, e))
- raise nox.command.CommandFailed()
+ logger.exception(f"Function {func!r} raised {e!r}.")
+ raise nox.command.CommandFailed() from e
+ @typing.overload
def run(
- self, *args: str, env: Mapping[str, str] = None, **kwargs: Any
- ) -> Optional[Any]:
+ self,
+ *args: str | os.PathLike[str],
+ env: Mapping[str, str | None] | None = None,
+ include_outer_env: bool = True,
+ silent: Literal[False] = ...,
+ success_codes: Iterable[int] | None = None,
+ log: bool = True,
+ external: ExternalType | None = None,
+ stdout: int | IO[str] | None = None,
+ stderr: int | IO[str] | None = subprocess.STDOUT,
+ interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
+ terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
+ ) -> bool | None: ...
+
+ @typing.overload
+ def run(
+ self,
+ *args: str | os.PathLike[str],
+ env: Mapping[str, str | None] | None = None,
+ include_outer_env: bool = True,
+ silent: Literal[True],
+ success_codes: Iterable[int] | None = None,
+ log: bool = True,
+ external: ExternalType | None = None,
+ stdout: int | IO[str] | None = None,
+ stderr: int | IO[str] | None = subprocess.STDOUT,
+ interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
+ terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
+ ) -> str | None: ...
+
+ @typing.overload
+ def run(
+ self,
+ *args: str | os.PathLike[str],
+ env: Mapping[str, str | None] | None = None,
+ include_outer_env: bool = True,
+ silent: bool,
+ success_codes: Iterable[int] | None = None,
+ log: bool = True,
+ external: ExternalType | None = None,
+ stdout: int | IO[str] | None = None,
+ stderr: int | IO[str] | None = subprocess.STDOUT,
+ interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
+ terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
+ ) -> str | bool | None: ...
+
+ def run(
+ self,
+ *args: str | os.PathLike[str],
+ env: Mapping[str, str | None] | None = None,
+ include_outer_env: bool = True,
+ silent: bool = False,
+ success_codes: Iterable[int] | None = None,
+ log: bool = True,
+ external: ExternalType | None = None,
+ stdout: int | IO[str] | None = None,
+ stderr: int | IO[str] | None = subprocess.STDOUT,
+ interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
+ terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
+ ) -> str | bool | None:
"""Run a command.
Commands must be specified as a list of strings, for example::
@@ -231,7 +443,18 @@ def run(
'bash', '-c', 'echo $SOME_ENV',
env={'SOME_ENV': 'Hello'})
- You can also tell nox to treat non-zero exit codes as success using
+ You can extend the shutdown timeout to allow long-running cleanup tasks to
+ complete before being terminated. For example, if you wanted to allow ``pytest``
+ extra time to clean up large projects in the case that Nox receives an
+ interrupt signal from your build system and needs to terminate its child
+ processes::
+
+ session.run(
+ 'pytest', '-k', 'long_cleanup',
+ interrupt_timeout=10.0,
+ terminate_timeout=2.0)
+
+ You can also tell Nox to treat non-zero exit codes as success using
``success_codes``. For example, if you wanted to treat the ``pytest``
"tests discovered, but none selected" error as success::
@@ -244,10 +467,37 @@ def run(
session.run('cmd', '/c', 'del', 'docs/modules.rst')
+ If ``session.run`` fails, it will stop the session and will not run the next steps.
+ Basically, this will raise a Python exception. Taking this in count, you can use a
+ ``try...finally`` block for cleanup runs, that will run even if the other runs fail::
+
+ try:
+ session.run("coverage", "run", "-m", "pytest")
+ finally:
+ # Display coverage report even when tests fail.
+ session.run("coverage", "report")
+
+ If you pass ``silent=True``, you can capture the output of a command that would
+ otherwise be shown to the user. For example to get the current Git commit ID::
+
+ out = session.run(
+ "git", "rev-parse", "--short", "HEAD",
+ external=True, silent=True
+ )
+
+ print("Current Git commit is", out.strip())
+
:param env: A dictionary of environment variables to expose to the
- command. By default, all environment variables are passed.
+ command. By default, all environment variables are passed. You
+ can block an environment variable from the outer environment by
+ setting it to None.
:type env: dict or None
+ :param include_outer_env: Boolean parameter that determines if the
+ environment variables from the nox invocation environment should
+ be passed to the command. ``True`` by default.
+ :type include_outer_env: bool
:param bool silent: Silence command output, unless the command fails.
+ If ``True``, returns the command output (unless the command fails).
``False`` by default.
:param success_codes: A list of return codes that are considered
successful. By default, only ``0`` is considered success.
@@ -258,24 +508,70 @@ def run(
``--error-on-external-run``. This has no effect for sessions that
do not have a virtualenv.
:type external: bool
+ :param interrupt_timeout: The timeout (in seconds) that Nox should wait after it
+ and its children receive an interrupt signal before sending a terminate
+ signal to its children. Set to ``None`` to never send a terminate signal.
+ Default: ``0.3``
+ :type interrupt_timeout: float or None
+ :param terminate_timeout: The timeout (in seconds) that Nox should wait after it
+ sends a terminate signal to its children before sending a kill signal to
+ them. Set to ``None`` to never send a kill signal.
+ Default: ``0.2``
+ :type terminate_timeout: float or None
+ :param stdout: Redirect standard output of the command into a file. Can't be
+ combined with *silent*.
+ :type stdout: file or file descriptor
+ :param stderr: Redirect standard output of the command into a file. Can't be
+ combined with *silent*.
+ :type stderr: file or file descriptor
"""
if not args:
- raise ValueError("At least one argument required to run().")
+ msg = "At least one argument required to run()."
+ raise ValueError(msg)
+
+ if len(args) == 1 and isinstance(args[0], (list, tuple)):
+ msg = "First argument to `session.run` is a list. Did you mean to use `session.run(*args)`?"
+ raise ValueError(msg)
if self._runner.global_config.install_only:
- logger.info("Skipping {} run, as --install-only is set.".format(args[0]))
+ logger.info(f"Skipping {args[0]} run, as --install-only is set.")
return None
- return self._run(*args, env=env, **kwargs)
+ return self._run(
+ *args,
+ env=env,
+ include_outer_env=include_outer_env,
+ silent=silent,
+ success_codes=success_codes,
+ log=log,
+ external=external,
+ stdout=stdout,
+ stderr=stderr,
+ interrupt_timeout=interrupt_timeout,
+ terminate_timeout=terminate_timeout,
+ )
- def run_always(
- self, *args: str, env: Mapping[str, str] = None, **kwargs: Any
- ) -> Optional[Any]:
- """Run a command **always**.
+ def run_install(
+ self,
+ *args: str | os.PathLike[str],
+ env: Mapping[str, str | None] | None = None,
+ include_outer_env: bool = True,
+ silent: bool = False,
+ success_codes: Iterable[int] | None = None,
+ log: bool = True,
+ external: ExternalType | None = None,
+ stdout: int | IO[str] | None = None,
+ stderr: int | IO[str] | None = subprocess.STDOUT,
+ interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
+ terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
+ ) -> str | bool | None:
+ """Run a command in the install step.
This is a variant of :meth:`run` that runs even in the presence of
``--install-only``. This method returns early if ``--no-install`` is
- specified and the virtualenv is being reused.
+ specified and the virtualenv is being reused. (In nox 2023.04.22 and
+ earlier, this was called ``run_always``, and that continues to be
+ available as an alias.)
Here are some cases where this method is useful:
@@ -287,6 +583,10 @@ def run_always(
:param env: A dictionary of environment variables to expose to the
command. By default, all environment variables are passed.
:type env: dict or None
+ :param include_outer_env: Boolean parameter that determines if the
+ environment variables from the nox invocation environment should
+ be passed to the command. ``True`` by default.
+ :type include_outer_env: bool
:param bool silent: Silence command output, unless the command fails.
``False`` by default.
:param success_codes: A list of return codes that are considered
@@ -298,6 +598,16 @@ def run_always(
``--error-on-external-run``. This has no effect for sessions that
do not have a virtualenv.
:type external: bool
+ :param interrupt_timeout: The timeout (in seconds) that Nox should wait after it
+ and its children receive an interrupt signal before sending a terminate
+ signal to its children. Set to ``None`` to never send a terminate signal.
+ Default: ``0.3``
+ :type interrupt_timeout: float or None
+ :param terminate_timeout: The timeout (in seconds) that Nox should wait after it
+ sends a terminate signal to its children before sending a kill signal to
+ them. Set to ``None`` to never send a kill signal.
+ Default: ``0.2``
+ :type terminate_timeout: float or None
"""
if (
self._runner.global_config.no_install
@@ -307,40 +617,133 @@ def run_always(
return None
if not args:
- raise ValueError("At least one argument required to run_always().")
+ msg = "At least one argument required to run_install()"
+ raise ValueError(msg)
+
+ return self._run(
+ *args,
+ env=env,
+ include_outer_env=include_outer_env,
+ silent=silent,
+ success_codes=success_codes,
+ log=log,
+ external=external,
+ stdout=stdout,
+ stderr=stderr,
+ interrupt_timeout=interrupt_timeout,
+ terminate_timeout=terminate_timeout,
+ )
- return self._run(*args, env=env, **kwargs)
+ def run_always(
+ self,
+ *args: str | os.PathLike[str],
+ env: Mapping[str, str | None] | None = None,
+ include_outer_env: bool = True,
+ silent: bool = False,
+ success_codes: Iterable[int] | None = None,
+ log: bool = True,
+ external: ExternalType | None = None,
+ stdout: int | IO[str] | None = None,
+ stderr: int | IO[str] | None = subprocess.STDOUT,
+ interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
+ terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
+ ) -> str | bool | None:
+ """This is an alias to ``run_install``, which better describes the use case.
+
+ :meta private:
+ """
+
+ return self.run_install(
+ *args,
+ env=env,
+ include_outer_env=include_outer_env,
+ silent=silent,
+ success_codes=success_codes,
+ log=log,
+ external=external,
+ stdout=stdout,
+ stderr=stderr,
+ interrupt_timeout=interrupt_timeout,
+ terminate_timeout=terminate_timeout,
+ )
- def _run(self, *args: str, env: Mapping[str, str] = None, **kwargs: Any) -> Any:
+ def _run(
+ self,
+ *args: str | os.PathLike[str],
+ env: Mapping[str, str | None] | None = None,
+ include_outer_env: bool,
+ silent: bool,
+ success_codes: Iterable[int] | None,
+ log: bool,
+ external: ExternalType | None,
+ stdout: int | IO[str] | None,
+ stderr: int | IO[str] | None,
+ interrupt_timeout: float | None,
+ terminate_timeout: float | None,
+ ) -> str | bool:
"""Like run(), except that it runs even if --install-only is provided."""
# Legacy support - run a function given.
if callable(args[0]):
- return self._run_func(args[0], args[1:], kwargs)
+ return self._run_func(args[0], args[1:]) # type: ignore[unreachable]
+
+ # Using `"uv"` or `"uvx" when `uv` is the backend is guaranteed to
+ # work, even if it was co-installed with nox.
+ if self.virtualenv.venv_backend == "uv" and nox.virtualenv.UV != "uv":
+ if (
+ args[0] == "uv"
+ and shutil.which("uv", path=self.bin)
+ is None # Session uv takes priority
+ ):
+ args = (nox.virtualenv.UV, *args[1:])
+ elif args[0] == "uvx" and shutil.which("uvx", path=self.bin) is None:
+ args = (f"{nox.virtualenv.UV}x", *args[1:])
# Combine the env argument with our virtualenv's env vars.
- if env is not None:
- overlay_env = env
- env = self.env.copy()
- env.update(overlay_env)
- else:
- env = self.env
+ env = self.virtualenv._get_env(env or {}, include_outer_env=include_outer_env)
# If --error-on-external-run is specified, error on external programs.
- if self._runner.global_config.error_on_external_run:
- kwargs.setdefault("external", "error")
+ if self._runner.global_config.error_on_external_run and external is None:
+ external = "error"
# Allow all external programs when running outside a sandbox.
- if not self.virtualenv.is_sandboxed:
- kwargs["external"] = True
+ if (
+ not self.virtualenv.is_sandboxed
+ or args[0] in self.virtualenv.allowed_globals
+ ):
+ external = True
- if args[0] in self.virtualenv.allowed_globals:
- kwargs["external"] = True
+ if external is None:
+ external = False
# Run a shell command.
- return nox.command.run(args, env=env, paths=self.bin_paths, **kwargs)
+ return nox.command.run(
+ args,
+ env=env,
+ paths=self.bin_paths,
+ silent=silent,
+ success_codes=success_codes,
+ log=log,
+ external=external,
+ stdout=stdout,
+ stderr=stderr,
+ interrupt_timeout=interrupt_timeout,
+ terminate_timeout=terminate_timeout,
+ )
def conda_install(
- self, *args: str, auto_offline: bool = True, **kwargs: Any
+ self,
+ *args: str,
+ auto_offline: bool = True,
+ channel: str | Sequence[str] = "",
+ env: Mapping[str, str] | None = None,
+ include_outer_env: bool = True,
+ silent: bool | None = None,
+ success_codes: Iterable[int] | None = None,
+ log: bool = True,
+ stdout: int | IO[str] | None = None,
+ stderr: int | IO[str] | None = subprocess.STDOUT,
+ interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
+ terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
) -> None:
"""Install invokes `conda install`_ to install packages inside of the
session's environment.
@@ -349,7 +752,7 @@ def conda_install(
session.conda_install('pandas')
session.conda_install('numpy', 'scipy')
- session.conda_install('--channel=conda-forge', 'dask==2.1.0')
+ session.conda_install('dask==2.1.0', channel='conda-forge')
To install packages from a ``requirements.txt`` file::
@@ -367,51 +770,91 @@ def conda_install(
# Install in editable mode.
session.install('-e', '.', '--no-deps')
+ You can specify a conda channel using `channel=`; a falsey value will
+ not change the current channels. You can specify a list of channels if
+ needed. It is highly recommended to specify this; micromamba does not
+ set default channels, and default channels vary for conda. Note that
+ "defaults" is also not permissively licensed like "conda-forge" is.
+
Additional keyword args are the same as for :meth:`run`.
.. _conda install:
"""
venv = self._runner.venv
- prefix_args = () # type: Tuple[str, ...]
+ prefix_args: tuple[str, ...] = ()
if isinstance(venv, CondaEnv):
prefix_args = ("--prefix", venv.location)
- elif not isinstance(venv, PassthroughEnv): # pragma: no cover
- raise ValueError(
- "A session without a conda environment can not install dependencies from conda."
+ elif not isinstance(venv, PassthroughEnv):
+ msg = (
+ "A session without a conda environment can not install dependencies"
+ " from conda."
)
+ raise TypeError(msg)
if not args:
- raise ValueError("At least one argument required to install().")
+ msg = "At least one argument required to install()."
+ raise ValueError(msg)
- if self._runner.global_config.no_install and venv._reused:
- return None
+ if self._runner.global_config.no_install and (
+ isinstance(venv, PassthroughEnv) or venv._reused
+ ):
+ return
# Escape args that should be (conda-specific; pip install does not need this)
- args = _dblquote_pkg_install_args(args)
+ if sys.platform.startswith("win"):
+ args = _dblquote_pkg_install_args(args)
- if "silent" not in kwargs:
- kwargs["silent"] = True
+ if silent is None:
+ silent = not self._runner.global_config.verbose
- extraopts = () # type: Tuple[str, ...]
+ extraopts: list[str] = []
if auto_offline and venv.is_offline():
logger.warning(
- "Automatically setting the `--offline` flag as conda repo seems unreachable."
+ "Automatically setting the `--offline` flag as conda repo seems"
+ " unreachable."
)
- extraopts = ("--offline",)
+ extraopts.append("--offline")
+
+ if channel:
+ if isinstance(channel, str):
+ extraopts.append(f"--channel={channel}")
+ else:
+ extraopts += [f"--channel={c}" for c in channel]
self._run(
- "conda",
+ venv.conda_cmd,
"install",
"--yes",
*extraopts,
*prefix_args,
*args,
+ env=env,
+ include_outer_env=include_outer_env,
+ silent=silent,
+ success_codes=success_codes,
+ log=log,
external="error",
- **kwargs
+ stdout=stdout,
+ stderr=stderr,
+ interrupt_timeout=interrupt_timeout,
+ terminate_timeout=terminate_timeout,
)
- def install(self, *args: str, **kwargs: Any) -> None:
+ def install(
+ self,
+ *args: str,
+ env: Mapping[str, str | None] | None = None,
+ include_outer_env: bool = True,
+ silent: bool | None = None,
+ success_codes: Iterable[int] | None = None,
+ log: bool = True,
+ external: ExternalType | None = None, # noqa: ARG002
+ stdout: int | IO[str] | None = None,
+ stderr: int | IO[str] | None = subprocess.STDOUT,
+ interrupt_timeout: float | None = DEFAULT_INTERRUPT_TIMEOUT,
+ terminate_timeout: float | None = DEFAULT_TERMINATE_TIMEOUT,
+ ) -> None:
"""Install invokes `pip`_ to install packages inside of the session's
virtualenv.
@@ -434,6 +877,18 @@ def install(self, *args: str, **kwargs: Any) -> None:
Additional keyword args are the same as for :meth:`run`.
+ .. warning::
+
+ Running ``session.install`` without a virtual environment
+ is no longer supported. If you still want to do that, please
+ use ``session.run("pip", "install", ...)`` instead.
+
+ .. warning::
+
+ The ``uv`` backend does not reinstall, even for local packages, so
+ you need to include ``--reinstall-package `` (uv-only) if
+ reusing the environment.
+
.. _pip: https://pip.readthedocs.org
"""
venv = self._runner.venv
@@ -441,34 +896,75 @@ def install(self, *args: str, **kwargs: Any) -> None:
if not isinstance(
venv, (CondaEnv, VirtualEnv, PassthroughEnv)
): # pragma: no cover
- raise ValueError(
- "A session without a virtualenv can not install dependencies."
+ msg = f"A session without a virtualenv (got {venv!r}) can not install dependencies."
+ raise TypeError(msg)
+ if isinstance(venv, PassthroughEnv):
+ if self._runner.global_config.no_install:
+ return
+ msg = (
+ f"Session {self.name} does not have a virtual environment, so use of"
+ " session.install() is no longer allowed since it would modify the"
+ " global Python environment. If you're really sure that is what you"
+ ' want to do, use session.run("pip", "install", ...) instead.'
)
+ raise ValueError(msg)
if not args:
- raise ValueError("At least one argument required to install().")
+ msg = "At least one argument required to install()."
+ raise ValueError(msg)
if self._runner.global_config.no_install and venv._reused:
- return None
+ return
- if "silent" not in kwargs:
- kwargs["silent"] = True
+ if silent is None:
+ silent = not self._runner.global_config.verbose
- self._run("python", "-m", "pip", "install", *args, external="error", **kwargs)
+ if isinstance(venv, VirtualEnv) and venv.venv_backend == "uv":
+ cmd = ["uv", "pip", "install"]
+ else:
+ cmd = ["python", "-m", "pip", "install"]
+ self._run(
+ *cmd,
+ *args,
+ env=env,
+ include_outer_env=include_outer_env,
+ external="error",
+ silent=silent,
+ success_codes=success_codes,
+ log=log,
+ stdout=stdout,
+ stderr=stderr,
+ interrupt_timeout=interrupt_timeout,
+ terminate_timeout=terminate_timeout,
+ )
def notify(
self,
- target: "Union[str, SessionRunner]",
- posargs: Optional[Iterable[str]] = None,
+ target: str | SessionRunner,
+ posargs: Iterable[str] | None = None,
) -> None:
"""Place the given session at the end of the queue.
This method is idempotent; multiple notifications to the same session
have no effect.
+ A common use case is to notify a code coverage analysis session
+ from a test session::
+
+ @nox.session
+ def test(session):
+ session.run("pytest")
+ session.notify("coverage")
+
+ @nox.session
+ def coverage(session):
+ session.run("coverage")
+
+ Now if you run `nox -s test`, the coverage session will run afterwards.
+
Args:
- target (Union[str, Callable]): The session to be notified. This
- may be specified as the appropriate string (same as used for
- ``nox -s``) or using the function object.
+ target (str): The session name to notify, as used for ``nox -s``.
+ Passing the decorated function object is not currently
+ supported.
posargs (Optional[Iterable[str]]): If given, sets the positional
arguments *only* for the queued session. Otherwise, the
standard globally available positional arguments will be
@@ -482,11 +978,19 @@ def log(self, *args: Any, **kwargs: Any) -> None:
"""Outputs a log during the session."""
logger.info(*args, **kwargs)
- def error(self, *args: Any) -> "_typing.NoReturn":
+ def warn(self, *args: Any, **kwargs: Any) -> None:
+ """Outputs a warning during the session."""
+ logger.warning(*args, **kwargs)
+
+ def debug(self, *args: Any, **kwargs: Any) -> None:
+ """Outputs a debug-level message during the session."""
+ logger.debug(*args, **kwargs)
+
+ def error(self, *args: Any) -> NoReturn:
"""Immediately aborts the session and optionally logs an error."""
raise _SessionQuit(*args)
- def skip(self, *args: Any) -> "_typing.NoReturn":
+ def skip(self, *args: Any) -> NoReturn:
"""Immediately skips the session and optionally logs a warning."""
raise _SessionSkip(*args)
@@ -495,134 +999,279 @@ class SessionRunner:
def __init__(
self,
name: str,
- signatures: List[str],
+ signatures: Sequence[str],
func: Func,
global_config: argparse.Namespace,
- manifest: "Manifest",
+ manifest: Manifest,
+ *,
+ multi: bool = False,
) -> None:
self.name = name
self.signatures = signatures
self.func = func
self.global_config = global_config
self.manifest = manifest
- self.venv: Optional[ProcessEnv] = None
- self.posargs: List[str] = global_config.posargs[:]
+ self.venv: ProcessEnv | None = None
+ self.posargs: list[str] = global_config.posargs[:]
+ self.result: Result | None = None
+ self.multi = multi
+
+ if getattr(func, "parametrize", None):
+ self.multi = True
+
+ def __repr__(self) -> str:
+ return (
+ f"<{self.__class__.__name__} {self.name}: {self.signatures!r} {self.multi}>"
+ )
@property
- def description(self) -> Optional[str]:
+ def description(self) -> str | None:
doc = self.func.__doc__
if doc:
- first_line = doc.strip().split("\n")[0]
- return first_line
+ return doc.strip().split("\n")[0]
+ return None
+
+ @property
+ def full_description(self) -> str | None:
+ doc = self.func.__doc__
+ if doc:
+ return inspect.cleandoc(doc)
return None
def __str__(self) -> str:
sigs = ", ".join(self.signatures)
- return "Session(name={}, signatures={})".format(self.name, sigs)
+ return f"Session(name={self.name}, signatures={sigs})"
@property
def friendly_name(self) -> str:
return self.signatures[0] if self.signatures else self.name
+ @property
+ def tags(self) -> list[str]:
+ return self.func.tags
+
@property
def envdir(self) -> str:
return _normalize_path(self.global_config.envdir, self.friendly_name)
+ def get_direct_dependencies(
+ self, sessions_by_id: Mapping[str, SessionRunner] | None = None
+ ) -> Iterator[SessionRunner]:
+ """Yields the sessions of the session's direct dependencies.
+
+ Args:
+ sessions_by_id (Mapping[str, ~nox.sessions.SessionRunner] | None): An
+ optional mapping from both dependency signatures and names to
+ corresponding ``SessionRunner``s. If this is not provided,
+ ``self.manifest.all_sessions_by_signature`` will be used to find the
+ sessions corresponding to signatures in ``self.func.requires``, and
+ non-signature names (i.e. names of sessions that were parameterized with
+ multiple Pythons) in ``self.func.requires`` will be resolved via
+ ``self.manifest.parametrized_sessions_by_name``.
+
+ Returns:
+ Iterator[~nox.session.SessionRunner]
+
+ Raises:
+ KeyError: If a dependency's session could not be found.
+ """
+ try:
+ if sessions_by_id is None:
+ sessions_by_signature = self.manifest.all_sessions_by_signature
+ parametrized_sessions_by_name = (
+ self.manifest.parametrized_sessions_by_name
+ )
+ for requirement in self.func.requires:
+ if requirement in sessions_by_signature:
+ yield sessions_by_signature[requirement]
+ else:
+ yield from parametrized_sessions_by_name[requirement]
+ else:
+ yield from map(sessions_by_id.__getitem__, self.func.requires)
+ except KeyError as exc:
+ msg = f"Session not found: {exc.args[0]}"
+ raise KeyError(msg) from exc
+
def _create_venv(self) -> None:
- backend = (
+ reuse_existing = self.reuse_existing_venv()
+
+ backends = (
self.global_config.force_venv_backend
or self.func.venv_backend
or self.global_config.default_venv_backend
- )
+ or "virtualenv"
+ ).split("|")
- if backend == "none" or self.func.python is False:
- self.venv = PassthroughEnv()
- return
+ download_python = (
+ self.global_config.download_python or self.func.download_python or "auto"
+ )
- reuse_existing = (
- self.func.reuse_venv or self.global_config.reuse_existing_virtualenvs
+ self.venv = get_virtualenv(
+ *backends,
+ download_python=download_python,
+ envdir=self.envdir,
+ reuse_existing=reuse_existing,
+ interpreter=self.func.python,
+ venv_params=self.func.venv_params,
)
- if backend is None or backend == "virtualenv":
- self.venv = VirtualEnv(
- self.envdir,
- interpreter=self.func.python, # type: ignore
- reuse_existing=reuse_existing,
- venv_params=self.func.venv_params,
- )
- elif backend == "conda":
- self.venv = CondaEnv(
- self.envdir,
- interpreter=self.func.python, # type: ignore
- reuse_existing=reuse_existing,
- venv_params=self.func.venv_params,
- )
- elif backend == "venv":
- self.venv = VirtualEnv(
- self.envdir,
- interpreter=self.func.python, # type: ignore
- reuse_existing=reuse_existing,
- venv=True,
- venv_params=self.func.venv_params,
+ self.venv.create()
+
+ def reuse_existing_venv(self) -> bool:
+ """
+ Determines whether to reuse an existing virtual environment.
+
+ The decision matrix is as follows:
+
+ +--------------------------+-----------------+-------------+
+ | global_config.reuse_venv | func.reuse_venv | Reuse venv? |
+ +==========================+=================+=============+
+ | "always" | N/A | Yes |
+ +--------------------------+-----------------+-------------+
+ | "never" | N/A | No |
+ +--------------------------+-----------------+-------------+
+ | "yes" | True|None | Yes |
+ +--------------------------+-----------------+-------------+
+ | "yes" | False | No |
+ +--------------------------+-----------------+-------------+
+ | "no" | True | Yes |
+ +--------------------------+-----------------+-------------+
+ | "no" | False|None | No |
+ +--------------------------+-----------------+-------------+
+
+ Summary
+ ~~~~~~~
+ - "always" forces reuse regardless of `func.reuse_venv`.
+ - "never" forces recreation regardless of `func.reuse_venv`.
+ - "yes" and "no" respect `func.reuse_venv` being ``False`` or ``True`` respectively.
+
+ Returns:
+ bool: True if the existing virtual environment should be reused, False otherwise.
+ """
+ if self.global_config.reuse_venv not in {"always", "never", "no", "yes", None}:
+ msg = (
+ "nox.options.reuse_venv must be set to 'always', 'never', 'no', or 'yes',"
+ f" got {self.global_config.reuse_venv!r}!"
)
- else:
- raise ValueError(
- "Expected venv_backend one of ('virtualenv', 'conda', 'venv'), but got '{}'.".format(
- backend
- )
+ raise AttributeError(msg)
+
+ return any(
+ (
+ # "always" forces reuse regardless of func.reuse_venv
+ self.global_config.reuse_venv == "always",
+ # Respect func.reuse_venv when it's explicitly True, unless global_config is "never"
+ self.func.reuse_venv is True
+ and self.global_config.reuse_venv != "never",
+ # Delegate to reuse ("yes") when func.reuse_venv is not explicitly False
+ self.func.reuse_venv is not False
+ and self.global_config.reuse_venv == "yes",
)
+ )
- self.venv.create()
-
- def execute(self) -> "Result":
- logger.warning("Running session {}".format(self.friendly_name))
+ def execute(self) -> Result:
+ logger.session_info(f"Running session {self.friendly_name}")
+
+ for dependency in self.get_direct_dependencies():
+ if not dependency.result:
+ self.result = Result(
+ self,
+ Status.ABORTED,
+ reason=(
+ f"Prerequisite session {dependency.friendly_name} was not"
+ " successful"
+ ),
+ duration=0,
+ )
+ return self.result
+ start = time.perf_counter()
try:
- # By default, nox should quietly change to the directory where
- # the noxfile.py file is located.
- cwd = py.path.local(
- os.path.realpath(os.path.dirname(self.global_config.noxfile))
- ).as_cwd()
+ cwd = os.path.realpath(os.path.dirname(self.global_config.noxfile))
- with cwd:
+ with _chdir(cwd):
self._create_venv()
session = Session(self)
+ session.env["NOX_CURRENT_SESSION"] = session.name
self.func(session)
# Nothing went wrong; return a success.
- return Result(self, Status.SUCCESS)
+ self.result = Result(
+ self, Status.SUCCESS, duration=time.perf_counter() - start
+ )
except nox.virtualenv.InterpreterNotFound as exc:
if self.global_config.error_on_missing_interpreters:
- return Result(self, Status.FAILED, reason=str(exc))
+ self.result = Result(
+ self,
+ Status.FAILED,
+ reason=str(exc),
+ duration=time.perf_counter() - start,
+ )
else:
- return Result(self, Status.SKIPPED, reason=str(exc))
+ logger.warning(
+ "Missing interpreters will error by default on CI systems."
+ )
+ self.result = Result(
+ self,
+ Status.SKIPPED,
+ reason=str(exc),
+ duration=time.perf_counter() - start,
+ )
except _SessionQuit as exc:
- return Result(self, Status.ABORTED, reason=str(exc))
+ self.result = Result(
+ self,
+ Status.ABORTED,
+ reason=str(exc),
+ duration=time.perf_counter() - start,
+ )
except _SessionSkip as exc:
- return Result(self, Status.SKIPPED, reason=str(exc))
+ self.result = Result(
+ self,
+ Status.SKIPPED,
+ reason=str(exc),
+ duration=time.perf_counter() - start,
+ )
except nox.command.CommandFailed:
- return Result(self, Status.FAILED)
+ self.result = Result(
+ self, Status.FAILED, duration=time.perf_counter() - start
+ )
except KeyboardInterrupt:
- logger.error("Session {} interrupted.".format(self.friendly_name))
+ logger.error(f"Session {self.friendly_name} interrupted.")
raise
- except Exception as exc:
- logger.exception(
- "Session {} raised exception {!r}".format(self.friendly_name, exc)
+ except Exception as exc: # noqa: BLE001
+ logger.exception(f"Session {self.friendly_name} raised exception {exc!r}")
+ self.result = Result(
+ self, Status.FAILED, duration=time.perf_counter() - start
)
- return Result(self, Status.FAILED)
+
+ return self.result
+
+
+def _duration_str(seconds: float, text: str) -> str:
+ time_str = humanize.naturaldelta(datetime.timedelta(seconds=seconds))
+
+ # Might be "a moment" if short, return empty string in that case
+ if time_str == "a moment":
+ return ""
+
+ return text.format(time=time_str)
class Result:
"""An object representing the result of a session."""
def __init__(
- self, session: SessionRunner, status: Status, reason: Optional[str] = None
+ self,
+ session: SessionRunner,
+ status: Status,
+ reason: str | None = None,
+ *,
+ duration: float = 0.0,
) -> None:
"""Initialize the Result object.
@@ -631,17 +1280,16 @@ def __init__(
The session runner which ran.
status (~nox.sessions.Status): The final result status.
reason (str): Additional info.
+ duration (float): Time taken in seconds.
"""
self.session = session
self.status = status
self.reason = reason
+ self.duration = duration
def __bool__(self) -> bool:
return self.status.value > 0
- def __nonzero__(self) -> bool:
- return self.__bool__()
-
@property
def imperfect(self) -> str:
"""Return the English imperfect tense for the status.
@@ -649,13 +1297,15 @@ def imperfect(self) -> str:
Returns:
str: A word or phrase representing the status.
"""
- if self.status == Status.SUCCESS:
- return "was successful"
- status = self.status.name.lower()
- if self.reason:
- return "{}: {}".format(status, self.reason)
- else:
- return status
+ match self.status:
+ case Status.SUCCESS:
+ return "was successful" + _duration_str(self.duration, " in {time}")
+ case _:
+ status = self.status.name.lower()
+ if self.reason:
+ duration_err = _duration_str(self.duration, " (took {time})")
+ return f"{status}: {self.reason}{duration_err}"
+ return status
def log(self, message: str) -> None:
"""Log a message using the appropriate log function.
@@ -663,16 +1313,16 @@ def log(self, message: str) -> None:
Args:
message (str): The message to be logged.
"""
- log_function = logger.info
- if self.status == Status.SUCCESS:
- log_function = logger.success
- if self.status == Status.SKIPPED:
- log_function = logger.warning
- if self.status.value <= 0:
- log_function = logger.error
+ match self.status:
+ case Status.SUCCESS:
+ log_function = logger.success
+ case Status.SKIPPED:
+ log_function = logger.warning
+ case status:
+ log_function = logger.error if status.value <= 0 else logger.info
log_function(message)
- def serialize(self) -> Dict[str, Any]:
+ def serialize(self) -> dict[str, Any]:
"""Return a serialized representation of this result.
Returns:
@@ -684,4 +1334,5 @@ def serialize(self) -> Dict[str, Any]:
"result": self.status.name.lower(),
"result_code": self.status.value,
"signatures": self.session.signatures,
+ "duration": self.duration,
}
diff --git a/nox/tasks.py b/nox/tasks.py
index c23b118d..da04f531 100644
--- a/nox/tasks.py
+++ b/nox/tasks.py
@@ -12,26 +12,94 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import importlib.machinery
-import io
+from __future__ import annotations
+
+__lazy_modules__ = {
+ "ast",
+ "colorlog",
+ "colorlog.escape_codes",
+ "importlib",
+ "importlib.util",
+ "json",
+ "nox._resolver",
+ "nox._version",
+ "nox.logger",
+ "nox.manifest",
+}
+
+import ast
+import importlib.util
import json
import os
-import types
-from argparse import Namespace
-from typing import List, Union
+import sys
+from collections.abc import Sequence
+from typing import TYPE_CHECKING, TypeVar
from colorlog.escape_codes import parse_colors
import nox
from nox import _options, registry
+from nox._resolver import CycleError
from nox._version import InvalidVersionSpecifier, VersionCheckFailed, check_nox_version
from nox.logger import logger
from nox.manifest import WARN_PYTHONS_IGNORED, Manifest
-from nox.sessions import Result
+from nox.sessions import Result, Status, _duration_str
+if TYPE_CHECKING:
+ import types
+ from argparse import Namespace
-def load_nox_module(global_config: Namespace) -> Union[types.ModuleType, int]:
- """Load the user's noxfile and return the module object for it.
+__all__ = [
+ "create_report",
+ "discover_manifest",
+ "filter_manifest",
+ "final_reduce",
+ "honor_list_request",
+ "load_nox_module",
+ "merge_noxfile_options",
+ "print_summary",
+ "run_manifest",
+]
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+
+def _load_and_exec_nox_module(global_config: Namespace) -> types.ModuleType:
+ """
+ Loads, executes, then returns the global_config Nox module.
+
+ Args:
+ global_config (Namespace): The global config.
+
+ Raises:
+ IOError: If the Nox module cannot be loaded. This
+ exception is chosen such that it will be caught
+ by load_nox_module and logged appropriately.
+
+ Returns:
+ types.ModuleType: The initialised Nox module.
+ """
+ spec = importlib.util.spec_from_file_location(
+ "user_nox_module", str(global_config.noxfile)
+ )
+ assert spec is not None # If None, fatal importlib error, would crash anyway
+
+ module = importlib.util.module_from_spec(spec)
+ assert module is not None # If None, fatal importlib error, would crash anyway
+
+ sys.modules["user_nox_module"] = module
+
+ loader = spec.loader
+ assert loader is not None # If None, fatal importlib error, would crash anyway
+ # See https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
+ loader.exec_module(module)
+ return module
+
+
+def load_nox_module(global_config: Namespace) -> types.ModuleType | int:
+ """Load the user's Noxfile and return the module object for it.
.. note::
@@ -44,34 +112,45 @@ def load_nox_module(global_config: Namespace) -> Union[types.ModuleType, int]:
Returns:
module: The module designated by the Noxfile path.
"""
- try:
- # Save the absolute path to the Noxfile.
- # This will inoculate it if nox changes paths because of an implicit
- # or explicit chdir (like the one below).
- global_config.noxfile = os.path.realpath(
- # Be sure to expand variables
- os.path.expandvars(global_config.noxfile)
- )
+ # Be sure to expand variables
+ global_config_noxfile = os.path.expandvars(global_config.noxfile)
+
+ # Make sure we only expand the parent dir just in case the noxfile is a symlink
+ noxfile_parent_dir = os.path.realpath(os.path.dirname(global_config_noxfile))
+
+ # Save the absolute path to the Noxfile.
+ # This will inoculate it if Nox changes paths because of an implicit
+ # or explicit chdir (like the one below).
+ # Keeps the class of the original string
+ global_config.noxfile = global_config.noxfile.__class__(
+ os.path.join(noxfile_parent_dir, os.path.basename(global_config_noxfile))
+ )
+ try:
# Check ``nox.needs_version`` by parsing the AST.
check_nox_version(global_config.noxfile)
# Move to the path where the Noxfile is.
# This will ensure that the Noxfile's path is on sys.path, and that
# import-time path resolutions work the way the Noxfile author would
- # guess.
- os.chdir(os.path.realpath(os.path.dirname(global_config.noxfile)))
- return importlib.machinery.SourceFileLoader(
- "user_nox_module", global_config.noxfile
- ).load_module() # type: ignore
+ # guess. The original working directory (the directory that Nox was
+ # invoked from) gets stored by the .invoke_from "option" in _options.
+ os.chdir(noxfile_parent_dir)
except (VersionCheckFailed, InvalidVersionSpecifier) as error:
logger.error(str(error))
return 2
- except (IOError, OSError):
- logger.exception("Failed to load Noxfile {}".format(global_config.noxfile))
+ except FileNotFoundError:
+ logger.error(
+ f"Failed to load Noxfile {global_config.noxfile}, no such file exists."
+ )
+ return 2
+ except OSError:
+ logger.exception(f"Failed to load Noxfile {global_config.noxfile}")
return 2
+ return _load_and_exec_nox_module(global_config)
+
def merge_noxfile_options(
module: types.ModuleType, global_config: Namespace
@@ -88,9 +167,9 @@ def merge_noxfile_options(
def discover_manifest(
- module: Union[types.ModuleType, int], global_config: Namespace
+ module: types.ModuleType | int, global_config: Namespace
) -> Manifest:
- """Discover all session functions in the noxfile module.
+ """Discover all session functions in the Noxfile module.
Args:
module (module): The Noxfile module.
@@ -104,13 +183,14 @@ def discover_manifest(
# sorted by decorator call time.
functions = registry.get()
+ # Get the docstring from the Noxfile
+ module_docstring = module.__doc__
+
# Return the final dictionary of session functions.
- return Manifest(functions, global_config)
+ return Manifest(functions, global_config, module_docstring)
-def filter_manifest(
- manifest: Manifest, global_config: Namespace
-) -> Union[Manifest, int]:
+def filter_manifest(manifest: Manifest, global_config: Namespace) -> Manifest | int:
"""Filter the manifest according to the provided configuration.
Args:
@@ -122,36 +202,128 @@ def filter_manifest(
the manifest otherwise (to be sent to the next task).
"""
+ # Shouldn't happen unless the Noxfile is empty
+ if not manifest:
+ logger.error(f"No sessions found in {global_config.noxfile}.")
+ return 3
+
# Filter by the name of any explicit sessions.
# This can raise KeyError if a specified session does not exist;
- # log this if it happens.
- if global_config.sessions:
- try:
- manifest.filter_by_name(global_config.sessions)
- except KeyError as exc:
- logger.error("Error while collecting sessions.")
- logger.error(exc.args[0])
- return 3
+ # log this if it happens. The sessions does not come from the Noxfile
+ # if keywords is not empty or tags are supplied.
+ if global_config.tags is None and not global_config.keywords:
+ if global_config.sessions is None:
+ manifest.filter_by_default()
+ else:
+ try:
+ manifest.filter_by_name(global_config.sessions)
+ except KeyError as exc:
+ logger.error("Error while collecting sessions.")
+ logger.error(exc.args[0])
+ return 3
+
+ if not manifest and not global_config.list_sessions:
+ print("No sessions selected. Please select a session with -s .\n")
+ _produce_listing(manifest, global_config)
+ return 0
# Filter by python interpreter versions.
- # This function never errors, but may cause an empty list of sessions
- # (which is an error condition later).
if global_config.pythons:
manifest.filter_by_python_interpreter(global_config.pythons)
+ if not manifest and not global_config.list_sessions:
+ logger.error("Python version selection caused no sessions to be selected.")
+ return 3
+
+ # Filter by tags.
+ if global_config.tags is not None:
+ manifest.filter_by_tags(global_config.tags)
+ if not manifest and not global_config.list_sessions:
+ logger.error("Tag selection caused no sessions to be selected.")
+ return 3
# Filter by keywords.
- # This function never errors, but may cause an empty list of sessions
- # (which is an error condition later).
if global_config.keywords:
+ try:
+ ast.parse(global_config.keywords, mode="eval")
+ except SyntaxError:
+ logger.error(
+ "Error while collecting sessions: keywords argument must be a Python"
+ " expression."
+ )
+ return 3
+
+ # This function never errors, but may cause an empty list of sessions
+ # (which is an error condition later).
manifest.filter_by_keywords(global_config.keywords)
+ if not manifest and not global_config.list_sessions:
+ logger.error("No sessions selected after filtering by keyword.")
+ return 3
+
+ # Add dependencies.
+ try:
+ manifest.add_dependencies()
+ except (KeyError, CycleError) as exc:
+ logger.error("Error while resolving session dependencies.")
+ logger.error(exc.args[0])
+ return 3
+
# Return the modified manifest.
return manifest
-def honor_list_request(
- manifest: Manifest, global_config: Namespace
-) -> Union[Manifest, int]:
+def _produce_listing(manifest: Manifest, global_config: Namespace) -> None:
+ # If the user just asked for a list of sessions, print that
+ # and any docstring specified in noxfile.py and be done. This
+ # can also be called if Noxfile sessions is an empty list.
+
+ if manifest.module_docstring:
+ print(manifest.module_docstring.strip(), end="\n\n")
+
+ print(f"Sessions defined in {global_config.noxfile}:\n")
+
+ reset = parse_colors("reset") if global_config.color else ""
+ selected_color = parse_colors("cyan") if global_config.color else ""
+ skipped_color = parse_colors("white") if global_config.color else ""
+
+ for session, selected in manifest.list_all_sessions():
+ marker = "*" if selected else "-"
+ color = selected_color if selected else skipped_color
+ tag_color = (
+ (parse_colors("purple") if selected else parse_colors("light_purple"))
+ if global_config.color
+ else ""
+ )
+
+ tags = f" {tag_color}[{','.join(session.tags)}]{reset}" if session.tags else ""
+ description = f" -> {session.description}" if session.description else ""
+
+ print(f"{marker} {color}{session.friendly_name}{reset}{tags}{description}")
+
+ print(
+ f"\nsessions marked with {selected_color}*{reset} are selected, sessions marked"
+ f" with {skipped_color}-{reset} are skipped."
+ )
+
+
+def _produce_json_listing(manifest: Manifest, global_config: Namespace) -> None: # noqa: ARG001
+ report = []
+ for session, selected in manifest.list_all_sessions():
+ if selected:
+ report.append(
+ {
+ "session": session.friendly_name,
+ "name": session.name,
+ "description": session.description or "",
+ "python": session.func.python,
+ "tags": session.tags,
+ "call_spec": getattr(session.func, "call_spec", {}),
+ }
+ )
+ print(json.dumps(report, default=str))
+
+
+def honor_list_request(manifest: Manifest, global_config: Namespace) -> Manifest | int:
"""If --list was passed, simply list the manifest and exit cleanly.
Args:
@@ -162,68 +334,61 @@ def honor_list_request(
Union[~.Manifest,int]: ``0`` if a listing is all that is requested,
the manifest otherwise (to be sent to the next task).
"""
- if not global_config.list_sessions:
+ if not (global_config.list_sessions or global_config.json):
return manifest
- # If the user just asked for a list of sessions, print that
- # and be done.
-
- print("Sessions defined in {noxfile}:\n".format(noxfile=global_config.noxfile))
-
- reset = parse_colors("reset") if global_config.color else ""
- selected_color = parse_colors("cyan") if global_config.color else ""
- skipped_color = parse_colors("white") if global_config.color else ""
+ # JSON output requires list sessions also be specified
+ if global_config.json and not global_config.list_sessions:
+ logger.error("Must specify --list-sessions with --json")
+ return 3
- for session, selected in manifest.list_all_sessions():
- output = "{marker} {color}{session}{reset}"
+ if global_config.json:
+ _produce_json_listing(manifest, global_config)
+ else:
+ _produce_listing(manifest, global_config)
- if selected:
- marker = "*"
- color = selected_color
- else:
- marker = "-"
- color = skipped_color
-
- if session.description is not None:
- output += " -> {description}"
-
- print(
- output.format(
- color=color,
- reset=reset,
- session=session.friendly_name,
- description=session.description,
- marker=marker,
- )
- )
-
- print(
- "\nsessions marked with {selected_color}*{reset} are selected, sessions marked with {skipped_color}-{reset} are skipped.".format(
- selected_color=selected_color, skipped_color=skipped_color, reset=reset
- )
- )
return 0
-def verify_manifest_nonempty(
- manifest: Manifest, global_config: Namespace
-) -> Union[Manifest, int]:
- """Abort with an error code if the manifest is empty.
+def honor_usage_request(manifest: Manifest, global_config: Namespace) -> Manifest | int:
+ """If --usage was passed, print the full docstring of the session and exit.
+ Raise an error if the session does not exist or has no docstring to print.
Args:
manifest (~.Manifest): The manifest of sessions to be run.
global_config (~nox.main.GlobalConfig): The global configuration.
Returns:
- Union[~.Manifest,int]: ``3`` on an empty manifest, the manifest
- otherwise.
+ Union[~.Manifest,int]: ``0`` if usage is printed,
+ the manifest otherwise (to be sent to the next task).
"""
- if not manifest:
- return 3
- return manifest
+ if not global_config.usage:
+ return manifest
+ name = global_config.usage[0]
+
+ # Search all sessions, not just the filtered queue, so that
+ # non-default sessions can also be looked up.
+ session = None
+ for _ in manifest._all_sessions:
+ if _.name == name or name in _.signatures:
+ session = _
+ break
+
+ if session is None:
+ logger.error("Session %s not found.", name)
+ return 1
-def run_manifest(manifest: Manifest, global_config: Namespace) -> List[Result]:
+ full_description = session.full_description
+ if full_description is None:
+ logger.error("Session %s has no docstring.", name)
+ return 1
+
+ print(full_description)
+ return 0
+
+
+def run_manifest(manifest: Manifest, global_config: Namespace) -> list[Result]:
"""Run the full manifest of sessions.
Args:
@@ -244,21 +409,19 @@ def run_manifest(manifest: Manifest, global_config: Namespace) -> List[Result]:
# possibly raise warnings associated with this session
if WARN_PYTHONS_IGNORED in session.func.should_warn:
logger.warning(
- "Session {} is set to run with venv_backend='none', IGNORING its python={} parametrization. ".format(
- session.name, session.func.should_warn[WARN_PYTHONS_IGNORED]
- )
+ f"Session {session.name} is set to run with venv_backend='none', "
+ "IGNORING its"
+ f" python={session.func.should_warn[WARN_PYTHONS_IGNORED]} parametrization. "
)
result = session.execute()
- result.log(
- "Session {name} {status}.".format(
- name=session.friendly_name, status=result.imperfect
- )
- )
+ name = session.friendly_name
+ status = result.imperfect
+ result.log(f"Session {name} {status}.")
results.append(result)
# Sanity check: If we are supposed to stop on the first error case,
- # the abort now.
+ # then abort now.
if not result and global_config.stop_on_first_error:
return results
@@ -266,7 +429,13 @@ def run_manifest(manifest: Manifest, global_config: Namespace) -> List[Result]:
return results
-def print_summary(results: List[Result], global_config: Namespace) -> List[Result]:
+Sequence_Results_T = TypeVar("Sequence_Results_T", bound=Sequence[Result])
+
+
+def print_summary(
+ results: Sequence_Results_T,
+ global_config: Namespace, # noqa: ARG001
+) -> Sequence_Results_T:
"""Print a summary of the results.
Args:
@@ -283,19 +452,25 @@ def print_summary(results: List[Result], global_config: Namespace) -> List[Resul
# Iterate over the results and print the result for each in a
# human-readable way.
- logger.warning("Ran multiple sessions:")
+ total_duration = sum(result.duration for result in results)
+ duration_str = _duration_str(total_duration, " in {time}")
+ logger.session_info(f"Ran {len(results)} sessions{duration_str}:")
for result in results:
- result.log(
- "* {name}: {status}".format(
- name=result.session.friendly_name, status=result.status.name.lower()
- )
- )
+ name = result.session.friendly_name
+ status = result.status.name.lower()
+ duration_str = _duration_str(result.duration, ", took {time}")
+ if result.status is Status.SKIPPED and result.reason:
+ result.log(f"* {name}: {status} ({result.reason}){duration_str}")
+ else:
+ result.log(f"* {name}: {status}{duration_str}")
# Return the results that were sent to this function.
return results
-def create_report(results: List[Result], global_config: Namespace) -> List[Result]:
+def create_report(
+ results: Sequence_Results_T, global_config: Namespace
+) -> Sequence_Results_T:
"""Write a report to the location designated in the config, if any.
Args:
@@ -311,7 +486,7 @@ def create_report(results: List[Result], global_config: Namespace) -> List[Resul
return results
# Write the JSON report.
- with io.open(global_config.report, "w") as report_file:
+ with open(global_config.report, "w", encoding="utf-8") as report_file:
json.dump(
{
"result": int(all(results)),
@@ -325,7 +500,7 @@ def create_report(results: List[Result], global_config: Namespace) -> List[Resul
return results
-def final_reduce(results: List[Result], global_config: Namespace) -> int:
+def final_reduce(results: list[Result], global_config: Namespace) -> int: # noqa: ARG001
"""Reduce the results to a final exit code.
Args:
diff --git a/nox/tox4_to_nox.jinja2 b/nox/tox4_to_nox.jinja2
new file mode 100644
index 00000000..e5a67d9b
--- /dev/null
+++ b/nox/tox4_to_nox.jinja2
@@ -0,0 +1,33 @@
+import nox
+
+{% for envname, envconfig in config.items()|sort: %}
+@nox.session({%- if envconfig.base_python %}python='{{envconfig.base_python}}'{%- endif %})
+def {{fixname(envname)}}(session):
+ {%- if envconfig.description != '' %}
+ """{{envconfig.description}}"""
+ {%- endif %}
+ {%- set envs = envconfig.get('set_env', {}) -%}
+ {%- for key, value in envs.items()|sort: %}
+ session.env['{{key}}'] = '{{value}}'
+ {%- endfor %}
+
+ {%- if envconfig.deps %}
+ session.install({{envconfig.deps}})
+ {%- endif %}
+
+ {%- if not envconfig.skip_install %}
+ {%- if envconfig.use_develop %}
+ session.install('-e', '.')
+ {%- else %}
+ session.install('.')
+ {%- endif -%}
+ {%- endif %}
+
+ {%- if envconfig.change_dir %}
+ session.chdir('{{envconfig.change_dir}}')
+ {%- endif %}
+
+ {%- for command in envconfig.commands %}
+ session.run({{command}})
+ {%- endfor %}
+{% endfor %}
diff --git a/nox/tox_to_nox.jinja2 b/nox/tox_to_nox.jinja2
deleted file mode 100644
index 9494a05d..00000000
--- a/nox/tox_to_nox.jinja2
+++ /dev/null
@@ -1,33 +0,0 @@
-import nox
-
-{% for envname, envconfig in config.envconfigs.items()|sort: %}
-@nox.session({%- if envconfig.basepython %}python='{{envconfig.basepython}}'{%- endif %})
-def {{envname}}(session):
- {%- set envs = dict(envconfig.setenv) -%}
- {%- do envs.pop('PYTHONHASHSEED', None) -%}
- {%- do envs.pop('TOX_ENV_DIR', None) -%}
- {%- do envs.pop('TOX_ENV_NAME', None) -%}
- {%- for key, value in envs.items()|sort: %}
- session.env['{{key}}'] = '{{value}}'
- {%- endfor %}
-
- {%- if envconfig.deps %}
- session.install({{wrapjoin(envconfig.deps)}})
- {%- endif %}
-
- {%- if not envconfig.skip_install %}
- {%- if envconfig.usedevelop %}
- session.install('-e', '.')
- {%- else %}
- session.install('.')
- {%- endif -%}
- {%- endif %}
-
- {%- if envconfig.changedir and config.toxinidir.bestrelpath(envconfig.changedir) != '.' %}
- session.chdir('{{config.toxinidir.bestrelpath(envconfig.changedir)}}')
- {%- endif %}
-
- {%- for command in envconfig.commands %}
- session.run({{wrapjoin(command)}})
- {%- endfor %}
-{% endfor %}
diff --git a/nox/tox_to_nox.py b/nox/tox_to_nox.py
index 222f5fb9..8c09f727 100644
--- a/nox/tox_to_nox.py
+++ b/nox/tox_to_nox.py
@@ -14,32 +14,134 @@
"""Naively converts tox.ini files into noxfile.py files."""
+from __future__ import annotations
+
+__lazy_modules__ = {
+ "argparse",
+ "configparser",
+ "functools",
+ "pathlib",
+ "re",
+ "subprocess",
+}
+
import argparse
-import io
-import pkgutil
-from typing import Any, Iterator
+import functools
+import os
+import re
+import sys
+from configparser import ConfigParser
+from pathlib import Path
+from subprocess import check_output
+from typing import TYPE_CHECKING, Any
import jinja2
-import tox.config
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
+from importlib.resources import files
+
+__all__ = ["main"]
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+
+DATA = files("nox")
_TEMPLATE = jinja2.Template(
- pkgutil.get_data(__name__, "tox_to_nox.jinja2").decode("utf-8"), # type: ignore
+ DATA.joinpath("tox4_to_nox.jinja2").read_text(encoding="utf-8"),
extensions=["jinja2.ext.do"],
)
-def wrapjoin(seq: Iterator[Any]) -> str:
- return ", ".join(["'{}'".format(item) for item in seq])
+def wrapjoin(seq: Iterable[Any]) -> str:
+ """Wrap each item in single quotes and join them with a comma."""
+ return ", ".join([f"'{item}'" for item in seq])
+
+
+def fixname(envname: str) -> str:
+ """
+ Replace dashes with underscores. Tox 4+ requires valid identifiers for
+ names already.
+ """
+ return envname.replace("-", "_").replace("testenv:", "")
+
+
+def write_output_to_file(output: str, filename: str) -> None:
+ """Write output to a file."""
+ with open(filename, "w", encoding="utf-8") as outfile:
+ outfile.write(output)
def main() -> None:
- parser = argparse.ArgumentParser(description="Converts toxfiles to noxfiles.")
+ make_parser = functools.partial(argparse.ArgumentParser, allow_abbrev=False)
+ if sys.version_info >= (3, 14):
+ make_parser = functools.partial(make_parser, color=True, suggest_on_error=True)
+ parser = make_parser(description="Converts toxfiles to noxfiles.")
parser.add_argument("--output", default="noxfile.py")
args = parser.parse_args()
- config = tox.config.parseconfig([])
- output = _TEMPLATE.render(config=config, wrapjoin=wrapjoin)
+ output = check_output(["tox", "config"], text=True, encoding="utf-8") # noqa: S607
+ original_config = ConfigParser()
+ original_config.read_string(output)
+ config: dict[str, dict[str, Any]] = {}
- with io.open(args.output, "w") as outfile:
- outfile.write(output)
+ for name, section in original_config.items():
+ if name == "DEFAULT":
+ continue
+
+ config[name] = dict(section)
+ # Convert set_env from string to dict
+ set_env = {}
+ for var in section.get("set_env", "").strip().splitlines():
+ k, v = var.split("=", 1)
+ if k not in {
+ "PYTHONHASHSEED",
+ "PIP_DISABLE_PIP_VERSION_CHECK",
+ "PYTHONIOENCODING",
+ }:
+ set_env[k] = v
+
+ config[name]["set_env"] = set_env
+
+ config[name]["commands"] = [
+ wrapjoin(c.split()) for c in section["commands"].strip().splitlines()
+ ]
+
+ config[name]["deps"] = wrapjoin(
+ [
+ part
+ for dep in section["deps"].strip().splitlines()
+ for part in (dep.split() if dep.startswith("-r") else [dep])
+ ]
+ )
+
+ for option in "skip_install", "use_develop":
+ if section.get(option):
+ config[name][option] = section[option] != "False"
+
+ if os.path.isabs(section["base_python"]) or re.match(
+ r"py\d+", section["base_python"]
+ ):
+ impl = "python" if section["py_impl"] == "cpython" else section["py_impl"]
+ config[name]["base_python"] = impl + section["py_dot_ver"]
+
+ change_dir = Path(section.get("change_dir", ""))
+ try:
+ rel_to_cwd = change_dir.relative_to(Path.cwd())
+ except ValueError:
+ # change_dir is outside the project root, keep the absolute path
+ config[name]["change_dir"] = change_dir
+ else:
+ if str(rel_to_cwd) == ".":
+ config[name]["change_dir"] = None
+ else:
+ config[name]["change_dir"] = rel_to_cwd
+
+ output = _TEMPLATE.render(config=config, wrapjoin=wrapjoin, fixname=fixname)
+
+ write_output_to_file(output, args.output)
diff --git a/nox/virtualenv.py b/nox/virtualenv.py
index ab015268..6909e4cf 100644
--- a/nox/virtualenv.py
+++ b/nox/virtualenv.py
@@ -12,64 +12,362 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
+__lazy_modules__ = {
+ "contextlib",
+ "json",
+ "nox.command",
+ "nox.logger",
+ "packaging",
+ "re",
+ "shutil",
+ "socket",
+ "subprocess",
+}
+
+import abc
+import contextlib
+import functools
+import json
import os
-import platform
import re
import shutil
+import subprocess
import sys
+import sysconfig
+from pathlib import Path
from socket import gethostbyname
-from typing import Any, List, Mapping, Optional, Tuple, Union
+from typing import TYPE_CHECKING, Any, Literal
-import py
+from packaging import version
+import nox
import nox.command
from nox.logger import logger
-from . import _typing
+if TYPE_CHECKING:
+ from collections.abc import Callable, Mapping, Sequence
+
+ from nox._typing import Python
+
+ # These are implemented via the module-level __getattr__ below, so that
+ # uv detection (which spawns a subprocess) only runs when first needed.
+ HAS_UV: bool
+ UV: str
+ UV_VERSION: version.Version
+ OPTIONAL_VENVS: dict[str, bool]
+
+__all__ = [
+ "ALL_VENVS",
+ "HAS_UV",
+ "OPTIONAL_VENVS",
+ "UV",
+ "UV_VERSION",
+ "CondaEnv",
+ "InterpreterNotFound",
+ "PassthroughEnv",
+ "ProcessEnv",
+ "VirtualEnv",
+ "find_uv",
+ "get_virtualenv",
+ "pbs_install_python",
+ "uv_install_python",
+ "uv_version",
+]
+
+
+def __dir__() -> list[str]:
+ return __all__
+
+
+# Use for test mocking and to make mypy happy
+_PLATFORM = sys.platform
+_IS_MINGW = sysconfig.get_platform().startswith("mingw")
+
# Problematic environment variables that are stripped from all commands inside
# of a virtualenv. See https://github.com/theacodes/nox/issues/44
_BLACKLISTED_ENV_VARS = frozenset(
- ["PIP_RESPECT_VIRTUALENV", "PIP_REQUIRE_VIRTUALENV", "__PYVENV_LAUNCHER__"]
+ [
+ "PIP_RESPECT_VIRTUALENV",
+ "PIP_REQUIRE_VIRTUALENV",
+ "__PYVENV_LAUNCHER__",
+ "UV_SYSTEM_PYTHON",
+ "UV_PYTHON",
+ ]
)
-_SYSTEM = platform.system()
+
+NOX_PBS_PYTHONS = Path.home() / ".local" / "share" / "nox" / "pythons"
+
+
+@functools.cache
+def find_uv() -> tuple[bool, str, version.Version]:
+ uv_name = os.environ.get("UV", None)
+ uv_on_path = shutil.which(uv_name or "uv")
+
+ # Look for uv in Nox's environment, to handle `pipx install nox[uv]`.
+ if uv_name is None:
+ with contextlib.suppress(ImportError, FileNotFoundError):
+ from uv import find_uv_bin # noqa: PLC0415
+
+ uv_bin = find_uv_bin()
+
+ uv_vers = uv_version(uv_bin)
+ if uv_vers > version.Version("0"):
+ # If the returned value is the same as calling "uv" already, don't
+ # expand (simpler logging)
+ if uv_on_path and Path(uv_bin).samefile(uv_on_path):
+ return True, "uv", uv_vers
+
+ return True, uv_bin, uv_vers
+
+ # Fall back to PATH.
+ uv_vers = uv_version(uv_name or "uv")
+ return (
+ uv_on_path is not None and uv_vers > version.Version("0"),
+ uv_name or "uv",
+ uv_vers,
+ )
+
+
+@functools.cache
+def _find_python(interpreter: str, xy_ver: str) -> str | None:
+ """Find a python executable matching the requested interpreter.
+
+ Cached: discovery is pure PATH/launcher lookup, and on Windows a miss
+ spawns ``py -X.Y`` subprocesses, which would otherwise repeat for every
+ session using the same interpreter.
+ """
+ if shutil.which(interpreter):
+ return interpreter
+
+ # Windows only search for the executable
+ if _PLATFORM.startswith("win"):
+ # Allow versions of the form ``X.Y-32`` for Windows.
+ match = re.match(r"^\d\.\d+-32$", interpreter)
+ if match:
+ # preserve the "-32" suffix, as the Python launcher expects it.
+ xy_ver = interpreter
+
+ path_from_launcher = locate_via_py(xy_ver)
+ if path_from_launcher:
+ return path_from_launcher
+
+ path_from_version_param = locate_using_path_and_version(xy_ver)
+ if path_from_version_param:
+ return path_from_version_param
+
+ # not found case
+ return None
+
+
+def uv_version(uv_bin: str) -> version.Version:
+ """Returns uv's version defaulting to 0.0 if uv is not available"""
+ try:
+ ret = subprocess.run(
+ [uv_bin, "self", "version", "--output-format", "json"],
+ check=False,
+ text=True,
+ capture_output=True,
+ encoding="utf-8",
+ )
+ except (FileNotFoundError, PermissionError):
+ logger.info("uv binary not found.")
+ return version.Version("0.0")
+
+ if ret.returncode == 2:
+ # uv < 0.7
+ ret = subprocess.run(
+ [uv_bin, "version", "--output-format", "json"],
+ check=False,
+ text=True,
+ capture_output=True,
+ encoding="utf-8",
+ )
+
+ if ret.returncode == 0 and ret.stdout:
+ return version.Version(json.loads(ret.stdout).get("version"))
+
+ logger.info("Failed to establish uv's version.")
+ return version.Version("0.0")
+
+
+def uv_install_python(python_version: str) -> bool:
+ """Attempts to install a given python version with uv"""
+ _, uv, _ = _uv_state()
+ ret = subprocess.run(
+ [uv, "python", "install", python_version],
+ check=False,
+ )
+ return ret.returncode == 0
+
+
+def _find_pbs_python(implementation: str, version: str) -> str | None:
+ """Check for an existing pbs-installer installation
+ by default it creates dirs with this format:
+ "pypy@3.8.16", "cpython@3.13.3" """
+ executable = "python.exe" if _PLATFORM.startswith("win") else "bin/python"
+
+ if NOX_PBS_PYTHONS.exists():
+ for path in NOX_PBS_PYTHONS.iterdir():
+ if path.is_dir() and (
+ path.name == f"{implementation}@{version}"
+ or path.name.startswith(f"{implementation}@{version}.")
+ ):
+ python_exe = path / executable
+ if python_exe.exists():
+ return str(python_exe)
+ return None
+
+
+def pbs_install_python(python_version: str) -> str | None:
+ """Attempts to install a given python version with pbs-installer.
+
+ Returns the full path to the installed executable, or None if installation failed.
+ """
+
+ # separate implementation / xyz version
+ match = re.match(
+ r"^(?Ppypy|cpython|python)?[-_\.]?(?P\d(\.\d+)?(\.\d+))?$",
+ python_version,
+ re.IGNORECASE,
+ )
+
+ if not match or match["xyz_ver"] is None:
+ logger.warning(f"{python_version=} is not a valid version to install with pbs")
+ return None
+
+ # A bare version with no implementation prefix means CPython.
+ impl = match["impl"] or "cpython"
+ implementation: Literal["cpython", "pypy"] = (
+ "pypy" if impl.lower() == "pypy" else "cpython"
+ )
+ xyz_ver: str = match["xyz_ver"]
+
+ if python_exe := _find_pbs_python(implementation, xyz_ver):
+ return python_exe
+
+ # Requires the [pbs] extra to make it past here
+ try:
+ import pbs_installer # noqa: PLC0415
+ except ModuleNotFoundError: # pragma: nocover
+ logger.warning(
+ "Nox was installed without the `[pbs]` extra, can't download Python"
+ )
+ return None
+
+ try:
+ pbs_installer.install(
+ xyz_ver,
+ destination=NOX_PBS_PYTHONS,
+ version_dir=True,
+ implementation=implementation,
+ )
+ except Exception as err: # noqa: BLE001
+ logger.warning(f"Failed to install a pbs version for {python_version=}: {err}")
+ return None
+
+ return _find_pbs_python(implementation, xyz_ver)
+
+
+def _uv_state() -> tuple[bool, str, version.Version]:
+ """Read ``(HAS_UV, UV, UV_VERSION)``, respecting monkeypatched values."""
+ mod = sys.modules[__name__]
+ has_uv: bool = mod.HAS_UV
+ uv: str = mod.UV
+ uv_ver: version.Version = mod.UV_VERSION
+ return has_uv, uv, uv_ver
+
+
+def _optional_venvs() -> dict[str, bool]:
+ """Read ``OPTIONAL_VENVS``, respecting monkeypatched values."""
+ optional_venvs: dict[str, bool] = sys.modules[__name__].OPTIONAL_VENVS
+ return optional_venvs
+
+
+def __getattr__(name: str) -> Any:
+ """Compute uv detection lazily; importing this module must not run uv."""
+ if name in {"HAS_UV", "UV", "UV_VERSION"}:
+ has_uv, uv, uv_ver = find_uv()
+ return {"HAS_UV": has_uv, "UV": uv, "UV_VERSION": uv_ver}[name]
+ if name == "OPTIONAL_VENVS":
+ # Any environment in this dict could be missing, and is only available
+ # if the value is True. If an environment is always available, it
+ # should not be in this dict. "virtualenv" is not considered optional
+ # since it's a dependency of nox.
+ return {
+ "conda": shutil.which("conda") is not None,
+ "mamba": shutil.which("mamba") is not None,
+ "micromamba": shutil.which("micromamba") is not None,
+ "uv": _uv_state()[0],
+ }
+ msg = f"module {__name__!r} has no attribute {name!r}"
+ raise AttributeError(msg)
+
+
+def _ensure_gitignore(envdir: Path) -> None:
+ """Ensure the shared environment directory has a broad gitignore."""
+ envdir.mkdir(parents=True, exist_ok=True)
+
+ gitignore = envdir.joinpath(".gitignore")
+ if gitignore.exists():
+ return
+
+ try:
+ gitignore.write_text("*\n", encoding="utf-8")
+ except OSError: # pragma: no cover
+ logger.debug(f"Failed to write {gitignore!s}")
+
+
+def _ensure_cachedir_tag(envdir: Path) -> None:
+ """Ensure the shared environment directory has a CACHEDIR.TAG"""
+ envdir.mkdir(parents=True, exist_ok=True)
+
+ cachedir_tag = envdir.joinpath("CACHEDIR.TAG")
+ if cachedir_tag.exists():
+ return
+
+ try:
+ cachedir_tag.write_text(
+ "Signature: 8a477f597d28d172789f06886806bc55\n", encoding="utf-8"
+ )
+ except OSError: # pragma: no cover
+ logger.debug(f"Failed to write {cachedir_tag!s}")
class InterpreterNotFound(OSError):
def __init__(self, interpreter: str) -> None:
- super().__init__("Python interpreter {} not found".format(interpreter))
+ super().__init__(f"Python interpreter {interpreter} not found")
self.interpreter = interpreter
-class ProcessEnv:
- """A environment with a 'bin' directory and a set of 'env' vars."""
+class ProcessEnv(abc.ABC):
+ """An environment with a 'bin' directory and a set of 'env' vars."""
location: str
# Does this environment provide any process isolation?
is_sandboxed = False
- # Special programs that aren't included in the environment.
- allowed_globals = () # type: _typing.ClassVar[Tuple[Any, ...]]
+ @property
+ def allowed_globals(self) -> tuple[str, ...]:
+ """Special programs that aren't included in the environment."""
+ return ()
- def __init__(self, bin_paths: None = None, env: Mapping[str, str] = None) -> None:
- self._bin_paths = bin_paths
- self.env = os.environ.copy()
+ def __init__(
+ self,
+ bin_paths: Sequence[str] | None = None,
+ env: Mapping[str, str | None] | None = None,
+ ) -> None:
+ self._bin_paths = None if bin_paths is None else list(bin_paths)
self._reused = False
- if env is not None:
- self.env.update(env)
-
- for key in _BLACKLISTED_ENV_VARS:
- self.env.pop(key, None)
-
- if self.bin_paths:
- self.env["PATH"] = os.pathsep.join(
- self.bin_paths + [self.env.get("PATH", "")]
- )
+ # .command's env supports None, meaning don't include value even if in parent
+ self.env = {**dict.fromkeys(_BLACKLISTED_ENV_VARS), **(env or {})}
@property
- def bin_paths(self) -> Optional[List[str]]:
+ def bin_paths(self) -> list[str] | None:
return self._bin_paths
@property
@@ -77,19 +375,54 @@ def bin(self) -> str:
"""The first bin directory for the virtualenv."""
paths = self.bin_paths
if paths is None:
- raise ValueError("The environment does not have a bin directory.")
+ msg = "The environment does not have a bin directory."
+ raise ValueError(msg)
return paths[0]
+ @abc.abstractmethod
def create(self) -> bool:
- raise NotImplementedError("ProcessEnv.create should be overwritten in subclass")
+ """Create a new environment.
+
+ Returns True if the environment is new, and False if it was reused.
+ """
+
+ @property
+ @abc.abstractmethod
+ def venv_backend(self) -> str:
+ """
+ Returns the string used to select this environment.
+ """
+
+ def _get_env(
+ self,
+ /,
+ env: Mapping[str, str | None],
+ *,
+ include_outer_env: bool = True,
+ ) -> dict[str, str | None]:
+ """
+ Get the computed environment, with bin paths added. You can request
+ the outer environment be excluded. The initial env can be empty.
+ """
+
+ computed_env = {**self.env, **env}
+ if include_outer_env:
+ computed_env = {**os.environ, **computed_env}
+ if self.bin_paths:
+ path_parts = [*self.bin_paths]
+ prior_path = computed_env.get("PATH")
+ if prior_path:
+ path_parts.append(prior_path)
+ computed_env["PATH"] = os.pathsep.join(path_parts)
+ return computed_env
-def locate_via_py(version: str) -> Optional[str]:
+def locate_via_py(version: str) -> str | None:
"""Find the Python executable using the Windows Launcher.
This is based on :pep:397 which details that executing
- ``py.exe -{version}`` should execute python with the requested
- version. We then make the python process print out its full
+ ``py.exe -{version}`` should execute Python with the requested
+ version. We then make the Python process print out its full
executable path which we use as the location for the version-
specific Python interpreter.
@@ -103,16 +436,21 @@ def locate_via_py(version: str) -> Optional[str]:
if it is found.
"""
script = "import sys; print(sys.executable)"
- py_exe = py.path.local.sysfind("py")
+ py_exe = shutil.which("py")
if py_exe is not None:
- try:
- return py_exe.sysexec("-" + version, "-c", script).strip()
- except py.process.cmdexec.Error:
- return None
+ ret = subprocess.run(
+ [py_exe, f"-{version}", "-c", script],
+ check=False,
+ text=True,
+ capture_output=True,
+ encoding="utf-8",
+ )
+ if ret.returncode == 0 and ret.stdout:
+ return ret.stdout.strip()
return None
-def locate_using_path_and_version(version: str) -> Optional[str]:
+def locate_using_path_and_version(version: str) -> str | None:
"""Check the PATH's python interpreter and return it if the version
matches.
@@ -132,31 +470,47 @@ def locate_using_path_and_version(version: str) -> Optional[str]:
return None
script = "import platform; print(platform.python_version())"
- path_python = py.path.local.sysfind("python")
+ path_python = shutil.which("python")
if path_python:
- try:
- prefix = "{}.".format(version)
- version_string = path_python.sysexec("-c", script).strip()
- if version_string.startswith(prefix):
- return str(path_python)
- except py.process.cmdexec.Error:
- return None
+ ret = subprocess.run(
+ [path_python, "-c", script],
+ check=False,
+ text=True,
+ capture_output=True,
+ )
+ if (
+ ret.returncode == 0
+ and ret.stdout
+ and ret.stdout.strip().startswith(version)
+ ):
+ return path_python
return None
class PassthroughEnv(ProcessEnv):
- """Represents the environment used to run nox itself
+ """Represents the environment used to run Nox itself
For now, this class is empty but it might contain tools to grasp some
hints about the actual env.
"""
+ conda_cmd = "conda"
+
@staticmethod
def is_offline() -> bool:
"""As of now this is only used in conda_install"""
return CondaEnv.is_offline() # pragma: no cover
+ def create(self) -> bool:
+ """Does nothing, since this is an existing environment. Always returns
+ False since it's always reused."""
+ return False
+
+ @property
+ def venv_backend(self) -> str:
+ return "none"
+
class CondaEnv(ProcessEnv):
"""Conda environment management class.
@@ -176,78 +530,106 @@ class CondaEnv(ProcessEnv):
If not specified, this will use the currently running Python.
reuse_existing (Optional[bool]): Flag indicating if the conda environment
should be reused if it already exists at ``location``.
+ conda_cmd (str): The name of the command, can be "conda" (default) or "mamba".
"""
is_sandboxed = True
- allowed_globals = ("conda",)
+
+ @property
+ def allowed_globals(self) -> tuple[str, ...]:
+ """Conda-family launchers are allowed, even if not in the environment."""
+ return ("conda", "mamba", "micromamba")
def __init__(
self,
location: str,
- interpreter: Optional[str] = None,
+ interpreter: str | None = None,
+ *,
reuse_existing: bool = False,
- venv_params: Any = None,
- ):
+ venv_params: Sequence[str] = (),
+ conda_cmd: str = "conda",
+ **kwargs: Any,
+ ) -> None:
self.location_name = location
self.location = os.path.abspath(location)
self.interpreter = interpreter
self.reuse_existing = reuse_existing
- self.venv_params = venv_params if venv_params else []
- super(CondaEnv, self).__init__()
+ self.venv_params = list(venv_params)
+ self.conda_cmd = conda_cmd
+ super().__init__(env={"CONDA_PREFIX": self.location, "VIRTUAL_ENV": None})
def _clean_location(self) -> bool:
"""Deletes existing conda environment"""
+ is_conda = os.path.isdir(os.path.join(self.location, "conda-meta"))
if os.path.exists(self.location):
- if self.reuse_existing:
+ if self.reuse_existing and is_conda:
return False
+ if not is_conda:
+ shutil.rmtree(self.location, ignore_errors=True)
else:
- cmd = ["conda", "remove", "--yes", "--prefix", self.location, "--all"]
+ cmd = [
+ self.conda_cmd,
+ "remove",
+ "--yes",
+ "--prefix",
+ self.location,
+ "--all",
+ ]
nox.command.run(cmd, silent=True, log=False)
- # Make sure that location is clean
- try:
- shutil.rmtree(self.location)
- except FileNotFoundError:
- pass
+ # Make sure that location is clean
+ shutil.rmtree(self.location, ignore_errors=True)
return True
@property
- def bin_paths(self) -> List[str]:
+ def bin_paths(self) -> list[str]:
"""Returns the location of the conda env's bin folder."""
- # see https://docs.anaconda.com/anaconda/user-guide/tasks/integration/python-path/#examples
- if _SYSTEM == "Windows":
- return [self.location, os.path.join(self.location, "Scripts")]
- else:
- return [os.path.join(self.location, "bin")]
+ # see https://github.com/conda/conda/blob/f60f0f1643af04ed9a51da3dd4fa242de81e32f4/conda/activate.py#L563-L572
+ if _PLATFORM.startswith("win"):
+ return [
+ self.location,
+ os.path.join(self.location, "Library", "mingw-w64", "bin"),
+ os.path.join(self.location, "Library", "usr", "bin"),
+ os.path.join(self.location, "Library", "bin"),
+ os.path.join(self.location, "Scripts"),
+ os.path.join(self.location, "bin"),
+ ]
+
+ return [os.path.join(self.location, "bin")]
def create(self) -> bool:
"""Create the conda env."""
+ nox_dir = Path(self.location).parent
+ _ensure_gitignore(nox_dir)
+ _ensure_cachedir_tag(nox_dir)
+
if not self._clean_location():
- logger.debug(
- "Re-using existing conda env at {}.".format(self.location_name)
- )
+ logger.debug(f"Reusing existing conda env at {self.location_name}.")
self._reused = True
return False
- cmd = ["conda", "create", "--yes", "--prefix", self.location]
+ cmd = [self.conda_cmd, "create", "--yes", "--prefix", self.location]
+ if self.conda_cmd == "micromamba" and not any(
+ v.startswith(("--channel=", "-c")) or v == "--channel"
+ for v in self.venv_params
+ ):
+ # Micromamba doesn't have any default channels
+ cmd.append("--channel=conda-forge")
cmd.extend(self.venv_params)
# Ensure the pip package is installed.
cmd.append("pip")
- if self.interpreter:
- python_dep = "python={}".format(self.interpreter)
- else:
- python_dep = "python"
+ python_dep = f"python={self.interpreter}" if self.interpreter else "python"
cmd.append(python_dep)
logger.info(
- "Creating conda env in {} with {}".format(self.location_name, python_dep)
+ f"Creating {self.conda_cmd} env in {self.location_name} with {python_dep}"
)
- nox.command.run(cmd, silent=True, log=False)
+ nox.command.run(cmd, silent=True, log=nox.options.verbose or False)
return True
@@ -263,10 +645,14 @@ def is_offline() -> bool:
"""
try:
# DNS resolution to detect situation (1) or (2).
- host = gethostbyname("repo.anaconda.com")
- return host is None
- except: # pragma: no cover # noqa E722
+ gethostbyname("repo.anaconda.com")
+ except OSError: # pragma: no cover
return True
+ return False
+
+ @property
+ def venv_backend(self) -> str:
+ return self.conda_cmd
class VirtualEnv(ProcessEnv):
@@ -282,6 +668,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.
@@ -291,23 +678,42 @@ class VirtualEnv(ProcessEnv):
is_sandboxed = True
+ @property
+ def allowed_globals(self) -> tuple[str, ...]:
+ """uv/uvx are allowed, even if not in the environment."""
+ _, uv, _ = _uv_state()
+ return (uv, f"{uv}x")
+
def __init__(
self,
location: str,
- interpreter: Optional[str] = None,
- reuse_existing: bool = False,
+ interpreter: str | None = None,
*,
- venv: bool = False,
- venv_params: Any = None,
- ):
+ download_python: Literal["auto", "never", "always"] = "auto",
+ reuse_existing: bool = False,
+ venv_backend: str = "virtualenv",
+ venv_params: Sequence[str] = (),
+ ) -> None:
+ # "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
- self._resolved = None # type: Union[None, str, InterpreterNotFound]
+ self._resolved: None | str | InterpreterNotFound = None
self.reuse_existing = reuse_existing
- self.venv_or_virtualenv = "venv" if venv else "virtualenv"
- self.venv_params = venv_params if venv_params else []
- super(VirtualEnv, self).__init__(env={"VIRTUAL_ENV": self.location})
+ self._venv_backend = venv_backend
+ self.venv_params = list(venv_params)
+ self.download_python = download_python
+ if venv_backend not in {"virtualenv", "venv", "uv"}:
+ msg = f"venv_backend {venv_backend!r} not recognized"
+ raise ValueError(msg)
+ env = {"VIRTUAL_ENV": self.location, "CONDA_PREFIX": None}
+ if self._venv_backend == "uv":
+ env["UV_PROJECT_ENVIRONMENT"] = self.location
+ env["UV_PYTHON"] = self.location
+ super().__init__(env=env)
def _clean_location(self) -> bool:
"""Deletes any existing virtual environment"""
@@ -315,38 +721,103 @@ def _clean_location(self) -> bool:
if (
self.reuse_existing
and self._check_reused_environment_type()
+ and self._check_reused_environment_links()
and self._check_reused_environment_interpreter()
):
return False
- else:
- shutil.rmtree(self.location)
+ # uv clears it for us, and it balks at files left around
+ if self.venv_backend != "uv":
+ shutil.rmtree(self.location, ignore_errors=True)
+ return True
+
+ def _check_reused_environment_links(self) -> bool:
+ """Check that interpreter links in the reused environment are not broken."""
+ bin_dir = self.bin
+ names = ["python.exe"] if self._windows_layout else ["python", "python3"]
+
+ for name in names:
+ path = os.path.join(bin_dir, name)
+ # ``lexists`` catches broken symlinks, ``exists`` verifies the target.
+ if os.path.lexists(path) and not os.path.exists(path):
+ return False
return True
- def _check_reused_environment_type(self) -> bool:
- """Check if reused environment type is the same."""
+ def _read_pyvenv_cfg(self) -> dict[str, str] | None:
+ """Read a pyvenv.cfg file into dict, returns None if missing."""
path = os.path.join(self.location, "pyvenv.cfg")
- if not os.path.isfile(path):
- # virtualenv < 20.0 does not create pyvenv.cfg
+ with contextlib.suppress(FileNotFoundError), open(path, encoding="utf-8") as fp:
+ parts = (x.partition("=") for x in fp if "=" in x)
+ return {k.strip(): v.strip() for k, _, v in parts}
+ return None
+
+ def _check_reused_environment_type(self) -> bool:
+ """Check if reused environment type is the same or equivalent."""
+
+ config = self._read_pyvenv_cfg()
+ # virtualenv < 20.0 does not create pyvenv.cfg
+ if config is None:
+ old_env = "virtualenv"
+ elif "uv" in config or "gourgeist" in config:
+ old_env = "uv"
+ elif "virtualenv" in config:
old_env = "virtualenv"
else:
- pattern = re.compile("virtualenv[ \t]*=")
- with open(path) as fp:
- old_env = (
- "virtualenv" if any(pattern.match(line) for line in fp) else "venv"
- )
- return old_env == self.venv_or_virtualenv
+ old_env = "venv"
+
+ # Can't detect mamba separately, but shouldn't matter
+ if os.path.isdir(os.path.join(self.location, "conda-meta")):
+ return False
+
+ # Matching is always true
+ if old_env == self.venv_backend:
+ return True
+
+ # venv family with pip installed
+ if {old_env, self.venv_backend} <= {"virtualenv", "venv"}:
+ return True
+
+ # Switching to "uv" is safe, but not the other direction (no pip)
+ if old_env in {"virtualenv", "venv"} and self.venv_backend == "uv": # noqa: SIM103
+ return True
+
+ return False
def _check_reused_environment_interpreter(self) -> bool:
- """Check if reused environment interpreter is the same."""
- program = "import sys; print(getattr(sys, 'real_prefix', sys.base_prefix))"
- original = nox.command.run(
- [self._resolved_interpreter, "-c", program], silent=True, log=False
+ """
+ Check if reused environment interpreter is the same. Currently only checks if
+ NOX_ENABLE_STALENESS_CHECK is set in the environment. See
+
+ * https://github.com/wntrblm/nox/issues/449#issuecomment-860030890
+ * https://github.com/wntrblm/nox/issues/441
+ * https://github.com/pypa/virtualenv/issues/2130
+ """
+ if not os.environ.get("NOX_ENABLE_STALENESS_CHECK", ""):
+ return True
+
+ config = self._read_pyvenv_cfg() or {}
+ original = config.get("base-prefix", None)
+
+ program = (
+ "import sys; sys.stdout.write(getattr(sys, 'real_prefix', sys.base_prefix))"
)
+
+ if original is None:
+ output = nox.command.run(
+ [self._resolved_interpreter, "-c", program], silent=True, log=False
+ )
+ assert isinstance(output, str)
+ original = output
+
created = nox.command.run(
["python", "-c", program], silent=True, log=False, paths=self.bin_paths
)
- return original == created
+
+ return (
+ os.path.exists(original)
+ and os.path.exists(created)
+ and os.path.samefile(original, created)
+ )
@property
def _resolved_interpreter(self) -> str:
@@ -374,80 +845,174 @@ def _resolved_interpreter(self) -> str:
# If this is just a X, X.Y, or X.Y.Z string, extract just the X / X.Y
# part and add Python to the front of it.
- match = re.match(r"^(?P\d(\.\d+)?)(\.\d+)?$", self.interpreter)
+ match = re.match(r"^(?P\d(\.\d+)?)(\.\d+)?(?Pt?)$", self.interpreter)
if match:
xy_version = match.group("xy_ver")
- cleaned_interpreter = "python{}".format(xy_version)
-
- # If the cleaned interpreter is on the PATH, go ahead and return it.
- if py.path.local.sysfind(cleaned_interpreter):
- self._resolved = cleaned_interpreter
- return self._resolved
+ t = match.group("t")
+ cleaned_interpreter = f"python{xy_version}{t}"
+
+ match self.download_python:
+ # never -> check for interpreters
+ case "never":
+ if resolved := _find_python(cleaned_interpreter, xy_version):
+ self._resolved = resolved
+ return self._resolved
+
+ # always -> skip check, always install
+ case "always":
+ if resolved := self._install_python(cleaned_interpreter):
+ self._resolved = resolved
+ return self._resolved
+
+ case _:
+ # auto -> check interpreters -> fallback to installing
+ if resolved := _find_python(
+ cleaned_interpreter, xy_version
+ ) or self._install_python(cleaned_interpreter):
+ self._resolved = resolved
+ return self._resolved
- # The rest of this is only applicable to Windows, so if we don't have
- # an interpreter by now, raise.
- if _SYSTEM != "Windows":
- self._resolved = InterpreterNotFound(self.interpreter)
- raise self._resolved
+ self._resolved = InterpreterNotFound(self.interpreter)
+ raise self._resolved
- # Allow versions of the form ``X.Y-32`` for Windows.
- match = re.match(r"^\d\.\d+-32?$", cleaned_interpreter)
- if match:
- # preserve the "-32" suffix, as the Python launcher expects
- # it.
- xy_version = cleaned_interpreter
+ def _install_python(self, cleaned_interpreter: str) -> str | None:
+ """Install the requested interpreter for this backend, if possible.
- path_from_launcher = locate_via_py(xy_version)
- if path_from_launcher:
- self._resolved = path_from_launcher
- return self._resolved
+ Returns the resolved interpreter on success, or ``None`` on failure.
+ """
+ if self.venv_backend == "uv":
+ has_uv, _, uv_ver = _uv_state()
+ if (
+ has_uv
+ and version.Version("0.4.16") <= uv_ver
+ and uv_install_python(cleaned_interpreter)
+ ):
+ return cleaned_interpreter
+ return None
+ return pbs_install_python(cleaned_interpreter)
- path_from_version_param = locate_using_path_and_version(xy_version)
- if path_from_version_param:
- self._resolved = path_from_version_param
- return self._resolved
+ @property
+ def _windows_layout(self) -> bool:
+ """Whether the venv uses a Windows-style layout (``Scripts``/``python.exe``).
- # If we got this far, then we were unable to resolve the interpreter
- # to an actual executable; raise an exception.
- self._resolved = InterpreterNotFound(self.interpreter)
- raise self._resolved
+ uv always builds a Windows-style venv, even under MSYS2/MinGW, where the
+ native interpreter and virtualenv/venv use a POSIX layout (``bin``/``python``).
+ """
+ if not _PLATFORM.startswith("win"):
+ return False
+ return not _IS_MINGW or self.venv_backend == "uv"
@property
- def bin_paths(self) -> List[str]:
+ def bin_paths(self) -> list[str]:
"""Returns the location of the virtualenv's bin folder."""
- if _SYSTEM == "Windows":
+ if self._windows_layout:
return [os.path.join(self.location, "Scripts")]
- else:
- return [os.path.join(self.location, "bin")]
+ return [os.path.join(self.location, "bin")]
def create(self) -> bool:
"""Create the virtualenv or venv."""
+ nox_dir = Path(self.location).parent
+ _ensure_gitignore(nox_dir)
+ _ensure_cachedir_tag(nox_dir)
+
if not self._clean_location():
logger.debug(
- "Re-using existing virtual environment at {}.".format(
- self.location_name
- )
+ f"Reusing existing virtual environment at {self.location_name}."
)
self._reused = True
return False
- if self.venv_or_virtualenv == "virtualenv":
- cmd = [sys.executable, "-m", "virtualenv", self.location]
- if self.interpreter:
- cmd.extend(["-p", self._resolved_interpreter])
- else:
- cmd = [self._resolved_interpreter, "-m", "venv", self.location]
+ match self.venv_backend:
+ case "virtualenv":
+ cmd = [
+ sys.executable,
+ "-m",
+ "virtualenv",
+ self.location,
+ "--no-periodic-update",
+ ]
+ if self.interpreter:
+ cmd.extend(["-p", self._resolved_interpreter])
+ case "uv":
+ _, uv, uv_ver = _uv_state()
+ cmd = [uv, "venv"]
+ if self.interpreter:
+ cmd += ["-p", self._resolved_interpreter]
+ elif not _IS_MINGW:
+ # uv can't inspect the MSYS2/MinGW interpreter, so let it
+ # pick the default rather than passing sys.executable.
+ cmd += ["-p", sys.executable]
+ cmd += [self.location]
+ if version.Version("0.8") <= uv_ver:
+ cmd += ["--clear"]
+ case _:
+ cmd = [self._resolved_interpreter, "-m", "venv", self.location]
cmd.extend(self.venv_params)
+ resolved_interpreter_name = os.path.basename(self._resolved_interpreter)
+
logger.info(
- "Creating virtual environment ({}) using {} in {}".format(
- self.venv_or_virtualenv,
- os.path.basename(self._resolved_interpreter),
- self.location_name,
- )
+ f"Creating virtual environment ({self.venv_backend}) using"
+ f" {resolved_interpreter_name} in {self.location_name}"
)
- nox.command.run(cmd, silent=True, log=False)
+ nox.command.run(cmd, silent=True, log=nox.options.verbose or False)
return True
+
+ @property
+ def venv_backend(self) -> str:
+ return self._venv_backend
+
+
+ALL_VENVS: dict[str, Callable[..., ProcessEnv]] = {
+ "conda": functools.partial(CondaEnv, conda_cmd="conda"),
+ "mamba": functools.partial(CondaEnv, conda_cmd="mamba"),
+ "micromamba": functools.partial(CondaEnv, conda_cmd="micromamba"),
+ "virtualenv": functools.partial(VirtualEnv, venv_backend="virtualenv"),
+ "venv": functools.partial(VirtualEnv, venv_backend="venv"),
+ "uv": functools.partial(VirtualEnv, venv_backend="uv"),
+ "none": PassthroughEnv,
+}
+
+
+def get_virtualenv(
+ *backends: str,
+ download_python: Literal["auto", "never", "always"],
+ envdir: str,
+ reuse_existing: bool,
+ interpreter: Python = None,
+ venv_params: Sequence[str] = (),
+) -> ProcessEnv:
+ # Support fallback backends
+ for bk in backends:
+ if bk not in ALL_VENVS:
+ msg = f"Expected venv_backend one of {sorted(ALL_VENVS)!r}, but got {bk!r}."
+ raise ValueError(msg)
+
+ optional_venvs = _optional_venvs()
+
+ for bk in backends[:-1]:
+ if bk not in optional_venvs:
+ msg = f"Only optional backends ({sorted(optional_venvs)!r}) may have a fallback, {bk!r} is not optional."
+ raise ValueError(msg)
+
+ for bk in backends:
+ if optional_venvs.get(bk, True):
+ backend = bk
+ break
+ else:
+ msg = f"No backends present, looked for {backends!r}."
+ raise ValueError(msg)
+
+ if backend == "none" or interpreter is False:
+ return ALL_VENVS["none"]()
+
+ return ALL_VENVS[backend](
+ envdir,
+ download_python=download_python,
+ interpreter=interpreter,
+ reuse_existing=reuse_existing,
+ venv_params=venv_params,
+ )
diff --git a/nox/workflow.py b/nox/workflow.py
index 598da206..e812fe68 100644
--- a/nox/workflow.py
+++ b/nox/workflow.py
@@ -12,8 +12,19 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import argparse
-from typing import Any, Callable, Iterable, List
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ import argparse
+ from collections.abc import Callable, Iterable
+
+__all__ = ["execute"]
+
+
+def __dir__() -> list[str]:
+ return __all__
def execute(
@@ -40,20 +51,21 @@ def execute(
"""
try:
# Iterate over each task and run it.
- return_value = None
+ return_value: Any = None
for function_ in workflow:
# Send the previous task's return value if there was one.
- args = [] # type: List[Any]
+ args: list[Any] = []
if return_value is not None:
args.append(return_value)
- return_value = function_(*args, global_config=global_config)
+ kwargs: dict[str, Any] = {"global_config": global_config}
+ return_value = function_(*args, **kwargs)
- # If we got a integer value as a result, abort task processing
+ # If we got an integer value as a result, abort task processing
# and return it.
if isinstance(return_value, int):
return return_value
-
- # All tasks completed, presumably without error.
- return 0
except KeyboardInterrupt:
return 130 # http://tldp.org/LDP/abs/html/exitcodes.html
+
+ # All tasks completed, presumably without error.
+ return 0
diff --git a/noxfile.py b/noxfile.py
old mode 100644
new mode 100755
index 9e247bb2..bf951d92
--- a/noxfile.py
+++ b/noxfile.py
@@ -1,3 +1,5 @@
+#!/usr/bin/env -S uv run --script
+
# Copyright 2016 Alethea Katherine Flowers
#
# Licensed under the Apache License, Version 2.0 (the "License");
@@ -12,105 +14,160 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+# /// script
+# dependencies = ["nox>=2025.02.09"]
+# ///
+
+
+from __future__ import annotations
+
import functools
+import glob
import os
import platform
+import shutil
+import sys
+import sysconfig
import nox
-ON_WINDOWS_CI = "CI" in os.environ and platform.system() == "Windows"
+ON_WINDOWS_CI = "CI" in os.environ and sys.platform.startswith("win32")
+# uv has no MinGW wheel and won't build there; its tests skip without it.
+IS_MINGW = sysconfig.get_platform().startswith("mingw")
+nox.needs_version = ">=2025.02.09"
+nox.options.default_venv_backend = "uv|virtualenv"
-def is_python_version(session, version):
- if not version.startswith(session.python):
- return False
- py_version = session.run("python", "-V", silent=True)
- py_version = py_version.partition(" ")[2].strip()
- return py_version.startswith(version)
+PYPROJECT = nox.project.load_toml("pyproject.toml")
+ALL_PYTHONS = nox.project.python_versions(PYPROJECT)
-@nox.session(python=["3.6", "3.7", "3.8", "3.9"])
-def tests(session):
+@nox.session(python=ALL_PYTHONS)
+def tests(session: nox.Session) -> None:
"""Run test suite with pytest."""
- session.create_tmp()
- session.install("-r", "requirements-test.txt")
- session.install("-e", ".[tox_to_nox]")
- tests = session.posargs or ["tests/"]
- if is_python_version(session, "3.6.0"):
- session.run("pytest", *tests)
- return
+
+ coverage_file = (
+ f".coverage.pypi.{sys.platform}.{platform.machine()}.{session.python}"
+ )
+ env = {
+ "PYTHONWARNDEFAULTENCODING": "1",
+ "COVERAGE_FILE": coverage_file,
+ }
+ parallel = [] if sys.platform.startswith("win32") else ["--numprocesses=auto"]
+
+ session.create_tmp() # Fixes permission errors on Windows
+ uv = [] if IS_MINGW else ["uv"]
+ session.install(*PYPROJECT["dependency-groups"]["test"], *uv)
+ session.install("-e.[tox-to-nox,pbs]")
+ session.run("coverage", "erase", env=env)
session.run(
+ "coverage",
+ "run",
+ "-m",
"pytest",
- "--cov=nox",
- "--cov-config",
- ".coveragerc",
- "--cov-report=",
- *tests,
- env={"COVERAGE_FILE": ".coverage.{}".format(session.python)}
+ *parallel,
+ "-m",
+ "not conda",
+ *session.posargs,
+ env=env,
)
- session.notify("cover")
+ session.run("coverage", "combine", env=env)
+ session.run("coverage", "report", env=env)
-@nox.session(python=["3.6", "3.7", "3.8", "3.9"], venv_backend="conda")
-def conda_tests(session):
- """Run test suite with pytest."""
+@nox.session(venv_backend="uv", default=False)
+def minimums(session: nox.Session) -> None:
+ """Run test suite with the lowest supported versions of everything. Requires uv."""
session.create_tmp()
+
+ session.install("-e.", "--group=test", "--resolution=lowest-direct")
+ session.run("uv", "pip", "list")
+ session.run("pytest", "-m", "not conda", *session.posargs)
+
+
+def xonda_tests(session: nox.Session, xonda: str) -> None:
+ """Run test suite set up with conda/mamba/etc."""
+
+ coverage_file = (
+ f".coverage.{xonda}.{sys.platform}.{platform.machine()}.{session.python}"
+ )
+ env = {"COVERAGE_FILE": coverage_file}
+
+ # Windows breaks currently either with or without quoting, so it's omitted there.
+ extra_specs = [] if sys.platform.startswith("win32") else ["requests<99"]
session.conda_install(
- "--file", "requirements-conda-test.txt", "--channel", "conda-forge"
+ "--file", "requirements-conda-test.txt", *extra_specs, channel="conda-forge"
+ )
+ session.install("-e.", "--no-deps")
+
+ session.run("coverage", "erase", env=env)
+ session.run(
+ "coverage",
+ "run",
+ "-m",
+ "pytest",
+ "-m",
+ "conda",
+ *session.posargs,
+ env=env,
)
- session.install("-e", ".", "--no-deps")
- tests = session.posargs or ["tests/"]
- session.run("pytest", *tests)
+ session.run("coverage", "combine", env=env)
+ session.run("coverage", "report", env=env)
+
+
+@nox.session(venv_backend="conda", default=bool(shutil.which("conda")))
+def conda_tests(session: nox.Session) -> None:
+ """Run test suite set up with conda."""
+ xonda_tests(session, "conda")
+
+
+@nox.session(venv_backend="mamba", default=shutil.which("mamba"))
+def mamba_tests(session: nox.Session) -> None:
+ """Run test suite set up with mamba."""
+ xonda_tests(session, "mamba")
-@nox.session
-def cover(session):
+@nox.session(venv_backend="micromamba", default=shutil.which("micromamba"))
+def micromamba_tests(session: nox.Session) -> None:
+ """Run test suite set up with micromamba."""
+ xonda_tests(session, "micromamba")
+
+
+@nox.session(default=False)
+def cover(session: nox.Session) -> None:
"""Coverage analysis."""
if ON_WINDOWS_CI:
return
- session.install("coverage")
- session.run("coverage", "combine")
+ session.install("coverage[toml]>=7.3")
+ paths = sorted(glob.glob("coverage-*")) or ["."]
+ session.run("coverage", "combine", *paths)
session.run("coverage", "report", "--fail-under=100", "--show-missing")
session.run("coverage", "erase")
-@nox.session(python="3.8")
-def blacken(session):
- """Run black code formatter."""
- session.install("black==21.5b2", "isort==5.8.0")
- files = ["nox", "tests", "noxfile.py", "setup.py"]
- session.run("black", *files)
- session.run("isort", *files)
-
-
-@nox.session(python="3.8")
-def lint(session):
- session.install("flake8==3.9.2", "black==21.5b2", "isort==5.8.0", "mypy==0.812")
+@nox.session(python="3.12")
+def lint(session: nox.Session) -> None:
+ """Run the linters."""
+ session.install("prek")
session.run(
- "mypy",
- "--config-file=",
- "--disallow-untyped-defs",
- "--warn-unused-ignores",
- "--ignore-missing-imports",
- "nox",
+ "prek",
+ "run",
+ "--all-files",
+ *session.posargs,
)
- files = ["nox", "tests", "noxfile.py", "setup.py"]
- session.run("black", "--check", *files)
- session.run("isort", "--check", *files)
- session.run("flake8", *files)
-@nox.session(python="3.7")
-def docs(session):
+@nox.session(default=False)
+def docs(session: nox.Session) -> None:
"""Build the documentation."""
output_dir = os.path.join(session.create_tmp(), "output")
doctrees, html = map(
functools.partial(os.path.join, output_dir), ["doctrees", "html"]
)
- session.run("rm", "-rf", output_dir, external=True)
- session.install("-r", "requirements-test.txt")
- session.install(".")
+ shutil.rmtree(output_dir, ignore_errors=True)
+ session.install(*PYPROJECT["dependency-groups"]["docs"])
+ session.install("-e.")
session.cd("docs")
sphinx_args = ["-b", "html", "-W", "-d", doctrees, ".", html]
@@ -121,3 +178,60 @@ def docs(session):
sphinx_args.insert(0, "--open-browser")
session.run(sphinx_cmd, *sphinx_args)
+
+
+# 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"):
+ # Drop starting "pypy" and maybe "-"
+ python_version = session.python.lstrip("py-")
+ implementation = "pypy"
+ else:
+ # TODO: check free threaded match
+ python_version = session.python.rstrip("t")
+ implementation = "cpython"
+ session.run(
+ "python",
+ "-c",
+ "import sys; assert '.'.join(str(v) for v in sys.version_info[:2]) =="
+ f" '{python_version}'",
+ )
+ session.run(
+ "python",
+ "-c",
+ f"import sys; assert sys.implementation.name == '{implementation}'",
+ )
+
+
+@nox.session(
+ python=[
+ *ALL_PYTHONS,
+ "pypy-3.11",
+ "3.13t",
+ "3.14t",
+ ],
+ default=False,
+ tags=["gha"],
+)
+def github_actions_default_tests(session: nox.Session) -> None:
+ """Check default versions installed by the nox GHA Action"""
+ assert sys.version_info[:2] == (3, 12)
+ _check_python_version(session)
+
+
+@nox.session(
+ python=[
+ *ALL_PYTHONS,
+ "pypy3.10",
+ "pypy3.11",
+ ],
+ default=False,
+ tags=["gha"],
+)
+def github_actions_all_tests(session: nox.Session) -> None:
+ """Check all versions installed by the nox GHA Action"""
+ _check_python_version(session)
+
+
+if __name__ == "__main__":
+ nox.main()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..18825a11
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,155 @@
+[build-system]
+build-backend = "hatchling.build"
+requires = [
+ "hatchling",
+]
+
+[project]
+name = "nox"
+version = "2026.04.10"
+description = "Flexible test automation."
+readme = "README.md"
+keywords = [
+ "automation",
+ "testing",
+ "tox",
+]
+license = "Apache-2.0"
+authors = [
+ { name = "Alethea Katherine Flowers", email = "me@thea.codes" },
+]
+requires-python = ">=3.10"
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Environment :: Console",
+ "Intended Audience :: Developers",
+ "Operating System :: MacOS",
+ "Operating System :: Microsoft :: Windows",
+ "Operating System :: POSIX",
+ "Operating System :: Unix",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Topic :: Software Development :: Testing",
+]
+dependencies = [
+ "argcomplete>=1.9.4,<4",
+ "attrs>=24.1",
+ "colorlog>=2.6.1,<7",
+ "dependency-groups>=1.1",
+ "humanize>=4",
+ "packaging>=22",
+ "tomli>=1.1; python_version<'3.11'",
+ "virtualenv>=20.15",
+]
+optional-dependencies.pbs = [
+ "pbs-installer[all]>=2025.1.6",
+]
+optional-dependencies.tox-to-nox = [
+ "jinja2",
+ "tox>=4",
+]
+optional-dependencies.uv = [
+ "uv>=0.1.6",
+]
+urls.bug-tracker = "https://github.com/wntrblm/nox/issues"
+urls.documentation = "https://nox.thea.codes"
+urls.homepage = "https://github.com/wntrblm/nox"
+urls.repository = "https://github.com/wntrblm/nox"
+scripts.nox = "nox.__main__:main"
+scripts.tox-to-nox = "nox.tox_to_nox:main"
+
+[dependency-groups]
+dev = [
+ { include-group = "test" },
+]
+test = [
+ "coverage[toml]>=7.10.3",
+ "pytest>=7.4; python_version>='3.12'",
+ "pytest>=7; python_version<'3.12'",
+ "pytest-xdist>=3",
+]
+docs = [
+ "docutils<0.22", # Waiting for sphinx-tabs release
+ "myst-parser",
+ "sphinx>=3",
+ "sphinx-autobuild",
+ "sphinx-tabs",
+ "witchhazel",
+]
+
+[tool.ruff]
+show-fixes = true
+lint.select = [ "ALL" ]
+lint.ignore = [
+ "A002", # Function arguments can shadow builtins
+ "ANN401", # Any allowed (TODO: some maybe can be removed)
+ "C9", # Complexity
+ "COM812", # Trailing commas inform the formatter
+ "D", # Too many docs
+ "E501", # Line too long
+ "FIX", # We have todos
+ "N802", # Function name should be lowercase
+ "N818", # Error suffix for errors
+ "PLR09", # Too many X
+ "PLR2004", # Magic value used in comparison
+ "PTH", # Path usage (TODO)
+ "RSE102", # Parens on exception
+ "S101", # Assert is used by mypy and pytest
+ "S603", # subprocess call - check for execution of untrusted input
+ "SLF001", # Private member access
+ "T20", # We use print
+ "TD", # TODO format
+]
+lint.per-file-ignores.".github/**.py" = [ "INP001" ]
+lint.per-file-ignores."docs/**.py" = [ "ERA001", "INP001" ]
+lint.per-file-ignores."tests/*.py" = [ "FBT001", "INP001" ]
+lint.per-file-ignores."tests/resources/**.py" = [ "ANN", "ARG001", "DTZ001", "ERA001", "TRY002" ]
+lint.typing-modules = [ "nox._typing" ]
+lint.flake8-annotations.allow-star-arg-any = true
+lint.flake8-builtins.ignorelist = [ "copyright" ]
+lint.flake8-unused-arguments.ignore-variadic-names = true
+
+[tool.pyproject-fmt]
+max_supported_python = "3.14"
+
+[tool.mypy]
+mypy_path = [ ".github" ]
+python_version = "3.10"
+warn_unreachable = true
+enable_error_code = [ "ignore-without-code", "redundant-expr", "truthy-bool" ]
+strict = true
+overrides = [ { module = [ "tox.*" ], ignore_missing_imports = true } ]
+
+[tool.pytest]
+ini_options.minversion = "7.0"
+ini_options.testpaths = [ "tests" ]
+ini_options.pythonpath = [ ".github/" ]
+ini_options.addopts = [ "-ra", "--strict-markers", "--strict-config" ]
+ini_options.markers = [
+ "conda: test requires conda",
+]
+ini_options.xfail_strict = true
+ini_options.filterwarnings = [ "error" ]
+ini_options.log_level = "INFO"
+
+[tool.coverage]
+run.branch = true
+run.core = "sysmon"
+run.disable_warnings = [
+ "no-sysmon",
+]
+run.patch = [ "subprocess" ]
+run.relative_files = true
+run.source_pkgs = [ "nox" ]
+report.exclude_also = [
+ "@overload",
+ "def __dir__()",
+ "def __repr__",
+ "if TYPE_CHECKING:",
+]
+report.omit = [ "nox/_typing.py" ]
diff --git a/requirements-conda-test.txt b/requirements-conda-test.txt
index 0fa309a3..93abe5ed 100644
--- a/requirements-conda-test.txt
+++ b/requirements-conda-test.txt
@@ -1,7 +1,14 @@
-argcomplete >=1.9.4,<2.0
-colorlog >=2.6.1,<4.0.0
-py >=1.4.0,<2.0.0
-virtualenv >=14.0.0
+argcomplete >=1.9.4,<3.0
+attrs >=23.1
+colorlog >=2.6.1,<7.0.0
+coverage>=7.10.3
+dependency-groups >=1.1
+httpx
+humanize
jinja2
-tox
-pytest
\ No newline at end of file
+pbs-installer>=2025.1.6
+pytest
+pytest-xdist
+tox>=4.0.0
+virtualenv >=20.14.1
+zstandard
diff --git a/requirements-test.txt b/requirements-test.txt
deleted file mode 100644
index 6a0bc991..00000000
--- a/requirements-test.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-flask
-pytest
-pytest-cov
-sphinx
-sphinx-autobuild
-recommonmark
-witchhazel
diff --git a/setup.py b/setup.py
deleted file mode 100644
index d3db6370..00000000
--- a/setup.py
+++ /dev/null
@@ -1,73 +0,0 @@
-# Copyright 2016 Alethea Katherine Flowers
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-from codecs import open
-
-from setuptools import setup
-
-long_description = open("README.rst", "r", encoding="utf-8").read()
-
-setup(
- name="nox",
- version="2021.6.6",
- description="Flexible test automation.",
- long_description=long_description,
- url="https://nox.thea.codes",
- author="Alethea Katherine Flowers",
- author_email="me@thea.codes",
- license="Apache Software License",
- classifiers=[
- "Development Status :: 5 - Production/Stable",
- "Intended Audience :: Developers",
- "License :: OSI Approved :: Apache Software License",
- "Topic :: Software Development :: Testing",
- "Environment :: Console",
- "Programming Language :: Python",
- "Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.6",
- "Programming Language :: Python :: 3.7",
- "Programming Language :: Python :: 3.8",
- "Programming Language :: Python :: 3.9",
- "Operating System :: POSIX",
- "Operating System :: MacOS",
- "Operating System :: Unix",
- "Operating System :: Microsoft :: Windows",
- ],
- keywords="testing automation tox",
- packages=["nox"],
- package_data={"nox": ["py.typed"]},
- include_package_data=True,
- zip_safe=False,
- install_requires=[
- "argcomplete>=1.9.4,<2.0",
- "colorlog>=2.6.1,<7.0.0",
- "packaging>=20.9",
- "py>=1.4.0,<2.0.0",
- "virtualenv>=14.0.0",
- "importlib_metadata; python_version < '3.8'",
- ],
- extras_require={"tox_to_nox": ["jinja2", "tox"]},
- entry_points={
- "console_scripts": [
- "nox=nox.__main__:main",
- "tox-to-nox=nox.tox_to_nox:main [tox_to_nox]",
- ]
- },
- project_urls={
- "Documentation": "https://nox.thea.codes",
- "Source Code": "https://github.com/theacodes/nox",
- "Bug Tracker": "https://github.com/theacodes/nox/issues",
- },
- python_requires=">=3.6",
-)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..1553a805
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,126 @@
+# Copyright 2023 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+from __future__ import annotations
+
+import re
+import shutil
+import subprocess
+from pathlib import Path
+from string import Template
+from typing import TYPE_CHECKING, Any
+
+import pytest
+
+import nox.virtualenv
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Generator
+
+HAS_CONDA = shutil.which("conda") is not None
+
+
+@pytest.fixture(autouse=True)
+def reset_color_envvars(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Remove color-related envvars to fix test output"""
+ monkeypatch.delenv("FORCE_COLOR", raising=False)
+ monkeypatch.delenv("NO_COLOR", raising=False)
+
+
+@pytest.fixture(autouse=True)
+def clear_cache(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Clear the cache for each test."""
+ monkeypatch.setattr("nox.registry._REGISTRY", {})
+
+
+@pytest.fixture(autouse=True, scope="session")
+def warm_find_uv_cache() -> None:
+ """Warm uv detection once (per worker) before any test can mock anything.
+
+ ``find_uv()`` runs lazily on the first ``HAS_UV``/``UV``/``UV_VERSION``
+ access, so a cold cache inside a test that mocks ``shutil.which`` or
+ ``subprocess.run`` would leak a stray ``which("uv")`` call into the mock.
+ Warming it here keeps uv detection out of mocked windows; tests that
+ exercise the detection logic itself call ``find_uv.__wrapped__`` to
+ bypass this cache.
+ """
+ nox.virtualenv.find_uv()
+
+
+@pytest.fixture(autouse=True)
+def clear_find_python_cache() -> Generator[None, None, None]:
+ """Clear cached interpreter discovery around each test.
+
+ Tests monkeypatch ``shutil.which`` and ``_PLATFORM``, which feed this
+ cache, so it must be cold per test and mock-poisoned entries must not
+ leak into later tests.
+ """
+ nox.virtualenv._find_python.cache_clear()
+ yield
+ nox.virtualenv._find_python.cache_clear()
+
+
+RESOURCES = Path(__file__).parent.joinpath("resources")
+
+
+@pytest.fixture
+def generate_noxfile_options(tmp_path: Path) -> Callable[..., str]:
+ """Generate noxfile.py with test and templated options.
+
+ The options are enabled (if disabled) and the values are applied
+ if a matching format string is encountered with the option name.
+ """
+
+ def generate_noxfile(**option_mapping: str | bool) -> str:
+ path = Path(RESOURCES) / "noxfile_options.py"
+ text = path.read_text(encoding="utf8")
+ if option_mapping:
+ for opt in option_mapping:
+ # "uncomment" options with values provided
+ text = re.sub(rf"(# )?nox.options.{opt}", f"nox.options.{opt}", text)
+ text = Template(text).safe_substitute(**option_mapping)
+ path = tmp_path / "noxfile.py"
+ path.write_text(text, encoding="utf8")
+ return str(path)
+
+ return generate_noxfile
+
+
+# This fixture will be automatically used unless the test has the 'conda' marker
+@pytest.fixture
+def prevent_conda(monkeypatch: pytest.MonkeyPatch) -> None:
+ def blocked_popen(*args: Any, **kwargs: Any) -> Any:
+ cmd = args[0][0] if isinstance(args[0], list) else args[0]
+ msg = "Use of 'conda' command is blocked in tests without @pytest.mark.conda"
+ if "conda" in cmd or "mamba" in cmd:
+ raise RuntimeError(msg)
+ return original_popen(*args, **kwargs)
+
+ original_popen = subprocess.Popen
+ monkeypatch.setattr(subprocess, "Popen", blocked_popen)
+
+
+def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
+ for item in items:
+ if "make_conda" in getattr(item, "fixturenames", ()):
+ item.add_marker("conda")
+ if "conda" in item.keywords:
+ item.add_marker(
+ pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.")
+ )
+
+
+# Protection to make sure every conda-using test requests it
+def pytest_runtest_setup(item: pytest.Item) -> None:
+ if not any(mark.name == "conda" for mark in item.iter_markers()):
+ item.add_marker(pytest.mark.usefixtures("prevent_conda"))
diff --git a/tests/resources/noxfile.py b/tests/resources/noxfile.py
index af183c1a..0bec9605 100644
--- a/tests/resources/noxfile.py
+++ b/tests/resources/noxfile.py
@@ -12,4 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
SIGIL = "123"
diff --git a/tests/resources/noxfile_duplicate_sessions.py b/tests/resources/noxfile_duplicate_sessions.py
new file mode 100644
index 00000000..ec147f24
--- /dev/null
+++ b/tests/resources/noxfile_duplicate_sessions.py
@@ -0,0 +1,11 @@
+import nox
+
+
+@nox.session(name="foo")
+def a(session: nox.Session):
+ print("a!")
+
+
+@nox.session(name="foo")
+def b(session: nox.Session):
+ print("b!")
diff --git a/tests/resources/noxfile_empty_parametrize.py b/tests/resources/noxfile_empty_parametrize.py
new file mode 100644
index 00000000..b99e4f5d
--- /dev/null
+++ b/tests/resources/noxfile_empty_parametrize.py
@@ -0,0 +1,28 @@
+# Copyright 2026 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import nox
+
+
+@nox.session(venv_backend="none")
+def regular(session):
+ print(session.name)
+
+
+@nox.session(venv_backend="none")
+@nox.parametrize("param", [])
+def no_params(session, param):
+ print(session.name)
diff --git a/tests/resources/noxfile_multiple_sessions.py b/tests/resources/noxfile_multiple_sessions.py
new file mode 100644
index 00000000..1e4bd0fb
--- /dev/null
+++ b/tests/resources/noxfile_multiple_sessions.py
@@ -0,0 +1,35 @@
+# Copyright 2018 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import nox
+
+# Deliberately giving these silly names so we know this is not confused
+# with the projects Noxfile
+
+
+@nox.session
+def testytest(session):
+ session.log("Testing")
+
+
+@nox.session
+def lintylint(session):
+ session.log("Linting")
+
+
+@nox.session
+def typeytype(session):
+ session.log("Type Checking")
diff --git a/tests/resources/noxfile_nested.py b/tests/resources/noxfile_nested.py
index b7e70327..7e601052 100644
--- a/tests/resources/noxfile_nested.py
+++ b/tests/resources/noxfile_nested.py
@@ -12,10 +12,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
import nox
@nox.session(py=False)
@nox.parametrize("cheese", ["cheddar", "jack", "brie"])
def snack(unused_session, cheese):
- print("Noms, {} so good!".format(cheese))
+ print(f"Noms, {cheese} so good!")
diff --git a/tests/resources/noxfile_normalization.py b/tests/resources/noxfile_normalization.py
index 36a24880..29ea5c1b 100644
--- a/tests/resources/noxfile_normalization.py
+++ b/tests/resources/noxfile_normalization.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import datetime
import nox
diff --git a/tests/resources/noxfile_options.py b/tests/resources/noxfile_options.py
index fb32d420..6bd4e884 100644
--- a/tests/resources/noxfile_options.py
+++ b/tests/resources/noxfile_options.py
@@ -12,9 +12,13 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
import nox
-nox.options.reuse_existing_virtualenvs = True
+# nox.options.reuse_existing_virtualenvs = ${reuse_existing_virtualenvs}
+# nox.options.reuse_venv = "${reuse_venv}"
+# nox.options.error_on_missing_interpreters = ${error_on_missing_interpreters}
nox.options.sessions = ["test"]
diff --git a/tests/resources/noxfile_options_pythons.py b/tests/resources/noxfile_options_pythons.py
index 000d4683..80903df1 100644
--- a/tests/resources/noxfile_options_pythons.py
+++ b/tests/resources/noxfile_options_pythons.py
@@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
import nox
nox.options.sessions = ["{default_session}"]
diff --git a/tests/resources/noxfile_parametrize.py b/tests/resources/noxfile_parametrize.py
new file mode 100644
index 00000000..00077033
--- /dev/null
+++ b/tests/resources/noxfile_parametrize.py
@@ -0,0 +1,11 @@
+import nox
+
+
+@nox.session
+@nox.parametrize(
+ ["version"],
+ [["8.1.0"], ["7.5.0"]],
+ ["8.1.0", "7.5.0"],
+)
+def check_package_files(session: nox.Session, version: str):
+ pass
diff --git a/tests/resources/noxfile_pythons.py b/tests/resources/noxfile_pythons.py
index bfba5a08..2634a55f 100644
--- a/tests/resources/noxfile_pythons.py
+++ b/tests/resources/noxfile_pythons.py
@@ -1,7 +1,20 @@
+from __future__ import annotations
+
import nox
@nox.session(python=["3.6"])
+@nox.session(name="other", python=["3.6"])
@nox.parametrize("cheese", ["cheddar", "jack", "brie"])
def snack(unused_session, cheese):
- print("Noms, {} so good!".format(cheese))
+ print(f"Noms, {cheese} so good!")
+
+
+@nox.session(python=False)
+def nopy(unused_session):
+ print("No pythons here.")
+
+
+@nox.session(python="3.12")
+def strpy(unused_session):
+ print("Python-in-a-str here.")
diff --git a/tests/resources/noxfile_requires.py b/tests/resources/noxfile_requires.py
new file mode 100644
index 00000000..e4222f9c
--- /dev/null
+++ b/tests/resources/noxfile_requires.py
@@ -0,0 +1,130 @@
+# Copyright 2022 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import nox
+
+
+@nox.session(requires=["c", "b"])
+def a(session):
+ print(session.name)
+
+
+@nox.session()
+def b(session):
+ print(session.name)
+
+
+@nox.session()
+def c(session):
+ print(session.name)
+
+
+@nox.session(requires=["e"])
+def d(session):
+ print(session.name)
+
+
+@nox.session(requires=["c"])
+def e(session):
+ print(session.name)
+
+
+@nox.session(requires=["b", "g"])
+def f(session):
+ print(session.name)
+
+
+@nox.session(requires=["b", "h"])
+def g(session):
+ print(session.name)
+
+
+@nox.session(requires=["c"])
+def h(session):
+ print(session.name)
+
+
+@nox.session(requires=["j"])
+def i(session):
+ print(session.name)
+
+
+@nox.session(requires=["i"])
+def j(session):
+ print(session.name)
+
+
+@nox.session(python=["3.10", "3.11"])
+def k(session):
+ print(session.name)
+
+
+@nox.session(requires=["k"])
+def m(session):
+ print(session.name)
+
+
+@nox.session(python="3.11", requires=["k-{python}"])
+def n(session):
+ print(session.name)
+
+
+@nox.session(requires=["does_not_exist"])
+def o(session):
+ print(session.name)
+
+
+@nox.session(python=["3.10", "3.11"])
+def p(session):
+ print(session.name)
+
+
+@nox.session(python=None, requires=["p-{python}"])
+def q(session):
+ print(session.name)
+
+
+@nox.session
+def r(session):
+ print(session.name)
+ msg = "Fail!"
+ raise Exception(msg)
+
+
+@nox.session(requires=["r"])
+def s(session):
+ print(session.name)
+
+
+@nox.session(requires=["r"])
+def t(session):
+ print(session.name)
+
+
+@nox.parametrize("django", ["1.9", "2.0"])
+@nox.session
+def u(session, django):
+ print(session.name)
+
+
+@nox.session(requires=["u(django='1.9')", "u(django='2.0')"])
+def v(session):
+ print(session.name)
+
+
+@nox.session(requires=["u"])
+def w(session):
+ print(session.name)
diff --git a/tests/resources/noxfile_script_mode.py b/tests/resources/noxfile_script_mode.py
new file mode 100644
index 00000000..d95d0bb2
--- /dev/null
+++ b/tests/resources/noxfile_script_mode.py
@@ -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"))
diff --git a/tests/resources/noxfile_script_mode_exec.py b/tests/resources/noxfile_script_mode_exec.py
new file mode 100755
index 00000000..2c60fd27
--- /dev/null
+++ b/tests/resources/noxfile_script_mode_exec.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python
+
+# /// script
+# dependencies = ["nox", "cowsay"]
+# ///
+
+
+import nox
+
+
+@nox.session
+def exec_example(session: nox.Session) -> None:
+ # Importing inside the function so that if the test fails,
+ # it shows a better failure than immediately failing to import
+ import cowsay # noqa: PLC0415
+
+ print(cowsay.cow("another_world"))
+
+
+if __name__ == "__main__":
+ nox.main()
diff --git a/tests/resources/noxfile_script_mode_url_req.py b/tests/resources/noxfile_script_mode_url_req.py
new file mode 100644
index 00000000..3a3bb3f9
--- /dev/null
+++ b/tests/resources/noxfile_script_mode_url_req.py
@@ -0,0 +1,14 @@
+# /// script
+# dependencies = ["nox @ git+https://github.com/wntrblm/nox.git@2024.10.09"]
+# ///
+
+# The Nox version pinned above should be the second-most-recent version or older.
+
+import importlib.metadata
+
+import nox
+
+
+@nox.session(python=False)
+def example(session: nox.Session) -> None:
+ print(importlib.metadata.version("nox"))
diff --git a/tests/resources/noxfile_spaces.py b/tests/resources/noxfile_spaces.py
index 8c006f74..0704136e 100644
--- a/tests/resources/noxfile_spaces.py
+++ b/tests/resources/noxfile_spaces.py
@@ -12,10 +12,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
import nox
@nox.session(py=False, name="cheese list")
@nox.parametrize("cheese", ["cheddar", "jack", "brie"])
def snack(unused_session, cheese):
- print("Noms, {} so good!".format(cheese))
+ print(f"Noms, {cheese} so good!")
diff --git a/tests/resources/noxfile_tags.py b/tests/resources/noxfile_tags.py
new file mode 100644
index 00000000..10c85faa
--- /dev/null
+++ b/tests/resources/noxfile_tags.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+import nox
+
+
+@nox.session # no tags
+def no_tags(unused_session):
+ print("Look ma, no tags!")
+
+
+@nox.session(tags=["tag1"])
+def one_tag(unused_session):
+ print("Lonesome tag here.")
+
+
+@nox.session(tags=["tag1", "tag2", "tag3"])
+def more_tags(unused_session):
+ print("Some more tags here.")
+
+
+@nox.session(tags=["tag4"])
+@nox.parametrize("foo", [nox.param(1, tags=["tag5", "tag6"])])
+@nox.parametrize("bar", [2, 3], tags=[["tag7"]])
+def parametrized_tags(unused_session):
+ print("Parametrized tags here.")
diff --git a/tests/resources/orig_dir/noxfile.py b/tests/resources/orig_dir/noxfile.py
new file mode 100644
index 00000000..f6eb8ab6
--- /dev/null
+++ b/tests/resources/orig_dir/noxfile.py
@@ -0,0 +1,17 @@
+from pathlib import Path
+
+import nox
+
+FILE = Path(__file__).resolve()
+
+
+@nox.session(venv_backend="none", default=False)
+def orig(session: nox.Session) -> None:
+ assert Path("orig_file.txt").exists()
+
+
+@nox.session(venv_backend="none", default=False)
+def sym(session: nox.Session) -> None:
+ assert Path("sym_file.txt").exists()
+
+ assert FILE.parent.joinpath("orig_file.txt").exists()
diff --git a/tests/resources/orig_dir/orig_file.txt b/tests/resources/orig_dir/orig_file.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/resources/pep721example1.py b/tests/resources/pep721example1.py
new file mode 100644
index 00000000..cbf36f41
--- /dev/null
+++ b/tests/resources/pep721example1.py
@@ -0,0 +1,7 @@
+# /// script
+# dependencies = ["rich"]
+# ///
+
+import rich
+
+rich.print("[blue]This worked!")
diff --git a/tests/resources/sym_dir/noxfile.py b/tests/resources/sym_dir/noxfile.py
new file mode 120000
index 00000000..67bd5e2c
--- /dev/null
+++ b/tests/resources/sym_dir/noxfile.py
@@ -0,0 +1 @@
+../orig_dir/noxfile.py
\ No newline at end of file
diff --git a/tests/resources/sym_dir/sym_file.txt b/tests/resources/sym_dir/sym_file.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test__cli.py b/tests/test__cli.py
new file mode 100644
index 00000000..eef492dd
--- /dev/null
+++ b/tests/test__cli.py
@@ -0,0 +1,308 @@
+from __future__ import annotations
+
+import dataclasses
+import importlib.metadata
+import importlib.util
+import os
+import shutil
+import subprocess
+import sys
+import typing
+from types import SimpleNamespace
+
+import packaging.requirements
+import packaging.version
+import pytest
+
+import nox._cli
+import nox.virtualenv
+
+if typing.TYPE_CHECKING:
+ from pathlib import Path
+
+
+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",
+ "humanize",
+ "jinja2",
+ "nox",
+ "packaging",
+ "tox",
+ "virtualenv",
+ }
+ if sys.version_info < (3, 11):
+ dep_list.add("tomli")
+ assert {d.name for d in deps} == dep_list
+
+
+def test_get_dependencies_memoized(monkeypatch: pytest.MonkeyPatch) -> None:
+ requires: dict[str, list[str]] = {
+ "a": ['b[x]; extra == "y"', 'c; extra == "y"'],
+ "b": ['c; extra == "x"', 'a[y]; extra == "x"'], # cycle back to a
+ "c": [],
+ }
+ metadata_reads: list[str] = []
+
+ @dataclasses.dataclass
+ class FakeMetadataMessage:
+ name: str
+
+ def get_all(self, key: str) -> list[str] | None:
+ assert key == "requires-dist"
+ return requires[self.name] or None
+
+ def fake_metadata(name: str) -> FakeMetadataMessage:
+ metadata_reads.append(name)
+ return FakeMetadataMessage(name)
+
+ monkeypatch.setattr(importlib.metadata, "metadata", fake_metadata)
+
+ deps = list(nox._cli.get_dependencies(packaging.requirements.Requirement("a[y]")))
+
+ # Every encountered requirement is still yielded (so all specifiers get
+ # checked), but each package's metadata is read at most once, and the
+ # dependency cycle terminates.
+ assert [d.name for d in deps] == ["a", "b", "c", "a", "c"]
+ assert metadata_reads == ["a", "b", "c"]
+
+
+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()
+
+
+def test_invalid_backend_envvar(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ monkeypatch.setenv("NOX_SCRIPT_VENV_BACKEND", "invalid")
+ monkeypatch.setattr(sys, "argv", ["nox"])
+ # This will return pytest's filename instead, so patching it to None
+ monkeypatch.setattr(nox._cli, "get_main_filename", lambda: None)
+ monkeypatch.chdir(tmp_path)
+ tmp_path.joinpath("noxfile.py").write_text(
+ "# /// script\n# dependencies=['nox', 'invalid']\n# ///",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(ValueError, match="Expected venv_backend one of"):
+ nox._cli.main()
+
+
+def test_invalid_backend_inline(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ monkeypatch.setattr(sys, "argv", ["nox"])
+ # This will return pytest's filename instead, so patching it to None
+ monkeypatch.setattr(nox._cli, "get_main_filename", lambda: None)
+ monkeypatch.chdir(tmp_path)
+ tmp_path.joinpath("noxfile.py").write_text(
+ "# /// script\n# dependencies=['nox', 'invalid']\n# tool.nox.script-venv-backend = 'invalid'\n# ///",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(ValueError, match="Expected venv_backend one of"):
+ nox._cli.main()
+
+
+@dataclasses.dataclass
+class FakeDistribution:
+ origin: SimpleNamespace | None
+
+
+def test_url_dependency() -> None:
+ assert nox._cli.check_url_dependency(
+ "https://github.com/a/package",
+ FakeDistribution(origin=SimpleNamespace(url="https://github.com/a/package")), # type: ignore[arg-type]
+ )
+ assert not nox._cli.check_url_dependency(
+ "https://github.com/a/package",
+ FakeDistribution(origin=None), # type: ignore[arg-type]
+ )
+ assert nox._cli.check_url_dependency(
+ "https://github.com/a/package@v1.2.3",
+ FakeDistribution( # type: ignore[arg-type]
+ origin=SimpleNamespace(
+ url="https://github.com/a/package", requested_revision="v1.2.3"
+ )
+ ),
+ )
+
+
+def test_dependencies_with_url(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(nox._cli, "get_dependencies", lambda x: [x])
+ monkeypatch.setattr(
+ importlib.metadata,
+ "distribution",
+ lambda _: FakeDistribution(
+ SimpleNamespace(
+ url="https://github.com/wntrblm/nox", requested_revision="2024.10.09"
+ )
+ ),
+ )
+
+ assert nox._cli.check_dependencies(
+ ["nox @ git+https://github.com/wntrblm/nox@2024.10.09"]
+ )
+ assert not nox._cli.check_dependencies(
+ ["nox @ git+https://github.com/wntrblm/nox@2024.10.10"]
+ )
+ assert not nox._cli.check_dependencies(
+ ["nox @ git+https://github.com/other/nox@2024.10.09"]
+ )
+
+
+@pytest.mark.parametrize("backend", ["uv", "virtualenv"])
+def test_run_script_mode_pip_resolution(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, backend: str
+) -> None:
+ """pip must be resolved against the venv's PATH (Windows searches the parent's)."""
+ calls: list[list[str]] = []
+
+ fake_venv = SimpleNamespace(
+ venv_backend=backend,
+ create=lambda: None,
+ _get_env=lambda _env: {"PATH": "/fake/venv/bin"},
+ )
+ monkeypatch.setattr(
+ nox.virtualenv, "get_virtualenv", lambda *_args, **_kwargs: fake_venv
+ )
+
+ def fake_run(cmd: list[str], **_kwargs: object) -> SimpleNamespace:
+ calls.append(list(cmd))
+ return SimpleNamespace(returncode=0)
+
+ monkeypatch.setattr(subprocess, "run", fake_run)
+ monkeypatch.setattr(shutil, "which", lambda cmd, path=None: f"{path}/{cmd}")
+
+ def fake_execle(_path: str, *_args: object) -> typing.NoReturn:
+ raise SystemExit(0)
+
+ monkeypatch.setattr(os, "execle", fake_execle)
+ monkeypatch.setattr(sys, "argv", ["nox"])
+
+ with pytest.raises(SystemExit):
+ nox._cli.run_script_mode(
+ "noxfile.py",
+ tmp_path,
+ reuse=False,
+ dependencies=["nox", "cowsay"],
+ venv_backend=backend,
+ download_python="never",
+ )
+
+ expected = (
+ [nox.virtualenv.UV, "pip", "install"]
+ if backend == "uv"
+ else ["/fake/venv/bin/pip", "install"]
+ )
+ assert calls[0] == [*expected, "nox", "cowsay"]
+
+
+@pytest.mark.parametrize(
+ ("script_env", "global_env", "toml_value", "cli_args", "expected"),
+ [
+ ("always", "never", "never", ["--download-python", "never"], "always"),
+ (None, None, "always", ["--download-python", "never"], "always"),
+ (None, None, None, ["--download-python", "never"], "never"),
+ (None, "always", None, [], "always"),
+ (None, None, None, [], "auto"),
+ ],
+)
+def test_script_mode_download_python_precedence(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ script_env: str | None,
+ global_env: str | None,
+ toml_value: str | None,
+ cli_args: list[str],
+ expected: str,
+) -> None:
+ for var, value in (
+ ("NOX_SCRIPT_DOWNLOAD_PYTHON", script_env),
+ ("NOX_DOWNLOAD_PYTHON", global_env),
+ ):
+ if value is None:
+ monkeypatch.delenv(var, raising=False)
+ else:
+ monkeypatch.setenv(var, value)
+ monkeypatch.delenv("NOX_SCRIPT_MODE", raising=False)
+ monkeypatch.delenv("NOX_SCRIPT_VENV_BACKEND", raising=False)
+ monkeypatch.setattr(sys, "argv", ["nox", *cli_args])
+ # This will return pytest's filename instead, so patching it to None
+ monkeypatch.setattr(nox._cli, "get_main_filename", lambda: None)
+ monkeypatch.setattr(nox._cli, "check_dependencies", lambda _deps: False)
+
+ captured: dict[str, object] = {}
+
+ def fake_run_script_mode(*_args: object, **kwargs: object) -> typing.NoReturn:
+ captured.update(kwargs)
+ raise SystemExit(0)
+
+ monkeypatch.setattr(nox._cli, "run_script_mode", fake_run_script_mode)
+ monkeypatch.chdir(tmp_path)
+ toml_line = (
+ f"# tool.nox.script-download-python = '{toml_value}'\n" if toml_value else ""
+ )
+ tmp_path.joinpath("noxfile.py").write_text(
+ f"# /// script\n# dependencies=['nox']\n{toml_line}# ///",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(SystemExit):
+ nox._cli.main()
+
+ assert captured["download_python"] == expected
+
+
+def test_dependencies_with_version(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(nox._cli, "get_dependencies", lambda x: [x])
+ monkeypatch.setattr(
+ importlib.metadata, "version", lambda x: {"nox": "2024.10.09", "uv": "0.4.5"}[x]
+ )
+
+ assert nox._cli.check_dependencies(["nox", "uv"])
+ assert nox._cli.check_dependencies(["nox==2024.10.09", "uv>=0.4"])
+ assert nox._cli.check_dependencies(["nox<=2025", "uv~=0.4.2"])
+ assert not nox._cli.check_dependencies(["nox==2024.10.10", "uv"])
diff --git a/tests/test__option_set.py b/tests/test__option_set.py
index 202473ea..c3af3001 100644
--- a/tests/test__option_set.py
+++ b/tests/test__option_set.py
@@ -12,6 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
+from pathlib import Path
+
import pytest
from nox import _option_set, _options
@@ -20,8 +24,11 @@
# :func:`OptionSet.namespace` needs a bit of help to get to full coverage.
+RESOURCES = Path(__file__).parent.joinpath("resources")
+
+
class TestOptionSet:
- def test_namespace(self):
+ def test_namespace(self) -> None:
optionset = _option_set.OptionSet()
optionset.add_groups(_option_set.OptionGroup("group_a"))
optionset.add_options(
@@ -33,10 +40,10 @@ def test_namespace(self):
namespace = optionset.namespace()
assert hasattr(namespace, "option_a")
- assert not hasattr(namespace, "non_existant_option")
+ assert not hasattr(namespace, "non_existent_option")
assert namespace.option_a == "meep"
- def test_namespace_values(self):
+ def test_namespace_values(self) -> None:
optionset = _option_set.OptionSet()
optionset.add_groups(_option_set.OptionGroup("group_a"))
optionset.add_options(
@@ -49,27 +56,105 @@ def test_namespace_values(self):
assert namespace.option_a == "moop"
- def test_namespace_non_existant_options_with_values(self):
+ def test_namespace_non_existent_options_with_values(self) -> None:
optionset = _option_set.OptionSet()
with pytest.raises(KeyError):
- optionset.namespace(non_existant_option="meep")
+ optionset.namespace(non_existent_option="meep")
- def test_session_completer(self):
- parsed_args = _options.options.namespace(sessions=(), keywords=(), posargs=[])
- all_nox_sessions = _options._session_completer(
- prefix=None, parsed_args=parsed_args
+ def test_parser_hidden_option(self) -> None:
+ optionset = _option_set.OptionSet()
+ optionset.add_options(
+ _option_set.Option(
+ "oh_boy_i_am_hidden", hidden=True, group=None, default="meep"
+ )
+ )
+
+ parser = optionset.parser()
+ namespace = parser.parse_args([])
+ optionset._finalize_args(namespace)
+
+ assert namespace.oh_boy_i_am_hidden == "meep"
+
+ def test_parser_groupless_option(self) -> None:
+ optionset = _option_set.OptionSet()
+ optionset.add_options(
+ _option_set.Option("oh_no_i_have_no_group", group=None, default="meep")
+ )
+
+ with pytest.raises(
+ ValueError,
+ match="Option oh_no_i_have_no_group must either have a group or be hidden",
+ ):
+ optionset.parser()
+
+ def test_session_completer(self) -> None:
+ parsed_args = _options.options.namespace(
+ posargs=[],
+ noxfile=str(RESOURCES.joinpath("noxfile_multiple_sessions.py")),
+ )
+ actual_sessions_from_file = _options._session_completer(
+ prefix="", parsed_args=parsed_args
)
- # if noxfile.py changes, this will have to change as well since these are
- # some of the actual sessions found in noxfile.py
- some_expected_sessions = ["cover", "blacken", "lint", "docs"]
- assert len(set(some_expected_sessions) - set(all_nox_sessions)) == 0
- def test_session_completer_invalid_sessions(self):
+ expected_sessions = ["testytest", "lintylint", "typeytype"]
+ assert expected_sessions == list(actual_sessions_from_file)
+
+ def test_session_completer_invalid_sessions(self) -> None:
parsed_args = _options.options.namespace(
sessions=("baz",), keywords=(), posargs=[]
)
all_nox_sessions = _options._session_completer(
- prefix=None, parsed_args=parsed_args
+ prefix="", parsed_args=parsed_args
)
- assert len(all_nox_sessions) == 0
+ assert len(list(all_nox_sessions)) == 0
+
+ def test_python_completer(self) -> None:
+ parsed_args = _options.options.namespace(
+ posargs=[],
+ noxfile=str(RESOURCES.joinpath("noxfile_pythons.py")),
+ )
+ actual_pythons_from_file = _options._python_completer(
+ prefix="", parsed_args=parsed_args
+ )
+
+ expected_pythons = {"3.6", "3.12"}
+ assert expected_pythons == set(actual_pythons_from_file)
+
+ def test_tag_completer(self) -> None:
+ parsed_args = _options.options.namespace(
+ posargs=[],
+ noxfile=str(RESOURCES.joinpath("noxfile_tags.py")),
+ )
+ actual_tags_from_file = _options._tag_completer(
+ prefix="", parsed_args=parsed_args
+ )
+
+ expected_tags = {f"tag{n}" for n in range(1, 8)}
+ assert expected_tags == set(actual_tags_from_file)
+
+ def test_validation_options(self) -> None:
+ options = _option_set.NoxOptions(
+ default_venv_backend=None,
+ download_python="auto",
+ envdir=None,
+ error_on_external_run=False,
+ error_on_missing_interpreters=False,
+ force_venv_backend=None,
+ keywords=None,
+ pythons=None,
+ report=None,
+ reuse_existing_virtualenvs=False,
+ reuse_venv=None,
+ sessions=None,
+ stop_on_first_error=False,
+ tags=None,
+ verbose=False,
+ )
+ options.sessions = ["testytest"]
+ options.sessions = ("testytest",)
+ with pytest.raises(ValueError): # noqa: PT011
+ options.sessions = "testytest"
+
+ options.envdir = "envdir"
+ options.envdir = Path("envdir")
diff --git a/tests/test__parametrize.py b/tests/test__parametrize.py
index e581380b..a202909b 100644
--- a/tests/test__parametrize.py
+++ b/tests/test__parametrize.py
@@ -12,15 +12,18 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
from unittest import mock
import pytest
+import nox
from nox import _decorators, _parametrize, parametrize, session
@pytest.mark.parametrize(
- "param, other, expected",
+ ("param", "other", "expected"),
[
(_parametrize.Param(1, 2), _parametrize.Param(1, 2), True),
(_parametrize.Param(1, 2, id="a"), _parametrize.Param(1, 2, id="a"), True),
@@ -28,95 +31,99 @@
(_parametrize.Param(1, 2, arg_names=("a", "b")), {"a": 1, "b": 2}, True),
],
)
-def test_param_eq(param, other, expected):
+def test_param_eq(
+ param: _parametrize.Param,
+ other: _parametrize.Param | dict[str, int],
+ expected: bool,
+) -> None:
assert (param == other) is expected
-def test_param_eq_fail():
- with pytest.raises(NotImplementedError):
- _parametrize.Param() == "a"
+def test_param_eq_fail() -> None:
+ assert _parametrize.Param() != "a"
-def test_parametrize_decorator_one():
- def f():
+def test_parametrize_decorator_one() -> None:
+ def f() -> None:
pass
- _parametrize.parametrize_decorator("abc", 1)(f)
+ # A raw int is supported, but not part of the typed API.
+ _parametrize.parametrize_decorator("abc", 1)(f) # type: ignore[arg-type]
- assert f.parametrize == [_parametrize.Param(1, arg_names=("abc",))]
+ assert f.parametrize == [_parametrize.Param(1, arg_names=("abc",))] # type: ignore[attr-defined]
-def test_parametrize_decorator_one_param():
- def f():
+def test_parametrize_decorator_one_param() -> None:
+ def f() -> None:
pass
_parametrize.parametrize_decorator("abc", _parametrize.Param(1))(f)
- assert f.parametrize == [_parametrize.Param(1, arg_names=("abc",))]
+ assert f.parametrize == [_parametrize.Param(1, arg_names=("abc",))] # type: ignore[attr-defined]
-def test_parametrize_decorator_one_with_args():
- def f():
+def test_parametrize_decorator_one_with_args() -> None:
+ def f() -> None:
pass
_parametrize.parametrize_decorator("abc", [1, 2, 3])(f)
- assert f.parametrize == [{"abc": 1}, {"abc": 2}, {"abc": 3}]
+ assert f.parametrize == [{"abc": 1}, {"abc": 2}, {"abc": 3}] # type: ignore[attr-defined]
-def test_parametrize_decorator_param():
- def f():
+def test_parametrize_decorator_param() -> None:
+ def f() -> None:
pass
_parametrize.parametrize_decorator(["abc", "def"], _parametrize.Param(1))(f)
- assert f.parametrize == [_parametrize.Param(1, arg_names=("abc", "def"))]
+ assert f.parametrize == [_parametrize.Param(1, arg_names=("abc", "def"))] # type: ignore[attr-defined]
-def test_parametrize_decorator_id_list():
- def f():
+def test_parametrize_decorator_id_list() -> None:
+ def f() -> None:
pass
_parametrize.parametrize_decorator("abc", [1, 2, 3], ids=["a", "b", "c"])(f)
arg_names = ("abc",)
- assert f.parametrize == [
+ assert f.parametrize == [ # type: ignore[attr-defined]
_parametrize.Param(1, arg_names=arg_names, id="a"),
_parametrize.Param(2, arg_names=arg_names, id="b"),
_parametrize.Param(3, arg_names=arg_names, id="c"),
]
-def test_parametrize_decorator_multiple_args_as_list():
- def f():
+def test_parametrize_decorator_multiple_args_as_list() -> None:
+ def f() -> None:
pass
_parametrize.parametrize_decorator(["abc", "def"], [("a", 1), ("b", 2), ("c", 3)])(
f
)
- assert f.parametrize == [
+ assert f.parametrize == [ # type: ignore[attr-defined]
{"abc": "a", "def": 1},
{"abc": "b", "def": 2},
{"abc": "c", "def": 3},
]
-def test_parametrize_decorator_multiple_args_as_string():
- def f():
+def test_parametrize_decorator_multiple_args_as_string() -> None:
+ def f() -> None:
pass
_parametrize.parametrize_decorator("abc, def", [("a", 1), ("b", 2), ("c", 3)])(f)
- assert f.parametrize == [
+ assert f.parametrize == [ # type: ignore[attr-defined]
{"abc": "a", "def": 1},
{"abc": "b", "def": 2},
{"abc": "c", "def": 3},
]
-def test_parametrize_decorator_mixed_params():
- def f():
+def test_parametrize_decorator_mixed_params() -> None:
+ def f() -> None:
pass
_parametrize.parametrize_decorator(
@@ -124,21 +131,21 @@ def f():
)(f)
arg_names = ("abc", "def")
- assert f.parametrize == [
+ assert f.parametrize == [ # type: ignore[attr-defined]
_parametrize.Param(1, 2, arg_names=arg_names),
_parametrize.Param(3, 4, arg_names=arg_names, id="b"),
_parametrize.Param(5, 6, arg_names=arg_names),
]
-def test_parametrize_decorator_stack():
- def f():
+def test_parametrize_decorator_stack() -> None:
+ def f() -> None:
pass
_parametrize.parametrize_decorator("abc", [1, 2, 3])(f)
_parametrize.parametrize_decorator("def", ["a", "b"])(f)
- assert f.parametrize == [
+ assert f.parametrize == [ # type: ignore[attr-defined]
{"abc": 1, "def": "a"},
{"abc": 2, "def": "a"},
{"abc": 3, "def": "a"},
@@ -148,14 +155,14 @@ def f():
]
-def test_parametrize_decorator_multiple_and_stack():
- def f():
+def test_parametrize_decorator_multiple_and_stack() -> None:
+ def f() -> None:
pass
_parametrize.parametrize_decorator("abc, def", [(1, "a"), (2, "b")])(f)
_parametrize.parametrize_decorator("foo", ["bar", "baz"])(f)
- assert f.parametrize == [
+ assert f.parametrize == [ # type: ignore[attr-defined]
{"abc": 1, "def": "a", "foo": "bar"},
{"abc": 2, "def": "b", "foo": "bar"},
{"abc": 1, "def": "a", "foo": "baz"},
@@ -163,10 +170,10 @@ def f():
]
-def test_generate_calls_simple():
-
- f = mock.Mock()
+def test_generate_calls_simple() -> None:
+ f = mock.Mock(should_warn={}, tags=[])
f.__name__ = "f"
+ f.requires = None
f.some_prop = 42
arg_names = ("abc",)
@@ -192,14 +199,14 @@ def test_generate_calls_simple():
# Make sure wrapping was done correctly.
for call in calls:
- assert call.some_prop == 42
- assert call.__name__ == "f"
+ assert call.some_prop == 42 # type: ignore[attr-defined]
+ assert call.__name__ == "f" # type: ignore[attr-defined]
-def test_generate_calls_multiple_args():
-
- f = mock.Mock()
+def test_generate_calls_multiple_args() -> None:
+ f = mock.Mock(should_warn=None, tags=[])
f.__name__ = "f"
+ f.requires = None
arg_names = ("foo", "abc")
call_specs = [
@@ -223,9 +230,10 @@ def test_generate_calls_multiple_args():
f.assert_called_with(abc=3, foo="c")
-def test_generate_calls_ids():
- f = mock.Mock()
+def test_generate_calls_ids() -> None:
+ f = mock.Mock(should_warn={}, tags=[])
f.__name__ = "f"
+ f.requires = None
arg_names = ("foo",)
call_specs = [
@@ -245,12 +253,50 @@ def test_generate_calls_ids():
f.assert_called_with(foo=2)
-def test_generate_calls_session_python():
+def test_generate_calls_tags() -> None:
+ f = mock.Mock(should_warn={}, tags=[], requires=[])
+ f.__name__ = "f"
+
+ arg_names = ("foo",)
+ call_specs = [
+ _parametrize.Param(1, arg_names=arg_names, tags=["tag3"]),
+ _parametrize.Param(1, arg_names=arg_names),
+ _parametrize.Param(2, arg_names=arg_names, tags=["tag4", "tag5"]),
+ ]
+
+ calls = _decorators.Call.generate_calls(f, call_specs)
+
+ assert len(calls) == 3
+ assert calls[0].tags == ["tag3"]
+ assert calls[1].tags == []
+ assert calls[2].tags == ["tag4", "tag5"]
+
+
+def test_generate_calls_merge_tags() -> None:
+ f = mock.Mock(should_warn={}, tags=["tag1", "tag2"], requires=[])
+ f.__name__ = "f"
+
+ arg_names = ("foo",)
+ call_specs = [
+ _parametrize.Param(1, arg_names=arg_names, tags=["tag3"]),
+ _parametrize.Param(1, arg_names=arg_names),
+ _parametrize.Param(2, arg_names=arg_names, tags=["tag4", "tag5"]),
+ ]
+
+ calls = _decorators.Call.generate_calls(f, call_specs)
+
+ assert len(calls) == 3
+ assert calls[0].tags == ["tag1", "tag2", "tag3"]
+ assert calls[1].tags == ["tag1", "tag2"]
+ assert calls[2].tags == ["tag1", "tag2", "tag4", "tag5"]
+
+
+def test_generate_calls_session_python() -> None:
called_with = []
@session
@parametrize("python,dependency", [("3.8", "0.9"), ("3.9", "0.9"), ("3.9", "1.0")])
- def f(session, dependency):
+ def f(session: nox.Session, dependency: str) -> None:
called_with.append((session, dependency))
calls = _decorators.Call.generate_calls(f, f.parametrize)
@@ -273,17 +319,17 @@ def f(session, dependency):
assert len(called_with) == 3
- assert called_with[0] == (session_, "0.9")
- assert called_with[1] == (session_, "0.9")
+ assert called_with[0] == (session_, "0.9") # type: ignore[comparison-overlap]
+ assert called_with[1] == (session_, "0.9") # type: ignore[unreachable]
assert called_with[2] == (session_, "1.0")
-def test_generate_calls_python_compatibility():
+def test_generate_calls_python_compatibility() -> None:
called_with = []
@session
@parametrize("python,dependency", [("3.8", "0.9"), ("3.9", "0.9"), ("3.9", "1.0")])
- def f(session, python, dependency):
+ def f(session: nox.Session, python: str, dependency: str) -> None:
called_with.append((session, python, dependency))
calls = _decorators.Call.generate_calls(f, f.parametrize)
@@ -306,6 +352,6 @@ def f(session, python, dependency):
assert len(called_with) == 3
- assert called_with[0] == (session_, "3.8", "0.9")
- assert called_with[1] == (session_, "3.9", "0.9")
+ assert called_with[0] == (session_, "3.8", "0.9") # type: ignore[comparison-overlap]
+ assert called_with[1] == (session_, "3.9", "0.9") # type: ignore[unreachable]
assert called_with[2] == (session_, "3.9", "1.0")
diff --git a/tests/test__resolver.py b/tests/test__resolver.py
new file mode 100644
index 00000000..e0df0dc6
--- /dev/null
+++ b/tests/test__resolver.py
@@ -0,0 +1,169 @@
+# Copyright 2022 Alethea Katherine Flowers
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import pytest
+
+from nox._resolver import CycleError, lazy_stable_topo_sort
+
+
+@pytest.mark.parametrize(
+ ("dependencies", "expected"),
+ [
+ # Assert typical example with the following features:
+ # 1. Topological sort.
+ # 2. Ignores nodes outside the subgraph of ``dependencies[root]`` and its
+ # (recursive) dependencies. (``"f"`` and ``"g"``).
+ # 3. Lazy (``"e"`` does not occur any earlier than it needs to for ``"d"``).
+ # 4. Obeys ``dependencies[node]`` order preference when possible (``"a"`` and
+ # ``"d"``, ``"c"`` and ``"b"``).
+ (
+ {
+ "a": ("c", "b"),
+ "b": (),
+ "c": (),
+ "d": ("e",),
+ "e": ("c",),
+ "f": ("b", "g"),
+ "g": (),
+ "0": ("a", "d"),
+ },
+ ("c", "b", "a", "e", "d"),
+ ),
+ # 1. Topological sort.
+ # 2. Ignores (``"f"`` and ``"g"``).
+ # 3. Lazy (``"b"``).
+ # 4. Obeys order preference (``"d"`` and ``"a"``).
+ # 4. Obeys order preference (``"c"`` and ``"b"``).
+ (
+ {
+ "a": ("c", "b"),
+ "b": (),
+ "c": (),
+ "d": ("e",),
+ "e": ("c",),
+ "f": ("b", "g"),
+ "g": (),
+ "0": ("d", "a"),
+ },
+ ("c", "e", "d", "b", "a"),
+ ),
+ # 1. Topological sort.
+ # 2. Ignores (``"f"`` and ``"g"``).
+ # 4. Ignores order preference (``"a"`` and ``"d"``) when it is impossible to
+ # both satisfy a pair preference and produce a producing a topological and
+ # lazy sort.
+ (
+ {
+ "a": ("d",),
+ "b": (),
+ "c": (),
+ "d": ("e",),
+ "e": ("c",),
+ "f": ("b", "g"),
+ "g": (),
+ "0": ("a", "d"),
+ },
+ ("c", "e", "d", "a"),
+ ),
+ # 1. Topological sort.
+ # 2. Ignores (``"f"`` and ``"g"``).
+ # 3. Lazy (``"e"``).
+ # 4. Obeys order preference (``"a"`` and ``"d"``).
+ # 4. Ignores order preference (``"c"`` and ``"b"``).
+ (
+ {
+ "a": ("c", "b"),
+ "b": (),
+ "c": ("b",),
+ "d": ("e",),
+ "e": ("c",),
+ "f": ("b",),
+ "0": ("a", "d"),
+ },
+ ("b", "c", "a", "e", "d"),
+ ),
+ # 1. Topological sort.
+ # 2. Ignores (``"f"``).
+ # 3. Lazy (``"c"``, ``"h"``, ``"a"``, and ``"e"``).
+ # 4. Obeys order preference (``"g"``, ``"a"``, and ``"d"``).
+ # 4. Obeys order preference (``"b"`` and ``"g"``).
+ # 4. Obeys order preference (``"b"`` and ``"h"``).
+ # 4. Ignores order preference (``"c"`` and ``"b"``). Note that this is despite
+ # the fact that the topological order between ``"b"`` and ``"c"`` is
+ # undefined. In the tests above, we only saw a pair order preference ignored
+ # because the topological order between that pair was defined. Here,
+ # however, the ``dependencies["a"]`` order preference between ``"b"`` and
+ # ``"c"`` is ignored because obeying this order preference cannot be done
+ # without making the sort non-lazy (here, calling ``"c"`` earlier than is
+ # required by one of its dependents (``"h"``) would be non-lazy).
+ (
+ {
+ "a": ("c", "b"),
+ "b": (),
+ "c": (),
+ "d": ("e",),
+ "e": ("c",),
+ "f": ("b", "g"),
+ "g": ("b", "h"),
+ "h": ("c",),
+ "0": ("g", "a", "d"),
+ },
+ ("b", "c", "h", "g", "a", "e", "d"),
+ ),
+ ],
+)
+def test_lazy_stable_topo_sort(
+ dependencies: dict[str, tuple[str, ...]], expected: tuple[str, ...]
+) -> None:
+ actual = tuple(lazy_stable_topo_sort(dependencies, "0"))
+ actual_with_root = tuple(lazy_stable_topo_sort(dependencies, "0", drop_root=False))
+ assert actual == actual_with_root[:-1] == expected
+
+
+@pytest.mark.parametrize(
+ ("dependencies", "expected_cycle"),
+ [
+ # Note that these cycles are inherent to the dependency graph; they cannot be
+ # resolved by ignoring the order preference for a pair in ``dependencies[node]``
+ # for some ``node``.
+ (
+ {
+ "a": ("b",),
+ "b": ("a",),
+ "0": ("a",),
+ },
+ ("a", "b", "a"),
+ ),
+ (
+ {
+ "a": ("c", "b"),
+ "b": (),
+ "c": ("a", "b"),
+ "0": ("a",),
+ },
+ ("a", "c", "a"),
+ ),
+ ],
+)
+def test_lazy_stable_topo_sort_CycleError(
+ dependencies: dict[str, tuple[str, ...]], expected_cycle: tuple[str, ...]
+) -> None:
+ with pytest.raises(CycleError) as exc_info:
+ tuple(lazy_stable_topo_sort(dependencies, "0"))
+ # While the exact cycle reported is not unique and is an implementation detail, this
+ # still serves as a regression test for unexpected changes in the implementation's
+ # behavior.
+ assert exc_info.value.args[1] == expected_cycle
diff --git a/tests/test__version.py b/tests/test__version.py
index b4497d02..9c88c580 100644
--- a/tests/test__version.py
+++ b/tests/test__version.py
@@ -12,9 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from pathlib import Path
+from __future__ import annotations
+
from textwrap import dedent
-from typing import Optional
+from typing import TYPE_CHECKING
import pytest
@@ -27,12 +28,16 @@
get_nox_version,
)
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from pathlib import Path
+
@pytest.fixture
-def temp_noxfile(tmp_path: Path):
+def temp_noxfile(tmp_path: Path) -> Callable[[str], str]:
def make_temp_noxfile(content: str) -> str:
path = tmp_path / "noxfile.py"
- path.write_text(content)
+ path.write_text(content, encoding="utf8")
return str(path)
return make_temp_noxfile
@@ -46,12 +51,12 @@ def test_needs_version_default() -> None:
def test_get_nox_version() -> None:
"""It returns something that looks like a Nox version."""
result = get_nox_version()
- year, month, day = [int(part) for part in result.split(".")[:3]]
+ year, _month, _day, *_ = (int(part) for part in result.split("."))
assert year >= 2020
@pytest.mark.parametrize(
- "text,expected",
+ ("text", "expected"),
[
("", None),
(
@@ -93,13 +98,15 @@ def test_get_nox_version() -> None:
),
],
)
-def test_parse_needs_version(text: str, expected: Optional[str]) -> None:
+def test_parse_needs_version(text: str, expected: str | None) -> None:
"""It is parsed successfully."""
assert expected == _parse_needs_version(text)
@pytest.mark.parametrize("specifiers", ["", ">=2020.12.31", ">=2020.12.31,<9999.99.99"])
-def test_check_nox_version_succeeds(temp_noxfile, specifiers: str) -> None:
+def test_check_nox_version_succeeds(
+ temp_noxfile: Callable[[str], str], specifiers: str
+) -> None:
"""It does not raise if the version specifiers are satisfied."""
text = dedent(
f"""
@@ -111,7 +118,9 @@ def test_check_nox_version_succeeds(temp_noxfile, specifiers: str) -> None:
@pytest.mark.parametrize("specifiers", [">=9999.99.99"])
-def test_check_nox_version_fails(temp_noxfile, specifiers: str) -> None:
+def test_check_nox_version_fails(
+ temp_noxfile: Callable[[str], str], specifiers: str
+) -> None:
"""It raises an exception if the version specifiers are not satisfied."""
text = dedent(
f"""
@@ -124,7 +133,9 @@ def test_check_nox_version_fails(temp_noxfile, specifiers: str) -> None:
@pytest.mark.parametrize("specifiers", ["invalid", "2020.12.31"])
-def test_check_nox_version_invalid(temp_noxfile, specifiers: str) -> None:
+def test_check_nox_version_invalid(
+ temp_noxfile: Callable[[str], str], specifiers: str
+) -> None:
"""It raises an exception if the version specifiers cannot be parsed."""
text = dedent(
f"""
diff --git a/tests/test_action_helper.py b/tests/test_action_helper.py
new file mode 100644
index 00000000..7b1d15db
--- /dev/null
+++ b/tests/test_action_helper.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import json
+
+import pytest
+from action_helper import filter_version, setup_action
+
+VALID_VERSIONS = {
+ "2.7.18": "2.7",
+ "3.9-dev": "3.9",
+ "3.10": "3.10",
+ "3.11": "3.11",
+ "~3.12.0-0": "3.12",
+ "3.13t": "3.13t",
+ "3.13t-dev": "3.13t",
+ "pypy-3.7": "3.7",
+ "pypy-3.8-v7.3.9": "3.8",
+ "pypy-3.9": "3.9",
+ "pypy3.10": "3.10",
+}
+
+
+@pytest.mark.parametrize("version", VALID_VERSIONS.keys())
+def test_filter_version(version: str) -> None:
+ assert filter_version(version) == VALID_VERSIONS[version]
+
+
+def test_filter_version_invalid() -> None:
+ with pytest.raises(ValueError, match=r"invalid version: 3"):
+ filter_version("3")
+
+
+def test_filter_version_invalid_major() -> None:
+ with pytest.raises(ValueError, match=r"invalid major python version: x.0"):
+ filter_version("x.0")
+
+
+def test_filter_version_invalid_minor() -> None:
+ with pytest.raises(ValueError, match=r"invalid minor python version: 3.x"):
+ filter_version("3.x")
+
+
+VALID_VERSION_LISTS = {
+ "3.7, 3.8, 3.9, 3.10, 3.11, pypy-3.7, pypy-3.8, pypy-3.9": [
+ "pypy-3.7",
+ "pypy-3.8",
+ "pypy-3.9",
+ "3.7",
+ "3.8",
+ "3.9",
+ "3.10",
+ "3.11",
+ "3.12",
+ ],
+ "": ["3.12"],
+ "~3.12.0-0": ["~3.12.0-0"],
+ "3.11.4": ["3.11.4", "3.12"],
+ "3.9-dev,pypy3.9-nightly": ["pypy3.9-nightly", "3.9-dev", "3.12"],
+ "3.12, 3.11, 3.10, 3.9": ["3.11", "3.10", "3.9", "3.12"],
+ ",".join(f"3.{minor}" for minor in range(20)): [
+ f"3.{minor}"
+ for i, minor in enumerate(minor_ for minor_ in range(20) if minor_ != 12)
+ ]
+ + ["3.12"],
+}
+
+
+@pytest.mark.parametrize("version_list", VALID_VERSION_LISTS.keys())
+def test_setup_action(capsys: pytest.CaptureFixture[str], version_list: str) -> None:
+ setup_action(version_list)
+ captured = capsys.readouterr()
+ lines = captured.out.splitlines()
+ ordered_versions = VALID_VERSION_LISTS[version_list]
+ assert lines == [f"interpreters={json.dumps(ordered_versions)}"]
+
+
+def test_setup_action_multiple_pypy() -> None:
+ with pytest.raises(
+ ValueError,
+ match=(
+ r"multiple versions specified for the same 'major.minor' PyPy interpreter"
+ ),
+ ):
+ setup_action("pypy3.9, pypy-3.9-v7.3.9")
+
+
+def test_setup_action_multiple_cpython() -> None:
+ with pytest.raises(
+ ValueError,
+ match=(
+ r"multiple versions specified for the same 'major.minor' CPython"
+ r" interpreter"
+ ),
+ ):
+ setup_action("3.10, 3.10.4")
diff --git a/tests/test_command.py b/tests/test_command.py
index db49a874..a3a04198 100644
--- a/tests/test_command.py
+++ b/tests/test_command.py
@@ -12,15 +12,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
import ctypes
import logging
import os
import platform
+import shutil
import signal
import subprocess
import sys
import time
+from pathlib import Path
from textwrap import dedent
+from typing import Any
from unittest import mock
import pytest
@@ -31,22 +36,22 @@
PYTHON = sys.executable
skip_on_windows_primary_console_session = pytest.mark.skipif(
- platform.system() == "Windows" and "SECONDARY_CONSOLE_SESSION" not in os.environ,
+ sys.platform.startswith("win") and "SECONDARY_CONSOLE_SESSION" not in os.environ,
reason="On Windows, this test must run in a separate console session.",
)
only_on_windows = pytest.mark.skipif(
- platform.system() != "Windows", reason="Only run this test on Windows."
+ not sys.platform.startswith("win"), reason="Only run this test on Windows."
)
-def test_run_defaults(capsys):
+def test_run_defaults() -> None:
result = nox.command.run([PYTHON, "-c", "print(123)"])
assert result is True
-def test_run_silent(capsys):
+def test_run_silent(capsys: pytest.CaptureFixture[str]) -> None:
result = nox.command.run([PYTHON, "-c", "print(123)"], silent=True)
out, _ = capsys.readouterr()
@@ -55,7 +60,16 @@ def test_run_silent(capsys):
assert out == ""
-def test_run_verbosity(capsys, caplog):
+@pytest.mark.skipif(shutil.which("git") is None, reason="Needs git")
+def test_run_not_in_path() -> None:
+ # Paths falls back on the environment PATH if the command is not found.
+ result = nox.command.run(["git", "--version"], paths=["."])
+ assert result is True
+
+
+def test_run_verbosity(
+ capsys: pytest.CaptureFixture[str], caplog: pytest.LogCaptureFixture
+) -> None:
caplog.clear()
with caplog.at_level(logging.DEBUG):
result = nox.command.run([PYTHON, "-c", "print(123)"], silent=True)
@@ -82,16 +96,13 @@ def test_run_verbosity(capsys, caplog):
assert logs[0].message.strip() == "123"
-def test_run_verbosity_failed_command(capsys, caplog):
+def test_run_verbosity_failed_command(caplog: pytest.LogCaptureFixture) -> None:
caplog.clear()
with caplog.at_level(logging.DEBUG):
with pytest.raises(nox.command.CommandFailed):
nox.command.run([PYTHON, "-c", "print(123); exit(1)"], silent=True)
- out, err = capsys.readouterr()
-
- assert "123" in err
- assert out == ""
+ assert "123" in caplog.text
logs = [rec for rec in caplog.records if rec.levelname == "OUTPUT"]
assert not logs
@@ -101,16 +112,26 @@ def test_run_verbosity_failed_command(capsys, caplog):
with pytest.raises(nox.command.CommandFailed):
nox.command.run([PYTHON, "-c", "print(123); exit(1)"], silent=True)
- out, err = capsys.readouterr()
-
- assert "123" in err
- assert out == ""
+ assert "123" in caplog.text
# Nothing is logged but the error is still written to stderr
assert not logs
-def test_run_env_unicode():
+@pytest.mark.skipif(
+ sys.platform.startswith("win"),
+ reason="See https://github.com/python/cpython/issues/85815",
+)
+def test_run_non_str() -> None:
+ result = nox.command.run(
+ [Path(PYTHON), "-c", "import sys; print(sys.argv)", Path(PYTHON)],
+ silent=True,
+ )
+
+ assert PYTHON in result
+
+
+def test_run_env_unicode() -> None:
result = nox.command.run(
[PYTHON, "-c", 'import os; print(os.environ["SIGIL"])'],
silent=True,
@@ -120,8 +141,22 @@ def test_run_env_unicode():
assert "123" in result
-def test_run_env_systemroot():
- systemroot = os.environ.setdefault("SYSTEMROOT", str("sigil"))
+def test_run_env_remove(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("EMPTY", "notempty")
+ nox.command.run(
+ [PYTHON, "-c", 'import os; assert "EMPTY" in os.environ'],
+ silent=True,
+ )
+ nox.command.run(
+ [PYTHON, "-c", 'import os; assert "EMPTY" not in os.environ'],
+ silent=True,
+ env={"EMPTY": None},
+ )
+
+
+@mock.patch("nox.command._PLATFORM", "win32")
+def test_run_env_systemroot() -> None:
+ systemroot = os.environ.setdefault("SYSTEMROOT", "sigil")
result = nox.command.run(
[PYTHON, "-c", 'import os; print(os.environ["SYSTEMROOT"])'], silent=True
@@ -130,12 +165,12 @@ def test_run_env_systemroot():
assert systemroot in result
-def test_run_not_found():
+def test_run_not_found() -> None:
with pytest.raises(nox.command.CommandFailed):
nox.command.run(["nonexistentcmd"])
-def test_run_path_nonexistent():
+def test_run_path_nonexistent() -> None:
result = nox.command.run(
[PYTHON, "-c", "import sys; print(sys.executable)"],
silent=True,
@@ -145,47 +180,86 @@ def test_run_path_nonexistent():
assert "/non/existent" not in result
-def test_run_path_existent(tmpdir, monkeypatch):
- executable = tmpdir.join("testexc")
- executable.ensure("")
+def test_run_path_existent(tmp_path: Path) -> None:
+ executable_name = (
+ "testexc.exe" if "windows" in platform.platform().lower() else "testexc"
+ )
+ tmp_path.touch()
+ executable = tmp_path.joinpath(executable_name)
+ executable.touch()
executable.chmod(0o700)
with mock.patch("nox.command.popen") as mock_command:
mock_command.return_value = (0, "")
- nox.command.run(["testexc"], silent=True, paths=[tmpdir.strpath])
- mock_command.assert_called_with([executable.strpath], env=None, silent=True)
+ nox.command.run([executable_name], silent=True, paths=[str(tmp_path)])
+ mock_command.assert_called_with(
+ [str(executable)],
+ env=None,
+ silent=True,
+ stdout=None,
+ stderr=subprocess.STDOUT,
+ interrupt_timeout=0.3,
+ terminate_timeout=0.2,
+ )
-def test_run_external_warns(tmpdir, caplog):
+def test_run_external_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.WARNING)
- nox.command.run([PYTHON, "--version"], silent=True, paths=[tmpdir.strpath])
+ nox.command.run([PYTHON, "--version"], silent=True, paths=[tmp_path])
assert "external=True" in caplog.text
-def test_run_external_silences(tmpdir, caplog):
+def test_run_external_silences(
+ tmp_path: Path, caplog: pytest.LogCaptureFixture
+) -> None:
caplog.set_level(logging.WARNING)
- nox.command.run(
- [PYTHON, "--version"], silent=True, paths=[tmpdir.strpath], external=True
- )
+ nox.command.run([PYTHON, "--version"], silent=True, paths=[tmp_path], external=True)
assert "external=True" not in caplog.text
-def test_run_external_raises(tmpdir, caplog):
+def test_run_external_raises(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.ERROR)
with pytest.raises(nox.command.CommandFailed):
nox.command.run(
- [PYTHON, "--version"], silent=True, paths=[tmpdir.strpath], external="error"
+ [PYTHON, "--version"], silent=True, paths=[tmp_path], external="error"
)
assert "external=True" in caplog.text
-def test_exit_codes():
+def test_run_external_raises_without_log(
+ tmp_path: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+ caplog.set_level(logging.ERROR)
+
+ with pytest.raises(nox.command.CommandFailed):
+ nox.command.run(
+ [PYTHON, "--version"],
+ silent=True,
+ paths=[tmp_path],
+ external="error",
+ log=False,
+ )
+
+ assert "external=True" in caplog.text
+
+
+def test_run_external_no_warning_without_log(
+ tmp_path: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+ caplog.set_level(logging.WARNING)
+
+ nox.command.run([PYTHON, "--version"], silent=True, paths=[tmp_path], log=False)
+
+ assert "external=True" not in caplog.text
+
+
+def test_exit_codes() -> None:
assert nox.command.run([PYTHON, "-c", "import sys; sys.exit(0)"])
with pytest.raises(nox.command.CommandFailed):
@@ -196,64 +270,75 @@ def test_exit_codes():
)
-def test_fail_with_silent(capsys):
+def test_fail_with_silent(
+ caplog: pytest.LogCaptureFixture, capsys: pytest.CaptureFixture[str]
+) -> None:
+ caplog.clear()
with pytest.raises(nox.command.CommandFailed):
nox.command.run(
[
PYTHON,
"-c",
- 'import sys; sys.stdout.write("out");'
- 'sys.stderr.write("err"); sys.exit(1)',
+ (
+ 'import sys; sys.stdout.write("out");'
+ 'sys.stderr.write("err"); sys.exit(1)'
+ ),
],
silent=True,
)
- out, err = capsys.readouterr()
- assert "out" in err
- assert "err" in err
+ _out, err = capsys.readouterr()
+ assert "out" not in err
+ assert "err" not in err
+ assert "out" in caplog.text
+ assert "err" in caplog.text
@pytest.fixture
-def marker(tmp_path):
+def marker(tmp_path: Path) -> Path:
"""A marker file for process communication."""
return tmp_path / "marker"
-def enable_ctrl_c(enabled):
+def enable_ctrl_c(*, enabled: bool) -> None:
"""Enable keyboard interrupts (CTRL-C) on Windows."""
- kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
if not kernel32.SetConsoleCtrlHandler(None, not enabled):
- raise ctypes.WinError(ctypes.get_last_error())
+ raise ctypes.WinError(ctypes.get_last_error()) # type: ignore[attr-defined]
-def interrupt_process(proc):
+def interrupt_process(proc: subprocess.Popen[Any]) -> None:
"""Send SIGINT or CTRL_C_EVENT to the process."""
- if platform.system() == "Windows":
+ if sys.platform.startswith("win"):
# Disable Ctrl-C so we don't terminate ourselves.
- enable_ctrl_c(False)
+ enable_ctrl_c(enabled=False)
# Send the keyboard interrupt to all processes attached to the current
# console session.
- os.kill(0, signal.CTRL_C_EVENT)
+ os.kill(0, signal.CTRL_C_EVENT) # type: ignore[attr-defined]
else:
proc.send_signal(signal.SIGINT)
@pytest.fixture
-def command_with_keyboard_interrupt(monkeypatch, marker):
+def command_with_keyboard_interrupt(
+ monkeypatch: pytest.MonkeyPatch, marker: Any
+) -> None:
"""Monkeypatch Popen.communicate to raise KeyboardInterrupt."""
- if platform.system() == "Windows":
+ if sys.platform.startswith("win"):
# Enable Ctrl-C because the child inherits the setting from us.
- enable_ctrl_c(True)
+ enable_ctrl_c(enabled=True)
communicate = subprocess.Popen.communicate
- def wrapper(proc, *args, **kwargs):
+ def wrapper(
+ proc: subprocess.Popen[Any], *args: Any, **kwargs: Any
+ ) -> tuple[Any, Any]:
# Raise the interrupt only on the first call, so Nox has a chance to
# shut down the child process subsequently.
- if wrapper.firstcall:
- wrapper.firstcall = False
+ if wrapper.firstcall: # type: ignore[attr-defined]
+ wrapper.firstcall = False # type: ignore[attr-defined]
# Give the child time to install its signal handlers.
while not marker.exists():
@@ -267,12 +352,12 @@ def wrapper(proc, *args, **kwargs):
return communicate(proc, *args, **kwargs)
- wrapper.firstcall = True
+ wrapper.firstcall = True # type: ignore[attr-defined]
monkeypatch.setattr("subprocess.Popen.communicate", wrapper)
-def format_program(program, marker):
+def format_program(program: str, marker: Any) -> str:
"""Preprocess the Python program run by the child process."""
main = f"""
import time
@@ -284,21 +369,16 @@ def format_program(program, marker):
return dedent(program).format(MAIN=dedent(main))
-def run_pytest_in_new_console_session(test):
+def run_pytest_in_new_console_session(test: str) -> None:
"""Run the given test in a separate console session."""
env = dict(os.environ, SECONDARY_CONSOLE_SESSION="")
- creationflags = (
- subprocess.CREATE_NO_WINDOW
- if sys.version_info[:2] >= (3, 7)
- else subprocess.CREATE_NEW_CONSOLE
- )
+ creationflags = subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined]
subprocess.run(
[sys.executable, "-m", "pytest", f"{__file__}::{test}"],
env=env,
check=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
+ capture_output=True,
creationflags=creationflags,
)
@@ -327,20 +407,24 @@ def run_pytest_in_new_console_session(test):
""",
],
)
-def test_interrupt_raises(command_with_keyboard_interrupt, program, marker):
+@pytest.mark.usefixtures("command_with_keyboard_interrupt")
+def test_interrupt_raises(
+ program: str,
+ marker: Any,
+) -> None:
"""It kills the process and reraises the keyboard interrupt."""
with pytest.raises(KeyboardInterrupt):
nox.command.run([PYTHON, "-c", format_program(program, marker)])
@only_on_windows
-def test_interrupt_raises_on_windows():
+def test_interrupt_raises_on_windows() -> None:
"""It kills the process and reraises the keyboard interrupt."""
run_pytest_in_new_console_session("test_interrupt_raises")
@skip_on_windows_primary_console_session
-def test_interrupt_handled(command_with_keyboard_interrupt, marker):
+def test_interrupt_handled(command_with_keyboard_interrupt: None, marker: Any) -> None: # noqa: ARG001
"""It does not raise if the child handles the keyboard interrupt."""
program = """
import signal
@@ -356,19 +440,21 @@ def exithandler(sig, frame):
@only_on_windows
-def test_interrupt_handled_on_windows():
+def test_interrupt_handled_on_windows() -> None:
"""It does not raise if the child handles the keyboard interrupt."""
run_pytest_in_new_console_session("test_interrupt_handled")
-def test_custom_stdout(capsys, tmpdir):
- with open(str(tmpdir / "out.txt"), "w+b") as stdout:
+def test_custom_stdout(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
+ with (tmp_path / "out.txt").open("w+", encoding="utf-8") as stdout:
nox.command.run(
[
PYTHON,
"-c",
- 'import sys; sys.stdout.write("out");'
- 'sys.stderr.write("err"); sys.exit(0)',
+ (
+ 'import sys; sys.stdout.write("out");'
+ 'sys.stderr.write("err"); sys.exit(0)'
+ ),
],
stdout=stdout,
)
@@ -377,26 +463,30 @@ def test_custom_stdout(capsys, tmpdir):
assert "out" not in err
assert "err" not in err
stdout.seek(0)
- tempfile_contents = stdout.read().decode("utf-8")
+ tempfile_contents = stdout.read()
assert "out" in tempfile_contents
assert "err" in tempfile_contents
-def test_custom_stdout_silent_flag(capsys, tmpdir):
- with open(str(tmpdir / "out.txt"), "w+b") as stdout:
+def test_custom_stdout_silent_flag(tmp_path: Path) -> None:
+ with (tmp_path / "out.txt").open("w+", encoding="utf-8") as stdout: # noqa: SIM117
with pytest.raises(ValueError, match="silent"):
nox.command.run([PYTHON, "-c", 'print("hi")'], stdout=stdout, silent=True)
-def test_custom_stdout_failed_command(capsys, tmpdir):
- with open(str(tmpdir / "out.txt"), "w+b") as stdout:
+def test_custom_stdout_failed_command(
+ capsys: pytest.CaptureFixture[str], tmp_path: Path
+) -> None:
+ with (tmp_path / "out.txt").open("w+", encoding="utf-8") as stdout:
with pytest.raises(nox.command.CommandFailed):
nox.command.run(
[
PYTHON,
"-c",
- 'import sys; sys.stdout.write("out");'
- 'sys.stderr.write("err"); sys.exit(1)',
+ (
+ 'import sys; sys.stdout.write("out");'
+ 'sys.stderr.write("err"); sys.exit(1)'
+ ),
],
stdout=stdout,
)
@@ -405,19 +495,21 @@ def test_custom_stdout_failed_command(capsys, tmpdir):
assert "out" not in err
assert "err" not in err
stdout.seek(0)
- tempfile_contents = stdout.read().decode("utf-8")
+ tempfile_contents = stdout.read()
assert "out" in tempfile_contents
assert "err" in tempfile_contents
-def test_custom_stderr(capsys, tmpdir):
- with open(str(tmpdir / "err.txt"), "w+b") as stderr:
+def test_custom_stderr(capsys: pytest.CaptureFixture[str], tmp_path: Path) -> None:
+ with (tmp_path / "err.txt").open("w+", encoding="utf-8") as stderr:
nox.command.run(
[
PYTHON,
"-c",
- 'import sys; sys.stdout.write("out");'
- 'sys.stderr.write("err"); sys.exit(0)',
+ (
+ 'import sys; sys.stdout.write("out");'
+ 'sys.stderr.write("err"); sys.exit(0)'
+ ),
],
stderr=stderr,
)
@@ -426,20 +518,24 @@ def test_custom_stderr(capsys, tmpdir):
assert "out" not in out
assert "err" not in out
stderr.seek(0)
- tempfile_contents = stderr.read().decode("utf-8")
+ tempfile_contents = stderr.read()
assert "out" not in tempfile_contents
assert "err" in tempfile_contents
-def test_custom_stderr_failed_command(capsys, tmpdir):
- with open(str(tmpdir / "out.txt"), "w+b") as stderr:
+def test_custom_stderr_failed_command(
+ capsys: pytest.CaptureFixture[str], tmp_path: Path
+) -> None:
+ with (tmp_path / "out.txt").open("w+", encoding="utf-8") as stderr:
with pytest.raises(nox.command.CommandFailed):
nox.command.run(
[
PYTHON,
"-c",
- 'import sys; sys.stdout.write("out");'
- 'sys.stderr.write("err"); sys.exit(1)',
+ (
+ 'import sys; sys.stdout.write("out");'
+ 'sys.stderr.write("err"); sys.exit(1)'
+ ),
],
stderr=stderr,
)
@@ -448,7 +544,7 @@ def test_custom_stderr_failed_command(capsys, tmpdir):
assert "out" not in out
assert "err" not in out
stderr.seek(0)
- tempfile_contents = stderr.read().decode("utf-8")
+ tempfile_contents = stderr.read()
assert "out" not in tempfile_contents
assert "err" in tempfile_contents
@@ -460,13 +556,13 @@ def test_output_decoding() -> None:
def test_output_decoding_non_ascii() -> None:
- result = nox.popen.decode_output("ü".encode("utf-8"))
+ result = nox.popen.decode_output("ü".encode())
assert result == "ü"
def test_output_decoding_utf8_only_fail(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setattr(nox.popen.locale, "getpreferredencoding", lambda: "utf8")
+ monkeypatch.setattr(nox.popen.locale, "getpreferredencoding", lambda: "utf8") # type: ignore[attr-defined]
with pytest.raises(UnicodeDecodeError) as exc:
nox.popen.decode_output(b"\x95")
@@ -477,7 +573,7 @@ def test_output_decoding_utf8_only_fail(monkeypatch: pytest.MonkeyPatch) -> None
def test_output_decoding_utf8_fail_cp1252_success(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- monkeypatch.setattr(nox.popen.locale, "getpreferredencoding", lambda: "cp1252")
+ monkeypatch.setattr(nox.popen.locale, "getpreferredencoding", lambda: "cp1252") # type: ignore[attr-defined]
result = nox.popen.decode_output(b"\x95")
@@ -485,7 +581,7 @@ def test_output_decoding_utf8_fail_cp1252_success(
def test_output_decoding_both_fail(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setattr(nox.popen.locale, "getpreferredencoding", lambda: "ascii")
+ monkeypatch.setattr(nox.popen.locale, "getpreferredencoding", lambda: "ascii") # type: ignore[attr-defined]
with pytest.raises(UnicodeDecodeError) as exc:
nox.popen.decode_output(b"\x95")
diff --git a/tests/test_logger.py b/tests/test_logger.py
index eef9dcf3..a322675e 100644
--- a/tests/test_logger.py
+++ b/tests/test_logger.py
@@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
import logging
from unittest import mock
@@ -20,26 +22,26 @@
from nox import logger
-def test_success():
+def test_success() -> None:
with mock.patch.object(logger.LoggerWithSuccessAndOutput, "_log") as _log:
logger.LoggerWithSuccessAndOutput("foo").success("bar")
_log.assert_called_once_with(logger.SUCCESS, "bar", ())
-def test_output():
+def test_output() -> None:
with mock.patch.object(logger.LoggerWithSuccessAndOutput, "_log") as _log:
logger.LoggerWithSuccessAndOutput("foo").output("bar")
_log.assert_called_once_with(logger.OUTPUT, "bar", ())
-def test_formatter(caplog):
+def test_formatter(caplog: pytest.LogCaptureFixture) -> None:
caplog.clear()
- logger.setup_logging(True, verbose=True)
+ logger.setup_logging(color=True, verbose=True)
with caplog.at_level(logging.DEBUG):
logger.logger.info("bar")
logger.logger.output("foo")
- logs = [rec for rec in caplog.records if rec.levelname in ("INFO", "OUTPUT")]
+ logs = [rec for rec in caplog.records if rec.levelname in {"INFO", "OUTPUT"}]
assert len(logs) == 1
assert not hasattr(logs[0], "asctime")
@@ -48,12 +50,12 @@ def test_formatter(caplog):
logger.logger.info("bar")
logger.logger.output("foo")
- logs = [rec for rec in caplog.records if rec.levelname in ("INFO", "OUTPUT")]
+ logs = [rec for rec in caplog.records if rec.levelname in {"INFO", "OUTPUT"}]
assert len(logs) == 2
logs = [rec for rec in caplog.records if rec.levelname == "OUTPUT"]
assert len(logs) == 1
- # Make sure output level log records are not nox prefixed
+ # Make sure output level log records are not Nox prefixed
assert "nox" not in logs[0].message
@@ -62,18 +64,18 @@ def test_formatter(caplog):
[
# This currently fails due to some incompatibility between caplog and colorlog
# that causes caplog to not collect the asctime from colorlog.
- pytest.param(True, id="color", marks=pytest.mark.xfail),
+ pytest.param(True, id="color", marks=pytest.mark.xfail(strict=False)),
pytest.param(False, id="no-color"),
],
)
-def test_no_color_timestamp(caplog, color):
+def test_no_color_timestamp(caplog: pytest.LogCaptureFixture, color: bool) -> None:
logger.setup_logging(color=color, add_timestamp=True)
caplog.clear()
with caplog.at_level(logging.DEBUG):
logger.logger.info("bar")
logger.logger.output("foo")
- logs = [rec for rec in caplog.records if rec.levelname in ("INFO", "OUTPUT")]
+ logs = [rec for rec in caplog.records if rec.levelname in {"INFO", "OUTPUT"}]
assert len(logs) == 1
assert hasattr(logs[0], "asctime")
diff --git a/tests/test_main.py b/tests/test_main.py
index 15f7b506..0890203f 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -12,45 +12,52 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
import contextlib
import os
+import re
+import shutil
+import subprocess
import sys
+from importlib import metadata
from pathlib import Path
+from typing import TYPE_CHECKING, Any, Literal
from unittest import mock
import pytest
import nox
-import nox.__main__
+import nox._cli
import nox._options
import nox.registry
import nox.sessions
-try:
- import importlib.metadata as metadata
-except ImportError:
- import importlib_metadata as metadata
-
+if TYPE_CHECKING:
+ from collections.abc import Callable
RESOURCES = os.path.join(os.path.dirname(__file__), "resources")
VERSION = metadata.version("nox")
# This is needed because CI systems will mess up these tests due to the
-# way nox handles the --session parameter's default value. This avoids that
+# way Nox handles the --session parameter's default value. This avoids that
# mess.
os.environ.pop("NOXSESSION", None)
-def test_main_no_args(monkeypatch):
+@pytest.mark.parametrize(
+ "main", [nox.main, nox._cli.nox_main], ids=["main", "nox_main"]
+)
+def test_main_no_args(monkeypatch: pytest.MonkeyPatch, main: Any) -> None:
monkeypatch.setattr(sys, "argv", [sys.executable])
with mock.patch("nox.workflow.execute") as execute:
execute.return_value = 0
# Call the function.
- with mock.patch.object(sys, "exit") as exit:
- nox.__main__.main()
- exit.assert_called_once_with(0)
+ with mock.patch.object(sys, "exit") as mock_exit:
+ main()
+ mock_exit.assert_called_once_with(0)
assert execute.called
# Verify that the config looks correct.
@@ -59,11 +66,12 @@ def test_main_no_args(monkeypatch):
assert config.sessions is None
assert not config.no_venv
assert not config.reuse_existing_virtualenvs
+ assert not config.reuse_venv
assert not config.stop_on_first_error
assert config.posargs == []
-def test_main_long_form_args():
+def test_main_long_form_args() -> None:
sys.argv = [
sys.executable,
"--noxfile",
@@ -85,9 +93,9 @@ def test_main_long_form_args():
execute.return_value = 0
# Call the main function.
- with mock.patch.object(sys, "exit") as exit:
- nox.__main__.main()
- exit.assert_called_once_with(0)
+ with mock.patch.object(sys, "exit") as mock_exit:
+ nox.main()
+ mock_exit.assert_called_once_with(0)
assert execute.called
# Verify that the config looks correct.
@@ -99,11 +107,14 @@ def test_main_long_form_args():
assert config.force_venv_backend == "none"
assert config.no_venv is True
assert config.reuse_existing_virtualenvs is True
+ assert config.reuse_venv == "yes"
assert config.stop_on_first_error is True
assert config.posargs == []
-def test_main_no_venv(monkeypatch, capsys):
+def test_main_no_venv(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
# Check that --no-venv overrides force_venv_backend
monkeypatch.setattr(
sys,
@@ -119,7 +130,7 @@ def test_main_no_venv(monkeypatch, capsys):
)
with mock.patch("sys.exit") as sys_exit:
- nox.__main__.main()
+ nox.main()
stdout, stderr = capsys.readouterr()
assert stdout == "Noms, cheddar so good!\n"
assert (
@@ -130,7 +141,7 @@ def test_main_no_venv(monkeypatch, capsys):
sys_exit.assert_called_once_with(0)
-def test_main_no_venv_error():
+def test_main_no_venv_error() -> None:
# Check that --no-venv can not be set together with a non-none --force-venv-backend
sys.argv = [
sys.executable,
@@ -141,10 +152,35 @@ def test_main_no_venv_error():
"--no-venv",
]
with pytest.raises(ValueError, match="You can not use"):
- nox.__main__.main()
+ nox.main()
+
+
+def test_main_param_force_python(monkeypatch: pytest.MonkeyPatch) -> None:
+ """
+ Check that Python can be forced if something is parametrized over other things.
+ """
+
+ # Check that --no-venv overrides force_venv_backend
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "nox",
+ "--noxfile",
+ os.path.join(RESOURCES, "noxfile_parametrize.py"),
+ "--sessions",
+ "check_package_files(7.5.0)",
+ "--force-python",
+ ".".join(str(v) for v in sys.version_info[:2]),
+ ],
+ )
+ with mock.patch("sys.exit") as sys_exit:
+ nox.main()
+ sys_exit.assert_called_once_with(0)
-def test_main_short_form_args(monkeypatch):
+
+def test_main_short_form_args(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
sys,
"argv",
@@ -166,9 +202,9 @@ def test_main_short_form_args(monkeypatch):
execute.return_value = 0
# Call the main function.
- with mock.patch.object(sys, "exit") as exit:
- nox.__main__.main()
- exit.assert_called_once_with(0)
+ with mock.patch.object(sys, "exit") as mock_exit:
+ nox.main()
+ mock_exit.assert_called_once_with(0)
assert execute.called
# Verify that the config looks correct.
@@ -178,17 +214,18 @@ def test_main_short_form_args(monkeypatch):
assert config.default_venv_backend == "venv"
assert config.force_venv_backend == "conda"
assert config.reuse_existing_virtualenvs is True
+ assert config.reuse_venv == "yes"
-def test_main_explicit_sessions(monkeypatch):
+def test_main_explicit_sessions(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", [sys.executable, "-e", "1", "2"])
with mock.patch("nox.workflow.execute") as execute:
execute.return_value = 0
# Call the main function.
- with mock.patch.object(sys, "exit") as exit:
- nox.__main__.main()
- exit.assert_called_once_with(0)
+ with mock.patch.object(sys, "exit") as mock_exit:
+ nox.main()
+ mock_exit.assert_called_once_with(0)
assert execute.called
# Verify that the explicit sessions are listed in the config.
@@ -196,7 +233,9 @@ def test_main_explicit_sessions(monkeypatch):
assert config.sessions == ["1", "2"]
-def test_main_explicit_sessions_with_spaces_in_names(monkeypatch):
+def test_main_explicit_sessions_with_spaces_in_names(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
monkeypatch.setattr(
sys, "argv", [sys.executable, "-e", "unit tests", "the unit tests"]
)
@@ -204,9 +243,9 @@ def test_main_explicit_sessions_with_spaces_in_names(monkeypatch):
execute.return_value = 0
# Call the main function.
- with mock.patch.object(sys, "exit") as exit:
- nox.__main__.main()
- exit.assert_called_once_with(0)
+ with mock.patch.object(sys, "exit") as mock_exit:
+ nox.main()
+ mock_exit.assert_called_once_with(0)
assert execute.called
# Verify that the explicit sessions are listed in the config.
@@ -215,60 +254,121 @@ def test_main_explicit_sessions_with_spaces_in_names(monkeypatch):
@pytest.mark.parametrize(
- "env,sessions", [("foo", ["foo"]), ("foo,bar", ["foo", "bar"])]
+ ("var", "option", "env", "values"),
+ [
+ ("NOXSESSION", "sessions", "foo", ["foo"]),
+ ("NOXSESSION", "sessions", "foo,bar", ["foo", "bar"]),
+ ("NOXPYTHON", "pythons", "3.10", ["3.10"]),
+ ("NOXPYTHON", "pythons", "3.10,3.11", ["3.10", "3.11"]),
+ ("NOXEXTRAPYTHON", "extra_pythons", "3.10", ["3.10"]),
+ ("NOXEXTRAPYTHON", "extra_pythons", "3.10,3.11", ["3.10", "3.11"]),
+ ("NOXFORCEPYTHON", "force_pythons", "3.10", ["3.10"]),
+ ("NOXFORCEPYTHON", "force_pythons", "3.10,3.11", ["3.10", "3.11"]),
+ ],
+ ids=[
+ "single_session",
+ "multiple_sessions",
+ "single_python",
+ "multiple_pythons",
+ "single_extra_python",
+ "multiple_extra_pythons",
+ "single_force_python",
+ "multiple_force_pythons",
+ ],
)
-def test_main_session_from_nox_env_var(monkeypatch, env, sessions):
- monkeypatch.setenv("NOXSESSION", env)
+def test_main_list_option_from_nox_env_var(
+ monkeypatch: pytest.MonkeyPatch,
+ var: str,
+ option: str,
+ env: str,
+ values: list[str],
+) -> None:
+ monkeypatch.setenv(var, env)
monkeypatch.setattr(sys, "argv", [sys.executable])
with mock.patch("nox.workflow.execute") as execute:
execute.return_value = 0
# Call the main function.
- with mock.patch.object(sys, "exit") as exit:
- nox.__main__.main()
- exit.assert_called_once_with(0)
+ with mock.patch.object(sys, "exit") as mock_exit:
+ nox.main()
+ mock_exit.assert_called_once_with(0)
assert execute.called
# Verify that the sessions from the env var are listed in the config.
config = execute.call_args[1]["global_config"]
- assert len(config.sessions) == len(sessions)
- for session in sessions:
- assert session in config.sessions
+ config_values = getattr(config, option)
+ assert len(config_values) == len(values)
+ assert all(value in config_values for value in values)
-def test_main_positional_args(capsys, monkeypatch):
+@pytest.mark.parametrize(
+ ("options", "env", "expected"),
+ [
+ (["--default-venv-backend", "conda"], "", "conda"),
+ ([], "mamba", "mamba"),
+ (["--default-venv-backend", "conda"], "mamba", "conda"),
+ ],
+ ids=["option", "env", "option_over_env"],
+)
+def test_default_venv_backend_option(
+ monkeypatch: pytest.MonkeyPatch,
+ options: list[str],
+ env: str,
+ expected: str,
+) -> None:
+ monkeypatch.setenv("NOX_DEFAULT_VENV_BACKEND", env)
+ monkeypatch.setattr(sys, "argv", [sys.executable, *options])
+ with mock.patch("nox.workflow.execute") as execute:
+ execute.return_value = 0
+
+ # Call the main function.
+ with mock.patch.object(sys, "exit") as mock_exit:
+ nox.main()
+ mock_exit.assert_called_once_with(0)
+ assert execute.called
+
+ # Verify that the default venv backend is set in the config.
+ config = execute.call_args[1]["global_config"]
+ assert config.default_venv_backend == expected
+
+
+def test_main_positional_args(
+ capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+) -> None:
fake_exit = mock.Mock(side_effect=ValueError("asdf!"))
monkeypatch.setattr(sys, "argv", [sys.executable, "1", "2", "3"])
- with mock.patch.object(sys, "exit", fake_exit), pytest.raises(
- ValueError, match="asdf!"
+ with (
+ mock.patch.object(sys, "exit", fake_exit),
+ pytest.raises(ValueError, match="asdf!"),
):
- nox.__main__.main()
+ nox.main()
_, stderr = capsys.readouterr()
assert "Unknown argument(s) '1 2 3'" in stderr
fake_exit.assert_called_once_with(2)
fake_exit.reset_mock()
monkeypatch.setattr(sys, "argv", [sys.executable, "1", "2", "3", "--"])
- with mock.patch.object(sys, "exit", fake_exit), pytest.raises(
- ValueError, match="asdf!"
+ with (
+ mock.patch.object(sys, "exit", fake_exit),
+ pytest.raises(ValueError, match="asdf!"),
):
- nox.__main__.main()
+ nox.main()
_, stderr = capsys.readouterr()
assert "Unknown argument(s) '1 2 3'" in stderr
fake_exit.assert_called_once_with(2)
-def test_main_positional_with_double_hyphen(monkeypatch):
+def test_main_positional_with_double_hyphen(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", [sys.executable, "--", "1", "2", "3"])
with mock.patch("nox.workflow.execute") as execute:
execute.return_value = 0
# Call the main function.
- with mock.patch.object(sys, "exit") as exit:
- nox.__main__.main()
- exit.assert_called_once_with(0)
+ with mock.patch.object(sys, "exit") as mock_exit:
+ nox.main()
+ mock_exit.assert_called_once_with(0)
assert execute.called
# Verify that the positional args are listed in the config.
@@ -276,7 +376,9 @@ def test_main_positional_with_double_hyphen(monkeypatch):
assert config.posargs == ["1", "2", "3"]
-def test_main_positional_flag_like_with_double_hyphen(monkeypatch):
+def test_main_positional_flag_like_with_double_hyphen(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
monkeypatch.setattr(
sys, "argv", [sys.executable, "--", "1", "2", "3", "-f", "--baz"]
)
@@ -284,9 +386,9 @@ def test_main_positional_flag_like_with_double_hyphen(monkeypatch):
execute.return_value = 0
# Call the main function.
- with mock.patch.object(sys, "exit") as exit:
- nox.__main__.main()
- exit.assert_called_once_with(0)
+ with mock.patch.object(sys, "exit") as mock_exit:
+ nox.main()
+ mock_exit.assert_called_once_with(0)
assert execute.called
# Verify that the positional args are listed in the config.
@@ -294,42 +396,48 @@ def test_main_positional_flag_like_with_double_hyphen(monkeypatch):
assert config.posargs == ["1", "2", "3", "-f", "--baz"]
-def test_main_version(capsys, monkeypatch):
+def test_main_version(
+ capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+) -> None:
monkeypatch.setattr(sys, "argv", [sys.executable, "--version"])
with contextlib.ExitStack() as stack:
execute = stack.enter_context(mock.patch("nox.workflow.execute"))
exit_mock = stack.enter_context(mock.patch("sys.exit"))
- nox.__main__.main()
+ nox.main()
_, err = capsys.readouterr()
assert VERSION in err
exit_mock.assert_not_called()
execute.assert_not_called()
-def test_main_help(capsys, monkeypatch):
+def test_main_help(
+ capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+) -> None:
monkeypatch.setattr(sys, "argv", [sys.executable, "--help"])
with contextlib.ExitStack() as stack:
execute = stack.enter_context(mock.patch("nox.workflow.execute"))
exit_mock = stack.enter_context(mock.patch("sys.exit"))
- nox.__main__.main()
+ nox.main()
out, _ = capsys.readouterr()
assert "help" in out
exit_mock.assert_not_called()
execute.assert_not_called()
-def test_main_failure(monkeypatch):
+def test_main_failure(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", [sys.executable])
with mock.patch("nox.workflow.execute") as execute:
execute.return_value = 1
- with mock.patch.object(sys, "exit") as exit:
- nox.__main__.main()
- exit.assert_called_once_with(1)
+ with mock.patch.object(sys, "exit") as mock_exit:
+ nox.main()
+ mock_exit.assert_called_once_with(1)
-def test_main_nested_config(capsys, monkeypatch):
+def test_main_nested_config(
+ capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+) -> None:
monkeypatch.setattr(
sys,
"argv",
@@ -343,14 +451,16 @@ def test_main_nested_config(capsys, monkeypatch):
)
with mock.patch("sys.exit") as sys_exit:
- nox.__main__.main()
+ nox.main()
stdout, stderr = capsys.readouterr()
assert stdout == "Noms, cheddar so good!\n"
assert "Session snack(cheese='cheddar') was successful." in stderr
sys_exit.assert_called_once_with(0)
-def test_main_session_with_names(capsys, monkeypatch):
+def test_main_session_with_names(
+ capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+) -> None:
monkeypatch.setattr(
sys,
"argv",
@@ -364,7 +474,7 @@ def test_main_session_with_names(capsys, monkeypatch):
)
with mock.patch("sys.exit") as sys_exit:
- nox.__main__.main()
+ nox.main()
stdout, stderr = capsys.readouterr()
assert stdout == "Noms, cheddar so good!\n"
assert "Session cheese list(cheese='cheddar') was successful." in stderr
@@ -372,12 +482,14 @@ def test_main_session_with_names(capsys, monkeypatch):
@pytest.fixture
-def run_nox(capsys, monkeypatch):
- def _run_nox(*args):
+def run_nox(
+ capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+) -> Callable[..., tuple[int, str, str]]:
+ def _run_nox(*args: str) -> tuple[int, str, str]:
monkeypatch.setattr(sys, "argv", ["nox", *args])
with mock.patch("sys.exit") as sys_exit:
- nox.__main__.main()
+ nox.main()
stdout, stderr = capsys.readouterr()
returncode = sys_exit.call_args[0][0]
@@ -415,7 +527,9 @@ def _run_nox(*args):
),
],
)
-def test_main_with_normalized_session_names(run_nox, normalized_name, session):
+def test_main_with_normalized_session_names(
+ run_nox: Callable[..., tuple[int, str, str]], normalized_name: str, session: str
+) -> None:
noxfile = os.path.join(RESOURCES, "noxfile_normalization.py")
returncode, _, stderr = run_nox(f"--noxfile={noxfile}", f"--session={session}")
assert returncode == 0
@@ -436,14 +550,124 @@ def test_main_with_normalized_session_names(run_nox, normalized_name, session):
"test(arg=datetime.datetime(1980, 1, 1))",
],
)
-def test_main_with_bad_session_names(run_nox, session):
+def test_main_with_bad_session_names(
+ run_nox: Callable[..., tuple[Any, Any, Any]],
+ session: str,
+) -> None:
noxfile = os.path.join(RESOURCES, "noxfile_normalization.py")
returncode, _, stderr = run_nox(f"--noxfile={noxfile}", f"--session={session}")
assert returncode != 0
assert session in stderr
-def test_main_noxfile_options(monkeypatch):
+py310py311 = pytest.mark.skipif(
+ shutil.which("python3.10") is None or shutil.which("python3.11") is None,
+ reason="Python 3.10 and 3.11 required",
+)
+
+
+@pytest.mark.parametrize(
+ ("sessions", "expected_order"),
+ [
+ (("g", "a", "d"), ("b", "c", "h", "g", "a", "e", "d")),
+ pytest.param(("m",), ("k-3.10", "k-3.11", "m"), marks=py310py311),
+ pytest.param(("n",), ("k-3.11", "n"), marks=py310py311),
+ (("v",), ("u(django='1.9')", "u(django='2.0')", "v")),
+ (("w",), ("u(django='1.9')", "u(django='2.0')", "w")),
+ ],
+)
+def test_main_requires(
+ run_nox: Callable[..., tuple[Any, Any, Any]],
+ sessions: tuple[str, ...],
+ expected_order: tuple[str, ...],
+) -> None:
+ noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+ returncode, stdout, _ = run_nox(f"--noxfile={noxfile}", "--sessions", *sessions)
+ assert returncode == 0
+ assert tuple(stdout.rstrip("\n").split("\n")) == expected_order
+
+
+def test_main_requires_cycle(run_nox: Callable[..., tuple[Any, Any, Any]]) -> None:
+ noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+ returncode, _, stderr = run_nox(f"--noxfile={noxfile}", "--session=i")
+ assert returncode != 0
+ # While the exact cycle reported is not unique and is an implementation detail, this
+ # still serves as a regression test for unexpected changes in the implementation's
+ # behavior.
+ assert "Sessions are in a dependency cycle: i -> j -> i" in stderr
+
+
+def test_main_requires_missing_session(
+ run_nox: Callable[..., tuple[Any, Any, Any]],
+) -> None:
+ noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+ returncode, _, stderr = run_nox(f"--noxfile={noxfile}", "--session=o")
+ assert returncode != 0
+ assert "Session not found: does_not_exist" in stderr
+
+
+def test_main_requires_bad_python_parametrization(
+ run_nox: Callable[..., tuple[Any, Any, Any]],
+) -> None:
+ noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+ with pytest.raises(ValueError, match="Cannot parametrize requires"):
+ run_nox(f"--noxfile={noxfile}", "--session=q")
+
+
+@pytest.mark.parametrize("session", ["s", "t"])
+def test_main_requires_chain_fail(
+ run_nox: Callable[..., tuple[Any, Any, Any]], session: str
+) -> None:
+ noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+ returncode, _, stderr = run_nox(f"--noxfile={noxfile}", f"--session={session}")
+ assert returncode != 0
+ assert "Prerequisite session r was not successful" in stderr
+
+
+@pytest.mark.parametrize("session", ["w", "u"])
+def test_main_requries_modern_param(
+ run_nox: Callable[..., tuple[Any, Any, Any]],
+ session: str,
+) -> None:
+ noxfile = os.path.join(RESOURCES, "noxfile_requires.py")
+ returncode, _, _stderr = run_nox(f"--noxfile={noxfile}", f"--session={session}")
+ assert returncode == 0
+
+
+def test_main_list_with_empty_parametrize(
+ run_nox: Callable[..., tuple[Any, Any, Any]],
+) -> None:
+ noxfile = os.path.join(RESOURCES, "noxfile_empty_parametrize.py")
+ returncode, stdout, _ = run_nox(f"--noxfile={noxfile}", "--list")
+ assert returncode == 0
+ assert "regular" in stdout
+ assert "no_params" in stdout
+
+
+def test_main_run_with_empty_parametrize(
+ run_nox: Callable[..., tuple[Any, Any, Any]],
+) -> None:
+ noxfile = os.path.join(RESOURCES, "noxfile_empty_parametrize.py")
+ returncode, stdout, stderr = run_nox(f"--noxfile={noxfile}")
+ assert returncode == 0
+ assert stdout == "regular\n"
+ assert "Session regular was successful." in stderr
+ assert "This session had no parameters available." in stderr
+
+
+def test_main_duplicate_session(
+ run_nox: Callable[..., tuple[Any, Any, Any]],
+) -> None:
+ noxfile = os.path.join(RESOURCES, "noxfile_duplicate_sessions.py")
+ msg = "The session 'foo' has already been registered"
+ with pytest.warns(FutureWarning, match=re.escape(msg)):
+ run_nox(f"--noxfile={noxfile}")
+
+
+def test_main_noxfile_options(
+ monkeypatch: pytest.MonkeyPatch, generate_noxfile_options: Callable[..., str]
+) -> None:
+ noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True)
monkeypatch.setattr(
sys,
"argv",
@@ -453,7 +677,7 @@ def test_main_noxfile_options(monkeypatch):
"-s",
"test",
"--noxfile",
- os.path.join(RESOURCES, "noxfile_options.py"),
+ noxfile_path,
],
)
@@ -461,16 +685,20 @@ def test_main_noxfile_options(monkeypatch):
honor_list_request.return_value = 0
with mock.patch("sys.exit"):
- nox.__main__.main()
+ nox.main()
assert honor_list_request.called
# Verify that the config looks correct.
config = honor_list_request.call_args[1]["global_config"]
assert config.reuse_existing_virtualenvs is True
+ assert config.reuse_venv == "yes"
-def test_main_noxfile_options_disabled_by_flag(monkeypatch):
+def test_main_noxfile_options_disabled_by_flag(
+ monkeypatch: pytest.MonkeyPatch, generate_noxfile_options: Callable[..., str]
+) -> None:
+ noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True)
monkeypatch.setattr(
sys,
"argv",
@@ -481,7 +709,7 @@ def test_main_noxfile_options_disabled_by_flag(monkeypatch):
"test",
"--no-reuse-existing-virtualenvs",
"--noxfile",
- os.path.join(RESOURCES, "noxfile_options.py"),
+ noxfile_path,
],
)
@@ -489,27 +717,31 @@ def test_main_noxfile_options_disabled_by_flag(monkeypatch):
honor_list_request.return_value = 0
with mock.patch("sys.exit"):
- nox.__main__.main()
+ nox.main()
assert honor_list_request.called
# Verify that the config looks correct.
config = honor_list_request.call_args[1]["global_config"]
assert config.reuse_existing_virtualenvs is False
+ assert config.reuse_venv == "no"
-def test_main_noxfile_options_sessions(monkeypatch):
+def test_main_noxfile_options_sessions(
+ monkeypatch: pytest.MonkeyPatch, generate_noxfile_options: Callable[..., str]
+) -> None:
+ noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True)
monkeypatch.setattr(
sys,
"argv",
- ["nox", "-l", "--noxfile", os.path.join(RESOURCES, "noxfile_options.py")],
+ ["nox", "-l", "--noxfile", noxfile_path],
)
with mock.patch("nox.tasks.honor_list_request") as honor_list_request:
honor_list_request.return_value = 0
with mock.patch("sys.exit"):
- nox.__main__.main()
+ nox.main()
assert honor_list_request.called
@@ -519,7 +751,7 @@ def test_main_noxfile_options_sessions(monkeypatch):
@pytest.fixture
-def generate_noxfile_options_pythons(tmp_path):
+def generate_noxfile_options_pythons(tmp_path: Path) -> Callable[[str, str, str], str]:
"""Generate noxfile.py with test and launch_rocket sessions.
The sessions are defined for both the default and alternate Python versions.
@@ -527,28 +759,32 @@ def generate_noxfile_options_pythons(tmp_path):
goes into ``nox.options.sessions`` and ``nox.options.pythons``, respectively.
"""
- def generate_noxfile(default_session, default_python, alternate_python):
+ def generate_noxfile(
+ default_session: str, default_python: str, alternate_python: str
+ ) -> str:
path = Path(RESOURCES) / "noxfile_options_pythons.py"
- text = path.read_text()
+ text = path.read_text(encoding="utf-8")
text = text.format(
default_session=default_session,
default_python=default_python,
alternate_python=alternate_python,
)
path = tmp_path / "noxfile.py"
- path.write_text(text)
+ path.write_text(text, encoding="utf-8")
return str(path)
return generate_noxfile
-python_current_version = "{}.{}".format(sys.version_info.major, sys.version_info.minor)
-python_next_version = "{}.{}".format(sys.version_info.major, sys.version_info.minor + 1)
+python_current_version = f"{sys.version_info.major}.{sys.version_info.minor}"
+python_next_version = f"{sys.version_info.major}.{sys.version_info.minor + 1}"
def test_main_noxfile_options_with_pythons_override(
- capsys, monkeypatch, generate_noxfile_options_pythons
-):
+ capsys: pytest.CaptureFixture[str],
+ monkeypatch: pytest.MonkeyPatch,
+ generate_noxfile_options_pythons: Callable[..., str],
+) -> None:
noxfile = generate_noxfile_options_pythons(
default_session="test",
default_python=python_next_version,
@@ -560,13 +796,13 @@ def test_main_noxfile_options_with_pythons_override(
)
with mock.patch("sys.exit") as sys_exit:
- nox.__main__.main()
+ nox.main()
_, stderr = capsys.readouterr()
sys_exit.assert_called_once_with(0)
for python_version in [python_current_version, python_next_version]:
for session in ["test", "launch_rocket"]:
- line = "Running session {}-{}".format(session, python_version)
+ line = f"Running session {session}-{python_version}"
if session == "test" and python_version == python_current_version:
assert line in stderr
else:
@@ -574,8 +810,10 @@ def test_main_noxfile_options_with_pythons_override(
def test_main_noxfile_options_with_sessions_override(
- capsys, monkeypatch, generate_noxfile_options_pythons
-):
+ capsys: pytest.CaptureFixture[str],
+ monkeypatch: pytest.MonkeyPatch,
+ generate_noxfile_options_pythons: Callable[..., str],
+) -> None:
noxfile = generate_noxfile_options_pythons(
default_session="test",
default_python=python_current_version,
@@ -587,13 +825,13 @@ def test_main_noxfile_options_with_sessions_override(
)
with mock.patch("sys.exit") as sys_exit:
- nox.__main__.main()
+ nox.main()
_, stderr = capsys.readouterr()
sys_exit.assert_called_once_with(0)
for python_version in [python_current_version, python_next_version]:
for session in ["test", "launch_rocket"]:
- line = "Running session {}-{}".format(session, python_version)
+ line = f"Running session {session}-{python_version}"
if session == "launch_rocket" and python_version == python_current_version:
assert line in stderr
else:
@@ -601,7 +839,10 @@ def test_main_noxfile_options_with_sessions_override(
@pytest.mark.parametrize(("isatty_value", "expected"), [(True, True), (False, False)])
-def test_main_color_from_isatty(monkeypatch, isatty_value, expected):
+def test_main_color_from_isatty(
+ monkeypatch: pytest.MonkeyPatch, isatty_value: bool, expected: bool
+) -> None:
+ monkeypatch.delenv("FORCE_COLOR", raising=False)
monkeypatch.setattr(sys, "argv", [sys.executable])
with mock.patch("nox.workflow.execute") as execute:
execute.return_value = 0
@@ -610,7 +851,7 @@ def test_main_color_from_isatty(monkeypatch, isatty_value, expected):
# Call the main function.
with mock.patch.object(sys, "exit"):
- nox.__main__.main()
+ nox.main()
config = execute.call_args[1]["global_config"]
assert config.color == expected
@@ -625,47 +866,349 @@ def test_main_color_from_isatty(monkeypatch, isatty_value, expected):
("--no-color", False),
],
)
-def test_main_color_options(monkeypatch, color_opt, expected):
+def test_main_color_options(
+ monkeypatch: pytest.MonkeyPatch, color_opt: str, expected: bool
+) -> None:
+ monkeypatch.delenv("FORCE_COLOR", raising=False)
monkeypatch.setattr(sys, "argv", [sys.executable, color_opt])
with mock.patch("nox.workflow.execute") as execute:
execute.return_value = 0
# Call the main function.
with mock.patch.object(sys, "exit"):
- nox.__main__.main()
+ nox.main()
config = execute.call_args[1]["global_config"]
assert config.color == expected
-def test_main_color_conflict(capsys, monkeypatch):
+@pytest.mark.parametrize(
+ "force_color_value",
+ ["0", "false", "False", "FALSE", "no", "No", "NO", "off", "Off", "OFF", ""],
+)
+def test_main_force_color_falsy(
+ monkeypatch: pytest.MonkeyPatch, force_color_value: str
+) -> None:
+ """FORCE_COLOR set to a falsey value should not enable force color."""
+ monkeypatch.setenv("FORCE_COLOR", force_color_value)
+ monkeypatch.delenv("NO_COLOR", raising=False)
+ monkeypatch.setattr(sys, "argv", [sys.executable])
+ with mock.patch("nox.workflow.execute") as execute:
+ execute.return_value = 0
+ with (
+ mock.patch("sys.stdout.isatty", return_value=False),
+ mock.patch.object(sys, "exit"),
+ ):
+ nox.main()
+ config = execute.call_args[1]["global_config"]
+ assert config.color is False
+
+
+def test_main_no_color_and_force_color_zero(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """NO_COLOR=1 and FORCE_COLOR=0 should not conflict (issue #1069)."""
+ monkeypatch.setenv("NO_COLOR", "1")
+ monkeypatch.setenv("FORCE_COLOR", "0")
+ monkeypatch.setattr(sys, "argv", [sys.executable])
+ with mock.patch("nox.workflow.execute") as execute:
+ execute.return_value = 0
+ with mock.patch.object(sys, "exit"):
+ nox.main()
+ config = execute.call_args[1]["global_config"]
+ assert config.color is False
+
+
+def test_main_color_conflict(
+ capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
+) -> None:
monkeypatch.setattr(sys, "argv", [sys.executable, "--forcecolor", "--nocolor"])
with mock.patch("nox.workflow.execute") as execute:
execute.return_value = 1
# Call the main function.
- with mock.patch.object(sys, "exit") as exit:
- nox.__main__.main()
- exit.assert_called_with(1)
+ with mock.patch.object(sys, "exit") as mock_exit:
+ nox.main()
+ mock_exit.assert_called_with(1)
_, err = capsys.readouterr()
assert "color" in err
-def test_main_force_python(monkeypatch):
- monkeypatch.setattr(sys, "argv", ["nox", "--force-python=3.10"])
+def test_main_force_python(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(sys, "argv", ["nox", "--force-python=3.11"])
with mock.patch("nox.workflow.execute", return_value=0) as execute:
with mock.patch.object(sys, "exit"):
- nox.__main__.main()
+ nox.main()
config = execute.call_args[1]["global_config"]
- assert config.pythons == config.extra_pythons == ["3.10"]
+ assert config.pythons == config.extra_pythons == ["3.11"]
-def test_main_reuse_existing_virtualenvs_no_install(monkeypatch):
+def test_main_reuse_existing_virtualenvs_no_install(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
monkeypatch.setattr(sys, "argv", ["nox", "-R"])
with mock.patch("nox.workflow.execute", return_value=0) as execute:
with mock.patch.object(sys, "exit"):
- nox.__main__.main()
+ nox.main()
config = execute.call_args[1]["global_config"]
- assert config.reuse_existing_virtualenvs and config.no_install
+ assert config.reuse_existing_virtualenvs
+ assert config.no_install
+ assert config.reuse_venv == "yes"
+
+
+@pytest.mark.parametrize(
+ ("should_set_ci_env_var", "noxfile_option_value", "expected_final_value"),
+ [
+ (True, None, True),
+ (True, True, True),
+ (True, False, False),
+ (False, None, False),
+ (False, True, True),
+ (False, False, False),
+ ],
+)
+def test_main_noxfile_options_with_ci_override(
+ monkeypatch: pytest.MonkeyPatch,
+ generate_noxfile_options: Callable[..., str],
+ should_set_ci_env_var: bool,
+ noxfile_option_value: bool | None,
+ expected_final_value: bool,
+) -> None:
+ monkeypatch.delenv("CI", raising=False) # make sure we have a clean environment
+ if should_set_ci_env_var:
+ monkeypatch.setenv("CI", "True")
+ # Reload nox.options taking into consideration monkeypatch.{delenv|setenv}
+ monkeypatch.setattr(
+ nox._options, "noxfile_options", nox._options.options.noxfile_namespace()
+ )
+ monkeypatch.setattr(nox, "options", nox._options.noxfile_options)
+
+ if noxfile_option_value is None:
+ noxfile_path = generate_noxfile_options()
+ else:
+ noxfile_path = generate_noxfile_options(
+ error_on_missing_interpreters=noxfile_option_value
+ )
+
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ ["nox", "-l", "--noxfile", str(noxfile_path)],
+ )
+
+ with mock.patch(
+ "nox.tasks.honor_list_request", return_value=0
+ ) as honor_list_request:
+ with mock.patch("sys.exit"):
+ nox.main()
+ config = honor_list_request.call_args[1]["global_config"]
+ assert config.error_on_missing_interpreters == expected_final_value
+
+
+@pytest.mark.parametrize(
+ "reuse_venv",
+ [
+ "yes",
+ "no",
+ "always",
+ "never",
+ ],
+)
+@pytest.mark.usefixtures("generate_noxfile_options")
+def test_main_reuse_venv_cli_flags(
+ monkeypatch: pytest.MonkeyPatch,
+ reuse_venv: Literal["yes", "no", "always", "never"],
+) -> None:
+ monkeypatch.setattr(sys, "argv", ["nox", "--reuse-venv", reuse_venv])
+ with mock.patch("nox.workflow.execute", return_value=0) as execute:
+ with mock.patch.object(sys, "exit"):
+ nox.main()
+ config = execute.call_args[1]["global_config"]
+ assert (
+ not config.reuse_existing_virtualenvs
+ ) # should remain unaffected in this case
+ assert config.reuse_venv == reuse_venv
+
+
+@pytest.mark.parametrize(
+ ("reuse_venv", "reuse_existing_virtualenvs", "expected"),
+ [
+ ("yes", None, "yes"),
+ ("yes", False, "yes"),
+ ("yes", True, "yes"),
+ ("yes", "--no-reuse-existing-virtualenvs", "no"),
+ ("yes", "--reuse-existing-virtualenvs", "yes"),
+ ("no", None, "no"),
+ ("no", False, "no"),
+ ("no", True, "yes"),
+ ("no", "--no-reuse-existing-virtualenvs", "no"),
+ ("no", "--reuse-existing-virtualenvs", "yes"),
+ ("always", None, "always"),
+ ("always", False, "always"),
+ ("always", True, "yes"),
+ ("always", "--no-reuse-existing-virtualenvs", "no"),
+ ("always", "--reuse-existing-virtualenvs", "yes"),
+ ("never", None, "never"),
+ ("never", False, "never"),
+ ("never", True, "yes"),
+ ("never", "--no-reuse-existing-virtualenvs", "no"),
+ ("never", "--reuse-existing-virtualenvs", "yes"),
+ ],
+)
+def test_main_noxfile_options_reuse_venv_compat_check(
+ monkeypatch: pytest.MonkeyPatch,
+ generate_noxfile_options: Callable[..., str],
+ reuse_venv: str,
+ reuse_existing_virtualenvs: str | bool | None,
+ expected: str,
+) -> None:
+ cmd_args = ["nox", "-l"]
+ # CLI Compat Check
+ if isinstance(reuse_existing_virtualenvs, str):
+ cmd_args += [reuse_existing_virtualenvs]
+
+ # Generate noxfile
+ if isinstance(reuse_existing_virtualenvs, bool):
+ # Non-CLI Compat Check
+ noxfile_path = generate_noxfile_options(
+ reuse_venv=reuse_venv, reuse_existing_virtualenvs=reuse_existing_virtualenvs
+ )
+ else:
+ noxfile_path = generate_noxfile_options(reuse_venv=reuse_venv)
+ cmd_args += ["--noxfile", str(noxfile_path)]
+
+ # Reset nox.options
+ monkeypatch.setattr(
+ nox._options, "noxfile_options", nox._options.options.noxfile_namespace()
+ )
+ monkeypatch.setattr(nox, "options", nox._options.noxfile_options)
+
+ # Execute
+ monkeypatch.setattr(sys, "argv", cmd_args)
+ with mock.patch(
+ "nox.tasks.honor_list_request", return_value=0
+ ) as honor_list_request:
+ with mock.patch("sys.exit"):
+ nox.main()
+ config = honor_list_request.call_args[1]["global_config"]
+ assert config.reuse_venv == expected
+
+
+def test_noxfile_options_cant_be_set() -> None:
+ with pytest.raises(AttributeError, match="reuse_venvs"):
+ nox.options.reuse_venvs = True # type: ignore[attr-defined]
+
+
+def test_noxfile_options_cant_be_set_long() -> None:
+ with pytest.raises(AttributeError, match="i_am_clearly_not_an_option"):
+ nox.options.i_am_clearly_not_an_option = True # type: ignore[attr-defined]
+
+
+def test_symlink_orig(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.chdir(Path(RESOURCES) / "orig_dir")
+ subprocess.run([sys.executable, "-m", "nox", "-s", "orig"], check=True)
+
+
+def test_symlink_orig_not(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.chdir(Path(RESOURCES) / "orig_dir")
+ res = subprocess.run([sys.executable, "-m", "nox", "-s", "sym"], check=False)
+ assert res.returncode == 1
+
+
+def test_symlink_sym(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.chdir(Path(RESOURCES) / "sym_dir")
+ subprocess.run([sys.executable, "-m", "nox", "-s", "sym"], check=True)
+
+
+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(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("NOX_SCRIPT_MODE", raising=False)
+ job = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "nox",
+ "-f",
+ Path(RESOURCES) / "noxfile_script_mode.py",
+ "-s",
+ "example",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+ print(job.stdout)
+ print(job.stderr)
+ assert job.returncode == 0
+ assert "hello_world" in job.stdout
+
+
+def test_noxfile_no_script_mode(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("NOX_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,
+ encoding="utf-8",
+ )
+ assert job.returncode == 1
+ assert "No module named 'cowsay'" in job.stderr
+
+
+def test_noxfile_script_mode_exec(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("NOX_SCRIPT_MODE", raising=False)
+ job = subprocess.run(
+ [
+ sys.executable,
+ Path(RESOURCES) / "noxfile_script_mode_exec.py",
+ "-s",
+ "exec_example",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+ print(job.stdout)
+ print(job.stderr)
+ assert job.returncode == 0
+ assert "another_world" in job.stdout
+
+
+def test_noxfile_script_mode_url_req() -> None:
+ job = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "nox",
+ "-f",
+ Path(RESOURCES) / "noxfile_script_mode_url_req.py",
+ "-s",
+ "example",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+ print(job.stdout)
+ print(job.stderr)
+ assert job.returncode == 0
+ assert job.stdout.rstrip() == "2024.10.9"
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index 4c2cf151..ac142562 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -12,7 +12,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import collections
+from __future__ import annotations
+
+import typing
+from collections.abc import Sequence
+from typing import TYPE_CHECKING, Any
from unittest import mock
import pytest
@@ -28,40 +32,49 @@
_null_session_func,
)
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
-def create_mock_sessions():
- sessions = collections.OrderedDict()
- sessions["foo"] = mock.Mock(spec=(), python=None, venv_backend=None)
- sessions["bar"] = mock.Mock(spec=(), python=None, venv_backend=None)
+def create_mock_sessions() -> dict[str, mock.Mock]:
+ sessions = {}
+ sessions["foo"] = mock.Mock(spec=(), python=None, venv_backend=None, tags=["baz"])
+ sessions["bar"] = mock.Mock(
+ spec=(),
+ python=None,
+ venv_backend=None,
+ tags=["baz", "qux"],
+ )
return sessions
-def create_mock_config():
+def create_mock_config() -> Any:
cfg = mock.sentinel.MOCKED_CONFIG
cfg.force_venv_backend = None
cfg.default_venv_backend = None
cfg.extra_pythons = None
+ cfg.force_pythons = None
cfg.posargs = []
return cfg
-def test__normalize_arg():
+def test__normalize_arg() -> None:
assert _normalize_arg('test(foo="bar")') == _normalize_arg('test(foo="bar")')
- # In the case of SyntaxError it should fallback to strng
+ # In the case of SyntaxError it should fallback to string
assert (
_normalize_arg("datetime.datetime(1990; 2, 18),")
== "datetime.datetime(1990; 2, 18),"
)
-def test__normalized_session_match():
+def test__normalized_session_match() -> None:
session_mock = mock.MagicMock()
session_mock.signatures = ['test(foo="bar")']
assert _normalized_session_match("test(foo='bar')", session_mock)
-def test_init():
+def test_init() -> None:
sessions = create_mock_sessions()
manifest = Manifest(sessions, create_mock_config())
@@ -71,7 +84,7 @@ def test_init():
assert manifest["bar"].func is sessions["bar"]
-def test_contains():
+def test_contains() -> None:
sessions = create_mock_sessions()
manifest = Manifest(sessions, create_mock_config())
@@ -81,7 +94,7 @@ def test_contains():
assert "baz" not in manifest
# Establish that __contains__ works post-iteration.
- for session in manifest:
+ for _session in manifest:
pass
assert "foo" in manifest
assert "bar" in manifest
@@ -91,7 +104,7 @@ def test_contains():
assert manifest["foo"] in manifest
-def test_getitem():
+def test_getitem() -> None:
sessions = create_mock_sessions()
manifest = Manifest(sessions, create_mock_config())
@@ -104,13 +117,13 @@ def test_getitem():
# Establish that the sessions are still present even after being
# consumed by iteration.
- for session in manifest:
+ for _session in manifest:
pass
assert manifest["foo"].func is sessions["foo"]
assert manifest["bar"].func is sessions["bar"]
-def test_iteration():
+def test_iteration() -> None:
sessions = create_mock_sessions()
manifest = Manifest(sessions, create_mock_config())
@@ -126,9 +139,8 @@ def test_iteration():
assert len(manifest._consumed) == 1
assert len(manifest._queue) == 1
- # The .next() or .__next__() methods can be called directly according
- # to Python's data model.
- bar = manifest.next()
+ # The second item should be our "bar" session.
+ bar = next(manifest)
assert bar.func == sessions["bar"]
assert bar in manifest._consumed
assert bar not in manifest._queue
@@ -137,18 +149,18 @@ def test_iteration():
# Continuing past the end raises StopIteration.
with pytest.raises(StopIteration):
- manifest.__next__()
+ next(manifest)
-def test_len():
+def test_len() -> None:
sessions = create_mock_sessions()
manifest = Manifest(sessions, create_mock_config())
assert len(manifest) == 2
- for session in manifest:
+ for _session in manifest:
assert len(manifest) == 2
-def test_filter_by_name():
+def test_filter_by_name() -> None:
sessions = create_mock_sessions()
manifest = Manifest(sessions, create_mock_config())
manifest.filter_by_name(("foo",))
@@ -156,21 +168,21 @@ def test_filter_by_name():
assert "bar" not in manifest
-def test_filter_by_name_maintains_order():
+def test_filter_by_name_maintains_order() -> None:
sessions = create_mock_sessions()
manifest = Manifest(sessions, create_mock_config())
manifest.filter_by_name(("bar", "foo"))
assert [session.name for session in manifest] == ["bar", "foo"]
-def test_filter_by_name_not_found():
+def test_filter_by_name_not_found() -> None:
sessions = create_mock_sessions()
manifest = Manifest(sessions, create_mock_config())
with pytest.raises(KeyError):
manifest.filter_by_name(("baz",))
-def test_filter_by_python_interpreter():
+def test_filter_by_python_interpreter() -> None:
sessions = create_mock_sessions()
manifest = Manifest(sessions, create_mock_config())
manifest["foo"].func.python = "3.8"
@@ -180,7 +192,7 @@ def test_filter_by_python_interpreter():
assert "bar" not in manifest
-def test_filter_by_keyword():
+def test_filter_by_keyword() -> None:
sessions = create_mock_sessions()
manifest = Manifest(sessions, create_mock_config())
assert len(manifest) == 2
@@ -188,9 +200,30 @@ def test_filter_by_keyword():
assert len(manifest) == 2
manifest.filter_by_keywords("foo")
assert len(manifest) == 1
+ # Match tags
+ manifest.filter_by_keywords("not baz")
+ assert len(manifest) == 0
+
+
+@pytest.mark.parametrize(
+ ("tags", "session_count"),
+ [
+ (["baz", "qux"], 2),
+ (["baz"], 2),
+ (["qux"], 1),
+ (["missing"], 0),
+ (["baz", "missing"], 2),
+ ],
+)
+def test_filter_by_tags(tags: Sequence[str], session_count: int) -> None:
+ sessions = create_mock_sessions()
+ manifest = Manifest(sessions, create_mock_config())
+ assert len(manifest) == 2
+ manifest.filter_by_tags(tags)
+ assert len(manifest) == session_count
-def test_list_all_sessions_with_filter():
+def test_list_all_sessions_with_filter() -> None:
sessions = create_mock_sessions()
manifest = Manifest(sessions, create_mock_config())
assert len(manifest) == 2
@@ -203,7 +236,7 @@ def test_list_all_sessions_with_filter():
assert all_sessions[1][1] is False
-def test_add_session_plain():
+def test_add_session_plain() -> None:
manifest = Manifest({}, create_mock_config())
session_func = mock.Mock(spec=(), python=None, venv_backend=None)
for session in manifest.make_session("my_session", session_func):
@@ -211,10 +244,10 @@ def test_add_session_plain():
assert len(manifest) == 1
-def test_add_session_multiple_pythons():
+def test_add_session_multiple_pythons() -> None:
manifest = Manifest({}, create_mock_config())
- def session_func():
+ def session_func() -> None:
pass
func = Func(session_func, python=["3.5", "3.6"])
@@ -225,7 +258,7 @@ def session_func():
@pytest.mark.parametrize(
- "python,extra_pythons,expected",
+ ("python", "extra_pythons", "expected"),
[
(None, [], [None]),
(None, ["3.8"], [None]),
@@ -242,13 +275,56 @@ def session_func():
(["3.5", "3.9"], ["3.5", "3.9"], ["3.5", "3.9"]),
],
)
-def test_extra_pythons(python, extra_pythons, expected):
+def test_extra_pythons(
+ python: list[str] | str | bool | None,
+ extra_pythons: list[str],
+ expected: list[None] | list[bool] | list[str],
+) -> None:
cfg = create_mock_config()
cfg.extra_pythons = extra_pythons
manifest = Manifest({}, cfg)
- def session_func():
+ def session_func() -> None:
+ pass
+
+ func = Func(session_func, python=python)
+ for session in manifest.make_session("my_session", func):
+ manifest.add_session(session)
+
+ assert expected == [session.func.python for session in manifest._all_sessions]
+
+
+@pytest.mark.parametrize(
+ ("python", "force_pythons", "expected"),
+ [
+ (None, [], [None]),
+ (None, ["3.8"], ["3.8"]),
+ (None, ["3.8", "3.9"], ["3.8", "3.9"]),
+ (False, [], [False]),
+ (False, ["3.8"], ["3.8"]),
+ (False, ["3.8", "3.9"], ["3.8", "3.9"]),
+ ("3.5", [], ["3.5"]),
+ ("3.5", ["3.8"], ["3.5", "3.8"]),
+ ("3.5", ["3.8", "3.9"], ["3.5", "3.8", "3.9"]),
+ (["3.5", "3.9"], [], ["3.5", "3.9"]),
+ (["3.5", "3.9"], ["3.8"], ["3.5", "3.9", "3.8"]),
+ (["3.5", "3.9"], ["3.8", "3.4"], ["3.5", "3.9", "3.8", "3.4"]),
+ (["3.5", "3.9"], ["3.5", "3.9"], ["3.5", "3.9"]),
+ ],
+)
+def test_force_pythons(
+ python: list[str] | str | bool | None,
+ force_pythons: list[str],
+ expected: list[None] | list[bool] | list[str],
+) -> None:
+ cfg = create_mock_config()
+ cfg.force_pythons = force_pythons
+ cfg.extra_pythons = force_pythons
+
+ manifest = Manifest({}, cfg)
+
+ def session_func() -> None:
pass
func = Func(session_func, python=python)
@@ -258,12 +334,12 @@ def session_func():
assert expected == [session.func.python for session in manifest._all_sessions]
-def test_add_session_parametrized():
+def test_add_session_parametrized() -> None:
manifest = Manifest({}, create_mock_config())
# Define a session with parameters.
@nox.parametrize("param", ("a", "b", "c"))
- def my_session(session, param):
+ def my_session(session: nox.Session, param: str) -> None:
pass
func = Func(my_session, python=None)
@@ -274,12 +350,12 @@ def my_session(session, param):
assert len(manifest) == 3
-def test_add_session_parametrized_multiple_pythons():
+def test_add_session_parametrized_multiple_pythons() -> None:
manifest = Manifest({}, create_mock_config())
# Define a session with parameters.
@nox.parametrize("param", ("a", "b"))
- def my_session(session, param):
+ def my_session(session: nox.Session, param: str) -> None:
pass
func = Func(my_session, python=["2.7", "3.6"])
@@ -290,12 +366,12 @@ def my_session(session, param):
assert len(manifest) == 4
-def test_add_session_parametrized_noop():
+def test_add_session_parametrized_noop() -> None:
manifest = Manifest({}, create_mock_config())
# Define a session without any parameters.
@nox.parametrize("param", ())
- def my_session(session, param):
+ def my_session(session: nox.Session, param: object) -> None:
pass
my_session.python = None
@@ -311,19 +387,45 @@ def my_session(session, param):
assert session.func == _null_session_func
-def test_notify():
+def test_add_dependencies_with_empty_parametrize() -> None:
+ manifest = Manifest({}, create_mock_config())
+
+ # Define a session without any parameters available.
+ @nox.parametrize("param", ())
+ def empty(session: nox.Session, param: object) -> None:
+ pass
+
+ # Add the placeholder session and a regular session to the manifest.
+ for session in manifest.make_session("empty", Func(empty, python=None)):
+ manifest.add_session(session)
+ for session in manifest.make_session("regular", Func(lambda _: None)):
+ manifest.add_session(session)
+ assert len(manifest) == 2
+
+ # The placeholder session has no signatures, but dependency resolution
+ # must still account for it.
+ manifest.add_dependencies()
+
+ assert [session.name for session in manifest._queue] == ["empty", "regular"]
+
+
+def test_notify() -> None:
manifest = Manifest({}, create_mock_config())
# Define a session.
- def my_session(session):
+ def my_session_raw(session: nox.Session) -> None:
pass
+ my_session = typing.cast("Func", my_session_raw)
+
my_session.python = None
my_session.venv_backend = None
- def notified(session):
+ def notified_raw(session: nox.Session) -> None:
pass
+ notified = typing.cast("Func", notified_raw)
+
notified.python = None
notified.venv_backend = None
@@ -343,13 +445,15 @@ def notified(session):
assert len(manifest) == 2
-def test_notify_noop():
+def test_notify_noop() -> None:
manifest = Manifest({}, create_mock_config())
# Define a session and add it to the manifest.
- def my_session(session):
+ def my_session_raw(session: nox.Session) -> None:
pass
+ my_session = typing.cast("Func", my_session_raw)
+
my_session.python = None
my_session.venv_backend = None
@@ -363,11 +467,11 @@ def my_session(session):
assert len(manifest) == 1
-def test_notify_with_posargs():
+def test_notify_with_posargs() -> None:
cfg = create_mock_config()
manifest = Manifest({}, cfg)
- session = manifest.make_session("my_session", Func(lambda session: None))[0]
+ session = manifest.make_session("my_session", Func(lambda _: None))[0]
manifest.add_session(session)
# delete my_session from the queue
@@ -378,13 +482,13 @@ def test_notify_with_posargs():
assert session.posargs == ["--an-arg"]
-def test_notify_error():
+def test_notify_error() -> None:
manifest = Manifest({}, create_mock_config())
- with pytest.raises(ValueError):
+ with pytest.raises(ValueError, match="Session does_not_exist not found"):
manifest.notify("does_not_exist")
-def test_add_session_idempotent():
+def test_add_session_idempotent() -> None:
manifest = Manifest({}, create_mock_config())
session_func = mock.Mock(spec=(), python=None, venv_backend=None)
for session in manifest.make_session("my_session", session_func):
@@ -393,34 +497,36 @@ def test_add_session_idempotent():
assert len(manifest) == 1
-def test_null_session_function():
+def test_null_session_function() -> None:
session = mock.Mock(spec=("skip",))
_null_session_func(session)
assert session.skip.called
-def test_keyword_locals_length():
+def test_keyword_locals_length() -> None:
kw = KeywordLocals({"foo", "bar"})
assert len(kw) == 2
-def test_keyword_locals_iter():
- values = ["foo", "bar"]
+def test_keyword_locals_iter() -> None:
+ values = ["bar", "foo"]
kw = KeywordLocals(values)
- assert list(kw) == values
+ assert sorted(kw) == values
-def test_no_venv_backend_but_some_pythons():
+def test_no_venv_backend_but_some_pythons() -> None:
manifest = Manifest({}, create_mock_config())
# Define a session and add it to the manifest.
- def my_session(session):
+ def my_session_raw(session: nox.Session) -> None:
pass
+ my_session = typing.cast("Func", my_session_raw)
+
# the session sets "no venv backend" but declares some pythons
my_session.python = ["3.7", "3.8"]
my_session.venv_backend = "none"
- my_session.should_warn = dict()
+ my_session.should_warn = {}
sessions = manifest.make_session("my_session", my_session)
diff --git a/tests/test_project.py b/tests/test_project.py
new file mode 100644
index 00000000..e282038c
--- /dev/null
+++ b/tests/test_project.py
@@ -0,0 +1,100 @@
+import re
+
+import pytest
+
+from nox.project import dependency_groups, python_versions
+
+
+def test_classifiers() -> None:
+ pyproject = {
+ "project": {
+ "classifiers": [
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3 :: Only",
+ "Topic :: Software Development :: Testing",
+ ],
+ "requires-python": ">=3.10",
+ }
+ }
+
+ assert python_versions(pyproject) == ["3.10", "3.11", "3.12"]
+
+
+def test_no_classifiers() -> None:
+ pyproject = {"project": {"requires-python": ">=3.10"}}
+ with pytest.raises(ValueError, match="No Python version classifiers"):
+ python_versions(pyproject)
+
+
+def test_no_requires_python() -> None:
+ pyproject = {"project": {"classifiers": ["Programming Language :: Python :: 3.12"]}}
+ with pytest.raises(
+ ValueError, match=re.escape('No "project.requires-python" value set')
+ ):
+ python_versions(pyproject, max_version="3.13")
+
+
+def test_python_range() -> None:
+ pyproject = {
+ "project": {
+ "classifiers": [
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3 :: Only",
+ "Topic :: Software Development :: Testing",
+ ],
+ "requires-python": ">=3.10",
+ }
+ }
+
+ assert python_versions(pyproject, max_version="3.12") == ["3.10", "3.11", "3.12"]
+ assert python_versions(pyproject, max_version="3.11") == ["3.10", "3.11"]
+
+
+def test_python_range_gt() -> None:
+ pyproject = {"project": {"requires-python": ">3.2.1,<3.3"}}
+
+ assert python_versions(pyproject, max_version="3.4") == ["3.2", "3.3", "3.4"]
+
+
+def test_python_range_no_min() -> None:
+ pyproject = {"project": {"requires-python": "==3.3.1"}}
+
+ with pytest.raises(ValueError, match="No minimum version found"):
+ python_versions(pyproject, max_version="3.5")
+
+
+def test_dependency_groups() -> None:
+ example = {
+ "dependency-groups": {
+ "test": ["pytest", "coverage"],
+ "docs": ["sphinx", "sphinx-rtd-theme"],
+ "typing": ["mypy", "types-requests"],
+ "typing-test": [
+ {"include-group": "typing"},
+ {"include-group": "test"},
+ "useful-types",
+ ],
+ }
+ }
+
+ assert dependency_groups(example, "test") == ("pytest", "coverage")
+ assert dependency_groups(example, "typing-test") == (
+ "mypy",
+ "types-requests",
+ "pytest",
+ "coverage",
+ "useful-types",
+ )
+ assert dependency_groups(example, "typing_test") == (
+ "mypy",
+ "types-requests",
+ "pytest",
+ "coverage",
+ "useful-types",
+ )
diff --git a/tests/test_registry.py b/tests/test_registry.py
index 3c36f9a1..07779aec 100644
--- a/tests/test_registry.py
+++ b/tests/test_registry.py
@@ -12,13 +12,21 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Literal
+
import pytest
+import nox
from nox import registry
+if TYPE_CHECKING:
+ from collections.abc import Generator
-@pytest.fixture
-def cleanup_registry():
+
+@pytest.fixture(autouse=True)
+def cleanup_registry() -> Generator[None, None, None]:
"""Ensure that the session registry is completely empty before and
after each test.
"""
@@ -29,11 +37,11 @@ def cleanup_registry():
registry._REGISTRY.clear()
-def test_session_decorator(cleanup_registry):
+def test_session_decorator() -> None:
# Establish that the use of the session decorator will cause the
# function to be found in the registry.
@registry.session_decorator
- def unit_tests(session):
+ def unit_tests(session: nox.Session) -> None:
pass
answer = registry.get()
@@ -42,50 +50,60 @@ def unit_tests(session):
assert unit_tests.python is None
-def test_session_decorator_single_python(cleanup_registry):
+def test_session_decorator_single_python() -> None:
@registry.session_decorator(python="3.6")
- def unit_tests(session):
+ def unit_tests(session: nox.Session) -> None:
pass
assert unit_tests.python == "3.6"
-def test_session_decorator_list_of_pythons(cleanup_registry):
+def test_session_decorator_list_of_pythons() -> None:
@registry.session_decorator(python=["3.5", "3.6"])
- def unit_tests(session):
+ def unit_tests(session: nox.Session) -> None:
pass
assert unit_tests.python == ["3.5", "3.6"]
-def test_session_decorator_py_alias(cleanup_registry):
+def test_session_decorator_tags() -> None:
+ @registry.session_decorator(tags=["tag-1", "tag-2"])
+ def unit_tests(session: nox.Session) -> None:
+ pass
+
+ assert unit_tests.tags == ["tag-1", "tag-2"]
+
+
+def test_session_decorator_py_alias() -> None:
@registry.session_decorator(py=["3.5", "3.6"])
- def unit_tests(session):
+ def unit_tests(session: nox.Session) -> None:
pass
assert unit_tests.python == ["3.5", "3.6"]
-def test_session_decorator_py_alias_error(cleanup_registry):
+def test_session_decorator_py_alias_error() -> None:
with pytest.raises(ValueError, match="argument"):
@registry.session_decorator(python=["3.5", "3.6"], py="2.7")
- def unit_tests(session):
+ def unit_tests(session: nox.Session) -> None:
pass
-def test_session_decorator_reuse(cleanup_registry):
+def test_session_decorator_reuse() -> None:
@registry.session_decorator(reuse_venv=True)
- def unit_tests(session):
+ def unit_tests(session: nox.Session) -> None:
pass
assert unit_tests.reuse_venv is True
@pytest.mark.parametrize("name", ["unit-tests", "unit tests", "the unit tests"])
-def test_session_decorator_name(cleanup_registry, name):
+def test_session_decorator_name(
+ name: Literal["unit-tests", "unit tests", "the unit tests"],
+) -> None:
@registry.session_decorator(name=name)
- def unit_tests(session):
+ def unit_tests(session: nox.Session) -> None:
pass
answer = registry.get()
@@ -95,7 +113,7 @@ def unit_tests(session):
assert unit_tests.python is None
-def test_get(cleanup_registry):
+def test_get() -> None:
# Establish that the get method returns a copy of the registry.
empty = registry.get()
assert empty == registry._REGISTRY
@@ -103,11 +121,11 @@ def test_get(cleanup_registry):
assert len(empty) == 0
@registry.session_decorator
- def unit_tests(session):
+ def unit_tests(session: nox.Session) -> None:
pass
@registry.session_decorator
- def system_tests(session):
+ def system_tests(session: nox.Session) -> None:
pass
full = registry.get()
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
index 89df734e..f178b3bd 100644
--- a/tests/test_sessions.py
+++ b/tests/test_sessions.py
@@ -12,31 +12,73 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
import argparse
import logging
import operator
import os
+import re
+import shutil
+import subprocess
import sys
import tempfile
+import typing
from pathlib import Path
+from typing import Any, Literal, NoReturn
from unittest import mock
import pytest
+import nox._decorators
import nox.command
import nox.manifest
+import nox.popen
import nox.registry
import nox.sessions
import nox.virtualenv
from nox import _options
from nox.logger import logger
-
-def test__normalize_path():
+DIR = Path(__file__).parent.resolve()
+
+
+def run_with_defaults(**kwargs: Any) -> dict[str, Any]:
+ return {
+ "env": None,
+ "silent": False,
+ "paths": None,
+ "success_codes": None,
+ "log": True,
+ "external": False,
+ "stdout": None,
+ "stderr": subprocess.STDOUT,
+ "interrupt_timeout": nox.popen.DEFAULT_INTERRUPT_TIMEOUT,
+ "terminate_timeout": nox.popen.DEFAULT_TERMINATE_TIMEOUT,
+ **kwargs,
+ }
+
+
+def _run_with_defaults(**kwargs: Any) -> dict[str, Any]:
+ return {
+ "env": None,
+ "include_outer_env": True,
+ "silent": False,
+ "success_codes": None,
+ "log": True,
+ "external": False,
+ "stdout": None,
+ "stderr": subprocess.STDOUT,
+ "interrupt_timeout": nox.popen.DEFAULT_INTERRUPT_TIMEOUT,
+ "terminate_timeout": nox.popen.DEFAULT_TERMINATE_TIMEOUT,
+ **kwargs,
+ }
+
+
+def test__normalize_path() -> None:
envdir = "envdir"
normalize = nox.sessions._normalize_path
assert normalize(envdir, "hello") == os.path.join("envdir", "hello")
- assert normalize(envdir, b"hello") == os.path.join("envdir", "hello")
assert normalize(envdir, "hello(world)") == os.path.join("envdir", "hello-world")
assert normalize(envdir, "hello(world, meep)") == os.path.join(
"envdir", "hello-world-meep"
@@ -46,76 +88,127 @@ def test__normalize_path():
)
-def test__normalize_path_hash():
+def test__normalize_path_hash() -> None:
envdir = "d" * (100 - len("bin/pythonX.Y") - 10)
norm_path = nox.sessions._normalize_path(envdir, "a-really-long-virtualenv-path")
assert "a-really-long-virtualenv-path" not in norm_path
assert len(norm_path) < 100
-def test__normalize_path_give_up():
+def test__normalize_path_give_up() -> None:
envdir = "d" * 100
norm_path = nox.sessions._normalize_path(envdir, "any-path")
assert "any-path" in norm_path
+def test__normalize_path_non_ascii() -> None:
+ envdir = "envdir"
+ normalize = nox.sessions._normalize_path
+
+ norm_path = normalize(envdir, "测试")
+ # The result must be a subdirectory of envdir, not envdir itself.
+ assert os.path.dirname(norm_path) == envdir
+ assert os.path.basename(norm_path)
+ # The result must be stable across calls.
+ assert normalize(envdir, "测试") == norm_path
+ # Two different non-ASCII names must not collide.
+ assert normalize(envdir, "тест") != norm_path
+
+
+class FakeEnv(mock.MagicMock):
+ _get_env = nox.virtualenv.VirtualEnv._get_env
+
+
+def make_fake_env(venv_backend: str = "venv", **kwargs: Any) -> FakeEnv:
+ return FakeEnv(
+ spec=nox.virtualenv.VirtualEnv,
+ env={},
+ venv_backend=venv_backend,
+ **kwargs,
+ )
+
+
class TestSession:
- def make_session_and_runner(self):
+ def make_session_and_runner(
+ self,
+ ) -> tuple[nox.sessions.Session, nox.sessions.SessionRunner]:
func = mock.Mock(spec=["python"], python="3.7")
runner = nox.sessions.SessionRunner(
name="test",
signatures=["test"],
func=func,
global_config=_options.options.namespace(
- posargs=[], error_on_external_run=False, install_only=False
+ posargs=[],
+ error_on_external_run=False,
+ install_only=False,
+ invoked_from=os.getcwd(),
),
manifest=mock.create_autospec(nox.manifest.Manifest),
)
- runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
- runner.venv.env = {}
- runner.venv.bin_paths = ["/no/bin/for/you"]
+ runner.venv = make_fake_env(bin_paths=["/no/bin/for/you"])
+ assert isinstance(runner.venv, nox.virtualenv.VirtualEnv)
return nox.sessions.Session(runner=runner), runner
- def test_create_tmp(self):
+ def test_create_tmp(self) -> None:
session, runner = self.make_session_and_runner()
with tempfile.TemporaryDirectory() as root:
runner.global_config.envdir = root
tmpdir = session.create_tmp()
- assert session.env["TMPDIR"] == tmpdir
+ assert session.env["TMPDIR"] == os.path.abspath(tmpdir)
assert tmpdir.startswith(root)
- def test_create_tmp_twice(self):
+ def test_create_tmp_twice(self) -> None:
session, runner = self.make_session_and_runner()
with tempfile.TemporaryDirectory() as root:
runner.global_config.envdir = root
- runner.venv.bin = bin
+ assert runner.venv
+ runner.venv.bin = bin # type: ignore[misc, assignment]
session.create_tmp()
tmpdir = session.create_tmp()
- assert session.env["TMPDIR"] == tmpdir
+ assert session.env["TMPDIR"] == os.path.abspath(tmpdir)
assert tmpdir.startswith(root)
- def test_properties(self):
+ def test_properties(self) -> None:
+ session, runner = self.make_session_and_runner()
+ with tempfile.TemporaryDirectory() as root:
+ runner.global_config.envdir = root
+
+ assert session.name is runner.friendly_name
+ assert runner.venv
+ assert session.env is runner.venv.env
+ assert session.posargs == runner.global_config.posargs
+ assert session.virtualenv is runner.venv
+ assert runner.venv.bin_paths
+ assert session.bin_paths is runner.venv.bin_paths
+ assert session.bin is runner.venv.bin_paths[0]
+ assert session.python is runner.func.python
+ assert session.invoked_from is runner.global_config.invoked_from
+ assert session.cache_dir == Path(runner.global_config.envdir).joinpath(
+ ".cache"
+ )
+
+ def test_cache_dir_missing_envdir(self) -> None:
session, runner = self.make_session_and_runner()
+ with tempfile.TemporaryDirectory() as root:
+ runner.global_config.envdir = os.path.join(root, "missing", ".nox")
+
+ cache_dir = session.cache_dir
- assert session.name is runner.friendly_name
- assert session.env is runner.venv.env
- assert session.posargs == runner.global_config.posargs
- assert session.virtualenv is runner.venv
- assert session.bin_paths is runner.venv.bin_paths
- assert session.bin is runner.venv.bin_paths[0]
- assert session.python is runner.func.python
+ assert cache_dir == Path(runner.global_config.envdir).joinpath(".cache")
+ assert cache_dir.is_dir()
- def test_no_bin_paths(self):
+ def test_no_bin_paths(self) -> None:
session, runner = self.make_session_and_runner()
- runner.venv.bin_paths = None
+ assert runner.venv
+ runner.venv.bin_paths = None # type: ignore[misc]
with pytest.raises(
ValueError, match=r"^The environment does not have a bin directory\.$"
):
- session.bin
+ session.bin # noqa: B018
assert session.bin_paths is None
- def test_virtualenv_as_none(self):
+ def test_virtualenv_as_none(self) -> None:
session, runner = self.make_session_and_runner()
runner.venv = None
@@ -123,9 +216,18 @@ def test_virtualenv_as_none(self):
with pytest.raises(ValueError, match="virtualenv"):
_ = session.virtualenv
- def test_interactive(self):
+ assert session.venv_backend == "none"
+
+ def test_virtualenv_directory(self) -> None:
session, runner = self.make_session_and_runner()
+ with tempfile.TemporaryDirectory() as root:
+ runner.global_config.envdir = root
+ assert session.env_dir == Path(runner.envdir)
+
+ def test_interactive(self) -> None:
+ session, _runner = self.make_session_and_runner()
+
with mock.patch("nox.sessions.sys.stdin.isatty") as m_isatty:
m_isatty.return_value = True
@@ -135,7 +237,7 @@ def test_interactive(self):
assert session.interactive is False
- def test_explicit_non_interactive(self):
+ def test_explicit_non_interactive(self) -> None:
session, runner = self.make_session_and_runner()
with mock.patch("nox.sessions.sys.stdin.isatty") as m_isatty:
@@ -144,49 +246,78 @@ def test_explicit_non_interactive(self):
assert session.interactive is False
- def test_chdir(self, tmpdir):
- cdto = str(tmpdir.join("cdbby").ensure(dir=True))
+ def test_chdir(self, tmp_path: Path) -> None:
+ cdbby = tmp_path / "cdbby"
+ cdbby.mkdir()
current_cwd = os.getcwd()
session, _ = self.make_session_and_runner()
- session.chdir(cdto)
+ session.chdir(str(cdbby))
- assert os.getcwd() == cdto
+ assert cdbby.samefile(".")
os.chdir(current_cwd)
- def test_chdir_pathlib(self, tmpdir):
- cdto = str(tmpdir.join("cdbby").ensure(dir=True))
- current_cwd = os.getcwd()
+ def test_chdir_ctx(self, tmp_path: Path) -> None:
+ cdbby = tmp_path / "cdbby"
+ cdbby.mkdir()
+ current_cwd = Path.cwd().resolve()
+
+ session, _ = self.make_session_and_runner()
+
+ with session.chdir(cdbby):
+ assert cdbby.samefile(".")
+
+ assert current_cwd.samefile(".")
+
+ os.chdir(current_cwd)
+
+ def test_invoked_from(self, tmp_path: Path) -> None:
+ cdbby = tmp_path / "cdbby"
+ cdbby.mkdir()
+ current_cwd = Path.cwd().resolve()
+
+ session, _ = self.make_session_and_runner()
+
+ session.chdir(cdbby)
+
+ assert current_cwd.samefile(session.invoked_from)
+ os.chdir(current_cwd)
+
+ def test_chdir_pathlib(self, tmp_path: Path) -> None:
+ cdbby = tmp_path / "cdbby"
+ cdbby.mkdir()
+ current_cwd = Path.cwd().resolve()
session, _ = self.make_session_and_runner()
- session.chdir(Path(cdto))
+ session.chdir(cdbby)
- assert os.getcwd() == cdto
+ assert cdbby.samefile(".")
os.chdir(current_cwd)
- def test_run_bad_args(self):
+ def test_run_bad_args(self) -> None:
session, _ = self.make_session_and_runner()
with pytest.raises(ValueError, match="arg"):
session.run()
- def test_run_with_func(self):
+ def test_run_with_func(self) -> None:
session, _ = self.make_session_and_runner()
- assert session.run(operator.add, 1, 2) == 3
+ assert session.run(operator.add, 1, 2) == 3 # type: ignore[call-overload]
- def test_run_with_func_error(self):
+ def test_run_with_func_error(self) -> None:
session, _ = self.make_session_and_runner()
- def raise_value_error():
- raise ValueError("meep")
+ def raise_value_error() -> NoReturn:
+ msg = "meep"
+ raise ValueError(msg)
with pytest.raises(nox.command.CommandFailed):
- assert session.run(raise_value_error)
+ assert session.run(raise_value_error) # type: ignore[call-overload]
- def test_run_install_only(self, caplog):
+ def test_run_install_only(self, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.INFO)
session, runner = self.make_session_and_runner()
runner.global_config.install_only = True
@@ -198,7 +329,7 @@ def test_run_install_only(self, caplog):
assert "install-only" in caplog.text
- def test_run_install_only_should_install(self):
+ def test_run_install_only_should_install(self) -> None:
session, runner = self.make_session_and_runner()
runner.global_config.install_only = True
@@ -206,71 +337,142 @@ def test_run_install_only_should_install(self):
session.install("spam")
session.run("spam", "eggs")
+ env = dict(os.environ)
+ env["PATH"] = os.pathsep.join(["/no/bin/for/you", env["PATH"]])
+
run.assert_called_once_with(
("python", "-m", "pip", "install", "spam"),
- env=mock.ANY,
- external=mock.ANY,
- paths=mock.ANY,
- silent=mock.ANY,
+ **run_with_defaults(paths=mock.ANY, silent=True, env=env, external="error"),
)
- def test_run_success(self):
+ def test_run_success(self) -> None:
session, _ = self.make_session_and_runner()
result = session.run(sys.executable, "-c", "print(123)")
assert result
- def test_run_error(self):
+ def test_run_error(self) -> None:
session, _ = self.make_session_and_runner()
with pytest.raises(nox.command.CommandFailed):
session.run(sys.executable, "-c", "import sys; sys.exit(1)")
- def test_run_overly_env(self):
+ def test_run_install_script(self) -> None:
+ session, _ = self.make_session_and_runner()
+
+ with mock.patch.object(nox.command, "run") as run:
+ session.install_and_run_script(DIR / "resources/pep721example1.py")
+
+ assert len(run.call_args_list) == 2
+ assert "rich" in run.call_args_list[0][0][0]
+ assert DIR / "resources/pep721example1.py" in run.call_args_list[1][0][0]
+
+ def test_run_overly_env(self) -> None:
session, runner = self.make_session_and_runner()
+ assert runner.venv
runner.venv.env["A"] = "1"
runner.venv.env["B"] = "2"
+ runner.venv.env["C"] = "4"
result = session.run(
sys.executable,
"-c",
- 'import os; print(os.environ["A"], os.environ["B"])',
- env={"B": "3"},
+ 'import os; print(os.environ["A"], os.environ["B"], os.environ.get("C", "5"))',
+ env={"B": "3", "C": None},
silent=True,
)
- assert result.strip() == "1 3"
+ assert result
+ assert result.strip() == "1 3 5"
- def test_run_external_not_a_virtualenv(self):
+ def test_by_default_all_invocation_env_vars_are_passed(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("I_SHOULD_BE_INCLUDED", "happy")
+ session, runner = self.make_session_and_runner()
+ assert runner.venv
+ runner.venv.env["I_SHOULD_BE_INCLUDED_TOO"] = "happier"
+ runner.venv.env["EVERYONE_SHOULD_BE_INCLUDED_TOO"] = "happiest"
+ result = session.run(
+ sys.executable,
+ "-c",
+ "import os; print(os.environ)",
+ silent=True,
+ )
+ assert result
+ assert "happy" in result
+ assert "happier" in result
+ assert "happiest" in result
+
+ def test_no_included_invocation_env_vars_are_passed(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("I_SHOULD_NOT_BE_INCLUDED", "sad")
+ monkeypatch.setenv("AND_NEITHER_SHOULD_I", "unhappy")
+ session, runner = self.make_session_and_runner()
+ assert runner.venv
+ result = session.run(
+ sys.executable,
+ "-c",
+ "import os; print(os.environ)",
+ env={"I_SHOULD_BE_INCLUDED": "happy"},
+ include_outer_env=False,
+ silent=True,
+ )
+ assert result
+ assert "sad" not in result
+ assert "unhappy" not in result
+ assert "happy" in result
+
+ def test_no_included_invocation_env_vars_are_passed_empty(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("I_SHOULD_NOT_BE_INCLUDED", "sad")
+ monkeypatch.setenv("AND_NEITHER_SHOULD_I", "unhappy")
+ session, _runner = self.make_session_and_runner()
+ result = session.run(
+ sys.executable,
+ "-c",
+ "import os; print(os.environ)",
+ include_outer_env=False,
+ silent=True,
+ )
+ assert result
+ assert "sad" not in result
+ assert "unhappy" not in result
+
+ def test_run_external_not_a_virtualenv(self) -> None:
# Non-virtualenv sessions should always allow external programs.
session, runner = self.make_session_and_runner()
- runner.venv = nox.virtualenv.ProcessEnv()
+ runner.venv = nox.virtualenv.PassthroughEnv()
with mock.patch("nox.command.run", autospec=True) as run:
session.run(sys.executable, "--version")
run.assert_called_once_with(
- (sys.executable, "--version"), external=True, env=mock.ANY, paths=None
+ (sys.executable, "--version"),
+ **run_with_defaults(external=True, env=mock.ANY),
)
- def test_run_external_condaenv(self):
+ def test_run_external_condaenv(self) -> None:
# condaenv sessions should always allow conda.
session, runner = self.make_session_and_runner()
runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
- runner.venv.allowed_globals = ("conda",)
+ assert runner.venv
+ runner.venv.allowed_globals = ("conda",) # type: ignore[misc]
runner.venv.env = {}
- runner.venv.bin_paths = ["/path/to/env/bin"]
- runner.venv.create.return_value = True
+ runner.venv.bin_paths = ["/path/to/env/bin"] # type: ignore[misc]
+ runner.venv.create.return_value = True # type: ignore[union-attr]
with mock.patch("nox.command.run", autospec=True) as run:
session.run("conda", "--version")
run.assert_called_once_with(
("conda", "--version"),
- external=True,
- env=mock.ANY,
- paths=["/path/to/env/bin"],
+ **run_with_defaults(
+ external=True, env=mock.ANY, paths=["/path/to/env/bin"]
+ ),
)
- def test_run_external_with_error_on_external_run(self):
+ def test_run_external_with_error_on_external_run(self) -> None:
session, runner = self.make_session_and_runner()
runner.global_config.error_on_external_run = True
@@ -278,36 +480,101 @@ def test_run_external_with_error_on_external_run(self):
with pytest.raises(nox.command.CommandFailed, match="External"):
session.run(sys.executable, "--version")
- def test_run_external_with_error_on_external_run_condaenv(self):
+ def test_run_external_with_error_on_external_run_condaenv(self) -> None:
session, runner = self.make_session_and_runner()
runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
+ assert runner.venv
runner.venv.env = {}
- runner.venv.bin_paths = ["/path/to/env/bin"]
+ runner.venv.bin_paths = ["/path/to/env/bin"] # type: ignore[misc]
runner.global_config.error_on_external_run = True
with pytest.raises(nox.command.CommandFailed, match="External"):
session.run(sys.executable, "--version")
- def test_run_always_bad_args(self):
+ def test_run_install_bad_args(self) -> None:
session, _ = self.make_session_and_runner()
- with pytest.raises(ValueError) as exc_info:
- session.run_always()
+ with pytest.raises(
+ ValueError, match="At least one argument required to run_install"
+ ):
+ session.run_install()
+
+ def test_run_no_install_passthrough(self) -> None:
+ session, runner = self.make_session_and_runner()
+ runner.venv = nox.virtualenv.PassthroughEnv()
+ runner.global_config.no_install = True
+
+ session.install("numpy")
+ session.conda_install("numpy")
- exc_args = exc_info.value.args
- assert exc_args == ("At least one argument required to run_always().",)
+ def test_run_no_conda_install(self) -> None:
+ session, _runner = self.make_session_and_runner()
- def test_run_always_success(self):
+ with pytest.raises(TypeError, match="A session without a conda"):
+ session.conda_install("numpy")
+
+ def test_run_install_success(self) -> None:
session, _ = self.make_session_and_runner()
- assert session.run_always(operator.add, 1300, 37) == 1337
+ assert session.run_install(operator.add, 1300, 37) == 1337 # type: ignore[arg-type]
- def test_run_always_install_only(self, caplog):
+ def test_run_install_install_only(self) -> None:
session, runner = self.make_session_and_runner()
runner.global_config.install_only = True
- assert session.run_always(operator.add, 23, 19) == 42
+ assert session.run_install(operator.add, 23, 19) == 42 # type: ignore[arg-type]
+
+ @pytest.mark.parametrize(
+ (
+ "interrupt_timeout_setting",
+ "terminate_timeout_setting",
+ "interrupt_timeout_expected",
+ "terminate_timeout_expected",
+ ),
+ [
+ ("default", "default", 0.3, 0.2),
+ (None, None, None, None),
+ (0, 0, 0, 0),
+ (1, 2, 1, 2),
+ ],
+ )
+ def test_run_shutdown_process_timeouts(
+ self,
+ interrupt_timeout_setting: Literal["default"] | int | None,
+ terminate_timeout_setting: Literal["default"] | int | None,
+ interrupt_timeout_expected: float | None,
+ terminate_timeout_expected: float | None,
+ ) -> None:
+ session, runner = self.make_session_and_runner()
+
+ runner.venv = nox.virtualenv.PassthroughEnv()
+
+ subp_popen_instance = mock.Mock()
+ subp_popen_instance.communicate.side_effect = KeyboardInterrupt()
+ with (
+ mock.patch("nox.popen.shutdown_process", autospec=True) as shutdown_process,
+ mock.patch(
+ "nox.popen.subprocess.Popen",
+ new=mock.Mock(return_value=subp_popen_instance),
+ ),
+ ):
+ shutdown_process.return_value = ("", "")
+
+ timeout_kwargs: dict[str, None | float] = {}
+ if interrupt_timeout_setting != "default":
+ timeout_kwargs["interrupt_timeout"] = interrupt_timeout_setting
+ if terminate_timeout_setting != "default":
+ timeout_kwargs["terminate_timeout"] = terminate_timeout_setting
+
+ with pytest.raises(KeyboardInterrupt):
+ session.run(sys.executable, "--version", **timeout_kwargs) # type: ignore[call-overload]
+
+ shutdown_process.assert_called_once_with(
+ proc=mock.ANY,
+ interrupt_timeout=interrupt_timeout_expected,
+ terminate_timeout=terminate_timeout_expected,
+ )
@pytest.mark.parametrize(
("no_install", "reused", "run_called"),
@@ -318,53 +585,71 @@ def test_run_always_install_only(self, caplog):
(False, False, True),
],
)
- def test_run_always_no_install(self, no_install, reused, run_called):
+ @pytest.mark.parametrize("run_install_func", ["run_always", "run_install"])
+ def test_run_install_no_install(
+ self, no_install: bool, reused: bool, run_called: bool, run_install_func: str
+ ) -> None:
session, runner = self.make_session_and_runner()
runner.global_config.no_install = no_install
+ assert runner.venv
runner.venv._reused = reused
with mock.patch.object(nox.command, "run") as run:
- session.run_always("python", "-m", "pip", "install", "requests")
+ run_install = getattr(session, run_install_func)
+ run_install("python", "-m", "pip", "install", "requests")
assert run.called is run_called
- def test_conda_install_bad_args(self):
+ def test_conda_install_bad_args(self) -> None:
session, runner = self.make_session_and_runner()
runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
+ assert runner.venv
runner.venv.location = "dummy"
with pytest.raises(ValueError, match="arg"):
session.conda_install()
- def test_conda_install_bad_args_odd_nb_double_quotes(self):
+ @pytest.mark.skipif(not sys.platform.startswith("win32"), reason="Only on Windows")
+ def test_conda_install_bad_args_odd_nb_double_quotes(self) -> None:
session, runner = self.make_session_and_runner()
runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
+ assert runner.venv
runner.venv.location = "./not/a/location"
with pytest.raises(ValueError, match="odd number of quotes"):
session.conda_install('a"a')
- def test_conda_install_bad_args_cannot_escape(self):
+ @pytest.mark.skipif(not sys.platform.startswith("win32"), reason="Only on Windows")
+ def test_conda_install_bad_args_cannot_escape(self) -> None:
session, runner = self.make_session_and_runner()
runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
+ assert runner.venv
runner.venv.location = "./not/a/location"
with pytest.raises(ValueError, match="Cannot escape"):
session.conda_install('a"o" None:
session, runner = self.make_session_and_runner()
runner.venv = None
- with pytest.raises(ValueError, match="conda environment"):
+ with pytest.raises(TypeError, match="conda environment"):
session.conda_install()
@pytest.mark.parametrize(
"auto_offline", [False, True], ids="auto_offline={}".format
)
@pytest.mark.parametrize("offline", [False, True], ids="offline={}".format)
- def test_conda_install(self, auto_offline, offline):
+ @pytest.mark.parametrize("conda", ["conda", "mamba"], ids=str)
+ @pytest.mark.parametrize(
+ "channel",
+ ["", "conda-forge", ["conda-forge", "bioconda"]],
+ ids=["default", "conda-forge", "bioconda"],
+ )
+ def test_conda_install(
+ self, auto_offline: bool, offline: bool, conda: str, channel: str | list[str]
+ ) -> None:
runner = nox.sessions.SessionRunner(
name="test",
signatures=["test"],
@@ -373,9 +658,11 @@ def test_conda_install(self, auto_offline, offline):
manifest=mock.create_autospec(nox.manifest.Manifest),
)
runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
+ assert runner.venv
runner.venv.location = "/path/to/conda/env"
runner.venv.env = {}
- runner.venv.is_offline = lambda: offline
+ runner.venv.is_offline = lambda: offline # type: ignore[union-attr]
+ runner.venv.conda_cmd = conda # type: ignore[union-attr]
class SessionNoSlots(nox.sessions.Session):
pass
@@ -383,19 +670,24 @@ class SessionNoSlots(nox.sessions.Session):
session = SessionNoSlots(runner=runner)
with mock.patch.object(session, "_run", autospec=True) as run:
- args = ("--offline",) if auto_offline and offline else ()
- session.conda_install("requests", "urllib3", auto_offline=auto_offline)
+ args = ["--offline"] if auto_offline and offline else []
+ if channel and isinstance(channel, str):
+ args.append(f"--channel={channel}")
+ else:
+ args += [f"--channel={c}" for c in channel]
+ session.conda_install(
+ "requests<99", "urllib3", auto_offline=auto_offline, channel=channel
+ )
run.assert_called_once_with(
- "conda",
+ conda,
"install",
"--yes",
*args,
"--prefix",
"/path/to/conda/env",
- "requests",
+ '"requests<99"' if sys.platform.startswith("win32") else "requests<99",
"urllib3",
- silent=True,
- external="error",
+ **_run_with_defaults(silent=True, external="error"),
)
@pytest.mark.parametrize(
@@ -407,13 +699,17 @@ class SessionNoSlots(nox.sessions.Session):
(False, False, True),
],
)
- def test_conda_venv_reused_with_no_install(self, no_install, reused, run_called):
+ def test_conda_venv_reused_with_no_install(
+ self, no_install: bool, reused: bool, run_called: bool
+ ) -> None:
session, runner = self.make_session_and_runner()
runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
+ assert runner.venv
runner.venv.location = "/path/to/conda/env"
runner.venv.env = {}
- runner.venv.is_offline = lambda: True
+ runner.venv.is_offline = lambda: True # type: ignore[union-attr]
+ runner.venv.conda_cmd = "conda" # type: ignore[union-attr]
runner.global_config.no_install = no_install
runner.venv._reused = reused
@@ -428,7 +724,7 @@ def test_conda_venv_reused_with_no_install(self, no_install, reused, run_called)
["no", "yes", "already_dbl_quoted"],
ids="version_constraint={}".format,
)
- def test_conda_install_non_default_kwargs(self, version_constraint):
+ def test_conda_install_non_default_kwargs(self, version_constraint: str) -> None:
runner = nox.sessions.SessionRunner(
name="test",
signatures=["test"],
@@ -437,9 +733,11 @@ def test_conda_install_non_default_kwargs(self, version_constraint):
manifest=mock.create_autospec(nox.manifest.Manifest),
)
runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
+ assert runner.venv
runner.venv.location = "/path/to/conda/env"
runner.venv.env = {}
- runner.venv.is_offline = lambda: False
+ runner.venv.is_offline = lambda: False # type: ignore[union-attr]
+ runner.venv.conda_cmd = "conda" # type: ignore[union-attr]
class SessionNoSlots(nox.sessions.Session):
pass
@@ -448,9 +746,11 @@ class SessionNoSlots(nox.sessions.Session):
if version_constraint == "no":
pkg_requirement = passed_arg = "urllib3"
- elif version_constraint == "yes":
+ elif version_constraint == "yes" and not sys.platform.startswith("win32"):
+ pkg_requirement = passed_arg = "urllib3<1.25"
+ elif version_constraint == "yes" and sys.platform.startswith("win32"):
pkg_requirement = "urllib3<1.25"
- passed_arg = '"%s"' % pkg_requirement
+ passed_arg = f'"{pkg_requirement}"'
elif version_constraint == "already_dbl_quoted":
pkg_requirement = passed_arg = '"urllib3<1.25"'
else:
@@ -467,25 +767,24 @@ class SessionNoSlots(nox.sessions.Session):
"requests",
# this will be double quoted if unquoted constraint is present
passed_arg,
- silent=False,
- external="error",
+ **_run_with_defaults(silent=False, external="error"),
)
- def test_install_bad_args_no_arg(self):
+ def test_install_bad_args_no_arg(self) -> None:
session, _ = self.make_session_and_runner()
with pytest.raises(ValueError, match="arg"):
session.install()
- def test_install_not_a_virtualenv(self):
+ def test_install_not_a_virtualenv(self) -> None:
session, runner = self.make_session_and_runner()
runner.venv = None
- with pytest.raises(ValueError, match="virtualenv"):
+ with pytest.raises(TypeError, match="virtualenv"):
session.install()
- def test_install(self):
+ def test_install(self) -> None:
runner = nox.sessions.SessionRunner(
name="test",
signatures=["test"],
@@ -493,14 +792,16 @@ def test_install(self):
global_config=_options.options.namespace(posargs=[]),
manifest=mock.create_autospec(nox.manifest.Manifest),
)
- runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
- runner.venv.env = {}
+ runner.venv = make_fake_env()
+ assert isinstance(runner.venv, nox.virtualenv.VirtualEnv)
class SessionNoSlots(nox.sessions.Session):
pass
session = SessionNoSlots(runner=runner)
+ assert session.venv_backend == "venv"
+
with mock.patch.object(session, "_run", autospec=True) as run:
session.install("requests", "urllib3")
run.assert_called_once_with(
@@ -510,11 +811,10 @@ class SessionNoSlots(nox.sessions.Session):
"install",
"requests",
"urllib3",
- silent=True,
- external="error",
+ **_run_with_defaults(silent=True, external="error"),
)
- def test_install_non_default_kwargs(self):
+ def test_install_non_default_kwargs(self) -> None:
runner = nox.sessions.SessionRunner(
name="test",
signatures=["test"],
@@ -522,8 +822,8 @@ def test_install_non_default_kwargs(self):
global_config=_options.options.namespace(posargs=[]),
manifest=mock.create_autospec(nox.manifest.Manifest),
)
- runner.venv = mock.create_autospec(nox.virtualenv.VirtualEnv)
- runner.venv.env = {}
+ runner.venv = make_fake_env()
+ assert isinstance(runner.venv, nox.virtualenv.VirtualEnv)
class SessionNoSlots(nox.sessions.Session):
pass
@@ -539,41 +839,143 @@ class SessionNoSlots(nox.sessions.Session):
"install",
"requests",
"urllib3",
- silent=False,
- external="error",
+ **_run_with_defaults(silent=False, external="error"),
+ )
+
+ def test_install_no_venv_failure(self) -> None:
+ runner = nox.sessions.SessionRunner(
+ name="test",
+ signatures=["test"],
+ func=mock.sentinel.func,
+ global_config=_options.options.namespace(posargs=[]),
+ manifest=mock.create_autospec(nox.manifest.Manifest),
+ )
+ runner.venv = mock.create_autospec(nox.virtualenv.PassthroughEnv)
+ assert runner.venv
+ runner.venv.env = {}
+
+ class SessionNoSlots(nox.sessions.Session):
+ pass
+
+ session = SessionNoSlots(runner=runner)
+
+ with pytest.raises(
+ ValueError,
+ match=(
+ r"use of session\.install\(\) is no longer allowed since"
+ r" it would modify the global Python environment"
+ ),
+ ):
+ session.install("requests", "urllib3")
+
+ @pytest.mark.parametrize(
+ ("verbose", "expected_silent"),
+ [
+ (True, False),
+ (False, True),
+ ],
+ )
+ def test_install_verbose(self, verbose: bool, expected_silent: bool) -> None:
+ runner = nox.sessions.SessionRunner(
+ name="test",
+ signatures=["test"],
+ func=mock.sentinel.func,
+ global_config=_options.options.namespace(posargs=[], verbose=verbose),
+ manifest=mock.create_autospec(nox.manifest.Manifest),
+ )
+ runner.venv = make_fake_env()
+
+ class SessionNoSlots(nox.sessions.Session):
+ pass
+
+ session = SessionNoSlots(runner=runner)
+
+ with mock.patch.object(session, "_run", autospec=True) as run:
+ session.install("requests", "urllib3")
+ run.assert_called_once_with(
+ "python",
+ "-m",
+ "pip",
+ "install",
+ "requests",
+ "urllib3",
+ **_run_with_defaults(silent=expected_silent, external="error"),
+ )
+
+ @pytest.mark.parametrize(
+ ("verbose", "expected_silent"),
+ [
+ (True, False),
+ (False, True),
+ ],
+ )
+ def test_conda_install_verbose(self, verbose: bool, expected_silent: bool) -> None:
+ runner = nox.sessions.SessionRunner(
+ name="test",
+ signatures=["test"],
+ func=mock.sentinel.func,
+ global_config=_options.options.namespace(posargs=[], verbose=verbose),
+ manifest=mock.create_autospec(nox.manifest.Manifest),
+ )
+ runner.venv = mock.create_autospec(nox.virtualenv.CondaEnv)
+ assert runner.venv
+ runner.venv.location = "/path/to/conda/env"
+ runner.venv.env = {}
+ runner.venv.is_offline = lambda: False # type: ignore[union-attr]
+ runner.venv.conda_cmd = "conda" # type: ignore[union-attr]
+
+ class SessionNoSlots(nox.sessions.Session):
+ pass
+
+ session = SessionNoSlots(runner=runner)
+
+ with mock.patch.object(session, "_run", autospec=True) as run:
+ session.conda_install("requests", "urllib3")
+ run.assert_called_once_with(
+ "conda",
+ "install",
+ "--yes",
+ "--prefix",
+ "/path/to/conda/env",
+ "requests",
+ "urllib3",
+ **_run_with_defaults(silent=expected_silent, external="error"),
)
- def test_notify(self):
+ def test_notify(self) -> None:
session, runner = self.make_session_and_runner()
session.notify("other")
- runner.manifest.notify.assert_called_once_with("other", None)
+ runner.manifest.notify.assert_called_once_with("other", None) # type: ignore[attr-defined]
session.notify("other", posargs=["--an-arg"])
- runner.manifest.notify.assert_called_with("other", ["--an-arg"])
+ runner.manifest.notify.assert_called_with("other", ["--an-arg"]) # type: ignore[attr-defined]
- def test_posargs_are_not_shared_between_sessions(self, monkeypatch, tmp_path):
- registry = {}
+ def test_posargs_are_not_shared_between_sessions(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ registry: dict[str, nox._decorators.Func] = {}
monkeypatch.setattr("nox.registry._REGISTRY", registry)
@nox.session(venv_backend="none")
- def test(session):
+ def test(session: nox.Session) -> None:
session.posargs.extend(["-x"])
@nox.session(venv_backend="none")
- def lint(session):
+ def lint(session: nox.Session) -> None:
if "-x" in session.posargs:
- raise RuntimeError("invalid option: -x")
+ msg = "invalid option: -x"
+ raise RuntimeError(msg)
- config = _options.options.namespace(posargs=[])
+ config = _options.options.namespace(posargs=[], envdir=".nox")
manifest = nox.manifest.Manifest(registry, config)
assert manifest["test"].execute()
assert manifest["lint"].execute()
- def test_log(self, caplog):
+ def test_log(self, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.INFO)
session, _ = self.make_session_and_runner()
@@ -581,20 +983,36 @@ def test_log(self, caplog):
assert "meep" in caplog.text
- def test_error(self, caplog):
+ def test_warn(self, caplog: pytest.LogCaptureFixture) -> None:
+ caplog.set_level(logging.WARNING)
+ session, _ = self.make_session_and_runner()
+
+ session.warn("meep")
+
+ assert "meep" in caplog.text
+
+ def test_debug(self, caplog: pytest.LogCaptureFixture) -> None:
+ caplog.set_level(logging.DEBUG)
+ session, _ = self.make_session_and_runner()
+
+ session.debug("meep")
+
+ assert "meep" in caplog.text
+
+ def test_error(self, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.ERROR)
session, _ = self.make_session_and_runner()
with pytest.raises(nox.sessions._SessionQuit, match="meep"):
session.error("meep")
- def test_error_no_log(self):
+ def test_error_no_log(self) -> None:
session, _ = self.make_session_and_runner()
with pytest.raises(nox.sessions._SessionQuit):
session.error()
- def test_skip_no_log(self):
+ def test_skip_no_log(self) -> None:
session, _ = self.make_session_and_runner()
with pytest.raises(nox.sessions._SessionSkip):
@@ -609,9 +1027,12 @@ def test_skip_no_log(self):
(False, False, True),
],
)
- def test_session_venv_reused_with_no_install(self, no_install, reused, run_called):
+ def test_session_venv_reused_with_no_install(
+ self, no_install: bool, reused: bool, run_called: bool
+ ) -> None:
session, runner = self.make_session_and_runner()
runner.global_config.no_install = no_install
+ assert runner.venv
runner.venv._reused = reused
with mock.patch.object(nox.command, "run") as run:
@@ -619,26 +1040,127 @@ def test_session_venv_reused_with_no_install(self, no_install, reused, run_calle
assert run.called is run_called
- def test___slots__(self):
+ def test_install_uv(self) -> None:
+ runner = nox.sessions.SessionRunner(
+ name="test",
+ signatures=["test"],
+ func=mock.sentinel.func,
+ global_config=_options.options.namespace(posargs=[]),
+ manifest=mock.create_autospec(nox.manifest.Manifest),
+ )
+ runner.venv = make_fake_env(venv_backend="uv")
+ assert isinstance(runner.venv, nox.virtualenv.VirtualEnv)
+ assert runner.venv.venv_backend == "uv"
+
+ class SessionNoSlots(nox.sessions.Session):
+ pass
+
+ session = SessionNoSlots(runner=runner)
+
+ with mock.patch.object(session, "_run", autospec=True) as run:
+ session.install("requests", "urllib3", silent=False)
+ run.assert_called_once_with(
+ "uv",
+ "pip",
+ "install",
+ "requests",
+ "urllib3",
+ **_run_with_defaults(silent=False, external="error"),
+ )
+
+ def test_install_uv_command(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ runner = nox.sessions.SessionRunner(
+ name="test",
+ signatures=["test"],
+ func=mock.sentinel.func,
+ global_config=_options.options.namespace(posargs=[]),
+ manifest=mock.create_autospec(nox.manifest.Manifest),
+ )
+ runner.venv = make_fake_env(venv_backend="uv")
+ assert isinstance(runner.venv, nox.virtualenv.VirtualEnv)
+ assert runner.venv.venv_backend == "uv"
+
+ class SessionNoSlots(nox.sessions.Session):
+ pass
+
+ session = SessionNoSlots(runner=runner)
+
+ monkeypatch.setattr(nox.virtualenv, "UV", "/some/uv")
+ monkeypatch.setattr(shutil, "which", lambda x, path=None: None) # noqa: ARG005
+
+ with mock.patch.object(nox.command, "run", autospec=True) as run:
+ session.install("requests", "urllib3", silent=False)
+ run.assert_called_once()
+
+ ((call_args,), _) = run.call_args
+ assert call_args == (
+ "/some/uv",
+ "pip",
+ "install",
+ "requests",
+ "urllib3",
+ )
+
+ # User runs uvx
+ with mock.patch.object(nox.command, "run", autospec=True) as run:
+ session.run("uvx", "cowsay")
+ run.assert_called_once()
+
+ ((call_args,), _) = run.call_args
+ assert call_args == (
+ "/some/uvx",
+ "cowsay",
+ )
+
+ # user installs uv in the session venv
+ monkeypatch.setattr(
+ shutil, "which", lambda x, path="": path + "/uv" if x == "uv" else None
+ )
+
+ with mock.patch.object(nox.command, "run", autospec=True) as run:
+ session.install("requests", "urllib3", silent=False)
+ run.assert_called_once()
+
+ ((call_args,), _) = run.call_args
+ assert call_args == (
+ "uv",
+ "pip",
+ "install",
+ "requests",
+ "urllib3",
+ )
+
+ def test___slots__(self) -> None:
session, _ = self.make_session_and_runner()
with pytest.raises(AttributeError):
- session.foo = "bar"
+ session.foo = "bar" # type: ignore[attr-defined]
with pytest.raises(AttributeError):
- session.quux
+ session.quux # type: ignore[attr-defined] # noqa: B018
- def test___dict__(self):
+ def test___dict__(self) -> None:
session, _ = self.make_session_and_runner()
expected = {name: getattr(session, name) for name in session.__slots__}
assert session.__dict__ == expected
+ def test_first_arg_list(self) -> None:
+ session, _ = self.make_session_and_runner()
+
+ with pytest.raises(
+ ValueError,
+ match=re.escape("First argument to `session.run` is a list. Did you mean"),
+ ):
+ session.run(["ls", "-al"]) # type: ignore[call-overload]
+
class TestSessionRunner:
- def make_runner(self):
+ def make_runner(self) -> nox.sessions.SessionRunner:
func = mock.Mock()
func.python = None
func.venv_backend = None
+ func.venv_params = []
func.reuse_venv = False
- runner = nox.sessions.SessionRunner(
+ func.requires = []
+ return nox.sessions.SessionRunner(
name="test",
signatures=["test(1, 2)"],
func=func,
@@ -646,14 +1168,13 @@ def make_runner(self):
noxfile=os.path.join(os.getcwd(), "noxfile.py"),
envdir="envdir",
posargs=[],
- reuse_existing_virtualenvs=False,
- error_on_missing_interpreters=False,
+ reuse_venv="no",
+ error_on_missing_interpreters="CI" in os.environ,
),
manifest=mock.create_autospec(nox.manifest.Manifest),
)
- return runner
- def test_properties(self):
+ def test_properties(self) -> None:
runner = self.make_runner()
assert runner.name == "test"
@@ -664,23 +1185,23 @@ def test_properties(self):
assert runner.global_config.posargs == []
assert isinstance(runner.manifest, nox.manifest.Manifest)
- def test_str_and_friendly_name(self):
+ def test_str_and_friendly_name(self) -> None:
runner = self.make_runner()
runner.signatures = ["test(1, 2)", "test(3, 4)"]
assert str(runner) == "Session(name=test, signatures=test(1, 2), test(3, 4))"
assert runner.friendly_name == "test(1, 2)"
- def test_description_property_one_line(self):
- def foo():
+ def test_description_property_one_line(self) -> None:
+ def foo() -> None:
"""Just one line"""
runner = self.make_runner()
- runner.func = foo
+ runner.func = foo # type: ignore[assignment]
assert runner.description == "Just one line"
- def test_description_property_multi_line(self):
- def foo():
+ def test_description_property_multi_line(self) -> None:
+ def foo() -> None:
"""
Multiline
@@ -688,18 +1209,49 @@ def foo():
"""
runner = self.make_runner()
- runner.func = foo
+ runner.func = foo # type: ignore[assignment]
assert runner.description == "Multiline"
- def test_description_property_no_doc(self):
- def foo():
+ def test_description_property_no_doc(self) -> None:
+ def foo() -> None:
pass
runner = self.make_runner()
- runner.func = foo
+ runner.func = foo # type: ignore[assignment]
assert runner.description is None
- def test__create_venv_process_env(self):
+ def test_full_description_property_one_line(self) -> None:
+ def foo() -> None:
+ """Just one line"""
+
+ runner = self.make_runner()
+ runner.func = foo # type: ignore[assignment]
+ assert runner.full_description == "Just one line"
+
+ def test_full_description_property_multi_line(self) -> None:
+ def foo() -> None:
+ """Multiline
+
+ Extra description
+ with more details
+ """
+
+ runner = self.make_runner()
+ runner.func = foo # type: ignore[assignment]
+ assert (
+ runner.full_description
+ == "Multiline\n\nExtra description\nwith more details"
+ )
+
+ def test_full_description_property_no_doc(self) -> None:
+ def foo() -> None:
+ pass
+
+ runner = self.make_runner()
+ runner.func = foo # type: ignore[assignment]
+ assert runner.full_description is None
+
+ def test__create_venv_process_env(self) -> None:
runner = self.make_runner()
runner.func.python = False
@@ -708,7 +1260,7 @@ def test__create_venv_process_env(self):
assert isinstance(runner.venv, nox.virtualenv.ProcessEnv)
@mock.patch("nox.virtualenv.VirtualEnv.create", autospec=True)
- def test__create_venv(self, create):
+ def test__create_venv(self, create: mock.Mock) -> None:
runner = self.make_runner()
runner._create_venv()
@@ -720,7 +1272,7 @@ def test__create_venv(self, create):
assert runner.venv.reuse_existing is False
@pytest.mark.parametrize(
- "create_method,venv_backend,expected_backend",
+ ("create_method", "venv_backend", "expected_backend"),
[
("nox.virtualenv.VirtualEnv.create", None, nox.virtualenv.VirtualEnv),
(
@@ -729,10 +1281,20 @@ def test__create_venv(self, create):
nox.virtualenv.VirtualEnv,
),
("nox.virtualenv.VirtualEnv.create", "venv", nox.virtualenv.VirtualEnv),
- ("nox.virtualenv.CondaEnv.create", "conda", nox.virtualenv.CondaEnv),
+ pytest.param(
+ "nox.virtualenv.CondaEnv.create",
+ "conda",
+ nox.virtualenv.CondaEnv,
+ marks=pytest.mark.conda,
+ ),
],
)
- def test__create_venv_options(self, create_method, venv_backend, expected_backend):
+ def test__create_venv_options(
+ self,
+ create_method: str,
+ venv_backend: None | str,
+ expected_backend: type[nox.virtualenv.VirtualEnv | nox.virtualenv.CondaEnv],
+ ) -> None:
runner = self.make_runner()
runner.func.python = "coolpython"
runner.func.reuse_venv = True
@@ -746,19 +1308,99 @@ def test__create_venv_options(self, create_method, venv_backend, expected_backen
assert runner.venv.interpreter == "coolpython"
assert runner.venv.reuse_existing is True
- def test__create_venv_unexpected_venv_backend(self):
+ def test__create_venv_unexpected_venv_backend(self) -> None:
runner = self.make_runner()
runner.func.venv_backend = "somenewenvtool"
-
with pytest.raises(ValueError, match="venv_backend"):
runner._create_venv()
- def make_runner_with_mock_venv(self):
+ @pytest.mark.parametrize(
+ "venv_backend",
+ ["uv|virtualenv", "conda|virtualenv", "mamba|conda|venv"],
+ )
+ def test_fallback_venv(
+ self, venv_backend: str, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ runner = self.make_runner()
+ runner.func.venv_backend = venv_backend
+ monkeypatch.setattr(
+ nox.virtualenv,
+ "OPTIONAL_VENVS",
+ {"uv": False, "conda": False, "mamba": False},
+ )
+ with mock.patch("nox.virtualenv.VirtualEnv.create", autospec=True):
+ runner._create_venv()
+ assert runner.venv
+ assert runner.venv.venv_backend == venv_backend.rsplit("|", maxsplit=1)[-1]
+
+ @pytest.mark.parametrize(
+ "venv_backend",
+ [
+ "uv|virtualenv|unknown",
+ "conda|unknown|virtualenv",
+ "virtualenv|venv",
+ "conda|mamba",
+ ],
+ )
+ def test_invalid_fallback_venv(
+ self, venv_backend: str, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ runner = self.make_runner()
+ runner.func.venv_backend = venv_backend
+ monkeypatch.setattr(
+ nox.virtualenv,
+ "OPTIONAL_VENVS",
+ {"uv": False, "conda": False, "mamba": False},
+ )
+ with (
+ mock.patch("nox.virtualenv.VirtualEnv.create", autospec=True),
+ pytest.raises(
+ ValueError,
+ match=r"No backends present|Only optional backends|Expected venv_backend",
+ ),
+ ):
+ runner._create_venv()
+
+ @pytest.mark.parametrize(
+ ("reuse_venv", "reuse_venv_func", "should_reuse"),
+ [
+ ("yes", None, True),
+ ("yes", False, False),
+ ("yes", True, True),
+ ("no", None, False),
+ ("no", False, False),
+ ("no", True, True),
+ ("always", None, True),
+ ("always", False, True),
+ ("always", True, True),
+ ("never", None, False),
+ ("never", False, False),
+ ("never", True, False),
+ ],
+ )
+ def test__reuse_venv_outcome(
+ self, reuse_venv: str, reuse_venv_func: bool | None, should_reuse: bool
+ ) -> None:
runner = self.make_runner()
- runner._create_venv = mock.Mock()
+ runner.func.reuse_venv = reuse_venv_func
+ runner.global_config.reuse_venv = reuse_venv
+ assert runner.reuse_existing_venv() == should_reuse
+
+ def test__reuse_venv_invalid(self) -> None:
+ runner = self.make_runner()
+ runner.global_config.reuse_venv = True
+ msg = "nox.options.reuse_venv must be set to 'always', 'never', 'no', or 'yes', got True!"
+ with pytest.raises(AttributeError, match=re.escape(msg)):
+ runner.reuse_existing_venv()
+
+ def make_runner_with_mock_venv(self) -> nox.sessions.SessionRunner:
+ runner = self.make_runner()
+ runner._create_venv = mock.Mock() # type: ignore[method-assign]
+ runner.venv = make_fake_env()
+ assert isinstance(runner.venv, nox.virtualenv.VirtualEnv)
return runner
- def test_execute_noop_success(self, caplog):
+ def test_execute_noop_success(self, caplog: pytest.LogCaptureFixture) -> None:
caplog.set_level(logging.DEBUG)
runner = self.make_runner_with_mock_venv()
@@ -766,151 +1408,222 @@ def test_execute_noop_success(self, caplog):
result = runner.execute()
assert result
- runner.func.assert_called_once_with(mock.ANY)
+ runner.func.assert_called_once_with(mock.ANY) # type: ignore[attr-defined]
assert "Running session test(1, 2)" in caplog.text
- def test_execute_quit(self):
+ def test_execute_quit(self) -> None:
runner = self.make_runner_with_mock_venv()
- def func(session):
+ def func(session: nox.Session) -> None:
session.error("meep")
- runner.func = func
+ func.requires = [] # type: ignore[attr-defined]
+ runner.func = func # type: ignore[assignment]
result = runner.execute()
assert result.status == nox.sessions.Status.ABORTED
- def test_execute_skip(self):
+ def test_execute_skip(self) -> None:
runner = self.make_runner_with_mock_venv()
- def func(session):
+ def func(session: nox.Session) -> None:
session.skip("meep")
- runner.func = func
+ func.requires = [] # type: ignore[attr-defined]
+ runner.func = func # type: ignore[assignment]
result = runner.execute()
assert result.status == nox.sessions.Status.SKIPPED
- def test_execute_with_manifest_null_session_func(self):
+ def test_execute_with_manifest_null_session_func(self) -> None:
runner = self.make_runner()
runner.func = nox.manifest._null_session_func
result = runner.execute()
assert result.status == nox.sessions.Status.SKIPPED
+ assert result.reason
assert "no parameters" in result.reason
- def test_execute_skip_missing_interpreter(self):
+ def test_execute_skip_missing_interpreter(
+ self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # Important to have this first here as the runner will pick it up
+ # to set default for --error-on-missing-interpreters
+ monkeypatch.delenv("CI", raising=False)
+
runner = self.make_runner_with_mock_venv()
- runner._create_venv.side_effect = nox.virtualenv.InterpreterNotFound("meep")
+ runner._create_venv.side_effect = nox.virtualenv.InterpreterNotFound("meep") # type: ignore[attr-defined]
result = runner.execute()
assert result.status == nox.sessions.Status.SKIPPED
+ assert result.reason
+ assert "meep" in result.reason
+ assert (
+ "Missing interpreters will error by default on CI systems." in caplog.text
+ )
+
+ def test_execute_missing_interpreter_on_CI(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("CI", "True")
+ runner = self.make_runner_with_mock_venv()
+ runner._create_venv.side_effect = nox.virtualenv.InterpreterNotFound("meep") # type: ignore[attr-defined]
+
+ result = runner.execute()
+
+ assert result.status == nox.sessions.Status.FAILED
+ assert result.reason
assert "meep" in result.reason
- def test_execute_error_missing_interpreter(self):
+ def test_execute_error_missing_interpreter(self) -> None:
runner = self.make_runner_with_mock_venv()
runner.global_config.error_on_missing_interpreters = True
- runner._create_venv.side_effect = nox.virtualenv.InterpreterNotFound("meep")
+ runner._create_venv.side_effect = nox.virtualenv.InterpreterNotFound("meep") # type: ignore[attr-defined]
result = runner.execute()
assert result.status == nox.sessions.Status.FAILED
+ assert result.reason
assert "meep" in result.reason
- def test_execute_failed(self):
+ def test_execute_failed(self) -> None:
runner = self.make_runner_with_mock_venv()
- def func(session):
+ def func(session: nox.Session) -> None: # noqa: ARG001
raise nox.command.CommandFailed()
- runner.func = func
+ func.requires = [] # type: ignore[attr-defined]
+ runner.func = func # type: ignore[assignment]
result = runner.execute()
assert result.status == nox.sessions.Status.FAILED
- def test_execute_interrupted(self):
+ def test_execute_interrupted(self) -> None:
runner = self.make_runner_with_mock_venv()
- def func(session):
+ def func(session: nox.Session) -> None: # noqa: ARG001
raise KeyboardInterrupt()
- runner.func = func
+ func.requires = [] # type: ignore[attr-defined]
+ runner.func = func # type: ignore[assignment]
with pytest.raises(KeyboardInterrupt):
runner.execute()
- def test_execute_exception(self):
+ def test_execute_exception(self) -> None:
runner = self.make_runner_with_mock_venv()
- def func(session):
- raise ValueError("meep")
+ def func(session: nox.Session) -> None: # noqa: ARG001
+ msg = "meep"
+ raise ValueError(msg)
- runner.func = func
+ func.requires = [] # type: ignore[attr-defined]
+ runner.func = func # type: ignore[assignment]
result = runner.execute()
assert result.status == nox.sessions.Status.FAILED
+ def test_execute_check_env(self) -> None:
+ runner = self.make_runner_with_mock_venv()
+
+ def func(session: nox.Session) -> None:
+ session.run(
+ sys.executable,
+ "-c",
+ "import os; raise SystemExit(0 if"
+ f' os.environ["NOX_CURRENT_SESSION"] == {session.name!r} else 0)',
+ )
+
+ func.requires = [] # type: ignore[attr-defined]
+ runner.func = func # type: ignore[assignment]
+
+ result = runner.execute()
+
+ assert result
+
class TestResult:
- def test_init(self):
+ def test_init(self) -> None:
result = nox.sessions.Result(
session=mock.sentinel.SESSION, status=mock.sentinel.STATUS
)
assert result.session == mock.sentinel.SESSION
assert result.status == mock.sentinel.STATUS
- def test__bool_true(self):
+ def test__bool_true(self) -> None:
for status in (nox.sessions.Status.SUCCESS, nox.sessions.Status.SKIPPED):
- result = nox.sessions.Result(session=object(), status=status)
+ result = nox.sessions.Result(
+ session=typing.cast("nox.sessions.SessionRunner", object()),
+ status=status,
+ )
assert bool(result)
- assert result.__bool__()
- assert result.__nonzero__()
- def test__bool_false(self):
+ def test__bool_false(self) -> None:
for status in (nox.sessions.Status.FAILED, nox.sessions.Status.ABORTED):
- result = nox.sessions.Result(session=object(), status=status)
+ result = nox.sessions.Result(
+ session=typing.cast("nox.sessions.SessionRunner", object()),
+ status=status,
+ )
assert not bool(result)
- assert not result.__bool__()
- assert not result.__nonzero__()
- def test__imperfect(self):
- result = nox.sessions.Result(object(), nox.sessions.Status.SUCCESS)
+ def test__imperfect(self) -> None:
+ result = nox.sessions.Result(
+ typing.cast("nox.sessions.SessionRunner", object()),
+ nox.sessions.Status.SUCCESS,
+ )
assert result.imperfect == "was successful"
- result = nox.sessions.Result(object(), nox.sessions.Status.FAILED)
+ result = nox.sessions.Result(
+ typing.cast("nox.sessions.SessionRunner", object()),
+ nox.sessions.Status.FAILED,
+ )
assert result.imperfect == "failed"
result = nox.sessions.Result(
- object(), nox.sessions.Status.FAILED, reason="meep"
+ typing.cast("nox.sessions.SessionRunner", object()),
+ nox.sessions.Status.FAILED,
+ reason="meep",
)
assert result.imperfect == "failed: meep"
- def test__log_success(self):
- result = nox.sessions.Result(object(), nox.sessions.Status.SUCCESS)
+ def test__log_success(self) -> None:
+ result = nox.sessions.Result(
+ typing.cast("nox.sessions.SessionRunner", object()),
+ nox.sessions.Status.SUCCESS,
+ )
with mock.patch.object(logger, "success") as success:
result.log("foo")
success.assert_called_once_with("foo")
- def test__log_warning(self):
- result = nox.sessions.Result(object(), nox.sessions.Status.SKIPPED)
+ def test__log_warning(self) -> None:
+ result = nox.sessions.Result(
+ typing.cast("nox.sessions.SessionRunner", object()),
+ nox.sessions.Status.SKIPPED,
+ )
with mock.patch.object(logger, "warning") as warning:
result.log("foo")
warning.assert_called_once_with("foo")
- def test__log_error(self):
- result = nox.sessions.Result(object(), nox.sessions.Status.FAILED)
+ def test__log_error(self) -> None:
+ result = nox.sessions.Result(
+ typing.cast("nox.sessions.SessionRunner", object()),
+ nox.sessions.Status.FAILED,
+ )
with mock.patch.object(logger, "error") as error:
result.log("foo")
error.assert_called_once_with("foo")
- def test__serialize(self):
+ def test__serialize(self) -> None:
result = nox.sessions.Result(
- session=argparse.Namespace(
- signatures=["siggy"], name="namey", func=mock.Mock()
+ session=typing.cast(
+ "nox.sessions.SessionRunner",
+ argparse.Namespace(
+ signatures=["siggy"], name="namey", func=mock.Mock()
+ ),
),
status=nox.sessions.Status.SUCCESS,
)
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index 9827cfc8..6f5706d9 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -12,58 +12,86 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
import argparse
+import builtins
import copy
-import io
import json
import os
import platform
+import typing
+from pathlib import Path
from textwrap import dedent
from unittest import mock
import pytest
import nox
+import nox._decorators
from nox import _options, sessions, tasks
from nox.manifest import WARN_PYTHONS_IGNORED, Manifest
+if typing.TYPE_CHECKING:
+ from collections.abc import Callable, Generator
+ from types import ModuleType
+
RESOURCES = os.path.join(os.path.dirname(__file__), "resources")
-def session_func():
+def session_func_raw() -> None:
pass
+session_func = typing.cast("nox._decorators.Func", session_func_raw)
+
+
session_func.python = None
session_func.venv_backend = None
-session_func.should_warn = dict()
+session_func.should_warn = {}
+session_func.tags = []
+session_func.default = True
+session_func.requires = []
-def session_func_with_python():
+def session_func_with_python_raw() -> None:
pass
+session_func_with_python = typing.cast(
+ "nox._decorators.Func", session_func_with_python_raw
+)
+
+
session_func_with_python.python = "3.8"
session_func_with_python.venv_backend = None
+session_func_with_python.default = True
+session_func_with_python.requires = []
-def session_func_venv_pythons_warning():
+def session_func_venv_pythons_warning_raw() -> None:
pass
+session_func_venv_pythons_warning = typing.cast(
+ "nox._decorators.Func", session_func_venv_pythons_warning_raw
+)
+
+
session_func_venv_pythons_warning.python = ["3.7"]
session_func_venv_pythons_warning.venv_backend = "none"
session_func_venv_pythons_warning.should_warn = {WARN_PYTHONS_IGNORED: ["3.7"]}
-def test_load_nox_module():
+def test_load_nox_module() -> None:
config = _options.options.namespace(noxfile=os.path.join(RESOURCES, "noxfile.py"))
noxfile_module = tasks.load_nox_module(config)
+ assert not isinstance(noxfile_module, int)
assert noxfile_module.SIGIL == "123"
-def test_load_nox_module_expandvars():
- # Assert that variables are expanded when looking up the path to the noxfile
+def test_load_nox_module_expandvars() -> None:
+ # Assert that variables are expanded when looking up the path to the Noxfile
# This is particular importand in Windows when one needs to use variables like
# %TEMP% to point to the noxfile.py
with mock.patch.dict(os.environ, {"RESOURCES_PATH": RESOURCES}):
@@ -72,17 +100,34 @@ def test_load_nox_module_expandvars():
else:
config = _options.options.namespace(noxfile="${RESOURCES_PATH}/noxfile.py")
noxfile_module = tasks.load_nox_module(config)
+ assert not isinstance(noxfile_module, int)
assert noxfile_module.__file__ == os.path.join(RESOURCES, "noxfile.py")
assert noxfile_module.SIGIL == "123"
-def test_load_nox_module_not_found():
- config = _options.options.namespace(noxfile="bogus.py")
+def test_load_nox_module_not_found(
+ caplog: pytest.LogCaptureFixture, tmp_path: Path
+) -> None:
+ bogus_noxfile = tmp_path / "bogus.py"
+ config = _options.options.namespace(noxfile=str(bogus_noxfile))
+
assert tasks.load_nox_module(config) == 2
+ assert (
+ f"Failed to load Noxfile {bogus_noxfile}, no such file exists." in caplog.text
+ )
-@pytest.fixture
-def reset_needs_version():
+def test_load_nox_module_os_error(caplog: pytest.LogCaptureFixture) -> None:
+ noxfile = os.path.join(RESOURCES, "noxfile.py")
+ config = _options.options.namespace(noxfile=noxfile)
+ with mock.patch("nox.tasks.check_nox_version", autospec=True) as version_checker:
+ version_checker.side_effect = OSError
+ assert tasks.load_nox_module(config) == 2
+ assert f"Failed to load Noxfile {noxfile}" in caplog.text
+
+
+@pytest.fixture(autouse=True)
+def reset_needs_version() -> Generator[None, None, None]:
"""Do not leak ``nox.needs_version`` between tests."""
try:
yield
@@ -90,7 +135,12 @@ def reset_needs_version():
nox.needs_version = None
-def test_load_nox_module_needs_version_static(reset_needs_version, tmp_path):
+@pytest.fixture
+def reset_global_nox_options() -> None:
+ nox.options = _options.options.noxfile_namespace()
+
+
+def test_load_nox_module_needs_version_static(tmp_path: Path) -> None:
text = dedent(
"""
import nox
@@ -98,12 +148,12 @@ def test_load_nox_module_needs_version_static(reset_needs_version, tmp_path):
"""
)
noxfile = tmp_path / "noxfile.py"
- noxfile.write_text(text)
+ noxfile.write_text(text, encoding="utf-8")
config = _options.options.namespace(noxfile=str(noxfile))
assert tasks.load_nox_module(config) == 2
-def test_load_nox_module_needs_version_dynamic(reset_needs_version, tmp_path):
+def test_load_nox_module_needs_version_dynamic(tmp_path: Path) -> None:
text = dedent(
"""
import nox
@@ -112,33 +162,36 @@ def test_load_nox_module_needs_version_dynamic(reset_needs_version, tmp_path):
"""
)
noxfile = tmp_path / "noxfile.py"
- noxfile.write_text(text)
+ noxfile.write_text(text, encoding="utf-8")
config = _options.options.namespace(noxfile=str(noxfile))
tasks.load_nox_module(config)
# Dynamic version requirements are not checked.
assert nox.needs_version == ">=9999.99.99"
-def test_discover_session_functions_decorator():
+def test_discover_session_functions_decorator() -> None:
# Define sessions using the decorator.
@nox.session
- def foo():
+ def foo() -> None:
pass
@nox.session
- def bar():
+ def bar() -> None:
pass
@nox.session(name="not-a-bar")
- def not_a_bar():
+ def not_a_bar() -> None:
pass
- def notasession():
+ def notasession() -> None:
pass
# Mock up a noxfile.py module and configuration.
- mock_module = argparse.Namespace(
- __name__=foo.__module__, foo=foo, bar=bar, notasession=notasession
+ mock_module = typing.cast(
+ "ModuleType",
+ argparse.Namespace(
+ __name__=foo.__module__, foo=foo, bar=bar, notasession=notasession
+ ),
)
config = _options.options.namespace(sessions=(), keywords=(), posargs=[])
@@ -149,9 +202,9 @@ def notasession():
assert [i.friendly_name for i in sessions] == ["foo", "bar", "not-a-bar"]
-def test_filter_manifest():
+def test_filter_manifest() -> None:
config = _options.options.namespace(
- sessions=(), pythons=(), keywords=(), posargs=[]
+ sessions=None, pythons=(), keywords=(), posargs=[]
)
manifest = Manifest({"foo": session_func, "bar": session_func}, config)
return_value = tasks.filter_manifest(manifest, config)
@@ -159,7 +212,7 @@ def test_filter_manifest():
assert len(manifest) == 2
-def test_filter_manifest_not_found():
+def test_filter_manifest_not_found() -> None:
config = _options.options.namespace(
sessions=("baz",), pythons=(), keywords=(), posargs=[]
)
@@ -168,9 +221,9 @@ def test_filter_manifest_not_found():
assert return_value == 3
-def test_filter_manifest_pythons():
+def test_filter_manifest_pythons() -> None:
config = _options.options.namespace(
- sessions=(), pythons=("3.8",), keywords=(), posargs=[]
+ sessions=None, pythons=("3.8",), keywords=(), posargs=[]
)
manifest = Manifest(
{"foo": session_func_with_python, "bar": session_func, "baz": session_func},
@@ -181,9 +234,22 @@ def test_filter_manifest_pythons():
assert len(manifest) == 1
-def test_filter_manifest_keywords():
+def test_filter_manifest_pythons_not_found(caplog: pytest.LogCaptureFixture) -> None:
+ config = _options.options.namespace(
+ sessions=None, pythons=("1.2",), keywords=(), posargs=[]
+ )
+ manifest = Manifest(
+ {"foo": session_func_with_python, "bar": session_func, "baz": session_func},
+ config,
+ )
+ return_value = tasks.filter_manifest(manifest, config)
+ assert return_value == 3
+ assert "Python version selection caused no sessions to be selected." in caplog.text
+
+
+def test_filter_manifest_keywords() -> None:
config = _options.options.namespace(
- sessions=(), pythons=(), keywords="foo or bar", posargs=[]
+ sessions=None, pythons=(), keywords="foo or bar", posargs=[]
)
manifest = Manifest(
{"foo": session_func, "bar": session_func, "baz": session_func}, config
@@ -193,34 +259,218 @@ def test_filter_manifest_keywords():
assert len(manifest) == 2
-def test_honor_list_request_noop():
+def test_filter_manifest_keywords_not_found(caplog: pytest.LogCaptureFixture) -> None:
+ config = _options.options.namespace(
+ sessions=None, pythons=(), keywords="mouse or python", posargs=[]
+ )
+ manifest = Manifest(
+ {"foo": session_func, "bar": session_func, "baz": session_func}, config
+ )
+ return_value = tasks.filter_manifest(manifest, config)
+ assert return_value == 3
+ assert "No sessions selected after filtering by keyword." in caplog.text
+
+
+def test_filter_manifest_keywords_syntax_error() -> None:
+ config = _options.options.namespace(
+ sessions=None, pythons=(), keywords="foo:bar", posargs=[]
+ )
+ manifest = Manifest({"foo_bar": session_func, "foo_baz": session_func}, config)
+ return_value = tasks.filter_manifest(manifest, config)
+ assert return_value == 3
+
+
+@pytest.mark.parametrize(
+ ("tags", "session_count"),
+ [
+ (None, 8),
+ (["foo"], 7),
+ (["bar"], 5),
+ (["baz"], 3),
+ (["foo", "bar"], 8),
+ (["foo", "baz"], 7),
+ (["bar", "baz"], 6),
+ (["foo", "bar", "baz"], 8),
+ ],
+)
+def test_filter_manifest_tags(
+ tags: None | builtins.list[builtins.str],
+ session_count: builtins.int,
+) -> None:
+ @nox.session(tags=["foo"])
+ def qux() -> None:
+ pass
+
+ @nox.session(tags=["bar"])
+ def quux() -> None:
+ pass
+
+ @nox.session(tags=["foo", "bar"])
+ def quuz() -> None:
+ pass
+
+ @nox.session(tags=["foo", "bar", "baz"])
+ def corge() -> None:
+ pass
+
+ @nox.session(tags=["foo"])
+ @nox.parametrize("a", [1, nox.param(2, tags=["bar"])])
+ @nox.parametrize("b", [3, 4], tags=[["baz"]])
+ def grault() -> None:
+ pass
+
+ config = _options.options.namespace(
+ sessions=None, pythons=(), posargs=[], tags=tags
+ )
+ manifest = Manifest(
+ {
+ "qux": qux,
+ "quux": quux,
+ "quuz": quuz,
+ "corge": corge,
+ "grault": grault,
+ },
+ config,
+ )
+ return_value = tasks.filter_manifest(manifest, config)
+ assert return_value is manifest
+ assert len(manifest) == session_count
+
+
+@pytest.mark.parametrize(
+ "tags",
+ [
+ ["Foo"],
+ ["not-found"],
+ ],
+ ids=[
+ "tags-are-case-insensitive",
+ "tag-does-not-exist",
+ ],
+)
+def test_filter_manifest_tags_not_found(
+ tags: list[str], caplog: pytest.LogCaptureFixture
+) -> None:
+ @nox.session(tags=["foo"])
+ def quux() -> None:
+ pass
+
+ config = _options.options.namespace(
+ sessions=None, pythons=(), posargs=[], tags=tags
+ )
+ manifest = Manifest({"quux": quux}, config)
+ return_value = tasks.filter_manifest(manifest, config)
+ assert return_value == 3
+ assert "Tag selection caused no sessions to be selected." in caplog.text
+
+
+@pytest.mark.usefixtures("reset_global_nox_options")
+def test_merge_tags(generate_noxfile_options: Callable[..., str]) -> None:
+ @nox.session(tags=["foobar"])
+ def testing() -> None:
+ pass
+
+ @nox.session(tags=["foobar"])
+ def bar() -> None:
+ pass
+
+ noxfile_path = generate_noxfile_options(reuse_existing_virtualenvs=True)
+ config = _options.options.namespace(
+ noxfile=noxfile_path,
+ sessions=None,
+ pythons=(),
+ posargs=[],
+ tags=["foobar"],
+ )
+
+ nox_module = tasks.load_nox_module(config)
+ assert not isinstance(nox_module, int)
+ tasks.merge_noxfile_options(nox_module, config)
+ manifest = Manifest({"testing": testing, "bar": bar}, config)
+ return_value = tasks.filter_manifest(manifest, config)
+ assert return_value is manifest
+ assert len(manifest) == 2
+
+
+@pytest.mark.parametrize("selection", [None, ["qux"], ["quuz"], ["qux", "quuz"]])
+def test_default_false(selection: None | builtins.list[builtins.str]) -> None:
+ @nox.session()
+ def qux() -> None:
+ pass
+
+ @nox.session()
+ def quux() -> None:
+ pass
+
+ @nox.session(default=False)
+ def quuz() -> None:
+ pass
+
+ @nox.session(default=False)
+ def corge() -> None:
+ pass
+
+ config = _options.options.namespace(sessions=selection, pythons=(), posargs=[])
+ manifest = Manifest(
+ {
+ "qux": qux,
+ "quux": quux,
+ "quuz": quuz,
+ "corge": corge,
+ },
+ config,
+ )
+ return_value = tasks.filter_manifest(manifest, config)
+ assert return_value is manifest
+ expected = 2 if selection is None else len(selection)
+ assert len(manifest) == expected
+
+
+def test_honor_list_request_noop() -> None:
config = _options.options.namespace(list_sessions=False)
- manifest = {"thing": mock.sentinel.THING}
+ manifest = typing.cast("Manifest", {"thing": mock.sentinel.THING})
return_value = tasks.honor_list_request(manifest, global_config=config)
assert return_value is manifest
-@pytest.mark.parametrize("description", [None, "bar"])
-def test_honor_list_request(description):
+@pytest.mark.parametrize(
+ ("description", "module_docstring"),
+ [
+ (None, None),
+ (None, "hello docstring"),
+ ("Bar", None),
+ ("Bar", "hello docstring"),
+ ],
+)
+def test_honor_list_request(
+ description: None | builtins.str, module_docstring: None | builtins.str
+) -> None:
config = _options.options.namespace(
list_sessions=True, noxfile="noxfile.py", color=False
)
manifest = mock.create_autospec(Manifest)
+ manifest.module_docstring = module_docstring
manifest.list_all_sessions.return_value = [
- (argparse.Namespace(friendly_name="foo", description=description), True)
+ (
+ argparse.Namespace(friendly_name="foo", description=description, tags=[]),
+ True,
+ )
]
return_value = tasks.honor_list_request(manifest, global_config=config)
assert return_value == 0
-def test_honor_list_request_skip_and_selected(capsys):
+def test_honor_list_request_skip_and_selected(
+ capsys: pytest.CaptureFixture[builtins.str],
+) -> None:
config = _options.options.namespace(
list_sessions=True, noxfile="noxfile.py", color=False
)
manifest = mock.create_autospec(Manifest)
+ manifest.module_docstring = None
manifest.list_all_sessions.return_value = [
- (argparse.Namespace(friendly_name="foo", description=None), True),
- (argparse.Namespace(friendly_name="bar", description=None), False),
+ (argparse.Namespace(friendly_name="foo", description=None, tags=[]), True),
+ (argparse.Namespace(friendly_name="bar", description=None, tags=[]), False),
]
return_value = tasks.honor_list_request(manifest, global_config=config)
assert return_value == 0
@@ -231,34 +481,159 @@ def test_honor_list_request_skip_and_selected(capsys):
assert "- bar" in out
-def test_verify_manifest_empty():
+def test_honor_list_request_prints_docstring_if_present(
+ capsys: pytest.CaptureFixture[builtins.str],
+) -> None:
+ config = _options.options.namespace(
+ list_sessions=True, noxfile="noxfile.py", color=False
+ )
+ manifest = mock.create_autospec(Manifest)
+ manifest.module_docstring = "Hello I'm a docstring"
+ manifest.list_all_sessions.return_value = [
+ (argparse.Namespace(friendly_name="foo", description=None, tags=[]), True),
+ (argparse.Namespace(friendly_name="bar", description=None, tags=[]), False),
+ ]
+
+ return_value = tasks.honor_list_request(manifest, global_config=config)
+ assert return_value == 0
+
+ out = capsys.readouterr().out
+
+ assert "Hello I'm a docstring" in out
+
+
+def test_honor_list_request_doesnt_print_docstring_if_not_present(
+ capsys: pytest.CaptureFixture[builtins.str],
+) -> None:
+ config = _options.options.namespace(
+ list_sessions=True, noxfile="noxfile.py", color=False
+ )
+ manifest = mock.create_autospec(Manifest)
+ manifest.module_docstring = None
+ manifest.list_all_sessions.return_value = [
+ (argparse.Namespace(friendly_name="foo", description=None, tags=[]), True),
+ (argparse.Namespace(friendly_name="bar", description=None, tags=[]), False),
+ ]
+
+ return_value = tasks.honor_list_request(manifest, global_config=config)
+ assert return_value == 0
+
+ out = capsys.readouterr().out
+
+ assert "Hello I'm a docstring" not in out
+
+
+def test_honor_list_json_request(capsys: pytest.CaptureFixture[builtins.str]) -> None:
+ config = _options.options.namespace(
+ list_sessions=True, noxfile="noxfile.py", json=True
+ )
+ manifest = mock.create_autospec(Manifest)
+ manifest.list_all_sessions.return_value = [
+ (
+ argparse.Namespace(
+ name="bar",
+ friendly_name="foo",
+ description="simple",
+ func=argparse.Namespace(python=Path("123")),
+ tags=[],
+ ),
+ True,
+ ),
+ (
+ argparse.Namespace(),
+ False,
+ ),
+ ]
+ return_value = tasks.honor_list_request(manifest, global_config=config)
+ assert return_value == 0
+ assert json.loads(capsys.readouterr().out) == [
+ {
+ "session": "foo",
+ "name": "bar",
+ "description": "simple",
+ "python": "123",
+ "tags": [],
+ "call_spec": {},
+ }
+ ]
+
+
+def test_refuse_json_nolist_request(caplog: pytest.LogCaptureFixture) -> None:
+ config = _options.options.namespace(
+ list_sessions=False, noxfile="noxfile.py", json=True
+ )
+ manifest = mock.create_autospec(Manifest)
+ manifest.list_all_sessions.return_value = [
+ (
+ argparse.Namespace(
+ name="bar",
+ friendly_name="foo",
+ description="simple",
+ func=argparse.Namespace(python="123"),
+ tags=[],
+ ),
+ True,
+ )
+ ]
+ return_value = tasks.honor_list_request(manifest, global_config=config)
+ assert return_value == 3
+ (record,) = caplog.records
+ assert record.message == "Must specify --list-sessions with --json"
+
+
+def test_empty_session_list_in_noxfile(
+ capsys: pytest.CaptureFixture[builtins.str],
+) -> None:
+ config = _options.options.namespace(noxfile="noxfile.py", sessions=(), posargs=[])
+ manifest = Manifest({"session": session_func}, config)
+ return_value = tasks.filter_manifest(manifest, global_config=config)
+ assert return_value == 0
+ assert "No sessions selected." in capsys.readouterr().out
+
+
+def test_empty_session_None_in_noxfile() -> None:
+ config = _options.options.namespace(noxfile="noxfile.py", sessions=None, posargs=[])
+ manifest = Manifest({"session": session_func}, config)
+ return_value = tasks.filter_manifest(manifest, global_config=config)
+ assert return_value == manifest
+
+
+def test_verify_manifest_empty() -> None:
config = _options.options.namespace(sessions=(), keywords=())
manifest = Manifest({}, config)
- return_value = tasks.verify_manifest_nonempty(manifest, global_config=config)
+ return_value = tasks.filter_manifest(manifest, global_config=config)
assert return_value == 3
-def test_verify_manifest_nonempty():
- config = _options.options.namespace(sessions=(), keywords=(), posargs=[])
+def test_verify_manifest_nonempty() -> None:
+ config = _options.options.namespace(sessions=None, keywords=(), posargs=[])
manifest = Manifest({"session": session_func}, config)
- return_value = tasks.verify_manifest_nonempty(manifest, global_config=config)
+ return_value = tasks.filter_manifest(manifest, global_config=config)
assert return_value == manifest
+def test_verify_manifest_list(capsys: pytest.CaptureFixture[builtins.str]) -> None:
+ config = _options.options.namespace(sessions=(), keywords=(), posargs=[])
+ manifest = Manifest({"session": session_func}, config)
+ return_value = tasks.filter_manifest(manifest, global_config=config)
+ assert return_value == 0
+ assert "Please select a session" in capsys.readouterr().out
+
+
@pytest.mark.parametrize("with_warnings", [False, True], ids="with_warnings={}".format)
-def test_run_manifest(with_warnings):
+def test_run_manifest(with_warnings: builtins.bool) -> None:
# Set up a valid manifest.
config = _options.options.namespace(stop_on_first_error=False)
sessions_ = [
- mock.Mock(spec=sessions.SessionRunner),
- mock.Mock(spec=sessions.SessionRunner),
+ typing.cast("sessions.SessionRunner", mock.Mock(spec=sessions.SessionRunner)),
+ typing.cast("sessions.SessionRunner", mock.Mock(spec=sessions.SessionRunner)),
]
manifest = Manifest({}, config)
manifest._queue = copy.copy(sessions_)
# Ensure each of the mocks returns a successful result
for mock_session in sessions_:
- mock_session.execute.return_value = sessions.Result(
+ mock_session.execute.return_value = sessions.Result( # type: ignore[attr-defined]
session=mock_session, status=sessions.Status.SUCCESS
)
# we need the should_warn attribute, add some func
@@ -280,19 +655,19 @@ def test_run_manifest(with_warnings):
assert result.status == sessions.Status.SUCCESS
-def test_run_manifest_abort_on_first_failure():
+def test_run_manifest_abort_on_first_failure() -> None:
# Set up a valid manifest.
config = _options.options.namespace(stop_on_first_error=True)
sessions_ = [
- mock.Mock(spec=sessions.SessionRunner),
- mock.Mock(spec=sessions.SessionRunner),
+ typing.cast("sessions.SessionRunner", mock.Mock(spec=sessions.SessionRunner)),
+ typing.cast("sessions.SessionRunner", mock.Mock(spec=sessions.SessionRunner)),
]
manifest = Manifest({}, config)
manifest._queue = copy.copy(sessions_)
# Ensure each of the mocks returns a successful result.
for mock_session in sessions_:
- mock_session.execute.return_value = sessions.Result(
+ mock_session.execute.return_value = sessions.Result( # type: ignore[attr-defined]
session=mock_session, status=sessions.Status.FAILED
)
# we need the should_warn attribute, add some func
@@ -308,56 +683,86 @@ def test_run_manifest_abort_on_first_failure():
assert results[0].status == sessions.Status.FAILED
# Verify that only the first session was called.
- assert sessions_[0].execute.called
- assert not sessions_[1].execute.called
+ assert sessions_[0].execute.called # type: ignore[attr-defined]
+ assert not sessions_[1].execute.called # type: ignore[attr-defined]
-def test_print_summary_one_result():
+def test_print_summary_one_result() -> None:
results = [mock.sentinel.RESULT]
with mock.patch("nox.tasks.logger", autospec=True) as logger:
- answer = tasks.print_summary(results, object())
+ answer = tasks.print_summary(results, argparse.Namespace())
assert not logger.warning.called
assert not logger.success.called
assert not logger.error.called
assert answer is results
-def test_print_summary():
- results = [
- sessions.Result(
- session=argparse.Namespace(friendly_name="foo"),
- status=sessions.Status.SUCCESS,
- ),
- sessions.Result(
- session=argparse.Namespace(friendly_name="bar"),
- status=sessions.Status.FAILED,
- ),
- ]
- with mock.patch.object(sessions.Result, "log", autospec=True) as log:
- answer = tasks.print_summary(results, object())
- assert log.call_count == 2
+def test_print_summary() -> None:
+ with mock.patch.object(sessions.Result, "log") as mock_log:
+ results = [
+ sessions.Result(
+ session=typing.cast(
+ "sessions.SessionRunner",
+ argparse.Namespace(friendly_name="foo"),
+ ),
+ status=sessions.Status.SUCCESS,
+ ),
+ sessions.Result(
+ session=typing.cast(
+ "sessions.SessionRunner",
+ argparse.Namespace(friendly_name="bar"),
+ ),
+ status=sessions.Status.FAILED,
+ ),
+ sessions.Result(
+ session=typing.cast(
+ "sessions.SessionRunner",
+ argparse.Namespace(friendly_name="baz"),
+ ),
+ status=sessions.Status.SKIPPED,
+ ),
+ sessions.Result(
+ session=typing.cast(
+ "sessions.SessionRunner",
+ argparse.Namespace(friendly_name="qux"),
+ ),
+ status=sessions.Status.SKIPPED,
+ reason="something reason",
+ ),
+ ]
+
+ answer = tasks.print_summary(results, argparse.Namespace())
+
+ assert mock_log.call_count == 4
+ calls = mock_log.call_args_list
+ assert calls[0][0][0] == "* foo: success"
+ assert calls[1][0][0] == "* bar: failed"
+ assert calls[2][0][0] == "* baz: skipped"
+ assert calls[3][0][0] == "* qux: skipped (something reason)"
+
assert answer is results
-def test_create_report_noop():
+def test_create_report_noop() -> None:
config = _options.options.namespace(report=None)
- with mock.patch.object(io, "open", autospec=True) as open_:
+ with mock.patch.object(builtins, "open", autospec=True) as open_:
results = tasks.create_report(mock.sentinel.RESULTS, config)
assert not open_.called
assert results is mock.sentinel.RESULTS
-def test_create_report():
+def test_create_report() -> None:
config = _options.options.namespace(report="/path/to/report")
results = [
sessions.Result(
- session=argparse.Namespace(
- signatures=["foosig"], name="foo", func=object()
+ session=typing.cast(
+ "sessions.SessionRunner",
+ argparse.Namespace(signatures=["foosig"], name="foo", func=object()),
),
status=sessions.Status.SUCCESS,
)
]
- with mock.patch.object(io, "open", autospec=True) as open_:
+ with mock.patch.object(builtins, "open", autospec=True) as open_:
with mock.patch.object(json, "dump", autospec=True) as dump:
answer = tasks.create_report(results, config)
assert answer is results
@@ -371,17 +776,87 @@ def test_create_report():
"result": "success",
"result_code": 1,
"args": {},
+ "duration": 0.0,
}
],
},
mock.ANY,
indent=2,
)
- open_.assert_called_once_with("/path/to/report", "w")
+ open_.assert_called_once_with("/path/to/report", "w", encoding="utf-8")
+
+
+def test_honor_usage_request_noop() -> None:
+ config = _options.options.namespace(usage=None)
+ manifest = typing.cast("Manifest", {"thing": mock.sentinel.THING})
+ return_value = tasks.honor_usage_request(manifest, global_config=config)
+ assert return_value is manifest
+
+
+def test_honor_usage_request_with_docstring(
+ capsys: pytest.CaptureFixture[builtins.str],
+) -> None:
+ config = _options.options.namespace(usage=["my_session"])
+ manifest = mock.create_autospec(Manifest)
+ session = argparse.Namespace(
+ name="my_session",
+ signatures=["my_session"],
+ full_description="Full docstring\n\nWith details",
+ )
+ manifest._all_sessions = [session]
+ return_value = tasks.honor_usage_request(manifest, global_config=config)
+ assert return_value == 0
+ out = capsys.readouterr().out
+ assert "Full docstring\n\nWith details" in out
+
+
+def test_honor_usage_request_no_docstring() -> None:
+ config = _options.options.namespace(usage=["my_session"])
+ manifest = mock.create_autospec(Manifest)
+ session = argparse.Namespace(
+ name="my_session",
+ signatures=["my_session"],
+ full_description=None,
+ )
+ manifest._all_sessions = [session]
+ return_value = tasks.honor_usage_request(manifest, global_config=config)
+ assert return_value == 1
+
+
+def test_honor_usage_request_skips_non_matching_sessions(
+ capsys: pytest.CaptureFixture[builtins.str],
+) -> None:
+ config = _options.options.namespace(usage=["second"])
+ manifest = mock.create_autospec(Manifest)
+ first = argparse.Namespace(
+ name="first",
+ signatures=["first"],
+ full_description="First docstring",
+ )
+ second = argparse.Namespace(
+ name="second",
+ signatures=["second"],
+ full_description="Second docstring",
+ )
+ manifest._all_sessions = [first, second]
+ return_value = tasks.honor_usage_request(manifest, global_config=config)
+ assert return_value == 0
+ out = capsys.readouterr().out
+ assert "Second docstring" in out
+
+
+def test_honor_usage_request_session_not_found() -> None:
+ config = _options.options.namespace(usage=["nonexistent"])
+ manifest = mock.create_autospec(Manifest)
+ manifest._all_sessions = []
+ return_value = tasks.honor_usage_request(manifest, global_config=config)
+ assert return_value == 1
-def test_final_reduce():
- config = object()
- assert tasks.final_reduce([True, True], config) == 0
- assert tasks.final_reduce([True, False], config) == 1
+def test_final_reduce() -> None:
+ config = argparse.Namespace()
+ true = typing.cast("sessions.Result", True) # noqa: FBT003
+ false = typing.cast("sessions.Result", False) # noqa: FBT003
+ assert tasks.final_reduce([true, true], config) == 0
+ assert tasks.final_reduce([true, false], config) == 1
assert tasks.final_reduce([], config) == 0
diff --git a/tests/test_toml.py b/tests/test_toml.py
new file mode 100644
index 00000000..33609b31
--- /dev/null
+++ b/tests/test_toml.py
@@ -0,0 +1,122 @@
+import textwrap
+from pathlib import Path
+
+import pytest
+
+import nox
+
+
+def test_load_pyproject(tmp_path: Path) -> None:
+ filepath = tmp_path / "example.toml"
+ filepath.write_text(
+ """
+ [project]
+ name = "hi"
+ version = "1.0"
+ dependencies = ["numpy", "requests"]
+ """,
+ encoding="utf-8",
+ )
+
+ toml = nox.project.load_toml(filepath)
+ assert toml["project"]["dependencies"] == ["numpy", "requests"]
+
+
+@pytest.mark.parametrize("example", ["example.py", "example"])
+def test_load_script_block(tmp_path: Path, example: str) -> None:
+ filepath = tmp_path / example
+ filepath.write_text(
+ textwrap.dedent(
+ """\
+ #!/usr/bin/env pipx run
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "requests<3",
+ # "rich",
+ # ]
+ # ///
+
+ import requests
+ from rich.pretty import pprint
+
+ resp = requests.get("https://peps.python.org/api/peps.json")
+ data = resp.json()
+ pprint([(k, v["title"]) for k, v in data.items()][:10])
+ """
+ ),
+ encoding="utf-8",
+ )
+
+ toml = nox.project.load_toml(filepath)
+ assert toml["dependencies"] == ["requests<3", "rich"]
+
+
+def test_load_no_script_block(tmp_path: Path) -> None:
+ filepath = tmp_path / "example.py"
+ filepath.write_text(
+ textwrap.dedent(
+ """\
+ #!/usr/bin/python
+
+ import requests
+ from rich.pretty import pprint
+
+ resp = requests.get("https://peps.python.org/api/peps.json")
+ data = resp.json()
+ pprint([(k, v["title"]) for k, v in data.items()][:10])
+ """
+ ),
+ encoding="utf-8",
+ )
+
+ with pytest.raises(ValueError, match="No script block found"):
+ nox.project.load_toml(filepath)
+
+
+def test_load_multiple_script_block(tmp_path: Path) -> None:
+ filepath = tmp_path / "example.py"
+ filepath.write_text(
+ textwrap.dedent(
+ """\
+ # /// script
+ # dependencies = [
+ # "requests<3",
+ # "rich",
+ # ]
+ # ///
+
+ # /// script
+ # requires-python = ">=3.11"
+ # ///
+
+ import requests
+ from rich.pretty import pprint
+
+ resp = requests.get("https://peps.python.org/api/peps.json")
+ data = resp.json()
+ pprint([(k, v["title"]) for k, v in data.items()][:10])
+ """
+ ),
+ encoding="utf-8",
+ )
+
+ with pytest.raises(ValueError, match="Multiple script blocks found"):
+ nox.project.load_toml(filepath)
+
+
+def test_load_non_recognised_extension() -> None:
+ msg = "Extension must be .py or .toml, got .txt"
+ with pytest.raises(ValueError, match=msg):
+ nox.project.load_toml("some.txt")
+
+
+def test_load_missing_script_missing_ok(tmp_path: Path) -> None:
+ filepath = tmp_path / "noxfile.py"
+ assert nox.project.load_toml(filepath, missing_ok=True) == {}
+
+
+def test_load_missing_script_default_raises(tmp_path: Path) -> None:
+ filepath = tmp_path / "noxfile.py"
+ with pytest.raises(FileNotFoundError):
+ nox.project.load_toml(filepath)
diff --git a/tests/test_tox_to_nox.py b/tests/test_tox_to_nox.py
index 57ec2bf7..a8c3dc2c 100644
--- a/tests/test_tox_to_nox.py
+++ b/tests/test_tox_to_nox.py
@@ -12,35 +12,52 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
+import os
+import shutil
import sys
import textwrap
+from pathlib import Path
+from typing import TYPE_CHECKING
import pytest
-from nox import tox_to_nox
+# Jinja2 might be missing
+tox_to_nox = pytest.importorskip("nox.tox_to_nox")
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+
+pytestmark = pytest.mark.skipif(shutil.which("tox") is None, reason="Tox not available")
+
+PYTHON_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}"
+PYTHON_VERSION_NODOT = PYTHON_VERSION.replace(".", "")
@pytest.fixture
-def makeconfig(tmpdir):
- def makeconfig(toxini_content):
- tmpdir.join("tox.ini").write(toxini_content)
- old = tmpdir.chdir()
+def makeconfig(tmp_path: Path) -> Callable[[str], str]:
+ def makeconfig(toxini_content: str) -> str:
+ tmp_path.joinpath("tox.ini").write_text(toxini_content, encoding="utf-8")
+ old = Path.cwd().resolve()
+ os.chdir(tmp_path)
try:
sys.argv = [sys.executable]
tox_to_nox.main()
- return tmpdir.join("noxfile.py").read()
+ return tmp_path.joinpath("noxfile.py").read_text(encoding="utf-8")
finally:
- old.chdir()
+ os.chdir(old)
return makeconfig
-def test_trivial(makeconfig):
+def test_trivial(makeconfig: Callable[[str], str]) -> None:
result = makeconfig(
textwrap.dedent(
- """
+ f"""
[tox]
- envlist = py27
+ envlist = py{PYTHON_VERSION_NODOT}
"""
)
)
@@ -48,24 +65,24 @@ def test_trivial(makeconfig):
assert (
result
== textwrap.dedent(
- """
+ f"""
import nox
- @nox.session(python='python2.7')
- def py27(session):
+ @nox.session(python='python{PYTHON_VERSION}')
+ def py{PYTHON_VERSION_NODOT}(session):
session.install('.')
"""
).lstrip()
)
-def test_skipinstall(makeconfig):
+def test_skipinstall(makeconfig: Callable[[str], str]) -> None:
result = makeconfig(
textwrap.dedent(
- """
+ f"""
[tox]
- envlist = py27
+ envlist = py{PYTHON_VERSION_NODOT}
[testenv]
skip_install = True
@@ -76,23 +93,23 @@ def test_skipinstall(makeconfig):
assert (
result
== textwrap.dedent(
- """
+ f"""
import nox
- @nox.session(python='python2.7')
- def py27(session):
+ @nox.session(python='python{PYTHON_VERSION}')
+ def py{PYTHON_VERSION_NODOT}(session):
"""
).lstrip()
)
-def test_usedevelop(makeconfig):
+def test_usedevelop(makeconfig: Callable[[str], str]) -> None:
result = makeconfig(
textwrap.dedent(
- """
+ f"""
[tox]
- envlist = py27
+ envlist = py{PYTHON_VERSION_NODOT}
[testenv]
usedevelop = True
@@ -103,27 +120,27 @@ def test_usedevelop(makeconfig):
assert (
result
== textwrap.dedent(
- """
+ f"""
import nox
- @nox.session(python='python2.7')
- def py27(session):
+ @nox.session(python='python{PYTHON_VERSION}')
+ def py{PYTHON_VERSION_NODOT}(session):
session.install('-e', '.')
"""
).lstrip()
)
-def test_commands(makeconfig):
+def test_commands(makeconfig: Callable[[str], str]) -> None:
result = makeconfig(
textwrap.dedent(
- """
+ f"""
[tox]
envlist = lint
[testenv:lint]
- basepython = python2.7
+ basepython = python{PYTHON_VERSION}
commands =
python setup.py check --metadata --restructuredtext --strict
flake8 \\
@@ -136,11 +153,11 @@ def test_commands(makeconfig):
assert (
result
== textwrap.dedent(
- """
+ f"""
import nox
- @nox.session(python='python2.7')
+ @nox.session(python='python{PYTHON_VERSION}')
def lint(session):
session.install('.')
session.run('python', 'setup.py', 'check', '--metadata', \
@@ -151,15 +168,15 @@ def lint(session):
)
-def test_deps(makeconfig):
+def test_deps(makeconfig: Callable[[str], str]) -> None:
result = makeconfig(
textwrap.dedent(
- """
+ f"""
[tox]
envlist = lint
[testenv:lint]
- basepython = python2.7
+ basepython = python{PYTHON_VERSION}
deps =
flake8
gcp-devrel-py-tools>=0.0.3
@@ -170,11 +187,11 @@ def test_deps(makeconfig):
assert (
result
== textwrap.dedent(
- """
+ f"""
import nox
- @nox.session(python='python2.7')
+ @nox.session(python='python{PYTHON_VERSION}')
def lint(session):
session.install('flake8', 'gcp-devrel-py-tools>=0.0.3')
session.install('.')
@@ -183,15 +200,15 @@ def lint(session):
)
-def test_env(makeconfig):
+def test_env(makeconfig: Callable[[str], str]) -> None:
result = makeconfig(
textwrap.dedent(
- """
+ f"""
[tox]
envlist = lint
[testenv:lint]
- basepython = python2.7
+ basepython = python{PYTHON_VERSION}
setenv =
SPHINX_APIDOC_OPTIONS=members,inherited-members,show-inheritance
TEST=meep
@@ -202,11 +219,11 @@ def test_env(makeconfig):
assert (
result
== textwrap.dedent(
- """
+ f"""
import nox
- @nox.session(python='python2.7')
+ @nox.session(python='python{PYTHON_VERSION}')
def lint(session):
session.env['SPHINX_APIDOC_OPTIONS'] = \
'members,inherited-members,show-inheritance'
@@ -217,15 +234,48 @@ def lint(session):
)
-def test_chdir(makeconfig):
+def test_env_with_equals_sign_in_value(makeconfig: Callable[[str], str]) -> None:
result = makeconfig(
textwrap.dedent(
- """
+ f"""
[tox]
envlist = lint
[testenv:lint]
- basepython = python2.7
+ basepython = python{PYTHON_VERSION}
+ setenv =
+ URL=https://example.com/?a=1&b=2
+ TEST=meep=morp
+ """
+ )
+ )
+
+ assert (
+ result
+ == textwrap.dedent(
+ f"""
+ import nox
+
+
+ @nox.session(python='python{PYTHON_VERSION}')
+ def lint(session):
+ session.env['TEST'] = 'meep=morp'
+ session.env['URL'] = 'https://example.com/?a=1&b=2'
+ session.install('.')
+ """
+ ).lstrip()
+ )
+
+
+def test_chdir(makeconfig: Callable[[str], str]) -> None:
+ result = makeconfig(
+ textwrap.dedent(
+ f"""
+ [tox]
+ envlist = lint
+
+ [testenv:lint]
+ basepython = python{PYTHON_VERSION}
changedir = docs
"""
)
@@ -234,14 +284,137 @@ def test_chdir(makeconfig):
assert (
result
== textwrap.dedent(
- """
+ f"""
import nox
- @nox.session(python='python2.7')
+ @nox.session(python='python{PYTHON_VERSION}')
def lint(session):
session.install('.')
session.chdir('docs')
"""
).lstrip()
)
+
+
+def test_chdir_outside_project(
+ makeconfig: Callable[[str], str], tmp_path: Path
+) -> None:
+ outside = tmp_path.parent / f"{tmp_path.name}-outside"
+ outside.mkdir()
+ result = makeconfig(
+ textwrap.dedent(
+ f"""
+ [tox]
+ envlist = lint
+
+ [testenv:lint]
+ basepython = python{PYTHON_VERSION}
+ changedir = {outside}
+ """
+ )
+ )
+
+ assert (
+ result
+ == textwrap.dedent(
+ f"""
+ import nox
+
+
+ @nox.session(python='python{PYTHON_VERSION}')
+ def lint(session):
+ session.install('.')
+ session.chdir('{outside}')
+ """
+ ).lstrip()
+ )
+
+
+def test_dash_in_envname(makeconfig: Callable[[str], str]) -> None:
+ result = makeconfig(
+ textwrap.dedent(
+ f"""
+ [tox]
+ envlist = test-with-dash
+
+ [testenv:test-with-dash]
+ basepython = python{PYTHON_VERSION}
+ """
+ )
+ )
+
+ assert (
+ result
+ == textwrap.dedent(
+ f"""
+ import nox
+
+
+ @nox.session(python='python{PYTHON_VERSION}')
+ def test_with_dash(session):
+ session.install('.')
+ """
+ ).lstrip()
+ )
+
+
+def test_descriptions_into_docstrings(makeconfig: Callable[[str], str]) -> None:
+ result = makeconfig(
+ textwrap.dedent(
+ f"""
+ [tox]
+ envlist = lint
+
+ [testenv:lint]
+ basepython = python{PYTHON_VERSION}
+ description =
+ runs the lint action
+ now with an unnecessary second line
+ """
+ )
+ )
+
+ assert (
+ result
+ == textwrap.dedent(
+ f"""
+ import nox
+
+
+ @nox.session(python='python{PYTHON_VERSION}')
+ def lint(session):
+ \"\"\"runs the lint action now with an unnecessary second line\"\"\"
+ session.install('.')
+ """
+ ).lstrip()
+ )
+
+
+def test_commands_with_requirements(makeconfig: Callable[[str], str]) -> None:
+ result = makeconfig(
+ textwrap.dedent("""
+ [tox]
+ envlist = aiohttp
+
+ [testenv]
+ use_develop = true
+ deps =
+ pytest
+ pytest-cov
+ aiohttp: -r requirements/aiohttp.txt
+ """)
+ )
+
+ assert (
+ result
+ == textwrap.dedent(f"""
+ import nox
+
+
+ @nox.session(python='python{PYTHON_VERSION}')
+ def aiohttp(session):
+ session.install('pytest', 'pytest-cov', '-r', 'requirements/aiohttp.txt')
+ session.install('-e', '.')
+ """).lstrip()
+ )
diff --git a/tests/test_virtualenv.py b/tests/test_virtualenv.py
index ac0fa598..8274de36 100644
--- a/tests/test_virtualenv.py
+++ b/tests/test_virtualenv.py
@@ -12,241 +12,566 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
+import functools
import os
+import re
import shutil
+import subprocess
import sys
+import types
+from importlib import metadata
+from pathlib import Path
from textwrap import dedent
+from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn
from unittest import mock
-import py
import pytest
+from packaging import version
+import nox.command
import nox.virtualenv
-IS_WINDOWS = nox.virtualenv._SYSTEM == "Windows"
-HAS_CONDA = shutil.which("conda") is not None
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from nox.virtualenv import CondaEnv, ProcessEnv, VirtualEnv
+
+IS_WINDOWS = sys.platform.startswith("win")
+# Under MSYS2/MinGW, sys.platform is "win32" but the native venv layout is POSIX
+# ("bin"/"python"), so Windows-layout assertions must exclude this case.
+IS_MINGW = nox.virtualenv._IS_MINGW
+HAS_UV = shutil.which("uv") is not None
RAISE_ERROR = "RAISE_ERROR"
+VIRTUALENV_VERSION = metadata.version("virtualenv")
+has_uv = pytest.mark.skipif(not HAS_UV, reason="Missing uv command.")
-@pytest.fixture
-def make_one(tmpdir):
- def factory(*args, **kwargs):
- location = tmpdir.join("venv")
- venv = nox.virtualenv.VirtualEnv(location.strpath, *args, **kwargs)
- return (venv, location)
- return factory
+class TextProcessResult(NamedTuple):
+ stdout: str
+ returncode: int = 0
@pytest.fixture
-def make_conda(tmpdir):
- def factory(*args, **kwargs):
- location = tmpdir.join("condaenv")
- venv = nox.virtualenv.CondaEnv(location.strpath, *args, **kwargs)
+def make_one(
+ tmp_path: Path,
+) -> Callable[..., tuple[nox.virtualenv.VirtualEnv | nox.virtualenv.ProcessEnv, Path]]:
+ def factory(
+ *args: Any, venv_backend: str = "virtualenv", **kwargs: Any
+ ) -> tuple[nox.virtualenv.VirtualEnv | nox.virtualenv.ProcessEnv, Path]:
+ location = tmp_path.joinpath("venv")
+ try:
+ venv_fn = nox.virtualenv.ALL_VENVS[venv_backend]
+ except KeyError:
+ venv_fn = functools.partial(
+ nox.virtualenv.VirtualEnv, venv_backend=venv_backend
+ )
+ venv = venv_fn(str(location), *args, **kwargs)
return (venv, location)
return factory
@pytest.fixture
-def make_mocked_interpreter_path():
- """Provides a factory to create a mocked ``path`` object pointing
- to a python interpreter.
-
- This mocked ``path`` provides
- - a ``__str__`` which is equal to the factory's ``path`` parameter
- - a ``sysexec`` method which returns the value of the
- factory's ``sysexec_result`` parameter.
- (the ``sysexec_result`` parameter can be a version string
- or ``RAISE_ERROR``).
- """
-
- def factory(path, sysexec_result):
- def mock_sysexec(*_):
- if sysexec_result == RAISE_ERROR:
- raise py.process.cmdexec.Error(1, 1, "", "", "")
- else:
- return sysexec_result
-
- attrs = {
- "sysexec.side_effect": mock_sysexec,
- "__str__": mock.Mock(return_value=path),
- }
- mock_python = mock.Mock()
- mock_python.configure_mock(**attrs)
-
- return mock_python
+def make_conda(tmp_path: Path) -> Callable[..., tuple[CondaEnv, Path]]:
+ def factory(*args: Any, **kwargs: Any) -> tuple[CondaEnv, Path]:
+ location = tmp_path.joinpath("condaenv")
+ venv = nox.virtualenv.CondaEnv(str(location), *args, **kwargs)
+ return (venv, location)
return factory
@pytest.fixture
-def patch_sysfind(make_mocked_interpreter_path):
+def patch_sysfind(
+ monkeypatch: pytest.MonkeyPatch,
+) -> Callable[[tuple[str, ...], str | None, str], None]:
"""Provides a function to patch ``sysfind`` with parameters for tests related
to locating a Python interpreter in the system ``PATH``.
"""
- def patcher(sysfind, only_find, sysfind_result, sysexec_result):
- """Returns an extended ``sysfind`` patch object for tests related to locating a
- Python interpreter in the system ``PATH``.
+ def patcher(
+ only_find: tuple[str, ...], sysfind_result: str | None, sysexec_result: str
+ ) -> None:
+ """Monkeypatches python discovery, causing specific results to be found.
Args:
- sysfind: The original sysfind patch object
- only_find (Tuple[str]): The strings for which ``sysfind`` should be successful,
+ only_find (Tuple[str]): The strings for which ``shutil.which`` should be successful,
e.g. ``("python", "python.exe")``
sysfind_result (Optional[str]): The ``path`` string to create the returned
mocked ``path`` object with which will represent the found Python interpreter,
or ``None``.
- This parameter is passed on to ``make_mocked_interpreter_path``.
sysexec_result (str): A string that should be returned when executing the
mocked ``path`` object. Usually a Python version string.
Use the global ``RAISE_ERROR`` to have ``sysexec`` fail.
- This parameter is passed on to ``make_mocked_interpreter_path``.
"""
- mock_python = make_mocked_interpreter_path(sysfind_result, sysexec_result)
- def mock_sysfind(arg):
+ def special_which(name: str, path: Any = None) -> str | None: # noqa: ARG001
if sysfind_result is None:
return None
- elif arg.lower() in only_find:
- return mock_python
- else:
- return None
+ if name.lower() in only_find:
+ return sysfind_result or name
+ return None
+
+ monkeypatch.setattr(shutil, "which", special_which)
- sysfind.side_effect = mock_sysfind
+ def special_run(cmd: Any, *args: Any, **kwargs: Any) -> TextProcessResult: # noqa: ARG001
+ return TextProcessResult(sysexec_result)
- return sysfind
+ monkeypatch.setattr(subprocess, "run", special_run)
return patcher
-def test_process_env_constructor():
- penv = nox.virtualenv.ProcessEnv()
+def test_process_env_constructor() -> None:
+ penv = nox.virtualenv.PassthroughEnv()
assert not penv.bin_paths
with pytest.raises(
ValueError, match=r"^The environment does not have a bin directory\.$"
):
- penv.bin
+ print(penv.bin)
- penv = nox.virtualenv.ProcessEnv(env={"SIGIL": "123"})
+ penv = nox.virtualenv.PassthroughEnv(env={"SIGIL": "123"})
assert penv.env["SIGIL"] == "123"
- penv = nox.virtualenv.ProcessEnv(bin_paths=["/bin"])
+ penv = nox.virtualenv.PassthroughEnv(bin_paths=["/bin"])
assert penv.bin == "/bin"
-def test_process_env_create():
- penv = nox.virtualenv.ProcessEnv()
- with pytest.raises(NotImplementedError):
- penv.create()
+def test_process_env_create() -> None:
+ with pytest.raises(TypeError):
+ nox.virtualenv.ProcessEnv() # type: ignore[abstract]
+
+
+def test_process_env_get_env_with_bin_paths() -> None:
+ penv = nox.virtualenv.PassthroughEnv(bin_paths=["/test/bin"])
+ env = penv._get_env({})
+ path = env.get("PATH")
+ assert path
+ assert "/test/bin" in path
+
+
+def test_process_env_get_env_exclude_outer() -> None:
+ penv = nox.virtualenv.PassthroughEnv(bin_paths=["/test/bin"], env={"TEST": "value"})
+ env = penv._get_env({}, include_outer_env=False)
+ assert env["TEST"] == "value"
+ assert env["PATH"] == "/test/bin"
+
+
+def test_ensure_gitignore_creates_file(tmp_path: Path) -> None:
+ envdir = tmp_path.joinpath(".nox")
+ location = envdir.joinpath("session")
+
+ nox.virtualenv._ensure_gitignore(location.parent)
+
+ assert envdir.joinpath(".gitignore").read_text(encoding="utf-8") == "*\n"
+
+
+def test_ensure_cachedir_tag_creates_file(tmp_path: Path) -> None:
+ envdir = tmp_path.joinpath(".nox")
+ location = envdir.joinpath("session")
+
+ nox.virtualenv._ensure_cachedir_tag(location.parent)
+
+ assert (
+ envdir.joinpath("CACHEDIR.TAG").read_text(encoding="utf-8")
+ == "Signature: 8a477f597d28d172789f06886806bc55\n"
+ )
+
+
+def test_ensure_parent_gitignore_keeps_existing_file(tmp_path: Path) -> None:
+ envdir = tmp_path.joinpath(".nox")
+ envdir.mkdir()
+ gitignore = envdir.joinpath(".gitignore")
+ gitignore.write_text("!keep\n", encoding="utf-8")
+
+ nox.virtualenv._ensure_gitignore(envdir)
+
+ assert gitignore.read_text(encoding="utf-8") == "!keep\n"
+
+
+def test_ensure_parent_cachedir_tag_keeps_existing_file(tmp_path: Path) -> None:
+ envdir = tmp_path.joinpath(".nox")
+ envdir.mkdir()
+ cachedir_tag = envdir.joinpath("CACHEDIR.TAG")
+ cachedir_tag.write_text("!keep\n", encoding="utf-8")
+
+ nox.virtualenv._ensure_cachedir_tag(envdir)
+
+ assert cachedir_tag.read_text(encoding="utf-8") == "!keep\n"
+
+
+def test_invalid_venv_create(
+ make_one: Callable[
+ ..., tuple[nox.virtualenv.VirtualEnv | nox.virtualenv.ProcessEnv, Path]
+ ],
+) -> None:
+ with pytest.raises(ValueError, match="venv_backend 'invalid' not recognized"):
+ make_one(venv_backend="invalid")
+
+
+def test_get_virtualenv_invalid_backend(
+ tmp_path: Path,
+) -> None:
+ with pytest.raises(ValueError, match="Expected venv_backend one of"):
+ nox.virtualenv.get_virtualenv(
+ "invalid",
+ download_python="auto",
+ envdir=str(tmp_path),
+ reuse_existing=False,
+ )
+
+
+def test_get_virtualenv_fallback_to_available_backend(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Force optional backends to be unavailable so fallback behavior is deterministic.
+ monkeypatch.setattr(
+ nox.virtualenv,
+ "OPTIONAL_VENVS",
+ {"conda": False, "mamba": False, "micromamba": False, "uv": False},
+ )
+ venv = nox.virtualenv.get_virtualenv(
+ "conda",
+ "mamba",
+ "venv",
+ download_python="auto",
+ envdir=str(tmp_path),
+ reuse_existing=False,
+ )
+ assert isinstance(venv, nox.virtualenv.VirtualEnv)
+ assert venv.venv_backend == "venv"
+
+
+def test_get_virtualenv_no_backends_available(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Simulate no optional backends present.
+ monkeypatch.setattr(
+ nox.virtualenv,
+ "OPTIONAL_VENVS",
+ {"conda": False, "mamba": False, "micromamba": False, "uv": False},
+ )
+ with pytest.raises(ValueError, match="No backends present"):
+ nox.virtualenv.get_virtualenv(
+ "conda",
+ "mamba",
+ download_python="auto",
+ envdir=str(tmp_path),
+ reuse_existing=False,
+ )
+
+
+def test_get_virtualenv_none_backend(
+ tmp_path: Path,
+) -> None:
+ venv = nox.virtualenv.get_virtualenv(
+ "none",
+ download_python="auto",
+ envdir=str(tmp_path),
+ reuse_existing=False,
+ )
+ assert isinstance(venv, nox.virtualenv.ProcessEnv)
+
+
+def test_get_virtualenv_interpreter_false(
+ tmp_path: Path,
+) -> None:
+ venv = nox.virtualenv.get_virtualenv(
+ "venv",
+ download_python="auto",
+ envdir=str(tmp_path),
+ reuse_existing=False,
+ interpreter=False,
+ )
+ assert isinstance(venv, nox.virtualenv.ProcessEnv)
-def test_condaenv_constructor_defaults(make_conda):
+def test_get_virtualenv_non_optional_fallback(
+ tmp_path: Path,
+) -> None:
+ with pytest.raises(
+ ValueError, match=r"Only optional backends.*may have a fallback"
+ ):
+ nox.virtualenv.get_virtualenv(
+ "venv",
+ "uv",
+ download_python="auto",
+ envdir=str(tmp_path),
+ reuse_existing=False,
+ )
+
+
+def test_condaenv_constructor_defaults(
+ make_conda: Callable[..., tuple[CondaEnv, Path]],
+) -> None:
venv, _ = make_conda()
assert venv.location
assert venv.interpreter is None
assert venv.reuse_existing is False
-def test_condaenv_constructor_explicit(make_conda):
+def test_condaenv_constructor_explicit(
+ make_conda: Callable[..., tuple[CondaEnv, Path]],
+) -> None:
venv, _ = make_conda(interpreter="3.5", reuse_existing=True)
assert venv.location
assert venv.interpreter == "3.5"
assert venv.reuse_existing is True
-@pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.")
-def test_condaenv_create(make_conda):
+def test_condaenv_create(make_conda: Callable[..., tuple[CondaEnv, Path]]) -> None:
venv, dir_ = make_conda()
venv.create()
if IS_WINDOWS:
- assert dir_.join("python.exe").check()
- assert dir_.join("Scripts", "pip.exe").check()
- assert dir_.join("Library").check()
+ assert dir_.joinpath("python.exe").exists()
+ assert dir_.joinpath("Scripts", "pip.exe").exists()
+ assert dir_.joinpath("Library").exists()
else:
- assert dir_.join("bin", "python").check()
- assert dir_.join("bin", "pip").check()
- assert dir_.join("lib").check()
+ assert dir_.joinpath("bin", "python").exists()
+ assert dir_.joinpath("bin", "pip").exists()
+ assert dir_.joinpath("lib").exists()
# Test running create on an existing environment. It should be deleted.
- dir_.ensure("test.txt")
+ dir_.joinpath("test.txt").touch()
venv.create()
- assert not dir_.join("test.txt").check()
+ assert not dir_.joinpath("test.txt").exists()
- # Test running create on an existing environment with reuse_exising
+ # Test running create on an existing environment with reuse_existing
# enabled, it should not be deleted.
- dir_.ensure("test.txt")
- assert dir_.join("test.txt").check()
+ dir_.joinpath("test.txt").touch()
+ assert dir_.joinpath("test.txt").exists()
venv.reuse_existing = True
venv.create()
- assert dir_.join("test.txt").check()
+ assert dir_.joinpath("test.txt").exists()
assert venv._reused
-@pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.")
-def test_condaenv_create_with_params(make_conda):
+def test_condaenv_create_with_params(
+ make_conda: Callable[..., tuple[CondaEnv, Path]],
+) -> None:
venv, dir_ = make_conda(venv_params=["--verbose"])
venv.create()
if IS_WINDOWS:
- assert dir_.join("python.exe").check()
- assert dir_.join("Scripts", "pip.exe").check()
+ assert dir_.joinpath("python.exe").exists()
+ assert dir_.joinpath("Scripts", "pip.exe").exists()
else:
- assert dir_.join("bin", "python").check()
- assert dir_.join("bin", "pip").check()
+ assert dir_.joinpath("bin", "python").exists()
+ assert dir_.joinpath("bin", "pip").exists()
-@pytest.mark.skipif(not HAS_CONDA, reason="Missing conda command.")
-def test_condaenv_create_interpreter(make_conda):
- venv, dir_ = make_conda(interpreter="3.7")
+def test_condaenv_create_interpreter(
+ make_conda: Callable[..., tuple[CondaEnv, Path]],
+) -> None:
+ venv, dir_ = make_conda(interpreter="3.12")
venv.create()
if IS_WINDOWS:
- assert dir_.join("python.exe").check()
- assert dir_.join("python37.dll").check()
- assert dir_.join("python37.pdb").check()
- assert not dir_.join("python37.exe").check()
+ assert dir_.joinpath("python.exe").exists()
+ assert dir_.joinpath("python312.dll").exists()
+ assert dir_.joinpath("python312.pdb").exists()
+ assert not dir_.joinpath("python312.exe").exists()
else:
- assert dir_.join("bin", "python").check()
- assert dir_.join("bin", "python3.7").check()
+ assert dir_.joinpath("bin", "python").exists()
+ assert dir_.joinpath("bin", "python3.12").exists()
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-def test_condaenv_bin_windows(make_conda):
- venv, dir_ = make_conda()
- assert [dir_.strpath, dir_.join("Scripts").strpath] == venv.bin_paths
+def test_conda_env_create_verbose(
+ make_conda: Callable[..., tuple[CondaEnv, Path]],
+) -> None:
+ venv, _dir = make_conda()
+ with mock.patch("nox.virtualenv.nox.command.run") as mock_run:
+ venv.create()
+ _args, kwargs = mock_run.call_args
+ assert kwargs["log"] is False
-def test_condaenv_(make_conda):
+ nox.options.verbose = True
+ with mock.patch("nox.virtualenv.nox.command.run") as mock_run:
+ venv.create()
+
+ _args, kwargs = mock_run.call_args
+ assert kwargs["log"]
+
+
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+def test_condaenv_bin_windows(make_conda: Callable[..., tuple[CondaEnv, Path]]) -> None:
venv, dir_ = make_conda()
+ assert [
+ str(dir_),
+ str(dir_.joinpath("Library", "mingw-w64", "bin")),
+ str(dir_.joinpath("Library", "usr", "bin")),
+ str(dir_.joinpath("Library", "bin")),
+ str(dir_.joinpath("Scripts")),
+ str(dir_.joinpath("bin")),
+ ] == venv.bin_paths
+
+
+def test_condaenv_(make_conda: Callable[..., tuple[CondaEnv, Path]]) -> None:
+ venv, _dir = make_conda()
assert not venv.is_offline()
-def test_constructor_defaults(make_one):
+def test_condaenv_detection(make_conda: Callable[..., tuple[CondaEnv, Path]]) -> None:
+ venv, dir_ = make_conda()
+ venv.create()
+ conda = shutil.which("conda")
+ assert conda
+
+ env = {k: v for k, v in {**os.environ, **venv.env}.items() if v is not None}
+
+ proc_result = subprocess.run(
+ [conda, "list"],
+ env=env,
+ check=True,
+ capture_output=True,
+ )
+ output = proc_result.stdout.decode()
+ path_regex = re.compile(r"packages in environment at (?P.+):")
+
+ output_match = path_regex.search(output)
+ assert output_match
+ assert dir_.samefile(output_match.group("env_dir"))
+
+
+def test_create_args_new_uv(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ run_mock = mock.Mock()
+ monkeypatch.setattr(nox.command, "run", run_mock)
+ monkeypatch.setattr(nox.virtualenv, "UV", "uv")
+ monkeypatch.setattr(nox.virtualenv, "HAS_UV", True)
+ monkeypatch.setattr(nox.virtualenv, "UV_VERSION", version.Version("0.10.0"))
+ venv, _ = make_one(venv_backend="uv")
+ venv.create()
+ run_mock.assert_called_once()
+ assert run_mock.call_args.args[0][-1] == "--clear"
+
+
+def test_create_args_old_uv(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ run_mock = mock.Mock()
+ monkeypatch.setattr(nox.virtualenv, "UV", "uv")
+ monkeypatch.setattr(nox.virtualenv, "HAS_UV", True)
+ monkeypatch.setattr(nox.virtualenv, "UV_VERSION", version.Version("0.7.0"))
+ monkeypatch.setattr(nox.command, "run", run_mock)
+ venv, _ = make_one(venv_backend="uv")
+ venv.create()
+ run_mock.assert_called_once()
+ assert run_mock.call_args.args[0][-1] != "--clear"
+
+
+def test_create_uv_with_interpreter(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ run_mock = mock.Mock()
+ monkeypatch.setattr(nox.command, "run", run_mock)
+ monkeypatch.setattr(nox.virtualenv, "UV", "uv")
+ monkeypatch.setattr(nox.virtualenv, "HAS_UV", True)
+ monkeypatch.setattr(nox.virtualenv, "UV_VERSION", version.Version("0.10.0"))
+ venv, _ = make_one(interpreter="3.12", venv_backend="uv")
+ venv._resolved = "/path/to/python3.12"
+ venv.create()
+ run_mock.assert_called_once()
+ cmd = run_mock.call_args.args[0]
+ assert cmd[cmd.index("-p") + 1] == "/path/to/python3.12"
+
+
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch("nox.virtualenv._IS_MINGW", new=True)
+def test_create_uv_mingw_no_interpreter(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # uv can't inspect the MSYS2/MinGW interpreter, so nox must not pass ``-p``.
+ run_mock = mock.Mock()
+ monkeypatch.setattr(nox.command, "run", run_mock)
+ monkeypatch.setattr(nox.virtualenv, "UV", "uv")
+ monkeypatch.setattr(nox.virtualenv, "HAS_UV", True)
+ monkeypatch.setattr(nox.virtualenv, "UV_VERSION", version.Version("0.10.0"))
+ venv, _ = make_one(venv_backend="uv")
+ venv.create()
+ run_mock.assert_called_once()
+ assert "-p" not in run_mock.call_args.args[0]
+
+
+@has_uv
+def test_uv_creation(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ venv, _ = make_one(venv_backend="uv")
+ assert venv.location
+ assert venv.interpreter is None
+ assert venv.reuse_existing is False
+ assert venv.venv_backend == "uv"
+
+ venv.create()
+ assert venv._check_reused_environment_type()
+
+ venv.create()
+ assert venv._check_reused_environment_type()
+
+
+@has_uv
+def test_uv_managed_python(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ make_one(interpreter="cpython3.12", venv_backend="uv")
+
+
+def test_constructor_defaults(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
venv, _ = make_one()
assert venv.location
assert venv.interpreter is None
assert venv.reuse_existing is False
- assert venv.venv_or_virtualenv == "virtualenv"
+ assert venv.venv_backend == "virtualenv"
+
+
+def test_constructor_pypy_dash(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ venv, _ = make_one(interpreter="pypy-3.10")
+ assert venv.location
+ assert venv.interpreter == "pypy3.10"
+ assert venv.reuse_existing is False
+ assert venv.venv_backend == "virtualenv"
@pytest.mark.skipif(IS_WINDOWS, reason="Not testing multiple interpreters on Windows.")
-def test_constructor_explicit(make_one):
+def test_constructor_explicit(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
venv, _ = make_one(interpreter="python3.5", reuse_existing=True)
assert venv.location
assert venv.interpreter == "python3.5"
assert venv.reuse_existing is True
-def test_env(monkeypatch, make_one):
+def test_env(
+ monkeypatch: pytest.MonkeyPatch,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
monkeypatch.setenv("SIGIL", "123")
venv, _ = make_one()
- assert venv.env["SIGIL"] == "123"
assert len(venv.bin_paths) == 1
- assert venv.bin_paths[0] in venv.env["PATH"]
+ assert venv.bin_paths[0] == venv.bin
assert venv.bin_paths[0] not in os.environ["PATH"]
-def test_blacklisted_env(monkeypatch, make_one):
+def test_blacklisted_env(
+ monkeypatch: pytest.MonkeyPatch,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
monkeypatch.setenv("__PYVENV_LAUNCHER__", "meep")
venv, _ = make_one()
assert len(venv.bin_paths) == 1
@@ -254,10 +579,13 @@ def test_blacklisted_env(monkeypatch, make_one):
assert "__PYVENV_LAUNCHER__" not in venv.bin
-def test__clean_location(monkeypatch, make_one):
+def test__clean_location(
+ monkeypatch: pytest.MonkeyPatch,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
venv, dir_ = make_one()
- # Don't re-use existing, but doesn't currently exist.
+ # Don't reuse existing, but doesn't currently exist.
# Should return True indicating that the venv needs to be created.
monkeypatch.setattr(
nox.virtualenv.VirtualEnv, "_check_reused_environment_type", mock.MagicMock()
@@ -267,84 +595,132 @@ def test__clean_location(monkeypatch, make_one):
"_check_reused_environment_interpreter",
mock.MagicMock(),
)
- monkeypatch.delattr(nox.virtualenv.shutil, "rmtree")
- assert not dir_.check()
+ monkeypatch.delattr(nox.virtualenv.shutil, "rmtree") # type: ignore[attr-defined]
+ assert not dir_.exists()
assert venv._clean_location()
- # Re-use existing, and currently exists.
+ # Reuse existing, and currently exists.
# Should return False indicating that the venv doesn't need to be created.
dir_.mkdir()
- assert dir_.check()
+ assert dir_.exists()
venv.reuse_existing = True
assert not venv._clean_location()
- # Don't re-use existing, and currently exists.
+ # Don't reuse existing, and currently exists.
# Should return True indicating the venv needs to be created.
monkeypatch.undo()
- assert dir_.check()
+ assert dir_.exists()
venv.reuse_existing = False
assert venv._clean_location()
- assert not dir_.check()
+ assert not dir_.exists()
- # Re-use existing, but doesn't exist.
+ # Reuse existing, but doesn't exist.
# Should return True indicating the venv needs to be created.
venv.reuse_existing = True
assert venv._clean_location()
-def test_bin_paths(make_one):
+def test_bin_paths(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+) -> None:
venv, dir_ = make_one()
+ assert venv.bin_paths
assert len(venv.bin_paths) == 1
assert venv.bin_paths[0] == venv.bin
- if IS_WINDOWS:
- assert dir_.join("Scripts").strpath == venv.bin
- else:
- assert dir_.join("bin").strpath == venv.bin
+ win_layout = IS_WINDOWS and not IS_MINGW
+ assert str(dir_.joinpath("Scripts" if win_layout else "bin")) == venv.bin
+
+
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch("nox.virtualenv._IS_MINGW", new=False)
+def test_bin_windows(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+) -> None:
+ venv, dir_ = make_one()
+ assert venv.bin_paths
+ assert len(venv.bin_paths) == 1
+ assert venv.bin_paths[0] == venv.bin
+ assert str(dir_.joinpath("Scripts")) == venv.bin
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-def test_bin_windows(make_one):
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch("nox.virtualenv._IS_MINGW", new=True)
+def test_bin_windows_mingw(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+) -> None:
venv, dir_ = make_one()
+ assert venv.bin_paths
+ assert len(venv.bin_paths) == 1
+ assert venv.bin_paths[0] == venv.bin
+ assert str(dir_.joinpath("bin")) == venv.bin
+
+
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch("nox.virtualenv._IS_MINGW", new=True)
+def test_bin_windows_mingw_uv(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+) -> None:
+ # uv builds a Windows-style venv even under MinGW, unlike the native tools.
+ venv, dir_ = make_one(venv_backend="uv")
+ assert venv.bin_paths
assert len(venv.bin_paths) == 1
assert venv.bin_paths[0] == venv.bin
- assert dir_.join("Scripts").strpath == venv.bin
+ assert str(dir_.joinpath("Scripts")) == venv.bin
+
+def test_create(
+ monkeypatch: pytest.MonkeyPatch,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ monkeypatch.setenv("CONDA_PREFIX", "no-prefix-allowed")
+ monkeypatch.setenv("NOT_CONDA_PREFIX", "something-else")
-def test_create(monkeypatch, make_one):
venv, dir_ = make_one()
venv.create()
- if IS_WINDOWS:
- assert dir_.join("Scripts", "python.exe").check()
- assert dir_.join("Scripts", "pip.exe").check()
- assert dir_.join("Lib").check()
+ assert venv.env["CONDA_PREFIX"] is None
+ assert "NOT_CONDA_PREFIX" not in venv.env
+
+ if IS_WINDOWS and not IS_MINGW:
+ assert dir_.joinpath("Scripts", "python.exe").exists()
+ assert dir_.joinpath("Scripts", "pip.exe").exists()
+ assert dir_.joinpath("Lib").exists()
+ assert str(dir_.joinpath("Scripts")) in venv.bin_paths
+ elif IS_MINGW:
+ # MinGW uses a POSIX bin/ dir but Windows-style ``.exe`` executables.
+ assert dir_.joinpath("bin", "python.exe").exists()
+ assert dir_.joinpath("bin", "pip.exe").exists()
+ assert dir_.joinpath("lib").exists()
+ assert str(dir_.joinpath("bin")) in venv.bin_paths
else:
- assert dir_.join("bin", "python").check()
- assert dir_.join("bin", "pip").check()
- assert dir_.join("lib").check()
+ assert dir_.joinpath("bin", "python").exists()
+ assert dir_.joinpath("bin", "pip").exists()
+ assert dir_.joinpath("lib").exists()
+ assert str(dir_.joinpath("bin")) in venv.bin_paths
# Test running create on an existing environment. It should be deleted.
- dir_.ensure("test.txt")
+ dir_.joinpath("test.txt").touch()
venv.create()
- assert not dir_.join("test.txt").check()
+ assert not dir_.joinpath("test.txt").exists()
- # Test running create on an existing environment with reuse_exising
+ # Test running create on an existing environment with reuse_existing
# enabled, it should not be deleted.
- dir_.ensure("test.txt")
- assert dir_.join("test.txt").check()
+ dir_.joinpath("test.txt").touch()
+ assert dir_.joinpath("test.txt").exists()
venv.reuse_existing = True
- monkeypatch.setattr(nox.virtualenv.nox.command, "run", mock.MagicMock())
venv.create()
assert venv._reused
- assert dir_.join("test.txt").check()
+ assert dir_.joinpath("test.txt").exists()
-def test_create_reuse_environment(make_one):
- venv, location = make_one(reuse_existing=True)
+def test_create_reuse_environment(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+) -> None:
+ venv, _location = make_one(reuse_existing=True)
venv.create()
reused = not venv.create()
@@ -352,7 +728,13 @@ def test_create_reuse_environment(make_one):
assert reused
-def test_create_reuse_environment_with_different_interpreter(make_one, monkeypatch):
+def test_create_reuse_environment_with_different_interpreter(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Making the reuse requirement more strict
+ monkeypatch.setenv("NOX_ENABLE_STALENESS_CHECK", "1")
+
venv, location = make_one(reuse_existing=True)
venv.create()
@@ -360,49 +742,220 @@ def test_create_reuse_environment_with_different_interpreter(make_one, monkeypat
monkeypatch.setattr(venv, "_check_reused_environment_interpreter", lambda: False)
# Create a marker file. It should be gone after the environment is re-created.
- location.join("marker").ensure()
+ location.joinpath("marker").touch()
reused = not venv.create()
assert not reused
- assert not location.join("marker").check()
+ assert not location.joinpath("marker").exists()
-def test_create_reuse_stale_venv_environment(make_one):
+@pytest.mark.skipif(IS_WINDOWS, reason="Uses POSIX symlinks.")
+@pytest.mark.parametrize(
+ "venv_backend",
+ [
+ "venv",
+ "virtualenv",
+ pytest.param("uv", marks=has_uv),
+ ],
+)
+def test_create_reuse_environment_with_broken_interpreter_symlink(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+ venv_backend: str,
+) -> None:
+ venv, location = make_one(reuse_existing=True, venv_backend=venv_backend)
+ venv.create()
+
+ marker = location.joinpath("marker")
+ marker.touch()
+
+ python = location.joinpath("bin", "python")
+ python.unlink()
+ python.symlink_to("/definitely/missing/python")
+
+ reused = not venv.create()
+
+ assert not reused
+ assert not marker.exists()
+
+
+@has_uv
+def test_create_reuse_stale_venv_environment(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+) -> None:
venv, location = make_one(reuse_existing=True)
venv.create()
- # Drop a venv-style pyvenv.cfg into the environment.
+ # Drop a uv-style pyvenv.cfg into the environment.
pyvenv_cfg = """\
home = /usr/bin
include-system-site-packages = false
version = 3.9.6
+ uv = 0.1.9
"""
- location.join("pyvenv.cfg").write(dedent(pyvenv_cfg))
+ location.joinpath("pyvenv.cfg").write_text(dedent(pyvenv_cfg), encoding="utf-8")
reused = not venv.create()
- # The environment is not reused because it does not look like a
- # virtualenv-style environment.
assert not reused
-def test_create_reuse_stale_virtualenv_environment(make_one):
- venv, location = make_one(reuse_existing=True, venv=True)
+def test_not_stale_virtualenv_environment(
+ make_one: Callable[
+ ..., tuple[nox.virtualenv.VirtualEnv | nox.virtualenv.ProcessEnv, Path]
+ ],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Making the reuse requirement more strict
+ monkeypatch.setenv("NOX_ENABLE_STALENESS_CHECK", "1")
+
+ venv, _location = make_one(reuse_existing=True, venv_backend="virtualenv")
+ venv.create()
+
+ venv, _location = make_one(reuse_existing=True, venv_backend="virtualenv")
+ reused = not venv.create()
+
+ assert reused
+
+
+@pytest.mark.conda
+def test_stale_virtualenv_to_conda_environment(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+) -> None:
+ venv, _location = make_one(reuse_existing=True, venv_backend="virtualenv")
+ venv.create()
+
+ venv, _location = make_one(reuse_existing=True, venv_backend="conda")
+ reused = not venv.create()
+
+ # The environment is not reused because it is now conda style
+ # environment.
+ assert not reused
+
+
+@pytest.mark.conda
+def test_reuse_conda_environment(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+) -> None:
+ venv, _ = make_one(reuse_existing=True, venv_backend="conda")
+ venv.create()
+ assert venv.bin_paths
+ assert venv.bin_paths[-1].endswith("bin")
+
+ venv, _ = make_one(reuse_existing=True, venv_backend="conda")
+ reused = not venv.create()
+
+ assert reused
+
+
+# This mocks micromamba so that it doesn't need to be installed.
+@pytest.mark.conda
+def test_micromamba_environment(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ conda_path = shutil.which("conda")
+ which = shutil.which
+ monkeypatch.setattr(
+ shutil, "which", lambda x: conda_path if x == "micromamba" else which(x)
+ )
+ venv, _ = make_one(reuse_existing=True, venv_backend="micromamba")
+ run = mock.Mock()
+ monkeypatch.setattr(nox.command, "run", run)
+ venv.create()
+ run.assert_called_once()
+ (args,) = run.call_args.args
+ assert args[0] == "micromamba"
+ assert "--channel=conda-forge" in args
+
+
+# This mocks micromamba so that it doesn't need to be installed.
+@pytest.mark.parametrize(
+ "params",
+ [["--channel=default"], ["-cdefault"], ["-c", "default"], ["--channel", "default"]],
+)
+@pytest.mark.conda
+def test_micromamba_channel_environment(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ monkeypatch: pytest.MonkeyPatch,
+ params: list[str],
+) -> None:
+ conda_path = shutil.which("conda")
+ which = shutil.which
+ monkeypatch.setattr(
+ shutil, "which", lambda x: conda_path if x == "micromamba" else which(x)
+ )
+ venv, _ = make_one(reuse_existing=True, venv_backend="micromamba")
+ run = mock.Mock()
+ monkeypatch.setattr(nox.command, "run", run)
+ venv.venv_params = params
+ venv.create()
+ run.assert_called_once()
+ (args,) = run.call_args.args
+ assert args[0] == "micromamba"
+ for p in params:
+ assert p in args
+ assert "--channel=conda-forge" not in args
+
+
+@pytest.mark.parametrize(
+ ("frm", "to", "result"),
+ [
+ ("virtualenv", "venv", True),
+ ("venv", "virtualenv", True),
+ ("virtualenv", "uv", True),
+ pytest.param("uv", "virtualenv", False, marks=has_uv),
+ pytest.param("conda", "virtualenv", False, marks=pytest.mark.conda),
+ ],
+)
+def test_stale_environment(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ frm: str,
+ to: str,
+ result: bool,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("NOX_ENABLE_STALENESS_CHECK", "1")
+ venv, _ = make_one(reuse_existing=True, venv_backend=frm)
+ venv.create()
+ assert venv.venv_backend == frm
+
+ venv, _ = make_one(reuse_existing=True, venv_backend=to)
+ reused = venv._check_reused_environment_type()
+ assert venv.venv_backend == to
+
+ assert reused == result
+
+
+def test_passthrough_environment_venv_backend(
+ make_one: Callable[..., tuple[ProcessEnv, Path]],
+) -> None:
+ venv, _ = make_one(venv_backend="none")
+ venv.create()
+ assert venv.venv_backend == "none"
+
+
+@has_uv
+def test_create_reuse_stale_virtualenv_environment(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("NOX_ENABLE_STALENESS_CHECK", "1")
+ venv, location = make_one(reuse_existing=True, venv_backend="venv")
venv.create()
- # Drop a virtualenv-style pyvenv.cfg into the environment.
+ # Drop a uv-style pyvenv.cfg into the environment.
pyvenv_cfg = """\
home = /usr
implementation = CPython
version_info = 3.9.6.final.0
- virtualenv = 20.4.6
+ uv = 0.1.9
include-system-site-packages = false
base-prefix = /usr
base-exec-prefix = /usr
base-executable = /usr/bin/python3.9
"""
- location.join("pyvenv.cfg").write(dedent(pyvenv_cfg))
+ location.joinpath("pyvenv.cfg").write_text(dedent(pyvenv_cfg), encoding="utf-8")
reused = not venv.create()
@@ -411,13 +964,375 @@ def test_create_reuse_stale_virtualenv_environment(make_one):
assert not reused
-def test_create_reuse_venv_environment(make_one):
- venv, location = make_one(reuse_existing=True, venv=True)
+@has_uv
+def test_create_reuse_uv_environment(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+) -> None:
+ venv, location = make_one(reuse_existing=True, venv_backend="uv")
+ venv.create()
+
+ # Place a spurious occurrence of "uv" in the pyvenv.cfg.
+ pyvenv_cfg = location.joinpath("pyvenv.cfg")
+ pyvenv_cfg.write_text(
+ pyvenv_cfg.read_text(encoding="utf-8") + "bogus = uv\n", encoding="utf-8"
+ )
+
+ reused = not venv.create()
+
+ # The environment is reused because it looks like a uv environment
+ assert reused
+
+
+UV_IN_PIPX_VENV = "/home/user/.local/pipx/venvs/nox/bin/uv"
+
+
+@pytest.mark.parametrize(
+ (
+ "uv_env",
+ "which_result",
+ "find_uv_bin_result",
+ "found",
+ "path",
+ "vers",
+ "vers_rc",
+ ),
+ [
+ pytest.param(
+ None,
+ "/usr/bin/uv",
+ UV_IN_PIPX_VENV,
+ True,
+ UV_IN_PIPX_VENV,
+ "0.5.0",
+ 0,
+ id="pkg_pipx_uv_0.5",
+ ),
+ pytest.param(
+ None,
+ "/usr/bin/uv",
+ UV_IN_PIPX_VENV,
+ True,
+ UV_IN_PIPX_VENV,
+ "0.6.0",
+ 0,
+ id="pkg_pipx_uv_0.6",
+ ),
+ pytest.param(
+ "custom",
+ "/usr/bin/uv",
+ UV_IN_PIPX_VENV,
+ True,
+ "custom",
+ "0.6.0",
+ 0,
+ id="UV_pkg_pipx_uv_0.6",
+ ),
+ pytest.param(
+ None,
+ "/usr/bin/uv",
+ UV_IN_PIPX_VENV,
+ True,
+ UV_IN_PIPX_VENV,
+ "0.7.0",
+ 0,
+ id="pkg_pipx_uv_0.7",
+ ),
+ pytest.param(
+ "custom",
+ "/usr/bin/uv",
+ UV_IN_PIPX_VENV,
+ True,
+ "custom",
+ "0.7.0",
+ 0,
+ id="UV_pkg_pipx_uv_0.7",
+ ),
+ pytest.param(
+ None,
+ "/usr/bin/uv",
+ UV_IN_PIPX_VENV,
+ False,
+ "uv",
+ "0.0.0",
+ 0,
+ id="pkg_system_uv_0.0",
+ ),
+ pytest.param(
+ None,
+ "/usr/bin/uv",
+ UV_IN_PIPX_VENV,
+ False,
+ "uv",
+ "0.6.0",
+ 1,
+ id="pkg_system_uv_0.6_broken",
+ ),
+ pytest.param(
+ None,
+ "/usr/bin/uv",
+ UV_IN_PIPX_VENV,
+ False,
+ "uv",
+ "0.7.0",
+ 1,
+ id="pkg_system_uv_0.7_broken",
+ ),
+ pytest.param(
+ None, "/usr/bin/uv", None, True, "uv", "0.7.0", 0, id="system_uv_0.7"
+ ),
+ pytest.param(
+ None, "/usr/bin/uv", None, True, "uv", "0.6.0", 0, id="system_uv_0.6"
+ ),
+ pytest.param(
+ None, "/usr/bin/uv", None, False, "uv", "0.0.0", 0, id="system_uv_0.0"
+ ),
+ pytest.param(
+ None,
+ "/usr/bin/uv",
+ None,
+ False,
+ "uv",
+ "0.6.0",
+ 1,
+ id="system_uv_0.6_broken",
+ ),
+ pytest.param(
+ None,
+ "/usr/bin/uv",
+ None,
+ False,
+ "uv",
+ "0.7.0",
+ 1,
+ id="system_uv_0.7_broken",
+ ),
+ pytest.param(
+ None,
+ None,
+ UV_IN_PIPX_VENV,
+ True,
+ UV_IN_PIPX_VENV,
+ "0.5.0",
+ 0,
+ id="pipx_uv_0.5",
+ ),
+ pytest.param(
+ None,
+ None,
+ UV_IN_PIPX_VENV,
+ True,
+ UV_IN_PIPX_VENV,
+ "0.7.0",
+ 0,
+ id="pipx_uv_0.7",
+ ),
+ pytest.param(None, None, None, False, "uv", "0.5.0", 0, id="no_uv_0.5"),
+ ],
+)
+def test_find_uv(
+ monkeypatch: pytest.MonkeyPatch,
+ uv_env: str | None,
+ which_result: str | None,
+ find_uv_bin_result: str | None,
+ found: bool,
+ path: str,
+ vers: str,
+ vers_rc: int,
+) -> None:
+ monkeypatch.delenv("UV", raising=False)
+ if uv_env:
+ monkeypatch.setenv("UV", uv_env)
+
+ def find_uv_bin() -> str:
+ if find_uv_bin_result:
+ return find_uv_bin_result
+ raise FileNotFoundError()
+
+ def mock_run(*args: Any, **kwargs: object) -> subprocess.CompletedProcess[str]:
+ if version.Version(vers) < version.Version("0.7.0") and "self" in args[0]:
+ return subprocess.CompletedProcess(
+ args=args[0],
+ returncode=2,
+ )
+ return subprocess.CompletedProcess(
+ args=args[0],
+ stdout=f'{{"version": "{vers}", "commit_info": null}}',
+ returncode=vers_rc,
+ )
+
+ monkeypatch.setattr(subprocess, "run", mock_run)
+
+ monkeypatch.setattr(
+ shutil, "which", lambda x: which_result if x.endswith("uv") else uv_env
+ )
+ monkeypatch.setattr(Path, "samefile", lambda a, b: a == b)
+ monkeypatch.setitem(
+ sys.modules, "uv", types.SimpleNamespace(find_uv_bin=find_uv_bin)
+ )
+
+ # Bypass the functools.cache wrapper: the suite warms find_uv() with the
+ # real environment (see conftest), and this test must not poison it.
+ assert nox.virtualenv.find_uv.__wrapped__() == (
+ found,
+ path,
+ version.Version(vers if vers_rc == 0 else "0"),
+ )
+
+
+def test_uv_detection_is_lazy() -> None:
+ """Importing nox.virtualenv must not invoke uv (or any subprocess)."""
+ code = dedent(
+ """
+ import sys
+
+ events = []
+
+ def audit_hook(event, args):
+ if event == "subprocess.Popen":
+ events.append((event, args))
+
+ sys.addaudithook(audit_hook)
+
+ import nox.virtualenv
+
+ assert not events, f"subprocess spawned during import: {events}"
+ lazy = {"HAS_UV", "UV", "UV_VERSION", "OPTIONAL_VENVS"}
+ computed = lazy & set(vars(nox.virtualenv))
+ assert not computed, f"uv detection ran during import: {computed}"
+ """
+ )
+ result = subprocess.run(
+ [sys.executable, "-c", code],
+ check=False,
+ text=True,
+ capture_output=True,
+ encoding="utf-8",
+ )
+ assert result.returncode == 0, result.stderr
+
+
+def test_lazy_uv_module_attrs(monkeypatch: pytest.MonkeyPatch) -> None:
+ # Remove any values materialized by earlier monkeypatching so that the
+ # module-level __getattr__ is exercised.
+ for name in ("HAS_UV", "UV", "UV_VERSION", "OPTIONAL_VENVS"):
+ if name in vars(nox.virtualenv):
+ monkeypatch.delattr(nox.virtualenv, name)
+
+ assert isinstance(nox.virtualenv.HAS_UV, bool)
+ assert isinstance(nox.virtualenv.UV, str)
+ assert isinstance(nox.virtualenv.UV_VERSION, version.Version)
+
+ optional_venvs = nox.virtualenv.OPTIONAL_VENVS
+ assert set(optional_venvs) == {"conda", "mamba", "micromamba", "uv"}
+ assert optional_venvs["uv"] == nox.virtualenv.HAS_UV
+
+ # A monkeypatched HAS_UV must flow into OPTIONAL_VENVS.
+ monkeypatch.setattr(nox.virtualenv, "HAS_UV", not optional_venvs["uv"])
+ assert nox.virtualenv.OPTIONAL_VENVS["uv"] == (not optional_venvs["uv"])
+
+ with pytest.raises(AttributeError, match="has no attribute 'not_a_real_attr'"):
+ _ = nox.virtualenv.not_a_real_attr
+
+
+def test_allowed_globals(
+ tmp_path: Path,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ assert nox.virtualenv.PassthroughEnv().allowed_globals == ()
+ assert nox.virtualenv.CondaEnv(str(tmp_path / "conda")).allowed_globals == (
+ "conda",
+ "mamba",
+ "micromamba",
+ )
+
+ venv, _ = make_one(venv_backend="uv")
+ monkeypatch.setattr(nox.virtualenv, "UV", "/some/uv")
+ assert venv.allowed_globals == ("/some/uv", "/some/uvx")
+
+
+def test_find_python_cached(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Repeated interpreter discovery (one lookup per session) is cached."""
+ calls: list[str] = []
+
+ def fake_which(name: str) -> str | None:
+ calls.append(name)
+ return "/usr/bin/python3.99"
+
+ monkeypatch.setattr(shutil, "which", fake_which)
+
+ assert nox.virtualenv._find_python("python3.99", "3.99") == "python3.99"
+ assert nox.virtualenv._find_python("python3.99", "3.99") == "python3.99"
+ assert calls == ["python3.99"]
+
+
+@pytest.mark.parametrize(
+ ("return_code", "stdout", "expected_result"),
+ [
+ (0, '{"version": "0.2.3", "commit_info": null}', "0.2.3"),
+ (1, None, "0.0"),
+ (1, '{"version": "9.9.9", "commit_info": null}', "0.0"),
+ ],
+)
+def test_uv_version(
+ monkeypatch: pytest.MonkeyPatch,
+ return_code: int,
+ stdout: str | None,
+ expected_result: str,
+) -> None:
+ def mock_run(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
+ return subprocess.CompletedProcess(
+ args=["uv", "self", "version", "--output-format", "json"],
+ stdout=stdout,
+ returncode=return_code,
+ )
+
+ monkeypatch.setattr(subprocess, "run", mock_run)
+ assert nox.virtualenv.uv_version(nox.virtualenv.UV) == version.Version(
+ expected_result
+ )
+
+
+def test_uv_version_no_uv(monkeypatch: pytest.MonkeyPatch) -> None:
+ def mock_exception(*args: object, **kwargs: object) -> NoReturn:
+ raise FileNotFoundError
+
+ monkeypatch.setattr(subprocess, "run", mock_exception)
+ assert nox.virtualenv.uv_version(nox.virtualenv.UV) == version.Version("0.0")
+
+
+@pytest.mark.parametrize(
+ ("requested_python", "expected_result"),
+ [
+ ("3.11", True),
+ ("pypy3.8", True),
+ ("cpython3.9", True),
+ ("python3.12", True),
+ ("nonpython9.22", False),
+ ("java11", False),
+ ],
+)
+@has_uv
+def test_uv_install(requested_python: str, expected_result: bool) -> None:
+ assert nox.virtualenv.uv_install_python(requested_python) == expected_result
+
+
+def test_create_reuse_venv_environment(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Making the reuse requirement more strict
+ monkeypatch.setenv("NOX_ENABLE_STALENESS_CHECK", "1")
+
+ venv, location = make_one(reuse_existing=True, venv_backend="venv")
venv.create()
# Place a spurious occurrence of "virtualenv" in the pyvenv.cfg.
- pyvenv_cfg = location.join("pyvenv.cfg")
- pyvenv_cfg.write(pyvenv_cfg.read() + "bogus = virtualenv\n")
+ pyvenv_cfg = location.joinpath("pyvenv.cfg")
+ pyvenv_cfg.write_text(
+ pyvenv_cfg.read_text(encoding="utf-8") + "bogus = virtualenv\n",
+ encoding="utf-8",
+ )
reused = not venv.create()
@@ -426,16 +1341,18 @@ def test_create_reuse_venv_environment(make_one):
@pytest.mark.skipif(IS_WINDOWS, reason="Avoid 'No pyvenv.cfg file' error on Windows.")
-def test_create_reuse_oldstyle_virtualenv_environment(make_one):
+def test_create_reuse_oldstyle_virtualenv_environment(
+ make_one: Callable[..., tuple[VirtualEnv | ProcessEnv, Path]],
+) -> None:
venv, location = make_one(reuse_existing=True)
venv.create()
- pyvenv_cfg = location.join("pyvenv.cfg")
- if not pyvenv_cfg.check():
+ pyvenv_cfg = location.joinpath("pyvenv.cfg")
+ if not pyvenv_cfg.exists():
pytest.skip("Requires virtualenv >= 20.0.0.")
# virtualenv < 20.0.0 does not create a pyvenv.cfg file.
- pyvenv_cfg.remove()
+ pyvenv_cfg.unlink()
reused = not venv.create()
@@ -443,20 +1360,80 @@ def test_create_reuse_oldstyle_virtualenv_environment(make_one):
assert reused
-def test_create_venv_backend(make_one):
- venv, dir_ = make_one(venv=True)
+@pytest.mark.skipif(IS_WINDOWS, reason="Avoid 'No pyvenv.cfg file' error on Windows.")
+def test_inner_functions_reusing_venv(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("NOX_ENABLE_STALENESS_CHECK", "1")
+ venv, location = make_one(reuse_existing=True)
+ venv.create()
+
+ txt = location.joinpath("pyvenv.cfg").read_text(encoding="utf-8")
+ cfg = {
+ (v := line.partition("="))[0].strip(): v[-1].strip()
+ for line in txt.splitlines()
+ }
+ home = cfg["home"]
+
+ # Drop a venv-style pyvenv.cfg into the environment.
+ pyvenv_cfg = f"""\
+ home = {home}
+ include-system-site-packages = false
+ version = 3.10
+ base-prefix = foo
+ """
+ location.joinpath("pyvenv.cfg").write_text(dedent(pyvenv_cfg), encoding="utf-8")
+
+ config = venv._read_pyvenv_cfg()
+ assert config
+ base_prefix = config["base-prefix"]
+ assert base_prefix == "foo"
+
+ reused_interpreter = venv._check_reused_environment_interpreter()
+ # The created won't match 'foo'
+ assert not reused_interpreter
+
+
+@pytest.mark.skipif(
+ version.parse(VIRTUALENV_VERSION) >= version.parse("20.22.0"),
+ reason="Python 2.7 unsupported for virtualenv>=20.22.0",
+)
+def test_create_reuse_python2_environment(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ venv, _location = make_one(reuse_existing=True, interpreter="2.7")
+
+ try:
+ venv.create()
+ except nox.virtualenv.InterpreterNotFound:
+ pytest.skip("Requires Python 2.7 installation.")
+
+ reused = not venv.create()
+
+ assert reused
+
+
+def test_create_venv_backend(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ venv, _dir = make_one(venv_backend="venv")
venv.create()
@pytest.mark.skipif(IS_WINDOWS, reason="Not testing multiple interpreters on Windows.")
-def test_create_interpreter(make_one):
+def test_create_interpreter(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
venv, dir_ = make_one(interpreter="python3")
venv.create()
- assert dir_.join("bin", "python").check()
- assert dir_.join("bin", "python3").check()
+ assert dir_.joinpath("bin", "python").exists()
+ assert dir_.joinpath("bin", "python3").exists()
-def test__resolved_interpreter_none(make_one):
+def test__resolved_interpreter_none(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
# Establish that the _resolved_interpreter method is a no-op if the
# interpreter is not set.
venv, _ = make_one(interpreter=None)
@@ -464,131 +1441,184 @@ def test__resolved_interpreter_none(make_one):
@pytest.mark.parametrize(
- ["input_", "expected"],
+ ("input_", "expected"),
[
("3", "python3"),
("3.6", "python3.6"),
("3.6.2", "python3.6"),
("3.10", "python3.10"),
("2.7.15", "python2.7"),
+ ("3.13t", "python3.13t"),
+ ("3.14.1t", "python3.14t"),
],
)
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=True)
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
+@mock.patch.object(shutil, "which", return_value=True)
def test__resolved_interpreter_numerical_non_windows(
- sysfind, make_one, input_, expected
-):
+ which: mock.Mock,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ input_: str,
+ expected: str,
+) -> None:
venv, _ = make_one(interpreter=input_)
assert venv._resolved_interpreter == expected
- sysfind.assert_called_once_with(expected)
+ which.assert_called_once_with(expected)
@pytest.mark.parametrize("input_", ["2.", "2.7."])
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=False)
-def test__resolved_interpreter_invalid_numerical_id(sysfind, make_one, input_):
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
+@mock.patch.object(shutil, "which", return_value=False)
+def test__resolved_interpreter_invalid_numerical_id(
+ which: mock.Mock,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ input_: str,
+) -> None:
venv, _ = make_one(interpreter=input_)
with pytest.raises(nox.virtualenv.InterpreterNotFound):
- venv._resolved_interpreter
+ print(venv._resolved_interpreter)
- sysfind.assert_called_once_with(input_)
+ which.assert_called_once_with(input_)
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=False)
-def test__resolved_interpreter_32_bit_non_windows(sysfind, make_one):
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
+@mock.patch.object(shutil, "which", return_value=False)
+def test__resolved_interpreter_32_bit_non_windows(
+ which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
+) -> None:
venv, _ = make_one(interpreter="3.6-32")
with pytest.raises(nox.virtualenv.InterpreterNotFound):
- venv._resolved_interpreter
- sysfind.assert_called_once_with("3.6-32")
+ print(venv._resolved_interpreter)
+ which.assert_called_once_with("3.6-32")
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=True)
-def test__resolved_interpreter_non_windows(sysfind, make_one):
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
+@mock.patch.object(shutil, "which", return_value=True)
+def test__resolved_interpreter_non_windows(
+ which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
+) -> None:
# Establish that the interpreter is simply passed through resolution
# on non-Windows.
venv, _ = make_one(interpreter="python3.6")
assert venv._resolved_interpreter == "python3.6"
- sysfind.assert_called_once_with("python3.6")
+ which.assert_called_once_with("python3.6")
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-@mock.patch.object(py.path.local, "sysfind")
-def test__resolved_interpreter_windows_full_path(sysfind, make_one):
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch.object(shutil, "which")
+def test__resolved_interpreter_windows_full_path(
+ which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
+) -> None:
# Establish that if we get a fully-qualified system path (on Windows
# or otherwise) and the path exists, that we accept it.
venv, _ = make_one(interpreter=r"c:\Python36\python.exe")
- sysfind.return_value = py.path.local(venv.interpreter)
+ which.return_value = venv.interpreter
assert venv._resolved_interpreter == r"c:\Python36\python.exe"
- sysfind.assert_called_once_with(r"c:\Python36\python.exe")
+ which.assert_called_once_with(r"c:\Python36\python.exe")
@pytest.mark.parametrize(
- ["input_", "expected"],
+ ("input_", "expected"),
[
("3.7", r"c:\python37-x64\python.exe"),
("python3.6", r"c:\python36-x64\python.exe"),
("2.7-32", r"c:\python27\python.exe"),
],
)
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-@mock.patch.object(py.path.local, "sysfind")
-def test__resolved_interpreter_windows_pyexe(sysfind, make_one, input_, expected):
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch.object(subprocess, "run")
+@mock.patch.object(shutil, "which")
+def test__resolved_interpreter_windows_pyexe(
+ which: mock.Mock,
+ run: mock.Mock,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ input_: str,
+ expected: str,
+) -> None:
# Establish that if we get a standard pythonX.Y path, we look it
# up via the py launcher on Windows.
venv, _ = make_one(interpreter=input_)
+ if input_ == "3.7":
+ input_ = "python3.7"
+
# Trick the system into thinking that it cannot find python3.6
# (it likely will on Unix). Also, when the system looks for the
# py launcher, give it a dummy that returns our test value when
# run.
- attrs = {"sysexec.return_value": expected}
- mock_py = mock.Mock()
- mock_py.configure_mock(**attrs)
- sysfind.side_effect = lambda arg: mock_py if arg == "py" else None
+ def special_run(cmd: str, *args: str, **kwargs: object) -> TextProcessResult:
+ if cmd[0] == "py":
+ return TextProcessResult(expected)
+ return TextProcessResult("", 1)
+
+ run.side_effect = special_run
+ which.side_effect = lambda x: "py" if x == "py" else None
# Okay now run the test.
assert venv._resolved_interpreter == expected
- assert sysfind.call_count == 2
- if input_ == "3.7":
- sysfind.assert_has_calls([mock.call("python3.7"), mock.call("py")])
- else:
- sysfind.assert_has_calls([mock.call(input_), mock.call("py")])
+ assert which.call_count == 2
+ which.assert_has_calls([mock.call(input_), mock.call("py")])
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-@mock.patch.object(py.path.local, "sysfind")
-def test__resolved_interpreter_windows_pyexe_fails(sysfind, make_one):
+@pytest.mark.parametrize(
+ ("interpreter", "expected_py_arg"),
+ [
+ ("3.10-32", "3.10-32"),
+ # Only a literal "-32" suffix is meaningful to the py launcher.
+ ("3.10-3", ""),
+ ],
+)
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch("nox.virtualenv.locate_using_path_and_version", return_value=None)
+@mock.patch("nox.virtualenv.locate_via_py", return_value=None)
+@mock.patch.object(shutil, "which", return_value=None)
+def test_find_python_windows_32_bit_suffix(
+ which: mock.Mock, # noqa: ARG001
+ locate_via_py_mock: mock.Mock,
+ locate_using_path_and_version_mock: mock.Mock, # noqa: ARG001
+ interpreter: str,
+ expected_py_arg: str,
+) -> None:
+ # Only an exact "-32" suffix is preserved for the Windows py launcher.
+ assert nox.virtualenv._find_python(interpreter, "") is None
+ locate_via_py_mock.assert_called_once_with(expected_py_arg)
+
+
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch.object(subprocess, "run")
+@mock.patch.object(shutil, "which")
+def test__resolved_interpreter_windows_pyexe_fails(
+ which: mock.Mock, run: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
+) -> None:
# Establish that if the py launcher fails, we give the right error.
venv, _ = make_one(interpreter="python3.6")
- # Trick the nox.virtualenv._SYSTEM into thinking that it cannot find python3.6
- # (it likely will on Unix). Also, when the nox.virtualenv._SYSTEM looks for the
+ # Trick the nox.virtualenv into thinking that it cannot find python3.6
+ # (it likely will on Unix). Also, when the nox.virtualenv looks for the
# py launcher, give it a dummy that fails.
- attrs = {"sysexec.side_effect": py.process.cmdexec.Error(1, 1, "", "", "")}
- mock_py = mock.Mock()
- mock_py.configure_mock(**attrs)
- sysfind.side_effect = lambda arg: mock_py if arg == "py" else None
+ def special_run(cmd: str, *args: str, **kwargs: object) -> TextProcessResult: # noqa: ARG001
+ return TextProcessResult("", 1)
+
+ run.side_effect = special_run
+ which.side_effect = lambda x: "py" if x == "py" else None
# Okay now run the test.
with pytest.raises(nox.virtualenv.InterpreterNotFound):
- venv._resolved_interpreter
+ print(venv._resolved_interpreter)
- sysfind.assert_any_call("python3.6")
- sysfind.assert_any_call("py")
+ which.assert_has_calls([mock.call("python3.6"), mock.call("py")])
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-@mock.patch.object(py.path.local, "sysfind")
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch("nox.virtualenv.UV_VERSION", new=version.Version("0.3"))
def test__resolved_interpreter_windows_path_and_version(
- sysfind, make_one, patch_sysfind
-):
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ patch_sysfind: Callable[..., None],
+) -> None:
# Establish that if we get a standard pythonX.Y path, we look it
# up via the path on Windows.
venv, _ = make_one(interpreter="3.7")
@@ -600,7 +1630,6 @@ def test__resolved_interpreter_windows_path_and_version(
# in the system path.
correct_path = r"c:\python37-x64\python.exe"
patch_sysfind(
- sysfind,
only_find=("python", "python.exe"),
sysfind_result=correct_path,
sysexec_result="3.7.3\\n",
@@ -613,80 +1642,421 @@ def test__resolved_interpreter_windows_path_and_version(
@pytest.mark.parametrize("input_", ["2.7", "python3.7", "goofy"])
@pytest.mark.parametrize("sysfind_result", [r"c:\python37-x64\python.exe", None])
@pytest.mark.parametrize("sysexec_result", ["3.7.3\\n", RAISE_ERROR])
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-@mock.patch.object(py.path.local, "sysfind")
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch("nox.virtualenv.UV_VERSION", new=version.Version("0.3"))
def test__resolved_interpreter_windows_path_and_version_fails(
- sysfind, input_, sysfind_result, sysexec_result, make_one, patch_sysfind
-):
+ input_: str,
+ sysfind_result: None | str,
+ sysexec_result: str,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+ patch_sysfind: Callable[..., None],
+) -> None:
# Establish that if we get a standard pythonX.Y path, we look it
# up via the path on Windows.
- venv, _ = make_one(interpreter=input_)
+ venv, _ = make_one(interpreter=input_, download_python="never")
# Trick the system into thinking that it cannot find
# pythonX.Y up until the python-in-path check at the end.
# Also, we don't give it a mock py launcher.
# But we give it a mock python interpreter to find
# in the system path.
- patch_sysfind(sysfind, ("python", "python.exe"), sysfind_result, sysexec_result)
+ patch_sysfind(("python", "python.exe"), sysfind_result, sysexec_result)
with pytest.raises(nox.virtualenv.InterpreterNotFound):
- venv._resolved_interpreter
+ print(venv._resolved_interpreter)
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-@mock.patch.object(py._path.local.LocalPath, "check")
-@mock.patch.object(py.path.local, "sysfind")
-def test__resolved_interpreter_not_found(sysfind, check, make_one):
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch.object(shutil, "which")
+def test__resolved_interpreter_not_found(
+ which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
+) -> None:
# Establish that if an interpreter cannot be found at a standard
# location on Windows, we raise a useful error.
- venv, _ = make_one(interpreter="python3.6")
+ venv, _ = make_one(interpreter="python3.6", download_python="never")
# We are on Windows, and nothing can be found.
- sysfind.return_value = None
- check.return_value = False
+ which.return_value = None
# Run the test.
with pytest.raises(nox.virtualenv.InterpreterNotFound):
- venv._resolved_interpreter
+ print(venv._resolved_interpreter)
-@mock.patch("nox.virtualenv._SYSTEM", new="Windows")
-def test__resolved_interpreter_nonstandard(make_one):
+@mock.patch("nox.virtualenv._PLATFORM", new="win32")
+@mock.patch("nox.virtualenv.locate_via_py", new=lambda _: None) # type: ignore[untyped-decorator] # noqa: PT008
+def test__resolved_interpreter_nonstandard(
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
# Establish that we do not try to resolve non-standard locations
# on Windows.
venv, _ = make_one(interpreter="goofy")
with pytest.raises(nox.virtualenv.InterpreterNotFound):
- venv._resolved_interpreter
+ print(venv._resolved_interpreter)
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=True)
-def test__resolved_interpreter_cache_result(sysfind, make_one):
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
+@mock.patch.object(shutil, "which", return_value=True)
+def test__resolved_interpreter_cache_result(
+ which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
+) -> None:
venv, _ = make_one(interpreter="3.6")
assert venv._resolved is None
assert venv._resolved_interpreter == "python3.6"
- sysfind.assert_called_once_with("python3.6")
+ which.assert_called_once_with("python3.6")
# Check the cache and call again to make sure it is used.
assert venv._resolved == "python3.6"
- assert venv._resolved_interpreter == "python3.6"
- assert sysfind.call_count == 1
+ assert venv._resolved_interpreter == "python3.6" # type: ignore[unreachable]
+ assert which.call_count == 1
-@mock.patch("nox.virtualenv._SYSTEM", new="Linux")
-@mock.patch.object(py.path.local, "sysfind", return_value=None)
-def test__resolved_interpreter_cache_failure(sysfind, make_one):
+@mock.patch("nox.virtualenv._PLATFORM", new="linux")
+@mock.patch.object(shutil, "which", return_value=None)
+def test__resolved_interpreter_cache_failure(
+ which: mock.Mock, make_one: Callable[..., tuple[VirtualEnv, Path]]
+) -> None:
venv, _ = make_one(interpreter="3.7-32")
assert venv._resolved is None
with pytest.raises(nox.virtualenv.InterpreterNotFound) as exc_info:
- venv._resolved_interpreter
+ print(venv._resolved_interpreter)
caught = exc_info.value
- sysfind.assert_called_once_with("3.7-32")
+ which.assert_called_once_with("3.7-32")
# Check the cache and call again to make sure it is used.
assert venv._resolved is caught
+ with pytest.raises(nox.virtualenv.InterpreterNotFound): # type: ignore[unreachable]
+ print(venv._resolved_interpreter)
+ assert which.call_count == 1
+
+
+@pytest.mark.parametrize("venv_backend", ["uv", "venv", "virtualenv"])
+@mock.patch("nox.virtualenv.pbs_install_python")
+@mock.patch("nox.virtualenv.uv_install_python")
+@mock.patch.object(shutil, "which", return_value="/usr/bin/python3.11")
+def test_download_python_never_preexisting_interpreter(
+ which: mock.Mock,
+ uv_install_mock: mock.Mock,
+ pbs_install_mock: mock.Mock,
+ venv_backend: str,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ venv, _ = make_one(
+ interpreter="python3.11",
+ venv_backend=venv_backend,
+ download_python="never",
+ )
+
+ resolved_interpreter = venv._resolved_interpreter
+ assert resolved_interpreter == "python3.11"
+ which.assert_called_once_with("python3.11")
+
+ # should never try to install
+ uv_install_mock.assert_not_called()
+ pbs_install_mock.assert_not_called()
+
+
+@pytest.mark.parametrize("venv_backend", ["uv", "venv", "virtualenv"])
+@mock.patch("nox.virtualenv.pbs_install_python")
+@mock.patch("nox.virtualenv.uv_install_python")
+@mock.patch.object(shutil, "which", return_value=None)
+def test_download_python_never_missing_interpreter(
+ which: mock.Mock,
+ uv_install_mock: mock.Mock,
+ pbs_install_mock: mock.Mock,
+ venv_backend: str,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ venv, _ = make_one(
+ interpreter="python3.11",
+ venv_backend=venv_backend,
+ download_python="never",
+ )
+ with pytest.raises(nox.virtualenv.InterpreterNotFound):
+ _ = venv._resolved_interpreter
+
+ if IS_WINDOWS:
+ which.assert_called_with("py")
+ else:
+ which.assert_called_with("python3.11")
+
+ # should never try to install
+ uv_install_mock.assert_not_called()
+ pbs_install_mock.assert_not_called()
+
+
+@pytest.mark.parametrize("venv_backend", ["uv", "venv", "virtualenv"])
+@mock.patch("nox.virtualenv.pbs_install_python")
+@mock.patch("nox.virtualenv.uv_install_python")
+@mock.patch.object(shutil, "which", return_value="/usr/bin/python3.11")
+def test_download_python_auto_preexisting_interpreter(
+ which: mock.Mock,
+ uv_install_mock: mock.Mock,
+ pbs_install_mock: mock.Mock,
+ venv_backend: str,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ venv, _ = make_one(
+ interpreter="python3.11",
+ venv_backend=venv_backend,
+ download_python="auto",
+ )
+
+ # no install needed
+ uv_install_mock.assert_not_called()
+ pbs_install_mock.assert_not_called()
+
+ assert venv._resolved_interpreter == "python3.11"
+ which.assert_called_with("python3.11")
+
+
+@pytest.mark.parametrize("venv_backend", ["uv", "venv", "virtualenv"])
+@mock.patch(
+ "nox.virtualenv.pbs_install_python",
+ return_value="/.local/share/nox/cpython@3.11.3/bin/python3.11",
+)
+@mock.patch(
+ "nox.virtualenv.uv_install_python",
+ return_value=True,
+)
+@mock.patch.object(shutil, "which", return_value=None)
+def test_download_python_auto_missing_interpreter(
+ which: mock.Mock,
+ uv_install_mock: mock.Mock,
+ pbs_install_mock: mock.Mock,
+ venv_backend: str,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ venv, _ = make_one(
+ interpreter="python3.11",
+ venv_backend=venv_backend,
+ download_python="auto",
+ )
+
+ resolved_interpreter = venv._resolved_interpreter
+
+ # make sure we tried to find an interpreter first
+ which.assert_any_call("python3.11")
+
+ # the resolved interpreter should be the one we install
+ if venv_backend == "uv":
+ uv_install_mock.assert_called_once_with("python3.11")
+ pbs_install_mock.assert_not_called()
+ assert resolved_interpreter == "python3.11"
+ else:
+ pbs_install_mock.assert_called_once_with("python3.11")
+ uv_install_mock.assert_not_called()
+ assert resolved_interpreter == "/.local/share/nox/cpython@3.11.3/bin/python3.11"
+
+
+@pytest.mark.parametrize("venv_backend", ["uv", "venv", "virtualenv"])
+@mock.patch(
+ "nox.virtualenv.pbs_install_python",
+ return_value="/.local/share/nox/cpython@3.11.3/bin/python3.11",
+)
+@mock.patch(
+ "nox.virtualenv.uv_install_python",
+ return_value=True,
+)
+@mock.patch.object(shutil, "which", return_value="/usr/bin/python3.11")
+def test_download_python_always_preexisting_interpreter(
+ which: mock.Mock,
+ uv_install_mock: mock.Mock,
+ pbs_install_mock: mock.Mock,
+ venv_backend: str,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ venv, _ = make_one(
+ interpreter="python3.11",
+ venv_backend=venv_backend,
+ download_python="always",
+ )
+
+ resolved_interpreter = venv._resolved_interpreter
+
+ # We should NOT try to find an existing interpreter
+ which.assert_not_called()
+
+ # the resolved interpreter should be the one we install
+ if venv_backend == "uv":
+ uv_install_mock.assert_called_once_with("python3.11")
+ pbs_install_mock.assert_not_called()
+ assert resolved_interpreter == "python3.11"
+ else:
+ pbs_install_mock.assert_called_once_with("python3.11")
+ uv_install_mock.assert_not_called()
+ assert resolved_interpreter == "/.local/share/nox/cpython@3.11.3/bin/python3.11"
+
+
+@pytest.mark.parametrize("venv_backend", ["uv", "venv", "virtualenv"])
+@pytest.mark.parametrize("download_python", ["always", "auto"])
+@mock.patch("nox.virtualenv.pbs_install_python", return_value=None)
+@mock.patch("nox.virtualenv.uv_install_python", return_value=False)
+def test_download_python_failed_install(
+ uv_install_mock: mock.Mock,
+ pbs_install_mock: mock.Mock,
+ download_python: str,
+ venv_backend: str,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ venv, _ = make_one(
+ interpreter="python3.11",
+ venv_backend=venv_backend,
+ download_python=download_python,
+ )
+
+ with (
+ mock.patch.object(shutil, "which", return_value=None) as _,
+ pytest.raises(nox.virtualenv.InterpreterNotFound),
+ ):
+ _ = venv._resolved_interpreter
+
+ if venv_backend == "uv":
+ uv_install_mock.assert_called_once_with("python3.11")
+ pbs_install_mock.assert_not_called()
+ else:
+ pbs_install_mock.assert_called_once_with("python3.11")
+ uv_install_mock.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ ("implementation", "version", "dir_name", "expected"),
+ [
+ ("cpython", "3.11", "cpython@3.11.5", True),
+ ("pypy", "3.8", "pypy@3.8.16", True),
+ ("cpython", "3.12", "cpython@3.11.5", False),
+ ("pypy", "3.11", "cpython@3.11.5", False),
+ # Exact X.Y.Z installs are stored without a suffix (version_dir=True)
+ ("cpython", "3.13.2", "cpython@3.13.2", True),
+ ("pypy", "3.10.14", "pypy@3.10.14", True),
+ ("cpython", "3.1", "cpython@3.13.2", False),
+ ],
+)
+def test_find_pbs_python(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ implementation: str,
+ version: str,
+ dir_name: str,
+ expected: bool,
+) -> None:
+ """Test find_pbs_python deals with different python implementations/versions"""
+ nox_pbs_pythons = tmp_path / "nox_pbs_pythons"
+ monkeypatch.setattr("nox.virtualenv.NOX_PBS_PYTHONS", nox_pbs_pythons)
+
+ python_dir = nox_pbs_pythons / dir_name
+ if IS_WINDOWS:
+ python_dir.mkdir(parents=True)
+ python = python_dir / "python.exe"
+ else:
+ bin_dir = python_dir / "bin"
+ bin_dir.mkdir(parents=True)
+ python = bin_dir / "python"
+ python.touch()
+
+ result = nox.virtualenv._find_pbs_python(implementation, version)
+ if expected:
+ assert result == str(python)
+ else:
+ assert result is None
+
+
+def test_find_pbs_python_missing_interpreter(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ nox_pbs_pythons = tmp_path / "nox_pbs_pythons"
+ monkeypatch.setattr("nox.virtualenv.NOX_PBS_PYTHONS", nox_pbs_pythons)
+
+ python_dir = nox_pbs_pythons / "cpython@3.11.5"
+ if IS_WINDOWS:
+ python_dir.mkdir(parents=True)
+ else:
+ bin_dir = python_dir / "bin"
+ bin_dir.mkdir(parents=True)
+
+ assert nox.virtualenv._find_pbs_python("cpython", "3.11") is None
+
+
+@mock.patch("nox.virtualenv._find_pbs_python", return_value="/existing/python/path")
+def test_pbs_install_python_already_installed(find_pbs_mock: mock.Mock) -> None:
+ """Test pbs_install_python early return when it was already installed"""
+ result = nox.virtualenv.pbs_install_python("python3.11")
+
+ assert result == "/existing/python/path"
+ find_pbs_mock.assert_called_once_with("cpython", "3.11")
+
+
+@mock.patch("nox.virtualenv._find_pbs_python", return_value="/existing/python/path")
+def test_pbs_install_python_no_implementation(find_pbs_mock: mock.Mock) -> None:
+ """A bare version with no implementation prefix defaults to CPython."""
+ result = nox.virtualenv.pbs_install_python("3.12")
+
+ assert result == "/existing/python/path"
+ find_pbs_mock.assert_called_once_with("cpython", "3.12")
+
+
+@pytest.mark.parametrize("python_version", ["", "cpython", "not-a-python"])
+def test_pbs_install_python_invalid_version(
+ python_version: str, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Version-less or unparsable requests warn and return None."""
+ assert nox.virtualenv.pbs_install_python(python_version) is None
+ assert "not a valid version" in caplog.text
+
+
+def test_pbs_install_python_success(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ nox_pbs_pythons = tmp_path / "nox_pbs_pythons"
+ monkeypatch.setattr("nox.virtualenv.NOX_PBS_PYTHONS", nox_pbs_pythons)
+ pytest.importorskip("pbs_installer")
+
+ # fake the installation
+ python_dir = nox_pbs_pythons / "cpython@3.11.5"
+ if IS_WINDOWS:
+ python_dir.mkdir(parents=True)
+ python_exe = python_dir / "python.exe"
+ else:
+ bin_dir = python_dir / "bin"
+ bin_dir.mkdir(parents=True)
+ python_exe = bin_dir / "python"
+
+ def mock_install(*args: Any, **kwargs: Any) -> None:
+ python_exe.touch()
+
+ monkeypatch.setattr("pbs_installer.install", mock_install)
+
+ result = nox.virtualenv.pbs_install_python("python3.11")
+ # return value is a path to the executable
+ assert result == str(python_exe)
+
+
+@pytest.mark.parametrize("download_python", ["always", "auto"])
+@mock.patch("nox.virtualenv.HAS_UV", new=True)
+@mock.patch("nox.virtualenv.UV_VERSION", new=version.Version("0.4.0"))
+@mock.patch("nox.virtualenv.uv_install_python", return_value=True)
+@mock.patch.object(shutil, "which", return_value=None)
+def test_download_python_uv_unsupported_version(
+ which: mock.Mock,
+ uv_install_mock: mock.Mock,
+ download_python: str,
+ make_one: Callable[..., tuple[VirtualEnv, Path]],
+) -> None:
+ """Test we dont install for unsupported uv versions"""
+ venv, _ = make_one(
+ interpreter="python3.11",
+ venv_backend="uv",
+ download_python=download_python,
+ )
+
with pytest.raises(nox.virtualenv.InterpreterNotFound):
- venv._resolved_interpreter
- assert sysfind.call_count == 1
+ _ = venv._resolved_interpreter
+
+ uv_install_mock.assert_not_called()
+ if download_python == "always":
+ which.assert_not_called()
+ else: # auto
+ which.assert_any_call("python3.11")
diff --git a/tests/test_workflow.py b/tests/test_workflow.py
index 2e8eb661..dc3917e2 100644
--- a/tests/test_workflow.py
+++ b/tests/test_workflow.py
@@ -12,12 +12,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
from unittest import mock
from nox import workflow
-def test_simple_workflow():
+def test_simple_workflow() -> None:
# Set up functions for the workflow.
function_a = mock.Mock(spec=())
function_b = mock.Mock(spec=())
@@ -43,7 +45,7 @@ def test_simple_workflow():
)
-def test_workflow_int_cutoff():
+def test_workflow_int_cutoff() -> None:
# Set up functions for the workflow.
function_a = mock.Mock(spec=())
function_b = mock.Mock(spec=())
@@ -71,7 +73,7 @@ def test_workflow_int_cutoff():
assert not function_c.called
-def test_workflow_interrupted():
+def test_workflow_interrupted() -> None:
# Set up functions for the workflow.
function_a = mock.Mock(spec=())
function_b = mock.Mock(spec=())