Compare commits
7
Commits
v2.0.5
...
1cbcc1c19b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cbcc1c19b | ||
|
|
a0e5c9dcca | ||
|
|
44b15cb21a | ||
|
|
f6ee9a3724 | ||
|
|
0829354cc4 | ||
|
|
a0d509a2a3 | ||
|
|
9b7b15a67e |
@@ -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 <branch>`) 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
|
||||||
@@ -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/<path>/<module>.py` → `tests/unit/<module>_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/<backend>/` with the same `_test.py` suffix.
|
||||||
@@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [2.0.5] - 2026-07-10
|
## [2.0.5] - 2026-07-10
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|
||||||
Allow empty payloads in Redis and MinIO adapters
|
Allow empty payloads in Redis and MinIO adapters
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- 6d02f32 Merge pull request '[patch] Allow empty payloads in Redis and MinIO adapters' (#55) from cursor/allow-empty-payloads into main
|
- 6d02f32 Merge pull request '[patch] Allow empty payloads in Redis and MinIO adapters' (#55) from cursor/allow-empty-payloads into main
|
||||||
- 57b396b Allow empty payloads in Redis and MinIO adapters.
|
- 57b396b Allow empty payloads in Redis and MinIO adapters.
|
||||||
- 093e538 Merge pull request '[chore] Scope integration fixtures per backend subdirectory' (#54) from cursor/scoped-integration-fixtures into main
|
- 093e538 Merge pull request '[chore] Scope integration fixtures per backend subdirectory' (#54) from cursor/scoped-integration-fixtures into main
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ Subclass an adapter in your own repository to add domain-specific methods while
|
|||||||
| **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) |
|
| **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) |
|
||||||
| **Your project** | Subclass an adapter and add domain methods |
|
| **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.
|
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
|
||||||
|
|
||||||
|
The current API is synchronous. Async repository interfaces and adapters may be added in a future release; existing sync usage would remain supported.
|
||||||
|
|
||||||
## Optional dependencies
|
## Optional dependencies
|
||||||
|
|
||||||
Repository **interfaces** import with the base package. **Adapters** require the matching extra; importing an adapter without its extra raises `ImportError` with install instructions.
|
Repository **interfaces** import with the base package. **Adapters** require the matching extra; importing an adapter without its extra raises `ImportError` with install instructions.
|
||||||
@@ -168,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.
|
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: `<module>_test.py` tests `python_repositories/<module path>.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
|
### CI base image
|
||||||
|
|
||||||
Gitea Actions jobs use a pre-built image (`python-repositories-ci`) with Python 3.12,
|
Gitea Actions jobs use a pre-built image (`python-repositories-ci`) with Python 3.12,
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["."]
|
|
||||||
addopts = "--import-mode=importlib"
|
addopts = "--import-mode=importlib"
|
||||||
markers = [
|
markers = [
|
||||||
"integration: tests requiring Docker containers (deselect with '-m \"not integration\"')",
|
"integration: tests requiring Docker containers (deselect with '-m \"not integration\"')",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ except ImportError as exc:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
|
class MinioAdapter(ConnectionAwareAdapter, ObjectRepositoryInterface):
|
||||||
"""Minio adapter exposing basic CRUD functionality."""
|
"""Minio adapter exposing basic CRUD functionality."""
|
||||||
|
|
||||||
endpoint_env_var_name: str = "MINIO_ENDPOINT"
|
endpoint_env_var_name: str = "MINIO_ENDPOINT"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ except ImportError as exc:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
|
class RedisAdapter(ConnectionAwareAdapter, JsonRepositoryInterface):
|
||||||
"""Redis adapter exposing basic CRUD functionality."""
|
"""Redis adapter exposing basic CRUD functionality."""
|
||||||
|
|
||||||
uri_env_var_name: str = "REDIS_URI"
|
uri_env_var_name: str = "REDIS_URI"
|
||||||
|
|||||||
@@ -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."""
|
"""Interface that defines connection-related methods."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Definition of ContextAwareInterface abstract base class."""
|
"""Definition of ContextAwareInterface protocol and abstract base class."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import abstractmethod
|
||||||
from typing import Self
|
from typing import Protocol, Self, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
class ContextAwareInterface(ABC):
|
@runtime_checkable
|
||||||
|
class ContextAwareInterface(Protocol):
|
||||||
"""Interface that defines context-related methods."""
|
"""Interface that defines context-related methods."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -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 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."""
|
"""Interface that defines JSON document CRUD methods."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -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 io import BytesIO
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
class ObjectRepositoryInterface(ABC):
|
@runtime_checkable
|
||||||
|
class ObjectRepositoryInterface(Protocol):
|
||||||
"""Interface that defines binary object CRUD methods."""
|
"""Interface that defines binary object CRUD methods."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -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:
|
def test_instantiation_fails_when_connect_not_implemented() -> None:
|
||||||
"""Test that instantiation fails if connect is not implemented."""
|
"""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):
|
with pytest.raises(TypeError):
|
||||||
_ = Incomplete() # type: ignore
|
_ = 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)
|
||||||
|
|||||||
@@ -7,6 +7,24 @@ import pytest
|
|||||||
from python_repositories.interfaces.context_aware_interface import ContextAwareInterface
|
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:
|
def test_instantiation_fails_when_enter_not_implemented() -> None:
|
||||||
"""Test that instantiation fails if __enter__ is not implemented."""
|
"""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):
|
with pytest.raises(TypeError):
|
||||||
_ = Incomplete() # type: ignore
|
_ = 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)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Unit tests for JsonRepositoryInterface."""
|
"""Unit tests for JsonRepositoryInterface."""
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
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:
|
def test_instantiation_fails_when_get_not_implemented() -> None:
|
||||||
"""Test that instantiation fails if get is not implemented."""
|
"""Test that instantiation fails if get is not implemented."""
|
||||||
|
|
||||||
@@ -104,3 +135,10 @@ def test_scan_keys_defaults_to_list_keys() -> None:
|
|||||||
repository = Complete()
|
repository = Complete()
|
||||||
|
|
||||||
assert list(repository.scan_keys("user")) == ["user-1", "user-2"]
|
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)
|
||||||
|
|||||||
@@ -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:
|
def test_instantiation_fails_when_get_not_implemented() -> None:
|
||||||
"""Test that instantiation fails if get is not implemented."""
|
"""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):
|
with pytest.raises(TypeError):
|
||||||
_ = Incomplete() # type: ignore
|
_ = 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user