Compare commits

...
17 Commits
Author SHA1 Message Date
CI Bot b6538f9e91 chore: release v2.2.0 [skip ci] 2026-07-12 16:52:22 +00:00
brian b37b2e6e50 Merge pull request '[minor] Add Postgres table adapter with TableRepositoryInterface' (#58) from cursor/postgres-table-adapter into main
Release on merge to main / release (push) Failing after 9s
Code Quality Pipeline / code-quality (push) Successful in 20s
Test Python Package / unit-tests (push) Successful in 13s
Build CI Image / build-and-push (push) Successful in 38s
Test Python Package / integration-tests (push) Successful in 1m9s
Test Python Package / coverage-report (push) Successful in 13s
Reviewed-on: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/58
2026-07-12 18:51:59 +02:00
Brian Bjarke JensenandCursor 1babb52d09 Apply ruff formatting to postgres adapter and tests.
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 24s
Test Python Package / integration-tests (pull_request) Successful in 29s
Test Python Package / coverage-report (pull_request) Successful in 10s
Co-authored-by: Cursor <[email protected]>
2026-07-11 15:01:58 +02:00
Brian Bjarke JensenandCursor 63ee17d544 Apply prettier formatting to README and CHANGELOG.
PR Title Check / check-title (pull_request) Successful in 8s
Test Python Package / unit-tests (pull_request) Successful in 10s
Code Quality Pipeline / code-quality (pull_request) Failing after 18s
Test Python Package / integration-tests (pull_request) Successful in 56s
Test Python Package / coverage-report (pull_request) Successful in 13s
Co-authored-by: Cursor <[email protected]>
2026-07-11 15:01:08 +02:00
Brian Bjarke JensenandCursor 2e38bd2406 Fix ruff import ordering in postgres test files.
PR Title Check / check-title (pull_request) Successful in 8s
Test Python Package / unit-tests (pull_request) Successful in 10s
Code Quality Pipeline / code-quality (pull_request) Failing after 17s
Test Python Package / integration-tests (pull_request) Successful in 47s
Test Python Package / coverage-report (pull_request) Successful in 12s
Co-authored-by: Cursor <[email protected]>
2026-07-11 15:00:01 +02:00
Brian Bjarke JensenandCursor dc6f8a3e89 Add Postgres table adapter with TableRepositoryInterface.
PR Title Check / check-title (pull_request) Successful in 7s
Code Quality Pipeline / code-quality (pull_request) Failing after 19s
Test Python Package / unit-tests (pull_request) Successful in 47s
Test Python Package / integration-tests (pull_request) Successful in 44s
Test Python Package / coverage-report (pull_request) Successful in 11s
Introduce PostgresAdapter for dict-based row CRUD via psycopg3, including config, lazy exports, unit/integration tests with testcontainers, and an example UserTableRepository.

Co-authored-by: Cursor <[email protected]>
2026-07-11 14:57:34 +02:00
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
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: https://gitea.lille-vemmelund.dk/LilleVemmelund/python-repositories/pulls/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 <[email protected]>
2026-07-10 21:39:51 +02:00
39 changed files with 2005 additions and 55 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.
+5
View File
@@ -8,3 +8,8 @@ MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=my-bucket
MINIO_SECURE=false
MINIO_CREATE_BUCKET_IF_MISSING=true
# Postgres (requires postgres extra)
POSTGRES_URI=postgresql://localhost/mydb
POSTGRES_TABLE=users
POSTGRES_PRIMARY_KEY=id
+48
View File
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.2.0] - 2026-07-12
### Summary
Add Postgres table adapter with TableRepositoryInterface
### Changed
- b37b2e6 Merge pull request '[minor] Add Postgres table adapter with TableRepositoryInterface' (#58) from cursor/postgres-table-adapter into main
- 1babb52 Apply ruff formatting to postgres adapter and tests.
- 63ee17d Apply prettier formatting to README and CHANGELOG.
- 2e38bd2 Fix ruff import ordering in postgres test files.
- dc6f8a3 Add Postgres table adapter with TableRepositoryInterface.
## [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
### Summary
+66 -11
View File
@@ -6,14 +6,20 @@ Subclass an adapter in your own repository to add domain-specific methods while
## Architecture
| Layer | Responsibility |
| ---------------- | ----------------------------------------------------------------- |
| **Interfaces** | Abstract contracts for connection, context, and CRUD |
| **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`) |
| **Your project** | Subclass an adapter and add domain methods |
| Layer | Responsibility |
| ---------------- | ------------------------------------------------------------------------------------ |
| **Interfaces** | Abstract contracts for connection, context, and CRUD |
| **Adapters** | Technology-specific base classes (`RedisAdapter`, `MinioAdapter`, `PostgresAdapter`) |
| **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.
@@ -23,7 +29,8 @@ Install with the extras you need:
```bash
uv add python-repositories[redis]
uv add python-repositories[minio]
uv add python-repositories[redis,minio]
uv add python-repositories[postgres]
uv add python-repositories[redis,minio,postgres]
```
### Redis (`JsonRepositoryInterface`)
@@ -57,16 +64,33 @@ for key in repo.scan_keys("user:*"):
| `MINIO_SECURE` | Use HTTPS (`true`/`false`; default: `true`) |
| `MINIO_CREATE_BUCKET_IF_MISSING` | Auto-create `MINIO_BUCKET` on connect (`true`/`false`; default: `false`) |
Copy [`.env.example`](.env.example) to `.env` for local development. `RedisConfig.from_env()` and `MinioConfig.from_env()` load `.env` automatically when resolving configuration from the environment.
For production, it is recommended to leave `MINIO_CREATE_BUCKET_IF_MISSING` unset so that `connect()` fails fast if the expected bucket is missing. For local development, you will often want `MINIO_SECURE=false` and `MINIO_CREATE_BUCKET_IF_MISSING=true`.
### Postgres (`TableRepositoryInterface`)
Requires PostgreSQL 10 or later; tested against PostgreSQL 16 in CI. Tables are owned by your migrations — the adapter verifies the configured table exists on connect.
| Environment variable | Description |
| ---------------------- | --------------------------------------- |
| `POSTGRES_URI` | PostgreSQL connection URL |
| `POSTGRES_TABLE` | Table name for CRUD operations |
| `POSTGRES_PRIMARY_KEY` | Primary key column name (default: `id`) |
Copy [`.env.example`](.env.example) to `.env` for local development. `RedisConfig.from_env()`, `MinioConfig.from_env()`, and `PostgresConfig.from_env()` load `.env` automatically when resolving configuration from the environment.
## Configuration injection
Adapters accept optional `config` and `client` keyword arguments for explicit setup and testing:
```python
from python_repositories import RedisAdapter, RedisConfig, MinioAdapter, MinioConfig
from python_repositories import (
MinioAdapter,
MinioConfig,
PostgresAdapter,
PostgresConfig,
RedisAdapter,
RedisConfig,
)
redis = RedisAdapter(config=RedisConfig(uri="redis://localhost:6379"))
minio = MinioAdapter(
@@ -79,6 +103,13 @@ minio = MinioAdapter(
# create_bucket_if_missing=True, # convenient for local dev
)
)
postgres = PostgresAdapter(
config=PostgresConfig(
uri="postgresql://localhost/mydb",
table="users",
primary_key="user_id",
)
)
```
When both `config` and `client` are provided, `connect()` skips client creation (the caller owns the client lifecycle). `config` is required whenever `client` is injected.
@@ -91,7 +122,7 @@ from python_repositories import load_dotenv
load_dotenv() # optional — from_env() also loads .env by default
```
Calling `RedisAdapter()` or `MinioAdapter()` with no arguments still loads configuration from environment variables (and `.env` if present).
Calling `RedisAdapter()`, `MinioAdapter()`, or `PostgresAdapter()` with no arguments still loads configuration from environment variables (and `.env` if present).
## Quick start
@@ -120,6 +151,17 @@ with ArtifactObjectRepository() as repo:
data = repo.get_artifact("report-1")
```
### Relational rows with Postgres
```python
from python_repositories.examples.user_table_repository import UserTableRepository
with UserTableRepository() as repo:
repo.save_user("alice", {"name": "Alice", "email": "[email protected]"})
user = repo.get_user("alice")
repo.delete_user("alice")
```
### Subclassing in your own project
```python
@@ -148,8 +190,11 @@ from python_repositories import (
MinioAdapter,
MinioConfig,
ObjectRepositoryInterface,
PostgresAdapter,
PostgresConfig,
RedisAdapter,
RedisConfig,
TableRepositoryInterface,
load_dotenv,
)
```
@@ -163,10 +208,20 @@ uv run pytest tests/unit/ -v # fast, no Docker
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 tests/integration/postgres/ -v # Postgres container only
uv run pytest -v # full suite (requires Docker)
```
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`, `needs_postgres`) 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
+5 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "python-repositories"
version = "2.0.4"
version = "2.2.0"
description = "Various python repository interfaces exposed as a python package."
authors = [
{ name = "Brian Bjarke Jensen", email = "[email protected]" }
@@ -32,6 +32,9 @@ redis = [
minio = [
"minio>=7.2.16",
]
postgres = [
"psycopg[binary]>=3.2.0",
]
[build-system]
requires = ["hatchling"]
@@ -39,12 +42,12 @@ 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\"')",
"needs_redis: integration test requiring a Redis container",
"needs_minio: integration test requiring a MinIO container",
"needs_postgres: integration test requiring a Postgres container",
]
[tool.coverage.run]
+5 -1
View File
@@ -6,17 +6,19 @@ from typing import TYPE_CHECKING
# Interfaces are always available; they have no optional backend dependencies.
from . import adapters
from .config import MinioConfig, RedisConfig, load_dotenv
from .config import MinioConfig, PostgresConfig, RedisConfig, load_dotenv
from .interfaces import (
ConnectionAwareInterface,
ContextAwareInterface,
JsonRepositoryInterface,
ObjectRepositoryInterface,
TableRepositoryInterface,
)
# Adapters are imported only for static type checkers; runtime loading is delegated below.
if TYPE_CHECKING:
from .adapters.minio_adapter import MinioAdapter as MinioAdapter
from .adapters.postgres_adapter import PostgresAdapter as PostgresAdapter
from .adapters.redis_adapter import RedisAdapter as RedisAdapter
__all__ = [
@@ -25,7 +27,9 @@ __all__ = [
"JsonRepositoryInterface",
"MinioConfig",
"ObjectRepositoryInterface",
"PostgresConfig",
"RedisConfig",
"TableRepositoryInterface",
"load_dotenv",
*adapters.__all__,
]
+3
View File
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
# Adapters are imported only for static type checkers; runtime loading is deferred below.
if TYPE_CHECKING:
from .minio_adapter import MinioAdapter
from .postgres_adapter import PostgresAdapter
from .redis_adapter import RedisAdapter
# Map public adapter names to their defining module and class.
@@ -20,11 +21,13 @@ if TYPE_CHECKING:
_LAZY_EXPORTS = {
"RedisAdapter": (".redis_adapter", "RedisAdapter"),
"MinioAdapter": (".minio_adapter", "MinioAdapter"),
"PostgresAdapter": (".postgres_adapter", "PostgresAdapter"),
}
__all__ = [
"RedisAdapter",
"MinioAdapter",
"PostgresAdapter",
]
+15 -5
View File
@@ -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"
@@ -114,12 +114,17 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
data: BytesIO,
content_type: str = "application/octet-stream",
) -> 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
if not isinstance(object_name, str) or len(object_name) == 0:
raise ValueError("object_name must be a non-empty string")
if not isinstance(data, BytesIO) or data.getbuffer().nbytes == 0:
raise ValueError("data must be a non-empty BytesIO object")
if not isinstance(data, BytesIO):
raise ValueError("data must be a BytesIO object")
if not isinstance(content_type, str) or len(content_type) == 0:
raise ValueError("content_type must be a non-empty string")
# Check connection
@@ -143,7 +148,12 @@ class MinioAdapter(ObjectRepositoryInterface, ConnectionAwareAdapter):
)
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
if not isinstance(object_name, str) or len(object_name) == 0:
raise ValueError("object_name must be a non-empty string")
@@ -0,0 +1,230 @@
"""Definition of PostgresAdapter class."""
from __future__ import annotations
from typing import Any
from python_repositories.adapters.connection_aware_adapter import (
ConnectionAwareAdapter,
)
from python_repositories.config import PostgresConfig
from python_repositories.interfaces import TableRepositoryInterface
try:
import psycopg
from psycopg import sql
from psycopg.errors import UndefinedTable
from psycopg.rows import dict_row
except ImportError as exc:
raise ImportError(
"Postgres support requires the postgres extra. "
"Install with: pip install python-repositories[postgres]"
) from exc
class PostgresAdapter(ConnectionAwareAdapter, TableRepositoryInterface):
"""Postgres adapter exposing basic table CRUD functionality."""
uri_env_var_name: str = "POSTGRES_URI"
table_env_var_name: str = "POSTGRES_TABLE"
primary_key_env_var_name: str = "POSTGRES_PRIMARY_KEY"
connection_name: str = "Postgres"
def __init__(
self,
*,
config: PostgresConfig | None = None,
client: psycopg.Connection[Any] | None = None,
) -> None:
super().__init__()
if client is not None and config is None:
raise ValueError("config is required when client is provided")
if config is None:
config = PostgresConfig.from_env(
self.uri_env_var_name,
self.table_env_var_name,
self.primary_key_env_var_name,
)
self._config = config
self._client_injected = client is not None
self._client: psycopg.Connection[Any] | None = client
if self._client_injected:
self._invalidate_health_cache()
def _is_client_ready(self) -> bool:
return self._client is not None
def _table_identifier(self) -> sql.Identifier:
return sql.Identifier(self._config.table)
def _primary_key_identifier(self) -> sql.Identifier:
return sql.Identifier(self._config.primary_key)
def _validate_injected_client(self) -> None:
if self._client is None:
return
try:
self._run_connection_probe()
except (psycopg.Error, ConnectionError) as exc:
raise ConnectionError(
f"Could not connect to Postgres at {self._config.uri}"
) from exc
def _establish_connection(self) -> None:
uri = self._config.uri
try:
client = psycopg.connect(
uri,
row_factory=dict_row,
connect_timeout=10,
autocommit=True,
)
self._client = client
self._run_connection_probe()
except ConnectionError:
self._client = None
raise
except psycopg.Error as exc:
self._client = None
raise ConnectionError(f"Could not connect to Postgres at {uri}") from exc
def _run_connection_probe(self) -> None:
assert self._client is not None
with self._client.cursor() as cursor:
cursor.execute("SELECT 1")
try:
cursor.execute(
sql.SQL("SELECT 1 FROM {} LIMIT 0").format(self._table_identifier())
)
except UndefinedTable as exc:
raise ConnectionError(
f"Table '{self._config.table}' does not exist on Postgres at "
f"{self._config.uri}"
) from exc
def disconnect(self) -> None:
"""Disconnect from the Postgres server."""
if self._client is not None and not self._client_injected:
self._client.close()
self._client = None
self._invalidate_health_cache()
def _probe_connection(self) -> bool:
assert self._client is not None
try:
self._run_connection_probe()
except (psycopg.Error, ConnectionError):
return False
return True
def _validate_pk(self, pk: Any) -> None:
if pk is None:
raise ValueError("Primary key must not be None")
def _validate_row(self, row: dict[str, Any]) -> None:
if not isinstance(row, dict):
raise ValueError("Row must be a dictionary")
if self._config.primary_key not in row:
raise ValueError(
f"Row must include primary key column '{self._config.primary_key}'"
)
def _validate_limit(self, limit: int | None) -> None:
if limit is not None and (not isinstance(limit, int) or limit < 0):
raise ValueError("Limit must be a non-negative integer or None")
def fetch_one(self, pk: Any) -> dict[str, Any] | None:
"""Fetch a single row by primary key."""
self._validate_pk(pk)
self._require_connected()
assert self._client is not None
query = sql.SQL("SELECT * FROM {} WHERE {} = %s").format(
self._table_identifier(),
self._primary_key_identifier(),
)
with self._client.cursor() as cursor:
cursor.execute(query, (pk,))
row = cursor.fetchone()
self.logger.debug("Fetched row", pk=pk, found=row is not None)
return row
def fetch_all(self, *, limit: int | None = None) -> list[dict[str, Any]]:
"""Fetch all rows from the configured table."""
self._validate_limit(limit)
self._require_connected()
assert self._client is not None
query = sql.SQL("SELECT * FROM {}").format(self._table_identifier())
params: tuple[Any, ...] = ()
if limit is not None:
query = sql.Composed([query, sql.SQL(" LIMIT %s")])
params = (limit,)
with self._client.cursor() as cursor:
cursor.execute(query, params)
rows = cursor.fetchall()
self.logger.debug("Fetched rows", count=len(rows), limit=limit)
return list(rows)
def upsert(self, row: dict[str, Any]) -> None:
"""Insert or update a row by primary key."""
self._validate_row(row)
self._require_connected()
assert self._client is not None
pk_col = self._config.primary_key
columns = list(row.keys())
identifiers = [sql.Identifier(column) for column in columns]
placeholders = sql.SQL(", ").join(sql.Placeholder() * len(columns))
column_list = sql.SQL(", ").join(identifiers)
update_columns = [column for column in columns if column != pk_col]
on_conflict: sql.Composed | sql.SQL
if update_columns:
update_assignments = sql.SQL(", ").join(
sql.SQL("{} = EXCLUDED.{}").format(
sql.Identifier(column),
sql.Identifier(column),
)
for column in update_columns
)
on_conflict = sql.SQL("DO UPDATE SET {}").format(update_assignments)
else:
on_conflict = sql.SQL("DO NOTHING")
query = sql.SQL("INSERT INTO {} ({}) VALUES ({}) ON CONFLICT ({}) {}").format(
self._table_identifier(),
column_list,
placeholders,
self._primary_key_identifier(),
on_conflict,
)
with self._client.cursor() as cursor:
cursor.execute(query, tuple(row[column] for column in columns))
self.logger.debug("Upserted row", pk=row[pk_col], columns=columns)
def delete(self, pk: Any) -> None:
"""Delete a row by primary key."""
self._validate_pk(pk)
self._require_connected()
assert self._client is not None
query = sql.SQL("DELETE FROM {} WHERE {} = %s").format(
self._table_identifier(),
self._primary_key_identifier(),
)
with self._client.cursor() as cursor:
cursor.execute(query, (pk,))
self.logger.debug("Deleted row", pk=pk)
def execute(
self,
sql_text: str,
params: tuple[Any, ...] = (),
) -> list[dict[str, Any]]:
"""Run a read SQL statement and return rows as dicts."""
if not isinstance(sql_text, str) or len(sql_text) == 0:
raise ValueError("SQL must be a non-empty string")
if not isinstance(params, tuple):
raise ValueError("Params must be a tuple")
self._require_connected()
assert self._client is not None
with self._client.cursor() as cursor:
cursor.execute(sql_text, params)
rows = cursor.fetchall()
self.logger.debug("Executed SQL", row_count=len(rows))
return list(rows)
+15 -5
View File
@@ -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"
@@ -91,12 +91,17 @@ class RedisAdapter(JsonRepositoryInterface, ConnectionAwareAdapter):
return False
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
if not isinstance(key, str) or len(key) == 0:
raise ValueError("Key must be a non-empty string")
if not isinstance(data, dict) or len(data) == 0:
raise ValueError("Data must be a non-empty dictionary")
if not isinstance(data, dict):
raise ValueError("Data must be a dictionary")
# Check connection
self._require_connected()
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()))
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
if not isinstance(key, str) or len(key) == 0:
raise ValueError("Key must be a non-empty string")
+2
View File
@@ -1,9 +1,11 @@
from .dotenv_loader import load_dotenv as load_dotenv
from .minio_config import MinioConfig as MinioConfig
from .postgres_config import PostgresConfig as PostgresConfig
from .redis_config import RedisConfig as RedisConfig
__all__ = [
"MinioConfig",
"PostgresConfig",
"RedisConfig",
"load_dotenv",
]
@@ -0,0 +1,42 @@
"""PostgreSQL connection configuration."""
from __future__ import annotations
from dataclasses import dataclass
import os
from python_utils import check_env
from python_repositories.config.dotenv_loader import load_dotenv
@dataclass(frozen=True)
class PostgresConfig:
"""Configuration for connecting to PostgreSQL."""
uri: str
table: str
primary_key: str = "id"
@classmethod
def from_env(
cls,
uri_env_var_name: str = "POSTGRES_URI",
table_env_var_name: str = "POSTGRES_TABLE",
primary_key_env_var_name: str = "POSTGRES_PRIMARY_KEY",
*,
use_dotenv: bool = True,
) -> PostgresConfig:
"""Load configuration from environment variables."""
if use_dotenv:
load_dotenv()
env_var_names = {uri_env_var_name, table_env_var_name}
check_env(env_var_names)
primary_key = os.getenv(primary_key_env_var_name)
if primary_key is None or primary_key == "":
primary_key = "id"
return cls(
uri=str(os.getenv(uri_env_var_name)),
table=str(os.getenv(table_env_var_name)),
primary_key=primary_key,
)
@@ -0,0 +1,19 @@
"""Example domain repository backed by Postgres tables."""
from typing import Any
from python_repositories.adapters.postgres_adapter import PostgresAdapter
class UserTableRepository(PostgresAdapter):
"""Example: domain repository backed by a Postgres table."""
def get_user(self, user_id: str) -> dict[str, Any] | None:
return self.fetch_one(user_id)
def save_user(self, user_id: str, user: dict[str, Any]) -> None:
row = {self._config.primary_key: user_id, **user}
self.upsert(row)
def delete_user(self, user_id: str) -> None:
self.delete(user_id)
@@ -8,10 +8,14 @@ from .json_repository_interface import (
from .object_repository_interface import (
ObjectRepositoryInterface as ObjectRepositoryInterface,
)
from .table_repository_interface import (
TableRepositoryInterface as TableRepositoryInterface,
)
__all__ = [
"ConnectionAwareInterface",
"ContextAwareInterface",
"JsonRepositoryInterface",
"ObjectRepositoryInterface",
"TableRepositoryInterface",
]
@@ -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,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 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
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
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
def delete(self, key: str) -> None:
"""Delete a JSON object by key."""
"""Delete a JSON object by key, removing it entirely."""
...
@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 typing import Protocol, runtime_checkable
class ObjectRepositoryInterface(ABC):
@runtime_checkable
class ObjectRepositoryInterface(Protocol):
"""Interface that defines binary object CRUD methods."""
@abstractmethod
def get(self, object_name: str) -> BytesIO | None:
"""Get an object by name.
Returns None when the object does not exist. Raises ConnectionError when
not connected. Other backend errors propagate to the caller.
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. Raises ConnectionError when not connected. Other backend
errors propagate to the caller.
"""
...
@@ -23,12 +27,17 @@ class ObjectRepositoryInterface(ABC):
data: BytesIO,
content_type: str = "application/octet-stream",
) -> 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
def delete(self, object_name: str) -> None:
"""Delete an object by name."""
"""Delete an object by name, removing it entirely."""
...
@abstractmethod
@@ -0,0 +1,55 @@
"""Definition of TableRepositoryInterface protocol."""
from abc import abstractmethod
from typing import Any, Protocol, runtime_checkable
@runtime_checkable
class TableRepositoryInterface(Protocol):
"""Interface that defines relational table CRUD methods."""
@abstractmethod
def fetch_one(self, pk: Any) -> dict[str, Any] | None:
"""Fetch a single row by primary key.
Returns ``None`` when no row matches. Use ``value is not None`` to test
existence; avoid truthiness checks.
"""
...
@abstractmethod
def fetch_all(self, *, limit: int | None = None) -> list[dict[str, Any]]:
"""Fetch all rows from the configured table.
Returns an empty list when the table has no rows.
"""
...
@abstractmethod
def upsert(self, row: dict[str, Any]) -> None:
"""Insert or update a row by primary key.
``row`` must include the configured primary key column. Raises
``ValueError`` for a non-dict row or a missing primary key.
"""
...
@abstractmethod
def delete(self, pk: Any) -> None:
"""Delete a row by primary key.
Idempotent: no error when the row is already absent.
"""
...
@abstractmethod
def execute(
self,
sql: str,
params: tuple[Any, ...] = (),
) -> list[dict[str, Any]]:
"""Run a read SQL statement and return rows as dicts.
Escape hatch for joins, filters, and other queries in subclasses.
"""
...
+6 -1
View File
@@ -1,6 +1,6 @@
"""Shared test configuration constants."""
from python_repositories.config import MinioConfig, RedisConfig
from python_repositories.config import MinioConfig, PostgresConfig, RedisConfig
TEST_REDIS_CONFIG = RedisConfig(uri="redis://localhost:6379")
TEST_MINIO_CONFIG = MinioConfig(
@@ -10,3 +10,8 @@ TEST_MINIO_CONFIG = MinioConfig(
bucket="test-bucket",
secure=False,
)
TEST_POSTGRES_CONFIG = PostgresConfig(
uri="postgresql://localhost/mydb",
table="test_items",
primary_key="id",
)
+26 -1
View File
@@ -6,12 +6,17 @@ from minio import Minio
import pytest
import redis
from python_repositories.config import MinioConfig, RedisConfig
from python_repositories.config import MinioConfig, PostgresConfig, RedisConfig
from tests.integration.minio._containers import (
minio_config_from_env,
minio_env,
raw_minio_client_from_env,
)
from tests.integration.postgres._containers import (
postgres_config_from_container,
postgres_uri,
raw_postgres_client_from_container,
)
from tests.integration.redis._containers import (
raw_redis_client_from_container,
redis_config_from_container,
@@ -53,3 +58,23 @@ def minio_config(minio_container: dict[str, str]) -> MinioConfig:
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)
@pytest.fixture(scope="session")
def postgres_container() -> Generator[str, None, None]:
"""Set up a Postgres container for testing and yield the Postgres URI."""
yield from postgres_uri()
@pytest.fixture(scope="session")
def postgres_config(postgres_container: str) -> PostgresConfig:
"""Provide PostgresConfig built from the test container."""
return postgres_config_from_container(postgres_container)
@pytest.fixture(scope="session")
def raw_postgres_client(
postgres_container: str,
) -> Generator[object, None, None]:
"""Provide a raw Postgres client connected to the test Postgres container."""
yield from raw_postgres_client_from_container(postgres_container)
@@ -6,12 +6,15 @@ import os
import random
from minio import Minio
import psycopg
import pytest
from python_repositories.examples.artifact_object_repository import (
ArtifactObjectRepository,
)
from python_repositories.examples.user_json_repository import UserJsonRepository
from python_repositories.examples.user_table_repository import UserTableRepository
from tests.integration.postgres._containers import TEST_TABLE
pytestmark = [
pytest.mark.integration,
@@ -36,6 +39,24 @@ def set_example_env(
_ = os.environ.pop(key, default=None)
@pytest.fixture(scope="module", autouse=True)
def set_postgres_example_env(
postgres_container: str,
raw_postgres_client: psycopg.Connection,
) -> Generator[None, None, None]:
"""Set Postgres env vars for example repositories using from_env() defaults."""
env_vars = {
"POSTGRES_URI": postgres_container,
"POSTGRES_TABLE": TEST_TABLE,
"POSTGRES_PRIMARY_KEY": "id",
}
for key, value in env_vars.items():
os.environ[key] = value
yield
for key in env_vars:
_ = os.environ.pop(key, default=None)
@pytest.fixture(scope="module")
def user_data() -> Generator[dict[str, str], None, None]:
"""Provide sample user data for tests."""
@@ -77,3 +98,27 @@ def test_artifact_object_repository_store_and_get(
assert received is not None
artifact_data.seek(0)
assert received.read() == artifact_data.read()
@pytest.mark.needs_postgres
def test_user_table_repository_save_and_get(
user_data: dict[str, str],
) -> None:
"""Test that UserTableRepository can save and retrieve a user."""
with UserTableRepository() as repo:
repo.save_user("alice", user_data)
user = repo.get_user("alice")
assert user is not None
assert user["name"] == user_data["name"]
assert user["email"] == user_data["email"]
@pytest.mark.needs_postgres
def test_user_table_repository_delete(
user_data: dict[str, str],
) -> None:
"""Test that UserTableRepository can delete a user."""
with UserTableRepository() as repo:
repo.save_user("alice", user_data)
repo.delete_user("alice")
assert repo.get_user("alice") is None
@@ -328,6 +328,44 @@ def test_should_raise_value_error_on_invalid_put_data(
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(
data: BytesIO,
minio_adapter: MinioAdapter,
+76
View File
@@ -0,0 +1,76 @@
"""Postgres container session helpers for integration tests."""
from collections.abc import Generator
import psycopg
from psycopg.rows import dict_row
from testcontainers.postgres import PostgresContainer
from python_repositories.config import PostgresConfig
POSTGRES_IMAGE = "postgres:16"
TEST_TABLE = "test_items"
_postgres_uri: str | None = None
_postgres_container: PostgresContainer | None = None
_raw_postgres_client: psycopg.Connection | None = None
_postgres_uri_refs = 0
_raw_postgres_client_refs = 0
def postgres_uri() -> Generator[str, None, None]:
"""Yield a session-scoped Postgres URI, starting the container once."""
global _postgres_uri, _postgres_container, _postgres_uri_refs
if _postgres_uri is None:
_postgres_container = PostgresContainer(POSTGRES_IMAGE, driver=None)
_postgres_container.start()
_postgres_uri = _postgres_container.get_connection_url()
_postgres_uri_refs += 1
yield _postgres_uri
_postgres_uri_refs -= 1
if _postgres_uri_refs == 0 and _postgres_container is not None:
_postgres_container.stop()
_postgres_container = None
_postgres_uri = None
def postgres_config_from_container(uri: str) -> PostgresConfig:
"""Build PostgresConfig from a container URI."""
return PostgresConfig(uri=uri, table=TEST_TABLE, primary_key="id")
def raw_postgres_client_from_container(
uri: str,
) -> Generator[psycopg.Connection, None, None]:
"""Yield a session-scoped raw Postgres client, reusing one client per session."""
global _raw_postgres_client, _raw_postgres_client_refs
if _raw_postgres_client is None:
_raw_postgres_client = psycopg.connect(
uri,
row_factory=dict_row, # type: ignore[arg-type]
autocommit=True,
)
with _raw_postgres_client.cursor() as cursor:
cursor.execute(
f"""
CREATE TABLE IF NOT EXISTS {TEST_TABLE} (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
value INTEGER,
email TEXT
)
"""
)
_raw_postgres_client_refs += 1
yield _raw_postgres_client
_raw_postgres_client_refs -= 1
if _raw_postgres_client_refs == 0 and _raw_postgres_client is not None:
with _raw_postgres_client.cursor() as cursor:
cursor.execute(f"TRUNCATE TABLE {TEST_TABLE}")
_raw_postgres_client.close()
_raw_postgres_client = None
+33
View File
@@ -0,0 +1,33 @@
"""Postgres integration test fixtures."""
from collections.abc import Generator
import psycopg
import pytest
from python_repositories.config import PostgresConfig
from tests.integration.postgres._containers import (
postgres_config_from_container,
postgres_uri,
raw_postgres_client_from_container,
)
@pytest.fixture(scope="session")
def postgres_container() -> Generator[str, None, None]:
"""Set up a Postgres container for testing and yield the Postgres URI."""
yield from postgres_uri()
@pytest.fixture(scope="session")
def postgres_config(postgres_container: str) -> PostgresConfig:
"""Provide PostgresConfig built from the test container."""
return postgres_config_from_container(postgres_container)
@pytest.fixture(scope="session")
def raw_postgres_client(
postgres_container: str,
) -> Generator[psycopg.Connection, None, None]:
"""Provide a raw Postgres client connected to the test container."""
yield from raw_postgres_client_from_container(postgres_container)
@@ -0,0 +1,318 @@
"""Integration tests for the PostgresAdapter."""
from collections.abc import Generator
import logging
import psycopg
import pytest
from python_repositories.adapters.postgres_adapter import PostgresAdapter
from python_repositories.config import PostgresConfig
from tests.integration.postgres._containers import TEST_TABLE
pytestmark = [pytest.mark.integration, pytest.mark.needs_postgres]
@pytest.fixture(scope="module")
def row() -> Generator[dict[str, object], None, None]:
"""Provide a sample row for tests."""
yield {"id": "test-id", "name": "Alice", "value": 42}
@pytest.fixture(scope="function")
def row_in_postgres(
raw_postgres_client: psycopg.Connection,
row: dict[str, object],
) -> Generator[tuple[str, dict[str, object]], None, None]:
"""Fixture to set up a known row in Postgres before each test."""
with raw_postgres_client.cursor() as cursor:
cursor.execute(
f"INSERT INTO {TEST_TABLE} (id, name, value) VALUES (%s, %s, %s)",
(row["id"], row["name"], row["value"]),
)
yield str(row["id"]), row
with raw_postgres_client.cursor() as cursor:
cursor.execute(f"DELETE FROM {TEST_TABLE} WHERE id = %s", (row["id"],))
@pytest.fixture(scope="module")
def postgres_adapter(
postgres_config: PostgresConfig,
raw_postgres_client: psycopg.Connection,
) -> Generator[PostgresAdapter, None, None]:
"""Fixture to provide a connected PostgresAdapter instance."""
_ = raw_postgres_client # ensure test table exists before connect probe
adapter = PostgresAdapter(config=postgres_config)
adapter.connect()
yield adapter
adapter.disconnect()
@pytest.fixture(scope="function", autouse=True)
def clear_postgres(raw_postgres_client: psycopg.Connection) -> None:
"""Fixture to clear all rows before each test."""
with raw_postgres_client.cursor() as cursor:
cursor.execute(f"TRUNCATE TABLE {TEST_TABLE}")
def test_should_log_info_when_already_connected(
postgres_adapter: PostgresAdapter,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that PostgresAdapter logs info when connect is called while connected."""
with caplog.at_level(logging.INFO):
postgres_adapter.connect()
assert "Already connected" in caplog.text
assert "Postgres" in caplog.text
def test_should_raise_connection_error_when_unable_to_connect() -> None:
"""Test that PostgresAdapter raises ConnectionError when unable to connect."""
adapter = PostgresAdapter(
config=PostgresConfig(
uri="postgresql://invalid:5432/nodb",
table=TEST_TABLE,
)
)
with pytest.raises(ConnectionError):
adapter.connect()
assert adapter._client is None
assert not adapter.is_connected()
def test_should_raise_connection_error_when_table_missing(
postgres_config: PostgresConfig,
) -> None:
"""Test that PostgresAdapter raises ConnectionError when the table is missing."""
config = PostgresConfig(
uri=postgres_config.uri,
table="missing_table",
primary_key="id",
)
adapter = PostgresAdapter(config=config)
with pytest.raises(ConnectionError, match="does not exist"):
adapter.connect()
def test_should_log_error_on_exception_during_exit(
postgres_config: PostgresConfig,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that PostgresAdapter logs an error when an exception occurs during exit."""
try:
with PostgresAdapter(config=postgres_config) as adapter:
assert adapter.is_connected()
raise ValueError("Simulated error")
except ValueError:
pass
assert "Error while exiting context" in caplog.text
def test_should_have_context_manager(postgres_config: PostgresConfig) -> None:
"""Test that PostgresAdapter can be used as a context manager."""
with PostgresAdapter(config=postgres_config) as adapter:
assert adapter._client is not None
assert adapter._client is None
def test_should_fetch_one(
row_in_postgres: tuple[str, dict[str, object]],
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter can fetch a row by primary key."""
pk, row = row_in_postgres
fetched = postgres_adapter.fetch_one(pk)
assert fetched is not None
assert fetched["id"] == row["id"]
assert fetched["name"] == row["name"]
assert fetched["value"] == row["value"]
def test_should_fetch_none_for_missing_row(
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that fetching a non-existent row returns None."""
assert postgres_adapter.fetch_one("missing-id") is None
def test_should_raise_value_error_on_invalid_fetch_one_pk(
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter raises ValueError for an invalid primary key."""
with pytest.raises(ValueError):
postgres_adapter.fetch_one(None)
def test_should_raise_connection_error_on_fetch_one_when_not_connected(
postgres_config: PostgresConfig,
) -> None:
"""Test that fetch_one raises ConnectionError when not connected."""
adapter = PostgresAdapter(config=postgres_config)
with pytest.raises(ConnectionError):
adapter.fetch_one("some-id")
def test_should_upsert_row(
row: dict[str, object],
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter can insert a row."""
assert postgres_adapter.fetch_one(row["id"]) is None
postgres_adapter.upsert(row)
fetched = postgres_adapter.fetch_one(row["id"])
assert fetched is not None
assert fetched["name"] == row["name"]
def test_should_update_row(
row_in_postgres: tuple[str, dict[str, object]],
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter can update an existing row."""
pk, _ = row_in_postgres
new_row = {"id": pk, "name": "Bob", "value": 99}
postgres_adapter.upsert(new_row)
fetched = postgres_adapter.fetch_one(pk)
assert fetched is not None
assert fetched["name"] == "Bob"
assert fetched["value"] == 99
def test_should_raise_value_error_on_invalid_upsert_row(
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter raises ValueError for invalid upsert data."""
with pytest.raises(ValueError):
postgres_adapter.upsert({"name": "Alice"}) # missing primary key
def test_should_raise_value_error_on_non_dict_upsert_row(
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter raises ValueError when upsert row is not a dict."""
with pytest.raises(ValueError):
postgres_adapter.upsert("not-a-dict") # type: ignore[arg-type]
def test_should_raise_connection_error_on_upsert_when_not_connected(
postgres_config: PostgresConfig,
row: dict[str, object],
) -> None:
"""Test that upsert raises ConnectionError when not connected."""
adapter = PostgresAdapter(config=postgres_config)
with pytest.raises(ConnectionError):
adapter.upsert(row)
def test_should_fetch_all_rows(
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter can fetch all rows."""
postgres_adapter.upsert({"id": "a", "name": "Alice", "value": 1})
postgres_adapter.upsert({"id": "b", "name": "Bob", "value": 2})
rows = postgres_adapter.fetch_all()
assert len(rows) == 2
ids = {row["id"] for row in rows}
assert ids == {"a", "b"}
def test_should_fetch_all_with_limit(
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter respects fetch_all limit."""
postgres_adapter.upsert({"id": "a", "name": "Alice", "value": 1})
postgres_adapter.upsert({"id": "b", "name": "Bob", "value": 2})
rows = postgres_adapter.fetch_all(limit=1)
assert len(rows) == 1
def test_should_return_empty_list_for_empty_table(
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that fetch_all returns an empty list for an empty table."""
assert postgres_adapter.fetch_all() == []
def test_should_raise_value_error_on_invalid_fetch_all_limit(
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter raises ValueError for an invalid limit."""
with pytest.raises(ValueError):
postgres_adapter.fetch_all(limit=-1)
def test_should_raise_connection_error_on_fetch_all_when_not_connected(
postgres_config: PostgresConfig,
) -> None:
"""Test that fetch_all raises ConnectionError when not connected."""
adapter = PostgresAdapter(config=postgres_config)
with pytest.raises(ConnectionError):
adapter.fetch_all()
def test_should_delete_row(
row_in_postgres: tuple[str, dict[str, object]],
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that deleting a row removes it from the table."""
pk, _ = row_in_postgres
assert postgres_adapter.fetch_one(pk) is not None
postgres_adapter.delete(pk)
assert postgres_adapter.fetch_one(pk) is None
def test_should_delete_idempotently(
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that deleting a missing row does not raise."""
postgres_adapter.delete("missing-id")
def test_should_raise_value_error_on_invalid_delete_pk(
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter raises ValueError for an invalid delete pk."""
with pytest.raises(ValueError):
postgres_adapter.delete(None)
def test_should_raise_connection_error_on_delete_when_not_connected(
postgres_config: PostgresConfig,
) -> None:
"""Test that delete raises ConnectionError when not connected."""
adapter = PostgresAdapter(config=postgres_config)
with pytest.raises(ConnectionError):
adapter.delete("some-id")
def test_should_execute_sql(
row_in_postgres: tuple[str, dict[str, object]],
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter can execute read SQL."""
pk, _ = row_in_postgres
rows = postgres_adapter.execute(
f"SELECT * FROM {TEST_TABLE} WHERE id = %s",
(pk,),
)
assert len(rows) == 1
assert rows[0]["id"] == pk
def test_should_raise_value_error_on_invalid_execute_sql(
postgres_adapter: PostgresAdapter,
) -> None:
"""Test that PostgresAdapter raises ValueError for invalid SQL."""
with pytest.raises(ValueError):
postgres_adapter.execute("")
def test_should_raise_connection_error_on_execute_when_not_connected(
postgres_config: PostgresConfig,
) -> None:
"""Test that execute raises ConnectionError when not connected."""
adapter = PostgresAdapter(config=postgres_config)
with pytest.raises(ConnectionError):
adapter.execute("SELECT 1")
+34 -1
View File
@@ -190,12 +190,45 @@ def test_should_raise_value_error_on_invalid_set_data(
) -> None:
"""Test that the RedisAdapter raises ValueError when setting with invalid data."""
key = "test_key"
invalid_data = ["", 123, None, [], {}]
invalid_data = ["", 123, None, []]
for data in invalid_data:
with pytest.raises(ValueError):
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(
redis_config: RedisConfig,
data: dict[str, str],
+31 -2
View File
@@ -45,6 +45,7 @@ from python_repositories import JsonRepositoryInterface
assert JsonRepositoryInterface is not None
assert "python_repositories.adapters.redis_adapter" not in sys.modules
assert "python_repositories.adapters.minio_adapter" not in sys.modules
assert "python_repositories.adapters.postgres_adapter" not in sys.modules
"""
result = subprocess.run(
[sys.executable, "-c", script],
@@ -58,10 +59,11 @@ assert "python_repositories.adapters.minio_adapter" not in sys.modules
def test_lazy_adapter_load_succeeds_when_extra_present() -> None:
"""Adapters load when their optional dependencies are installed."""
from python_repositories import MinioAdapter, RedisAdapter
from python_repositories import MinioAdapter, PostgresAdapter, RedisAdapter
assert RedisAdapter.__name__ == "RedisAdapter"
assert MinioAdapter.__name__ == "MinioAdapter"
assert PostgresAdapter.__name__ == "PostgresAdapter"
def test_redis_adapter_import_error_without_extra() -> None:
@@ -86,6 +88,17 @@ def test_minio_adapter_import_error_without_extra() -> None:
importlib.reload(minio_adapter_module)
def test_postgres_adapter_import_error_without_extra() -> None:
"""Missing postgres extra raises ImportError with install hint."""
import python_repositories.adapters.postgres_adapter as postgres_adapter_module
with patch.object(builtins, "__import__", new=_block_backend_import("psycopg")):
with pytest.raises(ImportError, match=r"python-repositories\[postgres\]"):
importlib.reload(postgres_adapter_module)
importlib.reload(postgres_adapter_module)
def test_top_level_lazy_import_propagates_redis_import_error() -> None:
"""Top-level RedisAdapter access surfaces adapter import errors."""
with patch(
@@ -112,6 +125,19 @@ def test_top_level_lazy_import_propagates_minio_import_error() -> None:
_ = python_repositories.MinioAdapter
def test_top_level_lazy_import_propagates_postgres_import_error() -> None:
"""Top-level PostgresAdapter access surfaces adapter import errors."""
with patch(
"importlib.import_module",
side_effect=ImportError(
"Postgres support requires the postgres extra. "
"Install with: pip install python-repositories[postgres]"
),
):
with pytest.raises(ImportError, match=r"python-repositories\[postgres\]"):
_ = python_repositories.PostgresAdapter
def test_adapters_subpackage_lazy_import_succeeds() -> None:
"""Adapter subpackage imports delegate to the same lazy loader."""
from python_repositories.adapters import RedisAdapter
@@ -123,7 +149,9 @@ def test_adapters_dir_exposes_lazy_exports() -> None:
"""dir(adapters) includes lazy adapter names for tab completion."""
import python_repositories.adapters as adapters
assert {"RedisAdapter", "MinioAdapter"}.issubset(set(dir(adapters)))
assert {"RedisAdapter", "MinioAdapter", "PostgresAdapter"}.issubset(
set(dir(adapters))
)
def test_adapters_getattr_raises_for_unknown() -> None:
@@ -138,3 +166,4 @@ def test_top_level_dir_exposes_lazy_exports() -> None:
"""dir(python_repositories) includes lazy adapter names for tab completion."""
assert "RedisAdapter" in dir(python_repositories)
assert "MinioAdapter" in dir(python_repositories)
assert "PostgresAdapter" in dir(python_repositories)
+14 -1
View File
@@ -5,12 +5,14 @@ from __future__ import annotations
from unittest.mock import MagicMock
from minio import Minio
import psycopg
import pytest
import redis
from python_repositories.adapters.minio_adapter import MinioAdapter
from python_repositories.adapters.postgres_adapter import PostgresAdapter
from python_repositories.adapters.redis_adapter import RedisAdapter
from tests.conftest import TEST_MINIO_CONFIG, TEST_REDIS_CONFIG
from tests.conftest import TEST_MINIO_CONFIG, TEST_POSTGRES_CONFIG, TEST_REDIS_CONFIG
@pytest.fixture
@@ -25,3 +27,14 @@ def minio_adapter() -> MinioAdapter:
"""Provide a MinioAdapter with an injected mock client."""
mock_client = MagicMock(spec=Minio)
return MinioAdapter(config=TEST_MINIO_CONFIG, client=mock_client)
@pytest.fixture
def postgres_adapter() -> PostgresAdapter:
"""Provide a PostgresAdapter with an injected mock client."""
mock_client = MagicMock(spec=psycopg.Connection)
mock_cursor = MagicMock()
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_client.cursor.return_value = mock_cursor
return PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
+74 -1
View File
@@ -5,11 +5,13 @@ from __future__ import annotations
from typing import cast
from unittest.mock import MagicMock, patch
import psycopg
import redis
from python_repositories.adapters.minio_adapter import MinioAdapter
from python_repositories.adapters.postgres_adapter import PostgresAdapter
from python_repositories.adapters.redis_adapter import RedisAdapter
from tests.conftest import TEST_MINIO_CONFIG, TEST_REDIS_CONFIG
from tests.conftest import TEST_MINIO_CONFIG, TEST_POSTGRES_CONFIG, TEST_REDIS_CONFIG
class TestRedisConnectionHealth:
@@ -161,3 +163,74 @@ class TestMinioConnectionHealth:
assert reinjected.is_connected()
assert mock_client.bucket_exists.call_count == 2
class TestPostgresConnectionHealth:
def test_not_connected_when_no_client(self) -> None:
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
assert not adapter.is_connected()
def test_connected_when_probe_succeeds(
self, postgres_adapter: PostgresAdapter
) -> None:
assert postgres_adapter.is_connected()
cast(MagicMock, postgres_adapter._client).cursor.assert_called()
def test_stale_connection_when_probe_fails(
self, postgres_adapter: PostgresAdapter
) -> None:
mock_cursor = cast(MagicMock, postgres_adapter._client).cursor.return_value
mock_cursor.execute.side_effect = psycopg.OperationalError("connection lost")
assert not postgres_adapter.is_connected()
def test_cache_hit_avoids_second_probe(
self, postgres_adapter: PostgresAdapter
) -> None:
mock_client = cast(MagicMock, postgres_adapter._client)
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
return_value=100.0,
):
assert postgres_adapter.is_connected()
assert postgres_adapter.is_connected()
assert mock_client.cursor.call_count == 1
def test_cache_miss_runs_probe_again(
self, postgres_adapter: PostgresAdapter
) -> None:
mock_client = cast(MagicMock, postgres_adapter._client)
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
side_effect=[100.0, 102.0],
):
assert postgres_adapter.is_connected()
assert postgres_adapter.is_connected()
assert mock_client.cursor.call_count == 2
def test_disconnect_clears_cache(self, postgres_adapter: PostgresAdapter) -> None:
mock_client = cast(MagicMock, postgres_adapter._client)
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
return_value=100.0,
):
assert postgres_adapter.is_connected()
postgres_adapter.disconnect()
reinjected = PostgresAdapter(
config=postgres_adapter._config,
client=mock_client,
)
with patch(
"python_repositories.adapters.connection_aware_adapter.time.monotonic",
return_value=100.0,
):
assert reinjected.is_connected()
assert mock_client.cursor.call_count == 2
@@ -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)
+237
View File
@@ -0,0 +1,237 @@
"""Unit tests for PostgresAdapter instantiation and injection."""
from __future__ import annotations
from unittest.mock import MagicMock
import psycopg
import pytest
from python_repositories.adapters.postgres_adapter import PostgresAdapter
from python_repositories.interfaces import TableRepositoryInterface
from tests.conftest import TEST_POSTGRES_CONFIG
def _mock_cursor() -> MagicMock:
mock_cursor = MagicMock()
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=False)
return mock_cursor
def _mock_client(*, probe_raises: Exception | None = None) -> MagicMock:
mock_client = MagicMock(spec=psycopg.Connection)
mock_cursor = _mock_cursor()
if probe_raises is not None:
mock_cursor.execute.side_effect = probe_raises
mock_client.cursor.return_value = mock_cursor
return mock_client
def test_should_adhere_to_interface() -> None:
assert issubclass(PostgresAdapter, TableRepositoryInterface)
_ = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
def test_should_have_logger_when_instantiated() -> None:
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
assert hasattr(adapter, "logger")
assert adapter.logger is not None
def test_should_not_be_connected_when_instantiated() -> None:
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
assert adapter._client is None
assert not adapter.is_connected()
def test_constructs_with_injected_config_without_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("POSTGRES_URI", raising=False)
monkeypatch.delenv("POSTGRES_TABLE", raising=False)
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
assert adapter._config == TEST_POSTGRES_CONFIG
def test_constructs_with_injected_client_without_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("POSTGRES_URI", raising=False)
monkeypatch.delenv("POSTGRES_TABLE", raising=False)
mock_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
assert adapter._client is mock_client
assert adapter._client_injected is True
def test_raises_when_client_provided_without_config() -> None:
mock_client = MagicMock(spec=psycopg.Connection)
with pytest.raises(ValueError, match="config is required"):
PostgresAdapter(client=mock_client)
def test_disconnect_does_not_close_injected_client() -> None:
mock_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
adapter.disconnect()
mock_client.close.assert_not_called()
assert adapter._client is None
class CustomEnvPostgresAdapter(PostgresAdapter):
uri_env_var_name = "CUSTOM_POSTGRES_URI"
table_env_var_name = "CUSTOM_POSTGRES_TABLE"
primary_key_env_var_name = "CUSTOM_POSTGRES_PRIMARY_KEY"
def test_subclass_custom_env_var_names(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CUSTOM_POSTGRES_URI", "postgresql://custom/mydb")
monkeypatch.setenv("CUSTOM_POSTGRES_TABLE", "items")
monkeypatch.setenv("CUSTOM_POSTGRES_PRIMARY_KEY", "item_id")
adapter = CustomEnvPostgresAdapter()
assert adapter._config.uri == "postgresql://custom/mydb"
assert adapter._config.table == "items"
assert adapter._config.primary_key == "item_id"
def test_connect_with_injected_client_succeeds_when_probe_ok() -> None:
mock_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
adapter.connect()
assert mock_client.cursor.return_value.execute.call_count == 2
def test_connect_with_injected_client_raises_on_probe_failure() -> None:
mock_client = _mock_client(probe_raises=psycopg.OperationalError("connection lost"))
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
with pytest.raises(ConnectionError, match="Could not connect to Postgres"):
adapter.connect()
def test_connect_with_injected_client_skips_validation_when_client_cleared() -> None:
mock_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
adapter.disconnect()
adapter.connect()
mock_client.cursor.assert_not_called()
def test_connect_reconnects_when_existing_client_unhealthy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
stale_client = _mock_client(
probe_raises=psycopg.OperationalError("connection lost")
)
new_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
adapter._client = stale_client
monkeypatch.setattr("psycopg.connect", lambda *args, **kwargs: new_client)
adapter.connect()
stale_client.close.assert_called_once()
assert adapter._client is new_client
def test_connect_skips_reconnect_when_already_connected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
healthy_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
adapter._client = healthy_client
connect = MagicMock()
monkeypatch.setattr("psycopg.connect", connect)
adapter.connect()
healthy_client.close.assert_not_called()
connect.assert_not_called()
assert adapter._client is healthy_client
def test_fetch_one_raises_value_error_on_invalid_pk() -> None:
mock_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
with pytest.raises(ValueError, match="Primary key must not be None"):
adapter.fetch_one(None)
def test_fetch_one_raises_connection_error_when_not_connected() -> None:
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
with pytest.raises(ConnectionError):
adapter.fetch_one("some-id")
def test_upsert_raises_value_error_on_invalid_row() -> None:
mock_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
with pytest.raises(ValueError, match="Row must be a dictionary"):
adapter.upsert("not-a-dict") # type: ignore[arg-type]
def test_upsert_raises_value_error_when_primary_key_missing() -> None:
mock_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
with pytest.raises(ValueError, match="Row must include primary key column 'id'"):
adapter.upsert({"name": "Alice"})
def test_upsert_raises_connection_error_when_not_connected() -> None:
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
with pytest.raises(ConnectionError):
adapter.upsert({"id": "alice", "name": "Alice"})
def test_fetch_all_raises_value_error_on_invalid_limit() -> None:
mock_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
with pytest.raises(ValueError, match="Limit must be a non-negative integer"):
adapter.fetch_all(limit=-1)
def test_execute_raises_value_error_on_invalid_sql() -> None:
mock_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
with pytest.raises(ValueError, match="SQL must be a non-empty string"):
adapter.execute("")
def test_execute_raises_value_error_on_invalid_params() -> None:
mock_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
with pytest.raises(ValueError, match="Params must be a tuple"):
adapter.execute("SELECT 1", []) # type: ignore[arg-type]
def test_upsert_with_primary_key_only_uses_do_nothing() -> None:
mock_client = _mock_client()
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG, client=mock_client)
adapter.upsert({"id": "pk-only"})
mock_cursor = mock_client.cursor.return_value.__enter__.return_value
insert_call = mock_cursor.execute.call_args_list[-1]
assert insert_call[0][1] == ("pk-only",)
def test_execute_raises_connection_error_when_not_connected() -> None:
adapter = PostgresAdapter(config=TEST_POSTGRES_CONFIG)
with pytest.raises(ConnectionError):
adapter.execute("SELECT 1")
+69
View File
@@ -0,0 +1,69 @@
"""Unit tests for PostgresConfig."""
from __future__ import annotations
import pytest
from python_repositories.config import PostgresConfig
def _set_required_postgres_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("POSTGRES_URI", "postgresql://localhost/mydb")
monkeypatch.setenv("POSTGRES_TABLE", "users")
def test_from_env_loads_all_fields(monkeypatch: pytest.MonkeyPatch) -> None:
_set_required_postgres_env(monkeypatch)
config = PostgresConfig.from_env(use_dotenv=False)
assert config.uri == "postgresql://localhost/mydb"
assert config.table == "users"
assert config.primary_key == "id"
def test_from_env_reads_primary_key_from_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_required_postgres_env(monkeypatch)
monkeypatch.setenv("POSTGRES_PRIMARY_KEY", "user_id")
config = PostgresConfig.from_env(use_dotenv=False)
assert config.primary_key == "user_id"
def test_from_env_defaults_primary_key_to_id_when_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_required_postgres_env(monkeypatch)
monkeypatch.delenv("POSTGRES_PRIMARY_KEY", raising=False)
config = PostgresConfig.from_env(use_dotenv=False)
assert config.primary_key == "id"
def test_from_env_raises_when_uri_missing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("POSTGRES_URI", raising=False)
monkeypatch.setenv("POSTGRES_TABLE", "users")
with pytest.raises(Exception):
PostgresConfig.from_env(use_dotenv=False)
def test_from_env_raises_when_table_missing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("POSTGRES_URI", "postgresql://localhost/mydb")
monkeypatch.delenv("POSTGRES_TABLE", raising=False)
with pytest.raises(Exception):
PostgresConfig.from_env(use_dotenv=False)
def test_from_env_respects_custom_env_var_names(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("CUSTOM_POSTGRES_URI", "postgresql://custom/mydb")
monkeypatch.setenv("CUSTOM_POSTGRES_TABLE", "items")
monkeypatch.setenv("CUSTOM_POSTGRES_PRIMARY_KEY", "item_id")
config = PostgresConfig.from_env(
"CUSTOM_POSTGRES_URI",
"CUSTOM_POSTGRES_TABLE",
"CUSTOM_POSTGRES_PRIMARY_KEY",
use_dotenv=False,
)
assert config.uri == "postgresql://custom/mydb"
assert config.table == "items"
assert config.primary_key == "item_id"
@@ -0,0 +1,162 @@
"""Unit tests for TableRepositoryInterface."""
from typing import Any
import pytest
from python_repositories.interfaces.table_repository_interface import (
TableRepositoryInterface,
)
class InMemoryTableRepo:
"""Plain class that satisfies TableRepositoryInterface without inheritance."""
def fetch_one(self, pk: Any) -> dict[str, Any] | None:
return None
def fetch_all(self, *, limit: int | None = None) -> list[dict[str, Any]]:
del limit
return []
def upsert(self, row: dict[str, Any]) -> None:
pass
def delete(self, pk: Any) -> None:
pass
def execute(
self,
sql: str,
params: tuple[Any, ...] = (),
) -> list[dict[str, Any]]:
del sql, params
return []
def accepts_table_repo(repo: TableRepositoryInterface) -> None:
"""Type-checking hook for TableRepositoryInterface structural subtyping."""
repo.fetch_one("pk")
def test_instantiation_fails_when_fetch_one_not_implemented() -> None:
"""Test that instantiation fails if fetch_one is not implemented."""
class Incomplete(TableRepositoryInterface):
def fetch_all(self, *, limit: int | None = None) -> list[dict[str, Any]]:
return []
def upsert(self, row: dict[str, Any]) -> None:
pass
def delete(self, pk: Any) -> None:
pass
def execute(
self,
sql: str,
params: tuple[Any, ...] = (),
) -> list[dict[str, Any]]:
return []
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_fetch_all_not_implemented() -> None:
"""Test that instantiation fails if fetch_all is not implemented."""
class Incomplete(TableRepositoryInterface):
def fetch_one(self, pk: Any) -> dict[str, Any] | None:
return None
def upsert(self, row: dict[str, Any]) -> None:
pass
def delete(self, pk: Any) -> None:
pass
def execute(
self,
sql: str,
params: tuple[Any, ...] = (),
) -> list[dict[str, Any]]:
return []
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_upsert_not_implemented() -> None:
"""Test that instantiation fails if upsert is not implemented."""
class Incomplete(TableRepositoryInterface):
def fetch_one(self, pk: Any) -> dict[str, Any] | None:
return None
def fetch_all(self, *, limit: int | None = None) -> list[dict[str, Any]]:
return []
def delete(self, pk: Any) -> None:
pass
def execute(
self,
sql: str,
params: tuple[Any, ...] = (),
) -> list[dict[str, Any]]:
return []
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_delete_not_implemented() -> None:
"""Test that instantiation fails if delete is not implemented."""
class Incomplete(TableRepositoryInterface):
def fetch_one(self, pk: Any) -> dict[str, Any] | None:
return None
def fetch_all(self, *, limit: int | None = None) -> list[dict[str, Any]]:
return []
def upsert(self, row: dict[str, Any]) -> None:
pass
def execute(
self,
sql: str,
params: tuple[Any, ...] = (),
) -> list[dict[str, Any]]:
return []
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_instantiation_fails_when_execute_not_implemented() -> None:
"""Test that instantiation fails if execute is not implemented."""
class Incomplete(TableRepositoryInterface):
def fetch_one(self, pk: Any) -> dict[str, Any] | None:
return None
def fetch_all(self, *, limit: int | None = None) -> list[dict[str, Any]]:
return []
def upsert(self, row: dict[str, Any]) -> None:
pass
def delete(self, pk: Any) -> None:
pass
with pytest.raises(TypeError):
_ = Incomplete() # type: ignore
def test_structural_subtyping() -> None:
"""Test that a plain class satisfies TableRepositoryInterface structurally."""
repo: TableRepositoryInterface = InMemoryTableRepo()
accepts_table_repo(repo)
assert isinstance(repo, TableRepositoryInterface)
Generated
+74 -3
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.15'",
@@ -864,6 +864,64 @@ wheels = [
{ url = "https://proxpi.lille-vemmelund.dk/index/pre-commit/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b" },
]
[[package]]
name = "psycopg"
version = "3.3.4"
source = { registry = "https://proxpi.lille-vemmelund.dk/index/" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://proxpi.lille-vemmelund.dk/index/psycopg/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc" }
wheels = [
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.4"
source = { registry = "https://proxpi.lille-vemmelund.dk/index/" }
wheels = [
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e" },
{ url = "https://proxpi.lille-vemmelund.dk/index/psycopg-binary/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8" },
]
[[package]]
name = "pycparser"
version = "3.0"
@@ -1056,7 +1114,7 @@ wheels = [
[[package]]
name = "python-repositories"
version = "2.0.4"
version = "2.2.0"
source = { editable = "." }
dependencies = [
{ name = "python-dotenv" },
@@ -1068,6 +1126,9 @@ dependencies = [
minio = [
{ name = "minio" },
]
postgres = [
{ name = "psycopg", extra = ["binary"] },
]
redis = [
{ name = "redis" },
]
@@ -1087,12 +1148,13 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "minio", marker = "extra == 'minio'", specifier = ">=7.2.16" },
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-utils", specifier = ">=0.1.0", index = "https://gitea.lille-vemmelund.dk/api/packages/brian/pypi/simple/" },
{ name = "redis", marker = "extra == 'redis'", specifier = ">=6.4.0" },
{ name = "structlog", specifier = ">=25.4.0" },
]
provides-extras = ["redis", "minio"]
provides-extras = ["redis", "minio", "postgres"]
[package.metadata.requires-dev]
dev = [
@@ -1514,6 +1576,15 @@ wheels = [
{ url = "https://proxpi.lille-vemmelund.dk/index/typing-inspection/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" },
]
[[package]]
name = "tzdata"
version = "2026.3"
source = { registry = "https://proxpi.lille-vemmelund.dk/index/" }
sdist = { url = "https://proxpi.lille-vemmelund.dk/index/tzdata/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415" }
wheels = [
{ url = "https://proxpi.lille-vemmelund.dk/index/tzdata/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931" },
]
[[package]]
name = "urllib3"
version = "2.7.0"