Compare commits

..

11 Commits

Author SHA1 Message Date
d71dd1bd0c test: add unit test suite for config, health checks, and lifespan wiring 2026-08-16 11:54:14 +03:30
3c660de093 feat(bootstrap,api,infra): scaffold app composition, health route, and postgres/minio/qdrant adapters
Why:
- first runnable slice of ADR-0012's resource-lifetime rules and ADR-0015's package layout: app-lifetime clients built once in the lifespan, released via explicit dependencies.

Changes:
- Settings split into per-domain nested settings (postgres/minio/ingestion/qdrant/app/logging); FastAPI app wired with /healthz, /readyz and a /v1 router; Postgres/MinIO/Qdrant adapters and SQLAlchemy models for tenants, API keys, source files, ingestion jobs/events.
2026-08-16 11:54:14 +03:30
df221279a5 build(db): add Alembic migration environment and initial schema 2026-08-16 11:54:14 +03:30
5258e1fdf6 chore(claude-code): remove unused Explore agent definition 2026-08-16 11:54:14 +03:30
ac3d545467 chore(hooks): audit edits for diagnostic-suppression patterns
Why:
- a diagnostic quietly suppressed (noqa, type: ignore, Any, getattr, broad except, config downgrades) is functionally worse than one left failing.

Changes:
- hook now scans added lines per file/config edit for suppression patterns and reports them, and remembers prior diagnostics so one vanishing in the same edit that added a suppression marker is flagged rather than treated as fixed.
2026-08-16 11:54:14 +03:30
990a9c2298 chore(docker): add local Postgres/MinIO/Qdrant compose stack 2026-08-16 11:54:05 +03:30
ee5da4ecab chore(config): add local env defaults and ignore hook state file 2026-08-16 11:54:05 +03:30
835d5bb4b0 build(deps): swap rabbitmq client for postgres/minio/qdrant async deps
Why:
- dependencies now match inline ingestion (ADR-0017): drops aio-pika, adds anyio, asyncpg, minio, qdrant-client, structlog; drops the rabbitmq pytest marker.
2026-08-16 11:54:05 +03:30
fa94a33b9b docs: add CLAUDE.md project guidance 2026-08-16 11:54:05 +03:30
c7c0570ab1 docs(plans): add point CRUD and keyword search plan 2026-08-16 11:54:05 +03:30
fd70ad01af docs(architecture): adopt inline synchronous ingestion (ADR-0017)
Why:
- ingestion is inline in the request instead of dispatched through RabbitMQ/outbox/worker; ADR-0014 is superseded (not deleted) and named as the design to adopt once ingestion needs to move off the request path.

Changes:
- new ADR-0017 plus amendments to every ADR/plan that referenced the job-shaped/broker contract, so none silently contradict it.

Impact:
- no broker, outbox, or worker code; rabbitmq test marker removed.
2026-08-16 11:53:59 +03:30
71 changed files with 3144 additions and 443 deletions

View File

@@ -1,5 +0,0 @@
---
name: Explore
description: Fast, read-only codebase search
model: sonnet
---

View File

