Compare commits

..
8 Commits
Author SHA1 Message Date
CI Bot f28d3c4844 chore: release v2.1.0 [skip ci] 2026-07-11 08:56:19 +00:00
brian 1cbcc1c19b Merge pull request '[minor] Add runtime-checkable Protocol typing to public interfaces' (#57) from cursor/protocol-abc-interfaces into main
Test Python Package / unit-tests (push) Successful in 10s
Release on merge to main / release (push) Successful in 15s
Code Quality Pipeline / code-quality (push) Successful in 23s
Test Python Package / integration-tests (push) Successful in 20s
Test Python Package / coverage-report (push) Successful in 7s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/57
2026-07-11 10:56:01 +02:00
Brian Bjarke JensenandCursor a0e5c9dcca Document test organization and add Cursor workflow rules.
PR Title Check / check-title (pull_request) Successful in 8s
Test Python Package / unit-tests (pull_request) Successful in 13s
Code Quality Pipeline / code-quality (pull_request) Successful in 23s
Test Python Package / integration-tests (pull_request) Successful in 1m14s
Test Python Package / coverage-report (pull_request) Successful in 12s
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 <[email protected]>
2026-07-11 10:50:51 +02:00
Brian Bjarke JensenandCursor 44b15cb21a Move structural typing tests into interface unit test files.
Code Quality Pipeline / code-quality (pull_request) Successful in 24s
PR Title Check / check-title (pull_request) Successful in 35s
Test Python Package / coverage-report (pull_request) Successful in 11s
Test Python Package / unit-tests (pull_request) Successful in 12s
Test Python Package / integration-tests (pull_request) Successful in 26s
Align test layout with the one-test-file-per-interface convention by removing structural_typing_test.py.

Co-authored-by: Cursor <[email protected]>
2026-07-11 10:32:17 +02:00
Brian Bjarke JensenandCursor f6ee9a3724 Add runtime-checkable Protocol typing to all public interfaces.
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 9s
Code Quality Pipeline / code-quality (pull_request) Successful in 22s
Test Python Package / integration-tests (pull_request) Successful in 24s
Test Python Package / coverage-report (pull_request) Successful in 8s
Enables structural subtyping for consumers while preserving nominal adapter inheritance, instantiation guards, and scan_keys defaults.

Co-authored-by: Cursor <[email protected]>
2026-07-11 09:45:42 +02:00
brian 0829354cc4 Merge pull request 'Add async future note and remove redundant pytest pythonpath' (#56) from cursor/readme-async-and-test-path-cleanup into main
Release on merge to main / release (push) Successful in 7s
Test Python Package / unit-tests (push) Successful in 12s
Code Quality Pipeline / code-quality (push) Successful in 23s
Test Python Package / integration-tests (push) Successful in 24s
Build CI Image / build-and-push (push) Successful in 49s
Test Python Package / coverage-report (push) Successful in 9s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/56
2026-07-11 09:28:01 +02:00
Brian Bjarke JensenandCursor a0d509a2a3 Format CHANGELOG.md with Prettier.
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 12s
Code Quality Pipeline / code-quality (pull_request) Successful in 23s
Test Python Package / integration-tests (pull_request) Successful in 1m14s
Test Python Package / coverage-report (pull_request) Successful in 13s
Fixes pre-commit prettier check in CI.

Co-authored-by: Cursor <[email protected]>
2026-07-11 09:25:16 +02:00
Brian Bjarke JensenandCursor 9b7b15a67e Document possible async support and drop redundant pytest pythonpath.
Test Python Package / unit-tests (pull_request) Successful in 12s
Code Quality Pipeline / code-quality (pull_request) Failing after 23s
PR Title Check / check-title (pull_request) Successful in 32s
Test Python Package / integration-tests (pull_request) Successful in 25s
Test Python Package / coverage-report (pull_request) Successful in 11s
The package is already installed via uv sync, so pytest no longer needs a manual repo-root path.

Co-authored-by: Cursor <[email protected]>
2026-07-11 09:22:37 +02:00
16 changed files with 265 additions and 19 deletions
+58
View File
@@ -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
+31
View File
@@ -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.
+16
View File
@@ -7,12 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.1.0] - 2026-07-11
### Summary
Add runtime-checkable Protocol typing to public interfaces
### Changed
- 1cbcc1c Merge pull request '[minor] Add runtime-checkable Protocol typing to public interfaces' (#57) from cursor/protocol-abc-interfaces into main
- a0e5c9d Document test organization and add Cursor workflow rules.
- 44b15cb Move structural typing tests into interface unit test files.
- f6ee9a3 Add runtime-checkable Protocol typing to all public interfaces.
- 0829354 Merge pull request 'Add async future note and remove redundant pytest pythonpath' (#56) from cursor/readme-async-and-test-path-cleanup into main
- a0d509a Format CHANGELOG.md with Prettier.
- 9b7b15a Document possible async support and drop redundant pytest pythonpath.
## [2.0.5] - 2026-07-10
### Summary
Allow empty payloads in Redis and MinIO adapters
### Changed
- 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.
- 093e538 Merge pull request '[chore] Scope integration fixtures per backend subdirectory' (#54) from cursor/scoped-integration-fixtures into main
+15
View File
@@ -12,8 +12,14 @@ 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
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
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.
### 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
Gitea Actions jobs use a pre-built image (`python-repositories-ci`) with Python 3.12,
+1 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "python-repositories"
version = "2.0.5"
version = "2.1.0"
description = "Various python repository interfaces exposed as a python package."
authors = [
{ name = "Brian Bjarke Jensen", email = "[email protected]" }
@@ -39,7 +39,6 @@ build-backend = "hatchling.build"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "--import-mode=importlib"
markers = [
"integration: tests requiring Docker containers (deselect with '-m \"not integration\"')",
@@ -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"
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -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)
@@ -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)
Generated
+1 -1
View File
@@ -1056,7 +1056,7 @@ wheels = [
[[package]]
name = "python-repositories"
version = "2.0.5"
version = "2.1.0"
source = { editable = "." }
dependencies = [
{ name = "python-dotenv" },