Skip to main content

gerenuk

Impact-based pytest selection for Python, powered by ty-find.

A diff comes in; gerenuk run runs exactly the tests that diff impacts, and nothing else:

$ gerenuk run -- -q
gerenuk: 5 node id(s) from 1 origin(s) in 152 ms — details: gerenuk impacted-tests
.........                                                                [100%]
9 passed in 0.02s

It gets there by asking ty's type checker, through tyf, which symbols the working tree changed and which tests can reach them — so the selection follows Python's actual name resolution, not a name match. Anything gerenuk cannot see through becomes "run the whole suite" rather than a confident short list.

📖 Documentation · 🤖 llms.txt

Install

uv add --dev "gerenuk[ty]"

ty-find comes with gerenuktyf is what it drives. The [ty] extra adds ty itself; without it tyf falls back to uvx ty, which works whenever uv is around but fetches the checker on first use.

Verify the setup:

gerenuk doctor

Usage

gerenuk changed-symbols                # what the working tree changed
gerenuk impacted-tests                 # and which tests that reaches
gerenuk run -- -q                      # and run exactly those, under pytest
gerenuk run --dry-run                  # the decision and the argv, no pytest
gerenuk audit pkg/module.py            # separately: what nothing references
gerenuk guide                          # agent-facing instructions for where you stand

Not sure where to start? uvx gerenuk guide prints the setup instructions without installing anything; once a madoqua step names gerenuk, the same command prints how to read a report. Every page is under 60 lines.

The three selection stages are one pipeline, and run computes the whole thing in-process — the first two are there to be read when a selection surprises you.

changed-symbols

Maps the working tree's diff to the Python symbols it changed — the first stage of impact-based test selection. It needs only git; no tyf, no ty, no Python environment.

$ gerenuk changed-symbols
base main (merge-base 5ddda1f)

changed symbols (2)
  modified  method    mypkg.pipelines.enrich:Enricher.run  src/mypkg/pipelines/enrich.py
  added     function  mypkg.utils:parse_date               src/mypkg/utils.py

module-level changes (1)
  mypkg.pipelines.enrich  src/mypkg/pipelines/enrich.py

--base defaults to the first of origin/main, main, master that exists; the diff runs from merge-base(HEAD, base) to the working tree, staged and unstaged alike. --format json emits the same data for scripting. Registry decorators can be filtered out via [tool.gerenuk] ignore-decorators in pyproject.toml. See the documentation.

impacted-tests

Walks the reference graph from those changed symbols out to the tests that reach them — the selection, minus the pytest invocation, which is run's job.

$ gerenuk impacted-tests
base main (merge-base 5ddda1f)
verdict selected

impacted tests (2)
  tests/test_enrich.py::test_run_enriches
    ← mypkg.pipelines.enrich:Enricher.run
  tests/test_api.py::test_endpoint_returns_lines
    ← mypkg.api:enrich_endpoint ← mypkg.pipelines.enrich:Enricher.run

41 symbol(s) visited, 5 tyf call(s), 640 ms

The chain is the point: when a selection looks wrong, it names the edge to blame, and tyf refs <symbol> confirms it by hand.

Every run emits a verdict. selected means the list is the answer; run_all means run the whole suite, with a machine-readable reason — a non-Python file changed, a file did not parse, tyf was unavailable or failed, or a budget tripped. Both exit 0. Under-selecting silently is the one outcome that would make the tool worse than not having it, so anything gerenuk cannot see through becomes "run everything" rather than a short list.

See the documentation.

run

Turns that selection into a pytest invocation and runs it. gerenuk says one line for itself and then becomes pytest, so Ctrl-C, colours and the exit code are pytest's own.

$ gerenuk run -- -q
gerenuk: 5 node id(s) from 1 origin(s) in 152 ms — details: gerenuk impacted-tests
.........                                                                [100%]
9 passed in 0.02s

gerenuk owns the invocation rather than printing node ids for a shell to interpolate, because a selection has three answers and an argument list has only two: an empty pytest argv is "run everything". So pytest $(gerenuk …) would run the entire suite in precisely the best case — the diff that impacts no tests. Here that case spawns nothing and exits 0.

