Merge branch 'python-3.14-trixie' into master
This commit is contained in:
commit
3cfb238adb
21 changed files with 726 additions and 800 deletions
|
|
@ -1 +1 @@
|
||||||
3.13
|
3.14
|
||||||
|
|
|
||||||
123
docker-compose.test.yml
Normal file
123
docker-compose.test.yml
Normal 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:
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue