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 29a8a95..d793889 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 @@ -172,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, 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/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)