@@ -3,18 +3,51 @@
The hook is intentionally non-blocking: it formats/fixes what Ruff can fix safely, The hook is intentionally non-blocking: it formats/fixes what Ruff can fix safely,
then reports remaining Ruff/ty diagnostics back to Claude as additional context. then reports remaining Ruff/ty diagnostics back to Claude as additional context.
It also audits each edit for *suppression*: inline ignore comments, typing weakened
to `Any`, dynamic-attribute escapes, and config-level rule downgrades. The point is
that silencing a diagnostic can never be quieter than fixing it. Diagnostics are
remembered between runs, so a diagnostic that disappears in the same edit that
introduced a suppression marker is reported loudly rather than passing as "clean".
""" """
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json import json
import re
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
PYTHON_SUFFIXES = {".py", ".pyi"} PYTHON_SUFFIXES = {".py", ".pyi"}
CONFIG_NAMES = {"pyproject.toml", "ty.toml", ".ty.toml", "ruff.toml", ".ruff.toml", "setup.cfg"}
SKIP_PARTS = {".git", ".venv", "__pycache__"} SKIP_PARTS = {".git", ".venv", "__pycache__"}
STATE_RELATIVE = Path(".claude") / "hooks" / ".quality_state.json"
# Markers that make a diagnostic go away without necessarily fixing what it found.
SUPPRESSION_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("type-checker suppression", re.compile(r"#\s*(?:ty|type|pyright|mypy)\s*:\s*ignore")),
("lint suppression", re.compile(r"#\s*noqa\b")),
("coverage suppression", re.compile(r"#\s*pragma:\s*no\s*cover")),
("typing weakened to Any", re.compile(r"(?:->|:)\s*(?:typing\.)?Any\b|\bcast\s*\(")),
("dynamic attribute escape", re.compile(r"\b(?:get|set|has)attr\s*\(")),
("broad exception swallow", re.compile(r"\bexcept\s+(?:BaseException|Exception)\b")),
("narrowing assert", re.compile(r"\bassert\s+.+\bis\s+not\s+None\b")),
)
# Config edits that disable checks repo-wide. These files are not Python, so the
# normal per-file check never runs on them and the effect is otherwise invisible.
CONFIG_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("rule downgraded", re.compile(r'^\s*[\w-]+\s*=\s*"(?:ignore|warn)"')),
("rule/override block", re.compile(r"^\s*\[\[?tool\.(?:ty|ruff)[\w.]*\]\]?")),
("lint ignore list", re.compile(r"^\s*(?:ignore|extend-ignore|per-file-ignores)\s*=")),
("paths excluded from checks", re.compile(r"^\s*(?:exclude|extend-exclude)\s*=")),
)
HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
TY_DIAGNOSTIC_RE = re.compile(r"^(?:error|warning)\[([a-z0-9-]+)\]")
RUFF_DIAGNOSTIC_RE = re.compile(r"^.+?:\d+:\d+:\s+([A-Z]+\d+)\b")
def main() -> int: def main() -> int:
@@ -26,10 +59,22 @@ def main() -> int:
repo_root = _repo_root() repo_root = _repo_root()
path = _resolve_path(file_path, repo_root) path = _resolve_path(file_path, repo_root)
if not _should_check(path): if not path.is_file() or not SKIP_PARTS.isdisjoint(path.parts):
return 0
is_python = path.suffix in PYTHON_SUFFIXES
is_config = not is_python and path.name in CONFIG_NAMES
if not (is_python or is_config):
return 0 return 0
relative_path = _display_path(path, repo_root) relative_path = _display_path(path, repo_root)
if is_config:
findings = _scan_added_lines(_added_lines(path, repo_root), CONFIG_PATTERNS)
message = _build_config_message(relative_path=relative_path, findings=findings)
_emit(message)
return 0
before = _sha256(path) before = _sha256(path)
fix = _run(["uv", "run", "ruff", "check", "--fix", str(path)], repo_root) fix = _run(["uv", "run", "ruff", "check", "--fix", str(path)], repo_root)
@@ -43,15 +88,27 @@ def main() -> int:
after = _sha256(path) after = _sha256(path)
changed = before != after changed = before != after
current_codes = _diagnostic_codes(lint, types)
previous_codes = _read_previous_codes(repo_root, relative_path)
_write_current_codes(repo_root, relative_path, current_codes)
findings = _scan_added_lines(_added_lines(path, repo_root), SUPPRESSION_PATTERNS)
message = _build_message( message = _build_message(
relative_path=relative_path, relative_path=relative_path,
changed=changed, changed=changed,
fix=fix, results=(fix, fmt, lint, types),
fmt=fmt, findings=findings,
lint=lint, resolved_codes=sorted(previous_codes - current_codes),
types=types,
) )
if message: _emit(message)
return 0
def _emit(message: str) -> None:
if not message:
return
print( print(
json.dumps( json.dumps(
{ {
@@ -64,8 +121,6 @@ def main() -> int:
) )
) )
return 0
def _read_payload() -> dict[str, object]: def _read_payload() -> dict[str, object]:
try: try:
@@ -111,10 +166,6 @@ def _resolve_path(file_path: str, repo_root: Path) -> Path:
return path.resolve() return path.resolve()
def _should_check(path: Path) -> bool:
return path.is_file() and path.suffix in PYTHON_SUFFIXES and SKIP_PARTS.isdisjoint(path.parts)
def _display_path(path: Path, repo_root: Path) -> str: def _display_path(path: Path, repo_root: Path) -> str:
try: try:
return str(path.relative_to(repo_root)) return str(path.relative_to(repo_root))
@@ -144,14 +195,115 @@ def _command_label(result: subprocess.CompletedProcess[str]) -> str:
return " ".join(result.args) if isinstance(result.args, list) else str(result.args) return " ".join(result.args) if isinstance(result.args, list) else str(result.args)
def _diagnostic_codes(*results: subprocess.CompletedProcess[str]) -> set[str]:
"""Extract stable rule identifiers (ty rule names, Ruff codes) from tool output."""
codes: set[str] = set()
for result in results:
for line in _combined_output(result).splitlines():
stripped = line.strip()
ty_match = TY_DIAGNOSTIC_RE.match(stripped)
if ty_match:
codes.add(ty_match.group(1))
continue
ruff_match = RUFF_DIAGNOSTIC_RE.match(stripped)
if ruff_match:
codes.add(ruff_match.group(1))
return codes
def _state_path(repo_root: Path) -> Path:
return repo_root / STATE_RELATIVE
def _read_previous_codes(repo_root: Path, relative_path: str) -> set[str]:
try:
state = json.loads(_state_path(repo_root).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return set()
entry = state.get(relative_path) if isinstance(state, dict) else None
if not isinstance(entry, list):
return set()
return {code for code in entry if isinstance(code, str)}
def _write_current_codes(repo_root: Path, relative_path: str, codes: set[str]) -> None:
state_path = _state_path(repo_root)
try:
state = json.loads(state_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
state = {}
if not isinstance(state, dict):
state = {}
if codes:
state[relative_path] = sorted(codes)
else:
state.pop(relative_path, None)
try:
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
except OSError:
pass
def _added_lines(path: Path, repo_root: Path) -> list[tuple[int, str]]:
"""Return (line number, text) for lines this working tree adds over HEAD."""
relative_path = _display_path(path, repo_root)
tracked = _run(["git", "ls-files", "--error-unmatch", "--", relative_path], repo_root)
if tracked.returncode != 0:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return []
return list(enumerate(text.splitlines(), start=1))
diff = _run(["git", "diff", "-U0", "HEAD", "--", relative_path], repo_root)
if diff.returncode != 0:
return []
return _parse_added_lines(diff.stdout)
def _parse_added_lines(diff_text: str) -> list[tuple[int, str]]:
added: list[tuple[int, str]] = []
lineno = 0
for line in diff_text.splitlines():
hunk = HUNK_RE.match(line)
if hunk:
lineno = int(hunk.group(1))
continue
if line.startswith(("+++", "---")):
continue
if line.startswith("+"):
added.append((lineno, line[1:]))
lineno += 1
elif not line.startswith("-"):
lineno += 1
return added
def _scan_added_lines(
added: list[tuple[int, str]],
patterns: tuple[tuple[str, re.Pattern[str]], ...],
) -> list[str]:
findings: list[str] = []
seen: set[tuple[int, str]] = set()
for lineno, text in added:
for category, pattern in patterns:
if pattern.search(text) and (lineno, category) not in seen:
seen.add((lineno, category))
findings.append(f"L{lineno} [{category}]: {text.strip()[:160]}")
return findings
def _build_message( def _build_message(
*, *,
relative_path: str, relative_path: str,
changed: bool, changed: bool,
fix: subprocess.CompletedProcess[str], results: tuple[subprocess.CompletedProcess[str], ...],
fmt: subprocess.CompletedProcess[str], findings: list[str],
lint: subprocess.CompletedProcess[str], resolved_codes: list[str],
types: subprocess.CompletedProcess[str],
) -> str: ) -> str:
lines: list[str] = [] lines: list[str] = []
@@ -161,14 +313,11 @@ def _build_message(
) )
lines.append("Read the file before making another manual edit to avoid stale text.") lines.append("Read the file before making another manual edit to avoid stale text.")
for result in (fix, fmt, lint, types): for result in results:
if result.returncode == 0: if result.returncode == 0:
continue continue
output = _combined_output(result) output = _combined_output(result) or f"Command exited with status {result.returncode}."
if not output:
output = f"Command exited with status {result.returncode}."
lines.extend( lines.extend(
[ [
"", "",
@@ -179,8 +328,53 @@ def _build_message(
] ]
) )
if findings:
lines.extend(
[
"",
f"Suppression audit — `{relative_path}` adds these lines over HEAD:",
"```text",
"\n".join(findings[-60:]),
"```",
"A silenced diagnostic is not a fixed diagnostic. For each entry: either the",
"code as written requires it, or it exists to make a checker pass — and the",
"second case hides a bug rather than resolving it. If a checker reported a real",
"problem, fix the problem. If you believe the checker is wrong, say so to the",
"user instead of suppressing it yourself.",
]
)
if resolved_codes:
lines.extend(
[
"",
"!! These diagnostics vanished in the same edit that added the markers",
f" above: {', '.join(resolved_codes)}",
"If any pointed at a genuine logic error, that error is still present and",
"is now merely unreported. Do NOT call this file clean. Tell the user what",
"you changed and why each diagnostic no longer applies.",
]
)
return "\n".join(lines).strip() return "\n".join(lines).strip()
def _build_config_message(*, relative_path: str, findings: list[str]) -> str:
if not findings:
return ""
return "\n".join(
[
f"Quality-config audit — `{relative_path}` adds these lines over HEAD:",
"```text",
"\n".join(findings[-60:]),
"```",
"These lines can disable or downgrade checks across the whole repository, and no",
"per-file check runs on this file. Loosening checker configuration is the user's",
"decision, not yours: if you changed it to make diagnostics go away, revert it and",
"report the diagnostics instead.",
]
)
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View File

@@ -0,0 +1,46 @@
# Application local development environment.
#
# Usage:
# cp .env.example .env
# docker compose -f docker-compose.yml up -d
#
# All values below are non-secret local-development defaults matching
# docker-compose.yml. Do not commit .env.
# Application
APP_ENV=local
APP_MAX_UPLOAD_SIZE_MB=25
APP_READINESS_CHECK_TIMEOUT_SECONDS=2.0
# Logging
LOG_LEVEL=INFO
LOG_JSON_FORMAT=false
# Postgres (application database, separate from Langfuse's Postgres)
# Use 127.0.0.1 rather than localhost: some environments resolve localhost to
# the IPv6 loopback first, and Docker only publishes these ports on IPv4.
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5433
POSTGRES_USER=chatbot
POSTGRES_PASSWORD=chatbot
POSTGRES_DB=chatbot
# MinIO (application bucket, separate from Langfuse's MinIO)
MINIO_ENDPOINT=127.0.0.1:9100
MINIO_ACCESS_KEY=chatbot
MINIO_SECRET_KEY=chatbot-secret
MINIO_SECURE=false
MINIO_BUCKET=chatbot-source-files
# Inline ingestion bounds (ADR-0017; no broker, no queue).
# INGESTION_TIMEOUT_SECONDS must stay below any proxy/client read timeout.
INGESTION_MAX_CONCURRENCY=4
INGESTION_THREAD_POOL_SIZE=8
INGESTION_TIMEOUT_SECONDS=120.0
INGESTION_MAX_CHUNKS_PER_FILE=5000
INGESTION_EMBED_BATCH_SIZE=128
INGESTION_EMBED_CONCURRENCY=4
# Qdrant
QDRANT_URL=http://127.0.0.1:6343
QDRANT_API_KEY=

1
.gitignore vendored
View File

@@ -22,3 +22,4 @@ infra/langfuse/.env
# Claude Code local overrides # Claude Code local overrides
.claude/settings.local.json .claude/settings.local.json
.claude/hooks/.quality_state.json

229
CLAUDE.md Normal file
View File

@@ -0,0 +1,229 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project status
This repo is currently ADR-driven and mostly pre-implementation: `src/` contains
only an empty `main.py`/`config.py` scaffold and empty `api/routers`,
`api/dependencies`, `db`, and `schemas` directories. Architecture decisions live
in `docs/adr/` (17 ADRs plus the 0000 template; 0001–0004 are `Accepted`,
0014 is `Superseded by 0017`, and the rest — 0005–0013 and 0015–0017 — are
`Proposed`). Implementation plans live in `docs/plans/`:
`001-ingestion-vertical-slice.md` and
`002-point-crud-and-keyword-search.md`. **Read the relevant
ADR(s) before implementing anything** — the ADRs are the source of truth for
package layout, boundaries, and invariants; code should not silently diverge
from them. If an ADR needs to change to fit new work, update the ADR rather
than quietly contradicting it.
## Commands
Package manager is `uv`; Python 3.13.
```bash
uv sync # install deps
uv run ruff check --fix <path> # lint (autofix)
uv run ruff format <path> # format
uv run ty check <path> # type check
uv run pytest # run tests
uv run pytest -m unit # fast, no external services (default dev loop)
uv run pytest -m "integration and postgres" # a single integration boundary
uv run pytest tests/path/to/test_file.py::test_name # single test
uv run alembic upgrade head # apply migrations (never create_all() at runtime)
uv run fastapi dev src/main.py # run the API locally (once main.py exists)
```
A PostToolUse hook (`.claude/hooks/python_quality.py`) already runs
`ruff check --fix`, `ruff format`, `ruff check`, and `ty check` on every
Python file you Write/Edit and reports remaining diagnostics back to you —
you don't need to run these manually after every edit, but do run the full
suite before considering a task done.
### Never silence a diagnostic
A `ty`/Ruff diagnostic is frequently a real bug — most often the checker is
right and the code is wrong. **Do not make a diagnostic go away by any means
other than correcting the code it points at.** Specifically, never do the
following to get a clean check:
- add `# ty: ignore`, `# type: ignore`, `# noqa`, or `# pragma: no cover`
- widen a type to `Any`, or wrap a value in `cast(...)`
- replace `x.attr` with `getattr(x, "attr")`, wrap the call in
`except Exception`, or otherwise restructure code so the checker can no
longer see the problem
- edit `[tool.ty.rules]`, `[[tool.ty.overrides]]`, `[tool.ruff.lint]`
`ignore`/`per-file-ignores`, or any `exclude` in `pyproject.toml`/`ty.toml`/
`ruff.toml` — checker configuration is the user's decision, not yours
- delete or skip a failing test, or narrow its assertions
If a dependency is missing, add it (`uv add`). If a third-party package genuinely
ships no types, say so and let the user decide on a scoped override. **If you
believe a diagnostic is a false positive, stop and tell the user — do not
suppress it yourself.** That claim is exactly the case where silencing is most
expensive, so it is theirs to confirm.
The hook audits every edit for these patterns and reports them, including on
config files. If it flags a line you added, either justify it explicitly in your
reply or revert it — never leave it unmentioned, and never describe a file as
clean when its diagnostics were suppressed rather than fixed.
Local Langfuse (observability) stack: `docker compose --env-file .env.langfuse -f docker-compose.langfuse.yml up -d`, UI at `http://localhost:3000` (see README.md).
## Architecture
This is a **modular monolith** (ADR-0015) backing a tenant-scoped RAG chatbot.
Today it is a **single process with no background work**: ingestion runs inline
in the `POST /v1/files` request and returns a terminal result (ADR-0017). There
is no message broker, no transactional outbox, no queue, and no worker process —
ADR-0014 describes that design and is deferred, not deleted.
### Package layout (target shape, created as needed)
```
src/
├── bootstrap/ # per-process composition: lifespan, dependency wiring
├── api/ # FastAPI HTTP adapter only (routers/, dependencies/, schemas/, router.py)
├── application/ # use-case services: files/, ingestion/, points/, retrieval/, threads/, ports/
├── agent/ # LangGraph graph.py, state.py, nodes/, prompts/, tools/, persistence.py
└── infrastructure/ # concrete adapters: postgres/, qdrant/, minio/, embedding/, langgraph/, observability/
```
`messaging/`, `workers/`, and `infrastructure/rabbitmq/` belong to the ADR-0014
target shape and are **not created** while ADR-0017 stands. The FastAPI route is
the only entry adapter; it calls `application/ingestion/` directly.
Dependency direction is one-way and enforced by convention, not tooling:
```
FastAPI routes / LangGraph nodes
-> application services -> application ports -> infrastructure adapters
```
Infrastructure never imports routers/graph nodes; LangGraph nodes never call route functions or construct SDK
clients directly; application services never import concrete
MinIO/Qdrant/SQLAlchemy client-construction code. Use ports only for
external side effects/persistence — not around pure local functions.
(ADR-0015)
### Resource lifetime rules (ADR-0012)
- Application-lifetime objects (SQLAlchemy engine/sessionmaker, Qdrant client,
LangGraph checkpointer/store, compiled graph, shared HTTP/model/embedding
clients, the ingestion `CapacityLimiter`) are created once in the FastAPI
lifespan and closed there. Never construct mutable network/DB clients at
import time.
- One `AsyncSession` per request/job unit of work — never shared globally.
- Routes/application services own transaction boundaries (explicit
`commit()`); repositories don't commit/rollback/close sessions they didn't
create.
- Lower layers receive dependencies as explicit parameters, not via imported
singletons — this is what makes FastAPI dependency overrides and test
fixtures work.
### Ingestion flow (the first vertical slice, ADR-0017 + plan 001)
Ingestion is **inline in the request**, in three phases — and the phase
boundaries are the point:
```
txn A (short): auth+tenant -> validate -> insert source_files,
ingestion_jobs(status=running) -> COMMIT, release connection
no txn: store bytes in MinIO -> parse+chunk (threads) ->
embed dense+sparse (async, batched, semaphore-bounded) ->
upsert Qdrant points (deterministic ids)
txn B (short): ingestion_jobs -> succeeded/failed + counters,
append ingestion_job_events -> COMMIT
201 Created { file_id, ingestion_job_id, status, chunks_indexed }
```
**Never hold a Postgres session/transaction open across the work phase** — it
pins a pool connection for the whole upload. `ingestion_jobs` is a record of an
attempt, not a queue.
Work placement is not optional:
- **Async + batched + `asyncio.Semaphore`**: dense embedders (network I/O; batch
before parallelizing, never an unbounded `gather`), Qdrant upserts.
- **`anyio.to_thread.run_sync` + `CapacityLimiter`**: `python-docx`/`csv`
parsing, chunking, hashing, the BM25 sparse pipeline, the sync `minio` SDK.
Calling these from `async def` directly is a defect — one big parse stalls
every concurrent request.
- **Not computed at ingest**: `late_interaction` (jina-colbert-v2, GPU).
Populating it is the trigger to move ingestion back off the request.
Bounds are enforced server-side and all have status codes: size/chunk ceiling
`413`, `INGESTION_MAX_CONCURRENCY` `503`, `INGESTION_TIMEOUT_SECONDS` `504`,
embedder failure `502`. A timeout must still write a terminal job status.
Retried uploads must be safe: deterministic point IDs, `(tenant_id, domain,
content_sha256)` idempotency, no terminal job returning to `running`.
### Retrieval / agent (ADR-0001, 0003, 0005, 0006, 0007)
- Single Qdrant collection `chunks`, shared across tenants, with named vectors
`dense_nomic` (nomic-embed-text-v2-moe), `dense_openai`, `sparse` (BM25,
Farsi-tuned), and `late_interaction` (jina-colbert-v2, rerank-only, on-disk).
Multitenancy via Qdrant's `is_tenant` payload index on `tenant_id`, `m: 0` +
`payload_m: 16` HNSW config — every query/prefetch carries a server-derived
`tenant_id`/`domain` filter, never client-supplied.
- Retrieval = 3 parallel prefetches (dense_nomic, dense_openai, sparse) → RRF
fusion → late-interaction rerank over the fused top-N only (bounded cost) →
context-window expansion via `previous_chunk_id`/`next_chunk_id` pointers.
- Conversational agent is an explicit LangGraph `StateGraph` (not a prebuilt
tool-calling agent): `load_memory -> triage -> {chitchat|out_of_scope|
handoff_request|account|knowledge->contextualize->retrieve->grade->
{clarify|generate->verify}} -> write_memory`. Escalation and retry-budget
policy are graph edges, not model discretion; `generate` answers only from
retrieved chunks with per-claim citations; `verify` checks groundedness.
- LangGraph persistence: single `AsyncPostgresSaver` backed by one
`psycopg_pool.AsyncConnectionPool`, graph compiled once at startup.
`.setup()` (DDL) runs as a deployment step, never at app startup. The
LangGraph `thread_id` **is** the conversation identifier this service
knows — no separate thread/session table; this service stores no
conversation metadata beyond the checkpoint sequence.
### Postgres conventions (ADR-0009)
UUID primary keys (app-generated), `timestamptz` for all timestamps,
`Numeric(18, 8)` for money (never floats), `JSONB` for flexible metadata but
typed/indexed columns for query-critical fields, string status columns with
`CHECK` constraints (not native Postgres enums — they're painful to migrate).
`metadata` column maps to a `metadata_` attribute (reserved name on
Declarative models). All DDL goes through Alembic; FastAPI never creates or
alters tables at startup.
### Observability (ADR-0010, 0011)
Langfuse is the observability/evaluation plane (traces, prompt versions,
datasets/experiments, user feedback) — it is not the transactional database.
Postgres remains system of record for tenants, API keys, audit, jobs,
`graph_runs`, `llm_calls`/`llm_pricing`. Correlate the two via `request_id`,
`tenant_id`, `thread_id`, `run_id`. Use `structlog` with stable event names
and structured fields (`logger.info("graph.run.completed", ...)`), not
interpolated prose; JSON logs by default in production.
## Testing (ADR-0016)
- pytest, `pytest-asyncio` strict mode, `httpx.AsyncClient` + `ASGITransport`
+ `LifespanManager` for API tests (not `TestClient`) so lifespan/ADR-0012
wiring is actually exercised.
- One primary marker per test: `unit`, `integration`, or `e2e`; integration
tests also carry `postgres`/`minio`/`qdrant`; `live_provider` for
opt-in credential-gated external calls (never in routine runs); `slow` only
when materially over the normal integration budget.
- Test naming: `test_<unit>_<scenario>_<outcome>`, Arrange–Act–Assert.
- Layout mirrors architecture: `tests/unit/{application,agent}`,
`tests/integration/{postgres,minio,qdrant}`, `tests/e2e/`.
- Integration tests use **Testcontainers** (never a developer's local
services or Langfuse-owned storage/credentials) — this is the standard
automated mechanism, not Docker Compose. Isolate data per test via unique
keys/queue/collection names; parallel integration execution is disabled
until fixture isolation is proven safe.
- Pytest never calls a live/paid model provider in routine runs — that's
Langfuse's job (dataset experiments), not pytest's. Migrations are always
tested through real Alembic upgrade, never `Base.metadata.create_all()`.
- Invariants worth testing explicitly wherever they apply: tenant identity
only from server-side auth context (never request-suppliable); cross-tenant
access returns 404, not 403; the file + job row commit atomically before the
work phase; no session is held across parse/embed/upsert; a timeout or failure
always writes a terminal job status; retrying an upload must not duplicate
chunks or regress terminal job state.

149
alembic.ini Normal file
View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

86
alembic/env.py Normal file
View File

@@ -0,0 +1,86 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from src.config import Settings
from src.infrastructure.postgres.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Application models' MetaData, used for 'autogenerate' support. The database
# URL is likewise sourced from application settings, not alembic.ini, so both
# migrations and the app read the same env-derived configuration (ADR-0009).
target_metadata = Base.metadata
config.set_main_option("sqlalchemy.url", Settings().postgres.dsn)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,156 @@
"""create tenants, api_keys, source_files, ingestion_jobs, ingestion_job_events
Revision ID: bfc6c81c2542
Revises:
Create Date: 2026-08-16 11:39:02.649094
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'bfc6c81c2542'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tenants',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('slug', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('status', sa.String(length=20), server_default='active', nullable=False),
sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('active', 'suspended', 'deleted')", name='ck_tenants_status'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tenants_slug'), 'tenants', ['slug'], unique=True)
op.create_table('api_keys',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('key_prefix', sa.String(length=32), nullable=False),
sa.Column('key_hash', sa.String(length=255), nullable=False),
sa.Column('scopes', postgresql.JSONB(astext_type=sa.Text()), server_default='[]', nullable=False),
sa.Column('actor_type', sa.String(length=20), server_default='backend', nullable=False),
sa.Column('status', sa.String(length=20), server_default='active', nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by', sa.String(length=200), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("actor_type IN ('backend', 'admin', 'worker')", name='ck_api_keys_actor_type'),
sa.CheckConstraint("status IN ('active', 'revoked', 'expired')", name='ck_api_keys_status'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_api_keys_key_prefix'), 'api_keys', ['key_prefix'], unique=True)
op.create_index(op.f('ix_api_keys_tenant_id'), 'api_keys', ['tenant_id'], unique=False)
op.create_index('ix_api_keys_tenant_id_status', 'api_keys', ['tenant_id', 'status'], unique=False)
op.create_table('source_files',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('domain', sa.String(length=80), nullable=False),
sa.Column('source_filename', sa.String(length=500), nullable=False),
sa.Column('source_type', sa.String(length=10), nullable=False),
sa.Column('content_sha256', sa.String(length=64), nullable=False),
sa.Column('byte_size', sa.BigInteger(), nullable=False),
sa.Column('storage_uri', sa.String(length=1000), nullable=True),
sa.Column('status', sa.String(length=20), server_default='active', nullable=False),
sa.Column('created_by_api_key_id', sa.Uuid(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("source_type IN ('csv', 'xlsx', 'docx', 'doc')", name='ck_source_files_source_type'),
sa.CheckConstraint("status IN ('active', 'superseded', 'soft_deleted', 'purged')", name='ck_source_files_status'),
sa.ForeignKeyConstraint(['created_by_api_key_id'], ['api_keys.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_source_files_tenant_id'), 'source_files', ['tenant_id'], unique=False)
op.create_index('ix_source_files_tenant_id_content_sha256', 'source_files', ['tenant_id', 'content_sha256'], unique=False)
op.create_index('ix_source_files_tenant_id_domain_created_at', 'source_files', ['tenant_id', 'domain', 'created_at'], unique=False)
op.create_table('ingestion_jobs',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('source_file_id', sa.Uuid(), nullable=False),
sa.Column('requested_by_api_key_id', sa.Uuid(), nullable=True),
sa.Column('status', sa.String(length=20), server_default='queued', nullable=False),
sa.Column('chunking_strategy', sa.String(length=20), nullable=True),
sa.Column('embedding_model_versions', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('points_created', sa.Integer(), server_default='0', nullable=False),
sa.Column('points_updated', sa.Integer(), server_default='0', nullable=False),
sa.Column('points_soft_deleted', sa.Integer(), server_default='0', nullable=False),
sa.Column('points_skipped', sa.Integer(), server_default='0', nullable=False),
sa.Column('error_code', sa.String(length=100), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("chunking_strategy IN ('semantic', 'fixed_size')", name='ck_ingestion_jobs_chunking_strategy'),
sa.CheckConstraint("status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')", name='ck_ingestion_jobs_status'),
sa.ForeignKeyConstraint(['requested_by_api_key_id'], ['api_keys.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['source_file_id'], ['source_files.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_ingestion_jobs_source_file_id'), 'ingestion_jobs', ['source_file_id'], unique=False)
op.create_index('ix_ingestion_jobs_source_file_id_created_at', 'ingestion_jobs', ['source_file_id', 'created_at'], unique=False)
op.create_index(op.f('ix_ingestion_jobs_tenant_id'), 'ingestion_jobs', ['tenant_id'], unique=False)
op.create_index('ix_ingestion_jobs_tenant_id_status_created_at', 'ingestion_jobs', ['tenant_id', 'status', 'created_at'], unique=False)
op.create_table('ingestion_job_events',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('ingestion_job_id', sa.Uuid(), nullable=False),
sa.Column('level', sa.String(length=10), nullable=False),
sa.Column('stage', sa.String(length=20), nullable=False),
sa.Column('message', sa.String(length=1000), nullable=False),
sa.Column('details', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("level IN ('info', 'warning', 'error')", name='ck_ingestion_job_events_level'),
sa.CheckConstraint("stage IN ('received', 'parsed', 'chunked', 'embedded', 'upserted', 'completed')", name='ck_ingestion_job_events_stage'),
sa.ForeignKeyConstraint(['ingestion_job_id'], ['ingestion_jobs.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_ingestion_job_events_ingestion_job_id'), 'ingestion_job_events', ['ingestion_job_id'], unique=False)
op.create_index('ix_ingestion_job_events_ingestion_job_id_created_at', 'ingestion_job_events', ['ingestion_job_id', 'created_at'], unique=False)
op.create_index(op.f('ix_ingestion_job_events_tenant_id'), 'ingestion_job_events', ['tenant_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_ingestion_job_events_tenant_id'), table_name='ingestion_job_events')
op.drop_index('ix_ingestion_job_events_ingestion_job_id_created_at', table_name='ingestion_job_events')
op.drop_index(op.f('ix_ingestion_job_events_ingestion_job_id'), table_name='ingestion_job_events')
op.drop_table('ingestion_job_events')
op.drop_index('ix_ingestion_jobs_tenant_id_status_created_at', table_name='ingestion_jobs')
op.drop_index(op.f('ix_ingestion_jobs_tenant_id'), table_name='ingestion_jobs')
op.drop_index('ix_ingestion_jobs_source_file_id_created_at', table_name='ingestion_jobs')
op.drop_index(op.f('ix_ingestion_jobs_source_file_id'), table_name='ingestion_jobs')
op.drop_table('ingestion_jobs')
op.drop_index('ix_source_files_tenant_id_domain_created_at', table_name='source_files')
op.drop_index('ix_source_files_tenant_id_content_sha256', table_name='source_files')
op.drop_index(op.f('ix_source_files_tenant_id'), table_name='source_files')
op.drop_table('source_files')
op.drop_index('ix_api_keys_tenant_id_status', table_name='api_keys')
op.drop_index(op.f('ix_api_keys_tenant_id'), table_name='api_keys')
op.drop_index(op.f('ix_api_keys_key_prefix'), table_name='api_keys')
op.drop_table('api_keys')
op.drop_index(op.f('ix_tenants_slug'), table_name='tenants')
op.drop_table('tenants')
# ### end Alembic commands ###

198
docker-compose.langfuse.yml Normal file
View File

@@ -0,0 +1,198 @@
# Development/staging Langfuse v4 Docker Compose stack.
#
# Based on the official Langfuse Docker Compose deployment:
# https://langfuse.com/self-hosting/deployment/docker-compose
#
# This file is intentionally separate from the future application compose file.
# Run it alone for Langfuse-only development, or combine it with the app stack:
#
# docker compose \
# -f docker-compose.yml \
# -f docker-compose.langfuse.yml \
# --env-file .env \
# --env-file .env.langfuse \
# up -d
#
# If the chatbot app runs in the same Compose project/network, configure it with:
# LANGFUSE_HOST=http://langfuse-web:3000
# If the chatbot app runs directly on the host machine, configure it with:
# LANGFUSE_HOST=http://localhost:3000
#
# Do not use this compose stack as-is for high-availability production.
services:
langfuse-worker:
image: docker.io/langfuse/langfuse-worker:4
restart: always
depends_on: &langfuse-depends-on
langfuse-postgres:
condition: service_healthy
langfuse-minio:
condition: service_healthy
langfuse-redis:
condition: service_healthy
langfuse-clickhouse:
condition: service_healthy
ports:
- 127.0.0.1:${LANGFUSE_WORKER_PORT:-3030}:3030
environment: &langfuse-worker-env
NEXTAUTH_URL: ${LANGFUSE_NEXTAUTH_URL:-http://localhost:3000}
DATABASE_URL: postgresql://${LANGFUSE_POSTGRES_USER:-postgres}:${LANGFUSE_POSTGRES_PASSWORD:-postgres}@langfuse-postgres:5432/${LANGFUSE_POSTGRES_DB:-postgres}
SALT: ${LANGFUSE_SALT:-mysalt} # CHANGEME
ENCRYPTION_KEY: ${LANGFUSE_ENCRYPTION_KEY:-0000000000000000000000000000000000000000000000000000000000000000} # CHANGEME: generate via `openssl rand -hex 32`
TELEMETRY_ENABLED: ${LANGFUSE_TELEMETRY_ENABLED:-false}
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: ${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000
CLICKHOUSE_URL: http://langfuse-clickhouse:8123
CLICKHOUSE_USER: ${LANGFUSE_CLICKHOUSE_USER:-clickhouse}
CLICKHOUSE_PASSWORD: ${LANGFUSE_CLICKHOUSE_PASSWORD:-clickhouse} # CHANGEME
CLICKHOUSE_CLUSTER_ENABLED: ${LANGFUSE_CLICKHOUSE_CLUSTER_ENABLED:-false}
LANGFUSE_USE_AZURE_BLOB: ${LANGFUSE_USE_AZURE_BLOB:-false}
LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE: ${LANGFUSE_USE_OCI_NATIVE_OBJECT_STORAGE:-false}
LANGFUSE_OCI_AUTH_TYPE: ${LANGFUSE_OCI_AUTH_TYPE:-workload_identity}
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: ${LANGFUSE_S3_EVENT_UPLOAD_BUCKET:-langfuse}
LANGFUSE_S3_EVENT_UPLOAD_REGION: ${LANGFUSE_S3_EVENT_UPLOAD_REGION:-auto}
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID:-minio}
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY:-miniosecret} # CHANGEME
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://langfuse-minio:9000
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE:-true}
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: ${LANGFUSE_S3_EVENT_UPLOAD_PREFIX:-events/}
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: ${LANGFUSE_S3_MEDIA_UPLOAD_BUCKET:-langfuse}
LANGFUSE_S3_MEDIA_UPLOAD_REGION: ${LANGFUSE_S3_MEDIA_UPLOAD_REGION:-auto}
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: ${LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID:-minio}
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: ${LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY:-miniosecret} # CHANGEME
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://langfuse-minio:9000
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: ${LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE:-true}
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: ${LANGFUSE_S3_MEDIA_UPLOAD_PREFIX:-media/}
LANGFUSE_OBSERVATION_FIELD_OVERFLOW_ENABLED: ${LANGFUSE_OBSERVATION_FIELD_OVERFLOW_ENABLED:-false}
LANGFUSE_OBSERVATION_FIELD_SIZE_LIMIT_BYTES: ${LANGFUSE_OBSERVATION_FIELD_SIZE_LIMIT_BYTES:-2097152}
LANGFUSE_S3_BATCH_EXPORT_ENABLED: ${LANGFUSE_S3_BATCH_EXPORT_ENABLED:-false}
LANGFUSE_S3_BATCH_EXPORT_BUCKET: ${LANGFUSE_S3_BATCH_EXPORT_BUCKET:-langfuse}
LANGFUSE_S3_BATCH_EXPORT_PREFIX: ${LANGFUSE_S3_BATCH_EXPORT_PREFIX:-exports/}
LANGFUSE_S3_BATCH_EXPORT_REGION: ${LANGFUSE_S3_BATCH_EXPORT_REGION:-auto}
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT: http://langfuse-minio:9000
LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT: ${LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT:-http://localhost:9090}
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID: ${LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID:-minio}
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY: ${LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY:-miniosecret} # CHANGEME
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE: ${LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE:-true}
LANGFUSE_INGESTION_QUEUE_DELAY_MS: ${LANGFUSE_INGESTION_QUEUE_DELAY_MS:-}
LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: ${LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS:-}
REDIS_HOST: langfuse-redis
REDIS_PORT: ${LANGFUSE_REDIS_PORT:-6379}
REDIS_AUTH: ${LANGFUSE_REDIS_AUTH:-myredissecret} # CHANGEME
LANGFUSE_BULLMQ_SKIP_REDIS_VERSION_CHECK: ${LANGFUSE_BULLMQ_SKIP_REDIS_VERSION_CHECK:-false}
REDIS_TLS_ENABLED: ${LANGFUSE_REDIS_TLS_ENABLED:-false}
REDIS_TLS_CA: ${LANGFUSE_REDIS_TLS_CA:-/certs/ca.crt}
REDIS_TLS_CERT: ${LANGFUSE_REDIS_TLS_CERT:-/certs/redis.crt}
REDIS_TLS_KEY: ${LANGFUSE_REDIS_TLS_KEY:-/certs/redis.key}
EMAIL_FROM_ADDRESS: ${LANGFUSE_EMAIL_FROM_ADDRESS:-}
SMTP_CONNECTION_URL: ${LANGFUSE_SMTP_CONNECTION_URL:-}
langfuse-web:
image: docker.io/langfuse/langfuse:4
restart: always
depends_on: *langfuse-depends-on
ports:
- ${LANGFUSE_WEB_PORT:-3000}:3000
environment:
<<: *langfuse-worker-env
NEXTAUTH_SECRET: ${LANGFUSE_NEXTAUTH_SECRET:-mysecret} # CHANGEME
# This value is used by browser-facing media upload flows and must be
# reachable from the browser, not just from inside the Docker network.
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: ${LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT_PUBLIC:-http://localhost:9090}
LANGFUSE_INIT_ORG_ID: ${LANGFUSE_INIT_ORG_ID:-}
LANGFUSE_INIT_ORG_NAME: ${LANGFUSE_INIT_ORG_NAME:-}
LANGFUSE_INIT_PROJECT_ID: ${LANGFUSE_INIT_PROJECT_ID:-}
LANGFUSE_INIT_PROJECT_NAME: ${LANGFUSE_INIT_PROJECT_NAME:-}
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: ${LANGFUSE_INIT_PROJECT_PUBLIC_KEY:-}
LANGFUSE_INIT_PROJECT_SECRET_KEY: ${LANGFUSE_INIT_PROJECT_SECRET_KEY:-}
LANGFUSE_INIT_USER_EMAIL: ${LANGFUSE_INIT_USER_EMAIL:-}
LANGFUSE_INIT_USER_NAME: ${LANGFUSE_INIT_USER_NAME:-}
LANGFUSE_INIT_USER_PASSWORD: ${LANGFUSE_INIT_USER_PASSWORD:-}
langfuse-clickhouse:
image: docker.io/clickhouse/clickhouse-server:25.12
restart: always
user: "101:101"
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: ${LANGFUSE_CLICKHOUSE_USER:-clickhouse}
CLICKHOUSE_PASSWORD: ${LANGFUSE_CLICKHOUSE_PASSWORD:-clickhouse} # CHANGEME
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
ports:
- 127.0.0.1:${LANGFUSE_CLICKHOUSE_HTTP_PORT:-18123}:8123
- 127.0.0.1:${LANGFUSE_CLICKHOUSE_NATIVE_PORT:-19000}:9000
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1
interval: 5s
timeout: 5s
retries: 10
start_period: 1s
langfuse-minio:
image: cgr.dev/chainguard/minio
restart: always
entrypoint: sh
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
environment:
MINIO_ROOT_USER: ${LANGFUSE_MINIO_ROOT_USER:-minio}
MINIO_ROOT_PASSWORD: ${LANGFUSE_MINIO_ROOT_PASSWORD:-miniosecret} # CHANGEME
ports:
- ${LANGFUSE_MINIO_API_PORT:-9090}:9000
- 127.0.0.1:${LANGFUSE_MINIO_CONSOLE_PORT:-9091}:9001
volumes:
- langfuse_minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 5s
retries: 5
start_period: 1s
langfuse-redis:
image: docker.io/redis:7
restart: always
command: >
--requirepass ${LANGFUSE_REDIS_AUTH:-myredissecret}
--maxmemory-policy noeviction
ports:
- 127.0.0.1:${LANGFUSE_REDIS_HOST_PORT:-16379}:6379
volumes:
- langfuse_redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 10s
retries: 10
langfuse-postgres:
image: docker.io/postgres:${LANGFUSE_POSTGRES_VERSION:-17}
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${LANGFUSE_POSTGRES_USER:-postgres}"]
interval: 3s
timeout: 3s
retries: 10
environment:
POSTGRES_USER: ${LANGFUSE_POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${LANGFUSE_POSTGRES_PASSWORD:-postgres} # CHANGEME
POSTGRES_DB: ${LANGFUSE_POSTGRES_DB:-postgres}
TZ: UTC
PGTZ: UTC
ports:
- 127.0.0.1:${LANGFUSE_POSTGRES_HOST_PORT:-15432}:5432
volumes:
- langfuse_postgres_data:/var/lib/postgresql/data
volumes:
langfuse_postgres_data:
driver: local
langfuse_clickhouse_data:
driver: local
langfuse_clickhouse_logs:
driver: local
langfuse_minio_data:
driver: local
langfuse_redis_data:
driver: local

74
docker-compose.yml Normal file
View File

@@ -0,0 +1,74 @@
# Application-owned local infrastructure: Postgres, MinIO, Qdrant.
#
# Intentionally separate from docker-compose.langfuse.yml (different service
# names, volumes, and ports). Run alone for app development, or combine with
# Langfuse:
#
# docker compose \
# -f docker-compose.yml \
# -f docker-compose.langfuse.yml \
# --env-file .env \
# --env-file .env.langfuse \
# up -d
#
# Do not use this compose stack as-is for production.
services:
app-postgres:
image: docker.io/postgres:17
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-chatbot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-chatbot}
POSTGRES_DB: ${POSTGRES_DB:-chatbot}
ports:
- 127.0.0.1:${POSTGRES_PORT:-5433}:5432
volumes:
- app_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-chatbot}"]
interval: 3s
timeout: 3s
retries: 10
app-minio:
image: cgr.dev/chainguard/minio
restart: always
entrypoint: sh
command: -c 'mkdir -p /data/${MINIO_BUCKET:-chatbot-source-files} && minio server --address ":9000" --console-address ":9001" /data'
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-chatbot}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-chatbot-secret}
ports:
- 127.0.0.1:9100:9000
- 127.0.0.1:9101:9001
volumes:
- app_minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 5s
retries: 5
start_period: 1s
app-qdrant:
image: docker.io/qdrant/qdrant
restart: always
ports:
- 127.0.0.1:6343:6333
- 127.0.0.1:6344:6334
volumes:
- app_qdrant_data:/qdrant/storage
healthcheck:
test: ["CMD-SHELL", "bash -c '</dev/tcp/localhost/6333' 2>/dev/null || exit 1"]
interval: 3s
timeout: 3s
retries: 10
start_period: 3s
volumes:
app_postgres_data:
driver: local
app_minio_data:
driver: local
app_qdrant_data:
driver: local

View File

@@ -105,6 +105,62 @@ Qdrant's `update_filter`, giving an optimistic-concurrency-style guard
against races between a concurrent ingestion re-run (ADR-0001) and a manual against races between a concurrent ingestion re-run (ADR-0001) and a manual
edit through this API. edit through this API.
### Re-embedding on content edit
`PUT /points/{point_id}` can change `content`, which leaves the stored
vectors stale unless they're recomputed. When `content` changes, the point
is **re-embedded inline**, reusing the same async embedding ports and
batch/semaphore bounds ingestion uses ([0017](0017-synchronous-ingestion-in-the-request-path.md)),
for parity between the two write paths. When `content` is unchanged, the
edit applies only the supplied vector/payload fields and skips re-embedding
entirely. Failure modes on this path reuse ingestion's status codes: `502`
on embedder failure, `504` if the edit's embedding step exceeds the same
timeout budget class as ingestion. The `version` guard (`update_filter`)
still applies to the write — re-embedding happens before the guarded write,
not instead of it, so a stale-version edit still fails with `409` rather
than re-embedding for nothing.
Rejected alternatives: requiring the caller to supply vectors when content
changes (pushes model knowledge onto the client, and is easy to get subtly
wrong); marking the point stale for background re-embedding later (needs
background work, which ADR-0017 currently rules out for this slice).
### `order_id` gap exhaustion
Repeatedly inserting into the same gap between two neighbors eventually
exhausts float precision (ADR-0001's known limitation). This slice does
**not** ship a renormalize endpoint. Instead, any operation that assigns a
new fractional `order_id` between two neighbors (insert, reorder) computes
the resulting gap and:
- logs a structured warning (`points.order_id.gap_low`) with `file_id` and
the two neighbor point IDs once the gap falls under a defined safety
threshold, so the condition is observable before it becomes uninsertable;
- **rejects** the write with `409` and a distinct error code if the
computed gap is no longer representable (would collapse to one of the two
neighbor values), instead of silently applying an imprecise value.
Recovering from an exhausted gap is a manual data-fix operation covered by
the operator runbook, not an endpoint this slice builds — deferring the
renormalize primitive is acceptable, silently producing an unrepresentable
gap is not.
### `POST /points/batch` semantics
Batch requests are **all-or-nothing**, capped at **100 operations per
request**. The service layer validates every operation's `version`
precondition before applying any of them; if any operation's precondition
fails, the whole request is rejected with `409` and nothing is applied — no
partially-applied batch ever reaches Qdrant. This follows directly from the
`version`-guard rule above applied at the batch level, and from the
pointer-relinking rule (a reorder/insert/delete's neighbor updates must land
in the same `points/batch` call, and a partial relink is a defect): partial
application of a batch is exactly the failure mode that would produce a
stale pointer chain. The 100-operation cap is independent of ADR-0001's
64–256-point bulk-ingestion batch sizing — that number is about upload
throughput; this one bounds an admin/manual edit request to something that
comfortably finishes inside a normal request timeout.
## Consequences ## Consequences
### Positive ### Positive
@@ -122,6 +178,15 @@ edit through this API.
- Optimistic concurrency via `version` requires every writer (ingestion and - Optimistic concurrency via `version` requires every writer (ingestion and
this API) to consistently read-check-write; a writer that skips this can this API) to consistently read-check-write; a writer that skips this can
silently clobber concurrent edits. silently clobber concurrent edits.
- Inline re-embedding puts embedder latency and `502`/`504` failure modes on
an admin content edit, not just on ingestion — an edit that only intended
to fix a typo pays the same embedding cost as a fresh chunk.
- Deferring the `order_id` renormalize endpoint means a file whose gaps are
genuinely exhausted has no automated recovery in this slice; an operator
must intervene by hand until that endpoint exists.
- All-or-nothing batch semantics mean one stale operation in a 100-operation
batch fails the entire request, even when the other 99 operations are
independent and would have succeeded on their own.
## Alternatives Considered ## Alternatives Considered
@@ -132,3 +197,16 @@ edit through this API.
- **Client-supplied `tenant_id` in request body**: rejected — trusting - **Client-supplied `tenant_id` in request body**: rejected — trusting
client input for the isolation boundary is a direct multitenancy security client input for the isolation boundary is a direct multitenancy security
risk; it must come from server-side auth context. risk; it must come from server-side auth context.
- **Caller-supplied vectors on content edit**: rejected — pushes embedding
model knowledge onto the client and makes it easy to silently desync
vectors from content.
- **Mark-stale-and-re-embed-later on content edit**: rejected for this
slice — needs background work, which ADR-0017 currently rules out.
- **Renormalize `order_id` automatically within this slice**: rejected —
nothing in current scope has hit gap exhaustion; building the primitive
now is speculative. Revisit if the logged warning starts firing in
practice.
- **Partial-success batch semantics (per-operation status)**: rejected —
a partially-applied batch is exactly the failure mode that leaves the
pointer chain (`previous_chunk_id`/`next_chunk_id`) inconsistent, which
this ADR treats as a defect, not a degraded-but-acceptable outcome.

View File

@@ -164,16 +164,20 @@ metadata such as `domain`. File validation is server-side:
- Derive `tenant_id`, `created_by`, and `updated_by` from `AuthContext`, not - Derive `tenant_id`, `created_by`, and `updated_by` from `AuthContext`, not
from form fields. from form fields.
Ingestion may be slow because it parses, chunks, embeds, and writes many Ingestion parses, chunks, embeds, and writes many Qdrant points.
Qdrant points. The REST contract is job-shaped even if the first [ADR-0017](0017-synchronous-ingestion-in-the-request-path.md) supersedes the
implementation runs inline: job-shaped contract originally specified here: ingestion runs inline and the
response is terminal.
```text ```text
202 Accepted -> { file_id, ingestion_job_id, status: "queued" | "running" } 201 Created -> { file_id, ingestion_job_id, status: "succeeded", chunks_indexed }
``` ```
A durable worker/job queue can be added later without changing the API `ingestion_job_id` is retained so the attempt stays inspectable via
contract. `GET /v1/files/{file_id}`, and so a future move back to a queued `202 Accepted`
contract (ADR-0014) is additive for clients that already read it. Ingestion
failures are HTTP failures: `400` unparseable, `413` too large, `502` embedder
failure, `503` at capacity, `504` past the ingestion timeout.
### Point endpoints replace the older `/chunks` sketches ### Point endpoints replace the older `/chunks` sketches
@@ -228,7 +232,7 @@ Important status codes:
| Status | Use | | Status | Use |
|---|---| |---|---|
| `202 Accepted` | Ingestion accepted as a job. | | `201 Created` | Ingestion completed inline (ADR-0017). |
| `400 Bad Request` | Invalid domain/filter combinations or unsupported file type. | | `400 Bad Request` | Invalid domain/filter combinations or unsupported file type. |
| `401 Unauthorized` | Missing/invalid API key. | | `401 Unauthorized` | Missing/invalid API key. |
| `403 Forbidden` | Valid key without required scope. | | `403 Forbidden` | Valid key without required scope. |
@@ -252,8 +256,8 @@ and Qdrant operations can be correlated.
records — while preserving the chunk payload schema underneath. records — while preserving the chunk payload schema underneath.
- `/threads/{thread_id}/runs` remains compatible with the LangGraph thread/run - `/threads/{thread_id}/runs` remains compatible with the LangGraph thread/run
model already chosen in ADR-0007. model already chosen in ADR-0007.
- Job-shaped file ingestion lets the first implementation be simple while - Inline file ingestion (ADR-0017) gives callers a terminal result in one
keeping room for a durable worker without breaking clients. request, with failures surfaced as ordinary HTTP errors.
- Router-level dependencies and typed FastAPI dependencies keep auth, tenant - Router-level dependencies and typed FastAPI dependencies keep auth, tenant
resolution, sessions, and scopes reusable instead of repeated per endpoint. resolution, sessions, and scopes reusable instead of repeated per endpoint.
@@ -266,8 +270,9 @@ and Qdrant operations can be correlated.
public product API. public product API.
- API-key auth in Postgres adds a database lookup to every request unless - API-key auth in Postgres adds a database lookup to every request unless
short-lived caching is introduced. Caching must preserve revocation semantics. short-lived caching is introduced. Caching must preserve revocation semantics.
- A job-shaped ingestion contract needs a job status store even if the initial - Inline ingestion ties the upload's duration to proxy/client timeouts, and
implementation processes inline. moving back to a queued `202` contract later is a breaking change for clients
(see ADR-0017's trigger list). The `ingestion_jobs` store is kept either way.
- The REST layer now depends on the tenant/API-key, ingestion-job, audit, and - The REST layer now depends on the tenant/API-key, ingestion-job, audit, and
usage tables defined in [ADR-0009](0009-postgres-sqlalchemy-alembic-schema.md). usage tables defined in [ADR-0009](0009-postgres-sqlalchemy-alembic-schema.md).
@@ -288,10 +293,12 @@ and Qdrant operations can be correlated.
- **Expose one generic `/v1/qdrant/*` proxy**: rejected. It would leak Qdrant's - **Expose one generic `/v1/qdrant/*` proxy**: rejected. It would leak Qdrant's
full API surface, bypass tenant/scoping rules too easily, and couple clients full API surface, bypass tenant/scoping rules too easily, and couple clients
to storage operations the service should hide. to storage operations the service should hide.
- **Synchronous file ingestion only**: rejected as the contract. It is simpler - **Synchronous file ingestion only**: originally rejected here on the grounds
to implement, but embedding and late-interaction vector generation can be that embedding and late-interaction vector generation can exceed HTTP timeouts;
slow enough to exceed HTTP timeouts. The job-shaped response gives the **adopted** by ADR-0017 for the first slice. Dense embedding is async network
implementation room to evolve. I/O that batches and runs concurrently, and late-interaction vectors are not
populated at ingest yet — which is what made the original objection decisive
and is now the named trigger for reverting to a job-shaped contract.
- **Create threads with `POST /v1/threads`**: rejected for now. The main - **Create threads with `POST /v1/threads`**: rejected for now. The main
backend owns conversation/session records, and LangGraph can create a backend owns conversation/session records, and LangGraph can create a
checkpoint sequence on the first run for a `thread_id`. A create endpoint checkpoint sequence on the first run for a `thread_id`. A create endpoint

View File

@@ -47,6 +47,7 @@ Use SQLAlchemy 2.x ORM models with typed mappings:
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
class Tenant(Base): class Tenant(Base):
__tablename__ = "tenants" __tablename__ = "tenants"
@@ -208,9 +209,12 @@ on the `content_hash` policy.
#### `ingestion_jobs` #### `ingestion_jobs`
One attempt to parse/chunk/embed/upsert a source file. This table is required One attempt to parse/chunk/embed/upsert a source file. Under
even if the first implementation processes inline, because ADR-0008's file [ADR-0017](0017-synchronous-ingestion-in-the-request-path.md) that attempt runs
upload contract is job-shaped. inline in the upload request, so a row is written `running` before the work and
updated to a terminal status after it — the table is a durable record of the
attempt, not a queue. It is what makes failures inspectable, re-ingestion
idempotent, and a later move back to queued dispatch (ADR-0014) additive.
| Column | Notes | | Column | Notes |
|---|---| |---|---|

View File

@@ -165,6 +165,7 @@ Avoid hidden global access:
# Do not do this. # Do not do this.
session = SessionLocal() session = SessionLocal()
async def create_user(data: CreateUserRequest) -> User: async def create_user(data: CreateUserRequest) -> User:
session.add(User(email=data.email)) session.add(User(email=data.email))
await session.commit() await session.commit()
@@ -206,8 +207,7 @@ async def get_point(
qdrant: QdrantClient, qdrant: QdrantClient,
auth: AuthContext, auth: AuthContext,
point_id: str, point_id: str,
) -> PointResponse: ) -> PointResponse: ...
...
``` ```
Do not have lower layers import mutable resource singletons. Explicit parameters Do not have lower layers import mutable resource singletons. Explicit parameters

View File

@@ -2,7 +2,14 @@
## Status ## Status
Proposed Superseded by
[0017](0017-synchronous-ingestion-in-the-request-path.md)
ADR-0017 defers this decision rather than rejecting it: ingestion currently runs
inline in the `POST /v1/files` request, with no broker, no outbox, and no queue.
This ADR remains the intended design for when ingestion becomes slow enough to
need one — ADR-0017 lists the triggers, and names an in-process Postgres-claimed
runner as the likely intermediate step before adopting a broker.
## Context ## Context

View File

@@ -4,6 +4,13 @@
Proposed Proposed
> Amended by [ADR-0017](0017-synchronous-ingestion-in-the-request-path.md):
> there is no broker, outbox publisher, queue, or separate worker process for
> now. `messaging/`, `workers/`, and `infrastructure/rabbitmq/` are part of the
> target shape but are **not created yet**; ingestion runs inline in the request,
> so the FastAPI route is the only entry adapter and it calls
> `application/ingestion/` directly. Every layering rule below applies unchanged.
## Context ## Context
The repository currently contains only a small FastAPI-oriented scaffold under The repository currently contains only a small FastAPI-oriented scaffold under
@@ -69,17 +76,18 @@ src/
│ │ ├── models/ │ │ ├── models/
│ │ ├── repositories/ │ │ ├── repositories/
│ │ ├── database.py │ │ ├── database.py
│ │ └── outbox.py │ │ └── outbox.py # ADR-0017: not yet; no outbox today
│ ├── qdrant/ │ ├── qdrant/
│ ├── minio/ │ ├── minio/
│ ├── rabbitmq/ │ ├── embedding/ # async embedding adapters (batched, bounded)
│ ├── rabbitmq/ # ADR-0017: not yet; ADR-0014 target only
│ ├── langgraph/ │ ├── langgraph/
│ └── observability/ │ └── observability/
├── messaging/ ├── messaging/ # ADR-0017: not yet; ADR-0014 target only
│ ├── events.py │ ├── events.py
│ ├── subjects.py │ ├── subjects.py
│ └── outbox_publisher.py │ └── outbox_publisher.py
└── workers/ └── workers/ # ADR-0017: not yet; ADR-0014 target only
├── ingestion.py ├── ingestion.py
└── maintenance.py └── maintenance.py
``` ```
@@ -103,7 +111,6 @@ tests/
├── integration/ ├── integration/
│ ├── postgres/ │ ├── postgres/
│ ├── minio/ │ ├── minio/
│ ├── rabbitmq/
│ └── qdrant/ │ └── qdrant/
└── e2e/ └── e2e/
``` ```
@@ -193,8 +200,10 @@ bootstrap. Graph nodes call application services, particularly
- `qdrant/` owns Qdrant client lifecycle, collection/bootstrap helpers, low-level - `qdrant/` owns Qdrant client lifecycle, collection/bootstrap helpers, low-level
point operations, and hybrid retrieval adapter mechanics. point operations, and hybrid retrieval adapter mechanics.
- `minio/` implements object-storage operations against MinIO/S3-compatible APIs. - `minio/` implements object-storage operations against MinIO/S3-compatible APIs.
- `embedding/` implements the async dense/sparse embedding adapters, including
provider batching and the concurrency semaphore from ADR-0017.
- `rabbitmq/` owns the RabbitMQ connection/channel lifecycle plus low-level - `rabbitmq/` owns the RabbitMQ connection/channel lifecycle plus low-level
publish and consumer adapters (aio-pika). publish and consumer adapters (aio-pika). Not created while ADR-0017 stands.
- `langgraph/` configures the concrete Postgres-backed LangGraph persistence - `langgraph/` configures the concrete Postgres-backed LangGraph persistence
adapters. adapters.
- `observability/` configures structlog and Langfuse integrations. - `observability/` configures structlog and Langfuse integrations.
@@ -204,6 +213,12 @@ must not create mutable external clients at import time.
### Messaging and workers ### Messaging and workers
Under ADR-0017 neither package exists yet: ingestion runs inline in the request,
so the FastAPI route plays the entry-adapter role described here and obeys the
same rule — it binds logging context and invokes an application service, and
holds no parsing/chunking/Qdrant business logic itself. The rest of this section
describes the shape both packages take when ADR-0014 is adopted.
`messaging/` contains versioned event schemas, stable routing-key names, and the `messaging/` contains versioned event schemas, stable routing-key names, and the
outbox-publisher orchestration. The outbox publisher coordinates Postgres outbox outbox-publisher orchestration. The outbox publisher coordinates Postgres outbox
records with the RabbitMQ adapter; it does not become a second source of job records with the RabbitMQ adapter; it does not become a second source of job

View File

@@ -4,17 +4,24 @@
Proposed Proposed
> Amended by [ADR-0017](0017-synchronous-ingestion-in-the-request-path.md):
> there is no broker, outbox, or queue, so the `rabbitmq` marker and
> `tests/integration/rabbitmq/` are not carried until ADR-0014 is adopted.
> Ingestion is exercised through the upload request itself, which now returns a
> terminal result. Every reliability invariant below still applies — retrying an
> upload stands in for redelivery.
## Context ## Context
The project has ADRs for tenant-scoped ingestion, explicit resource ownership, The project has ADRs for tenant-scoped ingestion, explicit resource ownership,
MinIO object storage, transactional outbox dispatch, RabbitMQ workers, MinIO object storage, inline request-path ingestion,
Qdrant indexing, and a modular monolith. It has no test runner, test fixtures, Qdrant indexing, and a modular monolith. It has no test runner, test fixtures,
or executable test suite yet. or executable test suite yet.
The first CSV ingestion slice has correctness properties that cannot be left to The first CSV ingestion slice has correctness properties that cannot be left to
manual testing: Alembic is the only schema-management path; tenant identity is manual testing: Alembic is the only schema-management path; tenant identity is
trusted server-side context; a file, job, and outbox event commit atomically; trusted server-side context; the source file and its job row commit
workers are safe under at-least-once delivery; and generated Qdrant points are atomically; job execution is safe under at-least-once semantics; and generated Qdrant points are
idempotent and tenant-filtered. ADR-0015 already reserves a test layout by idempotent and tenant-filtered. ADR-0015 already reserves a test layout by
boundary, while ADR-0012 requires explicit dependencies and resource lifetimes boundary, while ADR-0012 requires explicit dependencies and resource lifetimes
that should make tests practical without import-time client patching. that should make tests practical without import-time client patching.
@@ -23,8 +30,11 @@ Tests need to give fast feedback during implementation without replacing
integration coverage with mocks or making routine development depend on Docker, integration coverage with mocks or making routine development depend on Docker,
provider credentials, live models, or Langfuse availability. provider credentials, live models, or Langfuse availability.
ADR-0014's transactional-outbox decision controls ingestion dispatch. The upload ADR-0017 controls ingestion: `POST /v1/files` parses, chunks, embeds, and
path records durable dispatch intent; a separate outbox publisher publishes it. indexes inline, then returns a terminal `201`. `ingestion_jobs` records the
attempt. There is no outbox, queue, or publisher to test — but the request's
bounds (size, timeout, capacity) and its two-transaction shape are testable
contracts.
## Decision ## Decision
@@ -42,8 +52,8 @@ ADR-0012. `FastAPI.TestClient` is not the default project test client.
Register these markers: Register these markers:
- one primary boundary marker per test: `unit`, `integration`, or `e2e`; - one primary boundary marker per test: `unit`, `integration`, or `e2e`;
- `postgres`, `minio`, `rabbitmq`, or `qdrant` for the real service used by an - `postgres`, `minio`, or `qdrant` for the real service used by an integration
integration test; test (a `rabbitmq` marker returns with ADR-0014);
- `slow` only where a test materially exceeds the normal integration feedback - `slow` only where a test materially exceeds the normal integration feedback
target; target;
- `live_provider` for an opt-in, credential-gated external-provider smoke test. - `live_provider` for an opt-in, credential-gated external-provider smoke test.
@@ -68,7 +78,6 @@ tests/
├── integration/ ├── integration/
│ ├── postgres/ │ ├── postgres/
│ ├── minio/ │ ├── minio/
│ ├── rabbitmq/
│ └── qdrant/ │ └── qdrant/
└── e2e/ └── e2e/
``` ```
@@ -84,7 +93,7 @@ tests/
tests. tests.
Hand-written fakes and spies implement narrow application-owned ports, not Hand-written fakes and spies implement narrow application-owned ports, not
MinIO, RabbitMQ, Qdrant, or model SDK-shaped interfaces. Scripted model, embedder, MinIO, Qdrant, or model SDK-shaped interfaces. Scripted model, embedder,
retrieval, clock, and UUID fakes make normal test runs deterministic. retrieval, clock, and UUID fakes make normal test runs deterministic.
### Apply pragmatic TDD ### Apply pragmatic TDD
@@ -106,7 +115,7 @@ real-adapter integration test before declaring that boundary complete.
### Use disposable real infrastructure in integration tests ### Use disposable real infrastructure in integration tests
Use Testcontainers as the standard automated integration-test resource mechanism Use Testcontainers as the standard automated integration-test resource mechanism
for Postgres, MinIO, RabbitMQ, and Qdrant. for Postgres, MinIO, and Qdrant.
- Tests never connect to a developer's local services or Langfuse-owned storage - Tests never connect to a developer's local services or Langfuse-owned storage
and credentials. and credentials.
@@ -119,8 +128,8 @@ for Postgres, MinIO, RabbitMQ, and Qdrant.
adapters through their normal constructors. adapters through their normal constructors.
Docker Compose remains the mechanism for manual local validation and a later, Docker Compose remains the mechanism for manual local validation and a later,
serialized operational smoke test where web, outbox-publisher, and worker run as serialized operational smoke test of the running web process, which performs
independent processes. It is not the default pytest fixture mechanism. ingestion inline under ADR-0017. It is not the default pytest fixture mechanism.
### Treat invariants as reusable contracts ### Treat invariants as reusable contracts
@@ -128,21 +137,25 @@ Test the following requirements at the applicable application, adapter, and E2E
boundaries: boundaries:
- Tenant identity comes from server-side authenticated context. Request payloads, - Tenant identity comes from server-side authenticated context. Request payloads,
query parameters, object metadata, and broker messages cannot override it. query parameters, object metadata, and job payloads cannot override it.
- Cross-tenant access does not disclose tenant-owned data. Public routes normally - Cross-tenant access does not disclose tenant-owned data. Public routes normally
return `404` for inaccessible resources. return `404` for inaccessible resources.
- Alembic creates the schema from an empty database. Tests never use - Alembic creates the schema from an empty database. Tests never use
`Base.metadata.create_all()`, and FastAPI startup performs readiness checks only, `Base.metadata.create_all()`, and FastAPI startup performs readiness checks only,
never DDL. never DDL.
- The upload transaction records `source_files`, a queued `ingestion_jobs` row, - The first upload transaction records `source_files` and a `running`
and an unpublished `outbox_events` row atomically. The HTTP route does not `ingestion_jobs` row atomically, and commits before any parse/embed work; no
directly publish the ingestion event. session or transaction is held open across that work.
- Broker messages contain durable identifiers and correlation metadata only. The - Every terminating path — success, parse failure, embedder failure, timeout —
worker reloads job and source-file records from Postgres before tenant-scoped writes a terminal job status and its `ingestion_job_events` row. A job is
side effects. never left in `running` by a handled failure.
- Worker acknowledgement follows durable progress or terminal-state persistence. - Each bound maps to its status code: oversized upload `413`, capacity `503`,
Duplicate publication and redelivery do not regress terminal jobs, inflate timeout `504`, embedder failure `502`.
counters, or create duplicate logical chunks. - Retrying an upload does not regress terminal jobs, inflate counters, or create
duplicate logical chunks; identical content is recognized rather than
re-ingested.
- Embedding is batched and concurrency-bounded rather than serial per chunk, and
blocking work is executed off the event loop under an explicit limiter.
- MinIO keys are server-derived internal paths. Qdrant reads and mutations use a - MinIO keys are server-derived internal paths. Qdrant reads and mutations use a
server-derived tenant filter, deterministic point IDs, and upsert semantics. server-derived tenant filter, deterministic point IDs, and upsert semantics.
@@ -176,8 +189,8 @@ ratcheting threshold rather than encouraging low-value coverage.
- Unit tests provide fast, deterministic TDD feedback for core application - Unit tests provide fast, deterministic TDD feedback for core application
behavior. behavior.
- Real-service tests cover the behaviors least safe to simulate: Alembic - Real-service tests cover the behaviors least safe to simulate: Alembic
migrations, object storage, RabbitMQ acknowledgements/redelivery, and Qdrant migrations, object storage, transaction boundaries under real sessions, and
filtering/upserts. Qdrant filtering/upserts.
- Explicit fakes reinforce the dependency direction and resource ownership rules - Explicit fakes reinforce the dependency direction and resource ownership rules
from ADR-0012 and ADR-0015. from ADR-0012 and ADR-0015.
- The ingestion path has concrete tenant-isolation and reliability contracts, - The ingestion path has concrete tenant-isolation and reliability contracts,
@@ -197,8 +210,8 @@ ratcheting threshold rather than encouraging low-value coverage.
## Alternatives Considered ## Alternatives Considered
- **Mock all external SDKs**: rejected. Mocks cannot prove migrations, real - **Mock all external SDKs**: rejected. Mocks cannot prove migrations, real
RabbitMQ acknowledgement/redelivery behavior, MinIO semantics, or Qdrant transaction/connection behavior, MinIO semantics, or Qdrant tenant
tenant filtering. filtering.
- **Use full-stack Compose tests only**: rejected. They are slow and opaque for - **Use full-stack Compose tests only**: rejected. They are slow and opaque for
the default development loop and make failures difficult to localize. the default development loop and make failures difficult to localize.
- **Run all integration containers on every pytest invocation**: rejected. Test - **Run all integration containers on every pytest invocation**: rejected. Test

View File

@@ -0,0 +1,252 @@
# 0017. Synchronous ingestion in the request path
## Status
Proposed
## Context
ADR-0014 chose RabbitMQ plus a transactional outbox for durable job dispatch,
with three process entrypoints (web, outbox publisher, ingestion worker).
ADR-0008 made `POST /v1/files` job-shaped — `202 Accepted` with an
`ingestion_job_id` and a non-terminal status — explicitly so that a durable
queue could be added later without breaking clients.
That contract was chosen on the assumption that ingestion is slow enough to
exceed HTTP timeouts. For the first slice, that assumption does not hold, and
paying for it costs a broker, an outbox table, a publisher process, a worker
process, and a client that must poll for completion.
What ingestion actually does per file, and how expensive each stage is:
| Stage | Kind of work | Cost |
|---|---|---|
| Parse (`python-docx`, `csv`) | Blocking CPU, sync SDK | Modest; seconds at worst for a large document |
| Chunk (fixed-size, ADR-0004) | Blocking CPU, pure Python | Negligible |
| `dense_nomic`, `dense_openai` | **Async network I/O** | Dominant by wall-clock, but batchable and concurrent |
| `sparse` (own BM25 pipeline, ADR-0005) | Blocking CPU | Modest, GIL-bound |
| Qdrant upsert | Async network I/O | Modest, batchable |
The dense embedding stage is the largest term, but it is I/O, not computation
this process performs: both embedders take arrays of inputs, and independent
batches can be in flight at once. A few hundred chunks is a small number of
batched requests, issued concurrently — not a serial per-chunk round trip.
`late_interaction` (jina-colbert-v2) is the one genuinely heavy ingest-time
stage: a document-side multivector per chunk from a local GPU model requiring
`flash_attn`. ADR-0001 defines the vector now to avoid collection recreation but
does not require it to be populated, and plan 001 puts the reranker and GPU
deployment explicitly out of scope for the first slice. It is therefore **not
computed during synchronous ingestion**, and enabling it is a trigger to revisit
this decision rather than a cost this decision has to carry.
## Decision
**`POST /v1/files` performs ingestion inline and returns a terminal result.** No
broker, no outbox, no queue, no worker process, and no polling by the client.
```text
POST /v1/files
-> authenticate, resolve tenant, validate the upload
-> 201 Created { file_id, ingestion_job_id, status: "succeeded", chunks_indexed }
```
ADR-0008's job-shaped `202 Accepted` contract is superseded by this ADR;
ADR-0014 remains `Superseded by 0017` and is the design to adopt if the triggers
below are hit. `ingestion_jobs` is kept exactly as ADR-0009 defines it — it is
now a record of an ingestion *attempt* rather than a queue entry, and it is what
makes failures inspectable and re-ingestion idempotent.
### Request shape
The request runs in three phases, and the phase boundaries matter:
```text
1. txn A (short): insert source_files
insert ingestion_jobs(status='running', started_at=now())
commit -- release the connection
2. no transaction: store bytes in MinIO
parse + chunk (threads)
embed dense + sparse (async, batched, bounded)
upsert Qdrant points (deterministic ids)
3. txn B (short): update ingestion_jobs -> succeeded/failed + counters
append ingestion_job_events
commit
```
**Never hold a Postgres session or transaction open across phase 2.** A slow
upload would otherwise pin a pool connection — and an idle-in-transaction row
lock — for the entire ingestion. Take a session, commit, release, and take a
fresh one for phase 3. This is ADR-0012's "one session per unit of work" applied
to a request that contains two units.
Committing the job row *before* the work means a request that dies mid-ingestion
still leaves durable evidence: a `running` job that never reached a terminal
state, discoverable by age.
### Embedding is concurrent and batched, not serial
Chunks are embedded through the async embedding ports, batched per provider
limits, with independent batches in flight concurrently and bounded by a
semaphore:
```python
limiter = asyncio.Semaphore(settings.ingestion.embed_concurrency)
async def embed_batch(batch: Sequence[str]) -> list[Vector]:
async with limiter:
return await embedder.embed(batch)
batches = chunk_into_batches(texts, size=settings.ingestion.embed_batch_size)
vectors = flatten(await asyncio.gather(*(embed_batch(b) for b in batches)))
```
Rules:
- **Batch before you parallelize.** Both dense embedders accept arrays; sending
one request per chunk wastes far more time than concurrency can recover.
- **Bound concurrency with a semaphore**, never an unbounded `gather` over every
batch. The limit exists for the provider's rate limits and for the self-hosted
nomic server's capacity — past saturation, extra concurrency just moves the
queue somewhere you cannot see it.
- Retry `429`/transient failures with backoff *inside* the request's overall
timeout budget, not beyond it.
- The two dense embedders are themselves independent and run concurrently with
each other.
### Blocking work runs on threads
Parsing (`python-docx`, `csv`), chunking, hashing, the sparse BM25 pipeline, and
the synchronous `minio` SDK are blocking. They run via
`anyio.to_thread.run_sync`, with an explicit `anyio.CapacityLimiter` so ingestion
threads cannot exhaust the pool Starlette uses for sync route handlers and
dependencies:
```python
chunks = await anyio.to_thread.run_sync(parse_and_chunk, raw_bytes, limiter=ingestion_limiter)
```
Calling any of them directly from an `async def` service is a defect: one large
`python-docx` parse would block every concurrent request in the process.
### The request is bounded, and says so when it cannot finish
Synchronous ingestion means the client's timeout is now the system's deadline.
Three bounds, all configured, all enforced server-side:
- `INGESTION_MAX_UPLOAD_SIZE_MB` (and a max chunk count) reject work that is
obviously too large *before* any of it starts — a `413`, not a timeout.
- `INGESTION_TIMEOUT_SECONDS` bounds the whole of phase 2. On expiry the job is
marked `failed` with a timeout error code in phase 3, and the response is
`504`. A timeout must never leave the job stuck in `running`.
- `INGESTION_MAX_CONCURRENCY` bounds how many ingestions run in the process at
once. Over the limit, the request is rejected with `503` and `Retry-After`
rather than queued behind an unbounded wait — a queue that the client is
blocked on is the worst of both designs.
Document the deployment consequence: proxy, load balancer, and client read
timeouts must all exceed `INGESTION_TIMEOUT_SECONDS`, or the client will give up
on work that is still succeeding.
### Re-running an ingestion stays safe
The idempotency requirements survive, because a client that times out will
retry, and phase 2 has no transaction protecting it:
- Point ids are deterministic from `file_id` + `chunk_index` (ADR-0001), so a
retried upload upserts rather than duplicates.
- Identical uploads are recognized by `(tenant_id, domain, content_sha256)` and
return the existing file/job rather than re-ingesting (plan 001).
- `tenant_id` comes from `AuthContext`, never from the request body.
- A terminal job is never transitioned back to `running`.
- Qdrant points from a failed attempt do not replace the previous successful
index; replacement happens only after a successful attempt.
### Failures are HTTP failures
There is no dead-letter queue and no retry loop. A failed ingestion marks the
job `failed` with an error code and a terminating `ingestion_job_events` row,
and returns the corresponding status code (`400` for an unparseable file, `413`
too large, `503` at capacity, `504` on timeout, `502` for an embedder failure).
The client decides whether to retry, which is the correct owner of that decision
when the client is synchronous.
### `GET /v1/files/{file_id}` stays
It reports the stored job status. It is no longer a polling mechanism for the
upload, but it remains how an operator inspects a past ingestion, and how a
client that lost its connection mid-upload discovers what happened.
### When to revisit
Return to a queue — ADR-0014's design, or an in-process Postgres-claimed runner
as an intermediate step — when any of these becomes true:
- `late_interaction` document vectors are populated at ingest (GPU, per-chunk
model inference: this alone is likely sufficient);
- typical ingestion approaches `INGESTION_TIMEOUT_SECONDS`, or `504`/`503` rates
stop being negligible;
- file types arrive that are large or slow enough to be unbounded (bulk XLSX,
scanned PDFs with OCR);
- ingestion load starts degrading chat/retrieval latency in the same process;
- ingestion needs to be retried automatically rather than by the caller.
Track p95 ingestion duration, `503`/`504` counts, and the count of `running`
jobs older than the timeout. Those are the trigger metrics.
## Consequences
### Positive
- The client gets its answer in one call. No polling, no job-status endpoint in
the happy path, no "queued" state to explain to a frontend.
- The first slice needs Postgres, MinIO, and Qdrant only — no broker, no outbox
table, no publisher or worker process, no exchange/queue/DLX provisioning.
- Failures reach the caller directly, with a real status code and message,
instead of being discovered later by polling a job row.
- One process to run, deploy, and reason about; `docker compose up` is the whole
application.
- Batched, bounded-concurrent embedding is a property worth having regardless of
where ingestion runs — it carries over unchanged into a queued design.
### Negative
- The request's duration is now a product constraint. Proxy/client timeouts
become deployment configuration that can silently break uploads.
- Ingestion competes with chat/retrieval for CPU, threads, and connections in
the same process, and a burst of uploads degrades API latency for everyone.
- No automatic retry: a transient embedder failure surfaces as a `502` and
depends on the caller to retry.
- A client disconnect does not cancel the work, and leaves a job that can sit in
`running` until observed by age.
- Capacity rejection (`503`) is a worse experience than queueing for callers who
would rather wait — accepted deliberately, because a blocked client waiting on
a hidden queue is worse still.
- Reversing this decision changes the HTTP contract (`201` with a terminal status
becomes `202` with a pending one), so clients would have to change. This is the
real cost of the decision, and the reason the trigger list above is explicit.
## Alternatives Considered
- **Job-shaped `202 Accepted` with a queue (ADR-0008 as written, ADR-0014)**:
rejected for now. It is the right design once ingestion is genuinely slow or
needs automatic retry, and the trigger list says when to adopt it. Building it
first would add a broker, an outbox, and two processes to make a
seconds-long operation asynchronous.
- **In-process Postgres-claimed job runner with `202`**: rejected as the current
step, but it is the natural intermediate — it keeps the single process and the
single deployment while decoupling the request from the work. It is the first
thing to reach for when the triggers fire, before adopting a broker.
- **Serial per-chunk embedding**: rejected. It is the version of synchronous
ingestion that genuinely would exceed HTTP timeouts, and it is avoidable with
batching plus bounded concurrency.
- **Unbounded `asyncio.gather` over all batches**: rejected. It converts a large
upload into a rate-limit burst against the provider and an unbounded memory
spike locally; the semaphore is what makes the concurrency safe.
- **Run the blocking stages on the event loop directly**: rejected. A single
large `python-docx` parse would stall every concurrent request in the process.
- **`BackgroundTasks` after returning `201`**: rejected as the worst of both —
the client is told the work succeeded before it has, with no durable record
and no retry if the process restarts.
- **Populate `late_interaction` during synchronous ingestion**: rejected for this
slice, consistent with plan 001's scope. Per-chunk GPU model inference is the
stage that makes ingestion unbounded; adding it is a trigger to revisit this
ADR, not something to absorb into a request.

View File

@@ -4,9 +4,8 @@
This plan turns the accepted architectural direction in the ADRs into the first This plan turns the accepted architectural direction in the ADRs into the first
working product slice: a tenant-scoped CSV upload is stored in MinIO, represented working product slice: a tenant-scoped CSV upload is stored in MinIO, represented
by durable Postgres records, dispatched through RabbitMQ using a by durable Postgres records, parsed/chunked/embedded inline in the request
transactional outbox, processed by a separate worker, and indexed as Qdrant (ADR-0017), and indexed as Qdrant points before the response returns.
points.
This is an implementation plan, not an Architecture Decision Record. ADRs explain This is an implementation plan, not an Architecture Decision Record. ADRs explain
why major technologies and boundaries were chosen; this document defines the why major technologies and boundaries were chosen; this document defines the
@@ -19,10 +18,9 @@ The first vertical slice uses these responsibilities:
| System | Responsibility | | System | Responsibility |
|---|---| |---|---|
| FastAPI | HTTP boundary, validation, authentication, tenant derivation, and job creation. | | FastAPI | HTTP boundary, validation, authentication, tenant derivation, and job creation. |
| Postgres | Tenant/auth data, source-file metadata, ingestion job state/progress, audit, and transactional outbox events. | | Postgres | Tenant/auth data, source-file metadata, ingestion attempt records/progress, and audit. |
| MinIO | Private source-file bytes and retained derived ingestion blobs. | | MinIO | Private source-file bytes and retained derived ingestion blobs. |
| RabbitMQ | Durable delivery of ingestion and maintenance work. | | Ingestion service | Parsing, chunking, embedding, ingestion-generated Chunk/Point CRUD, and job status updates — inline in the request. |
| Ingestion worker | Parsing, chunking, embedding, ingestion-generated Chunk/Point CRUD, and job status updates. |
| Qdrant | Tenant-filtered generated chunks and their vectors/payloads. | | Qdrant | Tenant-filtered generated chunks and their vectors/payloads. |
The controlling ADRs are: The controlling ADRs are:
@@ -35,9 +33,11 @@ The controlling ADRs are:
resource lifetime, dependency injection, and explicit transaction ownership. resource lifetime, dependency injection, and explicit transaction ownership.
- [ADR-0013](../adr/0013-s3-compatible-object-storage-with-minio.md): MinIO object - [ADR-0013](../adr/0013-s3-compatible-object-storage-with-minio.md): MinIO object
storage boundary. storage boundary.
- [ADR-0014](../adr/0014-durable-job-dispatch-with-rabbitmq.md): RabbitMQ, - [ADR-0017](../adr/0017-synchronous-ingestion-in-the-request-path.md):
transactional outbox, separate workers, and worker-owned ingestion Chunk/Point inline ingestion, batched/bounded-concurrent embedding,
CRUD. `anyio.to_thread.run_sync` for blocking work, and request bounds. It supersedes
[ADR-0014](../adr/0014-durable-job-dispatch-with-rabbitmq.md), which remains
the design to adopt when a broker becomes necessary.
The cited ADRs are currently proposed. Treat them as the implementation baseline The cited ADRs are currently proposed. Treat them as the implementation baseline
only after the project owner accepts them; code should not silently diverge from only after the project owner accepts them; code should not silently diverge from
@@ -50,13 +50,14 @@ them.
- `POST /v1/files` for authenticated tenant-scoped **CSV** upload. - `POST /v1/files` for authenticated tenant-scoped **CSV** upload.
- File validation, size limits, content hashing, and streaming upload to MinIO. - File validation, size limits, content hashing, and streaming upload to MinIO.
- Alembic-managed Postgres schema for the minimal tenant/auth, source file, - Alembic-managed Postgres schema for the minimal tenant/auth, source file,
ingestion job, job event, and outbox records needed by this slice. ingestion job, and job event records needed by this slice.
- Transactional outbox publication of `ingestion.job.created` to RabbitMQ. - Inline ingestion in `POST /v1/files`, with batched/bounded-concurrent
- A separate ingestion worker process with a durable RabbitMQ consumer. embedding, thread-offloaded parsing, and enforced size/timeout/capacity
bounds.
- CSV parsing and deterministic chunk creation. - CSV parsing and deterministic chunk creation.
- Tenant-filtered Qdrant point upserts using deterministic point identifiers. - Tenant-filtered Qdrant point upserts using deterministic point identifiers.
- Job status/progress persistence and `GET /v1/files/{file_id}` status lookup. - Job status/progress persistence and `GET /v1/files/{file_id}` status lookup.
- Structured correlation logging at HTTP, outbox, and worker ingress. - Structured correlation logging at HTTP and ingestion-stage boundaries.
- Automated tests for the critical state transitions, redelivery, and tenant - Automated tests for the critical state transitions, redelivery, and tenant
boundaries. boundaries.
@@ -70,31 +71,40 @@ them.
ingestion. ingestion.
- Full tenant erasure and hard-deletion workflow. - Full tenant erasure and hard-deletion workflow.
- A public download API or presigned object URLs. - A public download API or presigned object URLs.
- Exactly-once end-to-end processing. The worker must instead be safe under - A message broker, transactional outbox, job queue, and separate
at-least-once delivery. worker/publisher processes (ADR-0014, deferred by ADR-0017).
- `late_interaction` (jina-colbert-v2) document vectors at ingest — populating
them is ADR-0017's primary trigger to move ingestion back off the request.
- Exactly-once end-to-end processing. Job execution must instead be safe when a
job runs more than once.
## Required invariants ## Required invariants
The implementation must preserve these rules from the ADRs: The implementation must preserve these rules from the ADRs:
1. `tenant_id` is derived from trusted authentication context; it is never 1. `tenant_id` is derived from trusted authentication context; it is never
accepted from the upload body, query parameters, MinIO metadata, or a broker accepted from the upload body, query parameters, MinIO metadata, or a
message as authority. dispatch payload as authority.
2. MinIO stores bytes; Postgres stores metadata, lifecycle state, job progress, 2. MinIO stores bytes; Postgres stores metadata, lifecycle state, job progress,
audit records, and dispatch intent. audit records, and the queue.
3. Broker messages contain stable IDs and correlation metadata only. They never 3. Dispatch payloads contain stable IDs and correlation metadata only. They never
contain file bytes, extracted text, chunks, embeddings, secrets, raw prompts, contain file bytes, extracted text, chunks, embeddings, secrets, raw prompts,
or raw model output. or raw model output.
4. The worker reloads the job and source-file records from Postgres before doing 4. Ingestion works from the persisted job and source-file records, not from
tenant-scoped work. request-supplied values.
5. The worker, not the HTTP publisher, performs parsing, chunking, embedding, and 5. Parsing, chunking, embedding, and generated Qdrant Chunk/Point CRUD live in
generated Qdrant Chunk/Point CRUD. the ingestion application service, not in the route handler. Blocking work
runs through `anyio.to_thread.run_sync`, never directly on the event loop.
6. Qdrant reads and mutations are tenant-filtered. Ingestion-generated point IDs 6. Qdrant reads and mutations are tenant-filtered. Ingestion-generated point IDs
are deterministic so retrying a job does not create duplicate logical chunks. are deterministic so retrying a job does not create duplicate logical chunks.
7. The worker acknowledges a RabbitMQ message only after it has durably persisted 7. No Postgres session or transaction is held open across parse/embed/upsert.
the applicable Postgres progress/final state. The job row is committed `running` before the work and updated to a terminal
8. Application clients are built at FastAPI or worker-process startup and closed status after it, in a second short transaction.
at shutdown. No mutable clients are opened as import-time globals. 8. Application clients are built at FastAPI startup and closed at shutdown. No
mutable clients are opened as import-time globals.
9. Embedding is batched per provider limits and concurrency-bounded by a
semaphore; blocking work (parse, chunk, BM25, `minio`) runs through
`anyio.to_thread.run_sync` with an explicit `CapacityLimiter`.
## Required decisions before implementing the affected phase ## Required decisions before implementing the affected phase
@@ -120,22 +130,25 @@ rather than becoming an accidental repository behavior.
For the first release, file deletion should be soft and job-shaped: For the first release, file deletion should be soft and job-shaped:
1. mark the source file as deletion requested/soft deleted in Postgres; 1. mark the source file as deletion requested/soft deleted in Postgres;
2. write a maintenance outbox event; 2. soft-delete the related Qdrant points inline, recording the attempt;
3. have a worker soft-delete the related Qdrant points;
4. retain the MinIO object until an explicit retention or hard-erasure workflow. 4. retain the MinIO object until an explicit retention or hard-erasure workflow.
Hard deletion requires a later retention/erasure implementation covering MinIO, Hard deletion requires a later retention/erasure implementation covering MinIO,
Qdrant, and the relevant Postgres data. Qdrant, and the relevant Postgres data.
### Broker operations ### Ingestion bounds and operations
Before deploying an environment, define and document: Before deploying an environment, define and document:
- RabbitMQ exchange, queue, binding, and dead-letter-exchange configuration; - `INGESTION_MAX_CONCURRENCY` and the thread-pool capacity limiter, and their
- queue name, prefetch, manual-ack policy, and DLX retry/backoff; relation to the process's CPU/memory budget;
- worker concurrency and resource limits; - `INGESTION_TIMEOUT_SECONDS`, and the proxy/load-balancer/client read timeouts
- outbox polling/publish interval and stuck-event alert threshold; that must exceed it;
- how failed jobs are inspected, retried, and cancelled. - `INGESTION_EMBED_BATCH_SIZE` and `INGESTION_EMBED_CONCURRENCY`, sized to the
provider's rate limits and the self-hosted embedder's capacity;
- alert thresholds for p95 ingestion duration, `503`/`504` rates, and jobs left
in `running` past the timeout;
- how failed ingestions are inspected and retried.
These are deployment/runbook settings, not new ADRs unless they change the These are deployment/runbook settings, not new ADRs unless they change the
reliability guarantee or system boundary. reliability guarantee or system boundary.
@@ -144,15 +157,14 @@ reliability guarantee or system boundary.
### Phase 1: Foundation and local dependencies ### Phase 1: Foundation and local dependencies
1. Add typed configuration in `src/config.py` for Postgres, MinIO, RabbitMQ, 1. Add typed configuration in `src/config.py` for Postgres, MinIO, Qdrant,
Qdrant, application limits, and logging. ingestion bounds, application limits, and logging.
2. Populate `.env.example` with non-secret local-development configuration. 2. Populate `.env.example` with non-secret local-development configuration.
3. Add application Docker Compose services for Postgres, MinIO, RabbitMQ, and 3. Add application Docker Compose services for Postgres, MinIO, and Qdrant. Keep
Qdrant. Keep application MinIO buckets/credentials separate from Langfuse application MinIO buckets/credentials separate from Langfuse infrastructure.
infrastructure.
4. Add direct Python dependencies and lock them with `uv`: 4. Add direct Python dependencies and lock them with `uv`:
SQLAlchemy async/Postgres driver, MinIO/S3 client, aio-pika, Qdrant client, SQLAlchemy async/Postgres driver, MinIO/S3 client, Qdrant client, and
and structured logging dependencies chosen by ADR-0011. structured logging dependencies chosen by ADR-0011.
5. Add the pytest foundation from ADR-0016: async test configuration, boundary 5. Add the pytest foundation from ADR-0016: async test configuration, boundary
markers, and support for dependency-injected fakes. Add a lifespan smoke test markers, and support for dependency-injected fakes. Add a lifespan smoke test
before creating external clients. before creating external clients.
@@ -166,11 +178,10 @@ run without Docker or live providers.
### Phase 2: Database, migrations, and domain contracts ### Phase 2: Database, migrations, and domain contracts
1. Define SQLAlchemy models and Alembic migrations for the minimum required 1. Define SQLAlchemy models and Alembic migrations for the minimum required
tables: `tenants`, `api_keys`, `source_files`, `ingestion_jobs`, tables: `tenants`, `api_keys`, `source_files`, `ingestion_jobs`, and
`ingestion_job_events`, and `outbox_events`. `ingestion_job_events`, per ADR-0009.
2. Define Pydantic request/response/message schemas, including a versioned 2. Define Pydantic request/response schemas, including the terminal upload
`ingestion.job.created` message containing `event_id`, `tenant_id`, `file_id`, response (`file_id`, `ingestion_job_id`, `status`, `chunks_indexed`).
`ingestion_job_id`, `request_id`, and `api_key_id`.
3. Implement explicit repositories/services with a request/job-lifetime 3. Implement explicit repositories/services with a request/job-lifetime
`AsyncSession`; routes/services own commit/rollback boundaries as specified by `AsyncSession`; routes/services own commit/rollback boundaries as specified by
ADR-0012. ADR-0012.
@@ -188,75 +199,75 @@ reads/writes and valid job transitions.
2. Implement `POST /v1/files` for CSV only, including streaming-size controls, 2. Implement `POST /v1/files` for CSV only, including streaming-size controls,
file-type validation, SHA-256 calculation, and a private MinIO upload using file-type validation, SHA-256 calculation, and a private MinIO upload using
an internal object key. an internal object key.
3. In one Postgres transaction, persist `source_files`, create 3. In one short Postgres transaction, persist `source_files` and create
`ingestion_jobs(status='queued')`, and write the corresponding unpublished `ingestion_jobs(status='running')`, then commit and release the connection
`outbox_events` row. before any parse/embed work.
4. Return `202 Accepted` with `file_id`, `ingestion_job_id`, and `queued` status. 4. Return `201 Created` with `file_id`, `ingestion_job_id`, terminal status, and
`chunks_indexed` once ingestion completes.
5. Implement `GET /v1/files/{file_id}` with tenant filtering and a public status 5. Implement `GET /v1/files/{file_id}` with tenant filtering and a public status
response that does not expose raw storage credentials or internal artifacts. response that does not expose raw storage credentials or internal artifacts.
6. Add cleanup/compensation handling for a MinIO upload that succeeds while the 6. Add cleanup/compensation handling for a MinIO upload that succeeds while the
database transaction fails. database transaction fails.
7. Add unit/API tests for trusted tenant derivation, CSV validation, idempotency, 7. Add unit/API tests for trusted tenant derivation, CSV validation, idempotency,
`202 Accepted`, and tenant-scoped status. Add MinIO adapter integration tests the terminal `201 Created` response, and tenant-scoped status. Add MinIO adapter integration tests
for server-derived private object paths and compensation behavior. for server-derived private object paths and compensation behavior.
**Exit criteria:** an authenticated CSV upload creates a private object, a queued **Exit criteria:** an authenticated CSV upload creates a private object and a
job, and an unpublished outbox event; a tenant cannot retrieve another tenant's `running` job row committed before any ingestion work; a tenant cannot retrieve
file status; the HTTP path does not publish directly to RabbitMQ. another tenant's file status.
### Phase 4: RabbitMQ and outbox publisher ### Phase 4: Bounded execution primitives
1. Provision/verify the application-owned RabbitMQ topic exchange, queue, and 1. Add the async embedding ports and adapters in `src/infrastructure/embedding/`,
binding configuration through deployment/bootstrap code rather than route with per-provider batching and an `asyncio.Semaphore` bounding in-flight
startup side effects. batches.
2. Implement an outbox-publisher process that safely claims unpublished events, 2. Route blocking work (parse, chunk, BM25, `minio`) through
publishes them with the stable event id using publisher confirms, and records `anyio.to_thread.run_sync` with an explicit `CapacityLimiter` created at
success/failure attempts. startup, so ingestion cannot exhaust Starlette's thread pool.
3. RabbitMQ has no broker-native message de-duplication; retain idempotency in 3. Enforce the request bounds: `INGESTION_MAX_CONCURRENCY` (`503` + `Retry-After`
all consumers as the sole guard against duplicate processing. when exceeded), `INGESTION_TIMEOUT_SECONDS` around the whole work phase
4. Add monitoring/logging for publish attempts, unpublished-event age, and (`504`), and the size/chunk-count ceiling (`413`) checked before work starts.
repeated failures. 4. Guarantee a bounded failure always writes a terminal job status — a timeout
5. Add unit tests for event claiming and retryable failures, then Testcontainers must never leave a job stuck in `running`.
RabbitMQ integration tests for durable publication, restart/retry, duplicate 5. Add unit tests for batching/concurrency limits, timeout-to-terminal-status,
publication, and message metadata. and capacity rejection, using scripted embedder fakes.
**Exit criteria:** an outbox event becomes a durable RabbitMQ message after a **Exit criteria:** embedding a few hundred chunks issues batched, concurrent
publisher restart; retrying publication cannot create duplicate application work. requests rather than serial ones; exceeding any bound produces the right status
code and a terminal job row.
### Phase 5: Ingestion worker and Qdrant Chunk/Point CRUD ### Phase 5: Ingestion execution and Qdrant Chunk/Point CRUD
1. Create a separate worker entrypoint with its own application-lifetime database, 1. Implement the ingestion service called by the route, using the
MinIO, RabbitMQ, Qdrant, model, and logging clients. application-lifetime database, MinIO, Qdrant, model, and logging clients.
2. Consume `ingestion.job.created`; reload and validate Postgres records before 2. Validate the persisted records before fetching the MinIO object.
fetching the MinIO object. 3. Append progress events, parse CSV, create deterministic chunks, embed them,
3. Transition the job from `queued` to `running` conditionally, append progress and upsert tenant-scoped Qdrant points — without holding a Postgres session
events, parse CSV, create deterministic chunks, and upsert tenant-scoped open across the work.
Qdrant points. 4. In a second short transaction, mark the job `succeeded` with counters or
4. Mark the job `succeeded` with counters or `failed` with a safe error summary; `failed` with a safe error summary, then return the terminal response.
acknowledge only after final/progress state is persisted. 5. Make a retried upload safe: no duplicate logical chunks, no incorrect
5. Make a repeated delivery of the same `ingestion_job_id` safe: no duplicate counters, and no transition from a terminal state back to `running`.
logical chunks, no incorrect counters, and no transition from a terminal state
back to `running`.
6. Add unit tests for deterministic CSV chunks, point IDs, and terminal job 6. Add unit tests for deterministic CSV chunks, point IDs, and terminal job
transitions. Add Testcontainers Qdrant and RabbitMQ integration tests for transitions. Add Testcontainers Qdrant and Postgres integration tests for
tenant-filtered upserts, acknowledgement after durable state, redelivery, and tenant-filtered upserts, terminal state persistence, retrying an upload, and
parser/Qdrant failure handling. parser/Qdrant failure handling.
**Exit criteria:** a successful upload reaches `succeeded`, and its points are **Exit criteria:** a successful upload returns `201` with a terminal status, and
retrievable only under the owning tenant's Qdrant filter. Forced worker failure its points are retrievable only under the owning tenant's Qdrant filter. A forced
and message redelivery produce a correct final job state. failure mid-ingestion produces a `failed` job and the right HTTP status, and
retrying the upload produces a correct final state without duplicate chunks.
### Phase 6: Operations, integration tests, and documentation ### Phase 6: Operations, integration tests, and documentation
1. Add an operator runbook covering local startup, migrations, MinIO bucket setup, 1. Add an operator runbook covering local startup, migrations, MinIO bucket
RabbitMQ exchange/queue setup, web/worker/outbox commands, and job replay. setup, the run command, ingestion-bound tuning, the proxy/client timeout
2. Add a serialized Compose-based operational smoke test where the web process, requirement, and how to retry a failed ingestion.
outbox publisher, and worker run independently for upload through indexed 2. Add a serialized Compose-based operational smoke test covering upload through
points. Testcontainers remains the standard pytest mechanism for individual indexed points against the running web process. Testcontainers remains the
adapter integration tests. standard pytest mechanism for individual adapter integration tests.
3. Add end-to-end tests for duplicate upload, duplicate RabbitMQ delivery, outbox 3. Add end-to-end tests for duplicate upload, retrying a failed upload, tenant
publisher crash/restart, worker crash/restart, tenant isolation, and failed isolation, capacity/timeout rejection, and failed parser/Qdrant behavior.
parser/Qdrant behavior.
4. Add health/readiness checks that distinguish process health from dependency 4. Add health/readiness checks that distinguish process health from dependency
readiness. readiness.
5. Update the README with local-start instructions and links to ADRs, this plan, 5. Update the README with local-start instructions and links to ADRs, this plan,
@@ -274,10 +285,11 @@ covered by automated tests:
```text ```text
POST /v1/files (authenticated CSV upload) POST /v1/files (authenticated CSV upload)
-> raw bytes stored privately in MinIO -> raw bytes stored privately in MinIO
-> source file, queued job, and outbox event committed in Postgres -> source file and running job committed in Postgres, connection released
-> outbox publisher writes a durable RabbitMQ message -> parse/chunk on threads, embed in bounded concurrent batches
-> ingestion worker processes and indexes deterministic Qdrant points -> deterministic Qdrant points upserted
-> Postgres records progress and terminal job status -> Postgres records progress and terminal job status
-> 201 Created returns the terminal result in the same request
-> GET /v1/files/{file_id} reports that status within the owning tenant only -> GET /v1/files/{file_id} reports that status within the owning tenant only
``` ```

View File

@@ -0,0 +1,285 @@
# 002. Point CRUD and keyword-search implementation plan
## Purpose
This plan covers the milestone named at the end of
[plan 001](001-ingestion-vertical-slice.md): direct, fine-grained management of
individual Qdrant points through `/v1/points`, plus filter/keyword search over
them. Ingestion (plan 001) writes points in bulk; this slice lets a human or
admin frontend read, edit, reorder, and soft-delete them one at a time, under
the same tenant isolation.
This is an implementation plan, not an Architecture Decision Record. The ADRs
explain why the collection schema, endpoints, and isolation rules are what they
are; this document defines order, scope, and verification criteria.
## Prerequisite
Plan 001 must be complete through **Phase 5** before Phase 3 of this plan
starts. Specifically this plan depends on: the `chunks` collection and its
payload indexes actually existing, API-key authentication and `AuthContext`
tenant derivation, the application-lifetime Qdrant client from the FastAPI
lifespan, and the request-lifetime `AsyncSession` wiring. Phases 1–2 below
(schemas and the read paths) can be built against the collection alone and do
not need the full ingestion path.
## Architecture baseline
| System | Responsibility |
|---|---|
| FastAPI | HTTP boundary, auth, tenant derivation, request/response schemas, scope checks. |
| Points application service | Point CRUD semantics: soft delete, ordering, neighbor relinking, optimistic concurrency. |
| Qdrant | The only store for point content, vectors, and payload. |
| Postgres | Audit of mutating operations. It does **not** hold a mirror of point state. |
The controlling ADRs are:
- [ADR-0001](../adr/0001-ingestion-pipeline-and-collection-schema.md): the
`chunks` collection, payload schema, payload indexes, deterministic point IDs,
`order_id` as a fractional float, and the
`previous_chunk_id`/`next_chunk_id` adjacency pointers.
- [ADR-0002](../adr/0002-chunk-crud-and-search-api.md): the CRUD operation set,
soft-delete-by-default, reorder semantics, keyword search vs. semantic search,
and `version`-based optimistic concurrency.
- [ADR-0008](../adr/0008-rest-api-and-fastapi-boundary.md): the `/v1/points`
REST surface, `GET /v1/files/{file_id}/points`,
`DELETE /v1/files/{file_id}`, and the `points:read`/`points:write` scopes.
- [ADR-0012](../adr/0012-application-resource-lifetime-and-dependency-ownership.md):
the Qdrant client is application-lifetime and injected, never constructed per
request.
- [ADR-0015](../adr/0015-modular-monolith-package-architecture.md): routes call
`application/points/`, which calls a port implemented in
`infrastructure/qdrant/`. Routers never build Qdrant filters or call the SDK.
- [ADR-0016](../adr/0016-testing-strategy-and-quality-gates.md): unit tests
against a fake point port; Testcontainers Qdrant for the adapter.
ADR-0001 through 0004 are Accepted; 0008, 0012, 0015, and 0016 are still
Proposed. Treat the proposed ones as the implementation baseline only once the
project owner accepts them, and update the ADR rather than diverging silently.
## Scope
### In scope
- `POST /v1/points`, `GET /v1/points/{point_id}`, `PUT /v1/points/{point_id}`,
`PATCH /v1/points/{point_id}/payload`, `DELETE /v1/points/{point_id}`.
- `GET /v1/points?file_id=...` (scroll, `order_by: order_id`, paginated),
`GET /v1/points/count`, `GET /v1/points/search?q=...`.
- `PATCH /v1/points/{point_id}/order` with correct neighbor relinking.
- `POST /v1/points/batch` for bulk multi-operation edits.
- `GET /v1/files/{file_id}/points` and `DELETE /v1/files/{file_id}`
(bulk soft delete of a file's points), from ADR-0008.
- Soft delete as the default for every delete path, with neighbor relinking.
- Optimistic concurrency on every mutating path via the `version` payload field.
- Audit rows in Postgres for mutating operations.
- Automated tests for tenant isolation, pointer integrity, concurrency
conflicts, and pagination.
### Explicitly out of scope
- Hybrid dense+sparse retrieval, RRF fusion, and late-interaction rerank
(ADR-0003/0005) — that is the next plan, 003. `GET /v1/points/search` here is
**keyword/filter matching only**; do not let it grow a semantic mode.
- Re-embedding a point on content edit (see the open decision below).
- Hard deletion / compliance purge endpoints.
- The LangGraph conversational API (ADR-0006/0007).
- A frontend.
## Required invariants
1. `tenant_id` comes from the authenticated context and is injected into every
filter server-side — on reads *and* writes, on every code path. A
`tenant_id`, or any payload key that would override it, appearing in a
request body or query string is rejected, never honored.
2. Cross-tenant access returns `404`, not `403` — the same rule plan 001 applies
to files. A caller must not be able to probe for the existence of another
tenant's point IDs.
3. Soft delete is the default: `is_active=false` + `deleted_at` via
`set_payload`. Points are never removed from Qdrant by any endpoint in this
slice.
4. Reads exclude inactive points unless the caller explicitly opts in.
5. Any operation that changes a point's position or removes it from the sequence
— insert, reorder, delete — updates the affected neighbors'
`previous_chunk_id`/`next_chunk_id` in the **same** `points/batch` request.
A partial relink is a defect: ADR-0003's context-window expansion walks these
pointers.
6. Point IDs stay derived from `file_id` + the immutable `chunk_index`.
Reordering changes `order_id` only, never the point ID.
7. Every mutating operation is guarded by the `version` payload field via
Qdrant's `update_filter`, and increments it. A stale write returns `409`,
it does not silently clobber.
8. `order_id` is a fractional float. Inserting or moving a point assigns a value
between its two new neighbors; it never renumbers siblings.
9. Routers contain no Qdrant SDK calls and no filter construction. The Qdrant
client is injected from the lifespan (ADR-0012).
## Decisions needed before the affected phase
### Re-embedding on content edit (blocks Phase 4)
`PUT /v1/points/{point_id}` can change `content`. The stored vectors then no
longer match the text. Three options, in order of preference:
1. **Re-embed inline** on content change, reusing plan 001's embedding ports and
bounds. Consistent, but puts embedder latency and `502`/`504` failure modes
on an admin edit path.
2. **Require caller-supplied vectors** when content changes, and reject the edit
otherwise. Simple and honest, but pushes model knowledge to the client.
3. **Mark the point stale** (a payload flag) and re-embed later. Needs
background work, which ADR-0017 currently rules out.
Default to (1) for parity with ingestion, with the same batch/semaphore bounds
and the same status codes. Record whichever is chosen in ADR-0002 before
implementing Phase 4 — this is a real behavioral contract, not an
implementation detail.
### Fractional-key exhaustion
ADR-0001 notes float keys eventually need renormalization. Decide now whether
this slice ships a renormalize path (an internal operation rewriting a file's
`order_id` values to `1000, 2000, 3000, ...`) or explicitly defers it with a
logged warning when the gap between neighbors falls under a threshold. Deferring
is acceptable; silently producing unrepresentable gaps is not.
### Batch semantics
`POST /v1/points/batch` must define, in the API schema and the tests: whether
operations are all-or-nothing, what happens when operation 3 of 5 fails a
version check, and the maximum operation count per request. Decide before
Phase 5; do not let the answer be "whatever Qdrant happened to do."
## Build order
### Phase 1: Point contracts and the port
1. Define the payload model in `src/application/points/` mirroring ADR-0001's
field list exactly, with the reserved/server-owned fields (`tenant_id`,
`version`, `chunk_index`, `deleted_at`) separated from caller-writable ones.
2. Define the point port: get, upsert, set payload, scroll, count, keyword
search, batch. Tenant filter is a required parameter on every method — not an
optional argument a caller can forget.
3. Implement the Qdrant adapter behind the port in
`src/infrastructure/qdrant/`, using the injected application-lifetime client.
4. Add a fake port implementation for unit tests, with ordering and version
behavior faithful enough to test the service against.
5. Define the Pydantic request/response schemas in `src/api/schemas/`. Vectors
are returned only when explicitly requested (ADR-0008).
**Exit criteria:** the service layer can be unit-tested end to end against the
fake; a Testcontainers Qdrant test confirms the adapter's filter construction
and `order_by` scroll match the fake's semantics.
### Phase 2: Read paths
1. `GET /v1/points/{point_id}` — tenant-filtered retrieve; `404` when the point
belongs to another tenant or does not exist.
2. `GET /v1/points?file_id=...` — scroll with `order_by: order_id`, stable
pagination, `is_active: true` implied.
3. `GET /v1/files/{file_id}/points` — same listing, addressed by file.
4. `GET /v1/points/count` — tenant/domain-filtered count.
5. `GET /v1/points/search?q=...` — full-text payload match on `content` plus
structured filters. Name the response and docstring so it cannot be mistaken
for semantic retrieval.
6. Enforce `points:read` scope on all of the above.
7. Tests: tenant isolation returns `404`; inactive points are excluded by
default and included on explicit opt-in; pagination does not skip or repeat
under a concurrent insert.
**Exit criteria:** a tenant can list and search only its own active points, in
display order, with correct paging.
### Phase 3: Soft delete and neighbor relinking
1. Implement the relinking primitive in the service: given a point leaving the
sequence, compute the neighbor payload updates and emit them with the
deactivation in one `points/batch` call.
2. `DELETE /v1/points/{point_id}` — soft delete plus relink.
3. `DELETE /v1/files/{file_id}` — bulk soft delete of a file's active points.
The whole file leaves the sequence, so the boundary pointers must end
consistent (typically all null within that file).
4. Deleting an already-inactive point is a no-op success, not a `404` and not a
second relink.
5. Tests: after deleting a middle point, its old neighbors point at each other;
after deleting the first point, the new first has a null
`previous_chunk_id`; a full traversal of a file's pointer chain after a
series of deletes visits every active point exactly once and never enters an
inactive one.
**Exit criteria:** no delete path can leave a stale or dangling pointer, and
none removes a point from Qdrant.
### Phase 4: Create, replace, and payload update
1. `POST /v1/points` — create one point. Server assigns `tenant_id`, `version`,
and the derived point ID; the caller supplies content, position, and
metadata. Insert relinks neighbors like a reorder does.
2. `PUT /v1/points/{point_id}` — upsert with `update_only`, guarded by
`version` via `update_filter`; `409` on a stale version. Apply the
re-embedding decision above.
3. `PATCH /v1/points/{point_id}/payload` — payload-only update. Reject attempts
to write server-owned fields.
4. Enforce `points:write` scope; write an audit row per mutation
(tenant, actor, point, operation, resulting version).
5. Tests: concurrent writers at the same version — one succeeds, one gets `409`
and does not mutate; a `tenant_id` or `version` in the request body is
rejected; a create lands in the right sequence position.
**Exit criteria:** manual edits and ingestion re-runs cannot silently clobber
each other; server-owned fields are unwritable through any endpoint.
### Phase 5: Reorder and batch
1. `PATCH /v1/points/{point_id}/order` — assign a new fractional `order_id`
between the new neighbors and relink up to four points in one
`points/batch`, per ADR-0002.
2. Handle the boundary moves (to first, to last) and the no-op move
(already in that position).
3. Implement the fractional-gap decision from above.
4. `POST /v1/points/batch` with the semantics decided above, including the
per-request operation cap.
5. Tests: a randomized sequence of inserts, moves, and deletes leaves the
`order_id` ordering and the pointer chain agreeing with each other after
every step — this property test is the main defense for this phase.
**Exit criteria:** display order derived from `order_id` and traversal order
derived from the pointer chain are identical for every file, after any sequence
of mutations.
### Phase 6: Integration tests, operations, and documentation
1. Testcontainers Qdrant integration tests for each endpoint's real filter and
ordering behavior, isolated per test by unique collection or tenant keys.
2. An end-to-end test crossing plan 001 and this slice: ingest a CSV, list its
points, reorder one, soft-delete another, re-upload the same file, and assert
the manual edits interact with re-ingestion exactly as ADR-0001/0002 specify.
If that interaction is not yet decided, this test is what forces the decision.
3. Structured logging at the mutation boundary with stable event names
(`points.updated`, `points.reordered`, `points.soft_deleted`) carrying
`request_id`, `tenant_id`, `file_id`, and the resulting version.
4. Extend the operator runbook: how to inspect a file's point sequence, how to
spot a broken pointer chain, and how to recover one.
5. Update the README and ADR-0002 with anything this implementation settled.
**Exit criteria:** every endpoint has an integration test against real Qdrant,
and an operator can diagnose an ordering problem from the logs and the runbook.
## Definition of done
This slice is done when, in local Compose and under automated test:
```text
ingest a CSV (plan 001)
-> GET /v1/points?file_id=... lists its active points in order_id order
-> PATCH .../order moves one point; order_id and the
previous/next chain still agree
-> PUT /v1/points/{id} edits content under a version guard;
a stale write gets 409
-> DELETE /v1/points/{id} soft-deletes and relinks neighbors
-> GET /v1/points/search?q=... keyword-matches content within the tenant
-> every one of the above returns 404, not 403, for another tenant
```
The next plan (003) is agent hybrid retrieval — three prefetches, RRF fusion,
late-interaction rerank, and context-window expansion over the pointer chain
this slice is responsible for keeping correct (ADR-0003, ADR-0005). Do not pull
any of it into this plan.

View File

@@ -5,12 +5,16 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"aio-pika>=9.5.0",
"alembic>=1.19.1", "alembic>=1.19.1",
"anyio>=4.11.0",
"asyncpg>=0.31.0",
"fastapi[standard]==0.141.1", "fastapi[standard]==0.141.1",
"langgraph>=1.2.10", "langgraph>=1.2.10",
"minio>=7.2.20",
"pydantic-settings>=2.15.0", "pydantic-settings>=2.15.0",
"qdrant-client>=1.19.0",
"sqlalchemy>=2.0.51", "sqlalchemy>=2.0.51",
"structlog>=26.1.0",
] ]
[dependency-groups] [dependency-groups]
@@ -36,7 +40,6 @@ markers = [
"e2e: end-to-end vertical-slice tests", "e2e: end-to-end vertical-slice tests",
"postgres: integration test using Postgres", "postgres: integration test using Postgres",
"minio: integration test using MinIO", "minio: integration test using MinIO",
"rabbitmq: integration test using RabbitMQ",
"qdrant: integration test using Qdrant", "qdrant: integration test using Qdrant",
"slow: test exceeds normal integration feedback time", "slow: test exceeds normal integration feedback time",
"live_provider: opt-in test that calls an external provider", "live_provider: opt-in test that calls an external provider",

0
src/__init__.py Normal file
View File

0
src/api/__init__.py Normal file
View File

View File

3
src/api/router.py Normal file
View File

@@ -0,0 +1,3 @@
from fastapi import APIRouter
router = APIRouter()

View File

36
src/api/routers/health.py Normal file
View File

@@ -0,0 +1,36 @@
import asyncio
from fastapi import APIRouter, Request, Response, status
from src.bootstrap.dependencies import AppResources
from src.infrastructure.minio.client import ping as ping_minio
from src.infrastructure.postgres.database import ping as ping_postgres
from src.infrastructure.qdrant.client import ping as ping_qdrant
router = APIRouter(tags=["health"])
@router.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
@router.get("/readyz")
async def readyz(request: Request, response: Response) -> dict[str, bool]:
resources: AppResources = request.app.state.resources
timeout = resources.settings.app.readiness_check_timeout_seconds
postgres_ready, minio_ready, qdrant_ready = await asyncio.gather(
ping_postgres(resources.db_engine, timeout),
ping_minio(resources.minio_client, timeout),
ping_qdrant(resources.qdrant_client, timeout),
)
result = {
"postgres": postgres_ready,
"minio": minio_ready,
"qdrant": qdrant_ready,
}
if not all(result.values()):
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return result

View File

View File

View File

@@ -0,0 +1,44 @@
from collections.abc import AsyncIterator
from dataclasses import dataclass
from fastapi import Request
from minio import Minio
from qdrant_client import AsyncQdrantClient
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from src.config import Settings
@dataclass
class AppResources:
settings: Settings
db_engine: AsyncEngine
db_sessionmaker: async_sessionmaker[AsyncSession]
minio_client: Minio
qdrant_client: AsyncQdrantClient
def _resources(request: Request) -> AppResources:
return request.app.state.resources
def get_settings(request: Request) -> Settings:
return _resources(request).settings
def get_minio_client(request: Request) -> Minio:
return _resources(request).minio_client
def get_qdrant_client(request: Request) -> AsyncQdrantClient:
return _resources(request).qdrant_client
async def get_db_session(request: Request) -> AsyncIterator[AsyncSession]:
sessionmaker = _resources(request).db_sessionmaker
async with sessionmaker() as session:
try:
yield session
except Exception:
await session.rollback()
raise

56
src/bootstrap/lifespan.py Normal file
View File

@@ -0,0 +1,56 @@
from collections.abc import AsyncIterator, Callable
from contextlib import AbstractAsyncContextManager, asynccontextmanager
import structlog
from fastapi import FastAPI
from src.bootstrap.dependencies import AppResources
from src.config import Settings
from src.infrastructure.minio.client import create_client as create_minio_client
from src.infrastructure.observability.logging import configure_logging
from src.infrastructure.postgres.database import create_engine, create_sessionmaker
from src.infrastructure.qdrant.client import create_client as create_qdrant_client
logger = structlog.get_logger(__name__)
def create_lifespan(
settings: Settings | None = None,
) -> Callable[[FastAPI], AbstractAsyncContextManager[None, bool | None]]:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
resolved_settings = settings or Settings()
configure_logging(resolved_settings.logging)
db_engine = create_engine(resolved_settings.postgres)
db_sessionmaker = create_sessionmaker(db_engine)
logger.info("lifespan.postgres.engine.created")
minio_client = create_minio_client(resolved_settings.minio)
logger.info("lifespan.minio.client.created")
qdrant_client = create_qdrant_client(resolved_settings.qdrant)
logger.info("lifespan.qdrant.client.created")
app.state.resources = AppResources(
settings=resolved_settings,
db_engine=db_engine,
db_sessionmaker=db_sessionmaker,
minio_client=minio_client,
qdrant_client=qdrant_client,
)
try:
yield
finally:
try:
await db_engine.dispose()
except Exception:
logger.exception("lifespan.postgres.dispose.failed")
try:
await qdrant_client.close()
except Exception:
logger.exception("lifespan.qdrant.close.failed")
return lifespan

View File

@@ -1,5 +1,76 @@
from pydantic_settings import BaseSettings from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class PostgresSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="POSTGRES_", extra="ignore")
host: str = "127.0.0.1"
port: int = 5433
user: str = "chatbot"
password: str = "chatbot"
db: str = "chatbot"
@property
def dsn(self) -> str:
return f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.db}"
class MinioSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="MINIO_", extra="ignore")
endpoint: str = "127.0.0.1:9100"
access_key: str = "chatbot"
secret_key: str = "chatbot-secret"
secure: bool = False
bucket: str = "chatbot-source-files"
class IngestionSettings(BaseSettings):
"""Bounds on inline ingestion (ADR-0017).
`timeout_seconds` must stay below the proxy/load-balancer/client read
timeouts, or callers give up on work that is still succeeding.
"""
model_config = SettingsConfigDict(env_prefix="INGESTION_", extra="ignore")
max_concurrency: int = 4
thread_pool_size: int = 8
timeout_seconds: float = 120.0
max_chunks_per_file: int = 5000
embed_batch_size: int = 128
embed_concurrency: int = 4
class QdrantSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="QDRANT_", extra="ignore")
url: str = "http://127.0.0.1:6343"
api_key: str | None = None
class AppLimitSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", extra="ignore")
env: str = "local"
max_upload_size_mb: int = 25
readiness_check_timeout_seconds: float = 2.0
class LoggingSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="LOG_", extra="ignore")
level: str = "INFO"
json_format: bool = False
class Settings(BaseSettings): class Settings(BaseSettings):
pass model_config = SettingsConfigDict(env_file=".env", extra="ignore")
postgres: PostgresSettings = Field(default_factory=PostgresSettings)
minio: MinioSettings = Field(default_factory=MinioSettings)
ingestion: IngestionSettings = Field(default_factory=IngestionSettings)
qdrant: QdrantSettings = Field(default_factory=QdrantSettings)
app: AppLimitSettings = Field(default_factory=AppLimitSettings)
logging: LoggingSettings = Field(default_factory=LoggingSettings)

View File

View File

View File

@@ -0,0 +1,23 @@
import asyncio
from minio import Minio
from src.config import MinioSettings
def create_client(settings: MinioSettings) -> Minio:
return Minio(
settings.endpoint,
access_key=settings.access_key,
secret_key=settings.secret_key,
secure=settings.secure,
)
async def ping(client: Minio, timeout: float) -> bool:
try:
async with asyncio.timeout(timeout):
await asyncio.to_thread(client.bucket_exists, "healthcheck-probe")
except Exception:
return False
return True

View File

@@ -0,0 +1,84 @@
import logging
import logging.config
import sys
import structlog
from src.config import LoggingSettings
def configure_logging(settings: LoggingSettings) -> None:
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.StackInfoRenderer(),
]
structlog.configure(
processors=[
*shared_processors,
structlog.processors.format_exc_info,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
renderer = (
structlog.processors.JSONRenderer()
if settings.json_format
else structlog.dev.ConsoleRenderer(colors=True)
)
logging.config.dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": structlog.stdlib.ProcessorFormatter,
"processors": [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
renderer,
],
"foreign_pre_chain": [
structlog.stdlib.ExtraAdder(),
*shared_processors,
],
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": settings.level,
"formatter": "default",
"stream": sys.stdout,
},
},
"loggers": {
"": {
"handlers": ["console"],
"level": settings.level,
"propagate": False,
},
"uvicorn": {
"handlers": ["console"],
"level": settings.level,
"propagate": False,
},
"uvicorn.access": {
"handlers": ["console"],
"level": settings.level,
"propagate": False,
},
"sqlalchemy.engine": {
"handlers": ["console"],
"level": "WARNING",
"propagate": False,
},
},
}
)

View File

View File

@@ -0,0 +1,28 @@
import asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from src.config import PostgresSettings
def create_engine(settings: PostgresSettings) -> AsyncEngine:
return create_async_engine(settings.dsn)
def create_sessionmaker(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(engine, expire_on_commit=False)
async def ping(engine: AsyncEngine, timeout: float) -> bool:
try:
async with asyncio.timeout(timeout), engine.connect() as connection:
await connection.execute(text("SELECT 1"))
except Exception:
return False
return True

View File

@@ -0,0 +1,15 @@
from src.infrastructure.postgres.models.api_key import ApiKey
from src.infrastructure.postgres.models.base import Base
from src.infrastructure.postgres.models.ingestion_job import IngestionJob
from src.infrastructure.postgres.models.ingestion_job_event import IngestionJobEvent
from src.infrastructure.postgres.models.source_file import SourceFile
from src.infrastructure.postgres.models.tenant import Tenant
__all__ = [
"ApiKey",
"Base",
"IngestionJob",
"IngestionJobEvent",
"SourceFile",
"Tenant",
]

View File

@@ -0,0 +1,41 @@
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.postgres.models.base import Base
API_KEY_ACTOR_TYPES = ("backend", "admin", "worker")
API_KEY_STATUSES = ("active", "revoked", "expired")
class ApiKey(Base):
__tablename__ = "api_keys"
__table_args__ = (
CheckConstraint(f"actor_type IN {API_KEY_ACTOR_TYPES}", name="ck_api_keys_actor_type"),
CheckConstraint(f"status IN {API_KEY_STATUSES}", name="ck_api_keys_status"),
Index("ix_api_keys_tenant_id_status", "tenant_id", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True)
tenant_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(200))
key_prefix: Mapped[str] = mapped_column(String(32), unique=True, index=True)
key_hash: Mapped[str] = mapped_column(String(255))
scopes: Mapped[list[str]] = mapped_column(JSONB, default=list, server_default="[]")
actor_type: Mapped[str] = mapped_column(String(20), default="backend", server_default="backend")
status: Mapped[str] = mapped_column(String(20), default="active", server_default="active")
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
created_by: Mapped[str | None] = mapped_column(String(200), default=None)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@@ -0,0 +1,59 @@
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.postgres.models.base import Base
INGESTION_JOB_STATUSES = ("queued", "running", "succeeded", "failed", "cancelled")
INGESTION_CHUNKING_STRATEGIES = ("semantic", "fixed_size")
class IngestionJob(Base):
__tablename__ = "ingestion_jobs"
__table_args__ = (
CheckConstraint(f"status IN {INGESTION_JOB_STATUSES}", name="ck_ingestion_jobs_status"),
CheckConstraint(
f"chunking_strategy IN {INGESTION_CHUNKING_STRATEGIES}",
name="ck_ingestion_jobs_chunking_strategy",
),
Index("ix_ingestion_jobs_tenant_id_status_created_at", "tenant_id", "status", "created_at"),
Index("ix_ingestion_jobs_source_file_id_created_at", "source_file_id", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True)
tenant_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), index=True
)
source_file_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("source_files.id", ondelete="CASCADE"), index=True
)
requested_by_api_key_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("api_keys.id", ondelete="SET NULL"), default=None
)
status: Mapped[str] = mapped_column(String(20), default="queued", server_default="queued")
chunking_strategy: Mapped[str | None] = mapped_column(String(20), default=None)
embedding_model_versions: Mapped[dict[str, object]] = mapped_column(
JSONB, default=dict, server_default="{}"
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)
points_created: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
points_updated: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
points_soft_deleted: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
points_skipped: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
error_code: Mapped[str | None] = mapped_column(String(100), default=None)
error_message: Mapped[str | None] = mapped_column(Text, default=None)
metadata_: Mapped[dict[str, object]] = mapped_column(
"metadata", JSONB, default=dict, server_default="{}"
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

View File

@@ -0,0 +1,47 @@
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.postgres.models.base import Base
INGESTION_JOB_EVENT_LEVELS = ("info", "warning", "error")
INGESTION_JOB_EVENT_STAGES = (
"received",
"parsed",
"chunked",
"embedded",
"upserted",
"completed",
)
class IngestionJobEvent(Base):
__tablename__ = "ingestion_job_events"
__table_args__ = (
CheckConstraint(
f"level IN {INGESTION_JOB_EVENT_LEVELS}", name="ck_ingestion_job_events_level"
),
CheckConstraint(
f"stage IN {INGESTION_JOB_EVENT_STAGES}", name="ck_ingestion_job_events_stage"
),
Index(
"ix_ingestion_job_events_ingestion_job_id_created_at", "ingestion_job_id", "created_at"
),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True)
tenant_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), index=True
)
ingestion_job_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("ingestion_jobs.id", ondelete="CASCADE"), index=True
)
level: Mapped[str] = mapped_column(String(10))
stage: Mapped[str] = mapped_column(String(20))
message: Mapped[str] = mapped_column(String(1000))
details: Mapped[dict[str, object]] = mapped_column(JSONB, default=dict, server_default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,41 @@
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, CheckConstraint, DateTime, ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.postgres.models.base import Base
SOURCE_FILE_TYPES = ("csv", "xlsx", "docx", "doc")
SOURCE_FILE_STATUSES = ("active", "superseded", "soft_deleted", "purged")
class SourceFile(Base):
__tablename__ = "source_files"
__table_args__ = (
CheckConstraint(f"source_type IN {SOURCE_FILE_TYPES}", name="ck_source_files_source_type"),
CheckConstraint(f"status IN {SOURCE_FILE_STATUSES}", name="ck_source_files_status"),
Index("ix_source_files_tenant_id_domain_created_at", "tenant_id", "domain", "created_at"),
Index("ix_source_files_tenant_id_content_sha256", "tenant_id", "content_sha256"),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True)
tenant_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"), index=True
)
domain: Mapped[str] = mapped_column(String(80))
source_filename: Mapped[str] = mapped_column(String(500))
source_type: Mapped[str] = mapped_column(String(10))
content_sha256: Mapped[str] = mapped_column(String(64))
byte_size: Mapped[int] = mapped_column(BigInteger)
storage_uri: Mapped[str | None] = mapped_column(String(1000), default=None)
status: Mapped[str] = mapped_column(String(20), default="active", server_default="active")
created_by_api_key_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("api_keys.id", ondelete="SET NULL"), default=None
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)

View File

@@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from src.infrastructure.postgres.models.base import Base
TENANT_STATUSES = ("active", "suspended", "deleted")
class Tenant(Base):
__tablename__ = "tenants"
__table_args__ = (CheckConstraint(f"status IN {TENANT_STATUSES}", name="ck_tenants_status"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(80), unique=True, index=True)
name: Mapped[str] = mapped_column(String(200))
status: Mapped[str] = mapped_column(String(20), default="active", server_default="active")
settings: Mapped[dict[str, object]] = mapped_column(JSONB, default=dict, server_default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None)

View File

View File

@@ -0,0 +1,18 @@
import asyncio
from qdrant_client import AsyncQdrantClient
from src.config import QdrantSettings
def create_client(settings: QdrantSettings) -> AsyncQdrantClient:
return AsyncQdrantClient(url=settings.url, api_key=settings.api_key)
async def ping(client: AsyncQdrantClient, timeout: float) -> bool:
try:
async with asyncio.timeout(timeout):
await client.get_collections()
except Exception:
return False
return True

View File

@@ -0,0 +1,16 @@
from fastapi import FastAPI
from src.api.router import router as v1_router
from src.api.routers.health import router as health_router
from src.bootstrap.lifespan import create_lifespan
from src.config import Settings
def create_app(settings: Settings | None = None) -> FastAPI:
app = FastAPI(lifespan=create_lifespan(settings))
app.include_router(health_router)
app.include_router(v1_router, prefix="/v1")
return app
app = create_app()

0
tests/__init__.py Normal file
View File

37
tests/conftest.py Normal file
View File

@@ -0,0 +1,37 @@
from collections.abc import AsyncIterator
import pytest
import pytest_asyncio
from asgi_lifespan import LifespanManager
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from src.config import Settings
from src.main import create_app
@pytest.fixture
def settings() -> Settings:
return Settings(
postgres={"host": "127.0.0.1", "port": 1},
minio={"endpoint": "127.0.0.1:1"},
ingestion={"timeout_seconds": 1.0},
qdrant={"url": "http://127.0.0.1:1"},
app={"readiness_check_timeout_seconds": 0.5},
)
@pytest.fixture
def app(settings: Settings) -> FastAPI:
return create_app(settings)
@pytest_asyncio.fixture
async def client(app: FastAPI) -> AsyncIterator[AsyncClient]:
async with (
LifespanManager(app) as manager,
AsyncClient(
transport=ASGITransport(app=manager.app), base_url="http://test"
) as async_client,
):
yield async_client

0
tests/e2e/__init__.py Normal file
View File

5
tests/fakes.py Normal file
View File

@@ -0,0 +1,5 @@
"""Hand-written fakes for narrow application-owned ports.
No application ports exist yet (Phase 1 only wires infrastructure client
lifecycle). Fakes are added here as ports are introduced in later phases.
"""

View File

View File

View File

View File

View File

View File

@@ -0,0 +1 @@
"""Custom test assertions, populated as later phases need them."""

View File

@@ -0,0 +1 @@
"""Object builders for test fixtures, populated as later phases need them."""

0
tests/unit/__init__.py Normal file
View File

View File

View File

View File

View File

@@ -0,0 +1,19 @@
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from src.config import Settings
pytestmark = [pytest.mark.unit, pytest.mark.asyncio]
async def test_lifespan_starts_and_stops_without_docker(client: AsyncClient) -> None:
response = await client.get("/healthz")
assert response.status_code == 200
async def test_lifespan_binds_resources_to_app_state(app: FastAPI, client: AsyncClient) -> None:
await client.get("/healthz")
resources = app.state.resources
assert isinstance(resources.settings, Settings)
assert resources.minio_client is not None

27
tests/unit/test_config.py Normal file
View File

@@ -0,0 +1,27 @@
from pathlib import Path
import pytest
from src.config import Settings
pytestmark = pytest.mark.unit
def test_settings_loads_defaults_without_env_file() -> None:
settings = Settings(_env_file=None)
assert settings.postgres.port == 5433
assert settings.minio.bucket == "chatbot-source-files"
assert settings.ingestion.max_concurrency == 4
assert settings.qdrant.url == "http://127.0.0.1:6343"
def test_settings_parses_env_example() -> None:
env_example = Path(__file__).parent.parent.parent / ".env.example"
settings = Settings(_env_file=env_example)
assert settings.postgres.host == "127.0.0.1"
assert settings.minio.endpoint == "127.0.0.1:9100"
assert settings.ingestion.embed_batch_size == 128
assert settings.qdrant.api_key is None

View File

@@ -0,0 +1,11 @@
import pytest
from httpx import AsyncClient
pytestmark = [pytest.mark.unit, pytest.mark.asyncio]
async def test_healthz_returns_ok_status(client: AsyncClient) -> None:
response = await client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

17
tests/unit/test_readyz.py Normal file
View File

@@ -0,0 +1,17 @@
import pytest
from httpx import AsyncClient
pytestmark = [pytest.mark.unit, pytest.mark.asyncio]
async def test_readyz_reports_all_dependencies_not_ready_without_docker(
client: AsyncClient,
) -> None:
response = await client.get("/readyz")
assert response.status_code == 503
assert response.json() == {
"postgres": False,
"minio": False,
"qdrant": False,
}

636
uv.lock generated
View File

@@ -6,32 +6,6 @@ resolution-markers = [
"python_full_version < '3.14'", "python_full_version < '3.14'",
] ]
[[package]]
name = "aio-pika"
version = "10.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiormq" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/01f4ea7fe3490194420bb52e596b9619092ed13c5a230014b02075c3bd77/aio_pika-10.0.1.tar.gz", hash = "sha256:96ec3ef748ca7a25a9d2fa6e511c16c3ffcfa6b1f40ade79b8a5baabba682efd", size = 70882, upload-time = "2026-07-09T13:31:35.709Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/3f/329d0e52f994349ff7449c714c242ad65f14586b0e205ca632ac817fda72/aio_pika-10.0.1-py3-none-any.whl", hash = "sha256:12120a3cf8022d2a8bc5dc89e716512a38bf742c24c5562f54764af27eec7edd", size = 56332, upload-time = "2026-07-09T13:31:33.634Z" },
]
[[package]]
name = "aiormq"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pamqp" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/16/7e1c2bb887db6cbad191db9a1562e1cf5c0c61ad93f194ddc7baf5661f02/aiormq-7.0.0.tar.gz", hash = "sha256:f524121f1afbb875f50235b2748f81331e3be47542ee600e83c321c4e97ea168", size = 49231, upload-time = "2026-07-09T11:40:51.775Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/88/8da3627882f6bd75f780f87e46d0b58da99332c1b71d038db7a127a80648/aiormq-7.0.0-py3-none-any.whl", hash = "sha256:df49bb2282e5374a28507c4c43948e8c8e5321590f2998781c2d90a34e100789", size = 32152, upload-time = "2026-07-09T11:40:50.508Z" },
]
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.19.1" version = "1.19.1"
@@ -76,6 +50,49 @@ 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" }, { 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 = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
{ url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
{ url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
{ url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
{ url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
{ url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
{ url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
{ url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
{ url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
{ url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
{ url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
{ url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
{ url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
{ url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
{ url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
]
[[package]] [[package]]
name = "asgi-lifespan" name = "asgi-lifespan"
version = "2.1.0" version = "2.1.0"
@@ -88,6 +105,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload-time = "2023-03-28T17:35:47.772Z" }, { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload-time = "2023-03-28T17:35:47.772Z" },
] ]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.7.22" version = "2026.7.22"
@@ -97,6 +146,79 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
] ]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.9" version = "3.4.9"
@@ -476,6 +598,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" },
] ]
[[package]]
name = "grpcio"
version = "1.83.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" },
{ url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" },
{ url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" },
{ url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" },
{ url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" },
{ url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" },
{ url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" },
{ url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" },
{ url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" },
{ url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" },
{ url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" },
{ url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" },
{ url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" },
{ url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" },
{ url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" },
{ url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" },
]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@@ -485,6 +638,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
] ]
[[package]]
name = "h2"
version = "4.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
]
[[package]]
name = "hpack"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]] [[package]]
name = "httpcore" name = "httpcore"
version = "1.0.9" version = "1.0.9"
@@ -542,6 +717,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
] ]
[package.optional-dependencies]
http2 = [
{ name = "h2" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.18" version = "3.18"
@@ -795,84 +984,19 @@ wheels = [
] ]
[[package]] [[package]]
name = "multidict" name = "minio"
version = "6.7.1" version = "7.2.20"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } dependencies = [
{ name = "argon2-cffi" },
{ name = "certifi" },
{ name = "pycryptodome" },
{ name = "typing-extensions" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" },
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
] ]
[[package]] [[package]]
@@ -880,12 +1004,16 @@ name = "new-chatbot"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aio-pika" },
{ name = "alembic" }, { name = "alembic" },
{ name = "anyio" },
{ name = "asyncpg" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "langgraph" }, { name = "langgraph" },
{ name = "minio" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "qdrant-client" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "structlog" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -903,12 +1031,16 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aio-pika", specifier = ">=9.5.0" },
{ name = "alembic", specifier = ">=1.19.1" }, { name = "alembic", specifier = ">=1.19.1" },
{ name = "anyio", specifier = ">=4.11.0" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "fastapi", extras = ["standard"], specifier = "==0.141.1" }, { name = "fastapi", extras = ["standard"], specifier = "==0.141.1" },
{ name = "langgraph", specifier = ">=1.2.10" }, { name = "langgraph", specifier = ">=1.2.10" },
{ name = "minio", specifier = ">=7.2.20" },
{ name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" },
{ name = "qdrant-client", specifier = ">=1.19.0" },
{ name = "sqlalchemy", specifier = ">=2.0.51" }, { name = "sqlalchemy", specifier = ">=2.0.51" },
{ name = "structlog", specifier = ">=26.1.0" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -924,6 +1056,68 @@ dev = [
{ name = "ty", specifier = ">=0.0.69" }, { name = "ty", specifier = ">=0.0.69" },
] ]
[[package]]
name = "numpy"
version = "2.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" },
{ url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" },
{ url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" },
{ url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" },
{ url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" },
{ url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" },
{ url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" },
{ url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" },
{ url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" },
{ url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" },
{ url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" },
{ url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" },
{ url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" },
{ url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" },
{ url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" },
{ url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" },
{ url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" },
{ url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" },
{ url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" },
{ url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" },
{ url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" },
{ url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" },
{ url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" },
{ url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" },
{ url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" },
{ url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" },
{ url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" },
{ url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" },
{ url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" },
{ url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" },
{ url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" },
{ url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" },
{ url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" },
{ url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" },
{ url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" },
{ url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" },
{ url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" },
{ url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" },
{ url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" },
{ url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" },
{ url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" },
{ url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" },
]
[[package]] [[package]]
name = "orjson" name = "orjson"
version = "3.11.9" version = "3.11.9"
@@ -1001,15 +1195,6 @@ 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" }, { 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 = "pamqp"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/31/4c/33a0ddaaac7bc42f9a542dbaaee8b580ceca3f89bf5da7c498d1fa97ff9a/pamqp-4.0.1.tar.gz", hash = "sha256:9dd13b828e346622793981f14a5df817fce5de998c746209d6c0154eb8403970", size = 137192, upload-time = "2026-07-06T16:37:51.732Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/14/1dfc08b743ba995a38dee0ea09beb46a05c7fe8ac53d729095905f7bf11d/pamqp-4.0.1-py3-none-any.whl", hash = "sha256:a547f45128b06e42ce8d7a739b0cfcc40f2c724770622eaaff4a3f587b1cf7d0", size = 32773, upload-time = "2026-07-06T16:37:50.623Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -1020,80 +1205,69 @@ wheels = [
] ]
[[package]] [[package]]
name = "propcache" name = "portalocker"
version = "0.5.2" version = "3.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } dependencies = [
{ name = "pywin32", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, ]
{ url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, [[package]]
{ url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, name = "protobuf"
{ url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, version = "7.35.1"
{ url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, source = { registry = "https://pypi.org/simple" }
{ url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" }
{ url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" },
{ url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" },
{ url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" },
{ url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" },
{ url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
{ url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, ]
{ url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, [[package]]
{ url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, name = "pycparser"
{ url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, version = "3.0"
{ url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, source = { registry = "https://pypi.org/simple" }
{ url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
{ url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
{ url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, ]
{ url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" },
{ url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, [[package]]
{ url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, name = "pycryptodome"
{ url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, version = "3.23.0"
{ url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, source = { registry = "https://pypi.org/simple" }
{ url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
{ url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, wheels = [
{ url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
] ]
[[package]] [[package]]
@@ -1332,6 +1506,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "qdrant-client"
version = "1.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "grpcio" },
{ name = "httpx", extra = ["http2"] },
{ name = "numpy" },
{ name = "portalocker" },
{ name = "protobuf" },
{ name = "pydantic" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/33/c6e4ec45b4fca5a0b808e8804e60be54a6d7505b68b53c0b1d0d62ba86c1/qdrant_client-1.19.0.tar.gz", hash = "sha256:365395a04b0a26c309b25b7d8b1c99ef2071ec9a2b74bc8a5fd3b7a3642fe963", size = 350953, upload-time = "2026-08-04T14:32:56.923Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/3c/480c61cc8d5a3e76bb44e86231f408c93643e5498beadbbeb381ab55d02b/qdrant_client-1.19.0-py3-none-any.whl", hash = "sha256:13602a2b3478a95ecdf42f97b93d7f703b63a3361cd912a04495a33a5ac14121", size = 396157, upload-time = "2026-08-04T14:32:55.734Z" },
]
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.34.2" version = "2.34.2"
@@ -1576,6 +1768,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
] ]
[[package]]
name = "structlog"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" },
]
[[package]] [[package]]
name = "tenacity" name = "tenacity"
version = "9.1.4" version = "9.1.4"
@@ -2011,71 +2212,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/f9/6ed7251bb6a8af10ac73b1821c60583d2826e5b2064e45a979c935287c98/xxhash-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20", size = 30239, upload-time = "2026-07-06T10:48:01.874Z" }, { url = "https://files.pythonhosted.org/packages/d6/f9/6ed7251bb6a8af10ac73b1821c60583d2826e5b2064e45a979c935287c98/xxhash-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20", size = 30239, upload-time = "2026-07-06T10:48:01.874Z" },
] ]
[[package]]
name = "yarl"
version = "1.24.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" },
{ url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" },
{ url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" },
{ url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" },
{ url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" },
{ url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" },
{ url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" },
{ url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" },
{ url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" },
{ url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" },
{ url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" },
{ url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" },
{ url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" },
{ url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" },
{ url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" },
{ url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" },
{ url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" },
{ url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" },
{ url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" },
{ url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" },
{ url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" },
{ url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" },
{ url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" },
{ url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" },
{ url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" },
{ url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" },
{ url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" },
{ url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" },
{ url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" },
{ url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" },
{ url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" },
{ url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" },
{ url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" },
{ url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" },
{ url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" },
{ url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" },
{ url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" },
{ url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" },
{ url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" },
{ url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" },
{ url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" },
{ url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" },
{ url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" },
{ url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
]
[[package]] [[package]]
name = "zstandard" name = "zstandard"
version = "0.25.0" version = "0.25.0"