feat(asset): 清理被截断的结尾图片引用,修复整单 502

仓库首次入库(此前仅存在于本地工作区与测试服构建产物中,未上传)。

改动(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 比对,为唯一变更文件
This commit is contained in:
姚章浩
2026-09-16 10:01:27 +08:00
commit 693cd952cd
64 changed files with 7869 additions and 0 deletions
+19
View File
@@ -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
+14
View File
@@ -0,0 +1,14 @@
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
*.py[cod]
.env
*.log
secrets/
*.secret
*.key
*.pem
.DS_Store
+25
View File
@@ -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"]
+24
View File
@@ -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
+29
View File
@@ -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.
+26
View File
@@ -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=<uuid>[,<uuid>]
WEKNORA_TENANT_ID=<positive-integer>
WEKNORA_OSS_STORAGE_BACKEND_ID=<backend-uuid>
OSS_REGION=cn-shenzhen
OSS_BUCKET=<private-bucket-name>
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
+87
View File
@@ -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}"
+87
View File
@@ -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;
}
}
+149
View File
@@ -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()
+21
View File
@@ -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())
+118
View File
@@ -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'
@@ -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
+61
View File
@@ -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))
+118
View File
@@ -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;
}
}
@@ -0,0 +1,14 @@
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": [
"oss:GetObject"
],
"Resource": [
"acs:oss:*:*:${OSS_BUCKET}/weknora/${WEKNORA_TENANT_ID}/*"
]
}
]
}
+15
View File
@@ -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;
+97
View File
@@ -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.
+129
View File
@@ -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**.
+144
View File
@@ -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.
+356
View File
@@ -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": "<Markdown知识片段,包含稳定的 /i/<UUID> 图片地址>",
"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/<UUID>
```
真实样例:
```text
https://weknora-api-test.dobot.cn/i/02e269e8-c4f0-4b3f-a41e-82f5b222a31f
```
前端直接将稳定地址作为图片地址:
```html
<img src="https://weknora-api-test.dobot.cn/i/<UUID>" alt="知识图片">
```
图片访问不需要Bearer Key:
```text
浏览器GET /i/<UUID>
→ Adapter查询有效映射
→ HTTP 302
→ 私有OSS 10分钟签名URL
→ 图片HTTP 200
```
注意:
- 保存和渲染稳定`/i/<UUID>`地址;
- 不保存、不打印、不缓存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/<UUID>` |
| 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和独立知识库白名单。
+83
View File
@@ -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.
+114
View File
@@ -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
+107
View File
@@ -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": "<table><tr><td rowspan=1 colspan=1>接口</td><td rowspan=1 colspan=1>参数</td><td rowspan=1 colspan=1>最小值</td><td rowspan=1 colspan=1>典型值</td><td rowspan=1 colspan=1>最大值</td><td rowspan=1 colspan=1>单位</td></tr><tr><td rowspan=1 colspan=2>模拟输入(电压模式)</td><td rowspan=1 colspan=1></td><td rowspan=1 colspan=1></td><td rowspan=1 colspan=1></td><td rowspan=1 colspan=1></td></tr><tr><td rowspan=3 colspan=1>Alx</td><td rowspan=1 colspan=1>电压</td><td rowspan=1 colspan=1>0</td><td rowspan=1 colspan=1>1</td><td rowspan=1 colspan=1>10</td><td rowspan=1 colspan=1>V</td></tr><tr><td rowspan=1 colspan=1>电阻</td><td rowspan=1 colspan=1>1</td><td rowspan=1 colspan=1>14.7</td><td rowspan=1 colspan=1>1</td><td rowspan=1 colspan=1>kΩ</td></tr><tr><td rowspan=1 colspan=1>分辨率</td><td rowspan=1 colspan=1>1</td><td rowspan=1 colspan=1>12</td><td rowspan=1 colspan=1>1</",
"score": 0.5136077357877354,
"title": "Dobot Nova系列(适配DSC)硬件使用手册.pdf",
"metadata": {
"knowledge_base_id": "b9dacd62-87eb-4b38-a6b6-85f68e144796",
"knowledge_id": "2ca9b86e-43d6-47cd-b519-727a4333dae8",
"chunk_id": "51963bfd-2514-458a-8e59-51b17e571d56",
"chunk_index": 95,
"chunk_type": "text"
}
},
{
"content": " \n机器人控制柜 \n焊机通讯模块和网关之间的线缆请按照设备上显示的接口定义对应连接。\n\n## 2.2.3 连接验证\n\n硬件连接完成后,可在焊接插件中完成焊机匹配后,使用手动送丝/退丝功能验证通讯是否正常。\n\n## 2.3 模拟通讯\n\n## 2.3.1 准备工作\n\n下图为焊接插件中需配置的I/O信号,请先根据现场需求进行规划:\n\n①控制柜信号:必须配置。\n\n②接触寻位信号/③其他信号/④弧焊模板信号:根据实际业务需求配置。\n\n信号的含义请参考基础焊接信号配置和接触寻位信号配置。\n\n![](https://weknora-api-test.dobot.cn/i/f6617db7-c032-4ae4-b67c-4f06ed5ccb0e) \n然后,请根据规划准备信号线。\n\n•模拟信号线x3,为提高模拟信号的精度,建议使用屏蔽线或双绞线。\n\n• 数字信号线,数量根据I/O 规划决定。\n\n## 2.3.2 10 接线\n\n连接控制柜模拟信号接口与对应的焊机模拟通讯接口。\n\n![](https://weknora-api-test.dobot.cn/i/034166a0-67fa-4e6b-b628-876a9072e159)\n\n## i说明\n\nCloos 焊机无需连接模拟信号,但需要额外连接DO1\\~DO8 至焊机用于指定Job号,具体接线说明请参考以下章节:Cloos焊机。\n\n此外还需连接数字通讯接口。控制柜侧的数字接口可在插件中配置,下图的接线方式仅供参考。\n\n用户手册\n\n![](https://weknora-api-test.dobot.cn/i/34bdd865-b1d5-4783-a718-9da865e9a85d)\n\n焊机侧的接线请参考焊机说明书。下文以奥太AMIGPR系列为例简单介绍。奥太AMIGPR系列焊机的模拟通讯接口及引脚序号如下图所示。 \n![](https://weknora-api-test.dobot.cn/i/74ed882a-822d-46ce-a5c4-6ccb22366b08)\n\n引脚定义和对应的机械臂控制柜侧接口如下表所示(数字信号均为NPN型)。\n",
"score": 0.49706713450175893,
"title": "DOBOT 焊接工艺包用户手册.pdf",
"metadata": {
"knowledge_base_id": "b9dacd62-87eb-4b38-a6b6-85f68e144796",
"knowledge_id": "d212c8d9-5bd4-43bd-8316-5f2d9168dd2b",
"chunk_id": "3fcd2242-cf69-4619-8be3-95c1b05f6667",
"chunk_index": 38,
"chunk_type": "text"
}
},
{
"content": "\n\n4.连接相机线缆,连接方法与全局相机相同。\n\n## 注意\n\n相机和主手机械臂要连接在不同的USB hub上,且相机的USB hub要连接在电脑的USB 3.0接口上。\n\n●确保相机线缆连接到位,避免因相机接触不良造成后续数据采集异常。\n\n## 2.3.8 连接散热风扇\n\n工作台柜体中自带一个散热风扇,如需使用,请将该风扇的电源线连接至电控柜的24V电源输出,红色线接24V接口,黑色线接0V接口。\n\n![](https://weknora-api-test.dobot.cn/i/43a53773-93cb-433d-adc0-375fdf612821) \n风扇\n\n![](https://weknora-api-test.dobot.cn/i/909f9735-d7ab-4596-af3d-cef504da47c4)\n\n## 2.3.9 连接指示灯\n\n指示灯出厂时已安装在底座上。使用指示灯连接线连接指示灯和从手1控制器。连接控制器时注意对齐线标和控制器上的丝印,接线后线缆应集中在端子左侧。\n\n![](https://weknora-api-test.dobot.cn/i/d90e850e-0388-4535-b642-33919cb41f89)\n\n## 2.3.10 连接训练主机\n\n使用网线连接训练主机和电控柜。\n\n![](https://weknora-api-test.dobot.cn/i/6a415304-6fe0-4bee-8c6e-74f5ae1770a7) \n电控柜\n\n## 2.3.11 整理线缆\n\n安装接线完成后,建议参考下图,使用发货附件的魔术贴(发货时为一整卷,需用户自行裁剪)将夹爪和相机线缆固定在从手上,避免从手运动时拉扯线缆。\n\n![](https://weknora-api-test.dobot.cn/i/bbc83287-1251-4d4e-87e4-c84bd4b62ddd) \n发货附件中还包含一包塑料扎带,用户可用于整理其余线缆。\n\n## 2.3.12 设备上电\n\n## 主手上电\n\n主手的电源开关位于底座,按下开关即可上电。上电状态下按下开关即可下电。\n\n![](https://weknora-api-test.dobot.cn/i/ed57e0bf-61d5-4d8a-b4c2-d3af02552bcf)\n\n## 从手上电\n\n从手的电源开关位于控制器(CCBOX)上,短按开机,长按(3s以上)关机。\n\n![](https://weknora-api-test.dobot.cn/i/7ff587e1-f4b6-4706-81bb-7e39b9fd8047)",
"score": 0.49572175212959224,
"title": "Dobot X-Trainer(完整版)_用户手册_cn.pdf",
"metadata": {
"knowledge_base_id": "b9dacd62-87eb-4b38-a6b6-85f68e144796",
"knowledge_id": "11dcd67e-770b-4387-ba87-3357a6c16c25",
"chunk_id": "7f52676c-a6f6-4663-b643-c5308a67e53f",
"chunk_index": 38,
"chunk_type": "text"
}
}
]
}
},
"image_e2e": {
"stable_url": "https://weknora-api-test.dobot.cn/i/02e269e8-c4f0-4b3f-a41e-82f5b222a31f",
"stable_uuid_status": 302,
"cache_control": "no-store",
"referrer_policy": "no-referrer",
"signed_location_recorded": false,
"signed_image_status": 200,
"content_type": "image/jpeg",
"bytes": 56025,
"sha256": "a0f2b91c84daf1b31585d3b09f0d0a518fad9fabc5b06f0406bb509cd830fb9b",
"format_signature_valid": true
}
}
+1
View File
@@ -0,0 +1 @@
DROP TABLE IF EXISTS asset_mapping;
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS asset_mapping (
asset_id UUID PRIMARY KEY,
source_ref TEXT NOT NULL,
object_key TEXT NOT NULL,
content_type TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled'))
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_asset_mapping_source_ref
ON asset_mapping (source_ref);
+48
View File
@@ -0,0 +1,48 @@
[build-system]
requires = ["hatchling>=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"]
+1
View File
@@ -0,0 +1 @@
"""Knowledge Adapter Service."""
+201
View File
@@ -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", "<unmatched>")
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
@@ -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<prefix>!\[[^\]\n]*\]\()"
r"(?P<source_ref>resource://[A-Za-z0-9_-]{22})"
r"(?P<suffix>\))"
)
_HTML_RESOURCE_IMAGE = re.compile(
r"(?P<prefix><img\b[^>]*?\bsrc\s*=\s*(?P<quote>['\"]))"
r"(?P<source_ref>resource://[A-Za-z0-9_-]{22})"
r"(?P<suffix>(?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"|(?<![A-Za-z0-9])/(?:home|srv|var|etc|root|tmp)/"
r"|https?://(?:localhost|127(?:\.\d{1,3}){3}|10(?:\.\d{1,3}){3}|"
r"192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})"
r"(?=[:/])"
r"|https?://[^\s)\]]*\.aliyuncs\.com(?=[:/]|$)"
r")",
re.IGNORECASE,
)
class ContentProcessingError(RuntimeError):
pass
class MappingRepository(Protocol):
async def get_active_by_source_ref(self, source_ref: str) -> 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
+56
View File
@@ -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
+113
View File
@@ -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)
+38
View File
@@ -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"},
)
+25
View File
@@ -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}"
+9
View File
@@ -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)
+209
View File
@@ -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
+56
View File
@@ -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 = ""
+74
View File
@@ -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),
)
+124
View File
@@ -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
+57
View File
@@ -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
+96
View File
@@ -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}
+138
View File
@@ -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)
+89
View File
@@ -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
@@ -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,
)
+314
View File
@@ -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'<td><img src="{SOURCE_REF}"/></td>')
processed = await processor.process(chunk)
assert processed == (f'<td><img src="https://assets.example.com/i/{ASSET_ID}"/></td>')
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"<img alt='diagram' src='{SOURCE_REF}' width='100'>")
processed = await processor.process(chunk)
assert processed == (
f"<img alt='diagram' src='https://assets.example.com/i/{ASSET_ID}' width='100'>"
)
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))
+126
View File
@@ -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 == []
+209
View File
@@ -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
+127
View File
@@ -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]
+91
View File
@@ -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
+34
View File
@@ -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
+218
View File
@@ -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
+22
View File
@@ -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]
+632
View File
@@ -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
+27
View File
@@ -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
+41
View File
@@ -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;"
+258
View File
@@ -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"] == "<unmatched>"
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]
+150
View File
@@ -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)
+110
View File
@@ -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",
)
+269
View File
@@ -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
+302
View File
@@ -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
+158
View File
@@ -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)
+113
View File
@@ -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)
+31
View File
@@ -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
+237
View File
@@ -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
Generated
+947
View File
@@ -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" },
]