From 693cd952cd6decd9cfb45432ea42f613c03e7ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Wed, 16 Sep 2026 10:01:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(asset):=20=E6=B8=85=E7=90=86=E8=A2=AB?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E7=9A=84=E7=BB=93=E5=B0=BE=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E5=BC=95=E7=94=A8=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=95=B4=E5=8D=95?= =?UTF-8?q?=20502?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仓库首次入库(此前仅存在于本地工作区与测试服构建产物中,未上传)。 改动(src/knowledge_adapter/asset_content_processor.py): - 新增 _TRUNCATED_TRAILING_MARKDOWN_RESOURCE_IMAGE 规则,清除 chunk 末尾被截断的 ![](resource://xxxx 残片(WeKnora 分块在图片引用中间截断时产生,旧逻辑无法匹配完整引用, 残留 resource:// 触发 ContentProcessingError,导致整单 502) - 回归测试 tests/test_asset_content_processor.py:: test_truncated_trailing_markdown_resource_image_is_removed 验证证据: - 测试服 120.76.217.51,镜像 knowledge-adapter-service:reviewed-test-aeab4b5fca2d (release aeab4b5fca2d8661):KB d015d05c-25c8-4d63-b2ab-e8590d3c2dfb 5 个查询全部 http=200、records=5、残留 resource:// = 0;KB b9dacd62 无退化;容器日志无 error/exception - 本地:pytest 141 项全通过、ruff 全通过 - 该文件与 258c31e0fe07fd08 源码的差异经逐文件 md5 比对,为唯一变更文件 --- .dockerignore | 19 + .gitignore | 14 + Dockerfile | 25 + Makefile | 24 + README.md | 29 + deploy/adapter.env.example | 26 + deploy/docker-compose.adapter.yml | 87 ++ deploy/nginx/knowledge-adapter.conf | 87 ++ deploy/nginx/nginx_adapter_helper.py | 149 +++ deploy/nginx/query_active_asset_id.py | 21 + deploy/nginx/rollout_test_nginx_adapter.sh | 118 +++ deploy/nginx/ubuntu-public-temporary.list | 4 + deploy/nginx/verify_image_route.py | 61 ++ deploy/nginx/weknora-test-origin.conf | 118 +++ .../knowledge-adapter-oss-read-policy.json | 14 + deploy/sql/weknora_readonly_grants.sql | 15 + docs/architecture.md | 97 ++ docs/audit-report-slice9.md | 129 +++ docs/deployment.md | 144 +++ docs/frontend-api-guide.md | 356 +++++++ docs/key-rotation.md | 83 ++ docs/security.md | 114 +++ docs/test-api-request-response.txt | 107 ++ migrations/0001_asset_mapping.down.sql | 1 + migrations/0001_asset_mapping.up.sql | 11 + pyproject.toml | 48 + src/knowledge_adapter/__init__.py | 1 + src/knowledge_adapter/app.py | 201 ++++ .../asset_content_processor.py | 166 +++ src/knowledge_adapter/asset_redirect.py | 56 ++ src/knowledge_adapter/asset_repository.py | 113 +++ src/knowledge_adapter/auth.py | 38 + src/knowledge_adapter/content_budget.py | 25 + src/knowledge_adapter/entrypoint.py | 9 + src/knowledge_adapter/factory.py | 209 ++++ src/knowledge_adapter/models.py | 56 ++ src/knowledge_adapter/observability.py | 74 ++ src/knowledge_adapter/oss_signer.py | 124 +++ src/knowledge_adapter/resource_locator.py | 57 ++ src/knowledge_adapter/service.py | 96 ++ src/knowledge_adapter/settings.py | 138 +++ src/knowledge_adapter/weknora_client.py | 89 ++ .../weknora_resource_resolver.py | 100 ++ tests/test_asset_content_processor.py | 314 ++++++ tests/test_asset_redirect.py | 126 +++ tests/test_asset_repository.py | 209 ++++ tests/test_asset_route.py | 127 +++ tests/test_auth.py | 91 ++ tests/test_content_budget.py | 34 + tests/test_delivery_artifacts.py | 218 ++++ tests/test_entrypoint.py | 22 + tests/test_factory.py | 632 ++++++++++++ tests/test_health.py | 27 + tests/test_migrations.py | 41 + tests/test_observability.py | 258 +++++ tests/test_oss_signer.py | 150 +++ tests/test_resource_locator.py | 110 ++ tests/test_retrieval.py | 269 +++++ tests/test_service.py | 302 ++++++ tests/test_settings.py | 158 +++ tests/test_weknora_client.py | 113 +++ tests/test_weknora_grants.py | 31 + tests/test_weknora_resource_resolver.py | 237 +++++ uv.lock | 947 ++++++++++++++++++ 64 files changed, 7869 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 deploy/adapter.env.example create mode 100644 deploy/docker-compose.adapter.yml create mode 100644 deploy/nginx/knowledge-adapter.conf create mode 100644 deploy/nginx/nginx_adapter_helper.py create mode 100644 deploy/nginx/query_active_asset_id.py create mode 100644 deploy/nginx/rollout_test_nginx_adapter.sh create mode 100644 deploy/nginx/ubuntu-public-temporary.list create mode 100644 deploy/nginx/verify_image_route.py create mode 100644 deploy/nginx/weknora-test-origin.conf create mode 100644 deploy/ram/knowledge-adapter-oss-read-policy.json create mode 100644 deploy/sql/weknora_readonly_grants.sql create mode 100644 docs/architecture.md create mode 100644 docs/audit-report-slice9.md create mode 100644 docs/deployment.md create mode 100644 docs/frontend-api-guide.md create mode 100644 docs/key-rotation.md create mode 100644 docs/security.md create mode 100644 docs/test-api-request-response.txt create mode 100644 migrations/0001_asset_mapping.down.sql create mode 100644 migrations/0001_asset_mapping.up.sql create mode 100644 pyproject.toml create mode 100644 src/knowledge_adapter/__init__.py create mode 100644 src/knowledge_adapter/app.py create mode 100644 src/knowledge_adapter/asset_content_processor.py create mode 100644 src/knowledge_adapter/asset_redirect.py create mode 100644 src/knowledge_adapter/asset_repository.py create mode 100644 src/knowledge_adapter/auth.py create mode 100644 src/knowledge_adapter/content_budget.py create mode 100644 src/knowledge_adapter/entrypoint.py create mode 100644 src/knowledge_adapter/factory.py create mode 100644 src/knowledge_adapter/models.py create mode 100644 src/knowledge_adapter/observability.py create mode 100644 src/knowledge_adapter/oss_signer.py create mode 100644 src/knowledge_adapter/resource_locator.py create mode 100644 src/knowledge_adapter/service.py create mode 100644 src/knowledge_adapter/settings.py create mode 100644 src/knowledge_adapter/weknora_client.py create mode 100644 src/knowledge_adapter/weknora_resource_resolver.py create mode 100644 tests/test_asset_content_processor.py create mode 100644 tests/test_asset_redirect.py create mode 100644 tests/test_asset_repository.py create mode 100644 tests/test_asset_route.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_content_budget.py create mode 100644 tests/test_delivery_artifacts.py create mode 100644 tests/test_entrypoint.py create mode 100644 tests/test_factory.py create mode 100644 tests/test_health.py create mode 100644 tests/test_migrations.py create mode 100644 tests/test_observability.py create mode 100644 tests/test_oss_signer.py create mode 100644 tests/test_resource_locator.py create mode 100644 tests/test_retrieval.py create mode 100644 tests/test_service.py create mode 100644 tests/test_settings.py create mode 100644 tests/test_weknora_client.py create mode 100644 tests/test_weknora_grants.py create mode 100644 tests/test_weknora_resource_resolver.py create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d1d261 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.gitignore +.venv +.pytest_cache +.ruff_cache +__pycache__ +*.py[cod] +.coverage +htmlcov +.env +*.log +secrets/ +*.secret +*.key +*.pem +docs +migrations +deploy +tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c2d347 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +*.py[cod] +.env +*.log +secrets/ +*.secret +*.key +*.pem +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..33e8ddd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1.7 + +FROM ghcr.io/astral-sh/uv:0.8.10 AS uv + +FROM python:3.12.11-slim-bookworm AS builder +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 +WORKDIR /app +COPY --from=uv /uv /usr/local/bin/uv +COPY pyproject.toml uv.lock ./ +COPY src ./src +RUN uv sync --frozen --no-dev --no-editable + +FROM python:3.12.11-slim-bookworm AS runtime +RUN groupadd --gid 10001 adapter \ + && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin adapter +ENV VIRTUAL_ENV=/app/.venv \ + PATH=/app/.venv/bin:$PATH \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 +WORKDIR /app +COPY --from=builder --chown=10001:10001 /app/.venv /app/.venv +USER 10001:10001 +EXPOSE 8080 +CMD ["uvicorn", "knowledge_adapter.entrypoint:create_production_app", "--factory", "--host", "0.0.0.0", "--port", "8080", "--no-access-log", "--timeout-keep-alive", "5"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..265d5b4 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +UV ?= uv +COMPOSE_FILE := deploy/docker-compose.adapter.yml +ENV_FILE ?= + +.PHONY: check test-slice8 lint compose-config image-build ram-policy-check + +check: test-slice8 lint ram-policy-check + +test-slice8: + $(UV) run pytest tests/test_entrypoint.py tests/test_settings.py tests/test_factory.py tests/test_delivery_artifacts.py -q + +lint: + $(UV) run ruff check src tests + +ram-policy-check: + python -m json.tool deploy/ram/knowledge-adapter-oss-read-policy.json >/dev/null + +compose-config: + @test -n "$(ENV_FILE)" || (printf '%s\n' 'ENV_FILE is required' >&2; exit 2) + docker compose --env-file "$(ENV_FILE)" -f "$(COMPOSE_FILE)" config --quiet + +image-build: + @test -n "$(ENV_FILE)" || (printf '%s\n' 'ENV_FILE is required' >&2; exit 2) + docker compose --env-file "$(ENV_FILE)" -f "$(COMPOSE_FILE)" build adapter diff --git a/README.md b/README.md new file mode 100644 index 0000000..f19becc --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Knowledge Adapter Service + +Small Python 3.12/FastAPI adapter for Dify External Knowledge retrieval through an existing WeKnora deployment, with stable UUID image URLs backed by a Private Alibaba Cloud OSS bucket. + +## Status + +Slices 1–8 provide implementation, automated tests, and prepared delivery artifacts. Production deployment is not authorized. Slice 9 must complete real test-environment Dify → Adapter → WeKnora → OSS E2E and produce an audit report before a production decision. + +## Documentation + +- [Architecture](docs/architecture.md) +- [Security model](docs/security.md) +- [Test deployment and rollback](docs/deployment.md) +- [Key and credential rotation](docs/key-rotation.md) +- [Slice 9 verification and E2E audit](docs/audit-report-slice9.md) + +## Local validation + +```text +make check +``` + +Compose rendering requires an external non-secret env file: + +```text +make compose-config ENV_FILE=/absolute/path/to/adapter.env +``` + +Neither target starts containers. `image-build` is explicit and requires Docker access; no target deploys or modifies production. diff --git a/deploy/adapter.env.example b/deploy/adapter.env.example new file mode 100644 index 0000000..477c912 --- /dev/null +++ b/deploy/adapter.env.example @@ -0,0 +1,26 @@ +# Non-secret runtime configuration. Copy outside Git before use. +ADAPTER_IMAGE_TAG=reviewed-test +DIFY_KEY_HASH_CURRENT=<64-char-sha256> +DIFY_KEY_HASH_NEXT= +WEKNORA_BASE_URL=http://app:8080 +WEKNORA_NETWORK_NAME=weknora071full-network +WEKNORA_ALLOWED_KB_IDS=[,] +WEKNORA_TENANT_ID= +WEKNORA_OSS_STORAGE_BACKEND_ID= +OSS_REGION=cn-shenzhen +OSS_BUCKET= +OSS_CREDENTIAL_MODE=static_ak +OSS_RAM_ROLE_NAME= +ASSET_PUBLIC_BASE_URL=https://assets.example.com +MAX_QUERY_CHARS=2000 +MAX_TOP_K=5 +MAX_CHUNK_CHARS=4000 +MAX_TOTAL_CHARS=12000 +WEKNORA_TIMEOUT_SECONDS=15 + +# Host paths only. Files must be owned by UID 10001 and mode 0400. +WEKNORA_API_KEY_SECRET_FILE=/opt/knowledge-adapter/secrets/weknora_api_key +ASSET_DATABASE_URL_SECRET_FILE=/opt/knowledge-adapter/secrets/asset_database_url +WEKNORA_READONLY_DATABASE_URL_SECRET_FILE=/opt/knowledge-adapter/secrets/weknora_readonly_database_url +OSS_ACCESS_KEY_ID_HOST_FILE=/opt/knowledge-adapter/secrets/oss_access_key_id +OSS_ACCESS_KEY_SECRET_HOST_FILE=/opt/knowledge-adapter/secrets/oss_access_key_secret diff --git a/deploy/docker-compose.adapter.yml b/deploy/docker-compose.adapter.yml new file mode 100644 index 0000000..6db8be2 --- /dev/null +++ b/deploy/docker-compose.adapter.yml @@ -0,0 +1,87 @@ +name: knowledge-adapter + +services: + adapter: + image: "knowledge-adapter-service:${ADAPTER_IMAGE_TAG:-local}" + build: + context: .. + dockerfile: Dockerfile + user: "10001:10001" + init: true + restart: unless-stopped + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 256 + mem_limit: 1g + cpus: 2.0 + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=64m + ports: + - "127.0.0.1:18080:8080" + environment: + DIFY_KEY_HASH_CURRENT: "${DIFY_KEY_HASH_CURRENT:?set DIFY_KEY_HASH_CURRENT}" + DIFY_KEY_HASH_NEXT: "${DIFY_KEY_HASH_NEXT:-}" + WEKNORA_BASE_URL: "${WEKNORA_BASE_URL:?set WEKNORA_BASE_URL}" + WEKNORA_API_KEY_FILE: /run/secrets/weknora_api_key + WEKNORA_ALLOWED_KB_IDS: "${WEKNORA_ALLOWED_KB_IDS:?set WEKNORA_ALLOWED_KB_IDS}" + WEKNORA_TENANT_ID: "${WEKNORA_TENANT_ID:?set WEKNORA_TENANT_ID}" + WEKNORA_OSS_STORAGE_BACKEND_ID: "${WEKNORA_OSS_STORAGE_BACKEND_ID:?set WEKNORA_OSS_STORAGE_BACKEND_ID}" + OSS_REGION: "${OSS_REGION:?set OSS_REGION}" + OSS_BUCKET: "${OSS_BUCKET:?set OSS_BUCKET}" + OSS_CREDENTIAL_MODE: "${OSS_CREDENTIAL_MODE:-static_ak}" + OSS_RAM_ROLE_NAME: "${OSS_RAM_ROLE_NAME:-}" + OSS_ACCESS_KEY_ID_FILE: /run/secrets/oss_access_key_id + OSS_ACCESS_KEY_SECRET_FILE: /run/secrets/oss_access_key_secret + ASSET_PUBLIC_BASE_URL: "${ASSET_PUBLIC_BASE_URL:?set ASSET_PUBLIC_BASE_URL}" + MAX_QUERY_CHARS: "${MAX_QUERY_CHARS:-2000}" + MAX_TOP_K: "${MAX_TOP_K:-5}" + MAX_CHUNK_CHARS: "${MAX_CHUNK_CHARS:-4000}" + MAX_TOTAL_CHARS: "${MAX_TOTAL_CHARS:-12000}" + WEKNORA_TIMEOUT_SECONDS: "${WEKNORA_TIMEOUT_SECONDS:-15}" + ASSET_DATABASE_URL_FILE: /run/secrets/asset_database_url + WEKNORA_READONLY_DATABASE_URL_FILE: /run/secrets/weknora_readonly_database_url + volumes: + - type: bind + source: "${WEKNORA_API_KEY_SECRET_FILE:?set WEKNORA_API_KEY_SECRET_FILE}" + target: /run/secrets/weknora_api_key + read_only: true + - type: bind + source: "${ASSET_DATABASE_URL_SECRET_FILE:?set ASSET_DATABASE_URL_SECRET_FILE}" + target: /run/secrets/asset_database_url + read_only: true + - type: bind + source: "${WEKNORA_READONLY_DATABASE_URL_SECRET_FILE:?set WEKNORA_READONLY_DATABASE_URL_SECRET_FILE}" + target: /run/secrets/weknora_readonly_database_url + read_only: true + - type: bind + source: "${OSS_ACCESS_KEY_ID_HOST_FILE:?set OSS_ACCESS_KEY_ID_HOST_FILE}" + target: /run/secrets/oss_access_key_id + read_only: true + - type: bind + source: "${OSS_ACCESS_KEY_SECRET_HOST_FILE:?set OSS_ACCESS_KEY_SECRET_HOST_FILE}" + target: /run/secrets/oss_access_key_secret + read_only: true + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2).read()" + interval: 10s + timeout: 3s + retries: 3 + start_period: 10s + stop_grace_period: 20s + networks: + - adapter + - weknora_upstream + +networks: + adapter: + driver: bridge + weknora_upstream: + external: true + name: "${WEKNORA_NETWORK_NAME:?set WEKNORA_NETWORK_NAME}" diff --git a/deploy/nginx/knowledge-adapter.conf b/deploy/nginx/knowledge-adapter.conf new file mode 100644 index 0000000..da18f11 --- /dev/null +++ b/deploy/nginx/knowledge-adapter.conf @@ -0,0 +1,87 @@ +map $http_x_request_id $adapter_request_id { + "~^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" $http_x_request_id; + default $request_id; +} + +limit_req_zone $binary_remote_addr zone=adapter_retrieval:10m rate=10r/s; +limit_req_zone $binary_remote_addr zone=adapter_assets:10m rate=30r/s; + +server { + listen 80; + server_name assets.example.com; + access_log off; + return 404; +} + +server { + listen 443 ssl; + http2 on; + server_name assets.example.com; + + ssl_certificate /etc/letsencrypt/live/assets.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/assets.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:adapter_tls:10m; + ssl_session_timeout 1d; + + server_tokens off; + access_log off; + error_log /var/log/nginx/knowledge-adapter-error.log error; + client_max_body_size 64k; + + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy no-referrer always; + + location = /retrieval { + limit_except POST { + deny all; + } + limit_req zone=adapter_retrieval burst=20 nodelay; + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ""; + proxy_set_header X-Request-ID $adapter_request_id; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 3s; + proxy_send_timeout 20s; + proxy_read_timeout 20s; + proxy_intercept_errors off; + proxy_hide_header Server; + proxy_hide_header X-Powered-By; + } + + location ~ ^/i/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ { + limit_except GET { + deny all; + } + limit_req zone=adapter_assets burst=60 nodelay; + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ""; + proxy_set_header X-Request-ID $adapter_request_id; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 3s; + proxy_send_timeout 10s; + proxy_read_timeout 10s; + proxy_intercept_errors off; + proxy_hide_header Server; + proxy_hide_header X-Powered-By; + } + + location = /healthz { + allow 127.0.0.1; + allow ::1; + deny all; + proxy_pass http://127.0.0.1:18080; + proxy_set_header X-Request-ID $adapter_request_id; + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_intercept_errors off; + } + + location / { + return 404; + } +} diff --git a/deploy/nginx/nginx_adapter_helper.py b/deploy/nginx/nginx_adapter_helper.py new file mode 100644 index 0000000..f1b262c --- /dev/null +++ b/deploy/nginx/nginx_adapter_helper.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +import hashlib +import json +import os +import pathlib +import sys +import tempfile +import urllib.error +import urllib.request +import uuid + +ENV_PATH = pathlib.Path("/opt/knowledge-adapter/adapter.env") +NEW_BASE = "https://weknora-api-test.dobot.cn" +API_HOST = "weknora-api-test.dobot.cn" +UI_HOST = "weknora-test.dobot.cn" +DIFY_KEY_PATH = pathlib.Path("/opt/knowledge-adapter/secrets/dify_test_key") + + +def parse_env(path: pathlib.Path) -> dict[str, str]: + result: dict[str, str] = {} + for raw in path.read_text().splitlines(): + line = raw.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + result[key] = value + return result + + +def update_env() -> None: + st = ENV_PATH.stat() + lines = ENV_PATH.read_text().splitlines(keepends=True) + found = 0 + output: list[str] = [] + for line in lines: + if line.startswith("ASSET_PUBLIC_BASE_URL="): + output.append(f"ASSET_PUBLIC_BASE_URL={NEW_BASE}\n") + found += 1 + else: + output.append(line) + if found != 1: + raise RuntimeError(f"expected exactly one ASSET_PUBLIC_BASE_URL, got {found}") + fd, tmp_name = tempfile.mkstemp(prefix=".adapter.env.", dir=str(ENV_PATH.parent)) + try: + with os.fdopen(fd, "w") as handle: + handle.writelines(output) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(tmp_name, st.st_mode & 0o777) + os.chown(tmp_name, st.st_uid, st.st_gid) + os.replace(tmp_name, ENV_PATH) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + if parse_env(ENV_PATH).get("ASSET_PUBLIC_BASE_URL") != NEW_BASE: + raise RuntimeError("ASSET_PUBLIC_BASE_URL readback mismatch") + print("adapter_env_update=ok") + + +def request(method: str, path: str, host: str, *, data: bytes | None = None, headers: dict[str, str] | None = None) -> tuple[int, dict[str, str], bytes]: + merged = {"Host": host, "Accept": "application/json"} + if headers: + merged.update(headers) + req = urllib.request.Request( + f"http://127.0.0.1{path}", data=data, method=method, headers=merged + ) + try: + with urllib.request.urlopen(req, timeout=25) as response: + return response.status, dict(response.headers.items()), response.read(2_000_000) + except urllib.error.HTTPError as exc: + return exc.code, dict(exc.headers.items()), exc.read(2_000_000) + + +def accept() -> None: + env = parse_env(ENV_PATH) + allowed = [x.strip() for x in env["WEKNORA_ALLOWED_KB_IDS"].split(",") if x.strip()] + if len(allowed) != 1: + raise RuntimeError("acceptance expects exactly one allowlisted KB") + raw_key = DIFY_KEY_PATH.read_text().strip() + if not raw_key: + raise RuntimeError("Dify key file is empty") + configured_hash = env["DIFY_KEY_HASH_CURRENT"].lower() + if hashlib.sha256(raw_key.encode()).hexdigest() != configured_hash: + raise RuntimeError("Dify key file does not match configured hash") + + status, _, _ = request("GET", "/", UI_HOST) + if status != 200: + raise RuntimeError(f"UI Host route expected 200, got {status}") + print("ui_host_route=200") + + status, _, _ = request("GET", "/", API_HOST) + if status != 404: + raise RuntimeError(f"API root expected 404, got {status}") + print("api_root=404") + + status, _, body = request( + "POST", "/retrieval", API_HOST, data=b"{}", headers={"Content-Type": "application/json"} + ) + if status != 401 or json.loads(body) != {"detail": "invalid API key"}: + raise RuntimeError("missing-auth boundary mismatch") + print("retrieval_missing_key=401_adapter") + + payload = json.dumps( + { + "knowledge_id": allowed[0], + "query": "越疆机器人技术资料", + "retrieval_setting": {"top_k": 3, "score_threshold": 0.0}, + } + ).encode() + status, _, body = request( + "POST", + "/retrieval", + API_HOST, + data=payload, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {raw_key}"}, + ) + if status != 200: + raise RuntimeError(f"allowlisted retrieval expected 200, got {status}") + result = json.loads(body) + records = result.get("records") + if not isinstance(records, list): + raise RuntimeError("retrieval response has no records list") + print(f"retrieval_allowed=200 records={len(records)}") + + denied_payload = json.dumps( + { + "knowledge_id": str(uuid.UUID("00000000-0000-4000-8000-000000000001")), + "query": "boundary probe", + "retrieval_setting": {"top_k": 1, "score_threshold": 0.0}, + } + ).encode() + status, _, body = request( + "POST", + "/retrieval", + API_HOST, + data=denied_payload, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {raw_key}"}, + ) + if status != 400 or json.loads(body) != {"detail": "invalid request"}: + raise RuntimeError("denied-KB boundary mismatch") + print("retrieval_denied_kb=400") + + +if __name__ == "__main__": + if len(sys.argv) != 2 or sys.argv[1] not in {"update-env", "accept"}: + raise SystemExit("usage: helper.py update-env|accept") + if sys.argv[1] == "update-env": + update_env() + else: + accept() diff --git a/deploy/nginx/query_active_asset_id.py b/deploy/nginx/query_active_asset_id.py new file mode 100644 index 0000000..6ffd727 --- /dev/null +++ b/deploy/nginx/query_active_asset_id.py @@ -0,0 +1,21 @@ +import asyncio +import os +import pathlib + +import asyncpg + + +async def main() -> None: + dsn_path = pathlib.Path(os.environ["ASSET_DATABASE_URL_FILE"]) + connection = await asyncpg.connect(dsn_path.read_text().strip()) + try: + row = await connection.fetchrow( + "SELECT asset_id FROM asset_mapping " + "WHERE status = 'active' ORDER BY created_at LIMIT 1" + ) + print(row["asset_id"] if row else "") + finally: + await connection.close() + + +asyncio.run(main()) diff --git a/deploy/nginx/rollout_test_nginx_adapter.sh b/deploy/nginx/rollout_test_nginx_adapter.sh new file mode 100644 index 0000000..0fc21e9 --- /dev/null +++ b/deploy/nginx/rollout_test_nginx_adapter.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +COMPOSE_FILE=/opt/knowledge-adapter/releases/a842917c69f9ae09/knowledge-adapter-service/deploy/docker-compose.adapter.yml +ENV_FILE=/opt/knowledge-adapter/adapter.env +PROJECT=knowledge-adapter +SERVICE=adapter +NGINX_SOURCE=/tmp/weknora-test-origin.conf +HELPER_SOURCE=/tmp/nginx_adapter_helper.py +APT_SOURCE=/tmp/ubuntu-public-temporary.list +NGINX_AVAILABLE=/etc/nginx/sites-available/weknora-test-origin.conf +NGINX_ENABLED=/etc/nginx/sites-enabled/weknora-test-origin.conf +BACKUP_DIR=/opt/knowledge-adapter/backups/nginx-adapter-$(date -u +%Y%m%dT%H%M%SZ) +POLICY_CREATED=0 +ENV_BACKED_UP=0 +ENV_CHANGED=0 +ADAPTER_RECREATED=0 + +cleanup_policy() { + if [[ "$POLICY_CREATED" == 1 ]]; then + rm -f /usr/sbin/policy-rc.d + fi +} + +rollback_on_error() { + rc=$? + trap - ERR + cleanup_policy + printf 'rollout_error=stage_failed rc=%s\n' "$rc" >&2 + if [[ "$ENV_CHANGED" == 1 && "$ENV_BACKED_UP" == 1 ]]; then + install -o root -g root -m 0600 "$BACKUP_DIR/adapter.env" "$ENV_FILE" + if [[ "$ADAPTER_RECREATED" == 1 ]]; then + docker compose -p "$PROJECT" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" \ + up -d --no-deps --force-recreate --no-build "$SERVICE" || true + fi + printf 'adapter_env_rollback=attempted\n' >&2 + fi + exit "$rc" +} +trap rollback_on_error ERR +trap cleanup_policy EXIT + +[[ $(id -u) == 0 ]] +[[ -f "$COMPOSE_FILE" ]] +[[ -f "$ENV_FILE" ]] +[[ -f "$NGINX_SOURCE" ]] +[[ -f "$HELPER_SOURCE" ]] +[[ -f "$APT_SOURCE" ]] +[[ $(stat -c '%a' "$ENV_FILE") == 600 ]] + +printf '[1/7] baseline and protected backup\n' +mkdir -p -m 0700 "$BACKUP_DIR" +install -o root -g root -m 0600 "$ENV_FILE" "$BACKUP_DIR/adapter.env" +ENV_BACKED_UP=1 +docker inspect -f '{{.Image}}' knowledge-adapter-adapter-1 > "$BACKUP_DIR/adapter-image-id.txt" +chmod 0600 "$BACKUP_DIR/adapter-image-id.txt" +printf 'backup_dir=%s\n' "$BACKUP_DIR" + +printf '[2/7] install nginx without starting default site\n' +if ! command -v nginx >/dev/null 2>&1; then + if [[ ! -e /usr/sbin/policy-rc.d ]]; then + printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d + chmod 0755 /usr/sbin/policy-rc.d + POLICY_CREATED=1 + fi + export DEBIAN_FRONTEND=noninteractive + apt-get \ + -o Dir::Etc::sourcelist="$APT_SOURCE" \ + -o Dir::Etc::sourceparts="-" \ + -o APT::Get::List-Cleanup="0" \ + update + apt-get \ + -o Dir::Etc::sourcelist="$APT_SOURCE" \ + -o Dir::Etc::sourceparts="-" \ + install -y nginx + cleanup_policy + POLICY_CREATED=0 +fi +nginx -v + +printf '[3/7] install test-only Host routing and validate syntax\n' +install -o root -g root -m 0644 "$NGINX_SOURCE" "$NGINX_AVAILABLE" +ln -sfn "$NGINX_AVAILABLE" "$NGINX_ENABLED" +rm -f /etc/nginx/sites-enabled/default +nginx -t + +printf '[4/7] atomically update Adapter public base and render Compose\n' +python3 "$HELPER_SOURCE" update-env +ENV_CHANGED=1 +docker compose -p "$PROJECT" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config --quiet + +printf '[5/7] recreate Adapter only, using existing image\n' +docker compose -p "$PROJECT" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" \ + up -d --no-deps --force-recreate --no-build "$SERVICE" +ADAPTER_RECREATED=1 +for attempt in $(seq 1 30); do + state=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' knowledge-adapter-adapter-1) + if [[ "$state" == healthy ]]; then + break + fi + if [[ "$state" == unhealthy || "$attempt" == 30 ]]; then + printf 'adapter_health=%s attempt=%s\n' "$state" "$attempt" >&2 + false + fi + sleep 2 +done +printf 'adapter_health=healthy\n' + +printf '[6/7] enable origin Nginx\n' +systemctl enable --now nginx +systemctl is-active --quiet nginx +nginx -t +printf 'nginx=active\n' + +printf '[7/7] real Host-route and retrieval acceptance\n' +python3 "$HELPER_SOURCE" accept +ss -ltn '( sport = :80 or sport = :18080 )' +printf 'rollout=success\n' diff --git a/deploy/nginx/ubuntu-public-temporary.list b/deploy/nginx/ubuntu-public-temporary.list new file mode 100644 index 0000000..a3f3e56 --- /dev/null +++ b/deploy/nginx/ubuntu-public-temporary.list @@ -0,0 +1,4 @@ +deb https://mirrors.aliyun.com/ubuntu noble main restricted universe multiverse +deb https://mirrors.aliyun.com/ubuntu noble-updates main restricted universe multiverse +deb https://mirrors.aliyun.com/ubuntu noble-backports main restricted universe multiverse +deb https://mirrors.aliyun.com/ubuntu noble-security main restricted universe multiverse diff --git a/deploy/nginx/verify_image_route.py b/deploy/nginx/verify_image_route.py new file mode 100644 index 0000000..f81d811 --- /dev/null +++ b/deploy/nginx/verify_image_route.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +import hashlib +import json +import subprocess +import urllib.error +import urllib.request + +query_script = open("/tmp/query_active_asset_id.py", "rb").read() +result = subprocess.run( + ["docker", "exec", "-i", "knowledge-adapter-adapter-1", "python", "-"], + input=query_script, + capture_output=True, + check=True, +) +asset_id = result.stdout.decode().strip() +if not asset_id: + raise SystemExit("no active asset mapping") +path = "/i/" + asset_id + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args: object, **kwargs: object) -> None: + return None + + +def request(follow: bool) -> tuple[int, dict[str, str], bytes]: + req = urllib.request.Request( + "http://127.0.0.1" + path, + headers={"Host": "weknora-api-test.dobot.cn"}, + ) + opener = ( + urllib.request.build_opener() + if follow + else urllib.request.build_opener(NoRedirect()) + ) + try: + with opener.open(req, timeout=30) as response: + return response.status, dict(response.headers.items()), response.read(20_000_000) + except urllib.error.HTTPError as exc: + return exc.code, dict(exc.headers.items()), exc.read(1000) + + +status, headers, _ = request(False) +location = headers.get("Location", "") +report: dict[str, object] = { + "active_mapping_found": True, + "redirect_status": status, + "cache_control": headers.get("Cache-Control"), + "referrer_policy": headers.get("Referrer-Policy"), + "location_https": location.startswith("https://"), + "location_not_logged": True, +} +if status != 302 or not location.startswith("https://"): + raise SystemExit(json.dumps(report)) +status, _, body = request(True) +report["follow"] = { + "status": status, + "bytes": len(body), + "sha256_prefix": hashlib.sha256(body).hexdigest()[:12], +} +print(json.dumps(report)) diff --git a/deploy/nginx/weknora-test-origin.conf b/deploy/nginx/weknora-test-origin.conf new file mode 100644 index 0000000..4a8639c --- /dev/null +++ b/deploy/nginx/weknora-test-origin.conf @@ -0,0 +1,118 @@ +map $http_x_request_id $adapter_request_id { + "~^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" $http_x_request_id; + default $request_id; +} + +map $http_upgrade $connection_upgrade { + default upgrade; + "" close; +} + +limit_req_zone $binary_remote_addr zone=adapter_retrieval:10m rate=10r/s; +limit_req_zone $binary_remote_addr zone=adapter_assets:10m rate=30r/s; + +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + access_log off; + return 404; +} + +server { + listen 80; + listen [::]:80; + server_name weknora-test.dobot.cn; + + server_tokens off; + client_max_body_size 64m; + + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy no-referrer always; + + location / { + proxy_pass http://127.0.0.1:8088; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_connect_timeout 3s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + proxy_buffering off; + proxy_hide_header Server; + proxy_hide_header X-Powered-By; + } +} + +server { + listen 80; + listen [::]:80; + server_name weknora-api-test.dobot.cn; + + server_tokens off; + access_log off; + error_log /var/log/nginx/weknora-api-test-error.log error; + client_max_body_size 64k; + + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy no-referrer always; + + location = /retrieval { + limit_except POST { + deny all; + } + limit_req zone=adapter_retrieval burst=20 nodelay; + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ""; + proxy_set_header Authorization $http_authorization; + proxy_set_header Content-Type $content_type; + proxy_set_header X-Request-ID $adapter_request_id; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 3s; + proxy_send_timeout 20s; + proxy_read_timeout 20s; + proxy_intercept_errors off; + proxy_hide_header Server; + proxy_hide_header X-Powered-By; + } + + location ~ "^/i/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" { + limit_except GET { + deny all; + } + limit_req zone=adapter_assets burst=60 nodelay; + proxy_pass http://127.0.0.1:18080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ""; + proxy_set_header X-Request-ID $adapter_request_id; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 3s; + proxy_send_timeout 10s; + proxy_read_timeout 10s; + proxy_intercept_errors off; + proxy_hide_header Server; + proxy_hide_header X-Powered-By; + } + + location = /healthz { + allow 127.0.0.1; + allow ::1; + deny all; + proxy_pass http://127.0.0.1:18080; + proxy_set_header X-Request-ID $adapter_request_id; + proxy_connect_timeout 2s; + proxy_read_timeout 2s; + proxy_intercept_errors off; + } + + location / { + return 404; + } +} diff --git a/deploy/ram/knowledge-adapter-oss-read-policy.json b/deploy/ram/knowledge-adapter-oss-read-policy.json new file mode 100644 index 0000000..f805427 --- /dev/null +++ b/deploy/ram/knowledge-adapter-oss-read-policy.json @@ -0,0 +1,14 @@ +{ + "Version": "1", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "oss:GetObject" + ], + "Resource": [ + "acs:oss:*:*:${OSS_BUCKET}/weknora/${WEKNORA_TENANT_ID}/*" + ] + } + ] +} diff --git a/deploy/sql/weknora_readonly_grants.sql b/deploy/sql/weknora_readonly_grants.sql new file mode 100644 index 0000000..5f8f602 --- /dev/null +++ b/deploy/sql/weknora_readonly_grants.sql @@ -0,0 +1,15 @@ +\set ON_ERROR_STOP on + +-- Run with: psql ... --set=adapter_role=knowledge_adapter_weknora_ro -f this-file +-- The named role must be a dedicated NOINHERIT login with no other memberships. +BEGIN; + +REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM :"adapter_role"; +REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM :"adapter_role"; +REVOKE CREATE ON SCHEMA public FROM :"adapter_role"; + +GRANT USAGE ON SCHEMA public TO :"adapter_role"; +GRANT SELECT (handle, tenant_id, storage_backend_id, provider, physical_path, mime_type, state, deleted_at) + ON TABLE public.resources TO :"adapter_role"; + +COMMIT; diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..53dcf24 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,97 @@ +# Knowledge Adapter Architecture + +## Scope + +Knowledge Adapter is a small protocol and security boundary between Dify and an existing WeKnora deployment. It does not modify WeKnora and does not reimplement RAG, embedding, reranking, parsing, or object storage. + +Public API: + +- `POST /retrieval`: authenticated Dify External Knowledge retrieval. +- `GET /i/{UUID}`: anonymous stable image URL; possession of the UUID URL grants image access. +- `GET /healthz`: process liveness only; no dependency or configuration disclosure. + +## Runtime topology + +```text +Dify + -> HTTPS POST /retrieval + -> host Nginx + -> Adapter :8080 + -> private WeKnora POST /api/v1/knowledge-search + -> projection + image rewrite + final-content budgets + -> Dify records + +Browser + -> HTTPS GET /i/{UUID} + -> host Nginx + -> Adapter + -> Adapter DB asset_mapping (active row only) + -> read-only mounted static AK Secret files + -> Private OSS V4 Presigned GET URL (600 seconds) + -> HTTP 302 +``` + +Nginx is the TLS boundary. Compose publishes Adapter only at `127.0.0.1:18080`; the Adapter joins a configurable external `WEKNORA_NETWORK_NAME` so `WEKNORA_BASE_URL=http://app:8080` reaches the existing WeKnora App without changing WeKnora source or Compose. Its separate `adapter` bridge remains the default network for isolation. No database, Redis, metadata, or OSS endpoint is exposed by this project. + +## Components + +### Retrieval path + +1. `DifyKeyAuthenticator` compares the presented key hash against `DIFY_KEY_HASH_CURRENT` and optional `DIFY_KEY_HASH_NEXT` in constant time. +2. `RetrievalService` checks the knowledge-base allowlist and clamps TopK. +3. `WeKnoraClient` calls the existing private WeKnora search endpoint with a fixed base URL and timeout. +4. Each returned chunk is revalidated against the requested knowledge-base UUID. +5. `AssetContentProcessor` rewrites permitted image locators and rejects unprocessed internal locators. +6. Per-chunk and total limits are applied to the final rewritten content. + +### Image mapping path + +```text +resource://handle + -> Adapter DB lookup + -> on miss only: dedicated WeKnora read-only DB resolver + -> fixed tenant/backend/provider/bucket checks + -> Adapter DB atomic mapping insert + -> https://assets.example.com/i/{UUID} +``` + +`asset_mapping` stores only the stable UUID mapping, source reference, OSS object key, content type, creation time, and active/disabled status. It never stores credentials or a Presigned URL. + +### Image redirect path + +`GET /i/{UUID}` accepts only a UUID path value. The caller cannot provide bucket, region, object key, endpoint, expiry, or another URL. The service reads an active mapping, reads the dedicated OSS AccessKey pair from static AK Secret files, produces a V4 GET URL for the configured Private OSS bucket, and returns it only in the 302 `Location` header. `Cache-Control: no-store` forces a later visit to generate a fresh 600-second URL while the public UUID remains unchanged. + +## Data and identity boundaries + +- Adapter DB account: read/write only for Adapter-owned `asset_mapping`. +- WeKnora read-only DB account: field-level `SELECT` only on the required resource locator columns. +- WeKnora API key: search API only; mounted as a Secret file. +- OSS RAM user static AK: separate test/production identities; production policy is `oss:GetObject` only for the reviewed bucket/object prefix. +- Dify key: Adapter stores only SHA-256 hashes; raw keys remain outside Git and Compose environment. + +The two database accounts must never be reused across databases. + +## Availability and scaling + +The service is stateless except for Adapter DB mappings. Multiple Adapter replicas can share the same Adapter DB; mapping creation is conflict-safe. Each process currently opens up to five Adapter DB connections and two WeKnora read-only DB connections. Capacity planning must multiply these limits by process and replica count before scaling. + +`/healthz` proves only that the Adapter process can answer. Dependency readiness and business E2E are separate acceptance gates. + +## Failure contract + +- Invalid request or disallowed KB: fixed 400. +- WeKnora unavailable: fixed 503. +- Invalid WeKnora response or unsafe content: fixed 502. +- Missing/disabled image UUID: empty 404; signer is not called. +- Adapter DB, OSS credential files, or signing unavailable: empty 503. +- Unexpected error: fixed JSON 500. + +Every response carries a sanitized `X-Request-ID`. Request logs use route templates and fixed error codes, not raw URLs or sensitive content. + +## Non-goals + +- Production deployment before Slice 9 review acceptance. +- WeKnora source or schema changes. +- Public OSS bucket access. +- Arbitrary URL fetch/upload, proxying, or SSRF-capable media import. +- OAuth, JWT, multi-tenancy, an administration UI, LLM summarization, or replacement retrieval logic. diff --git a/docs/audit-report-slice9.md b/docs/audit-report-slice9.md new file mode 100644 index 0000000..d16292e --- /dev/null +++ b/docs/audit-report-slice9.md @@ -0,0 +1,129 @@ +# Slice 9 Verification and E2E Audit Report + +- Project: Knowledge Adapter Service +- Repository: `/home/zheromiku/knowledge-adapter-service` +- Audit timestamp: `2026-08-24T18:44:35+08:00` +- Environment observed: local physical Inspur NF5568M4 host +- Scope: code/config validation and authorized read-only test-environment probes +- Production deployment: prohibited and not attempted + +## Executive conclusion + +The application implementation and source-delivery gates pass. The initial pre-report full suite completed with `133 passed in 1.06s`; after adding the audit artifact contract, the final full suite completed with `134 passed in 1.15s`. Ruff returned `All checks passed!`; the RAM policy parsed; the documentation citation ledger verified; and Docker Compose rendered successfully. + +The real `Dify → Adapter → WeKnora → Adapter DB → OSS` E2E is **BLOCKED**, not failed and not passed. This host lacks the three dedicated Adapter Secret files, an authorized Docker daemon, a running Adapter candidate, a test Dify binding, and—critically—the Alibaba Cloud ECS RAM Role/IMDSv2 environment required by the production signer. No cloud result has been fabricated. + +Production deployment remains **NOT RUN** by design. + +## Requirement-to-evidence matrix + +| Gate | Status | Evidence | +|---|---|---| +| Python runtime | PASS | `uv run python --version` → Python 3.12.11 | +| Full pytest before audit artifact | PASS | 133 tests collected; `133 passed in 1.06s` | +| Final full pytest | PASS | 134 tests collected; `134 passed in 1.15s` | +| Ruff | PASS | `uv run ruff check src tests` → `All checks passed!` | +| RAM policy JSON | PASS | `python -m json.tool ...` exited 0 | +| OSS permission contract | PASS (static) | Exact Action is `oss:GetObject`; resource is one configured bucket/tenant prefix | +| Security source citations | PASS | grounded-citations strict verify exited 0; four registered sources | +| Compose parse/render | PASS | `docker compose ... config --quiet` exited 0 | +| Compose service inventory | PASS | exactly `adapter`; image `knowledge-adapter-service:reviewed-test` | +| Existing WeKnora network integration | PASS (static) | Adapter joins configurable external `WEKNORA_NETWORK_NAME`; local baseline is `weknora071full-network`, App URL `http://app:8080` | +| Existing WeKnora /health | PASS (runtime/read-only) | `http://127.0.0.1:8080/health` returned HTTP 200 JSON | +| Dockerfile native build check | BLOCKED | Docker daemon permission denied at `/var/run/docker.sock`; no sudo or socket workaround used | +| Docker image build | BLOCKED | Same Docker daemon permission boundary | +| Container runtime/UID/mount health | BLOCKED | Adapter was not built or started; `127.0.0.1:18080` is closed | +| Nginx native syntax test | BLOCKED | `nginx binary unavailable` on this host; source-contract test passes only | +| Adapter DB migration rehearsal | BLOCKED | dedicated `asset_database_url` Secret/account/database are absent | +| WeKnora field-level grant rehearsal | BLOCKED | dedicated `weknora_readonly_database_url` Secret/account is absent | +| Test Dify external knowledge call | BLOCKED | no test Dify endpoint/binding/key was provided to this environment | +| Real cloud E2E | BLOCKED | no running Adapter candidate and no Alibaba ECS RAM Role/IMDSv2 context | +| Private OSS anonymous denial | BLOCKED | no authorized test-ECS signer path and no approved object fixture | +| OSS V4 signed-object fetch | BLOCKED | same prerequisite gap; Presigned URL was not generated or printed | +| 600-second expiry | BLOCKED | requires waiting against a real signed URL on test ECS | +| stable UUID re-signing | BLOCKED | requires active Adapter DB mapping and two real redirect requests | +| historical Dify image reopen | BLOCKED | requires a real Dify conversation and stable UUID fixture | +| Production deployment | NOT RUN | explicitly outside the approved scope | + +## Commands actually run + +```text +uv run pytest -o addopts='' tests +uv run ruff check src tests +python -m json.tool deploy/ram/knowledge-adapter-oss-read-policy.json +docker compose --env-file deploy/adapter.env.example \ + -f deploy/docker-compose.adapter.yml config --quiet +docker compose --env-file deploy/adapter.env.example \ + -f deploy/docker-compose.adapter.yml config --services +docker compose --env-file deploy/adapter.env.example \ + -f deploy/docker-compose.adapter.yml config --images +docker buildx build --check --progress=plain . +python .../grounded-citations/scripts/sources.py verify docs/security.md --strict +``` + +The build-check command returned only the Docker daemon permission error. It did not build an image or change container state. + +## Read-only prerequisite findings + +### Listening endpoints + +```text +127.0.0.1:8080 open existing WeKnora App +127.0.0.1:8088 open existing WeKnora UI +127.0.0.1:18080 closed Adapter candidate not running +127.0.0.1:80 closed +127.0.0.1:443 closed +``` + +### Dedicated Secret files + +All required files are missing: + +```text +/opt/knowledge-adapter/secrets/weknora_api_key +/opt/knowledge-adapter/secrets/asset_database_url +/opt/knowledge-adapter/secrets/weknora_readonly_database_url +``` + +No Secret content was read or printed. + +### ECS role prerequisite + +The observed host reports `sys_vendor=Inspur` and `product_name=NF5568M4`. It is not the target Alibaba ECS test instance. The signer intentionally supports only ECS RAM Role temporary credentials via IMDSv2 and has no long-lived AccessKey fallback, so a real OSS redirect cannot be accepted on this host. + +## Security assertions retained + +- No WeKnora source or schema was modified. +- No production system was contacted or deployed. +- No Docker permission bypass or sudo was used. +- No raw Dify/WeKnora key, database DSN, OSS AccessKey, STS token, object key, Authorization header, knowledge body, or Presigned URL was printed or committed. +- No migration or grant was applied. +- No OSS bucket ACL/policy was changed. +- No object was uploaded, copied, deleted, or made public. +- No caller-controlled bucket, object key, endpoint, expiry, or arbitrary URL path was introduced. + +## Remaining test-ECS acceptance procedure + +The following steps must execute on an authorized Alibaba ECS **test** instance after the prerequisites are provisioned: + +1. Confirm the reviewed ECS RAM Role is attached and IMDSv2 temporary credentials resolve without long-lived AK/SK. +2. Confirm the OSS bucket remains Private and the role policy grants only the reviewed `oss:GetObject` prefix. +3. Create the dedicated Adapter DB and WeKnora field-level read-only accounts; place all three values in UID 10001, mode `0400` Secret files. +4. Run the up migration and field-level grant in disposable/test databases, then prove unrelated columns/tables and all writes are denied to the WeKnora role. +5. Build the reviewed image, run native `nginx -t` against the complete target configuration, and start only the Adapter candidate. +6. Verify runtime UID 10001, read-only root, Secret mounts, external WeKnora network, `/healthz`, and sanitized logs. +7. Configure a test Dify External Knowledge endpoint and execute one allowlisted query containing an approved image fixture. +8. Prove Dify receives only the stable UUID URL and no locator/object key/internal endpoint/signature. +9. Request the stable UUID, follow the 302 without displaying its Location, and verify the expected object content/hash. +10. Prove direct anonymous OSS access is denied. +11. Wait beyond the fixed 600-second expiry and prove the old temporary URL is denied. +12. Request the unchanged stable UUID again and prove stable UUID re-signing yields a working fresh temporary URL. +13. Reopen a historical Dify message containing the UUID link and verify the image still loads. +14. Export sanitized request-ID-correlated evidence and rerun the full suite/Ruff/config gates before review. + +## Decision + +- Code and static delivery package: **PASS**. +- Existing WeKnora liveness: **PASS**. +- Real cloud E2E: **BLOCKED** pending test-ECS prerequisites. +- Production readiness/deployment: **NOT APPROVED / NOT RUN**. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..c71e994 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,144 @@ +# Test Deployment and Rollback + +> **TEST ENVIRONMENT ONLY** +> +> Production deployment is prohibited until Slice 9 real E2E evidence and the audit report are reviewed and explicitly accepted. This document is a prepared operator runbook, not authorization to execute it. + +## Delivered files + +```text +Dockerfile +deploy/docker-compose.adapter.yml +deploy/adapter.env.example +deploy/nginx/knowledge-adapter.conf +deploy/ram/knowledge-adapter-oss-read-policy.json +deploy/sql/weknora_readonly_grants.sql +migrations/0001_asset_mapping.up.sql +migrations/0001_asset_mapping.down.sql +``` + +## Prerequisites + +- Dedicated test OSS AccessKey pair; test and production identities must be different. +- Private OSS bucket in the configured region. +- Existing private WeKnora API endpoint. +- Dedicated Adapter PostgreSQL database/login. +- Different dedicated WeKnora read-only PostgreSQL login. +- Host Nginx and a test TLS certificate for the reviewed public hostname. +- Docker Compose operated by an authorized human; this repository does not modify Docker daemon permissions. + +## 1. Prepare non-secret configuration + +Copy `deploy/adapter.env.example` outside Git, for example to `/opt/knowledge-adapter/adapter.env`, and replace every placeholder. Do not add raw API keys or database URLs. + +`DIFY_KEY_HASH_CURRENT` is the SHA-256 digest of the active Dify key. `DIFY_KEY_HASH_NEXT` remains empty until a dual-slot rotation. + +Review these invariants: + +- `ASSET_PUBLIC_BASE_URL` exactly matches the HTTPS Nginx hostname. +- `WEKNORA_NETWORK_NAME` names an existing external Docker network and `WEKNORA_BASE_URL` resolves the existing WeKnora App service on it; the test baseline uses `weknora071full-network` and `http://app:8080`. +- `OSS_CREDENTIAL_MODE=static_ak`; `OSS_REGION` and `OSS_BUCKET` match the test resources. +- `OSS_ACCESS_KEY_ID_HOST_FILE` and `OSS_ACCESS_KEY_SECRET_HOST_FILE` point to the two protected host Secret files. +- the KB allowlist contains only approved UUIDs; +- all response budgets match the approved values. + +## 2. Prepare Secret files + +Create an untracked `/opt/knowledge-adapter/secrets` directory. Populate each file through an approved secret manager or interactive editor; never put a secret in a command argument or shell history. + +Required files: + +```text +weknora_api_key +asset_database_url +weknora_readonly_database_url +oss_access_key_id +oss_access_key_secret +``` + +Set directory mode `0700`. Set each file owner to UID/GID `10001:10001` and mode `0400`. Confirm each file is non-empty without printing its contents. The two database DSNs must identify different accounts and databases. Never place either OSS value in `adapter.env`. + +## 3. Provision database permissions + +A DBA reviews and applies `migrations/0001_asset_mapping.up.sql` only to the dedicated Adapter database. Separately, a WeKnora DBA reviews and applies `deploy/sql/weknora_readonly_grants.sql` to the dedicated read-only role. + +The application does not run migration automatically. Before migration: + +1. take a database-native backup; +2. verify the backup catalog is readable; +3. record the current schema state; +4. apply the migration in the test database; +5. verify table/index constraints and role privileges. + +The down migration drops Adapter mapping state and is destructive. Do not use it as routine rollback after data exists. + +## 4. Review the OSS static AK identity + +For the approved test window, the existing test-only AK may be used from the two Secret files. Before production, create a separate RAM user and AccessKey, replace `${OSS_BUCKET}` and `${WEKNORA_TENANT_ID}` in `deploy/ram/knowledge-adapter-oss-read-policy.json`, and confirm the only Action is `oss:GetObject` and the only Resource is the intended production object prefix. Do not reuse the test AK or place AccessKey values in environment variables. + +## 5. Render configuration + +The required read-only render gate is `docker compose config`; it must pass before any build or runtime action. + +From the repository root, an authorized operator can perform a read-only render: + +```text +docker compose --env-file /opt/knowledge-adapter/adapter.env \ + -f deploy/docker-compose.adapter.yml config --quiet +``` + +Then inspect the rendered service for image/build source, loopback port, UID 10001, Secret mount targets, resource limits, and healthcheck. Rendering must not print Secret file contents. + +## 6. Build and start the test candidate + +Build only after source tests, Ruff, JSON validation, and Compose rendering pass. Use an immutable reviewed image tag rather than `latest`. Starting/recreating the candidate requires a separate operator approval because it changes runtime state. + +The prepared Compose file contains only the Adapter; it does not create or replace PostgreSQL, Redis, WeKnora, Nginx, or OSS resources. + +## 7. Install Nginx test configuration + +Review `deploy/nginx/knowledge-adapter.conf` and replace the example hostname and certificate paths. Validate the complete host Nginx configuration before reload. The Adapter upstream remains `127.0.0.1:18080`. + +Nginx rules intentionally: + +- reject cleartext HTTP; +- expose only `/retrieval`, `/i/{UUID}`, and local `/healthz`; +- limit methods, body size, and request rate; +- sanitize `X-Request-ID`; +- disable access logs; +- pass 302 `Location` without logging it. + +Installing or reloading host Nginx is outside this repository and requires explicit operator approval. + +## 8. Test-environment acceptance + +Do not print credentials or the 302 Location value. Record only sanitized results: + +1. container health becomes healthy and `GET /healthz` returns 200; +2. no-auth and wrong-key `/retrieval` return 401; +3. allowlisted retrieval returns valid Dify records; +4. non-allowlisted KB returns fixed 400; +5. rewritten content has no internal locator, private endpoint, local path, object key, or signature; +6. valid `/i/{UUID}` returns 302 with `no-store` and a V4 URL whose expiry class is 600 seconds; +7. direct anonymous OSS object GET is denied; +8. following the 302 retrieves the expected test object; +9. a later visit to the same stable UUID produces a fresh temporary URL; +10. missing/disabled/malformed image IDs do not invoke OSS signing; +11. structured logs correlate request IDs without prohibited values; +12. CPU/RAM, database pool usage, upstream latency, and error rates are recorded. + +The full real E2E belongs to Slice 9. + +## Rollback + +Application rollback is image/config rollback, not database destruction: + +1. stop routing new test traffic at Nginx; +2. preserve sanitized logs and current rendered configuration; +3. restore the previously reviewed image tag and non-secret env file; +4. restore prior Secret files only when credential rotation caused the failure; +5. recreate only the Adapter service; +6. verify `/healthz`, authentication, retrieval, and stable image redirects; +7. restore Nginx routing. + +Do not run `down -v`, prune volumes, drop `asset_mapping`, revoke the active AK before rollback is proven, or restore over a database without a separately reviewed recovery plan and verified backup. diff --git a/docs/frontend-api-guide.md b/docs/frontend-api-guide.md new file mode 100644 index 0000000..2125b99 --- /dev/null +++ b/docs/frontend-api-guide.md @@ -0,0 +1,356 @@ +# 测试环境 WeKnora 检索 API 调用说明 + +## 1. 当前状态 + +公网HTTPS链路已经通过真实验收: + +```text +Dify/业务后端 + → https://weknora-api-test.dobot.cn/retrieval + → 公网测试入口 + → 测试ECS Nginx:80 + → Knowledge Adapter:18080 + → WeKnora knowledge-search +``` + +真实验收结果: + +```text +检索:HTTP 200 +records:5 +稳定图片UUID:15 +图片:稳定UUID 302 → 私有OSS签名取图 HTTP 200 +``` + +完整且已脱敏的真实请求与响应: + +```text +docs/test-api-request-response.txt +``` + +## 2. 接口地址与安全边界 + +### 检索地址 + +```http +POST https://weknora-api-test.dobot.cn/retrieval +``` + +这是服务端到服务端接口: + +```text +Dify可以直接调用 +业务后端/BFF可以直接调用 +浏览器前端不能携带Adapter API Key直接调用 +``` + +浏览器推荐链路: + +```text +浏览器前端 + → 业务后端/BFF + → Adapter + → WeKnora +``` + +禁止把真实API Key写入: + +- 前端JavaScript或打包产物; +- Git仓库; +- URL查询参数; +- LocalStorage或SessionStorage; +- 普通日志、埋点或错误上报。 + +## 3. 请求方法 + +### Headers + +```http +Authorization: Bearer [TEST_DIFY_KEY] +Content-Type: application/json +Accept: application/json +X-Request-ID: <可选,建议使用UUID> +``` + +对外调用使用的是Dify Bearer Key。WeKnora API Key和OSS凭据只由Adapter内部使用,不能提供给Dify或浏览器。 + +### Body + +```json +{ + "knowledge_id": "b9dacd62-87eb-4b38-a6b6-85f68e144796", + "query": "接线", + "retrieval_setting": { + "top_k": 5, + "score_threshold": 0.2 + } +} +``` + +### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|---|---:|---:|---| +| `knowledge_id` | UUID字符串 | 是 | 测试环境“对外知识库”固定为 `b9dacd62-87eb-4b38-a6b6-85f68e144796` | +| `query` | 字符串 | 是 | 用户问题;去除首尾空格后不能为空,最多2000字符 | +| `retrieval_setting.top_k` | 整数 | 是 | 至少为1,当前上限为5 | +| `retrieval_setting.score_threshold` | 浮点数 | 是 | 范围0至1;业务建议初始使用0.2,排查召回问题时可临时使用0 | +| `metadata_condition` | 对象 | 否 | 当前不支持非空过滤条件,调用方应省略 | + +`knowledge_id`应由Dify或业务后端固定,不能接受浏览器任意指定。 + +## 4. 应答格式 + +成功响应: + +```json +{ + "records": [ + { + "content": "Markdown格式的知识片段", + "score": 0.82, + "title": "文档标题", + "metadata": { + "knowledge_base_id": "知识库UUID", + "knowledge_id": "文档UUID", + "chunk_id": "Chunk UUID", + "chunk_index": 1, + "chunk_type": "text" + } + } + ] +} +``` + +真实公网响应节选(`content`为缩写,完整正文见TXT样本): + +```json +{ + "records": [ + { + "content": " 图片地址>", + "score": 0.5314404987902756, + "title": "Dobot Magician Go用户手册V1.1_20240624_cn.pdf", + "metadata": { + "knowledge_base_id": "b9dacd62-87eb-4b38-a6b6-85f68e144796", + "knowledge_id": "3c878f4e-bfea-4c5c-b122-b63ba9db4e34", + "chunk_id": "d674ba02-7769-4e9a-8596-961cb50e1f75", + "chunk_index": 53, + "chunk_type": "text" + } + } + ] +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +|---|---:|---| +| `records` | 数组 | 检索结果;没有命中时为空数组 | +| `records[].content` | 字符串 | Markdown知识片段,不是大模型生成的最终答案 | +| `records[].score` | 浮点数 | 当前检索相关度分数;不要跨模型或跨版本硬编码绝对含义 | +| `records[].title` | 字符串 | 来源文档标题 | +| `records[].metadata` | 对象 | 知识库、文档和Chunk审计标识 | + +该接口只负责检索。Dify拿到`records`后,再将知识片段交给大模型生成最终回答。 + +## 5. curl调用示例 + +真实Key应由运行环境或密钥系统注入,不要直接写进脚本: + +```bash +curl --request POST \ + 'https://weknora-api-test.dobot.cn/retrieval' \ + --header "Authorization: Bearer ${WEKNORA_ADAPTER_API_KEY}" \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json' \ + --header "X-Request-ID: $(python3 -c 'import uuid; print(uuid.uuid4())')" \ + --data '{ + "knowledge_id": "b9dacd62-87eb-4b38-a6b6-85f68e144796", + "query": "接线", + "retrieval_setting": { + "top_k": 5, + "score_threshold": 0.2 + } + }' +``` + +## 6. Node.js服务端调用示例 + +下面代码只能运行在Node.js服务端或BFF,不能放入浏览器包: + +```js +import { randomUUID } from "node:crypto"; + +const ADAPTER_URL = "https://weknora-api-test.dobot.cn/retrieval"; +const KNOWLEDGE_ID = "b9dacd62-87eb-4b38-a6b6-85f68e144796"; + +export async function retrieveKnowledge(query) { + const apiKey = process.env.WEKNORA_ADAPTER_API_KEY; + if (!apiKey) { + throw new Error("WEKNORA_ADAPTER_API_KEY is not configured"); + } + + const response = await fetch(ADAPTER_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + Accept: "application/json", + "X-Request-ID": randomUUID(), + }, + body: JSON.stringify({ + knowledge_id: KNOWLEDGE_ID, + query, + retrieval_setting: { + top_k: 5, + score_threshold: 0.2, + }, + }), + signal: AbortSignal.timeout(25000), + }); + + if (!response.ok) { + throw new Error(`Knowledge retrieval failed: HTTP ${response.status}`); + } + + const body = await response.json(); + if (!body || !Array.isArray(body.records)) { + throw new Error("Invalid knowledge retrieval response"); + } + return body.records; +} +``` + +日志中只建议记录: + +```text +X-Request-ID、HTTP状态、耗时、records数量 +``` + +不要记录: + +```text +Authorization、API Key、完整问题、完整知识正文、图片302 Location +``` + +## 7. 浏览器调用BFF示例 + +浏览器只调用业务后端,不携带Adapter API Key: + +```js +const response = await fetch("/api/knowledge/retrieve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: userInput }), +}); + +if (!response.ok) { + throw new Error(`检索失败:HTTP ${response.status}`); +} + +const { records } = await response.json(); +``` + +业务后端负责补充: + +```text +Authorization +knowledge_id +retrieval_setting +X-Request-ID +``` + +## 8. Dify配置 + +在Dify外部知识库API中填写: + +```text +Endpoint: +https://weknora-api-test.dobot.cn/retrieval + +API Key: +测试环境Adapter原始Key + +External Knowledge ID: +b9dacd62-87eb-4b38-a6b6-85f68e144796 +``` + +不要把Dify直接指向WeKnora原生`/api/v1/knowledge-search`。协议转换、知识库白名单、响应裁剪和图片稳定URL都由Adapter负责。 + +## 9. 图片调用方法 + +检索结果中的图片使用稳定地址: + +```text +https://weknora-api-test.dobot.cn/i/ +``` + +真实样例: + +```text +https://weknora-api-test.dobot.cn/i/02e269e8-c4f0-4b3f-a41e-82f5b222a31f +``` + +前端直接将稳定地址作为图片地址: + +```html +知识图片 +``` + +图片访问不需要Bearer Key: + +```text +浏览器GET /i/ +→ Adapter查询有效映射 +→ HTTP 302 +→ 私有OSS 10分钟签名URL +→ 图片HTTP 200 +``` + +注意: + +- 保存和渲染稳定`/i/`地址; +- 不保存、不打印、不缓存302后的OSS签名地址; +- 10分钟过期的是OSS签名地址,不是稳定UUID地址; +- 稳定UUID属于能力链接,不应进入无关日志或公开页面; +- OSS Bucket继续保持Private。 + +真实图片验收: + +```text +稳定UUID:HTTP 302 +Cache-Control:no-store +Referrer-Policy:no-referrer +私有OSS匿名访问:HTTP 403 +短期签名取图:HTTP 200 +类型:image/jpeg +大小:56,025 bytes +JPEG结构:有效 +``` + +## 10. 错误码 + +| HTTP状态 | 含义 | 调用方处理 | +|---:|---|---| +| 200 | 成功 | 读取`records`,允许空数组 | +| 400 | 请求格式错误或知识库未授权 | 不重试;检查请求字段和固定`knowledge_id` | +| 401 | API Key缺失或错误 | 不重试;检查服务端Secret配置 | +| 403 | 请求方法被Nginx拒绝 | 不重试;确认使用`POST /retrieval`或`GET /i/` | +| 404 | 路径或图片UUID不存在 | 不重试;检查地址 | +| 502 | WeKnora响应异常或内容处理失败 | 记录Request ID,有限重试并告警 | +| 503 | WeKnora超时、暂时不可用或图片签名失败 | 建议退避重试1至2次,仍失败则降级 | + +不要自动重试400、401、403和404。 + +## 11. 联调检查表 + +- [x] 公网HTTPS回源测试ECS Nginx 80; +- [x] 保留`Host`、`Authorization`、`Content-Type`和`X-Request-ID`; +- [x] 无Key请求返回401; +- [x] 正确Key请求返回HTTP 200和`records`; +- [x] 有图结果返回稳定UUID,图片302和最终下载通过; +- [ ] 前端构建产物中确认不存在Adapter API Key; +- [ ] 业务日志中确认没有Authorization、完整正文和OSS签名URL; +- [ ] 生产环境使用独立域名、独立Key和独立知识库白名单。 diff --git a/docs/key-rotation.md b/docs/key-rotation.md new file mode 100644 index 0000000..a1f6d02 --- /dev/null +++ b/docs/key-rotation.md @@ -0,0 +1,83 @@ +# Key and Credential Rotation + +All rotations are staged in the test environment first. Never print raw keys, DSNs, STS tokens, Presigned URLs, or Secret file contents. Every cutover needs a rollback copy outside Git and a post-change readback. + +## Dify Bearer key + +Dify rotation uses two SHA-256 hash slots so old and new callers can overlap briefly. + +1. Generate the new raw key in an approved secret manager. +2. Calculate its SHA-256 digest without placing the raw key in shell history. +3. Set the digest as `DIFY_KEY_HASH_NEXT`; leave `DIFY_KEY_HASH_CURRENT` unchanged. +4. Recreate only the Adapter and verify both old and new keys. +5. Update Dify to the new raw key and verify real test retrieval. +6. Set the new digest as `DIFY_KEY_HASH_CURRENT` and clear `DIFY_KEY_HASH_NEXT`. +7. Recreate and verify that the new key works and old key fails. +8. Revoke/delete the old raw key from its secret store. + +Rollback before step 6: restore the previous env file. Rollback after step 6: temporarily restore old current plus new next, recreate, and investigate. Raw keys never enter Compose environment; only `DIFY_KEY_HASH_CURRENT` and `DIFY_KEY_HASH_NEXT` do. + +## WeKnora API Key + +1. Create a new restricted WeKnora API Key without revoking the old key. +2. Write it to a sibling untracked Secret file using an approved secret manager/editor. +3. Set owner `10001:10001`, mode `0400`, and verify non-empty without printing. +4. Atomically replace `/opt/knowledge-adapter/secrets/weknora_api_key` on the same filesystem. +5. Recreate only the Adapter and run allowlisted retrieval. +6. Confirm request IDs reach WeKnora and logs contain no key. +7. Revoke the old WeKnora API Key. + +Rollback: restore the previous Secret file atomically and recreate only the Adapter before revoking the old key. + +## Adapter DB credential + +The Adapter DB account may read/write only Adapter-owned mapping objects. + +1. Create a new dedicated login/password and grant the same narrow privileges. +2. Test the new DSN from an isolated test client without displaying it. +3. Atomically replace `asset_database_url` with owner `10001:10001`, mode `0400`. +4. Recreate only the Adapter. +5. Verify mapping lookup, conflict-safe insert, and stable UUID redirect. +6. Disable the old login, observe, then remove it after the rollback window. + +Rollback: re-enable the old login if needed, restore the previous Secret file, and recreate only the Adapter. Do not alter `asset_mapping` rows. + +## WeKnora read-only DB credential + +The WeKnora read-only DB account is separate from the Adapter DB account. + +1. Create a new `NOINHERIT` login with no unrelated memberships. +2. Apply/review `deploy/sql/weknora_readonly_grants.sql` for the new role. +3. Prove permitted field-level SELECT succeeds and unrelated table/column/write access fails. +4. Atomically replace `weknora_readonly_database_url` with mode `0400`. +5. Recreate only the Adapter and verify first-time resource resolution. +6. disable the old login, observe, then remove it. + +Rollback: restore the old role and Secret file; no WeKnora schema or data changes are part of rotation. + +## OSS static AK + +The Adapter reads the OSS AccessKey pair from two mode-`0400` Secret files. Test and production use different RAM users, keys, buckets, and Secret directories. + +1. Create a second AccessKey for the same dedicated RAM user, or create a replacement dedicated RAM user with the same reviewed `oss:GetObject` policy. +2. Write the candidate ID and secret to sibling untracked files without printing either value. +3. Set owner `10001:10001`, mode `0400`, then atomically replace both active files within one maintenance window. +4. Recreate only the Adapter and verify 302 generation, object retrieval, anonymous direct denial, and absence of List/Put/Delete. +5. Keep the old pair available only for the bounded rollback window, then revoke it in RAM and securely remove the old files. + +Rollback: atomically restore both previous Secret files and recreate only the Adapter. Never put either AccessKey value in Compose environment variables. + +## TLS certificate + +Renew the certificate/key outside Git, validate the full Nginx configuration, reload Nginx, and verify certificate chain/hostname/expiry. Rollback by restoring the previous certificate pair and reloading. Adapter containers do not receive TLS private keys. + +## Rotation evidence + +For every credential class, record only: + +- change ticket/time; +- credential identity or role name, never value; +- old/new activation state; +- exact candidate image/config revision; +- sanitized health and E2E result; +- rollback result or rollback-window expiry. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..63a3799 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,114 @@ +# Security Model + +## Security objectives + +1. Keep the OSS bucket Private while permitting stable customer-facing UUID image links. +2. Keep all raw API keys, database credentials, OSS AccessKeys, object keys, and Presigned URL query parameters out of Git and logs. +3. Restrict retrieval to configured knowledge bases and image signing to active Adapter mappings. +4. Fail closed when validation, a database, WeKnora, OSS credential files, or signing is unavailable. + +## Credential model + +Because the target ECS instances are not permitted to receive RAM roles, the Adapter uses a dedicated RAM user's static AK. Test and production use different identities and different Private OSS buckets. The AccessKey ID and secret are mounted as separate read-only files; they are never accepted as Compose environment values, build arguments, API fields, or command-line arguments. + +The production RAM user policy grants only `oss:GetObject`. Alibaba Cloud documents `oss:GetObject` as the permission required to download an object.[4] The supplied policy narrows this action to: + +```text +acs:oss:*:*:${OSS_BUCKET}/weknora/${WEKNORA_TENANT_ID}/* +``` + +It intentionally omits List, Put, Delete, ACL, bucket-management, RAM management, and KMS permissions. A higher-privilege test AK may be used only for the explicitly approved test window; it must never be copied to production and must be replaced by a dedicated read-only identity before production traffic. If the reviewed bucket uses SSE-KMS, do not add `kms:Decrypt` automatically; review a separately scoped KMS policy first. + +## Private OSS and temporary access + +A V4 Presigned GET URL grants temporary access to one object without requiring credentials at the recipient.[2] V4 URLs expose `x-oss-expires`, use `OSS4-HMAC-SHA256`, and carry a signature in the query string.[2] Therefore: + +- the URL lifetime is fixed at 600 seconds; +- the generated scheme and hostname are revalidated as the configured public regional OSS host; +- the URL appears only in the 302 `Location` header; +- the response is `no-store` and `no-referrer`; +- the URL is never persisted or logged; +- a stable `/i/{UUID}` remains reusable after the temporary URL expires. + +The bucket must remain Private. Anonymous direct OSS access must return denial during Slice 9 acceptance. + +## Secret storage + +Raw secrets are mounted as read-only files owned by container UID 10001 with mode `0400`: + +- `/run/secrets/weknora_api_key` +- `/run/secrets/asset_database_url` +- `/run/secrets/weknora_readonly_database_url` +- `/run/secrets/oss_access_key_id` +- `/run/secrets/oss_access_key_secret` + +They must not appear in `.env`, Compose environment, Docker build args, image layers, source, fixtures, documentation examples, or shell command history. Dify stores only current/next SHA-256 hashes in non-secret runtime configuration. + +## Input and SSRF controls + +The Adapter does not perform arbitrary HTTP/HTTPS downloads. Callers cannot provide bucket, endpoint, object key, expiry, or destination URL. Only canonical `resource://` handles are accepted for first resolution; fixed tenant, storage backend, provider, bucket, and object-key rules are checked before persistence or signing. + +This removes the general outbound URL-fetch primitive that would create SSRF risk. Outbound application destinations are limited to configured WeKnora, PostgreSQL, and the SDK-derived regional OSS endpoint. + +## Authentication and authorization + +- `POST /retrieval` requires a Dify Bearer key matching current or next hash. +- Requested knowledge bases must be allowlisted before search. +- Every returned chunk must repeat the requested knowledge-base UUID. +- `GET /i/{UUID}` is intentionally anonymous; possession of a valid UUID grants access. +- Missing and disabled UUIDs have the same empty 404 behavior. +- `GET /healthz` discloses only `{"status":"ok"}`. + +## Logging and error redaction + +The application writes one JSON request summary with only: + +- request ID; +- method; +- matched route template; +- status code; +- duration in milliseconds; +- fixed error code for failures. + +The following are prohibited from application and Nginx access logs: + +- Dify or WeKnora API Key; +- `Authorization` header; +- request query/body and complete knowledge content; +- `resource://` locator; +- `object_key`; +- database DSN; +- OSS AccessKey ID/secret; +- Presigned URL or signature fields. + +Nginx access logging is disabled for this virtual host. Unexpected exceptions return a fixed 500 without traceback or exception text in Adapter request logs. + +## Container and network controls + +- runtime UID/GID 10001; no root process; +- read-only root filesystem and no-exec temporary filesystem; +- all Linux capabilities dropped; +- `no-new-privileges` enabled; +- process, CPU, and memory limits set; +- Adapter published only on loopback; +- TLS 1.2/1.3 terminated by host Nginx; +- route/method allowlists, request-size limit, and rate limits at Nginx; +- Uvicorn raw access log disabled. + +## Review checklist + +- [ ] Private bucket confirmed; anonymous object GET denied. +- [ ] Dedicated test/production RAM users and AccessKeys are separate. +- [ ] Production RAM policy has only `oss:GetObject` for the reviewed bucket/tenant prefix. +- [ ] No other policy grants overlapping OSS write/list access. +- [ ] Five Secret files are owned by UID 10001 and mode `0400`. +- [ ] Adapter DB and WeKnora DB use different least-privilege accounts. +- [ ] Nginx certificate paths and public hostname are reviewed. +- [ ] Sanitized logs contain request IDs but none of the prohibited values. +- [ ] Arbitrary URL, bucket, object-key, and endpoint override tests pass. +- [ ] Slice 9 real test-environment E2E and audit report are accepted. + +## Sources + +[2] https://alibabacloud.com/help/en/oss/developer-reference/python-download-using-a-presigned-url — Alibaba Cloud OSS Python presigned GET URL +[4] https://www.alibabacloud.com/help/en/oss/developer-reference/get-object — Alibaba Cloud OSS GetObject permission diff --git a/docs/test-api-request-response.txt b/docs/test-api-request-response.txt new file mode 100644 index 0000000..cce262b --- /dev/null +++ b/docs/test-api-request-response.txt @@ -0,0 +1,107 @@ +{ + "executed_at": "2026-09-01T02:32:40.311192+00:00", + "execution_note": "真实请求通过公网HTTPS域名进入测试入口,再到测试ECS Nginx、Adapter和WeKnora。", + "endpoint": "https://weknora-api-test.dobot.cn/retrieval", + "request": { + "method": "POST", + "headers": { + "Authorization": "Bearer [REDACTED]", + "Content-Type": "application/json", + "Accept": "application/json", + "X-Request-ID": "frontend-public-api-sample" + }, + "body": { + "knowledge_id": "b9dacd62-87eb-4b38-a6b6-85f68e144796", + "query": "接线", + "retrieval_setting": { + "top_k": 5, + "score_threshold": 0.2 + } + } + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json", + "x-request-id": "frontend-public-api-sample" + }, + "record_count": 5, + "stable_image_uuid_count": 15, + "body": { + "records": [ + { + "content": "与末端AI摄像头进行通信。\n\n![](https://weknora-api-test.dobot.cn/i/02e269e8-c4f0-4b3f-a41e-82f5b222a31f) \n图 3.4 Magic Box 侧面接线图\n\n## 3.1.2Magician Lite 接线说明\n\nMagician Lite需要单独购买,并将Magician Lite放置在Magician Go的机械臂安装槽内。机械臂安装槽内有1根10 Pin通信线和1根4 Pin电源线,用于连接Magician Lite的接口。\n\n![](https://weknora-api-test.dobot.cn/i/36a91564-9225-43fc-9fbf-b5ab226e22d6)\n\n请在设备完全断电的情况下进行接线。\n\n• 将Magician Lite的12V电源接口①与Magician Go的4 Pin电源线③连接,用于为Magician Lite供电。\n\n• 将Magician Lite的通信接口②与Magician Go的10 Pin通信线④连接,用于MagicBox与Magician Lite进行通信。\n\n![](https://weknora-api-test.dobot.cn/i/1e1743bb-e25c-4fe5-b1f0-ad861216e6dd) \n图 3.5 Magician Lite 接线图\n\n## 3.1.3 末端 AI 摄像头接线说明\n\n末端AI摄像头需要安装在Magician Lite上使用。在Magician Go的机械臂安装槽有1根4Pin摄像头通信线,出厂时已经装配到MagicianGo上,用于连接末端AI摄像头。\n\n注意\n\n请在设备完全断电的情况下进行接线。\n\n步骤1使用1.5mm内六角扳手松开AI摄像头套件的螺丝,如图3.6所示。\n\n![](https://weknora-api-test.dobot.cn/i/b8842b11-d3bb-4f82-aae3-82a59178d898)", + "score": 0.5314404987902756, + "title": "Dobot Magician Go用户手册V1.1_20240624_cn.pdf", + "metadata": { + "knowledge_base_id": "b9dacd62-87eb-4b38-a6b6-85f68e144796", + "knowledge_id": "3c878f4e-bfea-4c5c-b122-b63ba9db4e34", + "chunk_id": "d674ba02-7769-4e9a-8596-961cb50e1f75", + "chunk_index": 53, + "chunk_type": "text" + } + }, + { + "content": "![](images/84d8ba268e5bbc1bb09ecf06a574dab30b21f3c9a7c426a2720ec688e715d2d6.jpg)\n\n:机械臂的通讯线端子接入控制柜的CAN接口(①),机械臂的电源线端子接入控制柜的电源接口(②),拧紧接线端子的螺丝。\n\n## 步骤3 连接电源\n\n将电源的接线端子接入控制柜电源接口(③)的V+/V-端。\n\n## 步骤4连接急停开关\n\n将急停开关连接至控制柜的急停开关接口(④),拧紧接线端子的螺丝。\n\n## 步骤5 连接市电\n\n将电源的市电插头接入市电插座。\n\n![](images/558d9e43126be73a5cd7fa1505faf124b63ea0dbc8d79e0ec0878ec5d68c5221.jpg)\n\n:请完成全部接线并确认接线无误后,再接通电源。\n\n## 步骤6 (选配)连接无线接收器\n\n将无线接收器接入控制柜的USB接口(⑤)。\n\n## 注意\n\n•设置外部配线的规格和安装方式应符合当地配电法规的要求。\n\n•请勿自行拆卸机器人,否则可能导致漏电。\n\n• 请确认设备接地良好。\n\n•请勿过度弯曲线缆,否则可能导致接触不良或断线。\n\n连接外部设备时请确认控制系统的电源插座已断开。如果未断开,可能导致触电或设备故障。\n\n•为了保护设备和人身安全,请使用配套的线缆。\n\n•接线完成后请确认设备内无掉落的螺钉或裸露线缆。\n\n•设备正常运行过程中,请勿随意插拔电源线缆及通信线缆。\n\n•必须将设备所需线缆连接完成后才能给设备通电。\n\n•请确认设备线缆连接正确,否则可能导致内部模块或外部设备故障。\n\n•连接前,检查外部电缆的绝缘及护罩是否破损。\n\n机械臂电源(②)和控制柜电源(③)线缆连接后,需要应用额外的保护措施,避免导线和接线端子直接裸露在外,且接线端子不能承受直接的应力。\n\n## 7.5 开机调试\n\n", + "score": 0.5292120593766394, + "title": "DOBOT 新款 Nova系列硬件使用手册.pdf", + "metadata": { + "knowledge_base_id": "b9dacd62-87eb-4b38-a6b6-85f68e144796", + "knowledge_id": "f76a4398-97fe-4362-85ea-b5e40da53893", + "chunk_id": "cadac306-bb4c-4bbf-b03b-69eb35ae9d7b", + "chunk_index": 106, + "chunk_type": "text" + } + }, + { + "content": "') + + processed = await processor.process(chunk) + + assert processed == (f'') + assert repository.lookup_calls == [SOURCE_REF] + assert repository.create_calls == [] + assert resolver.calls == [] + + +async def test_html_img_resource_preserves_attributes_and_quote_style() -> None: + repository = RecordingAssetRepository(active_mapping()) + resolver = RecordingResolver() + processor = AssetContentProcessor( + repository=repository, + resolver=resolver, + public_base_url="https://assets.example.com", + ) + chunk = make_chunk(content=f"diagram") + + processed = await processor.process(chunk) + + assert processed == ( + f"diagram" + ) + assert repository.lookup_calls == [SOURCE_REF] + assert repository.create_calls == [] + assert resolver.calls == [] + + +async def test_unmapped_resource_is_resolved_and_persisted_once() -> None: + repository = RecordingAssetRepository( + None, + created_mapping=active_mapping(), + ) + resolver = RecordingResolver( + ResolvedAsset( + object_key="weknora/10008/assets/image.png", + content_type="image/png", + ) + ) + processor = AssetContentProcessor( + repository=repository, + resolver=resolver, + public_base_url="https://assets.example.com", + ) + + processed = await processor.process( + make_chunk(content=f"![diagram]({SOURCE_REF})") + ) + + assert processed == f"![diagram](https://assets.example.com/i/{ASSET_ID})" + assert repository.lookup_calls == [SOURCE_REF] + assert resolver.calls == [SOURCE_REF] + assert repository.create_calls == [ + (SOURCE_REF, "weknora/10008/assets/image.png", "image/png") + ] + + +async def test_duplicate_markdown_resource_is_deduplicated() -> None: + repository = RecordingAssetRepository(active_mapping()) + resolver = RecordingResolver() + processor = AssetContentProcessor( + repository=repository, + resolver=resolver, + public_base_url="https://assets.example.com", + ) + chunk = make_chunk( + content=f"![first]({SOURCE_REF}) and ![second]({SOURCE_REF})" + ) + + processed = await processor.process(chunk) + + public_url = f"https://assets.example.com/i/{ASSET_ID}" + assert processed == f"![first]({public_url}) and ![second]({public_url})" + assert repository.lookup_calls == [SOURCE_REF] + assert resolver.calls == [] + + +async def test_image_info_only_resource_is_appended_as_stable_image() -> None: + repository = RecordingAssetRepository(active_mapping()) + resolver = RecordingResolver() + processor = AssetContentProcessor( + repository=repository, + resolver=resolver, + public_base_url="https://assets.example.com", + ) + image_info = json.dumps( + [{"url": SOURCE_REF, "original_url": SOURCE_REF}], + ensure_ascii=False, + ) + + processed = await processor.process( + make_chunk(content="Text only", image_info=image_info) + ) + + public_url = f"https://assets.example.com/i/{ASSET_ID}" + assert processed == f"Text only\n\n![知识图片]({public_url})" + assert repository.lookup_calls == [SOURCE_REF] + assert resolver.calls == [] + + +async def test_malformed_image_info_fails_with_safe_error() -> None: + processor = AssetContentProcessor( + repository=RecordingAssetRepository(active_mapping()), + resolver=RecordingResolver(), + public_base_url="https://assets.example.com", + ) + + with pytest.raises(ContentProcessingError) as captured: + await processor.process(make_chunk(content="Text", image_info="not-json")) + + assert str(captured.value) == "content processing failed" + assert "not-json" not in str(captured.value) + + +async def test_non_array_image_info_fails_closed() -> None: + processor = AssetContentProcessor( + repository=RecordingAssetRepository(active_mapping()), + resolver=RecordingResolver(), + public_base_url="https://assets.example.com", + ) + + with pytest.raises(ContentProcessingError, match=r"^content processing failed$"): + await processor.process(make_chunk(content="Text", image_info="{}")) + + +async def test_resource_resolution_error_is_mapped_to_content_error() -> None: + processor = AssetContentProcessor( + repository=RecordingAssetRepository(None), + resolver=RecordingResolver(error=ResourceResolutionError("resource unavailable")), + public_base_url="https://assets.example.com", + ) + + with pytest.raises(ContentProcessingError, match=r"^content processing failed$"): + await processor.process(make_chunk(content=f"![diagram]({SOURCE_REF})")) + + +async def test_unrewritten_resource_reference_never_reaches_dify_content() -> None: + processor = AssetContentProcessor( + repository=RecordingAssetRepository(active_mapping()), + resolver=RecordingResolver(), + public_base_url="https://assets.example.com", + ) + + with pytest.raises(ContentProcessingError, match=r"^content processing failed$"): + await processor.process(make_chunk(content=f"internal ref: {SOURCE_REF}")) + + +async def test_truncated_trailing_markdown_resource_image_is_removed() -> None: + processor = AssetContentProcessor( + repository=RecordingAssetRepository(active_mapping()), + resolver=RecordingResolver(), + public_base_url="https://assets.example.com", + ) + + processed = await processor.process( + make_chunk(content="LED indicator meanings\n\n![diagram](resource://4j9P") + ) + + assert processed == "LED indicator meanings\n\n" + + +@pytest.mark.parametrize( + "forbidden_content", + [ + "local://srv/private/image.png", + "/home/app/private/image.png", + "http://127.0.0.1/image.png", + "http://10.0.0.8/image.png", + "http://192.168.1.8/image.png", + "http://172.16.0.8/image.png", + "https://private-assets.oss-cn-shenzhen-internal.aliyuncs.com/image.png", + "https://private-assets.oss-cn-shenzhen.aliyuncs.com/image.png", + ], +) +async def test_internal_locator_never_reaches_dify_content( + forbidden_content: str, +) -> None: + processor = AssetContentProcessor( + repository=RecordingAssetRepository(active_mapping()), + resolver=RecordingResolver(), + public_base_url="https://assets.example.com", + ) + + with pytest.raises(ContentProcessingError, match=r"^content processing failed$"): + await processor.process(make_chunk(content=forbidden_content)) diff --git a/tests/test_asset_redirect.py b/tests/test_asset_redirect.py new file mode 100644 index 0000000..37735ad --- /dev/null +++ b/tests/test_asset_redirect.py @@ -0,0 +1,126 @@ +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from knowledge_adapter.asset_redirect import ( + AssetNotFoundError, + AssetRedirectService, + AssetRedirectUnavailableError, + AssetSignerError, +) +from knowledge_adapter.asset_repository import ( + AssetMapping, + AssetRepositoryError, +) + +ASSET_ID = UUID("c7f61f89-19ce-45b1-ac60-dbeed5e2a491") +MAPPING = AssetMapping( + asset_id=ASSET_ID, + source_ref="resource://AbCdEfGhIjKlMnOpQrSt_1", + object_key="weknora/10008/assets/image.png", + content_type="image/png", + created_at=datetime(2026, 8, 24, tzinfo=UTC), + status="active", +) +SIGNED_URL = ( + "https://private-assets.oss-cn-shenzhen.aliyuncs.com/" + "weknora/10008/assets/image.png?x-oss-signature=redacted" +) + + +class RecordingRepository: + def __init__( + self, + mapping: AssetMapping | None, + *, + error: Exception | None = None, + ) -> None: + self.mapping = mapping + self.error = error + self.calls: list[UUID] = [] + + async def get_active_by_asset_id(self, asset_id: UUID) -> AssetMapping | None: + self.calls.append(asset_id) + if self.error is not None: + raise self.error + return self.mapping + + +class RecordingSigner: + def __init__( + self, + url: str = SIGNED_URL, + *, + error: Exception | None = None, + ) -> None: + self.url = url + self.error = error + self.calls: list[tuple[str, int]] = [] + + async def sign_get(self, *, object_key: str, expires_seconds: int) -> str: + self.calls.append((object_key, expires_seconds)) + if self.error is not None: + raise self.error + return self.url + + +async def test_redirect_service_signs_only_active_mapped_object() -> None: + repository = RecordingRepository(MAPPING) + signer = RecordingSigner() + service = AssetRedirectService(repository=repository, signer=signer) + + redirect_url = await service.get_redirect_url(ASSET_ID) + + assert redirect_url == SIGNED_URL + assert repository.calls == [ASSET_ID] + assert signer.calls == [("weknora/10008/assets/image.png", 600)] + + +async def test_redirect_service_rejects_unknown_asset_without_signing() -> None: + repository = RecordingRepository(None) + signer = RecordingSigner() + service = AssetRedirectService(repository=repository, signer=signer) + + with pytest.raises(AssetNotFoundError, match=r"^asset not found$"): + await service.get_redirect_url(ASSET_ID) + + assert repository.calls == [ASSET_ID] + assert signer.calls == [] + + +async def test_redirect_service_maps_signer_failure_to_safe_unavailable() -> None: + repository = RecordingRepository(MAPPING) + signer = RecordingSigner( + error=AssetSignerError( + "metadata http://100.100.100.200 object_key=private/image.png" + ) + ) + service = AssetRedirectService(repository=repository, signer=signer) + + with pytest.raises( + AssetRedirectUnavailableError, + match=r"^asset unavailable$", + ) as captured: + await service.get_redirect_url(ASSET_ID) + + assert "100.100.100.200" not in str(captured.value) + assert "object_key" not in str(captured.value) + + +async def test_redirect_service_maps_repository_failure_without_signing() -> None: + repository = RecordingRepository( + None, + error=AssetRepositoryError("postgresql://adapter@db.internal"), + ) + signer = RecordingSigner() + service = AssetRedirectService(repository=repository, signer=signer) + + with pytest.raises( + AssetRedirectUnavailableError, + match=r"^asset unavailable$", + ) as captured: + await service.get_redirect_url(ASSET_ID) + + assert "postgresql" not in str(captured.value) + assert signer.calls == [] diff --git a/tests/test_asset_repository.py b/tests/test_asset_repository.py new file mode 100644 index 0000000..6e72e76 --- /dev/null +++ b/tests/test_asset_repository.py @@ -0,0 +1,209 @@ +from contextlib import AbstractAsyncContextManager +from datetime import UTC, datetime +from types import TracebackType +from uuid import UUID + +import pytest + +from knowledge_adapter.asset_repository import ( + AssetMapping, + AssetMappingConflictError, + AssetRepository, + AssetRepositoryError, +) + +SOURCE_REF = "resource://AbCdEfGhIjKlMnOpQrSt_1" +ASSET_ID = UUID("c7f61f89-19ce-45b1-ac60-dbeed5e2a491") +CREATED_AT = datetime(2026, 8, 24, tzinfo=UTC) + + +class FakeAssetConnection: + def __init__(self, row: dict[str, object] | None) -> None: + self.row = row + self.query = "" + self.args: tuple[object, ...] = () + + async def fetchrow(self, query: str, *args: object) -> dict[str, object] | None: + self.query = query + self.args = args + return self.row + + +class FailingAssetConnection(FakeAssetConnection): + async def fetchrow(self, query: str, *args: object) -> dict[str, object] | None: + raise RuntimeError("postgresql://adapter:secret@db object_key=private/image.png") + + +class AcquireAssetConnection(AbstractAsyncContextManager[FakeAssetConnection]): + def __init__(self, connection: FakeAssetConnection) -> None: + self.connection = connection + + async def __aenter__(self) -> FakeAssetConnection: + return self.connection + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None + + +class FakeAssetPool: + def __init__(self, connection: FakeAssetConnection) -> None: + self.connection = connection + + def acquire(self) -> AcquireAssetConnection: + return AcquireAssetConnection(self.connection) + + +async def test_get_or_create_uses_atomic_source_ref_upsert() -> None: + connection = FakeAssetConnection( + { + "asset_id": ASSET_ID, + "source_ref": SOURCE_REF, + "object_key": "weknora/10008/assets/image.png", + "content_type": "image/png", + "created_at": CREATED_AT, + "status": "active", + } + ) + repository = AssetRepository(pool=FakeAssetPool(connection)) + + mapping = await repository.get_or_create( + source_ref=SOURCE_REF, + object_key="weknora/10008/assets/image.png", + content_type="image/png", + ) + + assert mapping == AssetMapping( + asset_id=ASSET_ID, + source_ref=SOURCE_REF, + object_key="weknora/10008/assets/image.png", + content_type="image/png", + created_at=CREATED_AT, + status="active", + ) + generated_asset_id = connection.args[0] + assert isinstance(generated_asset_id, UUID) + assert generated_asset_id.version == 4 + assert connection.args[1:] == ( + SOURCE_REF, + "weknora/10008/assets/image.png", + "image/png", + ) + sql = " ".join(connection.query.lower().split()) + assert "insert into asset_mapping" in sql + assert "(asset_id, source_ref, object_key, content_type, status)" in sql + assert "values ($1, $2, $3, $4, 'active')" in sql + assert "on conflict (source_ref) do update" in sql + assert "source_ref = asset_mapping.source_ref" in sql + assert "where asset_mapping.object_key = excluded.object_key" in sql + assert ( + "returning asset_id, source_ref, object_key, content_type, created_at, status" in sql + ) + + +async def test_get_or_create_rejects_source_ref_object_key_conflict() -> None: + repository = AssetRepository(pool=FakeAssetPool(FakeAssetConnection(None))) + + with pytest.raises(AssetMappingConflictError, match=r"^asset mapping conflict$"): + await repository.get_or_create( + source_ref=SOURCE_REF, + object_key="different/object.png", + content_type="image/png", + ) + + +async def test_get_or_create_maps_database_error_to_safe_error() -> None: + repository = AssetRepository( + pool=FakeAssetPool(FailingAssetConnection(None)), + ) + + with pytest.raises(AssetRepositoryError) as captured: + await repository.get_or_create( + source_ref=SOURCE_REF, + object_key="weknora/10008/assets/image.png", + content_type="image/png", + ) + + assert str(captured.value) == "asset repository unavailable" + assert "postgresql" not in str(captured.value) + assert "object_key" not in str(captured.value) + + +async def test_get_active_by_source_ref_reads_only_active_mapping() -> None: + connection = FakeAssetConnection( + { + "asset_id": ASSET_ID, + "source_ref": SOURCE_REF, + "object_key": "weknora/10008/assets/image.png", + "content_type": "image/png", + "created_at": CREATED_AT, + "status": "active", + } + ) + repository = AssetRepository(pool=FakeAssetPool(connection)) + + mapping = await repository.get_active_by_source_ref(SOURCE_REF) + + assert mapping is not None + assert mapping.asset_id == ASSET_ID + assert connection.args == (SOURCE_REF,) + sql = " ".join(connection.query.lower().split()) + assert sql.startswith( + "select asset_id, source_ref, object_key, content_type, created_at, status " + "from asset_mapping" + ) + assert "source_ref = $1" in sql + assert "status = 'active'" in sql + + +async def test_get_active_by_source_ref_returns_none_when_missing() -> None: + repository = AssetRepository(pool=FakeAssetPool(FakeAssetConnection(None))) + + mapping = await repository.get_active_by_source_ref(SOURCE_REF) + + assert mapping is None + + +async def test_get_active_by_source_ref_maps_database_error_to_safe_error() -> None: + repository = AssetRepository( + pool=FakeAssetPool(FailingAssetConnection(None)), + ) + + with pytest.raises(AssetRepositoryError, match=r"^asset repository unavailable$"): + await repository.get_active_by_source_ref(SOURCE_REF) + + +async def test_get_active_by_asset_id_reads_only_active_mapping() -> None: + connection = FakeAssetConnection( + { + "asset_id": ASSET_ID, + "source_ref": SOURCE_REF, + "object_key": "weknora/10008/assets/image.png", + "content_type": "image/png", + "created_at": CREATED_AT, + "status": "active", + } + ) + repository = AssetRepository(pool=FakeAssetPool(connection)) + + mapping = await repository.get_active_by_asset_id(ASSET_ID) + + assert mapping is not None + assert mapping.object_key == "weknora/10008/assets/image.png" + assert connection.args == (ASSET_ID,) + sql = " ".join(connection.query.lower().split()) + assert "from asset_mapping" in sql + assert "asset_id = $1" in sql + assert "status = 'active'" in sql + + +async def test_get_active_by_asset_id_returns_none_when_missing() -> None: + repository = AssetRepository(pool=FakeAssetPool(FakeAssetConnection(None))) + + mapping = await repository.get_active_by_asset_id(ASSET_ID) + + assert mapping is None diff --git a/tests/test_asset_route.py b/tests/test_asset_route.py new file mode 100644 index 0000000..c6ea4dd --- /dev/null +++ b/tests/test_asset_route.py @@ -0,0 +1,127 @@ +from uuid import UUID + +import httpx + +from knowledge_adapter.app import create_app +from knowledge_adapter.asset_redirect import ( + AssetNotFoundError, + AssetRedirectUnavailableError, +) + +ASSET_ID = UUID("c7f61f89-19ce-45b1-ac60-dbeed5e2a491") +SIGNED_URL = ( + "https://private-assets.oss-cn-shenzhen.aliyuncs.com/" + "weknora/10008/assets/image.png?x-oss-signature=redacted" +) + + +class RecordingRedirectService: + def __init__( + self, + url: str = SIGNED_URL, + *, + error: Exception | None = None, + ) -> None: + self.url = url + self.error = error + self.calls: list[UUID] = [] + + async def get_redirect_url(self, asset_id: UUID) -> str: + self.calls.append(asset_id) + if self.error is not None: + raise self.error + return self.url + + +async def test_asset_route_returns_anonymous_302() -> None: + service = RecordingRedirectService() + app = create_app(asset_redirect_service=service) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + follow_redirects=False, + ) as client: + response = await client.get(f"/i/{ASSET_ID}") + + assert response.status_code == 302 + assert response.headers["location"] == SIGNED_URL + assert response.headers["cache-control"] == "no-store" + assert response.headers["referrer-policy"] == "no-referrer" + assert response.content == b"" + assert service.calls == [ASSET_ID] + + +async def test_asset_route_returns_empty_404_for_unknown_asset() -> None: + service = RecordingRedirectService(error=AssetNotFoundError("asset not found")) + app = create_app(asset_redirect_service=service) + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get(f"/i/{ASSET_ID}") + + assert response.status_code == 404 + assert response.content == b"" + assert "asset" not in response.text.lower() + assert str(ASSET_ID) not in response.text + + +async def test_asset_route_rejects_malformed_uuid_before_service() -> None: + service = RecordingRedirectService() + app = create_app(asset_redirect_service=service) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/i/not-a-uuid") + + assert response.status_code == 404 + assert response.content == b"" + assert service.calls == [] + + +async def test_asset_route_returns_empty_503_when_redirect_is_unavailable() -> None: + service = RecordingRedirectService( + error=AssetRedirectUnavailableError( + "metadata http://100.100.100.200?token=secret object_key=private/image.png" + ) + ) + app = create_app(asset_redirect_service=service) + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get(f"/i/{ASSET_ID}") + + assert response.status_code == 503 + assert response.content == b"" + assert "100.100.100.200" not in response.text + assert "object_key" not in response.text + assert "secret" not in response.text + + +async def test_asset_route_does_not_accept_bucket_object_key_or_url_overrides() -> None: + service = RecordingRedirectService() + app = create_app(asset_redirect_service=service) + transport = httpx.ASGITransport(app=app) + + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + follow_redirects=False, + ) as client: + response = await client.get( + f"/i/{ASSET_ID}", + params={ + "bucket": "attacker-bucket", + "object_key": "other/secret.png", + "url": "https://attacker.example/file", + }, + headers={ + "X-OSS-Bucket": "attacker-bucket", + "X-Object-Key": "other/secret.png", + }, + ) + + assert response.status_code == 302 + assert response.headers["location"] == SIGNED_URL + assert service.calls == [ASSET_ID] diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..04af364 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,91 @@ +import hashlib +import hmac +from collections.abc import AsyncIterator + +import httpx +import pytest + +from knowledge_adapter.app import create_app +from knowledge_adapter.auth import DifyKeyAuthenticator + +CURRENT_KEY = "c" * 32 +NEXT_KEY = "n" * 32 +VALID_PAYLOAD = { + "knowledge_id": "3904b40e-278b-4273-a6fa-db6cbc975587", + "query": "test query", + "retrieval_setting": {"top_k": 3, "score_threshold": 0.5}, +} + + +def sha256_hex(value: str) -> str: + return hashlib.sha256(value.encode()).hexdigest() + + +@pytest.fixture +async def client() -> AsyncIterator[httpx.AsyncClient]: + authenticator = DifyKeyAuthenticator( + current_hash=sha256_hex(CURRENT_KEY), + next_hash=sha256_hex(NEXT_KEY), + ) + transport = httpx.ASGITransport(app=create_app(authenticator=authenticator)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as value: + yield value + + +async def test_retrieval_without_bearer_key_returns_401(client: httpx.AsyncClient) -> None: + response = await client.post("/retrieval", json=VALID_PAYLOAD) + + assert response.status_code == 401 + + +async def test_retrieval_with_wrong_bearer_key_returns_401(client: httpx.AsyncClient) -> None: + response = await client.post( + "/retrieval", + headers={"Authorization": f"Bearer {'x' * 32}"}, + json=VALID_PAYLOAD, + ) + + assert response.status_code == 401 + + +async def test_retrieval_with_current_key_returns_200(client: httpx.AsyncClient) -> None: + response = await client.post( + "/retrieval", + headers={"Authorization": f"Bearer {CURRENT_KEY}"}, + json=VALID_PAYLOAD, + ) + + assert response.status_code == 200 + + +async def test_retrieval_with_next_key_returns_200(client: httpx.AsyncClient) -> None: + response = await client.post( + "/retrieval", + headers={"Authorization": f"Bearer {NEXT_KEY}"}, + json=VALID_PAYLOAD, + ) + + assert response.status_code == 200 + + +async def test_current_match_does_not_short_circuit_next_hash_comparison( + client: httpx.AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_compare = hmac.compare_digest + compared_hashes: list[str] = [] + + def recording_compare(digest: str, configured_hash: str) -> bool: + compared_hashes.append(configured_hash) + return original_compare(digest, configured_hash) + + monkeypatch.setattr("knowledge_adapter.auth.hmac.compare_digest", recording_compare) + + response = await client.post( + "/retrieval", + headers={"Authorization": f"Bearer {CURRENT_KEY}"}, + json=VALID_PAYLOAD, + ) + + assert response.status_code == 200 + assert len(compared_hashes) == 2 diff --git a/tests/test_content_budget.py b/tests/test_content_budget.py new file mode 100644 index 0000000..da9cbe2 --- /dev/null +++ b/tests/test_content_budget.py @@ -0,0 +1,34 @@ +from knowledge_adapter.content_budget import TRUNCATION_MARKER, truncate_content + + +def test_plain_content_is_truncated_with_explicit_marker() -> None: + limit = len(TRUNCATION_MARKER) + 5 + + result = truncate_content("abcdefghij" * 10, max_chars=limit) + + assert result == f"abcde{TRUNCATION_MARKER}" + assert len(result) == limit + + +def test_truncation_never_splits_markdown_image_node() -> None: + content = ( + "prefix " + "![image](https://assets.example.com/i/550e8400-e29b-41d4-a716-446655440000)" + " trailing text" + ) + limit = len(TRUNCATION_MARKER) + len("prefix ![image](https://assets.") + + result = truncate_content(content, max_chars=limit) + + assert result == f"prefix {TRUNCATION_MARKER}" + assert len(result) <= limit + + +def test_truncation_never_splits_fenced_code_block() -> None: + content = "intro\n```python\nprint('sensitive')\n```\n" + ("after" * 20) + limit = len(TRUNCATION_MARKER) + len("intro\n```python\nprint") + + result = truncate_content(content, max_chars=limit) + + assert result == f"intro\n{TRUNCATION_MARKER}" + assert result.count("```") == 0 diff --git a/tests/test_delivery_artifacts.py b/tests/test_delivery_artifacts.py new file mode 100644 index 0000000..c0f83b9 --- /dev/null +++ b/tests/test_delivery_artifacts.py @@ -0,0 +1,218 @@ +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_dockerfile_is_reproducible_non_root_and_secret_free() -> None: + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + + assert "python:3.12.11-slim-bookworm" in dockerfile + assert "uv sync --frozen --no-dev --no-editable" in dockerfile + assert "USER 10001:10001" in dockerfile + assert "knowledge_adapter.entrypoint:create_production_app" in dockerfile + assert "--factory" in dockerfile + assert "--no-access-log" in dockerfile + assert "--host\", \"0.0.0.0\"" in dockerfile + assert "--port\", \"8080\"" in dockerfile + for forbidden in ( + "WEKNORA_API_KEY=", + "OSS_ACCESS_KEY_ID", + "OSS_ACCESS_KEY_SECRET", + "ALIBABA_CLOUD_ACCESS_KEY", + ): + assert forbidden not in dockerfile + + +def test_compose_is_hardened_and_mounts_only_secret_files() -> None: + compose = (ROOT / "deploy/docker-compose.adapter.yml").read_text(encoding="utf-8") + + for required in ( + '"127.0.0.1:18080:8080"', + 'user: "10001:10001"', + "read_only: true", + "no-new-privileges:true", + "cap_drop:", + "- ALL", + "pids_limit: 256", + "tmpfs:", + "healthcheck:", + "WEKNORA_API_KEY_FILE: /run/secrets/weknora_api_key", + "ASSET_DATABASE_URL_FILE: /run/secrets/asset_database_url", + "WEKNORA_READONLY_DATABASE_URL_FILE: /run/secrets/weknora_readonly_database_url", + 'OSS_CREDENTIAL_MODE: "${OSS_CREDENTIAL_MODE:-static_ak}"', + "OSS_ACCESS_KEY_ID_FILE: /run/secrets/oss_access_key_id", + "OSS_ACCESS_KEY_SECRET_FILE: /run/secrets/oss_access_key_secret", + "- adapter", + "- weknora_upstream", + "weknora_upstream:", + "external: true", + 'name: "${WEKNORA_NETWORK_NAME:?set WEKNORA_NETWORK_NAME}"', + "target: /run/secrets/weknora_api_key", + "target: /run/secrets/asset_database_url", + "target: /run/secrets/weknora_readonly_database_url", + "target: /run/secrets/oss_access_key_id", + "target: /run/secrets/oss_access_key_secret", + "read_only: true", + ): + assert required in compose + for forbidden in ( + "WEKNORA_API_KEY:", + "OSS_ACCESS_KEY_ID:", + "OSS_ACCESS_KEY_SECRET:", + "ALIBABA_CLOUD_ACCESS_KEY", + "/var/run/docker.sock", + "postgres:", + "redis:", + ): + assert forbidden not in compose + + +def test_nginx_config_enforces_tls_methods_limits_and_safe_logging() -> None: + nginx = (ROOT / "deploy/nginx/knowledge-adapter.conf").read_text(encoding="utf-8") + + for required in ( + "listen 443 ssl;", + "server_name assets.example.com;", + "ssl_protocols TLSv1.2 TLSv1.3;", + "access_log off;", + "proxy_pass http://127.0.0.1:18080;", + "proxy_set_header X-Request-ID $adapter_request_id;", + "proxy_intercept_errors off;", + "client_max_body_size 64k;", + "limit_req zone=adapter_retrieval", + "location = /retrieval", + "limit_except POST", + "location ~ ^/i/", + "limit_except GET", + "location = /healthz", + "return 404;", + ): + assert required in nginx + for forbidden in ( + "$request_uri", + "$args", + "$upstream_http_location", + "proxy_set_header X-OSS", + ): + assert forbidden not in nginx + + +def test_ram_policy_is_get_only_and_prefix_scoped() -> None: + policy_path = ROOT / "deploy/ram/knowledge-adapter-oss-read-policy.json" + policy = json.loads(policy_path.read_text(encoding="utf-8")) + + assert policy["Version"] == "1" + assert len(policy["Statement"]) == 1 + statement = policy["Statement"][0] + assert statement == { + "Effect": "Allow", + "Action": ["oss:GetObject"], + "Resource": [ + "acs:oss:*:*:${OSS_BUCKET}/weknora/${WEKNORA_TENANT_ID}/*" + ], + } + serialized = json.dumps(policy, sort_keys=True) + for forbidden in ( + "oss:List", + "oss:Put", + "oss:Delete", + "kms:Decrypt", + ): + assert forbidden not in serialized + + +def test_delivery_docs_and_makefile_cover_required_review_gates() -> None: + architecture = (ROOT / "docs/architecture.md").read_text(encoding="utf-8") + security = (ROOT / "docs/security.md").read_text(encoding="utf-8") + deployment = (ROOT / "docs/deployment.md").read_text(encoding="utf-8") + rotation = (ROOT / "docs/key-rotation.md").read_text(encoding="utf-8") + makefile = (ROOT / "Makefile").read_text(encoding="utf-8") + + for required in ( + "POST /retrieval", + "GET /i/{UUID}", + "GET /healthz", + "WeKnora", + "asset_mapping", + "Private OSS", + "static AK Secret files", + "600", + ): + assert required in architecture + for required in ( + "static AK", + "oss:GetObject", + "Private", + "Presigned URL", + "0400", + "Authorization", + "object_key", + "SSRF", + "## Sources", + ): + assert required in security + for required in ( + "TEST ENVIRONMENT ONLY", + "Production deployment is prohibited", + "docker compose config", + "migration", + "rollback", + "0400", + "10001", + "healthz", + "OSS_ACCESS_KEY_ID_HOST_FILE", + "OSS_ACCESS_KEY_SECRET_HOST_FILE", + ): + assert required in deployment + for required in ( + "DIFY_KEY_HASH_CURRENT", + "DIFY_KEY_HASH_NEXT", + "WeKnora API Key", + "Adapter DB", + "WeKnora read-only DB", + "OSS static AK", + "rollback", + ): + assert required in rotation + for required in ( + "test-slice8:", + "lint:", + "compose-config:", + "check:", + ): + assert required in makefile + for forbidden in ("sudo", " down -v", " compose up"): + assert forbidden not in makefile + + +def test_git_and_docker_context_ignore_secret_material() -> None: + gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines() + dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines() + + for pattern in (".env", "secrets/", "*.secret", "*.key", "*.pem"): + assert pattern in gitignore + assert pattern in dockerignore + + +def test_slice9_audit_report_distinguishes_pass_blocked_and_not_run() -> None: + report = (ROOT / "docs/audit-report-slice9.md").read_text(encoding="utf-8") + + for required in ( + "133 passed", + "All checks passed!", + "WeKnora /health", + "PASS", + "BLOCKED", + "NOT RUN", + "Docker daemon permission", + "nginx binary unavailable", + "ECS RAM Role", + "Dify → Adapter → WeKnora → Adapter DB → OSS", + "Private OSS anonymous denial", + "600-second expiry", + "stable UUID re-signing", + "Production deployment", + ): + assert required in report + assert "Real cloud E2E | PASS" not in report diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py new file mode 100644 index 0000000..247a025 --- /dev/null +++ b/tests/test_entrypoint.py @@ -0,0 +1,22 @@ +from typing import Any + + +def test_entrypoint_builds_production_app_from_environment( + monkeypatch: Any, +) -> None: + import knowledge_adapter.entrypoint as entrypoint + + settings = object() + application = object() + calls: list[object] = [] + + monkeypatch.setattr(entrypoint, "Settings", lambda: settings) + + def build(settings_value: object) -> object: + calls.append(settings_value) + return application + + monkeypatch.setattr(entrypoint, "build_production_app", build) + + assert entrypoint.create_production_app() is application + assert calls == [settings] diff --git a/tests/test_factory.py b/tests/test_factory.py new file mode 100644 index 0000000..3601f9e --- /dev/null +++ b/tests/test_factory.py @@ -0,0 +1,632 @@ +import hashlib +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from pathlib import Path +from unittest.mock import AsyncMock +from urllib.parse import parse_qs, urlparse + +import asyncpg +import httpx +import pytest +from alibabacloud_credentials.models import CredentialModel +from pydantic import SecretStr + +from knowledge_adapter.factory import build_app, build_production_app +from knowledge_adapter.settings import Settings + +DIFY_KEY = "f" * 32 +KB_ID = "3904b40e-278b-4273-a6fa-db6cbc975587" + + +@pytest.fixture(autouse=True) +def stub_weknora_api_key_file(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + Settings, + "read_weknora_api_key", + lambda _settings: SecretStr("test-weknora-key"), + ) + + +@pytest.fixture +async def configured_client() -> AsyncIterator[httpx.AsyncClient]: + settings = Settings( + DIFY_KEY_HASH_CURRENT=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + DIFY_KEY_HASH_NEXT="", + WEKNORA_BASE_URL="http://weknora.internal:8080", + WEKNORA_ALLOWED_KB_IDS=KB_ID, + WEKNORA_TENANT_ID=10008, + WEKNORA_OSS_STORAGE_BACKEND_ID="backend-test", + OSS_REGION="cn-shenzhen", + OSS_BUCKET="private-assets", + OSS_RAM_ROLE_NAME="knowledge-adapter-role", + ASSET_PUBLIC_BASE_URL="https://assets.example.com", + MAX_QUERY_CHARS=5, + ) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"success": True, "data": []}) + + app = build_app(settings, weknora_transport=httpx.MockTransport(handler)) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client + await app.state.weknora_client.aclose() + + +async def test_build_app_wires_settings_and_retrieval_service( + configured_client: httpx.AsyncClient, +) -> None: + response = await configured_client.post( + "/retrieval", + headers={"Authorization": f"Bearer {DIFY_KEY}"}, + json={ + "knowledge_id": KB_ID, + "query": "12345", + "retrieval_setting": {"top_k": 1, "score_threshold": 0}, + }, + ) + + assert response.status_code == 200 + assert response.json() == {"records": []} + + +async def test_build_app_uses_configured_query_limit( + configured_client: httpx.AsyncClient, +) -> None: + response = await configured_client.post( + "/retrieval", + headers={"Authorization": f"Bearer {DIFY_KEY}"}, + json={ + "knowledge_id": KB_ID, + "query": "123456", + "retrieval_setting": {"top_k": 1, "score_threshold": 0}, + }, + ) + + assert response.status_code == 400 + + +async def test_app_lifespan_closes_weknora_client() -> None: + settings = Settings( + DIFY_KEY_HASH_CURRENT=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + DIFY_KEY_HASH_NEXT="", + WEKNORA_BASE_URL="http://weknora.internal:8080", + WEKNORA_ALLOWED_KB_IDS=KB_ID, + WEKNORA_TENANT_ID=10008, + WEKNORA_OSS_STORAGE_BACKEND_ID="backend-test", + OSS_REGION="cn-shenzhen", + OSS_BUCKET="private-assets", + OSS_RAM_ROLE_NAME="knowledge-adapter-role", + ASSET_PUBLIC_BASE_URL="https://assets.example.com", + ) + app = build_app( + settings, + weknora_transport=httpx.MockTransport( + lambda _request: httpx.Response(200, json={"success": True, "data": []}) + ), + ) + close_mock = AsyncMock() + app.state.weknora_client.aclose = close_mock + + async with app.router.lifespan_context(app): + pass + + close_mock.assert_awaited_once_with() + + +async def test_build_app_passes_configured_max_top_k_to_service() -> None: + settings = Settings( + DIFY_KEY_HASH_CURRENT=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + DIFY_KEY_HASH_NEXT="", + WEKNORA_BASE_URL="http://weknora.internal:8080", + WEKNORA_ALLOWED_KB_IDS=KB_ID, + WEKNORA_TENANT_ID=10008, + WEKNORA_OSS_STORAGE_BACKEND_ID="backend-test", + OSS_REGION="cn-shenzhen", + OSS_BUCKET="private-assets", + OSS_RAM_ROLE_NAME="knowledge-adapter-role", + ASSET_PUBLIC_BASE_URL="https://assets.example.com", + MAX_TOP_K=1, + ) + base_chunk = { + "score": 0.9, + "knowledge_base_id": KB_ID, + "knowledge_id": "6f77a258-27ff-4278-a667-8fe242ff8e10", + "knowledge_title": "Manual", + "knowledge_filename": "manual.pdf", + "chunk_type": "text", + "image_info": "", + } + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "success": True, + "data": [ + { + **base_chunk, + "id": "c7f61f89-19ce-45b1-ac60-dbeed5e2a491", + "content": "first", + "chunk_index": 0, + }, + { + **base_chunk, + "id": "09ab721f-7d05-45de-ae0f-dac303276f6d", + "content": "second", + "chunk_index": 1, + }, + ], + }, + ) + + app = build_app(settings, weknora_transport=httpx.MockTransport(handler)) + transport = httpx.ASGITransport(app=app) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/retrieval", + headers={"Authorization": f"Bearer {DIFY_KEY}"}, + json={ + "knowledge_id": KB_ID, + "query": "query", + "retrieval_setting": {"top_k": 100, "score_threshold": 0}, + }, + ) + + assert response.status_code == 200 + assert [record["content"] for record in response.json()["records"]] == ["first"] + + +async def test_build_app_passes_configured_content_budgets_to_service() -> None: + settings = Settings( + DIFY_KEY_HASH_CURRENT=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + DIFY_KEY_HASH_NEXT="", + WEKNORA_BASE_URL="http://weknora.internal:8080", + WEKNORA_ALLOWED_KB_IDS=KB_ID, + WEKNORA_TENANT_ID=10008, + WEKNORA_OSS_STORAGE_BACKEND_ID="backend-test", + OSS_REGION="cn-shenzhen", + OSS_BUCKET="private-assets", + OSS_RAM_ROLE_NAME="knowledge-adapter-role", + ASSET_PUBLIC_BASE_URL="https://assets.example.com", + MAX_CHUNK_CHARS=50, + MAX_TOTAL_CHARS=70, + ) + base_chunk = { + "score": 0.9, + "knowledge_base_id": KB_ID, + "knowledge_id": "6f77a258-27ff-4278-a667-8fe242ff8e10", + "knowledge_title": "Manual", + "knowledge_filename": "manual.pdf", + "chunk_type": "text", + "image_info": "", + } + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "success": True, + "data": [ + { + **base_chunk, + "id": "c7f61f89-19ce-45b1-ac60-dbeed5e2a491", + "content": "a" * 100, + "chunk_index": 0, + }, + { + **base_chunk, + "id": "09ab721f-7d05-45de-ae0f-dac303276f6d", + "content": "b" * 100, + "chunk_index": 1, + }, + ], + }, + ) + + app = build_app(settings, weknora_transport=httpx.MockTransport(handler)) + transport = httpx.ASGITransport(app=app) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/retrieval", + headers={"Authorization": f"Bearer {DIFY_KEY}"}, + json={ + "knowledge_id": KB_ID, + "query": "query", + "retrieval_setting": {"top_k": 5, "score_threshold": 0}, + }, + ) + + records = response.json()["records"] + assert response.status_code == 200 + assert len(records) == 1 + assert len(records[0]["content"]) == 50 + + +class FakeRuntimePool: + def __init__(self, connection: object | None = None) -> None: + self.connection = connection + self.closed = False + + @asynccontextmanager + async def acquire(self) -> AsyncIterator[object]: + if self.connection is None: + raise AssertionError("pool acquire was not expected") + yield self.connection + + async def close(self) -> None: + self.closed = True + + +class RecordingPoolFactory: + def __init__(self, pools: list[FakeRuntimePool] | None = None) -> None: + self.calls: list[dict[str, object]] = [] + self.pools = pools or [FakeRuntimePool(), FakeRuntimePool()] + + async def __call__(self, **kwargs: object) -> FakeRuntimePool: + self.calls.append(kwargs) + return self.pools[len(self.calls) - 1] + + +async def test_production_factory_creates_separate_bounded_pools( + tmp_path: Path, +) -> None: + asset_secret = tmp_path / "asset-db-url" + weknora_secret = tmp_path / "weknora-db-url" + asset_secret.write_text("postgresql://adapter-test\n", encoding="utf-8") + weknora_secret.write_text("postgresql://weknora-readonly-test\n", encoding="utf-8") + settings = Settings( + DIFY_KEY_HASH_CURRENT=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + DIFY_KEY_HASH_NEXT="", + WEKNORA_BASE_URL="http://weknora.internal:8080", + WEKNORA_ALLOWED_KB_IDS=KB_ID, + WEKNORA_TENANT_ID=10008, + WEKNORA_OSS_STORAGE_BACKEND_ID="backend-test", + weknora_readonly_database_url_file=weknora_secret, + asset_database_url_file=asset_secret, + OSS_REGION="cn-shenzhen", + OSS_BUCKET="private-assets", + OSS_RAM_ROLE_NAME="knowledge-adapter-role", + ASSET_PUBLIC_BASE_URL="https://assets.example.com", + ) + pool_factory = RecordingPoolFactory() + app = build_production_app( + settings, + pool_factory=pool_factory, + weknora_transport=httpx.MockTransport( + lambda _request: httpx.Response(200, json={"success": True, "data": []}) + ), + ) + + async with app.router.lifespan_context(app): + assert len(pool_factory.calls) == 2 + asset_call, weknora_call = pool_factory.calls + assert asset_call == { + "dsn": "postgresql://adapter-test", + "min_size": 1, + "max_size": 5, + "command_timeout": 5, + "server_settings": {"statement_timeout": "5000"}, + } + assert weknora_call == { + "dsn": "postgresql://weknora-readonly-test", + "min_size": 1, + "max_size": 2, + "command_timeout": 1, + "server_settings": { + "default_transaction_read_only": "on", + "statement_timeout": "1000", + }, + } + assert not any(pool.closed for pool in pool_factory.pools) + + assert all(pool.closed for pool in pool_factory.pools) + + +class RuntimeAdapterConnection: + def __init__(self) -> None: + self.queries: list[tuple[str, tuple[object, ...]]] = [] + + async def fetchrow(self, query: str, *args: object) -> dict[str, object] | None: + self.queries.append((query, args)) + if query.lstrip().startswith("SELECT"): + return None + return { + "asset_id": "c7f61f89-19ce-45b1-ac60-dbeed5e2a491", + "source_ref": args[1], + "object_key": args[2], + "content_type": args[3], + "created_at": datetime(2026, 8, 24, tzinfo=UTC), + "status": "active", + } + + +class RuntimeWeKnoraConnection: + def __init__(self) -> None: + self.readonly_transactions: list[bool] = [] + self.fetch_calls: list[tuple[str, tuple[object, ...]]] = [] + + @asynccontextmanager + async def transaction(self, *, readonly: bool) -> AsyncIterator[None]: + self.readonly_transactions.append(readonly) + yield + + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + self.fetch_calls.append((query, args)) + return [ + { + "tenant_id": 10008, + "storage_backend_id": "backend-test", + "provider": "oss", + "physical_path": ( + "storage://backend-test/" + "oss://private-assets/weknora/10008/assets/image.png" + ), + "mime_type": "image/png", + } + ] + + +async def test_production_factory_wires_first_image_resolution_flow( + tmp_path: Path, +) -> None: + source_ref = "resource://AbCdEfGhIjKlMnOpQrSt_1" + asset_secret = tmp_path / "asset-db-url" + weknora_secret = tmp_path / "weknora-db-url" + asset_secret.write_text("postgresql://adapter-test\n", encoding="utf-8") + weknora_secret.write_text("postgresql://weknora-readonly-test\n", encoding="utf-8") + settings = Settings( + DIFY_KEY_HASH_CURRENT=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + DIFY_KEY_HASH_NEXT="", + WEKNORA_BASE_URL="http://weknora.internal:8080", + WEKNORA_ALLOWED_KB_IDS=KB_ID, + WEKNORA_TENANT_ID=10008, + WEKNORA_OSS_STORAGE_BACKEND_ID="backend-test", + weknora_readonly_database_url_file=weknora_secret, + asset_database_url_file=asset_secret, + OSS_REGION="cn-shenzhen", + OSS_BUCKET="private-assets", + OSS_RAM_ROLE_NAME="knowledge-adapter-role", + ASSET_PUBLIC_BASE_URL="https://assets.example.com", + ) + adapter_connection = RuntimeAdapterConnection() + weknora_connection = RuntimeWeKnoraConnection() + pool_factory = RecordingPoolFactory( + [FakeRuntimePool(adapter_connection), FakeRuntimePool(weknora_connection)] + ) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={ + "success": True, + "data": [ + { + "id": "09ab721f-7d05-45de-ae0f-dac303276f6d", + "content": f"![diagram]({source_ref})", + "score": 0.9, + "knowledge_base_id": KB_ID, + "knowledge_id": "6f77a258-27ff-4278-a667-8fe242ff8e10", + "knowledge_title": "Manual", + "knowledge_filename": "manual.pdf", + "chunk_index": 0, + "chunk_type": "text", + "image_info": "[]", + } + ], + }, + ) + + app = build_production_app( + settings, + pool_factory=pool_factory, + weknora_transport=httpx.MockTransport(handler), + ) + transport = httpx.ASGITransport(app=app) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/retrieval", + headers={"Authorization": f"Bearer {DIFY_KEY}"}, + json={ + "knowledge_id": KB_ID, + "query": "query", + "retrieval_setting": {"top_k": 1, "score_threshold": 0}, + }, + ) + + assert response.status_code == 200 + assert response.json()["records"][0]["content"] == ( + "![diagram](https://assets.example.com/i/" + "c7f61f89-19ce-45b1-ac60-dbeed5e2a491)" + ) + assert source_ref not in response.text + assert "weknora/10008/assets/image.png" not in response.text + assert len(adapter_connection.queries) == 2 + assert weknora_connection.readonly_transactions == [True] + assert len(weknora_connection.fetch_calls) == 1 + + +async def test_production_factory_defaults_to_asyncpg_pool_factory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + asset_secret = tmp_path / "asset-db-url" + weknora_secret = tmp_path / "weknora-db-url" + asset_secret.write_text("postgresql://adapter-test\n", encoding="utf-8") + weknora_secret.write_text("postgresql://weknora-readonly-test\n", encoding="utf-8") + settings = Settings( + DIFY_KEY_HASH_CURRENT=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + DIFY_KEY_HASH_NEXT="", + WEKNORA_BASE_URL="http://weknora.internal:8080", + WEKNORA_ALLOWED_KB_IDS=KB_ID, + WEKNORA_TENANT_ID=10008, + WEKNORA_OSS_STORAGE_BACKEND_ID="backend-test", + weknora_readonly_database_url_file=weknora_secret, + asset_database_url_file=asset_secret, + OSS_REGION="cn-shenzhen", + OSS_BUCKET="private-assets", + OSS_RAM_ROLE_NAME="knowledge-adapter-role", + ASSET_PUBLIC_BASE_URL="https://assets.example.com", + ) + pool_factory = RecordingPoolFactory() + monkeypatch.setattr(asyncpg, "create_pool", pool_factory) + + app = build_production_app( + settings, + weknora_transport=httpx.MockTransport( + lambda _request: httpx.Response(200, json={"success": True, "data": []}) + ), + ) + async with app.router.lifespan_context(app): + pass + + assert len(pool_factory.calls) == 2 + assert all(pool.closed for pool in pool_factory.pools) + + +class RuntimeAssetLookupConnection(RuntimeAdapterConnection): + async def fetchrow(self, query: str, *args: object) -> dict[str, object] | None: + self.queries.append((query, args)) + return { + "asset_id": "c7f61f89-19ce-45b1-ac60-dbeed5e2a491", + "source_ref": "resource://AbCdEfGhIjKlMnOpQrSt_1", + "object_key": "weknora/10008/assets/image.png", + "content_type": "image/png", + "created_at": datetime(2026, 8, 24, tzinfo=UTC), + "status": "active", + } + + +class FactoryCredentialClient: + def get_credential(self) -> CredentialModel: + return CredentialModel( + access_key_id="test-access-key-id", + access_key_secret="test-access-key-secret", + security_token="test-security-token", + type="ecs_ram_role", + ) + + +async def test_production_factory_wires_anonymous_asset_redirect( + tmp_path: Path, +) -> None: + asset_secret = tmp_path / "asset-db-url" + weknora_secret = tmp_path / "weknora-db-url" + asset_secret.write_text("postgresql://adapter-test\n", encoding="utf-8") + weknora_secret.write_text("postgresql://weknora-readonly-test\n", encoding="utf-8") + settings = Settings( + DIFY_KEY_HASH_CURRENT=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + DIFY_KEY_HASH_NEXT="", + WEKNORA_BASE_URL="http://weknora.internal:8080", + WEKNORA_ALLOWED_KB_IDS=KB_ID, + WEKNORA_TENANT_ID=10008, + WEKNORA_OSS_STORAGE_BACKEND_ID="backend-test", + weknora_readonly_database_url_file=weknora_secret, + asset_database_url_file=asset_secret, + OSS_REGION="cn-shenzhen", + OSS_BUCKET="private-assets", + OSS_RAM_ROLE_NAME="knowledge-adapter-role", + ASSET_PUBLIC_BASE_URL="https://assets.example.com", + ) + adapter_connection = RuntimeAssetLookupConnection() + pool_factory = RecordingPoolFactory( + [FakeRuntimePool(adapter_connection), FakeRuntimePool(RuntimeWeKnoraConnection())] + ) + role_names: list[str] = [] + + def credential_client_factory(role_name: str) -> FactoryCredentialClient: + role_names.append(role_name) + return FactoryCredentialClient() + + app = build_production_app( + settings, + pool_factory=pool_factory, + credential_client_factory=credential_client_factory, + weknora_transport=httpx.MockTransport( + lambda _request: httpx.Response(200, json={"success": True, "data": []}) + ), + ) + transport = httpx.ASGITransport(app=app) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + follow_redirects=False, + ) as client: + response = await client.get( + "/i/c7f61f89-19ce-45b1-ac60-dbeed5e2a491" + ) + + location = urlparse(response.headers["location"]) + query = parse_qs(location.query) + assert response.status_code == 302 + assert location.scheme == "https" + assert location.hostname == "private-assets.oss-cn-shenzhen.aliyuncs.com" + assert query["x-oss-signature-version"] == ["OSS4-HMAC-SHA256"] + assert query["x-oss-expires"] == ["600"] + assert role_names == ["knowledge-adapter-role"] + assert len(adapter_connection.queries) == 1 + + +async def test_production_factory_uses_static_ak_secret_files( + tmp_path: Path, +) -> None: + asset_secret = tmp_path / "asset-db-url" + weknora_secret = tmp_path / "weknora-db-url" + access_key_id_file = tmp_path / "oss-access-key-id" + access_key_secret_file = tmp_path / "oss-access-key-secret" + asset_secret.write_text("postgresql://adapter-test\n", encoding="utf-8") + weknora_secret.write_text("postgresql://weknora-readonly-test\n", encoding="utf-8") + access_key_id_file.write_text("test-access-key-id\n", encoding="utf-8") + access_key_secret_file.write_text("test-access-key-secret\n", encoding="utf-8") + settings = Settings( + DIFY_KEY_HASH_CURRENT=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + DIFY_KEY_HASH_NEXT="", + WEKNORA_BASE_URL="http://weknora.internal:8080", + WEKNORA_ALLOWED_KB_IDS=KB_ID, + WEKNORA_TENANT_ID=10008, + WEKNORA_OSS_STORAGE_BACKEND_ID="backend-test", + weknora_readonly_database_url_file=weknora_secret, + asset_database_url_file=asset_secret, + OSS_REGION="cn-shenzhen", + OSS_BUCKET="private-assets", + OSS_CREDENTIAL_MODE="static_ak", + OSS_ACCESS_KEY_ID_FILE=access_key_id_file, + OSS_ACCESS_KEY_SECRET_FILE=access_key_secret_file, + ASSET_PUBLIC_BASE_URL="https://assets.example.com", + ) + adapter_connection = RuntimeAssetLookupConnection() + pool_factory = RecordingPoolFactory( + [FakeRuntimePool(adapter_connection), FakeRuntimePool(RuntimeWeKnoraConnection())] + ) + + def unexpected_ram_role_factory(_role_name: str) -> FactoryCredentialClient: + raise AssertionError("RAM role factory must not be used in static AK mode") + + app = build_production_app( + settings, + pool_factory=pool_factory, + credential_client_factory=unexpected_ram_role_factory, + weknora_transport=httpx.MockTransport( + lambda _request: httpx.Response(200, json={"success": True, "data": []}) + ), + ) + transport = httpx.ASGITransport(app=app) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient( + transport=transport, + base_url="http://test", + follow_redirects=False, + ) as client: + response = await client.get("/i/c7f61f89-19ce-45b1-ac60-dbeed5e2a491") + + location = urlparse(response.headers["location"]) + query = parse_qs(location.query) + assert response.status_code == 302 + assert location.hostname == "private-assets.oss-cn-shenzhen.aliyuncs.com" + assert query["x-oss-signature-version"] == ["OSS4-HMAC-SHA256"] + assert "x-oss-security-token" not in query diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..14d7941 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,27 @@ +from collections.abc import AsyncIterator + +import httpx +import pytest + +from knowledge_adapter.app import create_app + + +@pytest.fixture +async def client() -> AsyncIterator[httpx.AsyncClient]: + transport = httpx.ASGITransport(app=create_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as value: + yield value + + +async def test_healthz_reports_only_adapter_liveness(client: httpx.AsyncClient) -> None: + response = await client.get("/healthz") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + assert set(response.headers) >= {"content-type"} + + +async def test_api_documentation_is_not_exposed(client: httpx.AsyncClient) -> None: + for path in ("/docs", "/redoc", "/openapi.json"): + response = await client.get(path) + assert response.status_code == 404 diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..48004d7 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,41 @@ +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +UP_MIGRATION = PROJECT_ROOT / "migrations" / "0001_asset_mapping.up.sql" +DOWN_MIGRATION = PROJECT_ROOT / "migrations" / "0001_asset_mapping.down.sql" + + +def normalized_sql(path: Path) -> str: + return " ".join(path.read_text(encoding="utf-8").lower().split()) + + +def test_asset_mapping_up_migration_has_minimal_idempotent_schema() -> None: + assert UP_MIGRATION.exists(), "asset_mapping up migration is missing" + + sql = normalized_sql(UP_MIGRATION) + assert "create table if not exists asset_mapping" in sql + assert "asset_id uuid primary key" in sql + assert "source_ref text not null" in sql + assert "object_key text not null" in sql + assert "content_type text" in sql + assert "created_at timestamptz not null default current_timestamp" in sql + assert "status text not null default 'active'" in sql + assert "check (status in ('active', 'disabled'))" in sql + assert "create unique index if not exists" in sql + assert "on asset_mapping (source_ref)" in sql + + for forbidden_identifier in ( + "presigned_url", + "access_key", + "secret_key", + "authorization", + "bucket", + ): + assert forbidden_identifier not in sql + + +def test_asset_mapping_down_migration_is_idempotent_and_scoped() -> None: + assert DOWN_MIGRATION.exists(), "asset_mapping down migration is missing" + + sql = normalized_sql(DOWN_MIGRATION) + assert sql == "drop table if exists asset_mapping;" diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 0000000..2cb7429 --- /dev/null +++ b/tests/test_observability.py @@ -0,0 +1,258 @@ +import json +import logging +from hashlib import sha256 +from uuid import UUID + +import httpx +from _pytest.logging import LogCaptureFixture +from fastapi import HTTPException +from pydantic import SecretStr + +from knowledge_adapter.app import create_app +from knowledge_adapter.auth import DifyKeyAuthenticator +from knowledge_adapter.service import RetrievalService +from knowledge_adapter.weknora_client import WeKnoraClient + + +async def test_response_contains_generated_request_id() -> None: + transport = httpx.ASGITransport(app=create_app()) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/healthz") + + request_id = response.headers["x-request-id"] + parsed = UUID(request_id) + assert parsed.version == 4 + assert str(parsed) == request_id + assert response.json() == {"status": "ok"} + + +async def test_response_preserves_safe_inbound_request_id() -> None: + transport = httpx.ASGITransport(app=create_app()) + inbound_request_id = "0123456789abcdef0123456789abcdef" + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/healthz", + headers={"X-Request-ID": inbound_request_id}, + ) + + assert response.headers["x-request-id"] == inbound_request_id + + +async def test_response_replaces_unsafe_inbound_request_id() -> None: + transport = httpx.ASGITransport(app=create_app()) + unsafe_request_id = "authorization bearer secret value" + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/healthz", + headers={"X-Request-ID": unsafe_request_id}, + ) + + request_id = response.headers["x-request-id"] + assert UUID(request_id).version == 4 + assert unsafe_request_id not in request_id + + +async def test_request_log_is_structured_and_uses_route_template( + caplog: LogCaptureFixture, +) -> None: + request_id = "request-123" + transport = httpx.ASGITransport(app=create_app()) + + with caplog.at_level(logging.INFO, logger="knowledge_adapter.http"): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/healthz?api_key=must-not-appear", + headers={"X-Request-ID": request_id}, + ) + + records = [ + record + for record in caplog.records + if record.name == "knowledge_adapter.http" + ] + assert len(records) == 1 + event = json.loads(records[0].getMessage()) + assert set(event) == { + "duration_ms", + "event", + "method", + "request_id", + "route", + "status_code", + } + assert event["event"] == "http_request" + assert event["request_id"] == request_id + assert event["method"] == "GET" + assert event["route"] == "/healthz" + assert event["status_code"] == response.status_code == 200 + assert isinstance(event["duration_ms"], int) + assert event["duration_ms"] >= 0 + assert "must-not-appear" not in records[0].getMessage() + + +class ExplodingRetrievalService: + async def retrieve(self, _request: object) -> dict[str, object]: + raise RuntimeError( + "Authorization: Bearer server-secret " + "resource://AbCdEfGhIjKlMnOpQrSt_1 " + "object_key=weknora/10008/private.png " + "https://private-assets.oss-cn-shenzhen.aliyuncs.com/private.png?" + "x-oss-signature=server-secret" + ) + + +async def test_unhandled_exception_is_safe_traceable_and_redacted( + caplog: LogCaptureFixture, +) -> None: + dify_key = "client-secret" + request_id = "request-error-123" + authenticator = DifyKeyAuthenticator( + current_hash=sha256(dify_key.encode()).hexdigest() + ) + app = create_app( + authenticator=authenticator, + retrieval_service=ExplodingRetrievalService(), # type: ignore[arg-type] + ) + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + + with caplog.at_level(logging.INFO, logger="knowledge_adapter.http"): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/retrieval", + headers={ + "Authorization": f"Bearer {dify_key}", + "X-Request-ID": request_id, + }, + json={ + "knowledge_id": "3904b40e-278b-4273-a6fa-db6cbc975587", + "query": "sensitive customer knowledge", + "retrieval_setting": {"top_k": 1, "score_threshold": 0}, + }, + ) + + assert response.status_code == 500 + assert response.headers["x-request-id"] == request_id + assert response.json() == {"detail": "internal server error"} + adapter_log = "\n".join( + record.getMessage() + for record in caplog.records + if record.name.startswith("knowledge_adapter") + ) + event = json.loads(adapter_log) + assert event["route"] == "/retrieval" + assert event["status_code"] == 500 + assert event["error_code"] == "internal_error" + for forbidden in ( + dify_key, + "server-secret", + "sensitive customer knowledge", + "resource://", + "object_key", + "private.png", + "x-oss-signature", + ): + assert forbidden not in response.text + assert forbidden not in adapter_log + + +async def test_unknown_route_uses_safe_uniform_404_without_logging_raw_path( + caplog: LogCaptureFixture, +) -> None: + request_id = "request-not-found" + raw_path_secret = "unknown-secret-path" + transport = httpx.ASGITransport(app=create_app()) + + with caplog.at_level(logging.INFO, logger="knowledge_adapter.http"): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + f"/{raw_path_secret}?authorization=server-secret", + headers={"X-Request-ID": request_id}, + ) + + assert response.status_code == 404 + assert response.headers["x-request-id"] == request_id + assert response.json() == {"detail": "not found"} + adapter_log = "\n".join( + record.getMessage() + for record in caplog.records + if record.name == "knowledge_adapter.http" + ) + event = json.loads(adapter_log) + assert event["route"] == "" + assert raw_path_secret not in adapter_log + assert "server-secret" not in adapter_log + + +async def test_unknown_http_exception_detail_and_headers_are_redacted() -> None: + app = create_app() + + @app.get("/test-only-http-error") + async def test_only_http_error() -> None: + raise HTTPException( + status_code=418, + detail="Authorization Bearer server-secret resource://private", + headers={"X-Internal-Secret": "server-secret"}, + ) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/test-only-http-error") + + assert response.status_code == 418 + assert response.json() == {"detail": "request failed"} + assert "x-internal-secret" not in response.headers + assert "server-secret" not in response.text + assert "resource://" not in response.text + + +async def test_safe_request_id_is_forwarded_to_weknora() -> None: + dify_key = "client-secret" + request_id = "request-upstream-123" + knowledge_base_id = UUID("3904b40e-278b-4273-a6fa-db6cbc975587") + upstream_headers: list[httpx.Headers] = [] + + def handler(request: httpx.Request) -> httpx.Response: + upstream_headers.append(request.headers) + return httpx.Response(200, json={"success": True, "data": []}) + + weknora_client = WeKnoraClient( + base_url="http://weknora.internal:8080", + api_key=SecretStr("weknora-secret"), + timeout_seconds=15, + transport=httpx.MockTransport(handler), + ) + service = RetrievalService( + search_client=weknora_client, + allowed_kb_ids=frozenset({knowledge_base_id}), + ) + authenticator = DifyKeyAuthenticator( + current_hash=sha256(dify_key.encode()).hexdigest() + ) + app = create_app(authenticator=authenticator, retrieval_service=service) + transport = httpx.ASGITransport(app=app) + + try: + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/retrieval", + headers={ + "Authorization": f"Bearer {dify_key}", + "X-Request-ID": request_id, + }, + json={ + "knowledge_id": str(knowledge_base_id), + "query": "query", + "retrieval_setting": {"top_k": 1, "score_threshold": 0}, + }, + ) + finally: + await weknora_client.aclose() + + assert response.status_code == 200 + assert len(upstream_headers) == 1 + assert upstream_headers[0]["x-request-id"] == request_id + assert upstream_headers[0]["x-api-key"] == "weknora-secret" + assert "authorization" not in upstream_headers[0] diff --git a/tests/test_oss_signer.py b/tests/test_oss_signer.py new file mode 100644 index 0000000..8ca83ff --- /dev/null +++ b/tests/test_oss_signer.py @@ -0,0 +1,150 @@ +from typing import ClassVar +from urllib.parse import parse_qs, unquote, urlparse + +import pytest +from alibabacloud_credentials.models import Config, CredentialModel +from pydantic import SecretStr + +import knowledge_adapter.oss_signer as oss_signer_module +from knowledge_adapter.asset_redirect import AssetSignerError +from knowledge_adapter.oss_signer import ( + AlibabaOssV4Signer, + create_ecs_ram_role_credential_client, +) + + +class RecordingCredentialClient: + def __init__(self) -> None: + self.calls = 0 + + def get_credential(self) -> CredentialModel: + self.calls += 1 + return CredentialModel( + access_key_id="test-access-key-id", + access_key_secret="test-access-key-secret", + security_token="test-security-token", + type="ecs_ram_role", + ) + + +async def test_oss_signer_generates_v4_temporary_get_url() -> None: + credential_client = RecordingCredentialClient() + signer = AlibabaOssV4Signer( + credential_client=credential_client, + region="cn-shenzhen", + bucket="private-assets", + ) + + signed_url = await signer.sign_get( + object_key="weknora/10008/assets/image.png", + expires_seconds=600, + ) + + parsed = urlparse(signed_url) + query = parse_qs(parsed.query) + assert parsed.scheme == "https" + assert parsed.hostname == "private-assets.oss-cn-shenzhen.aliyuncs.com" + assert unquote(parsed.path) == "/weknora/10008/assets/image.png" + assert query["x-oss-signature-version"] == ["OSS4-HMAC-SHA256"] + assert query["x-oss-expires"] == ["600"] + assert query["x-oss-security-token"] == ["test-security-token"] + assert credential_client.calls == 1 + + +@pytest.mark.parametrize( + ("object_key", "expires_seconds"), + [ + ("../other-tenant/secret.png", 600), + ("weknora/10008/assets/image.png", 601), + ("weknora/10008/assets/image.png", 60), + ], +) +async def test_oss_signer_rejects_unsafe_key_or_ttl_before_credentials( + object_key: str, + expires_seconds: int, +) -> None: + credential_client = RecordingCredentialClient() + signer = AlibabaOssV4Signer( + credential_client=credential_client, + region="cn-shenzhen", + bucket="private-assets", + ) + + with pytest.raises(AssetSignerError, match=r"^asset signing failed$"): + await signer.sign_get( + object_key=object_key, + expires_seconds=expires_seconds, + ) + + assert credential_client.calls == 0 + + +class UnsafeBucket: + def sign_url( + self, + method: str, + object_key: str, + expires_seconds: int, + ) -> str: + return "http://127.0.0.1/internal?x-oss-signature-version=OSS4-HMAC-SHA256" + + +async def test_oss_signer_rejects_untrusted_signed_url() -> None: + signer = AlibabaOssV4Signer( + credential_client=RecordingCredentialClient(), + region="cn-shenzhen", + bucket="private-assets", + ) + signer._bucket = UnsafeBucket() + + with pytest.raises(AssetSignerError, match=r"^asset signing failed$"): + await signer.sign_get( + object_key="weknora/10008/assets/image.png", + expires_seconds=600, + ) + + +class RecordingCredentialsClientFactory: + configs: ClassVar[list[Config]] = [] + + def __init__(self, config: Config) -> None: + self.configs.append(config) + + +async def test_ecs_ram_role_client_uses_imdsv2_without_static_keys( + monkeypatch: pytest.MonkeyPatch, +) -> None: + RecordingCredentialsClientFactory.configs = [] + monkeypatch.setattr( + oss_signer_module, + "CredentialsClient", + RecordingCredentialsClientFactory, + ) + + create_ecs_ram_role_credential_client("knowledge-adapter-role") + + assert len(RecordingCredentialsClientFactory.configs) == 1 + config = RecordingCredentialsClientFactory.configs[0] + assert config.type == "ecs_ram_role" + assert config.role_name == "knowledge-adapter-role" + assert config.enable_imds_v2 is True + assert config.disable_imds_v1 is True + assert config.connect_timeout == 1000 + assert config.timeout == 1000 + assert not config.access_key_id + assert not config.access_key_secret + + +def test_static_ak_client_uses_secret_values_without_security_token() -> None: + client = oss_signer_module.create_static_ak_credential_client( + SecretStr("test-access-key-id"), + SecretStr("test-access-key-secret"), + ) + + credential = client.get_credential() + + assert credential.access_key_id == "test-access-key-id" + assert credential.access_key_secret == "test-access-key-secret" + assert not credential.security_token + assert credential.type == "access_key" + assert "test-access-key-secret" not in repr(client) diff --git a/tests/test_resource_locator.py b/tests/test_resource_locator.py new file mode 100644 index 0000000..74034aa --- /dev/null +++ b/tests/test_resource_locator.py @@ -0,0 +1,110 @@ +import pytest + +from knowledge_adapter.resource_locator import ( + InvalidResourceLocator, + InvalidResourceReference, + parse_oss_object_key, + parse_resource_reference, +) + +VALID_HANDLE = "AbCdEfGhIjKlMnOpQrSt_1" + + +def test_parse_resource_reference_accepts_only_canonical_handle() -> None: + assert parse_resource_reference(f"resource://{VALID_HANDLE}") == VALID_HANDLE + + invalid_values = [ + "resource://short", + "resource://AbCdEfGhIjKlMnOpQrSt/1", + "resource://AbCdEfGhIjKlMnOpQrSt%1", + "https://example.com/image.png", + "http://example.com/image.png", + ] + for value in invalid_values: + with pytest.raises(InvalidResourceReference): + parse_resource_reference(value) + + +def test_parse_oss_object_key_accepts_matching_wrapped_locator() -> None: + object_key = parse_oss_object_key( + provider="oss", + physical_path=( + "storage://backend-test/" + "oss://private-assets/weknora/10008/assets/image.png" + ), + storage_backend_id="backend-test", + expected_storage_backend_id="backend-test", + expected_bucket="private-assets", + ) + + assert object_key == "weknora/10008/assets/image.png" + + +@pytest.mark.parametrize( + ("provider", "physical_path", "storage_backend_id"), + [ + ( + "oss", + "storage://backend-test/oss://private-assets/../secret.png", + "backend-test", + ), + ( + "oss", + "storage://backend-test/oss://private-assets//absolute.png", + "backend-test", + ), + ( + "oss", + "storage://backend-test/oss://private-assets/folder\\image.png", + "backend-test", + ), + ( + "oss", + "storage://backend-test/oss://private-assets/image.png\x00ignored", + "backend-test", + ), + ( + "oss", + "storage://backend-test/oss://private-assets/image.png?signature=bad", + "backend-test", + ), + ( + "cos", + "storage://backend-test/oss://private-assets/image.png", + "backend-test", + ), + ( + "oss", + "storage://other/oss://private-assets/image.png", + "backend-test", + ), + ( + "oss", + "storage://backend-test/oss://other-bucket/image.png", + "backend-test", + ), + ( + "oss", + "https://example.com/image.png", + "backend-test", + ), + ( + "oss", + "storage://backend-test/oss://private-assets/" + ("x" * 1024), + "backend-test", + ), + ], +) +def test_parse_oss_object_key_rejects_locator_outside_fixed_boundary( + provider: str, + physical_path: str, + storage_backend_id: str, +) -> None: + with pytest.raises(InvalidResourceLocator): + parse_oss_object_key( + provider=provider, + physical_path=physical_path, + storage_backend_id=storage_backend_id, + expected_storage_backend_id="backend-test", + expected_bucket="private-assets", + ) diff --git a/tests/test_retrieval.py b/tests/test_retrieval.py new file mode 100644 index 0000000..a233f5f --- /dev/null +++ b/tests/test_retrieval.py @@ -0,0 +1,269 @@ +import hashlib +from collections.abc import AsyncIterator +from uuid import UUID + +import httpx +import pytest + +from knowledge_adapter.app import create_app +from knowledge_adapter.asset_content_processor import ContentProcessingError +from knowledge_adapter.auth import DifyKeyAuthenticator +from knowledge_adapter.service import RetrievalService +from knowledge_adapter.weknora_client import WeKnoraBadResponseError, WeKnoraUnavailableError + +DIFY_KEY = "d" * 32 +ALLOWED_KB_ID = "3904b40e-278b-4273-a6fa-db6cbc975587" + + +@pytest.fixture +async def client() -> AsyncIterator[httpx.AsyncClient]: + authenticator = DifyKeyAuthenticator( + current_hash=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + ) + transport = httpx.ASGITransport(app=create_app(authenticator=authenticator)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as value: + yield value + + +def auth_headers() -> dict[str, str]: + return {"Authorization": f"Bearer {DIFY_KEY}"} + + +def retrieval_payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "knowledge_id": ALLOWED_KB_ID, + "query": "test query", + "retrieval_setting": {"top_k": 3, "score_threshold": 0.5}, + } + payload.update(overrides) + return payload + + +class RecordingSearchClient: + def __init__(self) -> None: + self.calls: list[tuple[str, UUID]] = [] + + async def search(self, *, query: str, knowledge_base_id: UUID) -> list[dict[str, object]]: + self.calls.append((query, knowledge_base_id)) + return [] + + +class UnavailableSearchClient: + async def search(self, *, query: str, knowledge_base_id: UUID) -> list[dict[str, object]]: + raise WeKnoraUnavailableError("WeKnora unavailable") + + +class BadResponseSearchClient: + async def search(self, *, query: str, knowledge_base_id: UUID) -> list[dict[str, object]]: + raise WeKnoraBadResponseError("invalid WeKnora response") + + +class OneChunkSearchClient: + async def search(self, *, query: str, knowledge_base_id: UUID) -> list[dict[str, object]]: + return [ + { + "id": "09ab721f-7d05-45de-ae0f-dac303276f6d", + "content": "safe text", + "score": 0.9, + "knowledge_base_id": str(knowledge_base_id), + "knowledge_id": "6f77a258-27ff-4278-a667-8fe242ff8e10", + "knowledge_title": "Manual", + "knowledge_filename": "manual.pdf", + "chunk_index": 0, + "chunk_type": "text", + "image_info": "", + } + ] + + +class FailingContentProcessor: + async def process(self, chunk: object) -> str: + raise ContentProcessingError("content processing failed") + + +async def test_valid_request_reaches_retrieval_service() -> None: + search_client = RecordingSearchClient() + service = RetrievalService( + search_client=search_client, + allowed_kb_ids=frozenset({UUID(ALLOWED_KB_ID)}), + ) + authenticator = DifyKeyAuthenticator( + current_hash=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + ) + transport = httpx.ASGITransport( + app=create_app(authenticator=authenticator, retrieval_service=service) + ) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as app_client: + response = await app_client.post( + "/retrieval", + headers=auth_headers(), + json=retrieval_payload(query=" normalized query "), + ) + + assert response.status_code == 200 + assert response.json() == {"records": []} + assert search_client.calls == [("normalized query", UUID(ALLOWED_KB_ID))] + + +async def test_disallowed_kb_returns_400_without_calling_upstream() -> None: + search_client = RecordingSearchClient() + service = RetrievalService( + search_client=search_client, + allowed_kb_ids=frozenset({UUID(ALLOWED_KB_ID)}), + ) + authenticator = DifyKeyAuthenticator( + current_hash=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + ) + transport = httpx.ASGITransport( + app=create_app(authenticator=authenticator, retrieval_service=service), + raise_app_exceptions=False, + ) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as app_client: + response = await app_client.post( + "/retrieval", + headers=auth_headers(), + json=retrieval_payload(knowledge_id="7fa6e8d2-05d8-4e74-b98c-d8591b680db9"), + ) + + assert response.status_code == 400 + assert response.json() == {"detail": "invalid request"} + assert search_client.calls == [] + + +async def test_weknora_timeout_returns_sanitized_503() -> None: + service = RetrievalService( + search_client=UnavailableSearchClient(), + allowed_kb_ids=frozenset({UUID(ALLOWED_KB_ID)}), + ) + authenticator = DifyKeyAuthenticator( + current_hash=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + ) + transport = httpx.ASGITransport( + app=create_app(authenticator=authenticator, retrieval_service=service), + raise_app_exceptions=False, + ) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as app_client: + response = await app_client.post( + "/retrieval", + headers=auth_headers(), + json=retrieval_payload(), + ) + + assert response.status_code == 503 + assert response.json() == {"detail": "upstream unavailable"} + + +async def test_weknora_bad_response_returns_sanitized_502() -> None: + service = RetrievalService( + search_client=BadResponseSearchClient(), + allowed_kb_ids=frozenset({UUID(ALLOWED_KB_ID)}), + ) + authenticator = DifyKeyAuthenticator( + current_hash=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + ) + transport = httpx.ASGITransport( + app=create_app(authenticator=authenticator, retrieval_service=service), + raise_app_exceptions=False, + ) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as app_client: + response = await app_client.post( + "/retrieval", + headers=auth_headers(), + json=retrieval_payload(), + ) + + assert response.status_code == 502 + assert response.json() == {"detail": "invalid upstream response"} + + +async def test_content_processing_failure_returns_sanitized_502() -> None: + service = RetrievalService( + search_client=OneChunkSearchClient(), + allowed_kb_ids=frozenset({UUID(ALLOWED_KB_ID)}), + content_processor=FailingContentProcessor(), + ) + authenticator = DifyKeyAuthenticator( + current_hash=hashlib.sha256(DIFY_KEY.encode()).hexdigest(), + ) + transport = httpx.ASGITransport( + app=create_app(authenticator=authenticator, retrieval_service=service), + raise_app_exceptions=False, + ) + + async with httpx.AsyncClient(transport=transport, base_url="http://test") as app_client: + response = await app_client.post( + "/retrieval", + headers=auth_headers(), + json=retrieval_payload(), + ) + + assert response.status_code == 502 + assert response.json() == {"detail": "content processing failed"} + + +async def test_empty_query_returns_400_without_echoing_query(client: httpx.AsyncClient) -> None: + response = await client.post( + "/retrieval", + headers=auth_headers(), + json=retrieval_payload(query=" "), + ) + + assert response.status_code == 400 + assert "query" not in response.text.lower() + + +async def test_invalid_json_returns_400(client: httpx.AsyncClient) -> None: + response = await client.post( + "/retrieval", + headers={**auth_headers(), "Content-Type": "application/json"}, + content=b'{"query":', + ) + + assert response.status_code == 400 + assert response.json() == {"detail": "invalid request"} + + +async def test_query_over_maximum_returns_400_without_echoing_content( + client: httpx.AsyncClient, +) -> None: + oversized_query = "敏" * 2001 + + response = await client.post( + "/retrieval", + headers=auth_headers(), + json=retrieval_payload(query=oversized_query), + ) + + assert response.status_code == 400 + assert oversized_query not in response.text + + +async def test_user_cannot_supply_object_key_or_upstream_parameters( + client: httpx.AsyncClient, +) -> None: + response = await client.post( + "/retrieval", + headers=auth_headers(), + json=retrieval_payload(object_key="private/path.png"), + ) + + assert response.status_code == 400 + + +async def test_nonempty_metadata_conditions_are_rejected(client: httpx.AsyncClient) -> None: + response = await client.post( + "/retrieval", + headers=auth_headers(), + json=retrieval_payload( + metadata_condition={ + "logical_operator": "and", + "conditions": [{"name": "product", "value": "secret"}], + } + ), + ) + + assert response.status_code == 400 diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..8b48800 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,302 @@ +from uuid import UUID + +import pytest + +from knowledge_adapter.content_budget import TRUNCATION_MARKER +from knowledge_adapter.models import RetrievalRequest, WeKnoraChunk +from knowledge_adapter.service import InvalidRequestError, RetrievalService + +ALLOWED_KB_ID = UUID("3904b40e-278b-4273-a6fa-db6cbc975587") +DENIED_KB_ID = UUID("7fa6e8d2-05d8-4e74-b98c-d8591b680db9") + + +class RecordingSearchClient: + def __init__(self, results: list[dict[str, object]] | None = None) -> None: + self.calls: list[tuple[str, UUID]] = [] + self.results = results or [] + + async def search(self, *, query: str, knowledge_base_id: UUID) -> list[dict[str, object]]: + self.calls.append((query, knowledge_base_id)) + return self.results + + +class ExpandingContentProcessor: + async def process(self, chunk: WeKnoraChunk) -> str: + return chunk.content * 100 + + +def request_for( + knowledge_id: UUID = ALLOWED_KB_ID, + *, + top_k: int = 3, + score_threshold: float = 0.5, +) -> RetrievalRequest: + return RetrievalRequest.model_validate( + { + "knowledge_id": str(knowledge_id), + "query": "test query", + "retrieval_setting": { + "top_k": top_k, + "score_threshold": score_threshold, + }, + } + ) + + +async def test_disallowed_kb_is_rejected_without_calling_upstream() -> None: + client = RecordingSearchClient() + service = RetrievalService( + search_client=client, + allowed_kb_ids=frozenset({ALLOWED_KB_ID}), + ) + + with pytest.raises(InvalidRequestError): + await service.retrieve(request_for(DENIED_KB_ID)) + + assert client.calls == [] + + +async def test_allowed_kb_calls_upstream_once_with_normalized_values() -> None: + client = RecordingSearchClient() + service = RetrievalService( + search_client=client, + allowed_kb_ids=frozenset({ALLOWED_KB_ID}), + ) + + response = await service.retrieve(request_for()) + + assert response == {"records": []} + assert client.calls == [("test query", ALLOWED_KB_ID)] + + +async def test_matching_chunk_is_mapped_to_dify_record() -> None: + chunk_id = "c7f61f89-19ce-45b1-ac60-dbeed5e2a491" + knowledge_id = "6f77a258-27ff-4278-a667-8fe242ff8e10" + client = RecordingSearchClient( + [ + { + "id": chunk_id, + "content": "safe chunk content", + "score": 0.82, + "knowledge_base_id": str(ALLOWED_KB_ID), + "knowledge_id": knowledge_id, + "knowledge_title": "Operation Manual", + "knowledge_filename": "manual.pdf", + "chunk_index": 4, + "chunk_type": "text", + "image_info": "", + } + ] + ) + service = RetrievalService( + search_client=client, + allowed_kb_ids=frozenset({ALLOWED_KB_ID}), + ) + + response = await service.retrieve(request_for()) + + assert response == { + "records": [ + { + "content": "safe chunk content", + "score": 0.82, + "title": "Operation Manual", + "metadata": { + "knowledge_base_id": str(ALLOWED_KB_ID), + "knowledge_id": knowledge_id, + "chunk_id": chunk_id, + "chunk_index": 4, + "chunk_type": "text", + }, + } + ] + } + + +async def test_cross_kb_chunk_is_dropped_after_upstream_returns() -> None: + client = RecordingSearchClient( + [ + { + "id": "c7f61f89-19ce-45b1-ac60-dbeed5e2a491", + "content": "must not escape", + "score": 0.99, + "knowledge_base_id": str(DENIED_KB_ID), + "knowledge_id": "6f77a258-27ff-4278-a667-8fe242ff8e10", + "knowledge_title": "Other KB", + "knowledge_filename": "other.pdf", + "chunk_index": 0, + "chunk_type": "text", + "image_info": "", + } + ] + ) + service = RetrievalService( + search_client=client, + allowed_kb_ids=frozenset({ALLOWED_KB_ID, DENIED_KB_ID}), + ) + + response = await service.retrieve(request_for(ALLOWED_KB_ID)) + + assert response == {"records": []} + + +async def test_score_threshold_filters_without_resorting() -> None: + base = { + "knowledge_base_id": str(ALLOWED_KB_ID), + "knowledge_id": "6f77a258-27ff-4278-a667-8fe242ff8e10", + "knowledge_title": "Manual", + "knowledge_filename": "manual.pdf", + "chunk_type": "text", + "image_info": "", + } + client = RecordingSearchClient( + [ + { + **base, + "id": "c7f61f89-19ce-45b1-ac60-dbeed5e2a491", + "content": "below", + "score": 0.49, + "chunk_index": 0, + }, + { + **base, + "id": "09ab721f-7d05-45de-ae0f-dac303276f6d", + "content": "at threshold", + "score": 0.5, + "chunk_index": 1, + }, + ] + ) + service = RetrievalService( + search_client=client, + allowed_kb_ids=frozenset({ALLOWED_KB_ID}), + ) + + response = await service.retrieve(request_for()) + + assert [(record["content"], record["score"]) for record in response["records"]] == [ + ("at threshold", 0.5) + ] + + +async def test_requested_top_k_is_clamped_to_service_maximum() -> None: + raw_chunks = [ + { + "id": str(UUID(int=index + 1)), + "content": f"chunk-{index}", + "score": 0.9, + "knowledge_base_id": str(ALLOWED_KB_ID), + "knowledge_id": "6f77a258-27ff-4278-a667-8fe242ff8e10", + "knowledge_title": "Manual", + "knowledge_filename": "manual.pdf", + "chunk_index": index, + "chunk_type": "text", + "image_info": "", + } + for index in range(6) + ] + service = RetrievalService( + search_client=RecordingSearchClient(raw_chunks), + allowed_kb_ids=frozenset({ALLOWED_KB_ID}), + ) + + response = await service.retrieve(request_for(top_k=100000)) + + assert [record["content"] for record in response["records"]] == [ + "chunk-0", + "chunk-1", + "chunk-2", + "chunk-3", + "chunk-4", + ] + + +async def test_single_chunk_content_is_hard_limited() -> None: + client = RecordingSearchClient( + [ + { + "id": "c7f61f89-19ce-45b1-ac60-dbeed5e2a491", + "content": "文" * 5000, + "score": 0.9, + "knowledge_base_id": str(ALLOWED_KB_ID), + "knowledge_id": "6f77a258-27ff-4278-a667-8fe242ff8e10", + "knowledge_title": "Manual", + "knowledge_filename": "manual.pdf", + "chunk_index": 0, + "chunk_type": "text", + "image_info": "", + } + ] + ) + service = RetrievalService( + search_client=client, + allowed_kb_ids=frozenset({ALLOWED_KB_ID}), + ) + + response = await service.retrieve(request_for()) + content = response["records"][0]["content"] + + assert isinstance(content, str) + assert len(content) <= 4000 + assert content.endswith(TRUNCATION_MARKER) + + +async def test_total_returned_content_stops_at_hard_budget() -> None: + raw_chunks = [ + { + "id": str(UUID(int=index + 100)), + "content": str(index) * 4000, + "score": 0.9, + "knowledge_base_id": str(ALLOWED_KB_ID), + "knowledge_id": "6f77a258-27ff-4278-a667-8fe242ff8e10", + "knowledge_title": "Manual", + "knowledge_filename": "manual.pdf", + "chunk_index": index, + "chunk_type": "text", + "image_info": "", + } + for index in range(4) + ] + service = RetrievalService( + search_client=RecordingSearchClient(raw_chunks), + allowed_kb_ids=frozenset({ALLOWED_KB_ID}), + ) + + response = await service.retrieve(request_for(top_k=5)) + returned_contents = [record["content"] for record in response["records"]] + + assert len(returned_contents) == 3 + assert sum(len(content) for content in returned_contents if isinstance(content, str)) <= 12000 + + +async def test_length_budget_is_applied_after_content_processing() -> None: + client = RecordingSearchClient( + [ + { + "id": "c7f61f89-19ce-45b1-ac60-dbeed5e2a491", + "content": "x", + "score": 0.9, + "knowledge_base_id": str(ALLOWED_KB_ID), + "knowledge_id": "6f77a258-27ff-4278-a667-8fe242ff8e10", + "knowledge_title": "Manual", + "knowledge_filename": "manual.pdf", + "chunk_index": 0, + "chunk_type": "text", + "image_info": "", + } + ] + ) + limit = len(TRUNCATION_MARKER) + 5 + service = RetrievalService( + search_client=client, + allowed_kb_ids=frozenset({ALLOWED_KB_ID}), + content_processor=ExpandingContentProcessor(), + max_chunk_chars=limit, + ) + + response = await service.retrieve(request_for()) + content = response["records"][0]["content"] + + assert isinstance(content, str) + assert content == f"xxxxx{TRUNCATION_MARKER}" + assert len(content) == limit diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..63f4544 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,158 @@ +from pathlib import Path +from typing import Any +from uuid import UUID + +import pytest +from pydantic import SecretStr, ValidationError + +from knowledge_adapter.content_budget import TRUNCATION_MARKER +from knowledge_adapter.settings import Settings + +BASE_SETTINGS: dict[str, Any] = { + "DIFY_KEY_HASH_CURRENT": "a" * 64, + "DIFY_KEY_HASH_NEXT": "", + "WEKNORA_BASE_URL": "http://weknora.internal:8080", + "WEKNORA_ALLOWED_KB_IDS": ( + "3904b40e-278b-4273-a6fa-db6cbc975587," + "7fa6e8d2-05d8-4e74-b98c-d8591b680db9" + ), + "WEKNORA_TENANT_ID": 10008, + "WEKNORA_OSS_STORAGE_BACKEND_ID": "backend-test", + "OSS_REGION": "cn-shenzhen", + "OSS_BUCKET": "private-assets", + "OSS_RAM_ROLE_NAME": "knowledge-adapter-role", + "ASSET_PUBLIC_BASE_URL": "https://assets.example.com", +} + + +def test_settings_parse_allowlist_secrets_and_required_defaults() -> None: + settings = Settings(**BASE_SETTINGS) + + assert settings.dify_key_hash_current == "a" * 64 + assert settings.dify_key_hash_next is None + assert settings.weknora_api_key_file == Path("/run/secrets/weknora_api_key") + assert settings.weknora_allowed_kb_ids == frozenset( + { + UUID("3904b40e-278b-4273-a6fa-db6cbc975587"), + UUID("7fa6e8d2-05d8-4e74-b98c-d8591b680db9"), + } + ) + assert settings.weknora_tenant_id == 10008 + assert settings.weknora_oss_storage_backend_id == "backend-test" + assert settings.max_query_chars == 2000 + assert settings.max_top_k == 5 + assert settings.max_chunk_chars == 4000 + assert settings.max_total_chars == 12000 + assert settings.weknora_timeout_seconds == 15 + assert settings.asset_database_url_file == Path("/run/secrets/asset_database_url") + assert settings.weknora_readonly_database_url_file == Path( + "/run/secrets/weknora_readonly_database_url" + ) + + +def test_settings_reject_malformed_current_key_hash() -> None: + values = {**BASE_SETTINGS, "DIFY_KEY_HASH_CURRENT": "not-a-sha256"} + + with pytest.raises(ValidationError): + Settings(**values) + + +def test_settings_uses_weknora_api_key_secret_file(tmp_path: Path) -> None: + secret_file = tmp_path / "weknora-api-key" + values = { + key: value + for key, value in BASE_SETTINGS.items() + if key != "WEKNORA_API_KEY" + } + values["WEKNORA_API_KEY_FILE"] = secret_file + + settings = Settings(**values) + + assert settings.weknora_api_key_file == secret_file + assert not hasattr(settings, "weknora_api_key") + + +def test_settings_reads_weknora_api_key_at_runtime(tmp_path: Path) -> None: + secret_file = tmp_path / "weknora-api-key" + secret_file.write_text("weknora-test-secret\n", encoding="utf-8") + values = { + key: value + for key, value in BASE_SETTINGS.items() + if key != "WEKNORA_API_KEY" + } + values["WEKNORA_API_KEY_FILE"] = secret_file + settings = Settings(**values) + + api_key = settings.read_weknora_api_key() + + assert isinstance(api_key, SecretStr) + assert api_key.get_secret_value() == "weknora-test-secret" + assert "weknora-test-secret" not in repr(settings) + + +def test_static_ak_mode_reads_secret_files_without_ram_role(tmp_path: Path) -> None: + access_key_id_file = tmp_path / "oss-access-key-id" + access_key_secret_file = tmp_path / "oss-access-key-secret" + access_key_id_file.write_text("test-access-key-id\n", encoding="utf-8") + access_key_secret_file.write_text("test-access-key-secret\n", encoding="utf-8") + values = { + **BASE_SETTINGS, + "OSS_CREDENTIAL_MODE": "static_ak", + "OSS_RAM_ROLE_NAME": None, + "OSS_ACCESS_KEY_ID_FILE": access_key_id_file, + "OSS_ACCESS_KEY_SECRET_FILE": access_key_secret_file, + } + + settings = Settings(**values) + + assert settings.oss_credential_mode == "static_ak" + assert settings.oss_ram_role_name is None + assert settings.read_oss_access_key_id().get_secret_value() == "test-access-key-id" + assert settings.read_oss_access_key_secret().get_secret_value() == "test-access-key-secret" + assert "test-access-key-id" not in repr(settings) + assert "test-access-key-secret" not in repr(settings) + + +@pytest.mark.parametrize("content", [None, " \n"]) +def test_settings_rejects_unavailable_weknora_api_key_secret( + tmp_path: Path, + content: str | None, +) -> None: + secret_file = tmp_path / "weknora-api-key" + if content is not None: + secret_file.write_text(content, encoding="utf-8") + values = { + key: value + for key, value in BASE_SETTINGS.items() + if key != "WEKNORA_API_KEY" + } + values["WEKNORA_API_KEY_FILE"] = secret_file + settings = Settings(**values) + + with pytest.raises(RuntimeError, match=r"^required secret unavailable$"): + settings.read_weknora_api_key() + + +def test_settings_reject_chunk_limit_smaller_than_truncation_marker() -> None: + values = {**BASE_SETTINGS, "MAX_CHUNK_CHARS": len(TRUNCATION_MARKER) - 1} + + with pytest.raises(ValidationError): + Settings(**values) + + +@pytest.mark.parametrize( + ("name", "value"), + [ + ("OSS_REGION", "https://attacker.example"), + ("OSS_REGION", "cn-shenzhen/internal"), + ("OSS_BUCKET", "Private_Assets"), + ("OSS_BUCKET", "https://attacker.example"), + ("OSS_RAM_ROLE_NAME", ""), + ("OSS_RAM_ROLE_NAME", "role/name"), + ], +) +def test_settings_reject_unsafe_oss_identifiers(name: str, value: str) -> None: + values = {**BASE_SETTINGS, name: value} + + with pytest.raises(ValidationError): + Settings(**values) diff --git a/tests/test_weknora_client.py b/tests/test_weknora_client.py new file mode 100644 index 0000000..54052b3 --- /dev/null +++ b/tests/test_weknora_client.py @@ -0,0 +1,113 @@ +from uuid import UUID + +import httpx +import pytest +from pydantic import SecretStr + +from knowledge_adapter.weknora_client import ( + WeKnoraBadResponseError, + WeKnoraClient, + WeKnoraUnavailableError, +) + +KB_ID = UUID("3904b40e-278b-4273-a6fa-db6cbc975587") +WEKNORA_KEY = "test-weknora-key" + + +async def test_search_uses_only_readonly_endpoint_and_expected_contract() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"success": True, "data": []}) + + client = WeKnoraClient( + base_url="http://weknora.internal:8080", + api_key=SecretStr(WEKNORA_KEY), + timeout_seconds=15, + transport=httpx.MockTransport(handler), + ) + async with client: + results = await client.search(query="normalized query", knowledge_base_id=KB_ID) + + assert results == [] + assert len(requests) == 1 + request = requests[0] + assert request.method == "POST" + assert request.url.path == "/api/v1/knowledge-search" + assert request.headers["X-API-Key"] == WEKNORA_KEY + assert request.headers.get("Authorization") is None + assert request.read().decode() == ( + '{"query":"normalized query",' + '"knowledge_base_ids":["3904b40e-278b-4273-a6fa-db6cbc975587"]}' + ) + + +async def test_timeout_becomes_sanitized_unavailable_error() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("internal timeout details", request=request) + + client = WeKnoraClient( + base_url="http://weknora.internal:8080", + api_key=SecretStr(WEKNORA_KEY), + timeout_seconds=15, + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(WeKnoraUnavailableError) as captured: + async with client: + await client.search(query="sensitive query", knowledge_base_id=KB_ID) + + assert str(captured.value) == "WeKnora unavailable" + + +async def test_500_becomes_sanitized_bad_response_error() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(500, text="sensitive upstream failure") + + client = WeKnoraClient( + base_url="http://weknora.internal:8080", + api_key=SecretStr(WEKNORA_KEY), + timeout_seconds=15, + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(WeKnoraBadResponseError) as captured: + async with client: + await client.search(query="sensitive query", knowledge_base_id=KB_ID) + + assert str(captured.value) == "invalid WeKnora response" + + +async def test_non_json_200_becomes_sanitized_bad_response_error() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="not-json sensitive body") + + client = WeKnoraClient( + base_url="http://weknora.internal:8080", + api_key=SecretStr(WEKNORA_KEY), + timeout_seconds=15, + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(WeKnoraBadResponseError) as captured: + async with client: + await client.search(query="sensitive query", knowledge_base_id=KB_ID) + + assert str(captured.value) == "invalid WeKnora response" + + +async def test_success_false_becomes_bad_response_error() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"success": False, "data": []}) + + client = WeKnoraClient( + base_url="http://weknora.internal:8080", + api_key=SecretStr(WEKNORA_KEY), + timeout_seconds=15, + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(WeKnoraBadResponseError): + async with client: + await client.search(query="query", knowledge_base_id=KB_ID) diff --git a/tests/test_weknora_grants.py b/tests/test_weknora_grants.py new file mode 100644 index 0000000..820e7c3 --- /dev/null +++ b/tests/test_weknora_grants.py @@ -0,0 +1,31 @@ +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +GRANT_SQL = PROJECT_ROOT / "deploy" / "sql" / "weknora_readonly_grants.sql" + + +def test_weknora_role_grants_only_required_resource_columns() -> None: + assert GRANT_SQL.exists(), "WeKnora readonly grant SQL is missing" + + sql = " ".join(GRANT_SQL.read_text(encoding="utf-8").lower().split()) + required_columns = ( + "handle, tenant_id, storage_backend_id, provider, physical_path, " + "mime_type, state, deleted_at" + ) + assert "grant usage on schema public" in sql + assert f"grant select ({required_columns}) on table public.resources" in sql + assert "grant select on table" not in sql + assert "grant select on all tables" not in sql + + for forbidden_operation in ("insert", "update", "delete", "truncate", "create", "alter"): + assert f"grant {forbidden_operation}" not in sql + for forbidden_table in ( + "users", + "api_keys", + "models", + "knowledge_chunks", + "knowledges", + "knowledge_bases", + "storage_backends", + ): + assert f"public.{forbidden_table}" not in sql diff --git a/tests/test_weknora_resource_resolver.py b/tests/test_weknora_resource_resolver.py new file mode 100644 index 0000000..5837528 --- /dev/null +++ b/tests/test_weknora_resource_resolver.py @@ -0,0 +1,237 @@ +from contextlib import AbstractAsyncContextManager +from types import TracebackType +from uuid import UUID + +import pytest + +from knowledge_adapter.weknora_resource_resolver import ( + ResolvedAsset, + ResourceResolutionError, + WeKnoraResourceResolver, +) + +HANDLE = "AbCdEfGhIjKlMnOpQrSt_1" +BACKEND_ID = "550e8400-e29b-41d4-a716-446655440000" + + +class FakeTransaction(AbstractAsyncContextManager[None]): + def __init__(self) -> None: + self.entered = False + self.readonly: bool | None = None + + async def __aenter__(self) -> None: + self.entered = True + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None + + +class FakeConnection: + def __init__(self, rows: list[dict[str, object]]) -> None: + self.rows = rows + self.transaction_context = FakeTransaction() + self.query = "" + self.args: tuple[object, ...] = () + + def transaction(self, *, readonly: bool) -> FakeTransaction: + self.transaction_context.readonly = readonly + return self.transaction_context + + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + self.query = query + self.args = args + return self.rows + + +class AcquireContext(AbstractAsyncContextManager[FakeConnection]): + def __init__(self, connection: FakeConnection) -> None: + self.connection = connection + + async def __aenter__(self) -> FakeConnection: + return self.connection + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None + + +class FakePool: + def __init__(self, connection: FakeConnection) -> None: + self.connection = connection + self.acquire_count = 0 + + def acquire(self) -> AcquireContext: + self.acquire_count += 1 + return AcquireContext(self.connection) + + +class FailingConnection(FakeConnection): + async def fetch(self, query: str, *args: object) -> list[dict[str, object]]: + raise RuntimeError( + "postgresql://readonly:secret@db.internal resources physical_path=private/object" + ) + + +async def test_resolver_uses_minimal_parameterized_readonly_query() -> None: + connection = FakeConnection( + [ + { + "tenant_id": 10008, + "storage_backend_id": "backend-test", + "provider": "oss", + "physical_path": ( + "storage://backend-test/" + "oss://private-assets/weknora/10008/assets/image.png" + ), + "mime_type": "image/png", + } + ] + ) + resolver = WeKnoraResourceResolver( + pool=FakePool(connection), + tenant_id=10008, + storage_backend_id="backend-test", + bucket="private-assets", + ) + + resolved = await resolver.resolve(f"resource://{HANDLE}") + + assert resolved == ResolvedAsset( + object_key="weknora/10008/assets/image.png", + content_type="image/png", + ) + assert connection.transaction_context.entered is True + assert connection.transaction_context.readonly is True + assert connection.args == (HANDLE, 10008, "backend-test") + normalized_query = " ".join(connection.query.split()).lower() + assert normalized_query.startswith( + "select tenant_id, storage_backend_id, provider, physical_path, mime_type from resources" + ) + assert "handle = $1" in normalized_query + assert "tenant_id = $2" in normalized_query + assert "storage_backend_id = $3" in normalized_query + assert "state = 'active'" in normalized_query + assert "deleted_at is null" in normalized_query + for forbidden_column in ("content", "credentials", "api_key", "secret", "original_name"): + assert forbidden_column not in normalized_query + + +async def test_resolver_accepts_asyncpg_uuid_storage_backend_id() -> None: + connection = FakeConnection( + [ + { + "tenant_id": 10008, + "storage_backend_id": UUID(BACKEND_ID), + "provider": "oss", + "physical_path": ( + f"storage://{BACKEND_ID}/" + "oss://private-assets/weknora/10008/assets/image.png" + ), + "mime_type": "image/png", + } + ] + ) + resolver = WeKnoraResourceResolver( + pool=FakePool(connection), + tenant_id=10008, + storage_backend_id=BACKEND_ID, + bucket="private-assets", + ) + + resolved = await resolver.resolve(f"resource://{HANDLE}") + + assert resolved.object_key == "weknora/10008/assets/image.png" + + +@pytest.mark.parametrize("row_count", [0, 2]) +async def test_resolver_requires_exactly_one_resource_row(row_count: int) -> None: + row = { + "tenant_id": 10008, + "storage_backend_id": "backend-test", + "provider": "oss", + "physical_path": ( + "storage://backend-test/" + "oss://private-assets/weknora/10008/assets/image.png" + ), + "mime_type": "image/png", + } + resolver = WeKnoraResourceResolver( + pool=FakePool(FakeConnection([row] * row_count)), + tenant_id=10008, + storage_backend_id="backend-test", + bucket="private-assets", + ) + + with pytest.raises(ResourceResolutionError, match=r"^resource unavailable$"): + await resolver.resolve(f"resource://{HANDLE}") + + +@pytest.mark.parametrize( + "overrides", + [ + {"tenant_id": 99999}, + {"storage_backend_id": "other-backend"}, + {"provider": "cos"}, + ], +) +async def test_resolver_rechecks_row_ownership(overrides: dict[str, object]) -> None: + row = { + "tenant_id": 10008, + "storage_backend_id": "backend-test", + "provider": "oss", + "physical_path": ( + "storage://backend-test/" + "oss://private-assets/weknora/10008/assets/image.png" + ), + "mime_type": "image/png", + **overrides, + } + resolver = WeKnoraResourceResolver( + pool=FakePool(FakeConnection([row])), + tenant_id=10008, + storage_backend_id="backend-test", + bucket="private-assets", + ) + + with pytest.raises(ResourceResolutionError, match=r"^resource unavailable$"): + await resolver.resolve(f"resource://{HANDLE}") + + +async def test_resolver_maps_database_error_to_safe_fixed_error() -> None: + resolver = WeKnoraResourceResolver( + pool=FakePool(FailingConnection([])), + tenant_id=10008, + storage_backend_id="backend-test", + bucket="private-assets", + ) + + with pytest.raises(ResourceResolutionError) as captured: + await resolver.resolve(f"resource://{HANDLE}") + + assert str(captured.value) == "resource unavailable" + assert "postgresql" not in str(captured.value) + assert "physical_path" not in str(captured.value) + + +async def test_invalid_resource_reference_is_rejected_before_database_access() -> None: + pool = FakePool(FakeConnection([])) + resolver = WeKnoraResourceResolver( + pool=pool, + tenant_id=10008, + storage_backend_id="backend-test", + bucket="private-assets", + ) + + with pytest.raises(ResourceResolutionError, match=r"^resource unavailable$"): + await resolver.resolve("https://attacker.example/image.png") + + assert pool.acquire_count == 0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c82bd17 --- /dev/null +++ b/uv.lock @@ -0,0 +1,947 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alibabacloud-credentials" +version = "0.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-tea" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/92/7cb0807d6d380fa09cbad6d4fe983781e657dcc16d60fc559d6575bf98be/alibabacloud_credentials-0.3.6.tar.gz", hash = "sha256:caa82cf258648dcbe1ca14aeba50ba21845567d6ac3cd48d318e0a445fff7f96", size = 18771, upload-time = "2024-10-28T03:40:03.806Z" } + +[[package]] +name = "alibabacloud-tea" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0f3f9d7f26b76b9ed93d4101add7867a2c87ed2534f5/alibabacloud-tea-0.4.3.tar.gz", hash = "sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a", size = 8785, upload-time = "2025-03-24T07:34:42.958Z" } + +[[package]] +name = "aliyun-python-sdk-core" +version = "2.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jmespath" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/38/eb3be49159d6442e68189f21a2205a199b51ca46cf42a3f874e695ff0ba9/aliyun_python_sdk_core-2.16.1-py3-none-any.whl", hash = "sha256:0f25a6e8ec1ea29c76610278cff81cbff85149a6e692bf2398a6f9b77e5e7424", size = 521331, upload-time = "2026-08-18T07:57:56.379Z" }, +] + +[[package]] +name = "aliyun-python-sdk-kms" +version = "2.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aliyun-python-sdk-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/2c/9877d0e6b18ecf246df671ac65a5d1d9fecbf85bdcb5d43efbde0d4662eb/aliyun-python-sdk-kms-2.16.5.tar.gz", hash = "sha256:f328a8a19d83ecbb965ffce0ec1e9930755216d104638cd95ecd362753b813b3", size = 12018, upload-time = "2024-08-30T09:01:20.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/5c/0132193d7da2c735669a1ed103b142fd63c9455984d48c5a88a1a516efaa/aliyun_python_sdk_kms-2.16.5-py2.py3-none-any.whl", hash = "sha256:24b6cdc4fd161d2942619479c8d050c63ea9cd22b044fe33b60bbb60153786f0", size = 99495, upload-time = "2024-08-30T09:01:18.462Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +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 = "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/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +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/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[[package]] +name = "crcmod" +version = "1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670, upload-time = "2010-06-27T14:35:29.538Z" } + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +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 = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +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]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "knowledge-adapter-service" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alibabacloud-credentials" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "oss2" }, + { name = "pydantic-settings" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "alibabacloud-credentials", specifier = ">=0.3.6,<1" }, + { name = "asyncpg", specifier = ">=0.30,<1" }, + { name = "fastapi", specifier = ">=0.116,<1" }, + { name = "httpx", specifier = ">=0.28,<1" }, + { name = "oss2", specifier = ">=2.19,<3" }, + { name = "pydantic-settings", specifier = ">=2.10,<3" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4,<9" }, + { name = "pytest-asyncio", specifier = ">=1.1,<2" }, + { name = "pytest-cov", specifier = ">=6.2,<7" }, + { name = "ruff", specifier = ">=0.12,<1" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +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" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "oss2" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aliyun-python-sdk-core" }, + { name = "aliyun-python-sdk-kms" }, + { name = "crcmod" }, + { name = "pycryptodome" }, + { name = "requests" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/f2cb1950dda46ac2284d6c950489fdacd0e743c2d79a347924d3cc44b86f/oss2-2.19.1.tar.gz", hash = "sha256:a8ab9ee7eb99e88a7e1382edc6ea641d219d585a7e074e3776e9dec9473e59c1", size = 298845, upload-time = "2024-10-25T11:37:46.638Z" } + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +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 = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +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" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +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/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]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +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 = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, +] + +[[package]] +name = "websockets" +version = "17.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ff/6199a52d864215750af8668d84b0274775011a90052081f5a9495807a92b/websockets-17.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f", size = 212603, upload-time = "2026-07-31T11:29:30.771Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/439a962bcada88dcf586da77a1b2385f91e2d2910e9359540934c827156b/websockets-17.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d", size = 210286, upload-time = "2026-07-31T11:29:32.157Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/123660edc759c225626b3b91952c7625f85c77a8362acbc35a4623120f7d/websockets-17.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1", size = 210549, upload-time = "2026-07-31T11:29:33.388Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2f/2940e57080cf56f28190287516400126d5a76b52b9a61dc10ba6f6400dbe/websockets-17.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21", size = 219874, upload-time = "2026-07-31T11:29:34.608Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/9ec976c16d63cc51c28dfec74b66854048c0b8b6579946e902f91b69e8bf/websockets-17.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a", size = 220150, upload-time = "2026-07-31T11:29:35.831Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a4/850c699a16bbc451723856360c59bd997bec075e637154f3fa96e80d5760/websockets-17.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c", size = 221389, upload-time = "2026-07-31T11:29:37.189Z" }, + { url = "https://files.pythonhosted.org/packages/47/e1/f60a891c1a4b3420d5052333a84eb7241e1fb4a71866dec1562f5fa30027/websockets-17.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761", size = 224169, upload-time = "2026-07-31T11:29:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/26/fb/e2a893be6fae4fddfe50ddc3035a331d3f381103d5467b7900026bdb3a64/websockets-17.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6", size = 222025, upload-time = "2026-07-31T11:29:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/d9/72/e3144b2d79276fab9798ed7d4aea2f0847434f186800b6f56a1eddcb3114/websockets-17.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861", size = 220779, upload-time = "2026-07-31T11:29:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/69c02174fbcf1c40c6adc45d3c316a401558392fe7bab8969ef8c46f1689/websockets-17.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4", size = 218053, upload-time = "2026-07-31T11:29:42.322Z" }, + { url = "https://files.pythonhosted.org/packages/b3/09/7574778b095b99cfa0856583462f56568df784f9b41485145169b2ec9c64/websockets-17.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f", size = 220825, upload-time = "2026-07-31T11:29:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e6/f46571f38765dbc4cbc0d0b47de8db65768006dbbd4340e6f5f51bc1d895/websockets-17.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2", size = 219427, upload-time = "2026-07-31T11:29:44.731Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/6ff1fee057bd7e9dd5237fc064a749615378d003aa045b5bfc2d12b2f4f7/websockets-17.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e", size = 220198, upload-time = "2026-07-31T11:29:45.997Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/d41847227a44b9ad87c3d5a9fddbfad8b7c4d6032878d8460d9d37c2d44f/websockets-17.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966", size = 221304, upload-time = "2026-07-31T11:29:47.314Z" }, + { url = "https://files.pythonhosted.org/packages/63/30/21a7e326c6ad2eb526cd5b816383d59cdeb28b8805b65a543c3cfbd8e8ce/websockets-17.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f", size = 218858, upload-time = "2026-07-31T11:29:48.587Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/55b0331cd5bec9ce29748f79edc00075805450a47011d0c8e3b1c61dbf04/websockets-17.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a", size = 219840, upload-time = "2026-07-31T11:29:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/2e8bc06e546f49b32a58a2bc2957902d1809ecc37552d3d7ccd6639a126e/websockets-17.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2", size = 220116, upload-time = "2026-07-31T11:29:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ad/4bac01fa41aca54307157b9c9f68b066a6bb51fb18716ba618078a67b283/websockets-17.0.1-cp312-cp312-win32.whl", hash = "sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0", size = 213050, upload-time = "2026-07-31T11:29:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/82/d8/c3a78cccc74a554780e9e76e323d5cde891048627025f0f82623e22dc3df/websockets-17.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3", size = 213348, upload-time = "2026-07-31T11:29:53.891Z" }, + { url = "https://files.pythonhosted.org/packages/7b/25/e1b8824bd632c8a5a62d504b61e9e35e470b67e4be0206f5c28f90c7f86d/websockets-17.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79", size = 213276, upload-time = "2026-07-31T11:29:55.299Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" }, +] + +[[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/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { 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" }, +]
接口参数最小值典型值最大值单位
模拟输入(电压模式)
Alx电压0110V
电阻114.71kΩ
分辨率1121=1.27,<2"] +build-backend = "hatchling.build" + +[project] +name = "knowledge-adapter-service" +version = "0.1.0" +description = "Small production-grade Dify to WeKnora retrieval adapter" +requires-python = ">=3.12,<3.13" +dependencies = [ + "alibabacloud-credentials>=0.3.6,<1", + "asyncpg>=0.30,<1", + "fastapi>=0.116,<1", + "httpx>=0.28,<1", + "oss2>=2.19,<3", + "pydantic-settings>=2.10,<3", + "uvicorn[standard]>=0.35,<1", +] + +[dependency-groups] +dev = [ + "pytest>=8.4,<9", + "pytest-asyncio>=1.1,<2", + "pytest-cov>=6.2,<7", + "ruff>=0.12,<1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/knowledge_adapter"] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] +asyncio_mode = "auto" +filterwarnings = [ + "ignore:datetime\\.datetime\\.utcnow\\(\\) is deprecated.*:DeprecationWarning:oss2\\.auth", +] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "S", "ASYNC", "RUF"] +ignore = ["S101"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S105", "S106"] diff --git a/src/knowledge_adapter/__init__.py b/src/knowledge_adapter/__init__.py new file mode 100644 index 0000000..f371727 --- /dev/null +++ b/src/knowledge_adapter/__init__.py @@ -0,0 +1 @@ +"""Knowledge Adapter Service.""" diff --git a/src/knowledge_adapter/app.py b/src/knowledge_adapter/app.py new file mode 100644 index 0000000..c828871 --- /dev/null +++ b/src/knowledge_adapter/app.py @@ -0,0 +1,201 @@ +from typing import Annotated, Any +from uuid import UUID + +from fastapi import Body, Depends, FastAPI, HTTPException, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, RedirectResponse, Response +from pydantic import ValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.types import Lifespan + +from knowledge_adapter.asset_content_processor import ContentProcessingError +from knowledge_adapter.asset_redirect import ( + AssetNotFoundError, + AssetRedirector, + AssetRedirectUnavailableError, +) +from knowledge_adapter.auth import DifyKeyAuthenticator +from knowledge_adapter.models import RetrievalRequest +from knowledge_adapter.observability import ( + bind_request_id, + log_http_request, + normalize_request_id, + request_started_at, + reset_request_id, +) +from knowledge_adapter.service import InvalidRequestError, RetrievalService +from knowledge_adapter.weknora_client import WeKnoraBadResponseError, WeKnoraUnavailableError + +_SAFE_HTTP_DETAILS = frozenset( + { + "content processing failed", + "invalid API key", + "invalid request", + "invalid upstream response", + "upstream unavailable", + } +) +_DEFAULT_ERROR_DETAILS = { + 400: "invalid request", + 401: "invalid API key", + 403: "forbidden", + 405: "method not allowed", + 413: "invalid request", + 422: "invalid request", + 500: "internal server error", + 502: "bad gateway", + 503: "service unavailable", +} + + +def create_app( + *, + authenticator: DifyKeyAuthenticator | None = None, + retrieval_service: RetrievalService | None = None, + asset_redirect_service: AssetRedirector | None = None, + max_query_chars: int = 2000, + lifespan: Lifespan[FastAPI] | None = None, +) -> FastAPI: + app = FastAPI( + docs_url=None, + redoc_url=None, + openapi_url=None, + lifespan=lifespan, + ) + + @app.middleware("http") + async def request_id_middleware(request: Request, call_next: Any) -> Response: + request_id = normalize_request_id(request.headers.get("X-Request-ID")) + request.state.request_id = request_id + request_id_token = bind_request_id(request_id) + started_at_ns = request_started_at() + try: + try: + response = await call_next(request) + except Exception: + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "internal server error"}, + ) + response.headers["X-Request-ID"] = request_id + route = getattr(request.scope.get("route"), "path", "") + log_http_request( + request_id=request_id, + method=request.method, + route=route, + status_code=response.status_code, + started_at_ns=started_at_ns, + ) + return response + finally: + reset_request_id(request_id_token) + + @app.exception_handler(RequestValidationError) + async def invalid_request_handler( + _request: Request, + _exc: RequestValidationError, + ) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"detail": "invalid request"}, + ) + + @app.exception_handler(StarletteHTTPException) + async def safe_http_error_handler( + request: Request, + exc: StarletteHTTPException, + ) -> Response: + if exc.status_code == status.HTTP_404_NOT_FOUND: + if request.url.path.startswith("/i/"): + return Response(status_code=status.HTTP_404_NOT_FOUND) + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content={"detail": "not found"}, + ) + detail = ( + exc.detail + if isinstance(exc.detail, str) and exc.detail in _SAFE_HTTP_DETAILS + else _DEFAULT_ERROR_DETAILS.get(exc.status_code, "request failed") + ) + headers = ( + {"WWW-Authenticate": "Bearer"} + if exc.status_code == status.HTTP_401_UNAUTHORIZED + else None + ) + return JSONResponse( + status_code=exc.status_code, + content={"detail": detail}, + headers=headers, + ) + + @app.get("/healthz") + async def healthz() -> dict[str, str]: + return {"status": "ok"} + + if asset_redirect_service is not None: + + @app.get("/i/{asset_id:uuid}", response_class=RedirectResponse) + async def asset_redirect(asset_id: UUID) -> Response: + try: + location = await asset_redirect_service.get_redirect_url(asset_id) + except AssetNotFoundError: + return Response(status_code=status.HTTP_404_NOT_FOUND) + except AssetRedirectUnavailableError: + return Response(status_code=status.HTTP_503_SERVICE_UNAVAILABLE) + return RedirectResponse( + url=location, + status_code=status.HTTP_302_FOUND, + headers={ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + }, + ) + + @app.get("/i/{_invalid_asset_id}") + async def invalid_asset_id(_invalid_asset_id: str) -> Response: + return Response(status_code=status.HTTP_404_NOT_FOUND) + + if authenticator is not None: + + @app.post("/retrieval", dependencies=[Depends(authenticator)]) + async def retrieval( + payload: Annotated[Any, Body()], + ) -> dict[str, list[dict[str, object]]]: + try: + request_model = RetrievalRequest.model_validate(payload) + except ValidationError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="invalid request", + ) from exc + if len(request_model.query) > max_query_chars: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="invalid request", + ) + if retrieval_service is not None: + try: + return await retrieval_service.retrieve(request_model) + except InvalidRequestError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="invalid request", + ) from exc + except WeKnoraUnavailableError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="upstream unavailable", + ) from exc + except WeKnoraBadResponseError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="invalid upstream response", + ) from exc + except ContentProcessingError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="content processing failed", + ) from exc + return {"records": []} + + return app diff --git a/src/knowledge_adapter/asset_content_processor.py b/src/knowledge_adapter/asset_content_processor.py new file mode 100644 index 0000000..23587f0 --- /dev/null +++ b/src/knowledge_adapter/asset_content_processor.py @@ -0,0 +1,166 @@ +import json +import re +from typing import Protocol + +from knowledge_adapter.asset_repository import ( + AssetMapping, + AssetMappingConflictError, + AssetRepositoryError, +) +from knowledge_adapter.models import WeKnoraChunk +from knowledge_adapter.resource_locator import InvalidResourceReference, parse_resource_reference +from knowledge_adapter.weknora_resource_resolver import ( + ResolvedAsset, + ResourceResolutionError, +) + +_MARKDOWN_RESOURCE_IMAGE = re.compile( + r"(?P!\[[^\]\n]*\]\()" + r"(?Presource://[A-Za-z0-9_-]{22})" + r"(?P\))" +) +_HTML_RESOURCE_IMAGE = re.compile( + r"(?P]*?\bsrc\s*=\s*(?P['\"]))" + r"(?Presource://[A-Za-z0-9_-]{22})" + r"(?P(?P=quote)[^>]*>)", + re.IGNORECASE, +) +_TRUNCATED_TRAILING_MARKDOWN_RESOURCE_IMAGE = re.compile( + r"!\[[^\]\n]*\]\(resource://[A-Za-z0-9_-]{0,22}\Z" +) +_FORBIDDEN_CONTENT = re.compile( + r"(?:" + r"local://" + r"|(? AssetMapping | None: ... + + async def get_or_create( + self, + *, + source_ref: str, + object_key: str, + content_type: str | None, + ) -> AssetMapping: ... + + +class ResourceResolver(Protocol): + async def resolve(self, source_ref: str) -> ResolvedAsset: ... + + +class AssetContentProcessor: + def __init__( + self, + *, + repository: MappingRepository, + resolver: ResourceResolver, + public_base_url: str, + ) -> None: + self._repository = repository + self._resolver = resolver + self._public_base_url = public_base_url.rstrip("/") + + async def _public_url( + self, + source_ref: str, + cache: dict[str, str], + ) -> str: + cached = cache.get(source_ref) + if cached is not None: + return cached + try: + mapping = await self._repository.get_active_by_source_ref(source_ref) + if mapping is None: + resolved = await self._resolver.resolve(source_ref) + mapping = await self._repository.get_or_create( + source_ref=source_ref, + object_key=resolved.object_key, + content_type=resolved.content_type, + ) + except ( + AssetMappingConflictError, + AssetRepositoryError, + ResourceResolutionError, + ) as error: + raise ContentProcessingError("content processing failed") from error + public_url = f"{self._public_base_url}/i/{mapping.asset_id}" + cache[source_ref] = public_url + return public_url + + async def process(self, chunk: WeKnoraChunk) -> str: + parts: list[str] = [] + public_urls: dict[str, str] = {} + cursor = 0 + for match in _MARKDOWN_RESOURCE_IMAGE.finditer(chunk.content): + source_ref = match.group("source_ref") + public_url = await self._public_url(source_ref, public_urls) + parts.extend( + ( + chunk.content[cursor : match.start()], + match.group("prefix"), + public_url, + match.group("suffix"), + ) + ) + cursor = match.end() + parts.append(chunk.content[cursor:]) + processed = "".join(parts) + + parts = [] + cursor = 0 + for match in _HTML_RESOURCE_IMAGE.finditer(processed): + source_ref = match.group("source_ref") + public_url = await self._public_url(source_ref, public_urls) + parts.extend( + ( + processed[cursor : match.start()], + match.group("prefix"), + public_url, + match.group("suffix"), + ) + ) + cursor = match.end() + parts.append(processed[cursor:]) + processed = "".join(parts) + processed = _TRUNCATED_TRAILING_MARKDOWN_RESOURCE_IMAGE.sub("", processed) + + seen_refs = set(public_urls) + try: + image_entries = json.loads(chunk.image_info) if chunk.image_info else [] + except json.JSONDecodeError as error: + raise ContentProcessingError("content processing failed") from error + if not isinstance(image_entries, list): + raise ContentProcessingError("content processing failed") + for entry in image_entries: + if not isinstance(entry, dict): + continue + for field in ("url", "original_url"): + candidate = entry.get(field) + if not isinstance(candidate, str): + continue + try: + source_ref = f"resource://{parse_resource_reference(candidate)}" + except InvalidResourceReference: + continue + if source_ref in seen_refs: + continue + seen_refs.add(source_ref) + public_url = await self._public_url(source_ref, public_urls) + separator = "\n\n" if processed else "" + processed += f"{separator}![知识图片]({public_url})" + if "resource://" in processed or _FORBIDDEN_CONTENT.search(processed): + raise ContentProcessingError("content processing failed") + return processed diff --git a/src/knowledge_adapter/asset_redirect.py b/src/knowledge_adapter/asset_redirect.py new file mode 100644 index 0000000..e8027b4 --- /dev/null +++ b/src/knowledge_adapter/asset_redirect.py @@ -0,0 +1,56 @@ +from typing import Protocol +from uuid import UUID + +from knowledge_adapter.asset_repository import AssetMapping, AssetRepositoryError + +PRESIGNED_GET_EXPIRES_SECONDS = 600 + + +class AssetNotFoundError(RuntimeError): + pass + + +class AssetSignerError(RuntimeError): + pass + + +class AssetRedirectUnavailableError(RuntimeError): + pass + + +class AssetLookupRepository(Protocol): + async def get_active_by_asset_id(self, asset_id: UUID) -> AssetMapping | None: ... + + +class AssetGetSigner(Protocol): + async def sign_get(self, *, object_key: str, expires_seconds: int) -> str: ... + + +class AssetRedirector(Protocol): + async def get_redirect_url(self, asset_id: UUID) -> str: ... + + +class AssetRedirectService: + def __init__( + self, + *, + repository: AssetLookupRepository, + signer: AssetGetSigner, + ) -> None: + self._repository = repository + self._signer = signer + + async def get_redirect_url(self, asset_id: UUID) -> str: + try: + mapping = await self._repository.get_active_by_asset_id(asset_id) + except AssetRepositoryError as error: + raise AssetRedirectUnavailableError("asset unavailable") from error + if mapping is None: + raise AssetNotFoundError("asset not found") + try: + return await self._signer.sign_get( + object_key=mapping.object_key, + expires_seconds=PRESIGNED_GET_EXPIRES_SECONDS, + ) + except AssetSignerError as error: + raise AssetRedirectUnavailableError("asset unavailable") from error diff --git a/src/knowledge_adapter/asset_repository.py b/src/knowledge_adapter/asset_repository.py new file mode 100644 index 0000000..8f28782 --- /dev/null +++ b/src/knowledge_adapter/asset_repository.py @@ -0,0 +1,113 @@ +from collections.abc import Mapping +from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Protocol, cast +from uuid import UUID, uuid4 + +_UPSERT_MAPPING = """ + INSERT INTO asset_mapping (asset_id, source_ref, object_key, content_type, status) + VALUES ($1, $2, $3, $4, 'active') + ON CONFLICT (source_ref) DO UPDATE + SET source_ref = asset_mapping.source_ref + WHERE asset_mapping.object_key = EXCLUDED.object_key + RETURNING asset_id, source_ref, object_key, content_type, created_at, status +""" + +_SELECT_ACTIVE_BY_ASSET_ID = """ + SELECT asset_id, source_ref, object_key, content_type, created_at, status + FROM asset_mapping + WHERE asset_id = $1 + AND status = 'active' +""" + +_SELECT_ACTIVE_BY_SOURCE_REF = """ + SELECT asset_id, source_ref, object_key, content_type, created_at, status + FROM asset_mapping + WHERE source_ref = $1 + AND status = 'active' +""" + + +@dataclass(frozen=True, slots=True) +class AssetMapping: + asset_id: UUID + source_ref: str + object_key: str + content_type: str | None + created_at: datetime + status: str + + +class AssetMappingConflictError(RuntimeError): + pass + + +class AssetRepositoryError(RuntimeError): + pass + + +class AssetConnection(Protocol): + async def fetchrow(self, query: str, *args: object) -> Mapping[str, Any] | None: ... + + +class AssetPool(Protocol): + def acquire(self) -> AbstractAsyncContextManager[AssetConnection]: ... + + +def _mapping_from_row(row: Mapping[str, Any]) -> AssetMapping: + return AssetMapping( + asset_id=UUID(str(row["asset_id"])), + source_ref=str(row["source_ref"]), + object_key=str(row["object_key"]), + content_type=str(row["content_type"]) if row["content_type"] else None, + created_at=cast(datetime, row["created_at"]), + status=str(row["status"]), + ) + + +class AssetRepository: + def __init__(self, *, pool: AssetPool) -> None: + self._pool = pool + + async def get_active_by_asset_id(self, asset_id: UUID) -> AssetMapping | None: + try: + async with self._pool.acquire() as connection: + row = await connection.fetchrow(_SELECT_ACTIVE_BY_ASSET_ID, asset_id) + except Exception as error: + raise AssetRepositoryError("asset repository unavailable") from error + if row is None: + return None + return _mapping_from_row(row) + + async def get_active_by_source_ref(self, source_ref: str) -> AssetMapping | None: + try: + async with self._pool.acquire() as connection: + row = await connection.fetchrow(_SELECT_ACTIVE_BY_SOURCE_REF, source_ref) + except Exception as error: + raise AssetRepositoryError("asset repository unavailable") from error + if row is None: + return None + return _mapping_from_row(row) + + async def get_or_create( + self, + *, + source_ref: str, + object_key: str, + content_type: str | None, + ) -> AssetMapping: + try: + async with self._pool.acquire() as connection: + row = await connection.fetchrow( + _UPSERT_MAPPING, + uuid4(), + source_ref, + object_key, + content_type, + ) + except Exception as error: + raise AssetRepositoryError("asset repository unavailable") from error + if row is None: + raise AssetMappingConflictError("asset mapping conflict") + return _mapping_from_row(row) diff --git a/src/knowledge_adapter/auth.py b/src/knowledge_adapter/auth.py new file mode 100644 index 0000000..7e9d673 --- /dev/null +++ b/src/knowledge_adapter/auth.py @@ -0,0 +1,38 @@ +import hashlib +import hmac +from typing import Annotated, NoReturn + +from fastapi import Header, HTTPException, status + + +class DifyKeyAuthenticator: + def __init__(self, *, current_hash: str, next_hash: str | None = None) -> None: + self.current_hash = current_hash + self.next_hash = next_hash + + async def __call__( + self, + authorization: Annotated[str | None, Header()] = None, + ) -> None: + if authorization is None: + self._reject() + + scheme, separator, token = authorization.partition(" ") + if scheme.lower() != "bearer" or separator != " " or not token or " " in token: + self._reject() + + digest = hashlib.sha256(token.encode()).hexdigest() + current_matches = hmac.compare_digest(digest, self.current_hash) + next_matches = False + if self.next_hash is not None: + next_matches = hmac.compare_digest(digest, self.next_hash) + if not (current_matches or next_matches): + self._reject() + + @staticmethod + def _reject() -> NoReturn: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/src/knowledge_adapter/content_budget.py b/src/knowledge_adapter/content_budget.py new file mode 100644 index 0000000..8d84615 --- /dev/null +++ b/src/knowledge_adapter/content_budget.py @@ -0,0 +1,25 @@ +import re + +TRUNCATION_MARKER = "[内容已由 Knowledge Adapter 截断]" +_MARKDOWN_LINK_PATTERN = re.compile(r"!?\[[^\]\n]*\]\([^\)\n]*\)") +_FENCED_CODE_PATTERN = re.compile(r"```[\s\S]*?(?:```|\Z)") + + +def _safe_cutoff(content: str, cutoff: int) -> int: + protected_starts = [ + match.start() + for pattern in (_MARKDOWN_LINK_PATTERN, _FENCED_CODE_PATTERN) + for match in pattern.finditer(content) + if match.start() < cutoff < match.end() + ] + return min(protected_starts, default=cutoff) + + +def truncate_content(content: str, *, max_chars: int) -> str: + if len(content) <= max_chars: + return content + if max_chars < len(TRUNCATION_MARKER): + raise ValueError("max_chars is too small for the truncation marker") + prefix_chars = max_chars - len(TRUNCATION_MARKER) + prefix_chars = _safe_cutoff(content, prefix_chars) + return f"{content[:prefix_chars]}{TRUNCATION_MARKER}" diff --git a/src/knowledge_adapter/entrypoint.py b/src/knowledge_adapter/entrypoint.py new file mode 100644 index 0000000..93b0a21 --- /dev/null +++ b/src/knowledge_adapter/entrypoint.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI + +from knowledge_adapter.factory import build_production_app +from knowledge_adapter.settings import Settings + + +def create_production_app() -> FastAPI: + settings = Settings() # type: ignore[call-arg] + return build_production_app(settings) diff --git a/src/knowledge_adapter/factory.py b/src/knowledge_adapter/factory.py new file mode 100644 index 0000000..8ea27f6 --- /dev/null +++ b/src/knowledge_adapter/factory.py @@ -0,0 +1,209 @@ +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from typing import Any +from uuid import UUID + +import asyncpg +import httpx +from fastapi import FastAPI + +from knowledge_adapter.app import create_app +from knowledge_adapter.asset_content_processor import ( + AssetContentProcessor, + ContentProcessingError, +) +from knowledge_adapter.asset_redirect import ( + AssetRedirectService, + AssetRedirectUnavailableError, +) +from knowledge_adapter.asset_repository import AssetRepository +from knowledge_adapter.auth import DifyKeyAuthenticator +from knowledge_adapter.models import WeKnoraChunk +from knowledge_adapter.oss_signer import ( + AlibabaOssV4Signer, + TemporaryCredentialClient, + create_ecs_ram_role_credential_client, + create_static_ak_credential_client, +) +from knowledge_adapter.service import RetrievalService +from knowledge_adapter.settings import Settings +from knowledge_adapter.weknora_client import WeKnoraClient +from knowledge_adapter.weknora_resource_resolver import WeKnoraResourceResolver + + +def build_app( + settings: Settings, + *, + weknora_transport: httpx.AsyncBaseTransport | None = None, +) -> FastAPI: + authenticator = DifyKeyAuthenticator( + current_hash=settings.dify_key_hash_current, + next_hash=settings.dify_key_hash_next, + ) + weknora_client = WeKnoraClient( + base_url=str(settings.weknora_base_url), + api_key=settings.read_weknora_api_key(), + timeout_seconds=settings.weknora_timeout_seconds, + transport=weknora_transport, + ) + retrieval_service = RetrievalService( + search_client=weknora_client, + allowed_kb_ids=settings.weknora_allowed_kb_ids, + max_top_k=settings.max_top_k, + max_chunk_chars=settings.max_chunk_chars, + max_total_chars=settings.max_total_chars, + ) + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + try: + yield + finally: + await weknora_client.aclose() + + app = create_app( + authenticator=authenticator, + retrieval_service=retrieval_service, + max_query_chars=settings.max_query_chars, + lifespan=lifespan, + ) + app.state.weknora_client = weknora_client + return app + + +PoolFactory = Callable[..., Awaitable[Any]] +CredentialClientFactory = Callable[[str], TemporaryCredentialClient] + + +class _RuntimeAssetRedirector: + def __init__(self) -> None: + self._delegate: AssetRedirectService | None = None + + def bind(self, delegate: AssetRedirectService) -> None: + self._delegate = delegate + + async def get_redirect_url(self, asset_id: UUID) -> str: + if self._delegate is None: + raise AssetRedirectUnavailableError("asset unavailable") + return await self._delegate.get_redirect_url(asset_id) + + +class _RuntimeContentProcessor: + def __init__(self) -> None: + self._delegate: AssetContentProcessor | None = None + + def bind(self, delegate: AssetContentProcessor) -> None: + self._delegate = delegate + + async def process(self, chunk: WeKnoraChunk) -> str: + if self._delegate is None: + raise ContentProcessingError("content processing failed") + return await self._delegate.process(chunk) + + +def build_production_app( + settings: Settings, + *, + pool_factory: PoolFactory | None = None, + credential_client_factory: CredentialClientFactory | None = None, + weknora_transport: httpx.AsyncBaseTransport | None = None, +) -> FastAPI: + effective_pool_factory = pool_factory or asyncpg.create_pool + effective_credential_client_factory = ( + credential_client_factory or create_ecs_ram_role_credential_client + ) + authenticator = DifyKeyAuthenticator( + current_hash=settings.dify_key_hash_current, + next_hash=settings.dify_key_hash_next, + ) + weknora_client = WeKnoraClient( + base_url=str(settings.weknora_base_url), + api_key=settings.read_weknora_api_key(), + timeout_seconds=settings.weknora_timeout_seconds, + transport=weknora_transport, + ) + runtime_processor = _RuntimeContentProcessor() + runtime_redirector = _RuntimeAssetRedirector() + retrieval_service = RetrievalService( + search_client=weknora_client, + allowed_kb_ids=settings.weknora_allowed_kb_ids, + max_top_k=settings.max_top_k, + max_chunk_chars=settings.max_chunk_chars, + max_total_chars=settings.max_total_chars, + content_processor=runtime_processor, + ) + + @asynccontextmanager + async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + pools: list[Any] = [] + try: + asset_dsn = settings.asset_database_url_file.read_text(encoding="utf-8").strip() + weknora_dsn = settings.weknora_readonly_database_url_file.read_text( + encoding="utf-8" + ).strip() + asset_pool = await effective_pool_factory( + dsn=asset_dsn, + min_size=1, + max_size=5, + command_timeout=5, + server_settings={"statement_timeout": "5000"}, + ) + pools.append(asset_pool) + weknora_pool = await effective_pool_factory( + dsn=weknora_dsn, + min_size=1, + max_size=2, + command_timeout=1, + server_settings={ + "default_transaction_read_only": "on", + "statement_timeout": "1000", + }, + ) + pools.append(weknora_pool) + asset_repository = AssetRepository(pool=asset_pool) + runtime_processor.bind( + AssetContentProcessor( + repository=asset_repository, + resolver=WeKnoraResourceResolver( + pool=weknora_pool, + tenant_id=settings.weknora_tenant_id, + storage_backend_id=settings.weknora_oss_storage_backend_id, + bucket=settings.oss_bucket, + ), + public_base_url=str(settings.asset_public_base_url), + ) + ) + if settings.oss_credential_mode == "static_ak": + credential_client = create_static_ak_credential_client( + settings.read_oss_access_key_id(), + settings.read_oss_access_key_secret(), + ) + else: + if settings.oss_ram_role_name is None: + raise RuntimeError("OSS credential unavailable") + credential_client = effective_credential_client_factory(settings.oss_ram_role_name) + runtime_redirector.bind( + AssetRedirectService( + repository=asset_repository, + signer=AlibabaOssV4Signer( + credential_client=credential_client, + region=settings.oss_region, + bucket=settings.oss_bucket, + ), + ) + ) + yield + finally: + for pool in reversed(pools): + await pool.close() + await weknora_client.aclose() + + app = create_app( + authenticator=authenticator, + retrieval_service=retrieval_service, + asset_redirect_service=runtime_redirector, + max_query_chars=settings.max_query_chars, + lifespan=lifespan, + ) + app.state.weknora_client = weknora_client + return app diff --git a/src/knowledge_adapter/models.py b/src/knowledge_adapter/models.py new file mode 100644 index 0000000..b4432be --- /dev/null +++ b/src/knowledge_adapter/models.py @@ -0,0 +1,56 @@ +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +class RetrievalSetting(BaseModel): + model_config = ConfigDict(extra="forbid") + + top_k: int = Field(ge=1) + score_threshold: float = Field(ge=0, le=1) + + +class MetadataCondition(BaseModel): + model_config = ConfigDict(extra="forbid") + + logical_operator: str | None = None + conditions: list[dict[str, Any]] = Field(default_factory=list) + + @model_validator(mode="after") + def reject_nonempty_conditions(self) -> "MetadataCondition": + if self.conditions: + raise ValueError("metadata conditions are not supported") + return self + + +class RetrievalRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + knowledge_id: UUID + query: str + retrieval_setting: RetrievalSetting + metadata_condition: MetadataCondition | None = None + + @field_validator("query") + @classmethod + def strip_nonempty_query(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("query must not be empty") + return normalized + + +class WeKnoraChunk(BaseModel): + model_config = ConfigDict(extra="ignore", allow_inf_nan=False) + + id: UUID + content: str + score: float + knowledge_base_id: UUID + knowledge_id: UUID + knowledge_title: str = "" + knowledge_filename: str = "" + chunk_index: int + chunk_type: str + image_info: str = "" diff --git a/src/knowledge_adapter/observability.py b/src/knowledge_adapter/observability.py new file mode 100644 index 0000000..77f5b60 --- /dev/null +++ b/src/knowledge_adapter/observability.py @@ -0,0 +1,74 @@ +import json +import logging +import re +from contextvars import ContextVar, Token +from time import monotonic_ns +from uuid import uuid4 + +_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_HTTP_LOGGER = logging.getLogger("knowledge_adapter.http") +_CURRENT_REQUEST_ID: ContextVar[str | None] = ContextVar( + "knowledge_adapter_request_id", + default=None, +) +_ERROR_CODE_BY_STATUS = { + 400: "invalid_request", + 401: "unauthorized", + 403: "forbidden", + 404: "not_found", + 405: "method_not_allowed", + 413: "invalid_request", + 418: "request_error", + 422: "invalid_request", + 500: "internal_error", + 502: "upstream_error", + 503: "unavailable", +} + + +def normalize_request_id(value: str | None) -> str: + if value is not None and _REQUEST_ID_PATTERN.fullmatch(value): + return value + return str(uuid4()) + + +def bind_request_id(request_id: str) -> Token[str | None]: + return _CURRENT_REQUEST_ID.set(request_id) + + +def reset_request_id(token: Token[str | None]) -> None: + _CURRENT_REQUEST_ID.reset(token) + + +def current_request_id() -> str | None: + return _CURRENT_REQUEST_ID.get() + + +def request_started_at() -> int: + return monotonic_ns() + + +def log_http_request( + *, + request_id: str, + method: str, + route: str, + status_code: int, + started_at_ns: int, +) -> None: + duration_ms = max(0, (monotonic_ns() - started_at_ns) // 1_000_000) + event = { + "duration_ms": duration_ms, + "event": "http_request", + "method": method, + "request_id": request_id, + "route": route, + "status_code": status_code, + } + if status_code >= 400: + event["error_code"] = _ERROR_CODE_BY_STATUS.get(status_code, "request_error") + level = logging.ERROR if status_code >= 500 else logging.INFO + _HTTP_LOGGER.log( + level, + json.dumps(event, separators=(",", ":"), sort_keys=True), + ) diff --git a/src/knowledge_adapter/oss_signer.py b/src/knowledge_adapter/oss_signer.py new file mode 100644 index 0000000..e5f1ccd --- /dev/null +++ b/src/knowledge_adapter/oss_signer.py @@ -0,0 +1,124 @@ +import asyncio +from typing import Protocol +from urllib.parse import parse_qs, urlparse + +import oss2 +from alibabacloud_credentials.client import Client as CredentialsClient +from alibabacloud_credentials.models import Config, CredentialModel +from oss2.credentials import Credentials +from pydantic import SecretStr + +from knowledge_adapter.asset_redirect import AssetSignerError +from knowledge_adapter.resource_locator import InvalidResourceLocator, validate_oss_object_key + + +class TemporaryCredentialModel(Protocol): + access_key_id: str | None + access_key_secret: str | None + security_token: str | None + + +class TemporaryCredentialClient(Protocol): + def get_credential(self) -> TemporaryCredentialModel: ... + + +class OssBucket(Protocol): + def sign_url( + self, + method: str, + key: str, + expires: int, + ) -> str: ... + + +def create_ecs_ram_role_credential_client(role_name: str) -> TemporaryCredentialClient: + config = Config( + type="ecs_ram_role", + role_name=role_name, + enable_imds_v2=True, + disable_imds_v1=True, + connect_timeout=1000, + timeout=1000, + ) + return CredentialsClient(config) + + +class _StaticAkCredentialClient: + def __init__(self, access_key_id: SecretStr, access_key_secret: SecretStr) -> None: + self._access_key_id = access_key_id + self._access_key_secret = access_key_secret + + def get_credential(self) -> CredentialModel: + return CredentialModel( + access_key_id=self._access_key_id.get_secret_value(), + access_key_secret=self._access_key_secret.get_secret_value(), + security_token="", + type="access_key", + ) + + +def create_static_ak_credential_client( + access_key_id: SecretStr, + access_key_secret: SecretStr, +) -> TemporaryCredentialClient: + return _StaticAkCredentialClient(access_key_id, access_key_secret) + + +class _RefreshingOssCredentialsProvider: + def __init__(self, client: TemporaryCredentialClient) -> None: + self._client = client + + def get_credentials(self) -> Credentials: + credential = self._client.get_credential() + return Credentials( + credential.access_key_id or "", + credential.access_key_secret or "", + credential.security_token or "", + ) + + +class AlibabaOssV4Signer: + def __init__( + self, + *, + credential_client: TemporaryCredentialClient, + region: str, + bucket: str, + ) -> None: + endpoint = f"https://oss-{region}.aliyuncs.com" + auth = oss2.ProviderAuthV4(_RefreshingOssCredentialsProvider(credential_client)) + self._expected_hostname = f"{bucket}.oss-{region}.aliyuncs.com" + self._bucket: OssBucket = oss2.Bucket( + auth, + endpoint, + bucket, + region=region, + ) + + async def sign_get(self, *, object_key: str, expires_seconds: int) -> str: + try: + validated_key = validate_oss_object_key(object_key) + except InvalidResourceLocator as error: + raise AssetSignerError("asset signing failed") from error + if expires_seconds != 600: + raise AssetSignerError("asset signing failed") + try: + signed_url = await asyncio.to_thread( + self._bucket.sign_url, + "GET", + validated_key, + expires_seconds, + ) + except Exception as error: + raise AssetSignerError("asset signing failed") from error + parsed = urlparse(signed_url) + query = parse_qs(parsed.query) + if ( + parsed.scheme != "https" + or parsed.hostname != self._expected_hostname + or query.get("x-oss-signature-version") != ["OSS4-HMAC-SHA256"] + or query.get("x-oss-expires") != ["600"] + or not query.get("x-oss-signature") + ): + raise AssetSignerError("asset signing failed") + return signed_url diff --git a/src/knowledge_adapter/resource_locator.py b/src/knowledge_adapter/resource_locator.py new file mode 100644 index 0000000..04eaed0 --- /dev/null +++ b/src/knowledge_adapter/resource_locator.py @@ -0,0 +1,57 @@ +import re + +_RESOURCE_REFERENCE_PATTERN = re.compile(r"resource://([A-Za-z0-9_-]{22})\Z") + + +class InvalidResourceReference(ValueError): + pass + + +class InvalidResourceLocator(ValueError): + pass + + +def parse_resource_reference(value: str) -> str: + match = _RESOURCE_REFERENCE_PATTERN.fullmatch(value.strip()) + if match is None: + raise InvalidResourceReference("invalid resource reference") + return match.group(1) + + +def parse_oss_object_key( + *, + provider: str, + physical_path: str, + storage_backend_id: str, + expected_storage_backend_id: str, + expected_bucket: str, +) -> str: + if provider != "oss" or storage_backend_id != expected_storage_backend_id: + raise InvalidResourceLocator("invalid resource locator") + + storage_prefix = f"storage://{expected_storage_backend_id}/" + if not physical_path.startswith(storage_prefix): + raise InvalidResourceLocator("invalid resource locator") + provider_path = physical_path.removeprefix(storage_prefix) + + oss_prefix = f"oss://{expected_bucket}/" + if not provider_path.startswith(oss_prefix): + raise InvalidResourceLocator("invalid resource locator") + object_key = provider_path.removeprefix(oss_prefix) + return validate_oss_object_key(object_key) + + +def validate_oss_object_key(object_key: str) -> str: + segments = object_key.split("/") + invalid_character = any( + ord(char) < 32 or ord(char) == 127 or char in "\\?#%" + for char in object_key + ) + if ( + not object_key + or len(object_key.encode("utf-8")) > 1023 + or invalid_character + or any(segment in {"", ".", ".."} for segment in segments) + ): + raise InvalidResourceLocator("invalid resource locator") + return object_key diff --git a/src/knowledge_adapter/service.py b/src/knowledge_adapter/service.py new file mode 100644 index 0000000..b68da58 --- /dev/null +++ b/src/knowledge_adapter/service.py @@ -0,0 +1,96 @@ +from typing import Any, Protocol +from uuid import UUID + +from pydantic import ValidationError + +from knowledge_adapter.content_budget import truncate_content +from knowledge_adapter.models import RetrievalRequest, WeKnoraChunk + + +class InvalidRequestError(Exception): + """A client request violates an adapter-owned policy.""" + + +class SearchClient(Protocol): + async def search( + self, + *, + query: str, + knowledge_base_id: UUID, + ) -> list[dict[str, Any]]: ... + + +class ContentProcessor(Protocol): + async def process(self, chunk: WeKnoraChunk) -> str: ... + + +class IdentityContentProcessor: + async def process(self, chunk: WeKnoraChunk) -> str: + return chunk.content + + +class RetrievalService: + def __init__( + self, + *, + search_client: SearchClient, + allowed_kb_ids: frozenset[UUID], + max_top_k: int = 5, + max_chunk_chars: int = 4000, + max_total_chars: int = 12000, + content_processor: ContentProcessor | None = None, + ) -> None: + self._search_client = search_client + self._allowed_kb_ids = allowed_kb_ids + self._max_top_k = max_top_k + self._max_chunk_chars = max_chunk_chars + self._max_total_chars = max_total_chars + self._content_processor = content_processor or IdentityContentProcessor() + + async def retrieve( + self, + request: RetrievalRequest, + ) -> dict[str, list[dict[str, object]]]: + if request.knowledge_id not in self._allowed_kb_ids: + raise InvalidRequestError + raw_results = await self._search_client.search( + query=request.query, + knowledge_base_id=request.knowledge_id, + ) + records: list[dict[str, object]] = [] + returned_chars = 0 + effective_top_k = min(request.retrieval_setting.top_k, self._max_top_k) + for raw_chunk in raw_results: + try: + chunk = WeKnoraChunk.model_validate(raw_chunk) + except ValidationError: + continue + if chunk.knowledge_base_id != request.knowledge_id: + continue + if chunk.score < request.retrieval_setting.score_threshold: + continue + processed_content = await self._content_processor.process(chunk) + content = truncate_content( + processed_content, + max_chars=self._max_chunk_chars, + ) + if returned_chars + len(content) > self._max_total_chars: + break + records.append( + { + "content": content, + "score": chunk.score, + "title": chunk.knowledge_title or chunk.knowledge_filename, + "metadata": { + "knowledge_base_id": str(chunk.knowledge_base_id), + "knowledge_id": str(chunk.knowledge_id), + "chunk_id": str(chunk.id), + "chunk_index": chunk.chunk_index, + "chunk_type": chunk.chunk_type, + }, + } + ) + returned_chars += len(content) + if len(records) >= effective_top_k: + break + return {"records": records} diff --git a/src/knowledge_adapter/settings.py b/src/knowledge_adapter/settings.py new file mode 100644 index 0000000..2248b4d --- /dev/null +++ b/src/knowledge_adapter/settings.py @@ -0,0 +1,138 @@ +from pathlib import Path +from typing import Annotated, Any, Literal, Self +from uuid import UUID + +from pydantic import AnyHttpUrl, Field, SecretStr, field_validator, model_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict + +from knowledge_adapter.content_budget import TRUNCATION_MARKER + + +class SecretUnavailableError(RuntimeError): + pass + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=None, + case_sensitive=False, + extra="ignore", + populate_by_name=True, + ) + + dify_key_hash_current: str = Field(alias="DIFY_KEY_HASH_CURRENT") + dify_key_hash_next: str | None = Field(default=None, alias="DIFY_KEY_HASH_NEXT") + weknora_base_url: AnyHttpUrl = Field(alias="WEKNORA_BASE_URL") + weknora_api_key_file: Path = Field( + default=Path("/run/secrets/weknora_api_key"), + alias="WEKNORA_API_KEY_FILE", + ) + weknora_allowed_kb_ids: Annotated[frozenset[UUID], NoDecode] = Field( + alias="WEKNORA_ALLOWED_KB_IDS" + ) + weknora_tenant_id: int = Field(gt=0, alias="WEKNORA_TENANT_ID") + weknora_oss_storage_backend_id: str = Field( + min_length=1, + max_length=36, + alias="WEKNORA_OSS_STORAGE_BACKEND_ID", + ) + oss_region: str = Field( + min_length=3, + max_length=32, + pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)+$", + alias="OSS_REGION", + ) + oss_bucket: str = Field( + min_length=3, + max_length=63, + pattern=r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", + alias="OSS_BUCKET", + ) + oss_credential_mode: Literal["ecs_ram_role", "static_ak"] = Field( + default="ecs_ram_role", + alias="OSS_CREDENTIAL_MODE", + ) + oss_ram_role_name: str | None = Field( + default=None, + min_length=1, + max_length=64, + pattern=r"^[A-Za-z0-9.@_-]+$", + alias="OSS_RAM_ROLE_NAME", + ) + oss_access_key_id_file: Path = Field( + default=Path("/run/secrets/oss_access_key_id"), + alias="OSS_ACCESS_KEY_ID_FILE", + ) + oss_access_key_secret_file: Path = Field( + default=Path("/run/secrets/oss_access_key_secret"), + alias="OSS_ACCESS_KEY_SECRET_FILE", + ) + asset_public_base_url: AnyHttpUrl = Field(alias="ASSET_PUBLIC_BASE_URL") + + max_query_chars: int = Field(default=2000, ge=1, alias="MAX_QUERY_CHARS") + max_top_k: int = Field(default=5, ge=1, alias="MAX_TOP_K") + max_chunk_chars: int = Field( + default=4000, + ge=len(TRUNCATION_MARKER), + alias="MAX_CHUNK_CHARS", + ) + max_total_chars: int = Field(default=12000, ge=1, alias="MAX_TOTAL_CHARS") + weknora_timeout_seconds: float = Field( + default=15, + gt=0, + alias="WEKNORA_TIMEOUT_SECONDS", + ) + + asset_database_url_file: Path = Path("/run/secrets/asset_database_url") + weknora_readonly_database_url_file: Path = Path( + "/run/secrets/weknora_readonly_database_url" + ) + + @field_validator("dify_key_hash_next", mode="before") + @classmethod + def empty_next_hash_is_none(cls, value: Any) -> Any: + if isinstance(value, str) and not value.strip(): + return None + return value + + @field_validator("dify_key_hash_current", "dify_key_hash_next") + @classmethod + def validate_key_hash(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.lower() + if len(normalized) != 64 or any(char not in "0123456789abcdef" for char in normalized): + raise ValueError("Dify key hashes must be 64 hexadecimal characters") + return normalized + + @field_validator("weknora_allowed_kb_ids", mode="before") + @classmethod + def parse_allowed_kb_ids(cls, value: Any) -> Any: + if isinstance(value, str): + return frozenset(UUID(item.strip()) for item in value.split(",") if item.strip()) + return value + + @model_validator(mode="after") + def require_ram_role_for_ram_role_mode(self) -> Self: + if self.oss_credential_mode == "ecs_ram_role" and self.oss_ram_role_name is None: + raise ValueError("OSS RAM role name is required") + return self + + @staticmethod + def _read_secret(path: Path) -> SecretStr: + try: + value = path.read_text(encoding="utf-8").strip() + except OSError: + raise SecretUnavailableError("required secret unavailable") from None + if not value: + raise SecretUnavailableError("required secret unavailable") + return SecretStr(value) + + def read_weknora_api_key(self) -> SecretStr: + return self._read_secret(self.weknora_api_key_file) + + def read_oss_access_key_id(self) -> SecretStr: + return self._read_secret(self.oss_access_key_id_file) + + def read_oss_access_key_secret(self) -> SecretStr: + return self._read_secret(self.oss_access_key_secret_file) diff --git a/src/knowledge_adapter/weknora_client.py b/src/knowledge_adapter/weknora_client.py new file mode 100644 index 0000000..b89b22a --- /dev/null +++ b/src/knowledge_adapter/weknora_client.py @@ -0,0 +1,89 @@ +from types import TracebackType +from uuid import UUID + +import httpx +from pydantic import SecretStr + +from knowledge_adapter.observability import current_request_id + + +class WeKnoraUnavailableError(Exception): + """WeKnora could not be reached within the configured budget.""" + + +class WeKnoraBadResponseError(Exception): + """WeKnora returned an unusable response.""" + + +class WeKnoraClient: + def __init__( + self, + *, + base_url: str, + api_key: SecretStr, + timeout_seconds: float, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + short_timeout = min(5.0, timeout_seconds) + timeout = httpx.Timeout( + timeout_seconds, + connect=short_timeout, + read=timeout_seconds, + write=short_timeout, + pool=short_timeout, + ) + limits = httpx.Limits(max_connections=100, max_keepalive_connections=20) + self._client = httpx.AsyncClient( + base_url=base_url.rstrip("/"), + headers={"X-API-Key": api_key.get_secret_value()}, + timeout=timeout, + limits=limits, + transport=transport, + trust_env=False, + ) + + async def __aenter__(self) -> "WeKnoraClient": + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.aclose() + + async def aclose(self) -> None: + await self._client.aclose() + + async def search( + self, + *, + query: str, + knowledge_base_id: UUID, + ) -> list[dict[str, object]]: + request_id = current_request_id() + headers = {"X-Request-ID": request_id} if request_id is not None else None + try: + response = await self._client.post( + "/api/v1/knowledge-search", + headers=headers, + json={ + "query": query, + "knowledge_base_ids": [str(knowledge_base_id)], + }, + ) + except (httpx.TimeoutException, httpx.NetworkError) as exc: + raise WeKnoraUnavailableError("WeKnora unavailable") from exc + if not response.is_success: + raise WeKnoraBadResponseError("invalid WeKnora response") + try: + payload = response.json() + except ValueError as exc: + raise WeKnoraBadResponseError("invalid WeKnora response") from exc + if not isinstance(payload, dict) or payload.get("success") is not True: + raise WeKnoraBadResponseError("invalid WeKnora response") + data = payload.get("data") + if not isinstance(data, list) or any(not isinstance(item, dict) for item in data): + raise WeKnoraBadResponseError("invalid WeKnora response") + return data diff --git a/src/knowledge_adapter/weknora_resource_resolver.py b/src/knowledge_adapter/weknora_resource_resolver.py new file mode 100644 index 0000000..602a996 --- /dev/null +++ b/src/knowledge_adapter/weknora_resource_resolver.py @@ -0,0 +1,100 @@ +from collections.abc import Mapping +from contextlib import AbstractAsyncContextManager +from dataclasses import dataclass +from typing import Any, Protocol + +from knowledge_adapter.resource_locator import ( + InvalidResourceLocator, + InvalidResourceReference, + parse_oss_object_key, + parse_resource_reference, +) + +_RESOURCE_QUERY = """ + SELECT tenant_id, storage_backend_id, provider, physical_path, mime_type + FROM resources + WHERE handle = $1 + AND tenant_id = $2 + AND storage_backend_id = $3 + AND provider = 'oss' + AND state = 'active' + AND deleted_at IS NULL + LIMIT 2 +""" + + +@dataclass(frozen=True, slots=True) +class ResolvedAsset: + object_key: str + content_type: str | None + + +class ResourceResolutionError(RuntimeError): + pass + + +class ReadonlyConnection(Protocol): + def transaction(self, *, readonly: bool) -> AbstractAsyncContextManager[None]: ... + + async def fetch(self, query: str, *args: object) -> list[Mapping[str, Any]]: ... + + +class ReadonlyPool(Protocol): + def acquire(self) -> AbstractAsyncContextManager[ReadonlyConnection]: ... + + +class WeKnoraResourceResolver: + def __init__( + self, + *, + pool: ReadonlyPool, + tenant_id: int, + storage_backend_id: str, + bucket: str, + ) -> None: + self._pool = pool + self._tenant_id = tenant_id + self._storage_backend_id = storage_backend_id + self._bucket = bucket + + async def resolve(self, source_ref: str) -> ResolvedAsset: + try: + handle = parse_resource_reference(source_ref) + except InvalidResourceReference as error: + raise ResourceResolutionError("resource unavailable") from error + try: + async with self._pool.acquire() as connection: + async with connection.transaction(readonly=True): + rows = await connection.fetch( + _RESOURCE_QUERY, + handle, + self._tenant_id, + self._storage_backend_id, + ) + except Exception as error: + raise ResourceResolutionError("resource unavailable") from error + if len(rows) != 1: + raise ResourceResolutionError("resource unavailable") + row = rows[0] + if ( + row["tenant_id"] != self._tenant_id + or str(row["storage_backend_id"]) != self._storage_backend_id + or row["provider"] != "oss" + ): + raise ResourceResolutionError("resource unavailable") + try: + object_key = parse_oss_object_key( + provider=str(row["provider"]), + physical_path=str(row["physical_path"]), + storage_backend_id=str(row["storage_backend_id"]), + expected_storage_backend_id=self._storage_backend_id, + expected_bucket=self._bucket, + ) + except InvalidResourceLocator as error: + raise ResourceResolutionError("resource unavailable") from error + + mime_type = row["mime_type"] + return ResolvedAsset( + object_key=object_key, + content_type=str(mime_type) if mime_type else None, + ) diff --git a/tests/test_asset_content_processor.py b/tests/test_asset_content_processor.py new file mode 100644 index 0000000..343e05b --- /dev/null +++ b/tests/test_asset_content_processor.py @@ -0,0 +1,314 @@ +import json +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from knowledge_adapter.asset_content_processor import ( + AssetContentProcessor, + ContentProcessingError, +) +from knowledge_adapter.asset_repository import AssetMapping +from knowledge_adapter.models import WeKnoraChunk +from knowledge_adapter.weknora_resource_resolver import ( + ResolvedAsset, + ResourceResolutionError, +) + +SOURCE_REF = "resource://AbCdEfGhIjKlMnOpQrSt_1" +ASSET_ID = UUID("c7f61f89-19ce-45b1-ac60-dbeed5e2a491") +KB_ID = UUID("3904b40e-278b-4273-a6fa-db6cbc975587") + + +class RecordingAssetRepository: + def __init__( + self, + mapping: AssetMapping | None, + *, + created_mapping: AssetMapping | None = None, + ) -> None: + self.mapping = mapping + self.created_mapping = created_mapping + self.lookup_calls: list[str] = [] + self.create_calls: list[tuple[str, str, str | None]] = [] + + async def get_active_by_source_ref(self, source_ref: str) -> AssetMapping | None: + self.lookup_calls.append(source_ref) + return self.mapping + + async def get_or_create( + self, + *, + source_ref: str, + object_key: str, + content_type: str | None, + ) -> AssetMapping: + self.create_calls.append((source_ref, object_key, content_type)) + if self.created_mapping is None: + raise AssertionError("unexpected mapping creation") + return self.created_mapping + + +class RecordingResolver: + def __init__( + self, + resolved: ResolvedAsset | None = None, + *, + error: Exception | None = None, + ) -> None: + self.resolved = resolved + self.error = error + self.calls: list[str] = [] + + async def resolve(self, source_ref: str) -> ResolvedAsset: + self.calls.append(source_ref) + if self.error is not None: + raise self.error + if self.resolved is None: + raise AssertionError("unexpected WeKnora DB resolution") + return self.resolved + + +def make_chunk(*, content: str, image_info: str = "") -> WeKnoraChunk: + return WeKnoraChunk.model_validate( + { + "id": "09ab721f-7d05-45de-ae0f-dac303276f6d", + "content": content, + "score": 0.9, + "knowledge_base_id": str(KB_ID), + "knowledge_id": "6f77a258-27ff-4278-a667-8fe242ff8e10", + "knowledge_title": "Manual", + "knowledge_filename": "manual.pdf", + "chunk_index": 0, + "chunk_type": "text", + "image_info": image_info, + } + ) + + +def active_mapping() -> AssetMapping: + return AssetMapping( + asset_id=ASSET_ID, + source_ref=SOURCE_REF, + object_key="weknora/10008/assets/image.png", + content_type="image/png", + created_at=datetime(2026, 8, 24, tzinfo=UTC), + status="active", + ) + + +async def test_markdown_resource_uses_existing_mapping_without_weknora_db() -> None: + repository = RecordingAssetRepository(active_mapping()) + resolver = RecordingResolver() + processor = AssetContentProcessor( + repository=repository, + resolver=resolver, + public_base_url="https://assets.example.com", + ) + chunk = make_chunk(content=f"Before ![diagram]({SOURCE_REF}) after") + + processed = await processor.process(chunk) + + assert processed == ( + f"Before ![diagram](https://assets.example.com/i/{ASSET_ID}) after" + ) + assert repository.lookup_calls == [SOURCE_REF] + assert repository.create_calls == [] + assert resolver.calls == [] + + +async def test_html_img_resource_uses_existing_mapping_without_weknora_db() -> None: + repository = RecordingAssetRepository(active_mapping()) + resolver = RecordingResolver() + processor = AssetContentProcessor( + repository=repository, + resolver=resolver, + public_base_url="https://assets.example.com", + ) + chunk = make_chunk(content=f'