run is also where pytest's own rules get applied. A recorded symbol is often a helper or a fixture rather than a test, so each name is checked against pytest's collection conventions and trimmed — or widened to the whole file — until it is one pytest will accept. And because pytest injects fixtures by name, which no type checker can follow, conftest.py is parsed for its fixtures and their consumers; the expansion keeps its audit trail:

tests/test_service.py::test_summary
  ← tests.conftest:shelter ← sample_pkg.service:describe

--dry-run prints the decision and the exact argv without spawning anything. pytest-command = ["uv", "run", "pytest"] under [tool.gerenuk] names the runner. See the documentation.

Dead code: audit

The same reference graph answers the opposite question — what does nothing reach? — so gerenuk audit asks it for every symbol in a file:

$ gerenuk audit sample_pkg/service.py
warn  sample_pkg/service.py:43  func `legacy_export` has no references
note  sample_pkg/service.py:34  method `ShelterService.seniors` is referenced only from tests (1)

1 file(s), 7 symbol(s) checked — 1 warn, 1 note
Severity Rule
warn The symbol has no references anywhere
note Every reference lives in a test file

Only callable symbols are audited; _private and dunder names are skipped. --format json emits the findings for scripting.

It is a verifier, not a sweep

vulture is the cheap repo-wide sweep for dead code: one pass over a whole project, no type checker involved. It works on names, so it cannot tell two same-named symbols apart, it flags dynamic and framework code that is very much alive, and it cannot tell you who references a symbol.

gerenuk audit asks ty's type checker instead, through tyf. References resolve the way Python resolves them — docstrings, comments and same-named symbols in other modules do not count — and each one comes back as a file and a line you can open. That costs one tyf refs call per symbol and needs tyf installed, which is why it takes the files you name rather than a whole repository. It is shaped for confirming a specific suspicion.

The two fit together in that order:

vulture src/                    # sweep: what might be dead
gerenuk audit src/suspect.py    # confirm the file against resolved references
tyf refs one_symbol             # or confirm a single name
# then delete

Exit codes carry the verdict: 0 nothing flagged, 1 findings reported, 2 the run could not complete. The split between 1 and 2 is what makes it usable in CI — a failing check and a broken setup are different problems.

Referenced only from tests

The note is the finding a name-based scanner cannot produce at all: every reference to the symbol exists, and every one of them is in a test file. Production stopped calling it and its own tests are what keep it alive — usually the residue of an unfinished refactor, and exactly the code that survives a dead-code sweep forever. That finding is why audit earns a place next to a repo-wide scanner rather than deferring to one entirely.

What it cannot see

gerenuk reports static references, plus the three edges a type checker cannot draw and gerenuk models explicitly: conftest.py fixtures, registering decorators, and renaming imports (from x import y as z, which tyf answers for under y while the code says z). Everything else dynamic — registries populated at runtime, getattr dispatch, __all__ star re-exports — stays invisible.

So findings are leads, not a delete list. Confirm with tyf refs <symbol> before deleting anything.

Registered functions

Nothing references a @app.command() or a @router.get() — the framework holds the only handle. So gerenuk follows the decorator to its registrar (app, router) and treats what reaches the registrar as reaching the function. A decorator that only wraps (@property, @functools.wraps) is left alone, and a registrar that cannot be resolved gives run_all with reason: "decorator_dispatch" rather than a confident empty answer. See ADR 0012.

Usage with Claude Code

Add this to your project's CLAUDE.md. Two lines is the whole of it — the exit codes, the run_all verdict and the caveat above are in gerenuk --help and on the documentation site, which is where an agent that needs them should go:

### `gerenuk` — test selection and dead code

- `gerenuk run -- -q` runs only the tests the working tree's diff impacts
  (`--dry-run` to inspect; `gerenuk impacted-tests` explains why).
- `gerenuk audit <file>` confirms a symbol vulture flagged is really unused;
  its `only tests reach it` findings are ones vulture cannot produce.

