Skip to content

Commit

Permalink
Use Nix and Nox to get fast local CI
Browse files Browse the repository at this point in the history
Use shell.nix to bring in all Python versions that we want to support.
Also bring in Nox via its own dependency group.

This, together with noxfile.py defines a set of new "actions" that we
can run locally (and maybe also in CI in a future commit). For now we
define three actions:

  - tests (run on all supported Python versions)
  - lint (run linters)
  - reformat (run formatting actions)

Examples of how to run this:

  - nox -s tests  # Run test suite on all supported Python versions
  - nox -s tests-3.7  # Run test suite on v3.7 only
  - nox -s reformat  # Run formatting action (black + isort)
  - nox -s lint  # Run linters (mypy, pylint, isort, black)
  - nox  # Run all of the above

Some complications worth mentioning:

We have organized our dependencies using Poetry dependency groups (see
[1] for more information on those), and we would therefore like to use
those groups when telling Nox which dependencies are needed for each
of the Nox actions described above.

However, we cannot use `poetry install ...` to install these
dependencies, because Poetry insists on installing into its own
virtualenv - i.e. NOT the virtualenv that Nox creates for each session.

We could have used nox-poetry (https://github.com/cjolowicz/nox-poetry),
but unfortunately it does not support Poetry dependency groups, yet[2].

The workaround/solution is to export the required dependency groups from
Poetry into a requirements.txt file that we can then pass on to Nox's
session.install(). This is implemented by the install_groups() helper
function in our noxfile.py.

On my work laptop, the nox command (i.e. running all of the actions)
completes in ~50s for the initial run, and ~17s on subsequent runs
(when Nox can reuse its per-session virtualenvs).

[1]: https://python-poetry.org/docs/master/managing-dependencies/#dependency-groups

[2]: see cjolowicz/nox-poetry#895 or
cjolowicz/nox-poetry#977 for more discussion.
  • Loading branch information
jherland committed Jan 9, 2023
1 parent 81a2a09 commit dcbd1fd
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 48 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
path: ~/.cache/pypoetry/virtualenvs
key: ${{ runner.os }}-poetry-linters-${{ hashFiles('poetry.lock') }}
- name: Install project
run: poetry install --no-interaction --sync --with=test,dev
run: poetry install --no-interaction --sync --with=test,dev,nox
- name: Run type checker
run: poetry run mypy
- name: Check formatting with Black
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
*/__pycache__/
*/*/__pycache__/
__pycache__/
dist/
.nox/
.vscode/
74 changes: 74 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import hashlib
from pathlib import Path
from typing import Iterable

import nox


def install_groups(
session: nox.Session,
*,
include: Iterable[str] = (),
exclude: Iterable[str] = (),
include_self: bool = True,
) -> None:
"""Install Poetry dependency groups
This function installs the given dependency groups into the session's
virtual environment. When 'include_self' is true (the default), the
function also installs this package (".") and its default dependencies.
We cannot use `poetry install` directly here, because it ignores the
session's virtualenv and installs into Poetry's own virtualenv. Instead, we
use `poetry export` with suitable options to generate a requirements.txt
file which we can then pass to session.install().
Auto-skip the `poetry export` step if the poetry.lock file is unchanged
since the last time this session was run.
"""
lockdata = Path("poetry.lock").read_bytes()
digest = hashlib.blake2b(lockdata).hexdigest()
requirements_txt = Path(session.cache_dir, session.name, "reqs_from_poetry.txt")
hashfile = requirements_txt.with_suffix(".hash")

if not hashfile.is_file() or hashfile.read_text() != digest:
requirements_txt.parent.mkdir(parents=True, exist_ok=True)
argv = [
"poetry",
"export",
"--format=requirements.txt",
f"--output={requirements_txt}",
]
if include:
option = "only" if not include_self else "with"
argv.append(f"--{option}={','.join(include)}")
if exclude:
argv.append(f"--without={','.join(exclude)}")
session.run_always(*argv, external=True)
hashfile.write_text(digest)

session.install("-r", str(requirements_txt))
if include_self:
session.install(".")


@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11"], reuse_venv=True)
def tests(session):
install_groups(session, include=["test"])
session.run("pytest", "-x", "--log-level=debug", *session.posargs)


@nox.session(reuse_venv=True)
def lint(session):
install_groups(session, include=["dev", "test"], include_self=False)
session.run("mypy", "fawltydeps", "tests")
session.run("pylint", "fawltydeps", "tests")
session.run("isort", "fawltydeps", "tests", "--check-only")
session.run("black", "--check", ".")


@nox.session(reuse_venv=True)
def reformat(session):
install_groups(session, include=["dev"], include_self=False)
session.run("isort", "fawltydeps", "tests")
session.run("black", ".")
Loading

0 comments on commit dcbd1fd

Please sign in to comment.