Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ Five non-abstract atomic primitives live on the base class with non-atomic fallb

`tests/conftest.py` sets `MemoryBackend` as the default backend via an `autouse=True` fixture for every test. Tests requiring Redis or Memcached must configure their own backends. The `memory_backend` fixture manages the cleanup task lifecycle.

`[tool.pytest.ini_options]` sets `asyncio_mode = "auto"` (no `@pytest.mark.asyncio`), `--strict-markers`, `xfail_strict = true` and `filterwarnings = ["error"]`: an expected warning needs `pytest.warns`, live Memcached resets go through `flush_memcached` in `tests/live_servers.py`, and the autouse `close_network_clients` fixture closes every Redis/Memcached client a test builds (an unclosed socket's `ResourceWarning` would fail a later test). The one global ignore covers starlette 1.0.0's deprecated anyio alias in the `lowest` tox env.

### Code Quality Rules

- Ruff is configured with `extend-select = ['ALL']` with specific ignores (see `pyproject.toml`). Notable: E501 (line length), FBT001/FBT002 (boolean args — intentional for Cache-Control API). `fastapi.Depends` is allowed in argument defaults via `flake8-bugbear.extend-immutable-calls` rather than ignoring B008.
Expand Down
25 changes: 25 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,31 @@ server is disposable, the flag says *whether* skipping is acceptable at all.
Never set the flag in a shell where the ports point at a server you care about —
it makes the run louder, not safer.

### Strict pytest settings

`[tool.pytest.ini_options]` in `pyproject.toml` keeps the suite strict:

- `asyncio_mode = "auto"`: every `async def` test and fixture runs on
pytest-asyncio, so tests carry no `@pytest.mark.asyncio`. Write an async test
instead of calling `asyncio.run()` inside a sync one.
- `--strict-markers`: a mistyped or unregistered marker fails collection
instead of being silently ignored.
- `xfail_strict = true`: an `xfail` test that starts passing fails the run, so
the marker gets removed.
- `filterwarnings = ["error"]`: any warning fails the test that raised it. A
test that expects one asserts it with `pytest.warns(..., match=...)`. A reset
that only needs the side effect silences that one warning locally, as
`tests/live_servers.py:flush_memcached` does for `Memcached.clear()`. Add a
global ignore only for a third-party warning the tests cannot avoid, with a
comment saying why.

An unclosed socket counts too: its `ResourceWarning` fails whichever test
is running when the garbage collector finds it. The autouse
`close_network_clients` fixture in `tests/conftest.py` closes every Redis
and Memcached client a test builds, on the test's own event loop. A sync
test that uses a Redis backend from inside `TestClient` closes it through
`client.portal`, because the connections belong to that loop.

### Checking that a test can fail

Coverage says a line ran, not that anything checked what it did. A test that
Expand Down
14 changes: 14 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,21 @@ include = ["fastapi_cachex/py.typed"]
pythonpath = [
"."
]
testpaths = ["tests"]
addopts = ["--strict-markers"]
xfail_strict = true
# Every async test and fixture runs on pytest-asyncio; no per-test marker.
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
# An unexpected warning fails the test. A test that expects one asserts it
# with pytest.warns or opts out with a narrow filterwarnings mark.
filterwarnings = [
"error",
# starlette 1.0.0 (our floor, run by the `lowest` tox env) imports
# TestClient through an alias that newer anyio deprecates. Later starlette
# releases no longer use it; nothing in this package or its tests does.
"ignore:The anyio.abc.BlockingPortal alias is deprecated:DeprecationWarning",
]

[tool.ruff.lint]
ignore = [
Expand Down
7 changes: 0 additions & 7 deletions tests/backends/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ def backend() -> DictBackend:
return DictBackend()


@pytest.mark.asyncio
async def test_increment_fallback_creates_then_adds(backend: DictBackend) -> None:
assert await backend.increment("hits", ttl=30) == 1
assert await backend.increment("hits", 4, ttl=30) == 5
Expand All @@ -57,7 +56,6 @@ async def test_increment_fallback_creates_then_adds(backend: DictBackend) -> Non
assert backend.store["hits"] == (counter_entry(3), None)


@pytest.mark.asyncio
async def test_increment_fallback_rejects_a_cached_response(
backend: DictBackend,
) -> None:
Expand All @@ -67,7 +65,6 @@ async def test_increment_fallback_rejects_a_cached_response(
await backend.increment("page")


@pytest.mark.asyncio
async def test_get_and_delete_fallback_returns_then_removes(
backend: DictBackend,
) -> None:
Expand All @@ -79,7 +76,6 @@ async def test_get_and_delete_fallback_returns_then_removes(
assert await backend.get_and_delete("once") is None


@pytest.mark.asyncio
async def test_delete_many_fallback_deletes_one_by_one(backend: DictBackend) -> None:
await backend.set("a", CacheEntry(fingerprint="e", content=b"1"))
await backend.set("b", CacheEntry(fingerprint="e", content=b"2"))
Expand All @@ -88,7 +84,6 @@ async def test_delete_many_fallback_deletes_one_by_one(backend: DictBackend) ->
assert backend.store == {}


@pytest.mark.asyncio
async def test_set_if_absent_fallback_stores_only_the_first_value(
backend: DictBackend,
) -> None:
Expand All @@ -100,7 +95,6 @@ async def test_set_if_absent_fallback_stores_only_the_first_value(
assert backend.store["slot"] == (first, 30)


@pytest.mark.asyncio
async def test_delete_if_equals_fallback_removes_only_a_matching_entry(
backend: DictBackend,
) -> None:
Expand All @@ -115,7 +109,6 @@ async def test_delete_if_equals_fallback_removes_only_a_matching_entry(
assert await backend.delete_if_equals("slot", theirs) is False


@pytest.mark.asyncio
async def test_expire_if_equals_fallback_updates_ttl_only_when_matching(
backend: DictBackend,
) -> None:
Expand Down
5 changes: 0 additions & 5 deletions tests/backends/test_clear_pattern_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ async def _populate(backend: BaseCacheBackend) -> None:
await backend.set(key, CacheEntry(fingerprint=key, content=b"v"))


@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True)
@pytest.mark.parametrize(
("pattern", "expected_removed"),
Expand All @@ -97,7 +96,6 @@ async def test_clear_pattern_globs_the_whole_key(
assert len(await backend.get_all_keys()) == len(KEYS) - expected_removed


@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True)
async def test_a_bare_path_pattern_warns_instead_of_clearing_nothing(
backend: BaseCacheBackend,
Expand All @@ -111,7 +109,6 @@ async def test_a_bare_path_pattern_warns_instead_of_clearing_nothing(
assert removed == 0


@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True)
@pytest.mark.parametrize(
"pattern", ["GET|||*|||/users/*", "cache:user:*", "*", "user:*", "/users/*"]
Expand All @@ -132,7 +129,6 @@ async def test_patterns_that_clear_something_do_not_warn(
await backend.clear_pattern(pattern)


@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True)
async def test_cache_manager_clear_pattern_is_relative_to_its_namespace(
backend: BaseCacheBackend,
Expand All @@ -148,7 +144,6 @@ async def test_cache_manager_clear_pattern_is_relative_to_its_namespace(
assert await manager.get("post:1") == {"title": "c"}


@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True)
async def test_clear_path_finds_paths_with_encoded_characters(
backend: BaseCacheBackend,
Expand Down
3 changes: 0 additions & 3 deletions tests/backends/test_counter_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ async def backend(request: pytest.FixtureRequest) -> AsyncIterator[BaseCacheBack
await instance.delete_many(list(KEYS))


@pytest.mark.asyncio
async def test_increment_rejects_a_cached_response_with_a_numeric_body(
backend: BaseCacheBackend,
) -> None:
Expand All @@ -77,7 +76,6 @@ async def test_increment_rejects_a_cached_response_with_a_numeric_body(
assert await backend.get(KEYS[0]) == page


@pytest.mark.asyncio
async def test_increment_continues_a_counter_written_with_set(
backend: BaseCacheBackend,
) -> None:
Expand All @@ -88,7 +86,6 @@ async def test_increment_continues_a_counter_written_with_set(
assert await backend.get(KEYS[1]) == counter_entry(4)


@pytest.mark.asyncio
async def test_a_counter_that_shrinks_reads_back_as_a_counter(
backend: BaseCacheBackend,
) -> None:
Expand Down
Loading
Loading