Compare commits

..
13 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: LilleVemmelund/python-repositories#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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
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: LilleVemmelund/python-repositories#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 <cursoragent@cursor.com>
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 <cursoragent@cursor.com>
2026-07-11 09:22:37 +02:00
CI Bot cd5b84b88e chore: release v2.0.5 [skip ci] 2026-07-10 19:44:15 +00:00
brian 6d02f32ca5 Merge pull request '[patch] Allow empty payloads in Redis and MinIO adapters' (#55) from cursor/allow-empty-payloads into main
Release on merge to main / release (push) Successful in 15s
Test Python Package / integration-tests (push) Successful in 23s
Test Python Package / coverage-report (push) Successful in 10s
Test Python Package / unit-tests (push) Successful in 13s
Code Quality Pipeline / code-quality (push) Successful in 19s
Reviewed-on: LilleVemmelund/python-repositories#55
2026-07-10 21:43:59 +02:00
Brian Bjarke JensenandCursor 57b396bcd3 Allow empty payloads in Redis and MinIO adapters.
PR Title Check / check-title (pull_request) Successful in 8s
Test Python Package / unit-tests (pull_request) Successful in 12s
Code Quality Pipeline / code-quality (pull_request) Successful in 20s
Test Python Package / integration-tests (pull_request) Successful in 24s
Test Python Package / coverage-report (pull_request) Successful in 11s
Relax write validation so {} and zero-byte BytesIO round-trip correctly,
document None-vs-empty semantics on interfaces and adapters, and add
integration tests for placeholders and existence distinction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 21:39:51 +02:00
brian 093e538d5b Merge pull request '[chore] Scope integration fixtures per backend subdirectory' (#54) from cursor/scoped-integration-fixtures into main
Release on merge to main / release (push) Successful in 7s
Code Quality Pipeline / code-quality (push) Successful in 19s
Test Python Package / unit-tests (push) Successful in 14s
Build CI Image / build-and-push (push) Successful in 38s
Test Python Package / integration-tests (push) Successful in 50s
Test Python Package / coverage-report (push) Successful in 12s
Reviewed-on: LilleVemmelund/python-repositories#54
2026-07-10 21:16:27 +02:00
Brian Bjarke JensenandCursor b28ac6e803 Scope integration fixtures per backend subdirectory.
PR Title Check / check-title (pull_request) Successful in 7s
Test Python Package / unit-tests (pull_request) Successful in 13s
Test Python Package / integration-tests (pull_request) Successful in 23s
Test Python Package / coverage-report (pull_request) Successful in 10s
Code Quality Pipeline / code-quality (pull_request) Successful in 49s
Split Redis and MinIO container setup into backend-specific conftest modules so only the containers needed for collected tests are imported and started.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 21:11:53 +02:00
25 changed files with 713 additions and 175 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.
+34
View File
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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
- b28ac6e Scope integration fixtures per backend subdirectory.
- 6c66fe4 Merge pull request 'Add consumer-facing CHANGELOG.md maintained by release CI' (#53) from cursor/consumer-changelog into main
- 089a128 Format CHANGELOG.md with Prettier
- 0a5bd39 Add consumer-facing CHANGELOG.md maintained by release CI.
- 683ee33 Merge pull request '[chore] add repository URL and Python 3.12 packaging metadata' (#52) from cursor/add-packaging-metadata into main
- 9df5032 Add repository URL and Python 3.12 packaging metadata.
- 4f6f33f Merge pull request 'Drop types-redis in favor of redis inline types' (#51) from cursor/drop-types-redis into main
- 10d43ce Drop types-redis in favor of redis inline types.
## [2.0.4] - 2026-07-10 ## [2.0.4] - 2026-07-10
### Summary ### Summary
+18 -1
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`) | | **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.
@@ -161,10 +167,21 @@ uv sync --all-extras
uv run pre-commit install # once per clone — runs hooks on git commit uv run pre-commit install # once per clone — runs hooks on git commit
uv run pytest tests/unit/ -v # fast, no Docker uv run pytest tests/unit/ -v # fast, no Docker
uv run pytest -m "not integration" -v # all non-Docker tests uv run pytest -m "not integration" -v # all non-Docker tests
uv run pytest tests/integration/redis/ -v # Redis container only
uv run pytest tests/integration/minio/ -v # MinIO container only
uv run pytest -v # full suite (requires Docker) uv run pytest -v # full suite (requires Docker)
``` ```
Integration tests are marked with `@pytest.mark.integration` and require Docker (testcontainers). 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
+3 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "python-repositories" name = "python-repositories"
version = "2.0.4" version = "2.1.0"
description = "Various python repository interfaces exposed as a python package." description = "Various python repository interfaces exposed as a python package."
authors = [ authors = [
{ name = "Brian Bjarke Jensen", email = "schnitzelen@gmail.com" } { name = "Brian Bjarke Jensen", email = "schnitzelen@gmail.com" }
@@ -39,10 +39,11 @@ 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\"')",
"needs_redis: integration test requiring a Redis container",
"needs_minio: integration test requiring a MinIO container",
] ]
[tool.coverage.run] [tool.coverage.run]
+15 -5
View File
@@ -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"
@@ -114,12 +114,17 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
data: BytesIO, data: BytesIO,
content_type: str = "application/octet-stream", content_type: str = "application/octet-stream",
) -> None: ) -> None:
"""Put an object into the Minio bucket.""" """Put an object into the Minio bucket.
Accepts zero-byte ``BytesIO``. A zero-byte object is returned by
``get()`` as an empty buffer, not ``None``. Use ``delete()`` to remove
an object entirely.
"""
# Check input # Check input
if not isinstance(object_name, str) or len(object_name) == 0: if not isinstance(object_name, str) or len(object_name) == 0:
raise ValueError("object_name must be a non-empty string") raise ValueError("object_name must be a non-empty string")
if not isinstance(data, BytesIO) or data.getbuffer().nbytes == 0: if not isinstance(data, BytesIO):
raise ValueError("data must be a non-empty BytesIO object") raise ValueError("data must be a BytesIO object")
if not isinstance(content_type, str) or len(content_type) == 0: if not isinstance(content_type, str) or len(content_type) == 0:
raise ValueError("content_type must be a non-empty string") raise ValueError("content_type must be a non-empty string")
# Check connection # Check connection
@@ -143,7 +148,12 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
) )
def get(self, object_name: str) -> BytesIO | None: def get(self, object_name: str) -> BytesIO | None:
"""Get an object from the Minio bucket.""" """Get an object from the Minio bucket.
Returns ``None`` when the object does not exist. Returns an empty
``BytesIO`` for a zero-byte object. Use ``value is not None`` to test
existence.
"""
# Check input # Check input
if not isinstance(object_name, str) or len(object_name) == 0: if not isinstance(object_name, str) or len(object_name) == 0:
raise ValueError("object_name must be a non-empty string") raise ValueError("object_name must be a non-empty string")
+15 -5
View File
@@ -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"
@@ -91,12 +91,17 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
return False return False
def set(self, key: str, data: dict[str, Any]) -> None: def set(self, key: str, data: dict[str, Any]) -> None:
"""Set a JSON object in Redis.""" """Set a JSON object in Redis.
Accepts any dict, including ``{}``. An empty dict creates a key that
``get()`` returns as ``{}``, not ``None``. Use ``delete()`` to remove a
key entirely.
"""
# Check input # Check input
if not isinstance(key, str) or len(key) == 0: if not isinstance(key, str) or len(key) == 0:
raise ValueError("Key must be a non-empty string") raise ValueError("Key must be a non-empty string")
if not isinstance(data, dict) or len(data) == 0: if not isinstance(data, dict):
raise ValueError("Data must be a non-empty dictionary") raise ValueError("Data must be a dictionary")
# Check connection # Check connection
self._require_connected() self._require_connected()
assert self._client is not None assert self._client is not None
@@ -105,7 +110,12 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
self.logger.debug("Set key", key=key, data_keys=list(data.keys())) self.logger.debug("Set key", key=key, data_keys=list(data.keys()))
def get(self, key: str) -> dict[str, Any] | None: def get(self, key: str) -> dict[str, Any] | None:
"""Get a JSON object from Redis.""" """Get a JSON object from Redis.
Returns ``None`` if the key is absent. Returns ``{}`` if the key exists
with an empty JSON object. Use ``value is not None`` to test existence;
avoid truthiness checks (``{}`` is falsy).
"""
# Check input # Check input
if not isinstance(key, str) or len(key) == 0: if not isinstance(key, str) or len(key) == 0:
raise ValueError("Key must be a non-empty string") raise ValueError("Key must be a non-empty string")
@@ -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,26 +1,37 @@
"""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
def get(self, key: str) -> dict[str, Any] | None: def get(self, key: str) -> dict[str, Any] | None:
"""Get a JSON object by key.""" """Get a JSON object by key.
Returns ``None`` when the key is absent. Returns ``{}`` when the key
exists with an empty JSON object. Use ``value is not None`` to test
existence; avoid truthiness checks (``{}`` is falsy).
"""
... ...
@abstractmethod @abstractmethod
def set(self, key: str, data: dict[str, Any]) -> None: def set(self, key: str, data: dict[str, Any]) -> None:
"""Set a JSON object by key.""" """Set a JSON object by key.
Accepts any dict, including ``{}``. An empty dict creates a key that
``get()`` returns as ``{}``, not ``None``. Use ``delete()`` to remove a
key entirely.
"""
... ...
@abstractmethod @abstractmethod
def delete(self, key: str) -> None: def delete(self, key: str) -> None:
"""Delete a JSON object by key.""" """Delete a JSON object by key, removing it entirely."""
... ...
@abstractmethod @abstractmethod
@@ -1,18 +1,22 @@
"""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
def get(self, object_name: str) -> BytesIO | None: def get(self, object_name: str) -> BytesIO | None:
"""Get an object by name. """Get an object by name.
Returns None when the object does not exist. Raises ConnectionError when Returns ``None`` when the object does not exist. Returns an empty
not connected. Other backend errors propagate to the caller. ``BytesIO`` for a zero-byte object. Use ``value is not None`` to test
existence. Raises ConnectionError when not connected. Other backend
errors propagate to the caller.
""" """
... ...
@@ -23,12 +27,17 @@ class ObjectRepositoryInterface(ABC):
data: BytesIO, data: BytesIO,
content_type: str = "application/octet-stream", content_type: str = "application/octet-stream",
) -> None: ) -> None:
"""Put an object by name.""" """Put an object by name.
Accepts zero-byte ``BytesIO``. A zero-byte object is returned by
``get()`` as an empty buffer, not ``None``. Use ``delete()`` to remove
an object entirely.
"""
... ...
@abstractmethod @abstractmethod
def delete(self, object_name: str) -> None: def delete(self, object_name: str) -> None:
"""Delete an object by name.""" """Delete an object by name, removing it entirely."""
... ...
@abstractmethod @abstractmethod
-135
View File
@@ -1,51 +1,9 @@
"""Integration tests configuration.""" """Integration tests configuration."""
from collections.abc import Generator
import logging import logging
from typing import Any, cast
from minio import Minio
import pytest import pytest
import redis
import structlog import structlog
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget
from testcontainers.minio import MinioContainer
from python_repositories.config import MinioConfig, RedisConfig
REDIS_PORT = 6379
MINIO_ACCESS_KEY = "minioadmin"
MINIO_SECRET_KEY = "minioadmin"
MINIO_BUCKET = "test-bucket"
class _RedisPingWaitStrategy(WaitStrategy):
def __init__(self) -> None:
super().__init__()
self.with_transient_exceptions(redis.exceptions.ConnectionError)
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
redis_container = cast("RedisTestContainer", container)
if not self._poll(lambda: redis_container.get_client().ping()):
raise redis.exceptions.ConnectionError("Could not connect to Redis")
class RedisTestContainer(DockerContainer):
"""Redis container using wait strategies instead of the deprecated decorator."""
def __init__(self, image: str, port: int = REDIS_PORT) -> None:
super().__init__(image, _wait_strategy=_RedisPingWaitStrategy())
self.port = port
self.with_exposed_ports(self.port)
def get_client(self, **kwargs: Any) -> redis.Redis:
return redis.Redis(
host=self.get_container_host_ip(),
port=self.get_exposed_port(self.port),
**kwargs,
)
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
@@ -64,96 +22,3 @@ def configure_logging() -> None:
cache_logger_on_first_use=True, cache_logger_on_first_use=True,
) )
logging.basicConfig(level=logging.ERROR) logging.basicConfig(level=logging.ERROR)
@pytest.fixture(scope="session")
def redis_container() -> Generator[str, None, None]:
"""Set up a Redis container for testing and yield the Redis URI."""
container = RedisTestContainer(
image="redis/redis-stack:7.2.0-v0",
)
container.start()
redis_host = container.get_container_host_ip()
redis_port = container.get_exposed_port(REDIS_PORT)
redis_uri = f"redis://{redis_host}:{redis_port}"
yield redis_uri
container.stop()
@pytest.fixture(scope="session")
def minio_container() -> Generator[dict[str, str], None, None]:
"""Set up a Minio container for testing and yield connection settings."""
container = MinioContainer(
image="minio/minio:latest",
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
)
container.start()
minio_host = container.get_container_host_ip()
minio_port = container.get_exposed_port(9000)
minio_endpoint = f"{minio_host}:{minio_port}"
env_vars = {
"MINIO_ENDPOINT": minio_endpoint,
"MINIO_ACCESS_KEY": MINIO_ACCESS_KEY,
"MINIO_SECRET_KEY": MINIO_SECRET_KEY,
"MINIO_BUCKET": MINIO_BUCKET,
"MINIO_SECURE": "false",
}
yield env_vars
container.stop()
@pytest.fixture(scope="session")
def redis_config(redis_container: str) -> RedisConfig:
"""Provide RedisConfig built from the test container."""
return RedisConfig(uri=redis_container)
@pytest.fixture(scope="session")
def minio_config(minio_container: dict[str, str]) -> MinioConfig:
"""Provide MinioConfig built from the test container."""
return MinioConfig(
endpoint=minio_container["MINIO_ENDPOINT"],
access_key=minio_container["MINIO_ACCESS_KEY"],
secret_key=minio_container["MINIO_SECRET_KEY"],
bucket=minio_container["MINIO_BUCKET"],
secure=False,
)
@pytest.fixture(scope="session")
def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]:
"""Provide a raw Redis client connected to the test Redis container."""
client = redis.Redis.from_url(
url=redis_container,
socket_connect_timeout=10,
)
yield client
client.flushall()
client.close()
@pytest.fixture(scope="session")
def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio, None, None]:
"""Provide a raw Minio client connected to the test Minio container."""
client = Minio(
endpoint=minio_container["MINIO_ENDPOINT"],
access_key=minio_container["MINIO_ACCESS_KEY"],
secret_key=minio_container["MINIO_SECRET_KEY"],
secure=False,
)
bucket_name = minio_container["MINIO_BUCKET"]
if not client.bucket_exists(bucket_name):
client.make_bucket(bucket_name)
yield client
objects = client.list_objects(bucket_name, recursive=True)
for obj in objects:
client.remove_object(bucket_name, obj.object_name)
+55
View File
@@ -0,0 +1,55 @@
"""Examples integration test fixtures."""
from collections.abc import Generator
from minio import Minio
import pytest
import redis
from python_repositories.config import MinioConfig, RedisConfig
from tests.integration.minio._containers import (
minio_config_from_env,
minio_env,
raw_minio_client_from_env,
)
from tests.integration.redis._containers import (
raw_redis_client_from_container,
redis_config_from_container,
redis_uri,
)
@pytest.fixture(scope="session")
def redis_container() -> Generator[str, None, None]:
"""Set up a Redis container for testing and yield the Redis URI."""
yield from redis_uri()
@pytest.fixture(scope="session")
def redis_config(redis_container: str) -> RedisConfig:
"""Provide RedisConfig built from the test container."""
return redis_config_from_container(redis_container)
@pytest.fixture(scope="session")
def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]:
"""Provide a raw Redis client connected to the test Redis container."""
yield from raw_redis_client_from_container(redis_container)
@pytest.fixture(scope="session")
def minio_container() -> Generator[dict[str, str], None, None]:
"""Set up a Minio container for testing and yield connection settings."""
yield from minio_env()
@pytest.fixture(scope="session")
def minio_config(minio_container: dict[str, str]) -> MinioConfig:
"""Provide MinioConfig built from the test container."""
return minio_config_from_env(minio_container)
@pytest.fixture(scope="session")
def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio, None, None]:
"""Provide a raw Minio client connected to the test Minio container."""
yield from raw_minio_client_from_env(minio_container)
@@ -13,7 +13,11 @@ from python_repositories.examples.artifact_object_repository import (
) )
from python_repositories.examples.user_json_repository import UserJsonRepository from python_repositories.examples.user_json_repository import UserJsonRepository
pytestmark = pytest.mark.integration pytestmark = [
pytest.mark.integration,
pytest.mark.needs_redis,
pytest.mark.needs_minio,
]
@pytest.fixture(scope="module", autouse=True) @pytest.fixture(scope="module", autouse=True)
+86
View File
@@ -0,0 +1,86 @@
"""MinIO container session helpers for integration tests."""
from collections.abc import Generator
from minio import Minio
from testcontainers.minio import MinioContainer
from python_repositories.config import MinioConfig
MINIO_ACCESS_KEY = "minioadmin"
MINIO_SECRET_KEY = "minioadmin"
MINIO_BUCKET = "test-bucket"
_minio_env: dict[str, str] | None = None
_minio_container: MinioContainer | None = None
_raw_minio_client: Minio | None = None
_minio_env_refs = 0
_raw_minio_client_refs = 0
def minio_env() -> Generator[dict[str, str], None, None]:
"""Yield session-scoped MinIO connection settings, starting the container once."""
global _minio_env, _minio_container, _minio_env_refs
if _minio_env is None:
_minio_container = MinioContainer(
image="minio/minio:latest",
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
)
_minio_container.start()
minio_host = _minio_container.get_container_host_ip()
minio_port = _minio_container.get_exposed_port(9000)
minio_endpoint = f"{minio_host}:{minio_port}"
_minio_env = {
"MINIO_ENDPOINT": minio_endpoint,
"MINIO_ACCESS_KEY": MINIO_ACCESS_KEY,
"MINIO_SECRET_KEY": MINIO_SECRET_KEY,
"MINIO_BUCKET": MINIO_BUCKET,
"MINIO_SECURE": "false",
}
_minio_env_refs += 1
yield _minio_env
_minio_env_refs -= 1
if _minio_env_refs == 0 and _minio_container is not None:
_minio_container.stop()
_minio_container = None
_minio_env = None
def minio_config_from_env(env: dict[str, str]) -> MinioConfig:
"""Build MinioConfig from container environment settings."""
return MinioConfig(
endpoint=env["MINIO_ENDPOINT"],
access_key=env["MINIO_ACCESS_KEY"],
secret_key=env["MINIO_SECRET_KEY"],
bucket=env["MINIO_BUCKET"],
secure=False,
)
def raw_minio_client_from_env(env: dict[str, str]) -> Generator[Minio, None, None]:
"""Yield a session-scoped raw MinIO client, reusing one client per session."""
global _raw_minio_client, _raw_minio_client_refs
if _raw_minio_client is None:
_raw_minio_client = Minio(
endpoint=env["MINIO_ENDPOINT"],
access_key=env["MINIO_ACCESS_KEY"],
secret_key=env["MINIO_SECRET_KEY"],
secure=False,
)
bucket_name = env["MINIO_BUCKET"]
if not _raw_minio_client.bucket_exists(bucket_name):
_raw_minio_client.make_bucket(bucket_name)
_raw_minio_client_refs += 1
yield _raw_minio_client
_raw_minio_client_refs -= 1
if _raw_minio_client_refs == 0 and _raw_minio_client is not None:
bucket_name = env["MINIO_BUCKET"]
objects = _raw_minio_client.list_objects(bucket_name, recursive=True)
for obj in objects:
_raw_minio_client.remove_object(bucket_name, obj.object_name)
_raw_minio_client = None
+31
View File
@@ -0,0 +1,31 @@
"""MinIO integration test fixtures."""
from collections.abc import Generator
from minio import Minio
import pytest
from python_repositories.config import MinioConfig
from tests.integration.minio._containers import (
minio_config_from_env,
minio_env,
raw_minio_client_from_env,
)
@pytest.fixture(scope="session")
def minio_container() -> Generator[dict[str, str], None, None]:
"""Set up a Minio container for testing and yield connection settings."""
yield from minio_env()
@pytest.fixture(scope="session")
def minio_config(minio_container: dict[str, str]) -> MinioConfig:
"""Provide MinioConfig built from the test container."""
return minio_config_from_env(minio_container)
@pytest.fixture(scope="session")
def raw_minio_client(minio_container: dict[str, str]) -> Generator[Minio, None, None]:
"""Provide a raw Minio client connected to the test Minio container."""
yield from raw_minio_client_from_env(minio_container)
@@ -15,7 +15,7 @@ from python_repositories.adapters.minio_adapter import MinioAdapter
from python_repositories.config import MinioConfig from python_repositories.config import MinioConfig
from tests.conftest import TEST_MINIO_CONFIG from tests.conftest import TEST_MINIO_CONFIG
pytestmark = pytest.mark.integration pytestmark = [pytest.mark.integration, pytest.mark.needs_minio]
def same_data( def same_data(
@@ -328,6 +328,44 @@ def test_should_raise_value_error_on_invalid_put_data(
minio_adapter.put(object_name, invalid) # type: ignore minio_adapter.put(object_name, invalid) # type: ignore
def test_should_put_and_get_empty_bytesio(
minio_adapter: MinioAdapter,
minio_config: MinioConfig,
) -> None:
"""Test that the MinioAdapter can put and get a zero-byte object."""
object_name = "empty_object"
empty_data = BytesIO()
assert minio_adapter.get(object_name) is None
minio_adapter.put(object_name, empty_data)
received_data = minio_adapter.get(object_name)
assert received_data is not None
assert same_data(empty_data, received_data)
minio_adapter._client.remove_object(minio_config.bucket, object_name) # type: ignore[union-attr]
def test_should_distinguish_missing_object_from_empty_object(
minio_adapter: MinioAdapter,
minio_config: MinioConfig,
) -> None:
"""Test that missing objects and zero-byte objects are distinguishable."""
object_name = "empty_object"
minio_adapter.put(object_name, BytesIO())
assert minio_adapter.get("other_object") is None
minio_adapter.delete(object_name)
assert minio_adapter.get(object_name) is None
def test_should_list_zero_byte_object(
minio_adapter: MinioAdapter,
minio_config: MinioConfig,
) -> None:
"""Test that a zero-byte object appears in object listings."""
object_name = "empty_object"
minio_adapter.put(object_name, BytesIO())
assert object_name in minio_adapter.list_objects()
minio_adapter._client.remove_object(minio_config.bucket, object_name) # type: ignore[union-attr]
def test_should_raise_value_error_on_invalid_put_content_type( def test_should_raise_value_error_on_invalid_put_content_type(
data: BytesIO, data: BytesIO,
minio_adapter: MinioAdapter, minio_adapter: MinioAdapter,
+90
View File
@@ -0,0 +1,90 @@
"""Redis container session helpers for integration tests."""
from collections.abc import Generator
from typing import Any, cast
import redis
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import WaitStrategy, WaitStrategyTarget
from python_repositories.config import RedisConfig
REDIS_PORT = 6379
class _RedisPingWaitStrategy(WaitStrategy):
def __init__(self) -> None:
super().__init__()
self.with_transient_exceptions(redis.exceptions.ConnectionError)
def wait_until_ready(self, container: WaitStrategyTarget) -> None:
redis_container = cast("RedisTestContainer", container)
if not self._poll(lambda: redis_container.get_client().ping()):
raise redis.exceptions.ConnectionError("Could not connect to Redis")
class RedisTestContainer(DockerContainer):
"""Redis container using wait strategies instead of the deprecated decorator."""
def __init__(self, image: str, port: int = REDIS_PORT) -> None:
super().__init__(image, _wait_strategy=_RedisPingWaitStrategy())
self.port = port
self.with_exposed_ports(self.port)
def get_client(self, **kwargs: Any) -> redis.Redis:
return redis.Redis(
host=self.get_container_host_ip(),
port=self.get_exposed_port(self.port),
**kwargs,
)
_redis_uri: str | None = None
_redis_container: RedisTestContainer | None = None
_raw_redis_client: redis.Redis | None = None
_redis_uri_refs = 0
_raw_redis_client_refs = 0
def redis_uri() -> Generator[str, None, None]:
"""Yield a session-scoped Redis URI, starting the container once."""
global _redis_uri, _redis_container, _redis_uri_refs
if _redis_uri is None:
_redis_container = RedisTestContainer(image="redis/redis-stack:7.2.0-v0")
_redis_container.start()
redis_host = _redis_container.get_container_host_ip()
redis_port = _redis_container.get_exposed_port(REDIS_PORT)
_redis_uri = f"redis://{redis_host}:{redis_port}"
_redis_uri_refs += 1
yield _redis_uri
_redis_uri_refs -= 1
if _redis_uri_refs == 0 and _redis_container is not None:
_redis_container.stop()
_redis_container = None
_redis_uri = None
def redis_config_from_container(uri: str) -> RedisConfig:
"""Build RedisConfig from a container URI."""
return RedisConfig(uri=uri)
def raw_redis_client_from_container(uri: str) -> Generator[redis.Redis, None, None]:
"""Yield a session-scoped raw Redis client, reusing one client per session."""
global _raw_redis_client, _raw_redis_client_refs
if _raw_redis_client is None:
_raw_redis_client = redis.Redis.from_url(
url=uri,
socket_connect_timeout=10,
)
_raw_redis_client_refs += 1
yield _raw_redis_client
_raw_redis_client_refs -= 1
if _raw_redis_client_refs == 0 and _raw_redis_client is not None:
_raw_redis_client.flushall()
_raw_redis_client.close()
_raw_redis_client = None
+31
View File
@@ -0,0 +1,31 @@
"""Redis integration test fixtures."""
from collections.abc import Generator
import pytest
import redis
from python_repositories.config import RedisConfig
from tests.integration.redis._containers import (
raw_redis_client_from_container,
redis_config_from_container,
redis_uri,
)
@pytest.fixture(scope="session")
def redis_container() -> Generator[str, None, None]:
"""Set up a Redis container for testing and yield the Redis URI."""
yield from redis_uri()
@pytest.fixture(scope="session")
def redis_config(redis_container: str) -> RedisConfig:
"""Provide RedisConfig built from the test container."""
return redis_config_from_container(redis_container)
@pytest.fixture(scope="session")
def raw_redis_client(redis_container: str) -> Generator[redis.Redis, None, None]:
"""Provide a raw Redis client connected to the test Redis container."""
yield from raw_redis_client_from_container(redis_container)
@@ -10,7 +10,7 @@ from redis.commands.json.path import Path as RedisPath
from python_repositories.adapters.redis_adapter import RedisAdapter from python_repositories.adapters.redis_adapter import RedisAdapter
from python_repositories.config import RedisConfig from python_repositories.config import RedisConfig
pytestmark = pytest.mark.integration pytestmark = [pytest.mark.integration, pytest.mark.needs_redis]
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
@@ -190,12 +190,45 @@ def test_should_raise_value_error_on_invalid_set_data(
) -> None: ) -> None:
"""Test that the RedisAdapter raises ValueError when setting with invalid data.""" """Test that the RedisAdapter raises ValueError when setting with invalid data."""
key = "test_key" key = "test_key"
invalid_data = ["", 123, None, [], {}] invalid_data = ["", 123, None, []]
for data in invalid_data: for data in invalid_data:
with pytest.raises(ValueError): with pytest.raises(ValueError):
redis_adapter.set(key, data) # type: ignore redis_adapter.set(key, data) # type: ignore
def test_should_set_and_get_empty_dict(
redis_adapter: RedisAdapter,
) -> None:
"""Test that the RedisAdapter can set and get an empty dict."""
key = "empty_key"
assert redis_adapter.get(key) is None
redis_adapter.set(key, {})
value = redis_adapter.get(key)
assert value is not None
assert value == {}
def test_should_distinguish_missing_key_from_empty_dict(
redis_adapter: RedisAdapter,
) -> None:
"""Test that missing keys and empty dicts are distinguishable."""
key = "empty_key"
redis_adapter.set(key, {})
assert redis_adapter.get("other_key") is None
redis_adapter.delete(key)
assert redis_adapter.get(key) is None
def test_should_list_empty_dict_key(
redis_adapter: RedisAdapter,
) -> None:
"""Test that a key with an empty dict appears in key listings."""
key = "empty_key"
redis_adapter.set(key, {})
assert key in redis_adapter.list_keys(key)
assert key in list(redis_adapter.scan_keys(key))
def test_should_raise_connection_error_on_set_when_not_connected( def test_should_raise_connection_error_on_set_when_not_connected(
redis_config: RedisConfig, redis_config: RedisConfig,
data: dict[str, str], data: dict[str, str],
@@ -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)
Generated
+2 -2
View File
@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.12" requires-python = ">=3.12"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.15'", "python_full_version >= '3.15'",
@@ -1056,7 +1056,7 @@ wheels = [
[[package]] [[package]]
name = "python-repositories" name = "python-repositories"
version = "2.0.4" version = "2.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "python-dotenv" }, { name = "python-dotenv" },