nox

repository·main·Indexed 23 days ago

https://github.com/wntrblm/nox

A command-line tool for flexible test automation in multiple Python environments. Nox uses standard Python files (typically noxfile.py) for programmatic session definitions, allowing users to manage dependencies and execute commands across various Python interpreters and virtualenv backends such as venv, uv, conda, and virtualenv.

Tokens
17.2K
Snippets
48
Records
92
Agent score
81%

What's inside nox

  1. How sessions work in Nox

    main

    Nox is configured via a noxfile.py in your project's root directory. This file defines sessions.

    A session is an isolated environment and a set of commands to run within that environment. In Nox, sessions are declared using the @nox.session decorator.

    Think of a session as analogous to a tox environment or a GNU Make target.

    import nox
    
    @nox.session
    def lint(session):
        session.install("flake8")
        session.run("flake8", "example.py")
  2. Configure Nox sessions in Python

    main

    Nox uses a standard Python file (typically noxfile.py) for configuration. You define automation tasks by decorating functions with @nox.session. Each session function receives a nox.Session object, which provides methods like .install() to manage dependencies and .run() to execute commands within that session's environment.

    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")
  3. Pass arguments into sessions using posargs

    main

    You can pass command-line arguments to a session by using the -- separator in the Nox CLI. Inside the session, these arguments are accessible via session.posargs.

    @nox.session
    def test(session):
        session.install('pytest')
    
        if session.posargs:
            test_files = session.posargs
        else:
            test_files = ['test_a.py', 'test_b.py']
    
        session.run('pytest', *test_files)
    
    # Usage:
    # nox             -> runs: pytest test_a.py test_b.py
    # nox -- test_c.py -> runs: pytest test_c.py
  4. Parametrize the session Python interpreter

    main

    You can use parametrization to select which Python interpreters to run a session against. This can be done via the @nox.parametrize decorator or by passing a list of versions directly to the @nox.session decorator.

    Using @nox.parametrize is particularly useful when you need to implement complex logic to exclude certain combinations (e.g., preventing a specific dependency from running on an older Python version).

    # Using @nox.parametrize
    @nox.session
    @nox.parametrize("python", ["3.10", "3.11", "3.12"])
    def tests(session):
        ...
    
    # Using @nox.session argument
    @nox.session(python=["3.10", "3.11", "3.12"])
    def tests(session):
        ...
    
    # Complex exclusion logic
    @nox.session
    @nox.parametrize(
        "python,dependency",
        [
            (python, dependency)
            for python in ("3.10", "3.11", "3.12")
            for dependency in ("1.0", "2.0")
            if (python, dependency) != ("3.10", "2.0")
        ],
    )
    def tests(session, dependency):
        ...
  5. Specify Nox version requirements

    main

    To ensure your Noxfile is compatible with the installed Nox version, use nox.needs_version. This must be specified as a string literal assigned to nox.needs_version at the module level. This allows Nox to check the version without fully importing the Noxfile.

    You can use any version specifiers defined in PEP 440.

    import nox
    
    nox.needs_version = ">=2019.5.30"
    
    @nox.session(name="test")
    def pytest(session):
        session.run("pytest")
  6. Generate a GitHub Actions matrix dynamically from Nox sessions

    main

    You can use the nox --json -l command to list all available sessions in JSON format. This allows you to dynamically generate a GitHub Actions matrix so that each Nox session runs as a separate job.

    Example GitHub Actions workflow snippet:

    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 }}"
  7. Use `uv` for dependency management in Nox sessions

    main

    If you use uv to lock dependencies, you can integrate it into your Nox sessions. You can either manually run uv sync within a session or use the nox-uv package to reduce boilerplate.

    Manual uv sync approach

    Set the venv_backend to "uv" and use session.run_install to sync dependencies into the Nox virtual environment.

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

    Using nox-uv for simplified dependency groups

    The nox-uv package allows you to specify uv_groups or uv_only_groups directly in the session decorator to automatically handle syncing specific dependency groups.

    #!/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()
  8. Combine coverage data across multiple Python versions

    main

    To get an accurate coverage report across a matrix of Python versions, follow these three phases:

    1. Erase: Remove stale coverage data from previous runs.
    2. Measure: Run tests for each Python version, saving the data to a unique file (e.g., .coverage.3.10).
    3. Report: Combine the per-version files into a single report.

    Use the requires argument in @nox.session to ensure the correct order of execution.

    import nox
    
    PYPROJECT = nox.project.load_toml("pyproject.toml")
    PYTHON_VERSIONS = nox.project.python_versions(PYPROJECT)
    
    
    @nox.session(default=False)
    def coverage_erase(session: nox.Session) -> None:
        """Remove data left by an earlier coverage run."""
        session.install("coverage[toml]")
        session.run("coverage", "erase")
    
    
    @nox.session(
        python=PYTHON_VERSIONS,
        default=False,
        requires=["coverage_erase"],
    )
    def tests(session: nox.Session) -> None:
        """Measure tests independently for each supported Python version."""
        session.install("coverage[toml]", "--group=test", ".")
        session.run(
            "coverage",
            "run",
            "-m",
            "pytest",
            *session.posargs,
            env={"COVERAGE_FILE": f".coverage.{session.python}"},
        )
    
    
    @nox.session(requires=["tests"])
    def coverage_report(session: nox.Session) -> None:
        """Combine the per-version data and display one report."""
        session.install("coverage[toml]")
        session.run("coverage", "combine")
        session.run("coverage", "report", "--show-missing")
  9. Convert tox.ini to noxfile.py

    main

    Nox has experimental support for converting tox.ini files into noxfile.py. This handles most mechanical conversion work, though manual adjustments may be required for generative environments or complex substitutions.

    Steps to convert:

    1. Install the tox-to-nox extra: pip install --upgrade nox[tox-to-nox]
    2. Run the converter in your project directory: tox-to-nox
    pip install --upgrade nox[tox-to-nox]
    tox-to-nox
  10. Assign tags to parametrized sessions

    main

    Tags can be assigned to parametrized sessions to allow for filtered execution (e.g., nox --tags quick). You can assign tags using the tags argument in @nox.parametrize or by using nox.param(..., tags=['tag1']).

    For highly complex tagging logic, you can pass a generator to @nox.parametrize that yields nox.param objects containing the desired tags.

    # Using tags in @nox.parametrize
    @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):
        ...
    
    # Using nox.param with tags
    @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):
        ...
    
    # Using a generator for sophisticated tagging
    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):
        ...
  11. Give friendly names to parametrized sessions

    main

    By default, parametrized sessions have long, unwieldy names like tests(django='1.9', database='postgres'). You can provide custom ids to make them easier to run via the CLI.

    You can achieve this in two ways:

    1. Using the ids argument in the @nox.parametrize decorator.
    2. Using nox.param(value, id='custom_id') within the parameter list.

    When using stacked parameterizations, the IDs are combined (e.g., tests(psql, old)).

    @nox.session
    @nox.parametrize('django',
        ['1.9', '2.0'],
        ids=['old', 'new'])
    def tests(session, django):
        ...
    
    # OR
    
    @nox.session
    @nox.parametrize('django', [
        nox.param('1.9', id='old'),
        nox.param('2.0', id='new'),
    ])
    def tests(session, django):
        ...