diff --git a/.env.example b/.env.example index 15963df..b6dcffd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2afbd67..68a864e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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. diff --git a/README.md b/README.md index d793889..0755aa6 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ 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. @@ -29,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`) @@ -63,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( @@ -85,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. @@ -97,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 @@ -126,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": "alice@example.com"}) + user = repo.get_user("alice") + repo.delete_user("alice") +``` + ### Subclassing in your own project ```python @@ -154,8 +190,11 @@ from python_repositories import ( MinioAdapter, MinioConfig, ObjectRepositoryInterface, + PostgresAdapter, + PostgresConfig, RedisAdapter, RedisConfig, + TableRepositoryInterface, load_dotenv, ) ``` @@ -169,10 +208,11 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index 54ee753..1f561e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ redis = [ minio = [ "minio>=7.2.16", ] +postgres = [ + "psycopg[binary]>=3.2.0", +] [build-system] requires = ["hatchling"] @@ -44,6 +47,7 @@ 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] diff --git a/python_repositories/__init__.py b/python_repositories/__init__.py index 7828124..c5124d2 100644 --- a/python_repositories/__init__.py +++ b/python_repositories/__init__.py @@ -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__, ] diff --git a/python_repositories/adapters/__init__.py b/python_repositories/adapters/__init__.py index eb0ef57..995d1ff 100644 --- a/python_repositories/adapters/__init__.py +++ b/python_repositories/adapters/__init__.py @@ -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", ] diff --git a/python_repositories/adapters/postgres_adapter.py b/python_repositories/adapters/postgres_adapter.py new file mode 100644 index 0000000..1098a91 --- /dev/null +++ b/python_repositories/adapters/postgres_adapter.py @@ -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) diff --git a/python_repositories/config/__init__.py b/python_repositories/config/__init__.py index 8bd2d81..1465415 100644 --- a/python_repositories/config/__init__.py +++ b/python_repositories/config/__init__.py @@ -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", ] diff --git a/python_repositories/config/postgres_config.py b/python_repositories/config/postgres_config.py new file mode 100644 index 0000000..1887439 --- /dev/null +++ b/python_repositories/config/postgres_config.py @@ -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, + ) diff --git a/python_repositories/examples/user_table_repository.py b/python_repositories/examples/user_table_repository.py new file mode 100644 index 0000000..06619f7 --- /dev/null +++ b/python_repositories/examples/user_table_repository.py @@ -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) diff --git a/python_repositories/interfaces/__init__.py b/python_repositories/interfaces/__init__.py index c643fa6..2135084 100644 --- a/python_repositories/interfaces/__init__.py +++ b/python_repositories/interfaces/__init__.py @@ -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", ] diff --git a/python_repositories/interfaces/table_repository_interface.py b/python_repositories/interfaces/table_repository_interface.py new file mode 100644 index 0000000..d3947ce --- /dev/null +++ b/python_repositories/interfaces/table_repository_interface.py @@ -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. + """ + ... diff --git a/tests/conftest.py b/tests/conftest.py index 09b99f1..c5ed56e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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", +) diff --git a/tests/integration/examples/conftest.py b/tests/integration/examples/conftest.py index e7f6555..b7813e3 100644 --- a/tests/integration/examples/conftest.py +++ b/tests/integration/examples/conftest.py @@ -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) diff --git a/tests/integration/examples/examples_test.py b/tests/integration/examples/examples_test.py index e7309bb..d12822e 100644 --- a/tests/integration/examples/examples_test.py +++ b/tests/integration/examples/examples_test.py @@ -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 diff --git a/tests/integration/postgres/_containers.py b/tests/integration/postgres/_containers.py new file mode 100644 index 0000000..e362df8 --- /dev/null +++ b/tests/integration/postgres/_containers.py @@ -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 diff --git a/tests/integration/postgres/conftest.py b/tests/integration/postgres/conftest.py new file mode 100644 index 0000000..ac01867 --- /dev/null +++ b/tests/integration/postgres/conftest.py @@ -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) diff --git a/tests/integration/postgres/postgres_adapter_test.py b/tests/integration/postgres/postgres_adapter_test.py new file mode 100644 index 0000000..abf61f0 --- /dev/null +++ b/tests/integration/postgres/postgres_adapter_test.py @@ -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") diff --git a/tests/unit/adapters_test.py b/tests/unit/adapters_test.py index 2b1bb90..164c8fe 100644 --- a/tests/unit/adapters_test.py +++ b/tests/unit/adapters_test.py @@ -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) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index ee89fb2..9160b52 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -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) diff --git a/tests/unit/connection_aware_adapter_test.py b/tests/unit/connection_aware_adapter_test.py index 1ea7ef2..0027b87 100644 --- a/tests/unit/connection_aware_adapter_test.py +++ b/tests/unit/connection_aware_adapter_test.py @@ -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 diff --git a/tests/unit/postgres_adapter_test.py b/tests/unit/postgres_adapter_test.py new file mode 100644 index 0000000..d28ccd5 --- /dev/null +++ b/tests/unit/postgres_adapter_test.py @@ -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") diff --git a/tests/unit/postgres_config_test.py b/tests/unit/postgres_config_test.py new file mode 100644 index 0000000..cf6cfc4 --- /dev/null +++ b/tests/unit/postgres_config_test.py @@ -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" diff --git a/tests/unit/table_repository_interface_test.py b/tests/unit/table_repository_interface_test.py new file mode 100644 index 0000000..3f0c083 --- /dev/null +++ b/tests/unit/table_repository_interface_test.py @@ -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) diff --git a/uv.lock b/uv.lock index 5b6d230..a0e4bf8 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 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" @@ -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"