Merge branch 'python-3.14-trixie' into master

This commit is contained in:
febux 2026-09-06 17:42:41 +03:00
commit 3cfb238adb
21 changed files with 726 additions and 800 deletions

View file

@ -1 +1 @@
3.13 3.14

123
docker-compose.test.yml Normal file
View file

@ -0,0 +1,123 @@
# Test-only infrastructure for integration tests.
#
# Mirrors the real infra (postgres + redis + rabbitmq + minio) but uses
# dedicated container names and host ports so it can coexist with the
# development stack (`docker-compose.yml`). Nothing in this file runs
# application services — only the data stores required by
# tests/integration/conftest.py.
#
# Usage:
# docker compose -f docker-compose.test.yml up -d --wait
# uv run pytest -m integration
# docker compose -f docker-compose.test.yml down -v
#
# The conftest.py fixture `infra` brings this file up automatically before the
# first integration test and tears it down after the session.
services:
postgres-test:
image: postgres:18-alpine
restart: "no"
command:
- "postgres"
- "-c"
- "wal_level=replica"
- "-c"
- "archive_mode=on"
- "-c"
- "archive_command=test ! -f /walarchive/%f && cp %p /walarchive/%f"
environment:
POSTGRES_USER: contract_check
POSTGRES_PASSWORD: contract_check
POSTGRES_DB: contract_check
volumes:
- pgdata-test:/var/lib/postgresql
- pgwal-test:/walarchive
ports:
- "25432:5432"
healthcheck:
test:
- CMD-SHELL
- "pg_isready -U contract_check -d contract_check"
interval: 5s
timeout: 3s
retries: 10
redis-test:
image: redis:8-alpine
restart: "no"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata-test:/data
ports:
- "27379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
rabbitmq-test:
image: rabbitmq:4-management-alpine
restart: "no"
environment:
RABBITMQ_DEFAULT_USER: contract_check
RABBITMQ_DEFAULT_PASS: contract_check
RABBITMQ_DEFAULT_VHOST: /
volumes:
- rabbitmq-test:/var/lib/rabbitmq
ports:
- "6672:5672" # AMQP
- "25672:15672" # management UI (http://localhost:25672)
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
minio-test:
image: minio/minio:latest
restart: "no"
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: contract_check
MINIO_ROOT_PASSWORD: contract_check
volumes:
- minio-test:/data
ports:
- "10000:9000" # S3 API
- "10001:9001" # console (http://localhost:10001)
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:9000/minio/health/ready"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
minio-init-test:
image: minio/mc:latest
depends_on:
minio-test:
condition: service_healthy
entrypoint: /bin/sh
command:
- -c
- |
set -e
mc alias set local http://minio-test:9000 contract_check contract_check
mc mb --ignore-existing local/contract-check-docs-test
mc anonymous set none local/contract-check-docs-test || true
mc ilm rule add --expire-days 7 local/contract-check-docs-test || true
echo "bucket contract-check-docs-test ready"
environment:
MINIO_ROOT_USER: contract_check
MINIO_ROOT_PASSWORD: contract_check
restart: "no"
volumes:
pgdata-test:
pgwal-test:
redisdata-test:
rabbitmq-test:
minio-test:

View file