Development

make review        # fmt + clippy + rust tests + fixture pytest + audit + deny
make review-quick  # skip the network checks
make test-impact   # impacted-tests against the fixture, with a real tyf
make test-run      # the whole pipeline, with a real tyf and a real pytest
make docs          # build the mdBook site and llms.txt into docs/book/html

The test suite is hermetic: tests/common/mod.rs stubs tyf and pytest with shell scripts and points GERENUK_TYF and GERENUK_PYTEST at them, so none of tyf, ty or pytest is needed to run cargo test. See docs/dev/ARCHITECTURE.md for the shape and docs/adr/ for why it is that shape.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

gerenuk-0.3.1.tar.gz (1.1 MB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

gerenuk-0.3.1-py3-none-win_amd64.whl (1.2 MB view details)

Uploaded Python 3Windows x86-64

gerenuk-0.3.1-py3-none-manylinux_2_28_x86_64.whl (1.3 MB view details)

Uploaded Python 3manylinux: glibc 2.28+ x86-64

gerenuk-0.3.1-py3-none-macosx_11_0_arm64.whl (1.2 MB view details)

Uploaded Python 3macOS 11.0+ ARM64

File details

Details for the file gerenuk-0.3.1.tar.gz.

File metadata

  • Download URL: gerenuk-0.3.1.tar.gz
  • Upload date:
  • Size: 1.1 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for gerenuk-0.3.1.tar.gz
Algorithm Hash digest
SHA256 0938a056fc46440f3b3a83c465526247f1815f05ed0aab26401cd8bdd8b3b727
MD5 15baf8c7cc4767fd8e64231f37e98aab
BLAKE2b-256 b6d730c04740c694ebe0a6bfd17ecb90553ee41274033adeae787cfb369ce31d

See more details on using hashes here.

Provenance

The following attestation bundles were made for gerenuk-0.3.1.tar.gz:

Publisher: release.yml on mojzis/gerenuk

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file gerenuk-0.3.1-py3-none-win_amd64.whl.

File metadata

  • Download URL: gerenuk-0.3.1-py3-none-win_amd64.whl
  • Upload date:
  • Size: 1.2 MB
  • Tags: Python 3, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for gerenuk-0.3.1-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 82e47509c9f101298d2a3c865fa3a9c4977f7fa632b11be6e190c4dbb79d76e9
MD5 ca9cb84c8963720a0845d881bcf20cf6
BLAKE2b-256 e91e8039f351bcbc75f4b648ac2a98c58e832a3b41bbf354751b64300820fa5b

See more details on using hashes here.

Provenance

The following attestation bundles were made for gerenuk-0.3.1-py3-none-win_amd64.whl:

Publisher: release.yml on mojzis/gerenuk

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file gerenuk-0.3.1-py3-none-manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for gerenuk-0.3.1-py3-none-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 d80528e96c195133a9cc2c7f0470413c97dceab86afd1437a213e96e78479c55
MD5 71aa4dcf91c2ee18c131e8d8b161a3fc
BLAKE2b-256 1bb92644c11616691979f0025be61269f4be04442c79fbd7d814b26b1ac59f32

See more details on using hashes here.

Provenance

The following attestation bundles were made for gerenuk-0.3.1-py3-none-manylinux_2_28_x86_64.whl:

Publisher: release.yml on mojzis/gerenuk

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file gerenuk-0.3.1-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for gerenuk-0.3.1-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 e33289016c58ec81b1afbac0c1ae59ce2caddde2264a29f46d70bce7f7030af2
MD5 3decd70316fcc791bfaa74431322bb7c
BLAKE2b-256 1d5a97341a2ab50e0fd73b812878d459bceec2edd54359555ba0ee4e4ce7afc2

See more details on using hashes here.

Provenance

The following attestation bundles were made for gerenuk-0.3.1-py3-none-macosx_11_0_arm64.whl:

Publisher: release.yml on mojzis/gerenuk

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.3.1 This release

4 files

0.3.0

4 files

0.2.0

4 files

0.1.0

4 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page