diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a73249f6..08923a151 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -52,6 +52,28 @@ jobs: version: "0.11.28" enable-cache: false + - name: Verify committed universal lock + id: lock + shell: bash + run: | + set -euo pipefail + if uv lock --check; then + echo "stale=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + uv lock + echo "stale=true" >> "$GITHUB_OUTPUT" + exit 1 + + - name: Preserve resolver-generated lock candidate + if: failure() && steps.lock.outputs.stale == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: uv-lock-candidate-${{ github.sha }}-${{ github.run_attempt }} + path: uv.lock + if-no-files-found: error + retention-days: 7 + - name: Select pinned Rust toolchain run: | rustup toolchain install 1.97.1 --profile minimal diff --git a/CHANGELOG.d/2.28.0-postgres-sync-driver.md b/CHANGELOG.d/2.28.0-postgres-sync-driver.md new file mode 100644 index 000000000..c0a0c5dce --- /dev/null +++ b/CHANGELOG.d/2.28.0-postgres-sync-driver.md @@ -0,0 +1,13 @@ +## 2.28.0 — Commercial-safe synchronous PostgreSQL boundary + +- Replace the synchronous seed/admin/schema `psycopg2-binary` dependency with + a pinned `pg8000` adapter while keeping runtime persistence on `asyncpg`. +- Preserve generated-identifier quoting, SQLSTATE-specific schema/security + assertions, libpq-style DSN semantics that are representable by the adapter, + and fail closed on unsupported or ambiguous connection options. +- Reject boolean, non-finite, non-positive, and float-unrepresentable explicit + connection timeouts through the adapter validation contract instead of + leaking arithmetic conversion errors. +- Keep dependency-lock verification fail closed: a stale committed `uv.lock` + fails before frozen install/tests while the pinned resolver output is retained + as short-lived evidence for an exact, non-hand-edited lock update. diff --git a/backend/app/main.py b/backend/app/main.py index 122165990..5e9391a9a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -147,6 +147,7 @@ ) from backend.app.occupational_construct_ingestion import ( load_occupational_construct_assertions, + load_occupational_construct_evidence_status, ) from backend.app.occupational_construct_search import ( OccupationalConstructSearchError, diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 520277031..5076efc49 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -23,10 +23,10 @@ import asyncpg import jwt -import psycopg2 import pytest import redis +from lineageweave import postgres_sync as psycopg2 from lineageweave.http_client import HttpClientError, get_json, post_form from lineageweave.knowledge_graph import knowledge_graph_edges_for_post from lineageweave.post_summary import POST_SUMMARY_CONTRACT_VERSION @@ -217,6 +217,40 @@ / "migrations" / "0218_global_ask_public_verification.sql" ) +_TEPP_RECEIPT_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0217_analysis_run_tepp_receipt.sql" +) +_PROV_O_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0017_prov_o_standard_relations.sql" +) +_VOICE_TAXONOMY_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0235_voice_of_x_post_taxonomy.sql" +) +_ONTOLOGY_TRUTH_STATUS_MIGRATION = ( + Path(__file__).resolve().parents[2] / "migrations" / "0175_ontology_truth_status.sql" +) +_CONVERSATION_EVIDENCE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0233_source_conversation_turn_evidence.sql" +) +_SOURCE_POST_VOICE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0237_source_post_voice_combination.sql" +) +_OCCUPATIONAL_CONSTRUCT_ASSERTION_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0238_occupational_construct_assertion.sql" +) +_OCCUPATIONAL_CONSTRUCT_EXTRACTION_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0240_occupational_construct_extraction_run.sql" +) _GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -332,6 +366,7 @@ def seeded_db(demo_analyst_token): try: with conn.cursor() as cur: cur.execute(_MIGRATION_PATH.read_text()) + cur.execute(_PROV_O_MIGRATION.read_text()) cur.execute(_REGISTRY_MIGRATION.read_text()) cur.execute(_RETENTION_MIGRATION.read_text()) cur.execute(_RECONSTRUCTION_MIGRATION.read_text()) @@ -415,16 +450,23 @@ def seeded_db(demo_analyst_token): ) conn.autocommit = False cur.execute(_GLOBAL_ASK_KNOWLEDGE_CUTOFF_MIGRATION.read_text()) + cur.execute(_TEPP_RECEIPT_MIGRATION.read_text()) cur.execute(_GLOBAL_ASK_PUBLIC_VERIFICATION_MIGRATION.read_text()) cur.execute(_EVENT_OCCURRED_AT_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_AXIS_MIGRATION.read_text()) cur.execute(_CHANNEL_EVIDENCE_MIGRATION.read_text()) + cur.execute(_ONTOLOGY_TRUTH_STATUS_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_CROSS_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RECONSTRUCTION_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_UNEXPLAINED_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_EXPLAINED_SHARE_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_COORDINATES_MIGRATION.read_text()) + cur.execute(_CONVERSATION_EVIDENCE_MIGRATION.read_text()) + cur.execute(_VOICE_TAXONOMY_MIGRATION.read_text()) + cur.execute(_SOURCE_POST_VOICE_MIGRATION.read_text()) + cur.execute(_OCCUPATIONAL_CONSTRUCT_ASSERTION_MIGRATION.read_text()) + cur.execute(_OCCUPATIONAL_CONSTRUCT_EXTRACTION_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " diff --git a/docs/adr/0366-synchronous-postgresql-default-tls.md b/docs/adr/0366-synchronous-postgresql-default-tls.md new file mode 100644 index 000000000..a8a8fb16a --- /dev/null +++ b/docs/adr/0366-synchronous-postgresql-default-tls.md @@ -0,0 +1,48 @@ +# ADR 0366 — Verify synchronous PostgreSQL server identity by default + +**Decision status:** Proposed +**Date:** 2026-09-05 + +## Context + +LineageWeave's synchronous administrative and test adapter accepts PostgreSQL +URIs and translates their transport policy to pg8000. PostgreSQL documents +`sslmode=prefer` as a backwards-compatibility default that may reconnect in +plaintext and does not authenticate the server. That behavior is unsuitable as +an implicit product default for credentials in transit. Explicit DSN policy +must remain representable for controlled compatibility paths. + +## Decision + +A network DSN that omits `sslmode` uses `verify-full`: TLS is required, the +certificate chain is validated, and the requested hostname must match. It never +retries in plaintext. An explicit `sslmode=prefer` remains TLS-first with +plaintext fallback only after PostgreSQL's exact SSL-refusal response. +`disable`, `require`, `verify-ca`, and `verify-full` retain their documented +meanings. Unix-socket inference remains unavailable because the adapter cannot +recover libpq's socket selection from a hostless URI. + +## Consequences + +- Accidental network DSNs fail closed against untrusted or mismatched servers. +- Existing private-CA deployments must install their CA or state an explicit + reviewed compatibility mode; a silent downgrade is no longer possible. +- Explicit `prefer` remains weaker and must be chosen in the DSN rather than + inherited from an omission. + +## Alternatives considered + +### Keep implicit `prefer` + +Rejected because an SSL-refusing endpoint can cause credential-bearing tooling +to reconnect without encryption. + +### Default to `require` + +Rejected because encryption without certificate and hostname verification does +not authenticate the server. + +## References + +- PostgreSQL 18, *SSL Support* and *Database Connection Control Functions*. +- pg8000 1.31.5, `ssl_context` connection contract. diff --git a/lineageweave/optional_extra_collection.py b/lineageweave/optional_extra_collection.py index dd108932c..5d5bda122 100644 --- a/lineageweave/optional_extra_collection.py +++ b/lineageweave/optional_extra_collection.py @@ -2,7 +2,7 @@ OpenCode coverage-evidence runs in a networkless sandbox that supplies pytest and coverage but not LineageWeave's optional backend extras -(``asyncpg``, ``psycopg2``, ``redis``, ``fast_mlsirm``, ``numpy``). Hosted CI +(``asyncpg``, ``pg8000``, ``redis``, ``fast_mlsirm``, ``numpy``). Hosted CI installs those extras and collects every suite. This helper keeps collection from failing with ``ModuleNotFoundError`` when extras are absent, without skipping anything when they are present. @@ -17,7 +17,7 @@ OPTIONAL_EXTRA_MODULES: tuple[str, ...] = ( "asyncpg", - "psycopg2", + "pg8000", "redis", "fast_mlsirm", "numpy", @@ -28,7 +28,14 @@ def _imported_module_names(source: str) -> frozenset[str]: - """Return exact top-level module paths from syntactically valid imports.""" + """Return exact top-level module paths from syntactically valid imports. + + For ``from package import submodule`` both the package and candidate + submodule path are retained. The local-source resolver later decides + whether that candidate is a real repository module, which lets collection + tracing follow imports such as ``from lineageweave import postgres_sync`` + without mistaking ordinary imported attributes for modules. + """ try: tree = ast.parse(source) except (SyntaxError, ValueError): @@ -39,6 +46,12 @@ def _imported_module_names(source: str) -> frozenset[str]: imported.update(alias.name for alias in node.names) elif isinstance(node, ast.ImportFrom) and node.module: imported.add(node.module) + if node.level == 0: + imported.update( + f"{node.module}.{alias.name}" + for alias in node.names + if alias.name != "*" + ) return frozenset(imported) diff --git a/lineageweave/postgres_sync.py b/lineageweave/postgres_sync.py new file mode 100644 index 000000000..8785153a3 --- /dev/null +++ b/lineageweave/postgres_sync.py @@ -0,0 +1,414 @@ +"""Synchronous PostgreSQL adapter for admin, seed, and schema tooling. + +The application runtime uses ``asyncpg``. A few administrative and integration +paths still need a blocking DB-API connection, chiefly to create ephemeral test +databases and to run the synthetic seed. This module keeps that secondary +boundary provider-specific in one place and deliberately exposes only the +behaviour LineageWeave needs. + +Connection URIs are parsed explicitly because pg8000 accepts keyword arguments +rather than libpq DSN strings. Unsupported query options fail closed instead +of disappearing during the driver migration. Generated SQL identifiers are +quoted locally; values must continue to use DB-API parameters. Server errors +used as executable schema/security contracts are translated by SQLSTATE so the +tests retain their semantic assertions without depending on a driver taxonomy. +""" + +from __future__ import annotations + +import getpass +import math +import ssl +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any, Mapping +from urllib.parse import parse_qsl, unquote, urlsplit + +import pg8000.dbapi as _dbapi + + +class DatabaseError(_dbapi.DatabaseError): + """Base class for translated server errors carrying PostgreSQL SQLSTATE.""" + + +class OperationalError(_dbapi.InterfaceError): + """Connection/setup error used by reachability probes.""" + + +class NotNullViolation(DatabaseError): + """PostgreSQL SQLSTATE 23502: a mandatory column was omitted or nulled.""" + + +class UniqueViolation(DatabaseError): + """PostgreSQL SQLSTATE 23505: a uniqueness constraint rejected the statement.""" + + +class ForeignKeyViolation(DatabaseError): + """PostgreSQL SQLSTATE 23503: a foreign-key constraint rejected the statement.""" + + +class CheckViolation(DatabaseError): + """PostgreSQL SQLSTATE 23514: a CHECK constraint rejected the statement.""" + + +class ExclusionViolation(DatabaseError): + """PostgreSQL SQLSTATE 23P01: an exclusion constraint rejected the statement.""" + + +class InsufficientPrivilege(DatabaseError): + """PostgreSQL SQLSTATE 42501: the current role lacks the required privilege.""" + + +class RaiseException(DatabaseError): + """PostgreSQL SQLSTATE P0001: server-side ``RAISE EXCEPTION``.""" + + +errors = SimpleNamespace( + NotNullViolation=NotNullViolation, + UniqueViolation=UniqueViolation, + ForeignKeyViolation=ForeignKeyViolation, + CheckViolation=CheckViolation, + ExclusionViolation=ExclusionViolation, + InsufficientPrivilege=InsufficientPrivilege, + RaiseException=RaiseException, +) + + +def _quote_identifier(value: str) -> str: + if not isinstance(value, str) or not value: + raise ValueError("PostgreSQL identifier must be a non-empty string") + if "\x00" in value: + raise ValueError("PostgreSQL identifier must not contain NUL") + return '"' + value.replace('"', '""') + '"' + + +@dataclass(frozen=True) +class _Identifier: + value: str + + def __str__(self) -> str: + return _quote_identifier(self.value) + + +def _render_sql_composable(value: object) -> str: + """Render only the adapter's explicitly safe dynamic SQL fragment types. + + Raw strings are rejected because accepting them would turn ``SQL.format`` + into an injection-capable text interpolation API. Callers must wrap dynamic + database and role names with ``Identifier``; static SQL fragments may use + ``SQL`` explicitly. + """ + + if isinstance(value, (_Identifier, _SQL)): + return str(value) + raise TypeError("SQL interpolation requires sql.Identifier or sql.SQL") + + +class _SQL(str): + def format(self, *args: object, **kwargs: object) -> str: + """Interpolate only explicitly safe SQL composable fragments.""" + positional = tuple(_render_sql_composable(value) for value in args) + named = {key: _render_sql_composable(value) for key, value in kwargs.items()} + return str(self).format(*positional, **named) + + +sql = SimpleNamespace(SQL=_SQL, Identifier=_Identifier) + + +def _encryption_only_ssl_context() -> ssl.SSLContext: + """Create the non-verifying TLS context used by libpq prefer/require semantics.""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + +def _ssl_context_for_mode(mode: str) -> ssl.SSLContext | bool | None: + normalized = mode.lower() + if normalized == "disable": + return False + if normalized == "prefer": + return _encryption_only_ssl_context() + if normalized == "require": + return _encryption_only_ssl_context() + if normalized == "verify-ca": + context = ssl.create_default_context() + context.check_hostname = False + return context + if normalized == "verify-full": + return ssl.create_default_context() + raise ValueError(f"unsupported PostgreSQL sslmode: {mode}") + + +def connection_kwargs_from_dsn( + dsn: str, + *, + connect_timeout: float | int | None = None, +) -> dict[str, Any]: + """Translate one PostgreSQL URI into explicit pg8000 connection arguments. + + ``connect_timeout`` passed by the caller takes precedence over the URI's + ``connect_timeout`` query value, matching the old call sites. Query + options are allow-listed because silently discarding a libpq option could + weaken transport security or alter session semantics. Duplicate options + are rejected because collapsing conflicting values would make the selected + connection policy depend on parser ordering rather than explicit intent. + + Libpq-style URIs may omit the username and then use the operating-system + account. Several repository PostgreSQL test fixtures rely on that default, + so the adapter resolves it explicitly before entering pg8000's required + ``user`` argument rather than changing fixture connection semantics. + + A URI without a host is different: libpq interprets it as a local Unix- + domain socket connection, while pg8000's omitted ``host`` defaults to + localhost TCP. The URI alone does not identify which Unix socket path + libpq would have selected, so this adapter rejects that ambiguous transport + rather than silently changing peer-authentication and network semantics. + """ + + parsed = urlsplit(dsn) + if parsed.scheme not in {"postgres", "postgresql"}: + raise ValueError("PostgreSQL DSN must use postgres:// or postgresql://") + if parsed.fragment: + raise ValueError("PostgreSQL DSN must not include a fragment") + if not parsed.path or parsed.path == "/": + raise ValueError("PostgreSQL DSN must include a database name") + if parsed.hostname is None: + raise ValueError("PostgreSQL DSN must include an explicit host") + + user = unquote(parsed.username) if parsed.username is not None else getpass.getuser() + if not user: + raise ValueError("PostgreSQL user could not be resolved") + + try: + port = parsed.port + except ValueError as exc: + raise ValueError("PostgreSQL port must be between 1 and 65535") from exc + if port is not None and not 1 <= port <= 65535: + raise ValueError("PostgreSQL port must be between 1 and 65535") + + kwargs: dict[str, Any] = { + "user": user, + "host": parsed.hostname, + "port": 5432 if port is None else port, + "database": unquote(parsed.path.lstrip("/")), + } + if parsed.password is not None: + kwargs["password"] = unquote(parsed.password) + + startup_params: dict[str, str] = {} + query_items = parse_qsl(parsed.query, keep_blank_values=True) + seen_query_options: set[str] = set() + for option_name, _ in query_items: + if option_name in seen_query_options: + raise ValueError(f"duplicate PostgreSQL DSN option: {option_name}") + seen_query_options.add(option_name) + query = dict(query_items) + supported = {"connect_timeout", "application_name", "sslmode", "options"} + unknown = sorted(set(query) - supported) + if unknown: + raise ValueError(f"unsupported PostgreSQL DSN option: {unknown[0]}") + + timeout_value = connect_timeout + if timeout_value is None and "connect_timeout" in query: + try: + timeout_value = float(query["connect_timeout"]) + except ValueError as exc: + raise ValueError("PostgreSQL connect_timeout must be numeric") from exc + if timeout_value is not None: + if isinstance(timeout_value, bool): + raise TypeError("PostgreSQL connect_timeout must be a real number") + try: + timeout = float(timeout_value) + except OverflowError as exc: + raise ValueError("PostgreSQL connect_timeout must be finite positive") from exc + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("PostgreSQL connect_timeout must be finite positive") + kwargs["timeout"] = timeout + + if "application_name" in query: + if not query["application_name"]: + raise ValueError("PostgreSQL application_name must not be empty") + kwargs["application_name"] = query["application_name"] + kwargs["ssl_context"] = _ssl_context_for_mode(query.get("sslmode", "verify-full")) + if "options" in query: + startup_params["options"] = query["options"] + if startup_params: + kwargs["startup_params"] = startup_params + return kwargs + + +def _sqlstate(error: BaseException) -> str | None: + for arg in getattr(error, "args", ()): + if isinstance(arg, Mapping): + state = arg.get("C") + if isinstance(state, str): + return state + return None + + +def _translated_error(error: BaseException) -> BaseException: + state = _sqlstate(error) + translated_type = { + "23502": NotNullViolation, + "23503": ForeignKeyViolation, + "23505": UniqueViolation, + "23514": CheckViolation, + "23P01": ExclusionViolation, + "42501": InsufficientPrivilege, + "P0001": RaiseException, + }.get(state) + if translated_type is None: + return error + return translated_type(*getattr(error, "args", ())) + + +def _server_refused_ssl(error: BaseException) -> bool: + """Return whether pg8000 reported PostgreSQL's explicit SSL refusal byte.""" + return any( + isinstance(arg, str) and arg.strip().lower() == "server refuses ssl" + for arg in getattr(error, "args", ()) + ) + + +class Cursor: + """Thin cursor wrapper that preserves the SQLSTATE-specific test contract.""" + + def __init__(self, inner: Any) -> None: + self._inner = inner + + def execute(self, operation: str, args: object | None = None, **kwargs: object) -> Any: + """Execute one statement while translating supported PostgreSQL SQLSTATEs.""" + try: + if args is None: + return self._inner.execute(operation, **kwargs) + return self._inner.execute(operation, args, **kwargs) + except _dbapi.DatabaseError as exc: + translated = _translated_error(exc) + if translated is exc: + raise + raise translated from exc + + def executemany(self, operation: str, param_sets: object) -> Any: + """Execute a statement for many parameter sets with SQLSTATE translation.""" + try: + return self._inner.executemany(operation, param_sets) + except _dbapi.DatabaseError as exc: + translated = _translated_error(exc) + if translated is exc: + raise + raise translated from exc + + def fetchone(self) -> tuple[Any, ...] | None: + """Return one DB-API row with the tuple shape existing callers expect.""" + row = self._inner.fetchone() + return None if row is None else tuple(row) + + def fetchall(self) -> list[tuple[Any, ...]]: + """Return all DB-API rows as tuples rather than pg8000's mutable lists.""" + return [tuple(row) for row in self._inner.fetchall()] + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + def __enter__(self) -> "Cursor": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> object: + self._inner.close() + return False + + +class Connection: + """Connection proxy that keeps pg8000 isolated from repository call sites.""" + + def __init__(self, inner: Any, *, database: str) -> None: + self._inner = inner + self.info = SimpleNamespace(dbname=database) + + @property + def autocommit(self) -> bool: + """Return the native connection's current autocommit setting.""" + return bool(self._inner.autocommit) + + @autocommit.setter + def autocommit(self, value: bool) -> None: + """Set autocommit on the isolated native pg8000 connection.""" + self._inner.autocommit = value + + def cursor(self, *args: object, **kwargs: object) -> Cursor: + """Create a cursor wrapped by the adapter's SQLSTATE boundary.""" + return Cursor(self._inner.cursor(*args, **kwargs)) + + def commit(self) -> None: + """Commit while preserving SQLSTATE-specific adapter error types.""" + try: + self._inner.commit() + except _dbapi.DatabaseError as exc: + translated = _translated_error(exc) + if translated is exc: + raise + raise translated from exc + + def rollback(self) -> None: + """Roll back while preserving SQLSTATE-specific adapter error types.""" + try: + self._inner.rollback() + except _dbapi.DatabaseError as exc: + translated = _translated_error(exc) + if translated is exc: + raise + raise translated from exc + + def close(self) -> None: + """Close the native connection, tolerating repeated cleanup calls.""" + try: + self._inner.close() + except _dbapi.InterfaceError as exc: + if "connection is closed" not in str(exc).lower(): + raise + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) + + def __enter__(self) -> "Connection": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> object: + if exc_type is None: + self.commit() + else: + self.rollback() + return False + + +def connect(dsn: str, *, connect_timeout: float | int | None = None) -> Connection: + """Open the repository's synchronous pg8000 connection boundary. + + pg8000 reports transport failures as ``InterfaceError`` and PostgreSQL + startup refusals such as authentication/database errors as ``DatabaseError``. + Both occur before a connection exists and therefore preserve the historical + ``OperationalError`` contract used by reachability probes. A DSN without + ``sslmode`` uses the product's fail-closed ``verify-full`` policy. Explicit + ``sslmode=prefer`` retains TLS-first behavior and falls back to plaintext + only when PostgreSQL explicitly refuses SSL. + """ + + parsed = urlsplit(dsn) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + sslmode = query.get("sslmode", "verify-full").lower() + kwargs = connection_kwargs_from_dsn(dsn, connect_timeout=connect_timeout) + try: + native = _dbapi.connect(**kwargs) + except _dbapi.InterfaceError as exc: + if sslmode != "prefer" or not _server_refused_ssl(exc): + raise OperationalError(*getattr(exc, "args", ())) from exc + fallback_kwargs = dict(kwargs) + fallback_kwargs["ssl_context"] = False + try: + native = _dbapi.connect(**fallback_kwargs) + except (_dbapi.InterfaceError, _dbapi.DatabaseError) as fallback_exc: + raise OperationalError(*getattr(fallback_exc, "args", ())) from fallback_exc + except _dbapi.DatabaseError as exc: + raise OperationalError(*getattr(exc, "args", ())) from exc + return Connection(native, database=str(kwargs["database"])) diff --git a/pyproject.toml b/pyproject.toml index 7744aef87..d821370e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,9 @@ build-backend = "setuptools.build_meta" [project.optional-dependencies] dev = [ - "psycopg2-binary>=2.9.12", + # Pure-Python BSD-3-Clause DB-API driver used only by the synchronous + # seed/admin/schema tooling boundary. Runtime persistence remains asyncpg. + "pg8000==1.31.5", "coverage>=7.6", "pyjwt[crypto]>=2.8.0", "pytest>=8.0", @@ -67,4 +69,4 @@ include = ["lineageweave*", "backend*"] [tool.pytest.ini_options] testpaths = ["tests", "backend/tests"] -pythonpath = ["."] +pythonpath = ["."] \ No newline at end of file diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index ca1718751..51ab10fde 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -29,8 +29,7 @@ # Allow `python3 scripts/seed_demo_data.py` from a checkout without install. sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -import psycopg2 - +from lineageweave import postgres_sync as psycopg2 from lineageweave.http_client import get_json, get_json_list, post_form from lineageweave.post_summary import ACTOR_TYPE_PERSON, POST_SUMMARY_CONTRACT_VERSION from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable diff --git a/tests/test_analysis_run_authorization.py b/tests/test_analysis_run_authorization.py index 8bfe62642..5404b1723 100644 --- a/tests/test_analysis_run_authorization.py +++ b/tests/test_analysis_run_authorization.py @@ -7,10 +7,10 @@ from pathlib import Path from urllib.parse import urlsplit, urlunsplit -import psycopg2 import pytest -from psycopg2 import sql +from lineageweave import postgres_sync as sync_postgres +from lineageweave.postgres_sync import sql from backend.app.analysis_run_ingestion import ( _COUNTS_BY_RUN_SQL, _RUN_DETAIL_SQL, @@ -40,9 +40,9 @@ def test_visible_run_sql_is_parameterized_literals() -> None: def _postgres_available() -> bool: """Return whether the configured administrator DSN is reachable.""" try: - psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close() + sync_postgres.connect(_ADMIN_DSN, connect_timeout=2).close() return True - except psycopg2.OperationalError: + except sync_postgres.OperationalError: return False @@ -58,14 +58,14 @@ def authz_db(): if not _postgres_available(): pytest.skip("a reachable PostgreSQL administrator DSN is required") database_name = f"lineageweave_authz_{uuid.uuid4().hex[:12]}" - admin_connection = psycopg2.connect(_ADMIN_DSN) + admin_connection = sync_postgres.connect(_ADMIN_DSN) admin_connection.autocommit = True with admin_connection.cursor() as cursor: cursor.execute( sql.SQL("create database {}").format(sql.Identifier(database_name)) ) try: - connection = psycopg2.connect(_database_dsn(database_name)) + connection = sync_postgres.connect(_database_dsn(database_name)) try: connection.autocommit = True with connection.cursor() as cursor: diff --git a/tests/test_analysis_run_reconstruction_schema.py b/tests/test_analysis_run_reconstruction_schema.py index 30a2b6579..a965afe8d 100644 --- a/tests/test_analysis_run_reconstruction_schema.py +++ b/tests/test_analysis_run_reconstruction_schema.py @@ -10,6 +10,9 @@ import pytest +from lineageweave import postgres_sync as sync_postgres +from lineageweave.postgres_sync import sql + _ROOT = Path(__file__).resolve().parents[1] _INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" _REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" @@ -80,11 +83,9 @@ def test_reconstruction_migration_is_normalized_and_wired() -> None: def _postgres_available() -> bool: """Return whether the configured administrator DSN is reachable.""" try: - import psycopg2 - - psycopg2.connect(_ADMIN_DSN, connect_timeout=2).close() + sync_postgres.connect(_ADMIN_DSN, connect_timeout=2).close() return True - except Exception: + except sync_postgres.OperationalError: return False @@ -99,17 +100,18 @@ def reconstruction_db(): """Yield a throwaway registry+reconstruction database.""" if not _postgres_available(): pytest.skip("a reachable PostgreSQL administrator DSN is required") - import psycopg2 database_name = f"lineageweave_recon_{uuid.uuid4().hex[:12]}" - admin = psycopg2.connect(_ADMIN_DSN) + admin = sync_postgres.connect(_ADMIN_DSN) admin.autocommit = True try: with admin.cursor() as cursor: - cursor.execute(f'create database "{database_name}"') + cursor.execute( + sql.SQL("create database {}").format(sql.Identifier(database_name)) + ) finally: admin.close() - conn = psycopg2.connect(_database_dsn(database_name)) + conn = sync_postgres.connect(_database_dsn(database_name)) conn.autocommit = True try: with conn.cursor() as cursor: @@ -120,7 +122,7 @@ def reconstruction_db(): yield conn finally: conn.close() - admin = psycopg2.connect(_ADMIN_DSN) + admin = sync_postgres.connect(_ADMIN_DSN) admin.autocommit = True try: with admin.cursor() as cursor: @@ -129,7 +131,9 @@ def reconstruction_db(): "where datname = %s and pid <> pg_backend_pid()", (database_name,), ) - cursor.execute(f'drop database "{database_name}"') + cursor.execute( + sql.SQL("drop database {}").format(sql.Identifier(database_name)) + ) finally: admin.close() diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index 4161f2452..e3612616e 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -9,10 +9,10 @@ from pathlib import Path from urllib.parse import urlsplit, urlunsplit -import psycopg2 -import psycopg2.errors import pytest -from psycopg2 import sql + +from lineageweave import postgres_sync as psycopg2 +from lineageweave.postgres_sync import sql _ROOT = Path(__file__).resolve().parents[1] _INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" diff --git a/tests/test_optional_extra_collection.py b/tests/test_optional_extra_collection.py index 252980b38..772f3cc2e 100644 --- a/tests/test_optional_extra_collection.py +++ b/tests/test_optional_extra_collection.py @@ -17,8 +17,8 @@ def test_missing_optional_extra_modules_returns_absent_names() -> None: """Names whose find_spec is None are reported; present names are not.""" def fake_find_spec(name: str) -> object | None: - """Model one unavailable optional module and one installed module.""" - if name == "asyncpg": + """Model one unavailable optional module and installed siblings.""" + if name == "pg8000": return None return object() @@ -26,7 +26,7 @@ def fake_find_spec(name: str) -> object | None: "lineageweave.optional_extra_collection.importlib.util.find_spec", side_effect=fake_find_spec, ): - assert missing_optional_extra_modules(("asyncpg", "redis")) == ("asyncpg",) + assert missing_optional_extra_modules(("pg8000", "redis")) == ("pg8000",) def test_collection_path_does_not_skip_when_no_extras_are_missing( @@ -57,11 +57,16 @@ def test_collection_path_skips_only_backend_tests_with_transitive_missing_import def test_collection_path_skips_files_that_import_a_missing_extra( tmp_path: Path, ) -> None: - """A test that imports fast_mlsirm is ignored only when that extra is absent.""" - path = tmp_path / "test_post_evaluation.py" - path.write_text("from fast_mlsirm import LLMJudgeResult\n", encoding="utf-8") - assert collection_path_requires_missing_extras(path, ("fast_mlsirm",)) is True - assert collection_path_requires_missing_extras(path, ("asyncpg",)) is False + """Direct optional imports are skipped only when that exact module is absent.""" + psychometrics = tmp_path / "test_post_evaluation.py" + psychometrics.write_text("from fast_mlsirm import LLMJudgeResult\n", encoding="utf-8") + assert collection_path_requires_missing_extras(psychometrics, ("fast_mlsirm",)) is True + assert collection_path_requires_missing_extras(psychometrics, ("asyncpg",)) is False + + postgres = tmp_path / "test_postgres_sync.py" + postgres.write_text("import pg8000.dbapi\n", encoding="utf-8") + assert collection_path_requires_missing_extras(postgres, ("pg8000",)) is True + assert collection_path_requires_missing_extras(postgres, ("asyncpg",)) is False def test_collection_path_does_not_match_comments_or_import_prefixes( @@ -121,11 +126,29 @@ def test_collection_path_skips_known_transitive_optional_importers( seed.write_text("from scripts.seed_demo_data import seed\n", encoding="utf-8") assert collection_path_requires_missing_extras(seed, ("redis",)) is False + sync_postgres = tmp_path / "test_sync_postgres.py" + sync_postgres.write_text( + "from lineageweave.postgres_sync import connect\n", + encoding="utf-8", + ) + assert collection_path_requires_missing_extras(sync_postgres, ("pg8000",)) is True + + +def test_collection_path_tracks_submodule_imported_from_package(tmp_path: Path) -> None: + """Package-style submodule imports still expose their transitive optional driver.""" + sync_postgres = tmp_path / "test_sync_postgres_package_import.py" + sync_postgres.write_text( + "from lineageweave import postgres_sync as sync_postgres\n", + encoding="utf-8", + ) + + assert collection_path_requires_missing_extras(sync_postgres, ("pg8000",)) is True + def test_helper_test_module_is_never_ignored(tmp_path: Path) -> None: """The collection-helper tests must run in the sandbox that lacks extras.""" path = tmp_path / "test_optional_extra_collection.py" - path.write_text("import asyncpg\n", encoding="utf-8") + path.write_text("import pg8000\n", encoding="utf-8") assert collection_path_requires_missing_extras(path, OPTIONAL_EXTRA_MODULES) is False diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index 7252fb8e8..80a3ce213 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -15,8 +15,6 @@ import uuid import asyncpg -import psycopg2 -from psycopg2 import sql import pytest from backend.app.keyman_ingestion import ingest_post_keymen @@ -27,6 +25,8 @@ related_for_start, visible_mention_post_ids, ) +from lineageweave import postgres_sync as psycopg2 +from lineageweave.postgres_sync import sql from backend.app import post_summary_ingestion as summary_ingestion from backend.app.post_summary_ingestion import ( fetch_persisted_summary, diff --git a/tests/test_postgres_sync_adr_number_contract.py b/tests/test_postgres_sync_adr_number_contract.py new file mode 100644 index 000000000..de5b234f4 --- /dev/null +++ b/tests/test_postgres_sync_adr_number_contract.py @@ -0,0 +1,21 @@ +"""Govern the synchronous PostgreSQL TLS ADR identity and proposal status.""" + +from __future__ import annotations + +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_EXPECTED_ADR = _REPO_ROOT / "docs" / "adr" / "0366-synchronous-postgresql-default-tls.md" +_COLLIDING_ADR = _REPO_ROOT / "docs" / "adr" / "0363-synchronous-postgresql-default-tls.md" + + +def test_postgres_sync_tls_adr_uses_unclaimed_number_and_stays_proposed() -> None: + """The TLS decision must not collide with ADR 0363 or pre-accept itself.""" + assert _EXPECTED_ADR.exists() + assert not _COLLIDING_ADR.exists() + + text = _EXPECTED_ADR.read_text(encoding="utf-8") + assert text.startswith("# ADR 0366 — Verify synchronous PostgreSQL server identity by default\n") + assert "**Decision status:** Proposed" in text + assert "**Decision status:** Accepted" not in text diff --git a/tests/test_postgres_sync_driver_contract.py b/tests/test_postgres_sync_driver_contract.py new file mode 100644 index 000000000..5f7500c15 --- /dev/null +++ b/tests/test_postgres_sync_driver_contract.py @@ -0,0 +1,309 @@ +"""Commercial-license and compatibility contract for synchronous PostgreSQL tooling. + +LineageWeave's runtime uses asyncpg, but seed/admin/schema tooling still needs a +small synchronous DB-API boundary. This contract prevents that boundary from +silently reintroducing the former psycopg2-binary dependency and locks the +behaviour that generated database/role identifiers, PostgreSQL DSNs, and +constraint/security assertions rely on. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from types import SimpleNamespace + +import pg8000.dbapi as _dbapi +import pytest + +from lineageweave.postgres_sync import ( + Connection, + Cursor, + DatabaseError, + OperationalError, + _translated_error, + connect, + connection_kwargs_from_dsn, + errors, + sql, +) + + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_FORBIDDEN_IMPORT = re.compile(r"(?m)^\s*(?:import\s+psycopg2\b|from\s+psycopg2\b)") +_UPLOAD_ARTIFACT_SHA = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" + + +def test_sync_postgres_driver_is_not_psycopg2() -> None: + """Executable Python and dependency metadata must not retain psycopg2.""" + roots = ( + _REPOSITORY_ROOT / "lineageweave", + _REPOSITORY_ROOT / "scripts", + _REPOSITORY_ROOT / "tests", + _REPOSITORY_ROOT / "backend", + ) + offenders: list[str] = [] + for root in roots: + for path in root.rglob("*.py"): + if path == Path(__file__): + continue + if _FORBIDDEN_IMPORT.search(path.read_text(encoding="utf-8")): + offenders.append(str(path.relative_to(_REPOSITORY_ROOT))) + + pyproject = (_REPOSITORY_ROOT / "pyproject.toml").read_text(encoding="utf-8") + assert "psycopg2-binary" not in pyproject + assert "pg8000" in pyproject + assert offenders == [] + + +def test_lockfile_matches_synchronous_postgres_driver_contract() -> None: + """The reproducible environment must resolve the same driver as pyproject.""" + lockfile = (_REPOSITORY_ROOT / "uv.lock").read_text(encoding="utf-8") + assert 'name = "psycopg2"' not in lockfile + assert 'name = "psycopg2-binary"' not in lockfile + assert 'name = "pg8000"' in lockfile + + +def test_ci_preserves_resolver_output_when_committed_lock_is_stale() -> None: + """A stale frozen lock must fail closed while preserving a valid resolver candidate.""" + workflow = (_REPOSITORY_ROOT / ".github" / "workflows" / "tests.yml").read_text( + encoding="utf-8" + ) + + lock_check = workflow.index("uv lock --check") + frozen_sync = workflow.index("uv sync --frozen --extra dev --extra backend") + assert lock_check < frozen_sync + assert "set -euo pipefail" in workflow + assert f"actions/upload-artifact@{_UPLOAD_ARTIFACT_SHA}" in workflow + assert "uv-lock-candidate-${{ github.sha }}" in workflow + assert "if-no-files-found: error" in workflow + + +def test_generated_identifier_quoting_is_postgresql_safe() -> None: + """Generated database and role names stay identifiers, never SQL text.""" + statement = sql.SQL("create database {}").format(sql.Identifier('tenant"archive')) + assert statement == 'create database "tenant""archive"' + + +def test_cursor_context_manager_closes_a_dbapi_cursor_without_native_context_support() -> None: + """The compatibility wrapper owns close even when pg8000 has no cursor context manager.""" + + class NativeCursor: + closed = False + + def close(self) -> None: + self.closed = True + + native = NativeCursor() + with Cursor(native) as cursor: + assert cursor._inner is native + + assert native.closed is True + + +def test_cursor_rows_and_connection_info_preserve_existing_tooling_shapes(monkeypatch) -> None: + """The adapter keeps tuple rows and the database-name metadata callers consume.""" + + class NativeCursor: + def fetchone(self) -> list[object]: + return ["one", 1] + + def fetchall(self) -> list[list[object]]: + return [["two", 2]] + + cursor = Cursor(NativeCursor()) + assert cursor.fetchone() == ("one", 1) + assert cursor.fetchall() == [("two", 2)] + + native_connection = SimpleNamespace(autocommit=False) + monkeypatch.setattr( + "lineageweave.postgres_sync._dbapi.connect", + lambda **_kwargs: native_connection, + ) + connection = connect("postgresql://alice:secret@db.example/archive") + assert connection.info.dbname == "archive" + + +def test_connection_context_manages_transaction_without_closing() -> None: + """The compatibility context commits or rolls back but leaves lifetime to its owner.""" + + class NativeConnection: + autocommit = False + commits = 0 + rollbacks = 0 + closes = 0 + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + self.rollbacks += 1 + + def close(self) -> None: + self.closes += 1 + + native = NativeConnection() + connection = Connection(native, database="archive") + with connection: + pass + assert (native.commits, native.rollbacks, native.closes) == (1, 0, 0) + + def fail_inside_transaction() -> None: + with connection: + raise RuntimeError("synthetic") + + pytest.raises(RuntimeError, fail_inside_transaction) + assert (native.commits, native.rollbacks, native.closes) == (1, 1, 0) + + +def test_connection_close_is_idempotent_for_pg8000_cleanup() -> None: + """Nested cleanup helpers may safely close one connection more than once.""" + + class NativeConnection: + autocommit = False + closed = False + + def close(self) -> None: + if self.closed: + raise _dbapi.InterfaceError("connection is closed") + self.closed = True + + connection = Connection(NativeConnection(), database="archive") + connection.close() + connection.close() + + +def test_generated_sql_rejects_raw_interpolation() -> None: + """Dynamic SQL fragments must use an explicit safe composable wrapper.""" + with pytest.raises(TypeError, match="SQL interpolation requires"): + sql.SQL("create database {}").format('tenant"; drop database archive; --') + + +def test_dsn_query_options_are_mapped_without_silent_loss() -> None: + """Supported libpq-style DSN options survive the pg8000 adapter boundary.""" + kwargs = connection_kwargs_from_dsn( + "postgresql://alice:p%40ss@db.example:6543/archive" + "?connect_timeout=7&application_name=lineageweave-test&sslmode=disable" + ) + + assert kwargs["user"] == "alice" + assert kwargs["password"] == "p@ss" + assert kwargs["host"] == "db.example" + assert kwargs["port"] == 6543 + assert kwargs["database"] == "archive" + assert kwargs["timeout"] == 7.0 + assert kwargs["application_name"] == "lineageweave-test" + assert kwargs["ssl_context"] is False + + +def test_explicit_zero_dsn_port_fails_closed() -> None: + """An explicit port zero must not silently become PostgreSQL's default port.""" + with pytest.raises(ValueError, match="port"): + connection_kwargs_from_dsn( + "postgresql://alice:secret@db.example:0/archive" + ) + + +def test_hostless_dsn_fails_closed_instead_of_switching_to_tcp() -> None: + """A libpq Unix-socket DSN must not silently become localhost TCP.""" + with pytest.raises(ValueError, match="host"): + connection_kwargs_from_dsn("postgresql:///archive") + + +@pytest.mark.parametrize("timeout_value", ("nan", "inf", "-inf")) +def test_dsn_connect_timeout_must_be_finite(timeout_value: str) -> None: + """Non-finite timeouts must not disable or destabilize the network deadline.""" + with pytest.raises(ValueError, match="finite positive"): + connection_kwargs_from_dsn( + f"postgresql://alice:secret@db.example/archive?connect_timeout={timeout_value}" + ) + + +def test_explicit_connect_timeout_rejects_boolean_values() -> None: + """Boolean values must not become accidental one-second network deadlines.""" + with pytest.raises(TypeError, match="real number"): + connection_kwargs_from_dsn( + "postgresql://alice:secret@db.example/archive", + connect_timeout=True, + ) + + +def test_explicit_connect_timeout_rejects_unrepresentable_integer() -> None: + """An integer too large for float conversion must fail through the validation contract.""" + with pytest.raises(ValueError, match="finite positive"): + connection_kwargs_from_dsn( + "postgresql://alice:secret@db.example/archive", + connect_timeout=10**10000, + ) + + +def test_dsn_without_user_preserves_libpq_os_user_default(monkeypatch) -> None: + """Existing admin DSNs without a username still use the local OS account.""" + monkeypatch.setattr("lineageweave.postgres_sync.getpass.getuser", lambda: "ci-runner") + + kwargs = connection_kwargs_from_dsn("postgresql://localhost/postgres") + + assert kwargs["user"] == "ci-runner" + assert kwargs["host"] == "localhost" + assert kwargs["database"] == "postgres" + + +def test_unknown_dsn_query_option_fails_closed() -> None: + """A connection option must never disappear merely because drivers differ.""" + with pytest.raises(ValueError, match="unsupported PostgreSQL DSN option"): + connection_kwargs_from_dsn( + "postgresql://alice:secret@db.example/archive?target_session_attrs=read-write" + ) + + +def test_dsn_fragment_fails_closed_instead_of_being_silently_discarded() -> None: + """URI fragments are outside libpq's connection grammar and must not disappear.""" + with pytest.raises(ValueError, match="must not include a fragment"): + connection_kwargs_from_dsn( + "postgresql://alice:secret@db.example/archive#sslmode=require" + ) + + +def test_duplicate_dsn_query_option_fails_closed() -> None: + """Conflicting duplicate options must not be collapsed by query parsing.""" + with pytest.raises(ValueError, match="duplicate PostgreSQL DSN option: sslmode"): + connection_kwargs_from_dsn( + "postgresql://alice:secret@db.example/archive?sslmode=require&sslmode=disable" + ) + + +def test_connect_translates_server_startup_failure_to_operational_error(monkeypatch) -> None: + """Server-side startup refusal must preserve the old reachability-probe contract.""" + failure = DatabaseError({"C": "28P01", "M": "password authentication failed"}) + + def fail_connect(**_: object) -> None: + raise failure + + monkeypatch.setattr("lineageweave.postgres_sync._dbapi.connect", fail_connect) + + with pytest.raises(OperationalError) as raised: + connect("postgresql://alice:secret@db.example/archive") + + assert raised.value.args == failure.args + assert raised.value.__cause__ is failure + + +@pytest.mark.parametrize( + ("sqlstate", "expected_type"), + ( + ("23502", errors.NotNullViolation), + ("23503", errors.ForeignKeyViolation), + ("23505", errors.UniqueViolation), + ("23514", errors.CheckViolation), + ("23P01", errors.ExclusionViolation), + ("42501", errors.InsufficientPrivilege), + ("P0001", errors.RaiseException), + ), +) +def test_schema_fixture_sqlstates_keep_typed_error_contract( + sqlstate: str, + expected_type: type[BaseException], +) -> None: + """Migrated tests still distinguish integrity, privilege, and trigger failures.""" + translated = _translated_error(DatabaseError({"C": sqlstate, "M": "synthetic"})) + assert isinstance(translated, expected_type) diff --git a/tests/test_postgres_sync_review_regressions.py b/tests/test_postgres_sync_review_regressions.py new file mode 100644 index 000000000..6db2fd7a9 --- /dev/null +++ b/tests/test_postgres_sync_review_regressions.py @@ -0,0 +1,81 @@ +"""Regression contracts for synchronous PostgreSQL review findings.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pg8000.dbapi as _dbapi +import pytest + +from lineageweave.postgres_sync import Connection, errors + + +_ROOT = Path(__file__).resolve().parents[1] +_WORKFLOW = _ROOT / ".github" / "workflows" / "tests.yml" + + +def test_lock_candidate_artifact_is_unique_per_workflow_attempt_and_review_durable() -> None: + """Resolver evidence must survive review and never collide across rerun attempts.""" + workflow = _WORKFLOW.read_text(encoding="utf-8") + + assert "uv-lock-candidate-${{ github.sha }}-${{ github.run_attempt }}" in workflow + retention_match = re.search( + r"name: uv-lock-candidate-\$\{\{ github\.sha \}\}-\$\{\{ github\.run_attempt \}\}" + r"[\s\S]*?retention-days:\s*(\d+)", + workflow, + ) + assert retention_match is not None + assert int(retention_match.group(1)) >= 7 + + +@pytest.mark.parametrize( + ("method_name", "sqlstate", "expected_type"), + ( + ("commit", "23505", errors.UniqueViolation), + ("rollback", "23503", errors.ForeignKeyViolation), + ), +) +def test_connection_transaction_methods_translate_database_errors( + method_name: str, + sqlstate: str, + expected_type: type[BaseException], +) -> None: + """Commit and rollback retain SQLSTATE-specific compatibility at the adapter boundary.""" + + class NativeConnection: + autocommit = False + + def commit(self) -> None: + raise _dbapi.DatabaseError({"C": sqlstate, "M": "synthetic transaction failure"}) + + def rollback(self) -> None: + raise _dbapi.DatabaseError({"C": sqlstate, "M": "synthetic transaction failure"}) + + connection = Connection(NativeConnection(), database="archive") + + with pytest.raises(expected_type): + getattr(connection, method_name)() + + +def test_connection_context_uses_translating_transaction_methods() -> None: + """Context-manager completion must not bypass transaction error translation.""" + + class NativeConnection: + autocommit = False + + def commit(self) -> None: + raise _dbapi.DatabaseError({"C": "23505", "M": "deferred unique violation"}) + + def rollback(self) -> None: + raise _dbapi.DatabaseError({"C": "23503", "M": "deferred foreign-key violation"}) + + connection = Connection(NativeConnection(), database="archive") + + with pytest.raises(errors.UniqueViolation): + with connection: + pass + + with pytest.raises(errors.ForeignKeyViolation): + with connection: + raise RuntimeError("force rollback") diff --git a/tests/test_postgres_sync_sslmode_contract.py b/tests/test_postgres_sync_sslmode_contract.py new file mode 100644 index 000000000..f790ed24b --- /dev/null +++ b/tests/test_postgres_sync_sslmode_contract.py @@ -0,0 +1,104 @@ +"""Transport-order contract for the synchronous PostgreSQL compatibility boundary.""" + +from __future__ import annotations + +import ssl +from types import SimpleNamespace + +import pg8000.dbapi as _dbapi +import pytest + +from lineageweave.postgres_sync import OperationalError, connect + + +def test_libpq_prefer_semantics_attempt_tls_before_plaintext_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Explicit prefer must try encrypted transport before server-refusal fallback.""" + attempts: list[object] = [] + native_connection = SimpleNamespace(autocommit=False) + + def fake_connect(**kwargs: object) -> object: + ssl_context = kwargs["ssl_context"] + attempts.append(ssl_context) + if len(attempts) == 1: + assert isinstance(ssl_context, ssl.SSLContext) + assert ssl_context.check_hostname is False + assert ssl_context.verify_mode == ssl.CERT_NONE + raise _dbapi.InterfaceError("Server refuses SSL") + assert ssl_context is False + return native_connection + + monkeypatch.setattr("lineageweave.postgres_sync._dbapi.connect", fake_connect) + + connection = connect("postgresql://alice:secret@db.example/archive?sslmode=prefer") + + assert connection._inner is native_connection + assert len(attempts) == 2 + + +def test_default_sslmode_verifies_identity_without_plaintext_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An omitted mode must verify certificate and host and never retry in plaintext.""" + attempts = 0 + + def refuse_ssl(**kwargs: object) -> object: + nonlocal attempts + attempts += 1 + ssl_context = kwargs["ssl_context"] + assert isinstance(ssl_context, ssl.SSLContext) + assert ssl_context.check_hostname is True + assert ssl_context.verify_mode == ssl.CERT_REQUIRED + raise _dbapi.InterfaceError("Server refuses SSL") + + monkeypatch.setattr("lineageweave.postgres_sync._dbapi.connect", refuse_ssl) + + with pytest.raises(OperationalError): + connect("postgresql://alice:secret@db.example/archive") + + assert attempts == 1 + + +def test_prefer_does_not_downgrade_on_arbitrary_transport_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Plaintext fallback is limited to an explicit PostgreSQL SSL refusal.""" + attempts = 0 + + def fail_connect(**kwargs: object) -> object: + nonlocal attempts + attempts += 1 + assert isinstance(kwargs["ssl_context"], ssl.SSLContext) + raise _dbapi.InterfaceError("network path failed during TLS negotiation") + + monkeypatch.setattr("lineageweave.postgres_sync._dbapi.connect", fail_connect) + + with pytest.raises(OperationalError): + connect("postgresql://alice:secret@db.example/archive?sslmode=prefer") + + assert attempts == 1 + + +def test_libpq_require_encrypts_without_implicit_certificate_verification( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Require must force TLS without silently becoming verify-ca/verify-full.""" + attempts = 0 + native_connection = SimpleNamespace(autocommit=False) + + def fake_connect(**kwargs: object) -> object: + nonlocal attempts + attempts += 1 + ssl_context = kwargs["ssl_context"] + assert isinstance(ssl_context, ssl.SSLContext) + assert ssl_context.check_hostname is False + assert ssl_context.verify_mode == ssl.CERT_NONE + return native_connection + + monkeypatch.setattr("lineageweave.postgres_sync._dbapi.connect", fake_connect) + + connection = connect("postgresql://alice:secret@db.example/archive?sslmode=require") + + assert connection._inner is native_connection + assert attempts == 1 diff --git a/tests/test_prov_o_schema.py b/tests/test_prov_o_schema.py index 733c96076..77358ea91 100644 --- a/tests/test_prov_o_schema.py +++ b/tests/test_prov_o_schema.py @@ -14,8 +14,8 @@ import pytest -psycopg2 = pytest.importorskip("psycopg2") -sql = pytest.importorskip("psycopg2.sql") +from lineageweave import postgres_sync as sync_postgres +from lineageweave.postgres_sync import sql _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" @@ -30,10 +30,10 @@ def _postgres_available() -> bool: """Return whether the configured PostgreSQL admin database is reachable.""" try: - connection = psycopg2.connect(_ADMIN_DSN, connect_timeout=2) + connection = sync_postgres.connect(_ADMIN_DSN, connect_timeout=2) connection.close() return True - except psycopg2.OperationalError: + except sync_postgres.OperationalError: return False @@ -53,7 +53,7 @@ def _dsn_for_database(admin_dsn: str, database_name: str) -> str: def prov_schema_db(): """Yield a freshly migrated database and drop it after the test.""" database_name = f"lineageweave_prov_{uuid.uuid4().hex[:12]}" - admin_connection = psycopg2.connect(_ADMIN_DSN) + admin_connection = sync_postgres.connect(_ADMIN_DSN) admin_connection.autocommit = True with admin_connection.cursor() as cursor: cursor.execute( @@ -61,7 +61,7 @@ def prov_schema_db(): ) try: database_dsn = _dsn_for_database(_ADMIN_DSN, database_name) - connection = psycopg2.connect(database_dsn) + connection = sync_postgres.connect(database_dsn) try: with connection.cursor() as cursor: for migration_path in _MIGRATION_PATHS: @@ -117,7 +117,7 @@ def test_database_accepts_valid_generation_and_rejects_wrong_domain(prov_schema_ "values (%s, 'prov_was_generated_by', %s)", (entity_id, activity_id), ) - with pytest.raises(psycopg2.errors.RaiseException, match="violates PROV-O domain"): + with pytest.raises(sync_postgres.errors.RaiseException, match="violates PROV-O domain"): cursor.execute( "insert into provenance_assertion " "(subject_resource_id, relation_code, object_resource_id) " @@ -135,7 +135,7 @@ def test_database_rejects_literal_for_object_property(prov_schema_db) -> None: "insert into provenance_literal_value (lexical_value) values ('bad') returning literal_id" ) literal_id = cursor.fetchone()[0] - with pytest.raises(psycopg2.errors.RaiseException, match="requires object_resource_id"): + with pytest.raises(sync_postgres.errors.RaiseException, match="requires object_resource_id"): cursor.execute( "insert into provenance_assertion " "(subject_resource_id, relation_code, object_literal_id) " @@ -154,7 +154,7 @@ def test_database_requires_xsd_datetime_for_event_time(prov_schema_db) -> None: "values ('2026-08-14T04:00:00Z') returning literal_id" ) literal_id = cursor.fetchone()[0] - with pytest.raises(psycopg2.errors.RaiseException, match="violates datatype"): + with pytest.raises(sync_postgres.errors.RaiseException, match="violates datatype"): cursor.execute( "insert into provenance_assertion " "(subject_resource_id, relation_code, object_literal_id) " @@ -192,7 +192,7 @@ def test_database_rejects_invalid_xsd_datetime(prov_schema_db, lexical_value: st lexical_value, "http://www.w3.org/2001/XMLSchema#dateTime", ) - with pytest.raises(psycopg2.errors.RaiseException, match="lexical xsd:dateTime"): + with pytest.raises(sync_postgres.errors.RaiseException, match="lexical xsd:dateTime"): cursor.execute( "insert into provenance_assertion " "(subject_resource_id, relation_code, object_literal_id) " @@ -231,7 +231,7 @@ def test_referenced_contract_rows_are_immutable(prov_schema_db) -> None: "values (%s, 'prov_was_generated_by', %s)", (entity_id, activity_id), ) - with pytest.raises(psycopg2.errors.RaiseException, match="types are immutable"): + with pytest.raises(sync_postgres.errors.RaiseException, match="types are immutable"): cursor.execute( "delete from provenance_resource_type " "where resource_id = %s and class_code = 'prov_activity'", @@ -252,7 +252,7 @@ def test_referenced_contract_rows_are_immutable(prov_schema_db) -> None: "values (%s, 'prov_started_at_time', %s)", (activity_id, literal_id), ) - with pytest.raises(psycopg2.errors.RaiseException, match="literal values are immutable"): + with pytest.raises(sync_postgres.errors.RaiseException, match="literal values are immutable"): cursor.execute( "update provenance_literal_value set datatype_iri = null " "where literal_id = %s", diff --git a/tests/test_schema.py b/tests/test_schema.py index 94876295f..650b3ca84 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -22,11 +22,10 @@ from urllib.parse import urlsplit, urlunsplit import asyncpg -import psycopg2 -import psycopg2.errors import pytest from backend.app.post_chat_ingestion import gather_global_chat_sources +from lineageweave import postgres_sync as psycopg2 from lineageweave.occupational_construct_catalog import ( catalog_content_sha256, sync_onet_construct_catalog, diff --git a/tests/test_source_post_voice_history_live.py b/tests/test_source_post_voice_history_live.py index 4ae774d2e..bfe9d6fe9 100644 --- a/tests/test_source_post_voice_history_live.py +++ b/tests/test_source_post_voice_history_live.py @@ -15,10 +15,11 @@ from pathlib import Path from urllib.parse import urlsplit, urlunsplit -import psycopg2 -import psycopg2.errors import pytest +from lineageweave import postgres_sync as sync_postgres +from lineageweave.postgres_sync import sql + _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" ) @@ -56,10 +57,10 @@ def _postgres_available() -> bool: try: - conn = psycopg2.connect(_ADMIN_DSN, connect_timeout=2) + conn = sync_postgres.connect(_ADMIN_DSN, connect_timeout=2) conn.close() return True - except psycopg2.OperationalError: + except sync_postgres.OperationalError: return False @@ -92,17 +93,19 @@ def _apply_migrations(database_dsn: str) -> None: def voice_history_dsn(): """Throwaway database with the full product schema, dropped afterward.""" database_name = f"lineageweave_voice_hist_{uuid.uuid4().hex[:12]}" - admin_conn = psycopg2.connect(_ADMIN_DSN) + admin_conn = sync_postgres.connect(_ADMIN_DSN) admin_conn.autocommit = True with admin_conn.cursor() as cursor: - cursor.execute(f'create database "{database_name}"') + cursor.execute( + sql.SQL("create database {}").format(sql.Identifier(database_name)) + ) admin_conn.close() database_dsn = _database_dsn(database_name) try: _apply_migrations(database_dsn) yield database_dsn finally: - admin_conn = psycopg2.connect(_ADMIN_DSN) + admin_conn = sync_postgres.connect(_ADMIN_DSN) admin_conn.autocommit = True with admin_conn.cursor() as cursor: cursor.execute( @@ -110,12 +113,14 @@ def voice_history_dsn(): "where datname = %s and pid <> pg_backend_pid()", (database_name,), ) - cursor.execute(f'drop database "{database_name}"') + cursor.execute( + sql.SQL("drop database {}").format(sql.Identifier(database_name)) + ) admin_conn.close() def _connect(database_dsn: str): - connection = psycopg2.connect(database_dsn) + connection = sync_postgres.connect(database_dsn) connection.autocommit = True return connection @@ -403,7 +408,10 @@ def test_gist_exclusion_rejects_overlapping_primary_intervals( ) opened_at = cursor.fetchone()[0] with pytest.raises( - (psycopg2.errors.ExclusionViolation, psycopg2.errors.RaiseException) + ( + sync_postgres.errors.ExclusionViolation, + sync_postgres.errors.RaiseException, + ) ): cursor.execute( """ @@ -433,7 +441,7 @@ def test_concurrent_primary_updates_serialize_non_overlapping_history( errors: list[Exception] = [] def _update(next_code: str) -> None: - connection = psycopg2.connect(voice_history_dsn) + connection = sync_postgres.connect(voice_history_dsn) try: barrier.wait(timeout=10) with connection.cursor() as cursor: diff --git a/tests/test_synthetic_seed_cleanup.py b/tests/test_synthetic_seed_cleanup.py index 6d2ab3405..20b9f5ecb 100644 --- a/tests/test_synthetic_seed_cleanup.py +++ b/tests/test_synthetic_seed_cleanup.py @@ -18,11 +18,13 @@ import subprocess import uuid from pathlib import Path +from urllib.parse import urlsplit, urlunsplit import asyncpg -import psycopg2 import pytest +from lineageweave import postgres_sync as sync_postgres +from lineageweave.postgres_sync import sql from lineageweave.synthetic_seed_cleanup import cleanup_synthetic_seed _ADMIN_DSN = os.environ.get( @@ -33,13 +35,19 @@ def _postgres_available() -> bool: try: - conn = psycopg2.connect(_ADMIN_DSN, connect_timeout=2) + conn = sync_postgres.connect(_ADMIN_DSN, connect_timeout=2) conn.close() return True - except psycopg2.OperationalError: + except sync_postgres.OperationalError: return False +def _database_dsn(database_name: str) -> str: + """Replace only the database path while retaining query options.""" + parsed = urlsplit(_ADMIN_DSN) + return urlunsplit(parsed._replace(path=f"/{database_name}")) + + pytestmark = pytest.mark.skipif( not _postgres_available(), reason=f"no reachable PostgreSQL server at {_ADMIN_DSN} (set LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN)", @@ -50,17 +58,16 @@ def _postgres_available() -> bool: def migrated_db(): """A freshly migrated, throwaway database, dropped afterward.""" db_name = f"lineageweave_cleanup_test_{uuid.uuid4().hex[:12]}" - admin_conn = psycopg2.connect(_ADMIN_DSN) + admin_conn = sync_postgres.connect(_ADMIN_DSN) admin_conn.autocommit = True with admin_conn.cursor() as cur: - cur.execute(f'create database "{db_name}"') + cur.execute(sql.SQL("create database {}").format(sql.Identifier(db_name))) admin_conn.close() - db_dsn = _ADMIN_DSN.rsplit("/", 1)[0] + f"/{db_name}" - # psql, not psycopg2 cur.execute(), matches docker/postgres-init/migrate.sh: - # a few migrations use CREATE INDEX CONCURRENTLY, which errors under - # psycopg2's implicit multi-statement transaction wrapping but not under - # psql's one-statement-at-a-time execution of a -f file. + db_dsn = _database_dsn(db_name) + # psql, not one DB-API execute(), matches docker/postgres-init/migrate.sh: + # a few migrations use CREATE INDEX CONCURRENTLY, which must run outside + # an implicit multi-statement transaction. for migration in sorted(_MIGRATIONS_DIR.glob("*.sql")): subprocess.run( ["psql", "-X", "-v", "ON_ERROR_STOP=1", db_dsn, "-f", str(migration)], @@ -69,14 +76,14 @@ def migrated_db(): yield db_dsn - admin_conn = psycopg2.connect(_ADMIN_DSN) + admin_conn = sync_postgres.connect(_ADMIN_DSN) admin_conn.autocommit = True with admin_conn.cursor() as cur: cur.execute( "select pg_terminate_backend(pid) from pg_stat_activity where datname = %s", (db_name,), ) - cur.execute(f'drop database "{db_name}"') + cur.execute(sql.SQL("drop database {}").format(sql.Identifier(db_name))) admin_conn.close() @@ -122,7 +129,6 @@ async def run() -> dict[str, int]: demo_pu, ) - # The synthetic seed post: no source_* evidence at all. synthetic_post = await conn.fetchval( "insert into source_post " "(author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, " @@ -133,8 +139,6 @@ async def run() -> dict[str, int]: demo_entity, demo_pu, ) - # A real, imported post sharing the same DEMO-CORP-01 entity (the - # entangled-scope shape this repo actually hit). real_post = await conn.fetchval( "insert into source_post " "(author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, " @@ -145,8 +149,6 @@ async def run() -> dict[str, int]: demo_entity, demo_pu, ) - # A second synthetic post that an analysis run has already - # reconstructed over -- must be reported as blocked, never deleted. blocked_synthetic_post = await conn.fetchval( "insert into source_post " "(author_account_id, corporate_entity_id, process_unit_id, post_title, post_body, " @@ -209,9 +211,6 @@ async def run() -> dict[str, int]: blocked_synthetic_post, ) - # The real post cites the synthetic post as internal corroborating - # evidence -- deleting the synthetic post must null this citation, - # never delete the real post's counterparty row. counterparty_relationship_type = await conn.fetchval( "select lookup_code from common_lookup_value " "where lookup_category = 'entity_relationship_type' limit 1" diff --git a/uv.lock b/uv.lock index f94e79cf4..286dc6800 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "asn1crypto" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080, upload-time = "2022-03-15T14:46:52.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -713,7 +722,7 @@ dev = [ { name = "coverage" }, { name = "httpx" }, { name = "httpx2" }, - { name = "psycopg2-binary" }, + { name = "pg8000" }, { name = "pyjwt", extra = ["crypto"] }, { name = "pyshacl" }, { name = "pytest" }, @@ -733,8 +742,8 @@ requires-dist = [ { name = "opentelemetry-api", specifier = ">=1.30.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.30.0" }, { name = "opentelemetry-sdk", specifier = ">=1.30.0" }, + { name = "pg8000", marker = "extra == 'dev'", specifier = "==1.31.5" }, { name = "pillow", specifier = ">=12.3.0" }, - { name = "psycopg2-binary", marker = "extra == 'dev'", specifier = ">=2.9.12" }, { name = "pyjwt", extras = ["crypto"], marker = "extra == 'backend'", specifier = ">=2.8.0" }, { name = "pyjwt", extras = ["crypto"], marker = "extra == 'dev'", specifier = ">=2.8.0" }, { name = "pyshacl", marker = "extra == 'dev'", specifier = ">=0.26.0" }, @@ -960,6 +969,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pg8000" +version = "1.31.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "scramp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/9a/077ab21e700051e03d8c5232b6bcb9a1a4d4b6242c9a0226df2cfa306414/pg8000-1.31.5.tar.gz", hash = "sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78", size = 118933, upload-time = "2025-09-14T09:16:49.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/07/5fd183858dff4d24840f07fc845f213cd371a19958558607ba22035dadd7/pg8000-1.31.5-py3-none-any.whl", hash = "sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201", size = 57816, upload-time = "2025-09-14T09:16:47.798Z" }, +] + [[package]] name = "pillow" version = "12.3.0" @@ -1067,47 +1089,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/c3/629999e78d46c1115c11886d51c6bd68c17ce4a944f1ea3e153a91316a33/protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c", size = 177024, upload-time = "2026-08-20T16:34:00.053Z" }, ] -[[package]] -name = "psycopg2-binary" -version = "2.9.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, - { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, - { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, - { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, - { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, - { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, - { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, - { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, - { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, - { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, - { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, - { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, - { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, - { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, - { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, - { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, - { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, - { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, - { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, - { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -1270,6 +1251,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1509,6 +1502,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, ] +[[package]] +name = "scramp" +version = "1.4.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/76/6db02f36db58a7d009e90f51e961bcc3c44a1c930a744f026e30e791989b/scramp-1.4.17.tar.gz", hash = "sha256:28970f29ebc33df47f9975c805e5e5a360effe5b31045e607d64b3b60370dba1", size = 21291, upload-time = "2026-08-07T17:19:40.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/99/0e372781210cd36b2f2727e5be3ea93066edad7edd6fa2dfdec3b3e28845/scramp-1.4.17-py3-none-any.whl", hash = "sha256:a4e3fd2e8169461a28a13777a166d3da94274454f0714a7d3023fee124474ac8", size = 16131, upload-time = "2026-08-07T17:19:39.591Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sse-starlette" version = "3.4.8"