@ -136,7 +136,7 @@ Control plane:
| Deploy | Compose now, k8s-ready later | — | | Deploy | Compose now, k8s-ready later | — |
| Prototype | Removed (stage-0 standalone benchmark no longer needed) | Kept as standalone benchmark | | Prototype | Removed (stage-0 standalone benchmark no longer needed) | Kept as standalone benchmark |
| Tests | pytest+respx unit + testcontainers integration | — | | Tests | pytest+respx unit + testcontainers integration | — |
| Python | **3.13** (was 3.14) — wheel availability | py3.14 | | Python | **3.14** | py3.13 |
| Landing | Incremental, green per step | — | | Landing | Incremental, green per step | — |
--- ---
@ -1184,14 +1184,14 @@ Same Ollama env as above; no DB/MQ/S3 env needed.
## 12. Docker — images & compose ## 12. Docker — images & compose
### Per-service Dockerfile pattern (uv, multi-stage, py3.13) ### Per-service Dockerfile pattern (uv, multi-stage, py3.14)
Dockerfiles live in `srv/<service>/Dockerfile` (one per service). Common shape Dockerfiles live in `srv/<service>/Dockerfile` (one per service). Common shape
(shown for api): (shown for api):
```dockerfile ```dockerfile
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never \ ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never \
UV_PROJECT_ENVIRONMENT=/app/.venv UV_PROJECT_ENVIRONMENT=/app/.venv
WORKDIR /app WORKDIR /app
@ -1201,7 +1201,7 @@ COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group api uv sync --frozen --no-default-groups --group api
FROM python:3.13-slim AS runtime FROM python:3.14-slim-trixie AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/app/.venv/bin:$PATH ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/app/.venv/bin:$PATH
WORKDIR /app WORKDIR /app
COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/.venv /app/.venv
@ -1218,14 +1218,14 @@ Per-service differences (`uv` uses PEP 735 dependency-groups — see `pyproject.
| Dockerfile | Extra installed | Runtime apt | CMD | Expose | | Dockerfile | Extra installed | Runtime apt | CMD | Expose |
|---|---|---|---|---| |---|---|---|---|---|
| `srv/api/Dockerfile` | `--group api` | none | `python -m contract_check.api` | 8000, 9100 | | `srv/api/Dockerfile` | `--group api` | none | `python -m contract_check.api` | 8000, 9100 |
| `srv/worker-extract/Dockerfile` | `--group extract` | tesseract-ocr, -rus, -eng, libmagic1 | `python -m contract_check.worker_extract` | 9101 | | `srv/worker-extract/Dockerfile` | `--group extract` | tesseract-ocr, -rus, -eng, libmagic1t64 | `python -m contract_check.worker_extract` | 9101 |
| `srv/worker-prescreen/Dockerfile` | `--group prescreen` | none | `python -m contract_check.worker_prescreen` | 9104 | | `srv/worker-prescreen/Dockerfile` | `--group prescreen` | none | `python -m contract_check.worker_prescreen` | 9104 |
| `srv/worker-analyze/Dockerfile` | `--group analyze` | none | `python -m contract_check.worker_analyze` | 9102 | | `srv/worker-analyze/Dockerfile` | `--group analyze` | none | `python -m contract_check.worker_analyze` | 9102 |
| `srv/bot/Dockerfile` | `--group bot` | none | `python -m contract_check.bot` | — | | `srv/bot/Dockerfile` | `--group bot` | none | `python -m contract_check.bot` | — |
The bot image is the leanest (no DB driver, no S3 client, no pymupdf). The The bot image is the leanest (no DB driver, no S3 client, no pymupdf). The
analyze image has httpx but no tesseract/pymupdf. The extract image is the analyze image has httpx but no tesseract/pymupdf. The extract image is the
heaviest (tesseract + language packs + libmagic1). This is the "fine-tuned deps heaviest (tesseract + language packs + libmagic1t64). This is the "fine-tuned deps
per service" payoff. per service" payoff.
### pyproject.toml dependency-groups (actual — PEP 735) ### pyproject.toml dependency-groups (actual — PEP 735)
@ -1239,7 +1239,7 @@ that need them. Sketch (see `pyproject.toml` for the authoritative list):
```toml ```toml
[project] [project]
name = "contract-check" name = "contract-check"
requires-python = ">=3.13" requires-python = ">=3.14"
dependencies = [ dependencies = [
"pydantic>=2.7", "pydantic-settings>=2.3", "structlog>=24.1", "pydantic>=2.7", "pydantic-settings>=2.3", "structlog>=24.1",
"python-dotenv>=1.0", "httpx[http2]>=0.27", "python-dotenv>=1.0", "httpx[http2]>=0.27",

View file

@ -7,7 +7,7 @@ name = "contract-check"
version = "0.1.0" version = "0.1.0"
description = "AI-скрининг рисков в договорах (PDF/DOCX) для СНГ — ГК РФ / ГК РБ. Event-driven production app." description = "AI-скрининг рисков в договорах (PDF/DOCX) для СНГ — ГК РФ / ГК РБ. Event-driven production app."
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.14"
license = { text = "Proprietary" } license = { text = "Proprietary" }
authors = [{ name = "Контракт-чек" }] authors = [{ name = "Контракт-чек" }]
keywords = ["legal", "contracts", "llm", "risk-screening", "fastapi", "rabbitmq"] keywords = ["legal", "contracts", "llm", "risk-screening", "fastapi", "rabbitmq"]
@ -142,7 +142,7 @@ dev = [
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py313" target-version = "py314"
src = ["src", "tests"] src = ["src", "tests"]
# Alembic migrations are autogenerated-style history; exclude them from lint+format. # Alembic migrations are autogenerated-style history; exclude them from lint+format.
extend-exclude = ["migrations"] extend-exclude = ["migrations"]

View file

@ -93,7 +93,7 @@ def safe_body(
if isinstance(body, Mapping | list): if isinstance(body, Mapping | list):
try: try:
text = json.dumps(redact_json(body), ensure_ascii=False, default=str) text = json.dumps(redact_json(body), ensure_ascii=False, default=str)
except (TypeError, ValueError): except TypeError, ValueError:
text = str(body) text = str(body)
return _truncate(text, max_chars) return _truncate(text, max_chars)
if isinstance(body, bytes): if isinstance(body, bytes):

View file

@ -48,7 +48,7 @@ def verify_password(plain: str, hashed: str) -> bool:
return _hasher.verify(hashed, plain) return _hasher.verify(hashed, plain)
except VerifyMismatchError: except VerifyMismatchError:
return False return False
except (VerificationError, InvalidHash): except VerificationError, InvalidHash:
return False return False
@ -56,7 +56,7 @@ def needs_rehash(hashed: str) -> bool:
"""True if the stored hash uses outdated params and should be re-hashed on next login.""" """True if the stored hash uses outdated params and should be re-hashed on next login."""
try: try:
return _hasher.check_needs_rehash(hashed) return _hasher.check_needs_rehash(hashed)
except (InvalidHash, TypeError): except InvalidHash, TypeError:
return False return False

View file

@ -28,15 +28,11 @@ from src.contract_check.worker_prescreen.extractor import (
from src.contract_check.worker_prescreen.extractor_heuristic import ( from src.contract_check.worker_prescreen.extractor_heuristic import (
EXTRACTOR_VERSION as HEURISTIC_VERSION, EXTRACTOR_VERSION as HEURISTIC_VERSION,
) )
from src.contract_check.worker_prescreen.extractor_heuristic import ( from src.contract_check.worker_prescreen.extractor_heuristic import HeuristicExtractor
HeuristicExtractor,
)
from src.contract_check.worker_prescreen.extractor_llm import ( from src.contract_check.worker_prescreen.extractor_llm import (
EXTRACTOR_VERSION as HYBRID_LLM_VERSION, EXTRACTOR_VERSION as HYBRID_LLM_VERSION,
) )
from src.contract_check.worker_prescreen.extractor_llm import ( from src.contract_check.worker_prescreen.extractor_llm import LLMPrescreenExtractor
LLMPrescreenExtractor,
)
log = get_logger(__name__) log = get_logger(__name__)

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -20,7 +20,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group api --no-install-project uv sync --frozen --no-default-groups --group api --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group bot --no-install-project uv sync --frozen --no-default-groups --group bot --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group analyze --no-install-project uv sync --frozen --no-default-groups --group analyze --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -17,7 +17,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group billing --no-install-project uv sync --frozen --no-default-groups --group billing --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group extract --no-install-project uv sync --frozen --no-default-groups --group extract --no-install-project
# ─── Stage 2: runtime with tesseract-ocr + language packs ──────────────────── # ─── Stage 2: runtime with tesseract-ocr + language packs ────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@ -33,7 +33,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr-rus \ tesseract-ocr-rus \
tesseract-ocr-eng \ tesseract-ocr-eng \
fonts-dejavu-core \ fonts-dejavu-core \
libmagic1 \ libmagic1t64 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/.venv /app/.venv

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group notify --no-install-project uv sync --frozen --no-default-groups --group notify --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# ─── Stage 1: build deps + project into a venv via uv ───────────────────────── # ─── Stage 1: build deps + project into a venv via uv ─────────────────────────
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \ ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-default-groups --group prescreen --no-install-project uv sync --frozen --no-default-groups --group prescreen --no-install-project
# ─── Stage 2: lean runtime ───────────────────────────────────────────────── # ─── Stage 2: lean runtime ─────────────────────────────────────────────────
FROM python:3.13-slim FROM python:3.14-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \

View file

@ -1,15 +1,21 @@
"""Integration-test fixtures using the running Docker Compose infrastructure. """Integration-test fixtures using an isolated Docker Compose infrastructure.
Run `docker compose up -d` before executing integration tests. Migrations run The test infra (postgres + redis + rabbitmq + minio) is brought up automatically
once per session; a service token is seeded so adapter-style endpoints can by the session-scoped `infra` fixture and torn down after the session. This
authenticate. keeps integration tests from racing against the development service workers
that also consume RabbitMQ queues.
To run against an already-running external infra instead, set
``INTEGRATION_TEST_USE_EXTERNAL_INFRA=1`` before invoking pytest.
""" """
from __future__ import annotations from __future__ import annotations
import json
import os import os
import subprocess import subprocess
import sys import sys
import time
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -32,9 +38,113 @@ pytestmark = pytest.mark.integration
_BOT_SERVICE_TOKEN = "it-test-bot-token" _BOT_SERVICE_TOKEN = "it-test-bot-token"
_DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:15432/contract_check" # Ports must match docker-compose.test.yml.
_AMQP_URL = "amqp://contract_check:contract_check@localhost:5672//" _TEST_DB_URL = "postgresql+asyncpg://contract_check:contract_check@localhost:25432/contract_check"
_S3_URL = "http://localhost:9000" _TEST_AMQP_URL = "amqp://contract_check:contract_check@localhost:6672//"
_TEST_S3_URL = "http://localhost:10000"
_DB_URL = _TEST_DB_URL
_AMQP_URL = _TEST_AMQP_URL
_S3_URL = _TEST_S3_URL
_USE_EXTERNAL_INFRA = os.environ.get("INTEGRATION_TEST_USE_EXTERNAL_INFRA", "0") == "1"
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
cmd,
cwd=str(_repo_root()),
env={**os.environ},
check=False,
capture_output=True,
text=True,
)
if check and result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
cmd,
output=result.stdout,
stderr=result.stderr,
)
return result
def _docker_compose_test(args: list[str]) -> list[str]:
# Use a dedicated project name so the test stack is independent from the
# development stack. Without this, `docker compose` treats the running dev
# services as "orphans" and returns a non-zero exit code.
return [
"docker",
"compose",
"-p",
"contract-check-test",
"-f",
str(_repo_root() / "docker-compose.test.yml"),
*args,
]
def _wait_for_healthy(service: str, *, deadline_seconds: int = 60) -> None:
"""Poll `docker compose ps` until `service` reports healthy."""
start = time.monotonic()
while time.monotonic() - start < deadline_seconds:
result = _run(
_docker_compose_test(["ps", service, "--format", "json"]),
check=False,
)
if result.returncode == 0 and result.stdout:
# `docker compose ps --format json` emits one JSON object per line.
for line in result.stdout.strip().splitlines():
try:
info = json.loads(line)
except json.JSONDecodeError:
continue
if info.get("Health") == "healthy":
return
if info.get("State") == "exited" and info.get("ExitCode") == 0:
# One-shot containers are also fine.
return
time.sleep(1)
raise RuntimeError(f"service {service} did not become healthy within {deadline_seconds}s")
def _start_test_infra() -> None:
if _USE_EXTERNAL_INFRA:
return
# Bring up the test stack without --wait: minio-init-test is a one-shot
# container that exits after creating the bucket, which makes --wait fail.
_run(_docker_compose_test(["up", "-d"]))
# Wait for every persistent service to be healthy.
for service in ("postgres-test", "redis-test", "rabbitmq-test", "minio-test"):
_wait_for_healthy(service)
# postgres healthcheck can still race with alembic, so explicitly wait for
# the DB to accept connections.
_run(
_docker_compose_test(
[
"exec",
"-T",
"postgres-test",
"sh",
"-c",
"until pg_isready -U contract_check -d contract_check; do sleep 1; done",
]
)
)
def _stop_test_infra() -> None:
if _USE_EXTERNAL_INFRA:
return
# Use --volumes to wipe test data between runs. Never use -v for external infra.
_run(_docker_compose_test(["down", "-v"]), check=False)
def _set_env() -> None: def _set_env() -> None:
@ -44,8 +154,8 @@ def _set_env() -> None:
"S3_ENDPOINT_URL": _S3_URL, "S3_ENDPOINT_URL": _S3_URL,
"S3_ACCESS_KEY": "contract_check", "S3_ACCESS_KEY": "contract_check",
"S3_SECRET_KEY": "contract_check", "S3_SECRET_KEY": "contract_check",
"S3_BUCKET": "contract-check-docs", "S3_BUCKET": "contract-check-docs-test",
"REDIS_URL": "redis://localhost:17379/0", "REDIS_URL": "redis://localhost:27379/0",
"OLLAMA_HOST": "http://localhost", "OLLAMA_HOST": "http://localhost",
"OLLAMA_API_KEY": "test", "OLLAMA_API_KEY": "test",
"JWT_SECRET": "it-test-jwt-secret-not-for-production", "JWT_SECRET": "it-test-jwt-secret-not-for-production",
@ -67,13 +177,19 @@ def infra() -> Iterator[dict[str, str]]:
_set_env() _set_env()
get_settings.cache_clear() get_settings.cache_clear()
repo_root = Path(__file__).resolve().parents[2] _start_test_infra()
subprocess.run(
[sys.executable, "-m", "alembic", "-c", str(repo_root / "alembic.ini"), "upgrade", "head"], _run(
cwd=str(repo_root), [
env={**os.environ}, sys.executable,
"-m",
"alembic",
"-c",
str(_repo_root() / "alembic.ini"),
"upgrade",
"head",
],
check=True, check=True,
capture_output=False,
) )
factory = create_session_factory() factory = create_session_factory()
@ -101,10 +217,12 @@ def infra() -> Iterator[dict[str, str]]:
"s3_endpoint_url": _S3_URL, "s3_endpoint_url": _S3_URL,
"s3_access_key": "contract_check", "s3_access_key": "contract_check",
"s3_secret_key": "contract_check", "s3_secret_key": "contract_check",
"s3_bucket": "contract-check-docs-test",
"token": _BOT_SERVICE_TOKEN, "token": _BOT_SERVICE_TOKEN,
} }
get_settings.cache_clear() get_settings.cache_clear()
_stop_test_infra()
@pytest_asyncio.fixture @pytest_asyncio.fixture

View file

@ -35,7 +35,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket="contract-check-docs", bucket=infra["s3_bucket"],
) )

View file

@ -63,7 +63,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket="contract-check-docs", bucket=infra["s3_bucket"],
) )

View file

@ -39,7 +39,7 @@ def storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket="contract-check-docs", bucket=infra["s3_bucket"],
) )

View file

@ -100,7 +100,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket="contract-check-docs", bucket=infra["s3_bucket"],
) )

View file

@ -83,7 +83,7 @@ def _storage(infra: dict[str, str]) -> MinioStorage:
endpoint_url=infra["s3_endpoint_url"], endpoint_url=infra["s3_endpoint_url"],
access_key=infra["s3_access_key"], access_key=infra["s3_access_key"],
secret_key=infra["s3_secret_key"], secret_key=infra["s3_secret_key"],
bucket="contract-check-docs", bucket=infra["s3_bucket"],
) )

1181
uv.lock generated

File diff suppressed because it is too large Load diff