From f6ee9a37242c8bb00f48dcbc129b311eabaffccb Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Sat, 11 Jul 2026 09:45:42 +0200 Subject: [PATCH 1/3] Add runtime-checkable Protocol typing to all public interfaces. Enables structural subtyping for consumers while preserving nominal adapter inheritance, instantiation guards, and scan_keys defaults. Co-authored-by: Cursor --- README.md | 2 + python_repositories/adapters/minio_adapter.py | 2 +- python_repositories/adapters/redis_adapter.py | 2 +- .../interfaces/connection_aware_interface.py | 8 +- .../interfaces/context_aware_interface.py | 9 +- .../interfaces/json_repository_interface.py | 9 +- .../interfaces/object_repository_interface.py | 8 +- tests/unit/structural_typing_test.py | 134 ++++++++++++++++++ 8 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 tests/unit/structural_typing_test.py diff --git a/README.md b/README.md index 29a8a95..1939d29 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Subclass an adapter in your own repository to add domain-specific methods while | **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) | | **Your project** | Subclass an adapter and add domain methods | +Each public interface is a `@runtime_checkable` `Protocol` with `@abstractmethod` members. **Subclass an adapter** when you need connection management and shared behavior — explicit subclasses get runtime instantiation guards and inherited default methods (e.g. `scan_keys`). **Type-annotate against an interface** when you want loose coupling — any object with the right methods satisfies the contract for mypy and `isinstance()` checks, without inheriting from this package. + Connection adapters expose `connect()`, `disconnect()`, and `is_connected()`. The latter verifies backend reachability with a cached health probe (default TTL: 1 second). Subclasses may override `health_check_ttl_seconds`. `connect()` is idempotent: calling it while already connected and healthy is a no-op. ## Future direction diff --git a/python_repositories/adapters/minio_adapter.py b/python_repositories/adapters/minio_adapter.py index 3e71d9f..db4751c 100644 --- a/python_repositories/adapters/minio_adapter.py +++ b/python_repositories/adapters/minio_adapter.py @@ -19,7 +19,7 @@ except ImportError as exc: ) from exc -class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter): +class MinioAdapter(ConnectionAwareAdapter, ObjectRepositoryInterface): """Minio adapter exposing basic CRUD functionality.""" endpoint_env_var_name: str = "MINIO_ENDPOINT" diff --git a/python_repositories/adapters/redis_adapter.py b/python_repositories/adapters/redis_adapter.py index 88bdbfa..5a21c5a 100644 --- a/python_repositories/adapters/redis_adapter.py +++ b/python_repositories/adapters/redis_adapter.py @@ -21,7 +21,7 @@ except ImportError as exc: ) from exc -class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter): +class RedisAdapter(ConnectionAwareAdapter, JsonRepositoryInterface): """Redis adapter exposing basic CRUD functionality.""" uri_env_var_name: str = "REDIS_URI" diff --git a/python_repositories/interfaces/connection_aware_interface.py b/python_repositories/interfaces/connection_aware_interface.py index 0cba6a4..e1a6d3a 100644 --- a/python_repositories/interfaces/connection_aware_interface.py +++ b/python_repositories/interfaces/connection_aware_interface.py @@ -1,9 +1,11 @@ -"""Definition of ConnectionAwareInterface abstract base class.""" +"""Definition of ConnectionAwareInterface protocol and abstract base class.""" -from abc import ABC, abstractmethod +from abc import abstractmethod +from typing import Protocol, runtime_checkable -class ConnectionAwareInterface(ABC): +@runtime_checkable +class ConnectionAwareInterface(Protocol): """Interface that defines connection-related methods.""" @abstractmethod diff --git a/python_repositories/interfaces/context_aware_interface.py b/python_repositories/interfaces/context_aware_interface.py index 7815304..9f29eae 100644 --- a/python_repositories/interfaces/context_aware_interface.py +++ b/python_repositories/interfaces/context_aware_interface.py @@ -1,12 +1,13 @@ -"""Definition of ContextAwareInterface abstract base class.""" +"""Definition of ContextAwareInterface protocol and abstract base class.""" from __future__ import annotations -from abc import ABC, abstractmethod -from typing import Self +from abc import abstractmethod +from typing import Protocol, Self, runtime_checkable -class ContextAwareInterface(ABC): +@runtime_checkable +class ContextAwareInterface(Protocol): """Interface that defines context-related methods.""" @abstractmethod diff --git a/python_repositories/interfaces/json_repository_interface.py b/python_repositories/interfaces/json_repository_interface.py index 66ae079..2a13e7a 100644 --- a/python_repositories/interfaces/json_repository_interface.py +++ b/python_repositories/interfaces/json_repository_interface.py @@ -1,11 +1,12 @@ -"""Definition of JsonRepositoryInterface abstract base class.""" +"""Definition of JsonRepositoryInterface protocol and abstract base class.""" -from abc import ABC, abstractmethod +from abc import abstractmethod from collections.abc import Iterator -from typing import Any +from typing import Any, Protocol, runtime_checkable -class JsonRepositoryInterface(ABC): +@runtime_checkable +class JsonRepositoryInterface(Protocol): """Interface that defines JSON document CRUD methods.""" @abstractmethod diff --git a/python_repositories/interfaces/object_repository_interface.py b/python_repositories/interfaces/object_repository_interface.py index 85482f9..7f07a9f 100644 --- a/python_repositories/interfaces/object_repository_interface.py +++ b/python_repositories/interfaces/object_repository_interface.py @@ -1,10 +1,12 @@ -"""Definition of ObjectRepositoryInterface abstract base class.""" +"""Definition of ObjectRepositoryInterface protocol and abstract base class.""" -from abc import ABC, abstractmethod +from abc import abstractmethod from io import BytesIO +from typing import Protocol, runtime_checkable -class ObjectRepositoryInterface(ABC): +@runtime_checkable +class ObjectRepositoryInterface(Protocol): """Interface that defines binary object CRUD methods.""" @abstractmethod diff --git a/tests/unit/structural_typing_test.py b/tests/unit/structural_typing_test.py new file mode 100644 index 0000000..ddbc5a6 --- /dev/null +++ b/tests/unit/structural_typing_test.py @@ -0,0 +1,134 @@ +"""Unit tests for structural typing of public interfaces.""" + +from __future__ import annotations + +from collections.abc import Iterator +from io import BytesIO +from typing import Any + +from python_repositories.interfaces import ( + ConnectionAwareInterface, + ContextAwareInterface, + JsonRepositoryInterface, + ObjectRepositoryInterface, +) + + +class InMemoryJsonRepo: + """Plain class that satisfies JsonRepositoryInterface without inheritance.""" + + def get(self, key: str) -> dict[str, Any] | None: + return None + + def set(self, key: str, data: dict[str, Any]) -> None: + pass + + def delete(self, key: str) -> None: + pass + + def list_keys(self, pattern: str) -> list[str]: + return [] + + def scan_keys( + self, + pattern: str, + *, + count: int | None = None, + ) -> Iterator[str]: + del count + yield from self.list_keys(pattern) + + +class InMemoryObjectRepo: + """Plain class that satisfies ObjectRepositoryInterface without inheritance.""" + + def get(self, object_name: str) -> BytesIO | None: + return None + + def put( + self, + object_name: str, + data: BytesIO, + content_type: str = "application/octet-stream", + ) -> None: + pass + + def delete(self, object_name: str) -> None: + pass + + def list_objects(self, prefix: str = "") -> list[str]: + return [] + + +class FakeConnection: + """Plain class that satisfies ConnectionAwareInterface without inheritance.""" + + def connect(self) -> None: + pass + + def disconnect(self) -> None: + pass + + def is_connected(self) -> bool: + return True + + +class FakeContextManager: + """Plain class that satisfies ContextAwareInterface without inheritance.""" + + def __enter__(self) -> FakeContextManager: + return self + + def __exit__( + self, exc_type: type | None, exc_val: object | None, exc_tb: object | None + ) -> None: + pass + + +def accepts_json_repo(repo: JsonRepositoryInterface) -> None: + """Type-checking hook for JsonRepositoryInterface structural subtyping.""" + repo.get("key") + + +def accepts_object_repo(repo: ObjectRepositoryInterface) -> None: + """Type-checking hook for ObjectRepositoryInterface structural subtyping.""" + repo.get("object") + + +def accepts_connection_aware(connection: ConnectionAwareInterface) -> None: + """Type-checking hook for ConnectionAwareInterface structural subtyping.""" + connection.is_connected() + + +def accepts_context_aware(context: ContextAwareInterface) -> None: + """Type-checking hook for ContextAwareInterface structural subtyping.""" + with context: + pass + + +def test_json_repository_structural_subtyping() -> None: + """Test that a plain class satisfies JsonRepositoryInterface structurally.""" + repo: JsonRepositoryInterface = InMemoryJsonRepo() + accepts_json_repo(repo) + assert isinstance(repo, JsonRepositoryInterface) + + +def test_object_repository_structural_subtyping() -> None: + """Test that a plain class satisfies ObjectRepositoryInterface structurally.""" + repo: ObjectRepositoryInterface = InMemoryObjectRepo() + accepts_object_repo(repo) + assert isinstance(repo, ObjectRepositoryInterface) + + +def test_connection_aware_structural_subtyping() -> None: + """Test that a plain class satisfies ConnectionAwareInterface structurally.""" + connection: ConnectionAwareInterface = FakeConnection() + accepts_connection_aware(connection) + assert isinstance(connection, ConnectionAwareInterface) + + +def test_context_aware_structural_subtyping() -> None: + """Test that a plain class satisfies ContextAwareInterface structurally.""" + context: ContextAwareInterface = FakeContextManager() + accepts_context_aware(context) + assert isinstance(context, ContextAwareInterface) -- 2.54.0 From 44b15cb21ad84ccc399cd2c03710f4c7a54cc29d Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Sat, 11 Jul 2026 10:32:17 +0200 Subject: [PATCH 2/3] Move structural typing tests into interface unit test files. Align test layout with the one-test-file-per-interface convention by removing structural_typing_test.py. Co-authored-by: Cursor --- tests/unit/connection_aware_interface_test.py | 25 ++++ tests/unit/context_aware_interface_test.py | 25 ++++ tests/unit/json_repository_interface_test.py | 38 +++++ .../unit/object_repository_interface_test.py | 33 +++++ tests/unit/structural_typing_test.py | 134 ------------------ 5 files changed, 121 insertions(+), 134 deletions(-) delete mode 100644 tests/unit/structural_typing_test.py diff --git a/tests/unit/connection_aware_interface_test.py b/tests/unit/connection_aware_interface_test.py index 560260c..4a36303 100644 --- a/tests/unit/connection_aware_interface_test.py +++ b/tests/unit/connection_aware_interface_test.py @@ -7,6 +7,24 @@ from python_repositories.interfaces.connection_aware_interface import ( ) +class FakeConnection: + """Plain class that satisfies ConnectionAwareInterface without inheritance.""" + + def connect(self) -> None: + pass + + def disconnect(self) -> None: + pass + + def is_connected(self) -> bool: + return True + + +def accepts_connection_aware(connection: ConnectionAwareInterface) -> None: + """Type-checking hook for ConnectionAwareInterface structural subtyping.""" + connection.is_connected() + + def test_instantiation_fails_when_connect_not_implemented() -> None: """Test that instantiation fails if connect is not implemented.""" @@ -53,3 +71,10 @@ def test_instantiation_fails_when_is_connected_not_implemented() -> None: with pytest.raises(TypeError): _ = Incomplete() # type: ignore + + +def test_structural_subtyping() -> None: + """Test that a plain class satisfies ConnectionAwareInterface structurally.""" + connection: ConnectionAwareInterface = FakeConnection() + accepts_connection_aware(connection) + assert isinstance(connection, ConnectionAwareInterface) diff --git a/tests/unit/context_aware_interface_test.py b/tests/unit/context_aware_interface_test.py index 925f322..205dc41 100644 --- a/tests/unit/context_aware_interface_test.py +++ b/tests/unit/context_aware_interface_test.py @@ -7,6 +7,24 @@ import pytest from python_repositories.interfaces.context_aware_interface import ContextAwareInterface +class FakeContextManager: + """Plain class that satisfies ContextAwareInterface without inheritance.""" + + def __enter__(self) -> FakeContextManager: + return self + + def __exit__( + self, exc_type: type | None, exc_val: object | None, exc_tb: object | None + ) -> None: + pass + + +def accepts_context_aware(context: ContextAwareInterface) -> None: + """Type-checking hook for ContextAwareInterface structural subtyping.""" + with context: + pass + + def test_instantiation_fails_when_enter_not_implemented() -> None: """Test that instantiation fails if __enter__ is not implemented.""" @@ -33,3 +51,10 @@ def test_instantiation_fails_when_exit_not_implemented() -> None: with pytest.raises(TypeError): _ = Incomplete() # type: ignore + + +def test_structural_subtyping() -> None: + """Test that a plain class satisfies ContextAwareInterface structurally.""" + context: ContextAwareInterface = FakeContextManager() + accepts_context_aware(context) + assert isinstance(context, ContextAwareInterface) diff --git a/tests/unit/json_repository_interface_test.py b/tests/unit/json_repository_interface_test.py index 208b4f9..70ecf1c 100644 --- a/tests/unit/json_repository_interface_test.py +++ b/tests/unit/json_repository_interface_test.py @@ -1,5 +1,6 @@ """Unit tests for JsonRepositoryInterface.""" +from collections.abc import Iterator from typing import Any import pytest @@ -9,6 +10,36 @@ from python_repositories.interfaces.json_repository_interface import ( ) +class InMemoryJsonRepo: + """Plain class that satisfies JsonRepositoryInterface without inheritance.""" + + def get(self, key: str) -> dict[str, Any] | None: + return None + + def set(self, key: str, data: dict[str, Any]) -> None: + pass + + def delete(self, key: str) -> None: + pass + + def list_keys(self, pattern: str) -> list[str]: + return [] + + def scan_keys( + self, + pattern: str, + *, + count: int | None = None, + ) -> Iterator[str]: + del count + yield from self.list_keys(pattern) + + +def accepts_json_repo(repo: JsonRepositoryInterface) -> None: + """Type-checking hook for JsonRepositoryInterface structural subtyping.""" + repo.get("key") + + def test_instantiation_fails_when_get_not_implemented() -> None: """Test that instantiation fails if get is not implemented.""" @@ -104,3 +135,10 @@ def test_scan_keys_defaults_to_list_keys() -> None: repository = Complete() assert list(repository.scan_keys("user")) == ["user-1", "user-2"] + + +def test_structural_subtyping() -> None: + """Test that a plain class satisfies JsonRepositoryInterface structurally.""" + repo: JsonRepositoryInterface = InMemoryJsonRepo() + accepts_json_repo(repo) + assert isinstance(repo, JsonRepositoryInterface) diff --git a/tests/unit/object_repository_interface_test.py b/tests/unit/object_repository_interface_test.py index 4cebd2d..3bb1ce3 100644 --- a/tests/unit/object_repository_interface_test.py +++ b/tests/unit/object_repository_interface_test.py @@ -9,6 +9,32 @@ from python_repositories.interfaces.object_repository_interface import ( ) +class InMemoryObjectRepo: + """Plain class that satisfies ObjectRepositoryInterface without inheritance.""" + + def get(self, object_name: str) -> BytesIO | None: + return None + + def put( + self, + object_name: str, + data: BytesIO, + content_type: str = "application/octet-stream", + ) -> None: + pass + + def delete(self, object_name: str) -> None: + pass + + def list_objects(self, prefix: str = "") -> list[str]: + return [] + + +def accepts_object_repo(repo: ObjectRepositoryInterface) -> None: + """Type-checking hook for ObjectRepositoryInterface structural subtyping.""" + repo.get("object") + + def test_instantiation_fails_when_get_not_implemented() -> None: """Test that instantiation fails if get is not implemented.""" @@ -98,3 +124,10 @@ def test_instantiation_fails_when_list_objects_not_implemented() -> None: with pytest.raises(TypeError): _ = Incomplete() # type: ignore + + +def test_structural_subtyping() -> None: + """Test that a plain class satisfies ObjectRepositoryInterface structurally.""" + repo: ObjectRepositoryInterface = InMemoryObjectRepo() + accepts_object_repo(repo) + assert isinstance(repo, ObjectRepositoryInterface) diff --git a/tests/unit/structural_typing_test.py b/tests/unit/structural_typing_test.py deleted file mode 100644 index ddbc5a6..0000000 --- a/tests/unit/structural_typing_test.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Unit tests for structural typing of public interfaces.""" - -from __future__ import annotations - -from collections.abc import Iterator -from io import BytesIO -from typing import Any - -from python_repositories.interfaces import ( - ConnectionAwareInterface, - ContextAwareInterface, - JsonRepositoryInterface, - ObjectRepositoryInterface, -) - - -class InMemoryJsonRepo: - """Plain class that satisfies JsonRepositoryInterface without inheritance.""" - - def get(self, key: str) -> dict[str, Any] | None: - return None - - def set(self, key: str, data: dict[str, Any]) -> None: - pass - - def delete(self, key: str) -> None: - pass - - def list_keys(self, pattern: str) -> list[str]: - return [] - - def scan_keys( - self, - pattern: str, - *, - count: int | None = None, - ) -> Iterator[str]: - del count - yield from self.list_keys(pattern) - - -class InMemoryObjectRepo: - """Plain class that satisfies ObjectRepositoryInterface without inheritance.""" - - def get(self, object_name: str) -> BytesIO | None: - return None - - def put( - self, - object_name: str, - data: BytesIO, - content_type: str = "application/octet-stream", - ) -> None: - pass - - def delete(self, object_name: str) -> None: - pass - - def list_objects(self, prefix: str = "") -> list[str]: - return [] - - -class FakeConnection: - """Plain class that satisfies ConnectionAwareInterface without inheritance.""" - - def connect(self) -> None: - pass - - def disconnect(self) -> None: - pass - - def is_connected(self) -> bool: - return True - - -class FakeContextManager: - """Plain class that satisfies ContextAwareInterface without inheritance.""" - - def __enter__(self) -> FakeContextManager: - return self - - def __exit__( - self, exc_type: type | None, exc_val: object | None, exc_tb: object | None - ) -> None: - pass - - -def accepts_json_repo(repo: JsonRepositoryInterface) -> None: - """Type-checking hook for JsonRepositoryInterface structural subtyping.""" - repo.get("key") - - -def accepts_object_repo(repo: ObjectRepositoryInterface) -> None: - """Type-checking hook for ObjectRepositoryInterface structural subtyping.""" - repo.get("object") - - -def accepts_connection_aware(connection: ConnectionAwareInterface) -> None: - """Type-checking hook for ConnectionAwareInterface structural subtyping.""" - connection.is_connected() - - -def accepts_context_aware(context: ContextAwareInterface) -> None: - """Type-checking hook for ContextAwareInterface structural subtyping.""" - with context: - pass - - -def test_json_repository_structural_subtyping() -> None: - """Test that a plain class satisfies JsonRepositoryInterface structurally.""" - repo: JsonRepositoryInterface = InMemoryJsonRepo() - accepts_json_repo(repo) - assert isinstance(repo, JsonRepositoryInterface) - - -def test_object_repository_structural_subtyping() -> None: - """Test that a plain class satisfies ObjectRepositoryInterface structurally.""" - repo: ObjectRepositoryInterface = InMemoryObjectRepo() - accepts_object_repo(repo) - assert isinstance(repo, ObjectRepositoryInterface) - - -def test_connection_aware_structural_subtyping() -> None: - """Test that a plain class satisfies ConnectionAwareInterface structurally.""" - connection: ConnectionAwareInterface = FakeConnection() - accepts_connection_aware(connection) - assert isinstance(connection, ConnectionAwareInterface) - - -def test_context_aware_structural_subtyping() -> None: - """Test that a plain class satisfies ContextAwareInterface structurally.""" - context: ContextAwareInterface = FakeContextManager() - accepts_context_aware(context) - assert isinstance(context, ContextAwareInterface) -- 2.54.0 From a0e5c9dcca06bf3bade22597e6406118efac54d1 Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Sat, 11 Jul 2026 10:50:51 +0200 Subject: [PATCH 3/3] Document test organization and add Cursor workflow rules. Record the one-to-one unit test file convention in README and add rules for test placement and copy-ready PR text after first push. Co-authored-by: Cursor --- .cursor/rules/pr-after-first-push.mdc | 58 +++++++++++++++++++++++++++ .cursor/rules/test-organization.mdc | 31 ++++++++++++++ README.md | 9 +++++ 3 files changed, 98 insertions(+) create mode 100644 .cursor/rules/pr-after-first-push.mdc create mode 100644 .cursor/rules/test-organization.mdc diff --git a/.cursor/rules/pr-after-first-push.mdc b/.cursor/rules/pr-after-first-push.mdc new file mode 100644 index 0000000..ee32313 --- /dev/null +++ b/.cursor/rules/pr-after-first-push.mdc @@ -0,0 +1,58 @@ +--- +description: After first branch push, provide copy-ready PR title and body +alwaysApply: true +--- + +# PR Title and Comment After First Push + +The user creates pull requests manually. After the **first push** of a feature branch to remote in a session, always end your response with a copy-ready PR title and PR comment. + +## When to provide it + +- After the first successful `git push` (or `git push -u origin `) for a branch in the current task +- Do **not** create the PR with `gh pr create` unless explicitly asked +- On later pushes to the same branch, only repeat if the user asks or the change set meaningfully updates what the PR should say + +## PR title + +- Use a fitting version-bump prefix when changes touch `python_repositories/`: + - `[patch]` or `[fix]` — bug fix + - `[minor]` or `[feat]` — new feature + - `[major]` or `[breaking]` — breaking change +- Docs-, CI-, or test-only changes: no prefix required +- Keep the title concise and descriptive + +## PR comment format + +Provide two separate fenced code blocks the user can copy directly: + +1. **PR title** — single line in a code block +2. **PR comment** — body with `## Summary` and `## Test plan` sections, checklist items reflecting what was actually run + +Example structure: + +```` +PR title: + +``` +[minor] Short description of the change +``` + +PR comment: + +``` +## Summary + +- Bullet points of what changed and why + +## Test plan + +- [x] Commands or checks that were run +``` +```` + +## Notes + +- Match the repo PR template ([`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)) +- Be accurate: only mark test-plan items done if they were run +- Keep prose clear; the user copies this verbatim into Gitea diff --git a/.cursor/rules/test-organization.mdc b/.cursor/rules/test-organization.mdc new file mode 100644 index 0000000..9e2474a --- /dev/null +++ b/.cursor/rules/test-organization.mdc @@ -0,0 +1,31 @@ +--- +description: Unit test file naming and placement conventions +globs: tests/**/*.py +alwaysApply: false +--- + +# Test Organization + +Unit tests use a one-to-one mapping between source modules and test files. + +## Naming + +- `python_repositories//.py` → `tests/unit/_test.py` +- Test files must end with `_test.py` (enforced by pre-commit `name-tests-test`) + +## Placement + +- Add tests to the existing `*_test.py` for the module under test +- Do not create cross-cutting test files (e.g. `structural_typing_test.py`); colocate with the relevant interface/adapter/config test file +- Shared fixtures → `tests/conftest.py` +- Module-specific helper classes → the matching test file + +## Examples + +``` +json_repository_interface.py → tests/unit/json_repository_interface_test.py +redis_adapter.py → tests/unit/redis_adapter_test.py +redis_config.py → tests/unit/redis_config_test.py +``` + +Integration tests live under `tests/integration//` with the same `_test.py` suffix. diff --git a/README.md b/README.md index 1939d29..d793889 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,15 @@ uv run pytest -v # full suite (requires Doc Integration tests are marked with `@pytest.mark.integration` and require Docker (testcontainers). Backend-specific markers (`needs_redis`, `needs_minio`) let you run only the containers a test module needs. Run unit tests alone for quick local feedback. +### Test organization + +Unit tests live in `tests/unit/` and follow a one-to-one naming convention: `_test.py` tests `python_repositories/.py`. Examples: + +- `json_repository_interface.py` → `tests/unit/json_repository_interface_test.py` +- `redis_adapter.py` → `tests/unit/redis_adapter_test.py` + +Add new tests to the existing file for that module rather than creating cross-cutting test files. Shared fixtures belong in `tests/conftest.py`; module-specific helpers may live in the matching test file. + ### CI base image Gitea Actions jobs use a pre-built image (`python-repositories-ci`) with Python 3.12, -- 2.54.0