A fast, pytest-compatible test collector and runner, written in Rust.
cito (Latin: "quickly" — the word doctors still write on urgent orders) makes the pytest inner loop fast, the way Ruff and uv did for linting and packaging. It discovers your tests by parsing them with ruff's parser — in milliseconds, not seconds — and verifies against real pytest that it finds the same node IDs.
Status: v0.3 (unreleased on main; 0.2.0 on PyPI). Collection is differential-tested against ~40 real suites — pytest's own, pandas, home-assistant, sphinx, pip — plus 100 fuzz seeds; ~640k node IDs checked in total. The runner does parallel subprocesses, warm in-process workers, and a per-project daemon that makes one-shot runs ~0.02 s. Not yet 1.0; the compatibility contract below is the map. Pre-1.0 versioning: 0.x minors may change behavior where pytest compatibility requires it, patches are fixes only; 1.0 freezes the CLI and the node-ID contract.
Collection, wall time (Apple M4 Max, Python 3.14, warm cache; see BENCHMARKS.md to reproduce):
| suite | tests | pytest --collect-only -q |
cito collect |
speedup |
|---|---|---|---|---|
| home-assistant 2026.7.1 (validated scope) | 81,251 | 16.91 s | 0.11 s | 156x |
| pandas 3.0.3 (installed) | 197,077 | 9.48 s | 0.26 s | 36x |
| pytest 9.1.1 (own suite) | 4,231 | 0.62 s | <0.01 s | >100x |
| synthetic corpus | 11,000 | 0.70 s | 0.01 s | 70x |
And the part that matters more than speed — the same answers:
| suite | pytest IDs | missing | wrong extras |
|---|---|---|---|
| pytest's own suite | 4,231 | 1 (a .txt doctest; doctest support is a known gap) |
0 |
| home-assistant 2026.7.1 (core + 249 integrations) | 81,251 | 0 | 0 |
| pandas 3.0.3 | 197,077 | 26 (0.013%) | 2 |
| flask 3.1.3 | 482 | 0 | 0 |
| rich 15.0.0 | 981 | 0 | 0 |
| click 8.4.2 | 1,686 | 0 | 0 |
| jinja2 3.1.6 | 909 | 0 | 0 |
| attrs 26.1.0 | 1,386 | 0 | 0 |
| httpx 0.28.1 | 1,418 | 0 | 0 |
| starlette 1.3.1 | 981 | 0 | 0 |
| urllib3 2.7.0 | 2,273 | 0 | 0 |
| werkzeug 3.1.8 | 969 | 0 | 0 |
| requests 2.34.2 | 633 | 0 | 0 |
| more-itertools 11.1.0 | 722 | 0 | 0 |
| packaging 26.2 | 61,513 | 0 | 0 |
| pluggy 1.6.0 | 124 | 0 | 0 |
| tornado 6.5.7 | 1,322 | 0 | 0 |
| black 26.5.1 | 446 | 0 | 0 |
| pydantic 2.13.4 | 12,775 | 0 | 0 |
| fastapi 0.139.0 | 3,323 | 0 | 0 |
| sympy 1.14.0 | 13,657 | 0 | 16 (0.1%: custom @SKIP import-time machinery) |
| typer 0.26.8 | 1,379 | 0 | 0 |
| networkx 3.6.1 | 7,100 | 0 | 0 |
| cryptography 49.0.0 | 4,472 | 0 | 0 |
| django-rest-framework 3.17.1 | 1,552 | 0 | 0 |
| sqlglot 30.12.0 | 1,127 | 0 | 0 |
| pytest-asyncio 1.4.0 | 299 | 0 | 0 |
| textual 8.2.8 | 3,467 | 0 | 0 |
| pytest-xdist 3.8.0 | 212 | 0 | 0 |
| httpcore 1.0.9 | 220 | 0 | 0 |
| scikit-learn wheel (site-packages) | 47,349 | 0 | 2 |
| botocore 1.43.40 | 78,668 | 0 | 0 |
| tox 4.56.1 | 7,929 | 0 | 0 |
| openai-python 2.44.0 | 6,731 | 0 | 0 |
| coverage.py 7.15.0 | 1,586 | 0 | 0 |
| virtualenv 21.6.0 | 328 | 0 | 0 |
| sphinx 9.1.0 | 2,424 | 0 | 0 |
| pip 26.1.2 | 2,997 | 0 | 0 |
| scipy wheel (site-packages) | 96,387 | 3,467 (3.6%: type() class factories) |
5 |
| numpy 3.x wheel (site-packages) | 49,443 | 760 (1.5%: type() loop-generated SIMD classes) |
0 |
| trio 0.33.0 | 895 | 0 | 0 |
| pillow 12.3.0 | 5,219 | 0 | 0 |
| aiohttp 3.14.1 | 4,364 | 0 | 0 |
| hypothesis 6.156.1 | 3,647 | 0 | 3 (asyncio wrapper dynamics) |
(scripts/validate_repos.py reruns the whole matrix against fresh clones —
the release gate. sqlalchemy and django are documented out: their suites
require project-specific collection-bootstrap plugins that no static tool
can see.)
scripts/diff_collect.py computes this equivalence on every CI run.
- pytest is the default test runner of the Python world (~a billion downloads
a month), and on large suites collection alone takes seconds to minutes
(pytest#5516). Every
pytest -k one_testpays that tax before a single test runs. - The tax is no longer only human: coding agents run the suite dozens of times per task. Suite latency is agent-loop latency.
- Ruff and uv proved the recipe: reimplement the hot path in Rust, treat the existing ecosystem's behavior as a compatibility contract, win by 10–100x.
Not on PyPI yet. From a checkout (builds with maturin or plain cargo):
$ uv tool install . # or: pip install .
$ cargo install --path . # Rust toolchain route$ cito collect # pytest-convention discovery, in parallel
tests/test_api.py::test_get
tests/test_api.py::TestAuth::test_login[admin]
$ cito collect --count # just the number
$ cito collect --json # grouped by file, for tools and agents
$ cito collect --python .venv/bin/python # env-aware: honors module-level importorskip
$ cito run -n 8 # parallel runner (subprocess workers)
$ cito run -n 8 --warm # v0.2 preview: pytest workers stay warm across chunks
$ cito run tests/test_api.py::TestAuth # node-ID selectors, like pytest
$ cito run --lf # only the tests that failed last time
$ cito run --watch --warm # live loop: warm workers survive across saves
$ cito run -k "http and not slow" # keyword expressions
$ cito run -x # stop at first failure (--maxfail N)
$ cito run --json # machine-readable summary for agents/CI
$ cito run -- --cov=mypkg # pass anything through to pytest; parallel
# coverage fragments are combined for you
$ cito run -m "not slow" # mark expressions, filtered at collection time
$ cito run --changed # only tests impacted by changes (AST import graph)
$ cito run --daemon # hit the per-project warm daemon: one-shot
# runs in ~0.02s (auto-starts; unix)
$ cito daemon status # start | stop | status- Configuration discovery:
pytest.ini,pyproject.toml([tool.pytest]and[tool.pytest.ini_options]),tox.ini,setup.cfg; rootdir inference;testpaths,python_files/python_classes/python_functions(prefix and glob forms, including path patterns liketesting/python/*.py),norecursedirs, virtualenv detection. - Collection semantics: definition-order node IDs; nested classes;
__init__/__new__exclusion; cross-module base-class resolution (the pandasTestMaskedArrays(base.ExtensionTests)pattern — resolved through imports, relative imports, star-imports, and Python's package-root sys.path semantics);unittest.TestCasesubclasses collected regardless of naming. - Parametrize expansion with an honesty contract: literal scalars, tuples,
stacked decorators (cartesian, pytest's piece order),
ids=, class-level parametrize, module-level parametrize aliases, and duplicate-ID disambiguation are expanded exactly. Anything static analysis cannot prove — floats, computed values,indirect=, parametrized or autouse fixtures,pytest_generate_testsin scope, unknown decorators — falls back to the bare test name rather than risking a wrong ID. A bare name is always a valid pytest selector for all of its parametrizations. - Environment awareness (opt-in):
--python PYprobes module-levelpytest.importorskip("...")requirements and drops modules pytest would skip in that environment. Without it, collection is fully static. - Runner preview:
cito runpartitions node IDs (whole files together, likexdist --dist loadfile) across N pytest subprocesses;--warmkeeps workers alive and runs chunks viapytest.main()in-process — execution stays inside real CPython, so conftest, fixtures, and plugins keep working. Corpus numbers: serial pytest 2.48 s →cito run -n 81.24 s →--warm1.20 s. - Scheduling: failures are recorded in
.cito/lastfailed(rootdir); every run schedules previously-failed files first, then changed files, then most-recently modified — the fastest possible time-to-first-signal.--lfruns only the recorded failures (the cache clears as they pass).--watchkeeps running: save a test file and only that file reruns. - Impact analysis (
--changed): runs only the tests a change can actually reach — a test file counts as impacted when it, a conftest above it, the config file, or any project file it transitively imports changed since the last run. Change one core module and exactly the tests that import it (directly or through other project modules) run; touch nothing andcito run --changedruns nothing. Resolution is AST-level and stays inside the project — third-party packages are treated as stable. - Node-ID selectors:
cito run tests/a.py::TestXandcito collect tests/a.py::test_yrestrict to matching tests, including their parametrizations. - Mark expressions:
-m "not slow"filters on statically-harvested marks (function, class chain, and modulepytestmark) at collection time — deselected tests are never scheduled at all.-m/-kinside configaddoptsare honored (CLI wins). Per-parametrizepytest.param(marks=...)marks are not filtered (approximation). - Namespace collection: test classes/functions imported into a test
module are collected there too (the urllib3-contrib rerun pattern);
@pytest.fixture(name=...)renames are tracked; anyio's plugin-injected backend parametrization is detected and falls back safely.
pytest's node IDs are the interface:
- A cito ID with a
[...]suffix must match a pytest ID exactly. - A bare cito ID stands for pytest's parametrized IDs of the same base — cito's declared fallback wherever static analysis cannot be sure.
scripts/diff_collect.py enforces both directions on every commit against
the fixture trees, a generated corpus, and randomized differential fuzzing
(bench/fuzz_gen.py builds seeded projects mixing nested classes,
cross-module inheritance, re-exports, parametrize variants, fixtures, marks,
and shadowing; 100 seeds pass locally, three run in CI). ~40 real
repositories — from flask to pandas to home-assistant — are checked before
releases via scripts/validate_repos.py.
Known gaps, tracked honestly:
- doctest collection (
--doctest-modules,.txtdoctests) - exact expansion where parametrization is computed at runtime (falls back to bare names by design; ~4% of IDs in heavily-fixtured repos like pandas), including pytest's duplicate-ID suffixes (True0/True1), which always fall back
pytest_generate_tests-generated extra tests that add new names- import-time class factories that synthesize tests from data files (jsonschema builds ~7k tests from the JSON-Schema-Test-Suite this way)
- plugin-driven collection hooks and custom collectors (literal
collect_ignore/collect_ignore_globlists in conftest.py ARE supported; computed appends are not), and plugins that redefine collection semantics outright (pytest-relaxed)
Plugins run untouched at execution time — cito hands pytest real node IDs and pytest loads your plugins as always. What matters for cito is whether a plugin changes collection; this matrix is the current, tested state:
| plugin | collection-time behavior | status | evidence |
|---|---|---|---|
| pytest-asyncio | async tests collected normally | supported | its own suite (299 IDs exact), aiohttp, home-assistant |
| anyio | backend fixture parametrizes tests | supported (declared fallback) | httpx 1,418 / starlette 981 exact |
| pytest-django | standard collection | supported | django-rest-framework 1,552 exact |
| hypothesis | @given wraps, IDs unchanged |
supported | hypothesis suite, pandas |
| pytest-xdist | none (runtime distribution) | compatible — cito schedules its own workers; pass -n through -- if you must |
pytest-xdist suite 212 exact |
| pytest-cov | none (runtime) | supported — per-chunk COVERAGE_FILE isolation, auto-combine |
coverage.py suite 1,586 exact |
| pytest-mock | none (fixture) | compatible | used across validated repos |
| pytest-timeout | none (runtime) | compatible | home-assistant venv |
| syrupy / snapshot plugins | none (fixture/report) | compatible | home-assistant, textual 3,467 exact |
| pytest-socket | none (runtime guard) | compatible | pip 2,997 exact (its addopts require it) |
| pytest-rerunfailures / flaky | none (runtime reruns) | compatible | pytest's own suite |
| pytest-randomly | runtime ordering only | compatible (cito orders files; in-chunk order is pytest's) | — |
| pytest-relaxed | rewrites collection semantics | not supported (documented out) | paramiko |
| ipython ipdoctest, doctest plugins | collect non-test sources | not supported (doctest is a known gap) | ipython, dateutil |
custom pytest_collect_file collectors |
turn arbitrary files into tests | not supported by design | pygments, sqlalchemy/django bootstrap plugins |
- v0.1 — collection parity + speed: shipped.
- v0.2 — warm workers: shipped —
--warmpools within a run,--watchkeeps them across saves, andcito run --daemonkeeps them across CLI invocations (workers self-purge modules whose files changed). - v0.3 — scheduling: shipped — failed-first,
--lf,--json, and AST-level impact analysis (--changedfollows the project import graph, conftest chain, and config). - Plugin compatibility matrix: shipped — see Plugin compatibility; each row is backed by a differential-validated repository.
- Replacing pytest's test-writing API. Your tests, fixtures, and plugins are the point; cito's job is to run them faster.
- A new assertion or fixture DSL.
$ cargo test # unit + integration tests
$ cargo build --release
$ uv run --with pytest scripts/diff_collect.py tests/fixtures/basic
$ bench/bench_collect.sh # reproduce the corpus numbersLicensed under either of Apache License 2.0 or MIT License at your option.