Contributing
Setup
Backing services
The Redis, MongoDB and PostgreSQL tests skip themselves when the service is not
reachable, so a bare make test always passes — it just covers less.
make services-up # docker compose up -d --wait
make test # backend tests now run instead of skipping
make services-down # docker compose down -v
make test-all does the first two in one step.
Point the suite somewhere else with CACHETIC_TEST_REDIS_URL,
CACHETIC_TEST_MONGO_URL or CACHETIC_TEST_POSTGRES_URL if the default ports
clash with something local.
Checks
CI runs the same lint and tests on Python 3.11, 3.12 and 3.13 with all three services as containers.
ruff is the linter of record and its rule set is pinned in pyproject.toml —
without pinning, the defaults drift between releases and CI starts failing on
untouched code. .flake8 exists only for editors that run flake8. Lines are 120
columns, set identically in all four places.
pyright is expected to report zero errors, and its settings are pinned in
pyproject.toml for the same reason as ruff's. Run it against the project
environment — the optional backends are dev dependencies, so a bare pyright on
an interpreter without them reports every extra as a missing import:
Tests
| File | Covers |
|---|---|
test_async_cache.py |
Every async operation, parametrized over all four backends via the backend_url fixture |
test_async_registry.py |
Event-loop affinity — sequential loops, threads with their own loops, close_all, dead-loop sweeping |
test_sync_registry.py |
Client sharing, close_all, recovery after it, and concurrent construction |
test_version_compat.py |
Reading data written by v0.1.0 onwards |
test_url.py |
Connection-URL parsing, shared by the sync and async adapters |
test_postgres_cache.py |
Schema and connection-option parity between the two PostgreSQL backends, pool sizing, lazy expiry |
test_readme_executes.py |
Runs every ```python block in README.md and docs/index.md |
test_readme_usages.py |
Asserts the outcomes the README examples describe |
test_client_semantics.py |
get's default, stored None vs a miss, and the disabled client |
test_v060_api_floor.py |
Principle 1 — every call v0.6.0 accepted is still accepted |
test_supplied_client.py |
Principle 1 — cache_url taking a client the caller owns, and not closing it |
test_backend_parity.py |
Principle 2 — the four adapters answer set(key, value, ex) identically |
test_lazy_expiry_race.py |
The conditional expiry delete, on all four paths (sync/async × get/exists) |
test_key_scheme.py |
Principle 4 — the key read back off the wire is prefix:key |
test_optional_imports.py |
Principle 5 — importing Cachetic imports no driver. Runs in a subprocess |
test_resource_discipline.py |
Principle 6 — when connections open, that they are shared, that nothing is memoised |
utils/test_compression.py |
zstd/zlib round-trips, format detection, and thread safety |
utils/test_hide_url_password.py |
Credential redaction — every log line and error message depends on it |
The backend_url fixture is the way to cover all four backends with one set of
assertions. Prefer it over writing per-backend tests.
Keys must be unique per test — backends are shared between tests and between
runs. test_async_cache.py has a unique_key() helper.
Every ```python block in README.md and docs/index.md is executed
test_readme_executes.py extracts them from both pages and runs each page in
order into its own namespace, in a temporary working directory — so a snippet
may build on names an earlier one introduced, but may not depend on anything
that page never shows. A snippet that cannot run is a failing test.
Blocks that only illustrate something — a before/after diff, a shell command — must use a different fence language. Do not add a ```python fence you do not intend to be runnable.
Snippets naming Redis, MongoDB or PostgreSQL are fine as long as they only construct a client: connecting is lazy, so no service is needed. A snippet that performs an operation has to use the disk backend.
test_readme_usages.py is separate and still hand-kept. Execution proves a
snippet runs; that file proves it does what the surrounding prose claims.
Documentation
README.md and docs/index.md carry the same content in two formats — plain
Markdown for GitHub, mkdocs-material tabs and admonitions for the site. Update
both. .github/workflows/docs.yml runs mkdocs build --strict on every pull
request and deploys from main only if that passes.
Conventions
Module layout
Every module under cachetic/ is laid out in this order:
- Module docstring
- Imports
- Constants (including
__all__, the logger, and module-level state) - Public functions
- Public classes
- Private functions and classes
Putting functions above classes means a return annotation naming a class defined
below has to be quoted — -> "Entry". That is deliberate, not an oversight:
Python evaluates annotations at definition time, so the quotes are what make the
order legal. cachetic/extensions/_registry.py and _url.py are the examples.
Module-level __getattr__ is the one exception and stays at the bottom with the
private definitions. It is import machinery, not part of the public surface.
Everything else
- Backend adapters implement the four-method protocol in
cachetic/types/and use positional-only parameters (key,value,ex). - Adapters resolve their shared client through an
EntryHandleon every operation. Never store theEntry— that breaks recovery afterclose_all(). - The sync and async registries mirror each other on purpose. A change to one belongs in the other.
- Connection URLs are parsed in one place,
cachetic/extensions/_url.py.
Before changing behaviour
Read Principles — six rules, and a change that breaks one does not ship. Then read the invariants in Architecture: every one of them is the kind that fails silently rather than loudly, which is why each names the test that pins it.
Proving a fix
Reading the new code and agreeing with yourself is not evidence. Put the old behaviour back, run the new test, and watch it fail — then restore the fix. A test that passes against the bug it was written for is worse than no test, because it reads like coverage.
This is not hypothetical here: of three tests written for one past fix, two turned out to pin a different mechanism than their author assumed. The failure mode is the test asserting something true both before and after.
Where the pre-fix failure is a crash rather than a wrong answer — the zstd
thread-safety test is the example — run it in a subprocess and assert on the
exit code, so a SIGSEGV fails one test instead of taking down the suite.