Skip to content

IShinji/cito

Repository files navigation

cito

CI

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.

Benchmarks

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.

Why

  • 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_test pays 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.

Install

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

What works today

$ 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 like testing/python/*.py), norecursedirs, virtualenv detection.
  • Collection semantics: definition-order node IDs; nested classes; __init__/__new__ exclusion; cross-module base-class resolution (the pandas TestMaskedArrays(base.ExtensionTests) pattern — resolved through imports, relative imports, star-imports, and Python's package-root sys.path semantics); unittest.TestCase subclasses 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_tests in 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 PY probes module-level pytest.importorskip("...") requirements and drops modules pytest would skip in that environment. Without it, collection is fully static.
  • Runner preview: cito run partitions node IDs (whole files together, like xdist --dist loadfile) across N pytest subprocesses; --warm keeps workers alive and runs chunks via pytest.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 8 1.24 s → --warm 1.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. --lf runs only the recorded failures (the cache clears as they pass). --watch keeps 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 and cito run --changed runs 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::TestX and cito collect tests/a.py::test_y restrict to matching tests, including their parametrizations.
  • Mark expressions: -m "not slow" filters on statically-harvested marks (function, class chain, and module pytestmark) at collection time — deselected tests are never scheduled at all. -m/-k inside config addopts are honored (CLI wins). Per-parametrize pytest.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.

The compatibility contract

pytest's node IDs are the interface:

  1. A cito ID with a [...] suffix must match a pytest ID exactly.
  2. 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, .txt doctests)
  • 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_glob lists in conftest.py ARE supported; computed appends are not), and plugins that redefine collection semantics outright (pytest-relaxed)

Plugin compatibility

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

Architecture

  1. v0.1 — collection parity + speed: shipped.
  2. v0.2 — warm workers: shipped — --warm pools within a run, --watch keeps them across saves, and cito run --daemon keeps them across CLI invocations (workers self-purge modules whose files changed).
  3. v0.3 — scheduling: shipped — failed-first, --lf, --json, and AST-level impact analysis (--changed follows the project import graph, conftest chain, and config).
  4. Plugin compatibility matrix: shipped — see Plugin compatibility; each row is backed by a differential-validated repository.

Non-goals

  • 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.

Development

$ 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 numbers

License

Licensed under either of Apache License 2.0 or MIT License at your option.

About

A fast, pytest-compatible test collector and runner, written in Rust

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors