Compare commits
11 Commits
e2322a2909
...
d71dd1bd0c
| Author | SHA1 | Date | |
|---|---|---|---|
| d71dd1bd0c | |||
| 3c660de093 | |||
| df221279a5 | |||
| 5258e1fdf6 | |||
| ac3d545467 | |||
| 990a9c2298 | |||
| ee5da4ecab | |||
| 835d5bb4b0 | |||
| fa94a33b9b | |||
| c7c0570ab1 | |||
| fd70ad01af |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
name: Explore
|
||||
description: Fast, read-only codebase search
|
||||
model: sonnet
|
||||
---
|
||||
@@ -3,18 +3,51 @@
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PYTHON_SUFFIXES = {".py", ".pyi"}
|
||||
CONFIG_NAMES = {"pyproject.toml", "ty.toml", ".ty.toml", "ruff.toml", ".ruff.toml", "setup.cfg"}
|
||||
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:
|
||||
@@ -26,10 +59,22 @@ def main() -> int:
|
||||
repo_root = _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
|
||||
|
||||
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)
|
||||
|
||||
fix = _run(["uv", "run", "ruff", "check", "--fix", str(path)], repo_root)
|
||||
@@ -43,15 +88,27 @@ def main() -> int:
|
||||
after = _sha256(path)
|
||||
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(
|
||||
relative_path=relative_path,
|
||||
changed=changed,
|
||||
fix=fix,
|
||||
fmt=fmt,
|
||||
lint=lint,
|
||||
types=types,
|
||||
results=(fix, fmt, lint, types),
|
||||
findings=findings,
|
||||
resolved_codes=sorted(previous_codes - current_codes),
|
||||
)
|
||||
if message:
|
||||
_emit(message)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _emit(message: str) -> None:
|
||||
if not message:
|
||||
return
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -64,8 +121,6 @@ def main() -> int:
|
||||
)
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _read_payload() -> dict[str, object]:
|
||||
try:
|
||||
@@ -111,10 +166,6 @@ def _resolve_path(file_path: str, repo_root: Path) -> Path:
|
||||
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:
|
||||
try:
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
*,
|
||||
relative_path: str,
|
||||
changed: bool,
|
||||
fix: subprocess.CompletedProcess[str],
|
||||
fmt: subprocess.CompletedProcess[str],
|
||||
lint: subprocess.CompletedProcess[str],
|
||||
types: subprocess.CompletedProcess[str],
|
||||
results: tuple[subprocess.CompletedProcess[str], ...],
|
||||
findings: list[str],
|
||||
resolved_codes: list[str],
|
||||
) -> 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.")
|
||||
|
||||
for result in (fix, fmt, lint, types):
|
||||
for result in results:
|
||||
if result.returncode == 0:
|
||||
continue
|
||||
|
||||
output = _combined_output(result)
|
||||
if not output:
|
||||
output = f"Command exited with status {result.returncode}."
|
||||
|
||||
output = _combined_output(result) or f"Command exited with status {result.returncode}."
|
||||
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()
|
||||
|
||||
|
||||
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__":
|
||||
raise SystemExit(main())
|
||||
|
||||
46
.env.example
46
.env.example
@@ -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
1
.gitignore
vendored
@@ -22,3 +22,4 @@ infra/langfuse/.env
|
||||
|
||||
# Claude Code local overrides
|
||||
.claude/settings.local.json
|
||||
.claude/hooks/.quality_state.json
|
||||
|
||||
229
CLAUDE.md
Normal file
229
CLAUDE.md
Normal 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
149
alembic.ini
Normal 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
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
||||
86
alembic/env.py
Normal file
86
alembic/env.py
Normal 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
28
alembic/script.py.mako
Normal 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"}
|
||||
@@ -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
198
docker-compose.langfuse.yml
Normal 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
74
docker-compose.yml
Normal 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
|
||||
@@ -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
|
||||
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
|
||||
|
||||
### Positive
|
||||
@@ -122,6 +178,15 @@ edit through this API.
|
||||
- Optimistic concurrency via `version` requires every writer (ingestion and
|
||||
this API) to consistently read-check-write; a writer that skips this can
|
||||
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
|
||||
|
||||
@@ -132,3 +197,16 @@ edit through this API.
|
||||
- **Client-supplied `tenant_id` in request body**: rejected — trusting
|
||||
client input for the isolation boundary is a direct multitenancy security
|
||||
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.
|
||||
|
||||
@@ -164,16 +164,20 @@ metadata such as `domain`. File validation is server-side:
|
||||
- Derive `tenant_id`, `created_by`, and `updated_by` from `AuthContext`, not
|
||||
from form fields.
|
||||
|
||||
Ingestion may be slow because it parses, chunks, embeds, and writes many
|
||||
Qdrant points. The REST contract is job-shaped even if the first
|
||||
implementation runs inline:
|
||||
Ingestion parses, chunks, embeds, and writes many Qdrant points.
|
||||
[ADR-0017](0017-synchronous-ingestion-in-the-request-path.md) supersedes the
|
||||
job-shaped contract originally specified here: ingestion runs inline and the
|
||||
response is terminal.
|
||||
|
||||
```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
|
||||
contract.
|
||||
`ingestion_job_id` is retained so the attempt stays inspectable via
|
||||
`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
|
||||
|
||||
@@ -228,7 +232,7 @@ Important status codes:
|
||||
|
||||
| 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. |
|
||||
| `401 Unauthorized` | Missing/invalid API key. |
|
||||
| `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.
|
||||
- `/threads/{thread_id}/runs` remains compatible with the LangGraph thread/run
|
||||
model already chosen in ADR-0007.
|
||||
- Job-shaped file ingestion lets the first implementation be simple while
|
||||
keeping room for a durable worker without breaking clients.
|
||||
- Inline file ingestion (ADR-0017) gives callers a terminal result in one
|
||||
request, with failures surfaced as ordinary HTTP errors.
|
||||
- Router-level dependencies and typed FastAPI dependencies keep auth, tenant
|
||||
resolution, sessions, and scopes reusable instead of repeated per endpoint.
|
||||
|
||||
@@ -266,8 +270,9 @@ and Qdrant operations can be correlated.
|
||||
public product API.
|
||||
- API-key auth in Postgres adds a database lookup to every request unless
|
||||
short-lived caching is introduced. Caching must preserve revocation semantics.
|
||||
- A job-shaped ingestion contract needs a job status store even if the initial
|
||||
implementation processes inline.
|
||||
- Inline ingestion ties the upload's duration to proxy/client timeouts, and
|
||||
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
|
||||
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
|
||||
full API surface, bypass tenant/scoping rules too easily, and couple clients
|
||||
to storage operations the service should hide.
|
||||
- **Synchronous file ingestion only**: rejected as the contract. It is simpler
|
||||
to implement, but embedding and late-interaction vector generation can be
|
||||
slow enough to exceed HTTP timeouts. The job-shaped response gives the
|
||||
implementation room to evolve.
|
||||
- **Synchronous file ingestion only**: originally rejected here on the grounds
|
||||
that embedding and late-interaction vector generation can exceed HTTP timeouts;
|
||||
**adopted** by ADR-0017 for the first slice. Dense embedding is async network
|
||||
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
|
||||
backend owns conversation/session records, and LangGraph can create a
|
||||
checkpoint sequence on the first run for a `thread_id`. A create endpoint
|
||||
|
||||
@@ -47,6 +47,7 @@ Use SQLAlchemy 2.x ORM models with typed mappings:
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
@@ -208,9 +209,12 @@ on the `content_hash` policy.
|
||||
|
||||
#### `ingestion_jobs`
|
||||
|
||||
One attempt to parse/chunk/embed/upsert a source file. This table is required
|
||||
even if the first implementation processes inline, because ADR-0008's file
|
||||
upload contract is job-shaped.
|
||||
One attempt to parse/chunk/embed/upsert a source file. Under
|
||||
[ADR-0017](0017-synchronous-ingestion-in-the-request-path.md) that attempt runs
|
||||
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 |
|
||||
|---|---|
|
||||
|
||||
@@ -165,6 +165,7 @@ Avoid hidden global access:
|
||||
# Do not do this.
|
||||
session = SessionLocal()
|
||||
|
||||
|
||||
async def create_user(data: CreateUserRequest) -> User:
|
||||
session.add(User(email=data.email))
|
||||
await session.commit()
|
||||
@@ -206,8 +207,7 @@ async def get_point(
|
||||
qdrant: QdrantClient,
|
||||
auth: AuthContext,
|
||||
point_id: str,
|
||||
) -> PointResponse:
|
||||
...
|
||||
) -> PointResponse: ...
|
||||
```
|
||||
|
||||
Do not have lower layers import mutable resource singletons. Explicit parameters
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
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
|
||||
|
||||
The repository currently contains only a small FastAPI-oriented scaffold under
|
||||
@@ -69,17 +76,18 @@ src/
|
||||
│ │ ├── models/
|
||||
│ │ ├── repositories/
|
||||
│ │ ├── database.py
|
||||
│ │ └── outbox.py
|
||||
│ │ └── outbox.py # ADR-0017: not yet; no outbox today
|
||||
│ ├── qdrant/
|
||||
│ ├── minio/
|
||||
│ ├── rabbitmq/
|
||||
│ ├── embedding/ # async embedding adapters (batched, bounded)
|
||||
│ ├── rabbitmq/ # ADR-0017: not yet; ADR-0014 target only
|
||||
│ ├── langgraph/
|
||||
│ └── observability/
|
||||
├── messaging/
|
||||
├── messaging/ # ADR-0017: not yet; ADR-0014 target only
|
||||
│ ├── events.py
|
||||
│ ├── subjects.py
|
||||
│ └── outbox_publisher.py
|
||||
└── workers/
|
||||
└── workers/ # ADR-0017: not yet; ADR-0014 target only
|
||||
├── ingestion.py
|
||||
└── maintenance.py
|
||||
```
|
||||
@@ -103,7 +111,6 @@ tests/
|
||||
├── integration/
|
||||
│ ├── postgres/
|
||||
│ ├── minio/
|
||||
│ ├── rabbitmq/
|
||||
│ └── qdrant/
|
||||
└── e2e/
|
||||
```
|
||||
@@ -193,8 +200,10 @@ bootstrap. Graph nodes call application services, particularly
|
||||
- `qdrant/` owns Qdrant client lifecycle, collection/bootstrap helpers, low-level
|
||||
point operations, and hybrid retrieval adapter mechanics.
|
||||
- `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
|
||||
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
|
||||
adapters.
|
||||
- `observability/` configures structlog and Langfuse integrations.
|
||||
@@ -204,6 +213,12 @@ must not create mutable external clients at import time.
|
||||
|
||||
### 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
|
||||
outbox-publisher orchestration. The outbox publisher coordinates Postgres outbox
|
||||
records with the RabbitMQ adapter; it does not become a second source of job
|
||||
|
||||
@@ -4,17 +4,24 @@
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
or executable test suite yet.
|
||||
|
||||
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
|
||||
trusted server-side context; a file, job, and outbox event commit atomically;
|
||||
workers are safe under at-least-once delivery; and generated Qdrant points are
|
||||
trusted server-side context; the source file and its job row commit
|
||||
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
|
||||
boundary, while ADR-0012 requires explicit dependencies and resource lifetimes
|
||||
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,
|
||||
provider credentials, live models, or Langfuse availability.
|
||||
|
||||
ADR-0014's transactional-outbox decision controls ingestion dispatch. The upload
|
||||
path records durable dispatch intent; a separate outbox publisher publishes it.
|
||||
ADR-0017 controls ingestion: `POST /v1/files` parses, chunks, embeds, and
|
||||
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
|
||||
|
||||
@@ -42,8 +52,8 @@ ADR-0012. `FastAPI.TestClient` is not the default project test client.
|
||||
Register these markers:
|
||||
|
||||
- one primary boundary marker per test: `unit`, `integration`, or `e2e`;
|
||||
- `postgres`, `minio`, `rabbitmq`, or `qdrant` for the real service used by an
|
||||
integration test;
|
||||
- `postgres`, `minio`, or `qdrant` for the real service used by an integration
|
||||
test (a `rabbitmq` marker returns with ADR-0014);
|
||||
- `slow` only where a test materially exceeds the normal integration feedback
|
||||
target;
|
||||
- `live_provider` for an opt-in, credential-gated external-provider smoke test.
|
||||
@@ -68,7 +78,6 @@ tests/
|
||||
├── integration/
|
||||
│ ├── postgres/
|
||||
│ ├── minio/
|
||||
│ ├── rabbitmq/
|
||||
│ └── qdrant/
|
||||
└── e2e/
|
||||
```
|
||||
@@ -84,7 +93,7 @@ tests/
|
||||
tests.
|
||||
|
||||
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.
|
||||
|
||||
### Apply pragmatic TDD
|
||||
@@ -106,7 +115,7 @@ real-adapter integration test before declaring that boundary complete.
|
||||
### Use disposable real infrastructure in integration tests
|
||||
|
||||
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
|
||||
and credentials.
|
||||
@@ -119,8 +128,8 @@ for Postgres, MinIO, RabbitMQ, and Qdrant.
|
||||
adapters through their normal constructors.
|
||||
|
||||
Docker Compose remains the mechanism for manual local validation and a later,
|
||||
serialized operational smoke test where web, outbox-publisher, and worker run as
|
||||
independent processes. It is not the default pytest fixture mechanism.
|
||||
serialized operational smoke test of the running web process, which performs
|
||||
ingestion inline under ADR-0017. It is not the default pytest fixture mechanism.
|
||||
|
||||
### Treat invariants as reusable contracts
|
||||
|
||||
@@ -128,21 +137,25 @@ Test the following requirements at the applicable application, adapter, and E2E
|
||||
boundaries:
|
||||
|
||||
- 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
|
||||
return `404` for inaccessible resources.
|
||||
- Alembic creates the schema from an empty database. Tests never use
|
||||
`Base.metadata.create_all()`, and FastAPI startup performs readiness checks only,
|
||||
never DDL.
|
||||
- The upload transaction records `source_files`, a queued `ingestion_jobs` row,
|
||||
and an unpublished `outbox_events` row atomically. The HTTP route does not
|
||||
directly publish the ingestion event.
|
||||
- Broker messages contain durable identifiers and correlation metadata only. The
|
||||
worker reloads job and source-file records from Postgres before tenant-scoped
|
||||
side effects.
|
||||
- Worker acknowledgement follows durable progress or terminal-state persistence.
|
||||
Duplicate publication and redelivery do not regress terminal jobs, inflate
|
||||
counters, or create duplicate logical chunks.
|
||||
- The first upload transaction records `source_files` and a `running`
|
||||
`ingestion_jobs` row atomically, and commits before any parse/embed work; no
|
||||
session or transaction is held open across that work.
|
||||
- Every terminating path — success, parse failure, embedder failure, timeout —
|
||||
writes a terminal job status and its `ingestion_job_events` row. A job is
|
||||
never left in `running` by a handled failure.
|
||||
- Each bound maps to its status code: oversized upload `413`, capacity `503`,
|
||||
timeout `504`, embedder failure `502`.
|
||||
- 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
|
||||
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
|
||||
behavior.
|
||||
- Real-service tests cover the behaviors least safe to simulate: Alembic
|
||||
migrations, object storage, RabbitMQ acknowledgements/redelivery, and Qdrant
|
||||
filtering/upserts.
|
||||
migrations, object storage, transaction boundaries under real sessions, and
|
||||
Qdrant filtering/upserts.
|
||||
- Explicit fakes reinforce the dependency direction and resource ownership rules
|
||||
from ADR-0012 and ADR-0015.
|
||||
- The ingestion path has concrete tenant-isolation and reliability contracts,
|
||||
@@ -197,8 +210,8 @@ ratcheting threshold rather than encouraging low-value coverage.
|
||||
## Alternatives Considered
|
||||
|
||||
- **Mock all external SDKs**: rejected. Mocks cannot prove migrations, real
|
||||
RabbitMQ acknowledgement/redelivery behavior, MinIO semantics, or Qdrant
|
||||
tenant filtering.
|
||||
transaction/connection behavior, MinIO semantics, or Qdrant tenant
|
||||
filtering.
|
||||
- **Use full-stack Compose tests only**: rejected. They are slow and opaque for
|
||||
the default development loop and make failures difficult to localize.
|
||||
- **Run all integration containers on every pytest invocation**: rejected. Test
|
||||
|
||||
252
docs/adr/0017-synchronous-ingestion-in-the-request-path.md
Normal file
252
docs/adr/0017-synchronous-ingestion-in-the-request-path.md
Normal 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.
|
||||
@@ -4,9 +4,8 @@
|
||||
|
||||
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
|
||||
by durable Postgres records, dispatched through RabbitMQ using a
|
||||
transactional outbox, processed by a separate worker, and indexed as Qdrant
|
||||
points.
|
||||
by durable Postgres records, parsed/chunked/embedded inline in the request
|
||||
(ADR-0017), and indexed as Qdrant points before the response returns.
|
||||
|
||||
This is an implementation plan, not an Architecture Decision Record. ADRs explain
|
||||
why major technologies and boundaries were chosen; this document defines the
|
||||
@@ -19,10 +18,9 @@ The first vertical slice uses these responsibilities:
|
||||
| System | Responsibility |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| RabbitMQ | Durable delivery of ingestion and maintenance work. |
|
||||
| Ingestion worker | Parsing, chunking, embedding, ingestion-generated Chunk/Point CRUD, and job status updates. |
|
||||
| Ingestion service | Parsing, chunking, embedding, ingestion-generated Chunk/Point CRUD, and job status updates — inline in the request. |
|
||||
| Qdrant | Tenant-filtered generated chunks and their vectors/payloads. |
|
||||
|
||||
The controlling ADRs are:
|
||||
@@ -35,9 +33,11 @@ The controlling ADRs are:
|
||||
resource lifetime, dependency injection, and explicit transaction ownership.
|
||||
- [ADR-0013](../adr/0013-s3-compatible-object-storage-with-minio.md): MinIO object
|
||||
storage boundary.
|
||||
- [ADR-0014](../adr/0014-durable-job-dispatch-with-rabbitmq.md): RabbitMQ,
|
||||
transactional outbox, separate workers, and worker-owned ingestion Chunk/Point
|
||||
CRUD.
|
||||
- [ADR-0017](../adr/0017-synchronous-ingestion-in-the-request-path.md):
|
||||
inline ingestion, batched/bounded-concurrent embedding,
|
||||
`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
|
||||
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.
|
||||
- File validation, size limits, content hashing, and streaming upload to MinIO.
|
||||
- Alembic-managed Postgres schema for the minimal tenant/auth, source file,
|
||||
ingestion job, job event, and outbox records needed by this slice.
|
||||
- Transactional outbox publication of `ingestion.job.created` to RabbitMQ.
|
||||
- A separate ingestion worker process with a durable RabbitMQ consumer.
|
||||
ingestion job, and job event records needed by this slice.
|
||||
- Inline ingestion in `POST /v1/files`, with batched/bounded-concurrent
|
||||
embedding, thread-offloaded parsing, and enforced size/timeout/capacity
|
||||
bounds.
|
||||
- CSV parsing and deterministic chunk creation.
|
||||
- Tenant-filtered Qdrant point upserts using deterministic point identifiers.
|
||||
- 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
|
||||
boundaries.
|
||||
|
||||
@@ -70,31 +71,40 @@ them.
|
||||
ingestion.
|
||||
- Full tenant erasure and hard-deletion workflow.
|
||||
- A public download API or presigned object URLs.
|
||||
- Exactly-once end-to-end processing. The worker must instead be safe under
|
||||
at-least-once delivery.
|
||||
- A message broker, transactional outbox, job queue, and separate
|
||||
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
|
||||
|
||||
The implementation must preserve these rules from the ADRs:
|
||||
|
||||
1. `tenant_id` is derived from trusted authentication context; it is never
|
||||
accepted from the upload body, query parameters, MinIO metadata, or a broker
|
||||
message as authority.
|
||||
accepted from the upload body, query parameters, MinIO metadata, or a
|
||||
dispatch payload as authority.
|
||||
2. MinIO stores bytes; Postgres stores metadata, lifecycle state, job progress,
|
||||
audit records, and dispatch intent.
|
||||
3. Broker messages contain stable IDs and correlation metadata only. They never
|
||||
audit records, and the queue.
|
||||
3. Dispatch payloads contain stable IDs and correlation metadata only. They never
|
||||
contain file bytes, extracted text, chunks, embeddings, secrets, raw prompts,
|
||||
or raw model output.
|
||||
4. The worker reloads the job and source-file records from Postgres before doing
|
||||
tenant-scoped work.
|
||||
5. The worker, not the HTTP publisher, performs parsing, chunking, embedding, and
|
||||
generated Qdrant Chunk/Point CRUD.
|
||||
4. Ingestion works from the persisted job and source-file records, not from
|
||||
request-supplied values.
|
||||
5. Parsing, chunking, embedding, and generated Qdrant Chunk/Point CRUD live in
|
||||
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
|
||||
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
|
||||
the applicable Postgres progress/final state.
|
||||
8. Application clients are built at FastAPI or worker-process startup and closed
|
||||
at shutdown. No mutable clients are opened as import-time globals.
|
||||
7. No Postgres session or transaction is held open across parse/embed/upsert.
|
||||
The job row is committed `running` before the work and updated to a terminal
|
||||
status after it, in a second short transaction.
|
||||
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
|
||||
|
||||
@@ -120,22 +130,25 @@ rather than becoming an accidental repository behavior.
|
||||
For the first release, file deletion should be soft and job-shaped:
|
||||
|
||||
1. mark the source file as deletion requested/soft deleted in Postgres;
|
||||
2. write a maintenance outbox event;
|
||||
3. have a worker soft-delete the related Qdrant points;
|
||||
2. soft-delete the related Qdrant points inline, recording the attempt;
|
||||
4. retain the MinIO object until an explicit retention or hard-erasure workflow.
|
||||
|
||||
Hard deletion requires a later retention/erasure implementation covering MinIO,
|
||||
Qdrant, and the relevant Postgres data.
|
||||
|
||||
### Broker operations
|
||||
### Ingestion bounds and operations
|
||||
|
||||
Before deploying an environment, define and document:
|
||||
|
||||
- RabbitMQ exchange, queue, binding, and dead-letter-exchange configuration;
|
||||
- queue name, prefetch, manual-ack policy, and DLX retry/backoff;
|
||||
- worker concurrency and resource limits;
|
||||
- outbox polling/publish interval and stuck-event alert threshold;
|
||||
- how failed jobs are inspected, retried, and cancelled.
|
||||
- `INGESTION_MAX_CONCURRENCY` and the thread-pool capacity limiter, and their
|
||||
relation to the process's CPU/memory budget;
|
||||
- `INGESTION_TIMEOUT_SECONDS`, and the proxy/load-balancer/client read timeouts
|
||||
that must exceed it;
|
||||
- `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
|
||||
reliability guarantee or system boundary.
|
||||
@@ -144,15 +157,14 @@ reliability guarantee or system boundary.
|
||||
|
||||
### Phase 1: Foundation and local dependencies
|
||||
|
||||
1. Add typed configuration in `src/config.py` for Postgres, MinIO, RabbitMQ,
|
||||
Qdrant, application limits, and logging.
|
||||
1. Add typed configuration in `src/config.py` for Postgres, MinIO, Qdrant,
|
||||
ingestion bounds, application limits, and logging.
|
||||
2. Populate `.env.example` with non-secret local-development configuration.
|
||||
3. Add application Docker Compose services for Postgres, MinIO, RabbitMQ, and
|
||||
Qdrant. Keep application MinIO buckets/credentials separate from Langfuse
|
||||
infrastructure.
|
||||
3. Add application Docker Compose services for Postgres, MinIO, and Qdrant. Keep
|
||||
application MinIO buckets/credentials separate from Langfuse infrastructure.
|
||||
4. Add direct Python dependencies and lock them with `uv`:
|
||||
SQLAlchemy async/Postgres driver, MinIO/S3 client, aio-pika, Qdrant client,
|
||||
and structured logging dependencies chosen by ADR-0011.
|
||||
SQLAlchemy async/Postgres driver, MinIO/S3 client, Qdrant client, and
|
||||
structured logging dependencies chosen by ADR-0011.
|
||||
5. Add the pytest foundation from ADR-0016: async test configuration, boundary
|
||||
markers, and support for dependency-injected fakes. Add a lifespan smoke test
|
||||
before creating external clients.
|
||||
@@ -166,11 +178,10 @@ run without Docker or live providers.
|
||||
### Phase 2: Database, migrations, and domain contracts
|
||||
|
||||
1. Define SQLAlchemy models and Alembic migrations for the minimum required
|
||||
tables: `tenants`, `api_keys`, `source_files`, `ingestion_jobs`,
|
||||
`ingestion_job_events`, and `outbox_events`.
|
||||
2. Define Pydantic request/response/message schemas, including a versioned
|
||||
`ingestion.job.created` message containing `event_id`, `tenant_id`, `file_id`,
|
||||
`ingestion_job_id`, `request_id`, and `api_key_id`.
|
||||
tables: `tenants`, `api_keys`, `source_files`, `ingestion_jobs`, and
|
||||
`ingestion_job_events`, per ADR-0009.
|
||||
2. Define Pydantic request/response schemas, including the terminal upload
|
||||
response (`file_id`, `ingestion_job_id`, `status`, `chunks_indexed`).
|
||||
3. Implement explicit repositories/services with a request/job-lifetime
|
||||
`AsyncSession`; routes/services own commit/rollback boundaries as specified by
|
||||
ADR-0012.
|
||||
@@ -188,75 +199,75 @@ reads/writes and valid job transitions.
|
||||
2. Implement `POST /v1/files` for CSV only, including streaming-size controls,
|
||||
file-type validation, SHA-256 calculation, and a private MinIO upload using
|
||||
an internal object key.
|
||||
3. In one Postgres transaction, persist `source_files`, create
|
||||
`ingestion_jobs(status='queued')`, and write the corresponding unpublished
|
||||
`outbox_events` row.
|
||||
4. Return `202 Accepted` with `file_id`, `ingestion_job_id`, and `queued` status.
|
||||
3. In one short Postgres transaction, persist `source_files` and create
|
||||
`ingestion_jobs(status='running')`, then commit and release the connection
|
||||
before any parse/embed work.
|
||||
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
|
||||
response that does not expose raw storage credentials or internal artifacts.
|
||||
6. Add cleanup/compensation handling for a MinIO upload that succeeds while the
|
||||
database transaction fails.
|
||||
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.
|
||||
|
||||
**Exit criteria:** an authenticated CSV upload creates a private object, a queued
|
||||
job, and an unpublished outbox event; a tenant cannot retrieve another tenant's
|
||||
file status; the HTTP path does not publish directly to RabbitMQ.
|
||||
**Exit criteria:** an authenticated CSV upload creates a private object and a
|
||||
`running` job row committed before any ingestion work; a tenant cannot retrieve
|
||||
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
|
||||
binding configuration through deployment/bootstrap code rather than route
|
||||
startup side effects.
|
||||
2. Implement an outbox-publisher process that safely claims unpublished events,
|
||||
publishes them with the stable event id using publisher confirms, and records
|
||||
success/failure attempts.
|
||||
3. RabbitMQ has no broker-native message de-duplication; retain idempotency in
|
||||
all consumers as the sole guard against duplicate processing.
|
||||
4. Add monitoring/logging for publish attempts, unpublished-event age, and
|
||||
repeated failures.
|
||||
5. Add unit tests for event claiming and retryable failures, then Testcontainers
|
||||
RabbitMQ integration tests for durable publication, restart/retry, duplicate
|
||||
publication, and message metadata.
|
||||
1. Add the async embedding ports and adapters in `src/infrastructure/embedding/`,
|
||||
with per-provider batching and an `asyncio.Semaphore` bounding in-flight
|
||||
batches.
|
||||
2. Route blocking work (parse, chunk, BM25, `minio`) through
|
||||
`anyio.to_thread.run_sync` with an explicit `CapacityLimiter` created at
|
||||
startup, so ingestion cannot exhaust Starlette's thread pool.
|
||||
3. Enforce the request bounds: `INGESTION_MAX_CONCURRENCY` (`503` + `Retry-After`
|
||||
when exceeded), `INGESTION_TIMEOUT_SECONDS` around the whole work phase
|
||||
(`504`), and the size/chunk-count ceiling (`413`) checked before work starts.
|
||||
4. Guarantee a bounded failure always writes a terminal job status — a timeout
|
||||
must never leave a job stuck in `running`.
|
||||
5. Add unit tests for batching/concurrency limits, timeout-to-terminal-status,
|
||||
and capacity rejection, using scripted embedder fakes.
|
||||
|
||||
**Exit criteria:** an outbox event becomes a durable RabbitMQ message after a
|
||||
publisher restart; retrying publication cannot create duplicate application work.
|
||||
**Exit criteria:** embedding a few hundred chunks issues batched, concurrent
|
||||
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,
|
||||
MinIO, RabbitMQ, Qdrant, model, and logging clients.
|
||||
2. Consume `ingestion.job.created`; reload and validate Postgres records before
|
||||
fetching the MinIO object.
|
||||
3. Transition the job from `queued` to `running` conditionally, append progress
|
||||
events, parse CSV, create deterministic chunks, and upsert tenant-scoped
|
||||
Qdrant points.
|
||||
4. Mark the job `succeeded` with counters or `failed` with a safe error summary;
|
||||
acknowledge only after final/progress state is persisted.
|
||||
5. Make a repeated delivery of the same `ingestion_job_id` safe: no duplicate
|
||||
logical chunks, no incorrect counters, and no transition from a terminal state
|
||||
back to `running`.
|
||||
1. Implement the ingestion service called by the route, using the
|
||||
application-lifetime database, MinIO, Qdrant, model, and logging clients.
|
||||
2. Validate the persisted records before fetching the MinIO object.
|
||||
3. Append progress events, parse CSV, create deterministic chunks, embed them,
|
||||
and upsert tenant-scoped Qdrant points — without holding a Postgres session
|
||||
open across the work.
|
||||
4. In a second short transaction, mark the job `succeeded` with counters or
|
||||
`failed` with a safe error summary, then return the terminal response.
|
||||
5. Make a retried upload safe: no duplicate 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
|
||||
transitions. Add Testcontainers Qdrant and RabbitMQ integration tests for
|
||||
tenant-filtered upserts, acknowledgement after durable state, redelivery, and
|
||||
transitions. Add Testcontainers Qdrant and Postgres integration tests for
|
||||
tenant-filtered upserts, terminal state persistence, retrying an upload, and
|
||||
parser/Qdrant failure handling.
|
||||
|
||||
**Exit criteria:** a successful upload reaches `succeeded`, and its points are
|
||||
retrievable only under the owning tenant's Qdrant filter. Forced worker failure
|
||||
and message redelivery produce a correct final job state.
|
||||
**Exit criteria:** a successful upload returns `201` with a terminal status, and
|
||||
its points are retrievable only under the owning tenant's Qdrant filter. A forced
|
||||
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
|
||||
|
||||
1. Add an operator runbook covering local startup, migrations, MinIO bucket setup,
|
||||
RabbitMQ exchange/queue setup, web/worker/outbox commands, and job replay.
|
||||
2. Add a serialized Compose-based operational smoke test where the web process,
|
||||
outbox publisher, and worker run independently for upload through indexed
|
||||
points. Testcontainers remains the standard pytest mechanism for individual
|
||||
adapter integration tests.
|
||||
3. Add end-to-end tests for duplicate upload, duplicate RabbitMQ delivery, outbox
|
||||
publisher crash/restart, worker crash/restart, tenant isolation, and failed
|
||||
parser/Qdrant behavior.
|
||||
1. Add an operator runbook covering local startup, migrations, MinIO bucket
|
||||
setup, the run command, ingestion-bound tuning, the proxy/client timeout
|
||||
requirement, and how to retry a failed ingestion.
|
||||
2. Add a serialized Compose-based operational smoke test covering upload through
|
||||
indexed points against the running web process. Testcontainers remains the
|
||||
standard pytest mechanism for individual adapter integration tests.
|
||||
3. Add end-to-end tests for duplicate upload, retrying a failed upload, tenant
|
||||
isolation, capacity/timeout rejection, and failed parser/Qdrant behavior.
|
||||
4. Add health/readiness checks that distinguish process health from dependency
|
||||
readiness.
|
||||
5. Update the README with local-start instructions and links to ADRs, this plan,
|
||||
@@ -274,10 +285,11 @@ covered by automated tests:
|
||||
```text
|
||||
POST /v1/files (authenticated CSV upload)
|
||||
-> raw bytes stored privately in MinIO
|
||||
-> source file, queued job, and outbox event committed in Postgres
|
||||
-> outbox publisher writes a durable RabbitMQ message
|
||||
-> ingestion worker processes and indexes deterministic Qdrant points
|
||||
-> source file and running job committed in Postgres, connection released
|
||||
-> parse/chunk on threads, embed in bounded concurrent batches
|
||||
-> deterministic Qdrant points upserted
|
||||
-> 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
|
||||
```
|
||||
|
||||
|
||||
285
docs/plans/002-point-crud-and-keyword-search.md
Normal file
285
docs/plans/002-point-crud-and-keyword-search.md
Normal 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.
|
||||
@@ -5,12 +5,16 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aio-pika>=9.5.0",
|
||||
"alembic>=1.19.1",
|
||||
"anyio>=4.11.0",
|
||||
"asyncpg>=0.31.0",
|
||||
"fastapi[standard]==0.141.1",
|
||||
"langgraph>=1.2.10",
|
||||
"minio>=7.2.20",
|
||||
"pydantic-settings>=2.15.0",
|
||||
"qdrant-client>=1.19.0",
|
||||
"sqlalchemy>=2.0.51",
|
||||
"structlog>=26.1.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -36,7 +40,6 @@ markers = [
|
||||
"e2e: end-to-end vertical-slice tests",
|
||||
"postgres: integration test using Postgres",
|
||||
"minio: integration test using MinIO",
|
||||
"rabbitmq: integration test using RabbitMQ",
|
||||
"qdrant: integration test using Qdrant",
|
||||
"slow: test exceeds normal integration feedback time",
|
||||
"live_provider: opt-in test that calls an external provider",
|
||||
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
0
src/api/__init__.py
Normal file
0
src/api/__init__.py
Normal file
0
src/api/dependencies/__init__.py
Normal file
0
src/api/dependencies/__init__.py
Normal file
3
src/api/router.py
Normal file
3
src/api/router.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
0
src/api/routers/__init__.py
Normal file
0
src/api/routers/__init__.py
Normal file
36
src/api/routers/health.py
Normal file
36
src/api/routers/health.py
Normal 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
|
||||
0
src/api/schemas/__init__.py
Normal file
0
src/api/schemas/__init__.py
Normal file
0
src/bootstrap/__init__.py
Normal file
0
src/bootstrap/__init__.py
Normal file
44
src/bootstrap/dependencies.py
Normal file
44
src/bootstrap/dependencies.py
Normal 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
56
src/bootstrap/lifespan.py
Normal 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
|
||||
@@ -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):
|
||||
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)
|
||||
|
||||
0
src/infrastructure/__init__.py
Normal file
0
src/infrastructure/__init__.py
Normal file
0
src/infrastructure/minio/__init__.py
Normal file
0
src/infrastructure/minio/__init__.py
Normal file
23
src/infrastructure/minio/client.py
Normal file
23
src/infrastructure/minio/client.py
Normal 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
|
||||
0
src/infrastructure/observability/__init__.py
Normal file
0
src/infrastructure/observability/__init__.py
Normal file
84
src/infrastructure/observability/logging.py
Normal file
84
src/infrastructure/observability/logging.py
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
0
src/infrastructure/postgres/__init__.py
Normal file
0
src/infrastructure/postgres/__init__.py
Normal file
28
src/infrastructure/postgres/database.py
Normal file
28
src/infrastructure/postgres/database.py
Normal 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
|
||||
15
src/infrastructure/postgres/models/__init__.py
Normal file
15
src/infrastructure/postgres/models/__init__.py
Normal 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",
|
||||
]
|
||||
41
src/infrastructure/postgres/models/api_key.py
Normal file
41
src/infrastructure/postgres/models/api_key.py
Normal 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()
|
||||
)
|
||||
5
src/infrastructure/postgres/models/base.py
Normal file
5
src/infrastructure/postgres/models/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
59
src/infrastructure/postgres/models/ingestion_job.py
Normal file
59
src/infrastructure/postgres/models/ingestion_job.py
Normal 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()
|
||||
)
|
||||
47
src/infrastructure/postgres/models/ingestion_job_event.py
Normal file
47
src/infrastructure/postgres/models/ingestion_job_event.py
Normal 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())
|
||||
41
src/infrastructure/postgres/models/source_file.py
Normal file
41
src/infrastructure/postgres/models/source_file.py
Normal 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)
|
||||
27
src/infrastructure/postgres/models/tenant.py
Normal file
27
src/infrastructure/postgres/models/tenant.py
Normal 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)
|
||||
0
src/infrastructure/qdrant/__init__.py
Normal file
0
src/infrastructure/qdrant/__init__.py
Normal file
18
src/infrastructure/qdrant/client.py
Normal file
18
src/infrastructure/qdrant/client.py
Normal 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
|
||||
16
src/main.py
16
src/main.py
@@ -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
0
tests/__init__.py
Normal file
37
tests/conftest.py
Normal file
37
tests/conftest.py
Normal 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
0
tests/e2e/__init__.py
Normal file
5
tests/fakes.py
Normal file
5
tests/fakes.py
Normal 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.
|
||||
"""
|
||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
0
tests/integration/minio/__init__.py
Normal file
0
tests/integration/minio/__init__.py
Normal file
0
tests/integration/postgres/__init__.py
Normal file
0
tests/integration/postgres/__init__.py
Normal file
0
tests/integration/qdrant/__init__.py
Normal file
0
tests/integration/qdrant/__init__.py
Normal file
0
tests/support/__init__.py
Normal file
0
tests/support/__init__.py
Normal file
1
tests/support/assertions.py
Normal file
1
tests/support/assertions.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Custom test assertions, populated as later phases need them."""
|
||||
1
tests/support/factories.py
Normal file
1
tests/support/factories.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Object builders for test fixtures, populated as later phases need them."""
|
||||
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
0
tests/unit/agent/__init__.py
Normal file
0
tests/unit/agent/__init__.py
Normal file
0
tests/unit/application/__init__.py
Normal file
0
tests/unit/application/__init__.py
Normal file
0
tests/unit/bootstrap/__init__.py
Normal file
0
tests/unit/bootstrap/__init__.py
Normal file
19
tests/unit/bootstrap/test_lifespan.py
Normal file
19
tests/unit/bootstrap/test_lifespan.py
Normal 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
27
tests/unit/test_config.py
Normal 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
|
||||
11
tests/unit/test_healthz.py
Normal file
11
tests/unit/test_healthz.py
Normal 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
17
tests/unit/test_readyz.py
Normal 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
636
uv.lock
generated
@@ -6,32 +6,6 @@ resolution-markers = [
|
||||
"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]]
|
||||
name = "alembic"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "asgi-lifespan"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "certifi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "charset-normalizer"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "h11"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "httpcore"
|
||||
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" },
|
||||
]
|
||||
|
||||
[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]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
@@ -795,84 +984,19 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multidict"
|
||||
version = "6.7.1"
|
||||
name = "minio"
|
||||
version = "7.2.20"
|
||||
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 = [
|
||||
{ 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/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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -880,12 +1004,16 @@ name = "new-chatbot"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aio-pika" },
|
||||
{ name = "alembic" },
|
||||
{ name = "anyio" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "langgraph" },
|
||||
{ name = "minio" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "qdrant-client" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "structlog" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -903,12 +1031,16 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aio-pika", specifier = ">=9.5.0" },
|
||||
{ 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 = "langgraph", specifier = ">=1.2.10" },
|
||||
{ name = "minio", specifier = ">=7.2.20" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.15.0" },
|
||||
{ name = "qdrant-client", specifier = ">=1.19.0" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.51" },
|
||||
{ name = "structlog", specifier = ">=26.1.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -924,6 +1056,68 @@ dev = [
|
||||
{ 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]]
|
||||
name = "orjson"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -1020,80 +1205,69 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
version = "0.5.2"
|
||||
name = "portalocker"
|
||||
version = "3.2.0"
|
||||
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 = [
|
||||
{ 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/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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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/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/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/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/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/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/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/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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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/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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "7.35.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||
]
|
||||
|
||||
[[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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "requests"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "tenacity"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
|
||||
Reference in New Issue
Block a user