Commit d0ec3142 authored by 谢宇轩's avatar 谢宇轩

feat: add workspace-scoped Neo4j query service

parents
.git
.venv
__pycache__
.pytest_cache
.ruff_cache
tests
*.db*
.env
config.local.yaml
acceptance.local.yaml
secrets
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/
*.egg-info/
dist/
.env
*.db*
config.local.yaml
acceptance.local.yaml
secrets/
# V1 验收记录
验收日期:2026-09-10。项目版本:0.1.0。
## 结果
- 自动化测试:**92 passed,0 failed,0 skipped**,包含真实 Neo4j 5.26.0 Community 集成测试。
- Docker 端到端:**10 项检查全部通过**。
- Ruff 静态检查、格式检查通过。
- 最终服务健康,地址 `http://127.0.0.1:8080`,Workspace 为 `local`。
- 镜像:`neo4j-query-service:0.1.0`,Linux ARM64。
- 镜像 ID:`sha256:0c98c2e7ef400a190338fa313e4c582f6d617e207237017c919889d421e5fa93`。
## 要求与验证证据
| 要求 | 验证 |
|---|---|
| Key 绑定 Workspace,路由指定 Workspace | 鉴权、跨 Workspace 资源与外键测试;容器请求返回正确 401/403/404 |
| Workspace 仅启动配置 | 冻结配置、源文件修改不影响已生成配置、移除 Workspace 后旧 Key 不可用 |
| Key 脚本实时新增/撤销 | 容器内 seed/revoke 前后服务 PID 相同,下一次请求立即生效 |
| 本地 db 文件持久化 | SQLite WAL、并发写入、容器重启保留数据、在线备份 integrity_check=ok |
| 环境变量生成配置 | 优先级、错误配置不覆盖旧文件;环境变量读取只出现在 bootstrap.py |
| 非 root Docker | UID 10001、只读根文件系统、0600 运行配置、9080 端口覆盖后健康检查正常 |
| 可信维护者管理模板 | reader 写模板被拒绝;更新生成版本,冲突返回 409,禁用阻止历史版本执行 |
| 参数校验与只读边界 | 参数默认值、类型/范围/未知字段、注入字符串、写入与过程/管理/跨库语句拒绝 |
| 快捷查询 | Workspace 共享、固定参数/版本、更新冲突、模板更新不改变旧快捷查询 |
| 超时与并发限制 | 全局/Workspace/Key 上限、真实事务超时、真实 HTTP 断连取消、名额回收 |
| 结果上限与图数据 | 截断标志、超大结果错误、小字节预算、节点/关系/路径/大整数/时间等类型 |
| 访问日志 | 不含 Key/参数/结果、按数量与时间清理、审计失败返回 503、断连记录 499 |
| 启动失败边界 | 损坏 SQLite 阻止服务启动、重复进程被文件锁拒绝 |
| 依赖故障隔离 | 一个 Workspace 数据库不可达不影响其他 Workspace |
## 真实数据库验收
写入拒绝和压力测试运行在独立的 `neoquery-acceptance-db`,测试前后核对图数据和索引未被拒绝的语句改变。超时和取消测试后,检查剩余服务事务为 0。
现有 `neo4j-agent` 仅用于限量读取:通过代理返回 3 个节点和 1 条路径。没有对现有库创建测试节点、修改图数据、索引或配置。APOC 的现有部署配置保持不变,代理仍拒绝过程调用。
交付容器更新到最终镜像后,原 Key 和已保存快捷查询仍可用。初始维护者/只读 Key 保存在项目的 `secrets/bootstrap-keys.json`(0600;父目录 0700),不进入 Git、镜像或验收报告。
## 复现入口
- `uv run pytest -q --integration`:需要 README 所述的隔离 Neo4j 实例。
- `tests/docker_acceptance.py`:Docker 端到端脚本;具体三 Workspace 环境要求见 README。
- `query-service healthcheck`:读取生成配置中的监听端口检查交付服务。
本轮未进行公网部署、多实例运行、APOC 放行或 AMD64 架构验收。导出的镜像为本机验证的 ARM64 版本;其他架构可从附带 Dockerfile 和锁文件构建。
FROM python:3.12-slim@sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea
WORKDIR /app
RUN groupadd --gid 10001 queryservice && useradd --uid 10001 --gid queryservice --no-create-home queryservice
COPY --from=ghcr.io/astral-sh/uv:0.8.22@sha256:9874eb7afe5ca16c363fe80b294fe700e460df29a55532bbfea234a0f12eddb1 /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY src ./src
COPY migrations ./migrations
COPY alembic.ini ./
RUN uv sync --frozen --no-dev && mkdir -p /data /run/query-service && chown -R queryservice:queryservice /data /run/query-service
ENV PATH="/app/.venv/bin:$PATH" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
USER 10001:10001
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s CMD query-service healthcheck --config "${NQ_CONFIG_OUTPUT:-/run/query-service/config.yaml}"
ENTRYPOINT ["query-service-bootstrap"]
# Neo4j Workspace Query Service
面向组内共享的轻量查询服务。适用于 Neo4j Community:调用方只持有 Workspace API Key,通过预定义模板查询图数据。
## 能力与边界
- 一个 Key 对应一个 Workspace;一个 Workspace 对应一个 Neo4j URI 与数据库。
- Workspace 启动时加载,修改源配置或环境变量需要重建/重启服务才生效。没有 Workspace 管理 API。
- `reader` 可查询、查看模板、管理 Workspace 内共享快捷查询;`maintainer` 另可维护模板。
- 模板更新生成不可变新版本;快捷查询固定具体版本和补齐默认值后的参数,不接受执行时覆盖。
- SQLite 保存 Key 摘要、模板、快捷查询和访问日志;图数据保留在 Neo4j。
- 超时、全局/Workspace/Key 并发上限、返回行数和字节上限、请求体上限。
- 原生节点、关系、路径、大整数和时间等类型使用稳定 JSON 协议。
**维护模板的 Key 是可信管理权限。**本服务不提供任意 Cypher 透传,也不把关键词过滤或驱动 READ 模式等同于数据库 RBAC。只读防护由可信模板、参数绑定、保守词法检查、内置函数白名单、`EXPLAIN` 只读类型检查和回滚事务共同构成。首版禁用所有 `CALL`(含子查询)、APOC、自定义/命名空间函数、`LOAD CSV`、`USE`、写入及管理语句、执行前缀和分号。
## Docker 启动
镜像:`neo4j-query-service:0.1.0`。示例 Compose 接入已有的 `neo4jagent_default` 网络,不启动或修改已有 Neo4j。
```bash
cp examples/workspaces.yaml config.local.yaml
# 编辑 URI、数据库、账号和密码。已有本地容器可使用 bolt://neo4j-agent:7687。
docker compose up -d --build
docker compose exec query-api query-service healthcheck
```
API 默认为 `http://127.0.0.1:8080`,OpenAPI 页面为 `/docs`。修改对外绑定地址并接入 TLS/内网访问控制后才向组成员开放。数据库网络凭据只保存在服务端,成员不能直接访问数据库来绕过 API。
容器以 UID/GID `10001` 运行,配置源必须对该 UID 可读。例如 Linux 可以将配置文件组设置为 `10001` 并授予组读权限;也可将敏感源文件放在宿主机 `0700` 的目录内,文件本身 `0644`,只挂载文件,不挂载整个目录。Compose 示例路径可自行改为该文件。实际生成的运行配置始终为 `0600`,不输出到日志。不要把实际密码提交到 Git。
挂载说明:
| 路径 | 用途 |
|---|---|
| `/etc/query-service/workspaces.yaml` | 只读配置源 |
| `/run/query-service/config.yaml` | 启动生成的私有配置,推荐 tmpfs |
| `/data` | 持久化整个目录,包括 SQLite WAL/SHM 文件 |
单实例、单 worker;禁止多个容器或多个进程共享同一数据库提供 HTTP 服务。服务通过文件锁防止误启动多个 worker。SQLite WAL 不应放在 NFS 等网络文件系统上。
## 配置
优先级:默认值 < YAML 文件 < 明确设置的 `NQ_` 环境变量。未知 `NQ_` 变量、未知字段、重复 Workspace、缺失连接参数或非法值使启动失败,不使用旧生成配置作为回退。
```yaml
config_version: 1
listen_host: 0.0.0.0
port: 8080
db_path: /data/service.db
limits:
query_timeout_seconds: 10
request_timeout_seconds: 12
max_concurrency: 16
workspace_concurrency: 4
key_concurrency: 2
max_rows: 1000
max_response_bytes: 5242880
max_request_bytes: 262144
log_retention_days: 14
log_max_records: 100000
workspaces:
- id: team
name: Team graph
neo4j:
uri: bolt://neo4j-agent:7687
database: neo4j
username: neo4j
password: replace-with-your-password
limits:
max_concurrency: 3
max_rows: 500
```
Workspace 限制只能收紧全局默认值。ID 是持久身份:切换业务数据集应使用新 ID;更新连接地址或密码可保留 ID 并重启。删除 Workspace 配置后旧数据与 Key 保留但不可访问,重新启用同一 ID 会恢复访问。两个 Workspace 若指向同一个物理数据库,读取的图数据相同;Workspace 隔离的是授权、模板与快捷查询,不会自动分割图数据。
| 环境变量 | 对应配置 / 默认值 |
|---|---|
| `NQ_CONFIG_SOURCE` | `/etc/query-service/workspaces.yaml` |
| `NQ_CONFIG_OUTPUT` | `/run/query-service/config.yaml` |
| `NQ_LISTEN_HOST`, `NQ_PORT`, `NQ_DB_PATH` | 监听地址、端口、SQLite 文件 |
| `NQ_WORKSPACE_OVERRIDES_JSON` | 已存在 Workspace 的 `neo4j`、`limits`、`name` 覆盖,不能新增 ID |
| `NQ_QUERY_TIMEOUT_SECONDS`, `NQ_REQUEST_TIMEOUT_SECONDS` | 10 秒事务 / 12 秒请求 |
| `NQ_MAX_CONCURRENCY`, `NQ_WORKSPACE_CONCURRENCY`, `NQ_KEY_CONCURRENCY` | 16 / 4 / 2 |
| `NQ_MAX_ROWS`, `NQ_MAX_RESPONSE_BYTES`, `NQ_MAX_REQUEST_BYTES` | 1000 / 5 MiB / 256 KiB |
| `NQ_LOG_RETENTION_DAYS`, `NQ_LOG_MAX_RECORDS` | 14 天 / 100000 |
覆盖示例(实际密钥通过受保护的部署环境提供):
```json
{"team":{"neo4j":{"password":"replace-at-deployment"},"limits":{"max_rows":100}}}
```
仅 `bootstrap.py` 读取环境变量;服务、数据库迁移和管理脚本只读取生成配置。Docker 启动完成配置生成、迁移、连接池初始化和 HTTP 启动。驱动按需连接,某 Workspace 的 Neo4j 不可达不会阻止其他 Workspace 使用。
## 实时管理 API Key
```bash
# 首个维护者 Key,创建后完整值只输出这一次。
docker compose exec query-api query-service keys seed \
--config /run/query-service/config.yaml --workspace team --name owner --role maintainer
# 普通组成员,90 天后过期。
docker compose exec query-api query-service keys seed \
--config /run/query-service/config.yaml --workspace team --name member-a \
--role reader --expires-in-days 90
docker compose exec query-api query-service keys list \
--config /run/query-service/config.yaml --workspace team
docker compose exec query-api query-service keys revoke \
--config /run/query-service/config.yaml --key-id <key-id>
```
`seed` 每次创建一把新 Key,不是按名称覆盖。未提供有效期的 Key 无期限,直到撤销。摘要存库,完整 Key 无法找回。服务每次请求读取 Key 状态,seed/revoke 不用重启;已开始的查询按原有时限结束。轮换顺序为新增、切换、撤销;快捷查询属于 Workspace,因此不随旧 Key 失效。
## API 用法
认证头:`Authorization: Bearer nq_<id>.<secret>`。所有业务路由均以 `/api/v1/workspaces/{workspace_id}` 开头。
| 方法 | 相对路由 | 说明 |
|---|---|---|
| GET | `/templates` | 可用模板列表;offset/limit,默认 20、最大 100 |
| GET | `/templates/{id}?version=1` | 指定版本,省略为当前版本 |
| POST | `/templates` | 维护者创建模板 |
| PUT | `/templates/{id}` | 维护者创建新版本;必填 expected_version |
| POST | `/templates/{id}/disable` | 永久禁用该模板的所有版本,首版没有重新启用接口 |
| POST | `/templates/{id}/execute` | 执行模板,params/version/max_rows |
| GET/POST | `/shortcuts` | 列表 / 保存快捷查询 |
| GET/PUT/DELETE | `/shortcuts/{id}` | 详情 / 修改 / 删除;PUT 必填 expected_revision |
| POST | `/shortcuts/{id}/execute` | 无请求体或 `{}`;不接受参数覆盖 |
使用仓库的 `examples/template.json` 创建模板(先根据实际图模型修改 Cypher):
```bash
curl -X POST http://127.0.0.1:8080/api/v1/workspaces/team/templates \
-H "Authorization: Bearer $MAINTAINER_KEY" \
-H 'Content-Type: application/json' --data-binary @examples/template.json
```
执行请求示例:
```json
{"params":{"entity_id":"company_123","limit":10},"version":1,"max_rows":10}
```
保存快捷查询:
```json
{"name":"Company neighbors","template_id":"模板ID","template_version":1,"params":{"entity_id":"company_123","limit":10}}
```
不传 `template_version` 时,保存的是当时解析出的具体版本号;默认参数也在保存时补齐。模板升级不会修改已有快捷查询。修改快捷查询时需要提交完整新定义和当前 `expected_revision`,冲突返回 409。
### 参数规则
`parameter_schema` 是受限 JSON Schema:每一层必须明确一个 `type`,对象必须设置 `additionalProperties:false`。支持 string/integer/number/boolean/null/array/object,以及 required、enum、default、description、minimum/maximum/exclusiveMinimum/exclusiveMaximum、minLength/maxLength、minItems/maxItems、minProperties/maxProperties。数组必须有 items。每个 Cypher 参数必须为 required 或提供 default,声明名必须与 `$parameter` 完全一致。
不支持 pattern、外部引用、自定义验证代码或类型自动转换;整数必须是真实 JSON 整数,且在 Neo4j 64 位范围内。提交模板的 `example_params` 用于 `EXPLAIN`,不执行查询。查询请求、快捷保存和快捷执行都会重新校验参数。
### 返回协议
```json
{
"request_id":"…","template_id":"…","template_version":1,
"columns":["n"],
"rows":[[{"type":"node","value":{
"element_id":"…","labels":["Company"],
"properties":{"name":{"type":"string","value":"Example"}}
}}]],
"row_count":1,"truncated":false,"duration_ms":7
}
```
每个值统一 `{type,value}`,用户属性不会与类型标记冲突。integer 值为十进制字符串;float 通常为 JSON 数字,特殊值为字符串。map/list 递归编码,bytes 为 Base64;date/time/datetime 为 ISO 字符串;duration 为 months/days/seconds/nanoseconds;point 为 srid/coordinates。relationship 包含 element_id、relationship_type、起止节点 element_id 与 properties;path 保存有序 nodes/relationships,可据端点与节点顺序判断方向。
element_id 只用于返回结果中的关联,不作为永久业务 ID。达到行数上限时读取额外一行判断 `truncated`,不读取全部结果;超过字节上限返回错误,不返回半截 JSON。返回限制不能替代查询耗时限制,也不是 Neo4j 的服务端内存配额;昂贵聚合和单条巨大记录仍应由可信维护者控制,并结合 Neo4j 内存配置。
### 错误与日志
错误体为 `{"error":{"code":"…","message":"…"},"request_id":"…"}`,响应头含 `X-Request-ID`。
- 401:Key 缺失、无效、撤销或过期。
- 403:Workspace/角色不匹配或 Workspace 未启用。
- 404:本 Workspace 内资源不存在。
- 409:版本冲突或模板禁用。
- 413/422:请求过大、参数/模板错误、结果过大。
- 429:并发满,不排队;调用方自行退避。
- 503/504:依赖不可用、审计存储不可用、超时。
超时/客户端断开会取消驱动会话并回收名额,不自动重试查询。访问日志只保存请求 ID、Key ID、Workspace、路由模式、资源 ID/版本、状态、耗时和行数,不保存 Key 原文、参数值、Cypher 正文或结果。客户端断开审计为 499。健康检查不写 SQLite 访问日志,业务审计失败返回 503。管理操作若已落库而审计失败,重试前应先查询资源状态。
日志每分钟清理一次,因此保留数量在两次清理间可能暂时超出阈值。运行日志输出 stdout;Uvicorn 原始访问日志关闭,避免记录敏感 URL。
## 备份、迁移与开发
```bash
docker compose exec query-api query-service db backup \
--config /run/query-service/config.yaml --output /data/backup-20260909.db
```
在线备份为一致的单 db 文件,目标文件必须不存在。恢复需停止服务,保留原目录备份,再把备份放回目标 db 路径并移走对应旧 WAL/SHM 文件后启动。禁止运行中只复制主 db 文件。迁移只触及服务的 SQLite,不修改 Neo4j;升级前备份,失败时停止启动,首版不提供破坏性 downgrade。
源码开发使用可编辑安装(迁移文件与仓库同级),Docker 已包含迁移资源:
```bash
uv sync --frozen --python 3.12
uv run ruff check src migrations tests
uv run pytest -q
# 开发运行也传入配置文件;服务代码不会读环境变量。
uv run query-service serve --config /absolute/path/config.local.yaml
```
真实集成测试只连接专门的 `127.0.0.1:17687`,会创建并删除带 `NQAcceptance` 标签的测试数据,切勿将此端口映射到现有业务库:
```bash
docker run -d --name neoquery-acceptance-db -p 127.0.0.1:17687:7687 \
-e NEO4J_AUTH=neo4j/acceptance-only-2026 \
-e NEO4J_server_memory_heap_initial__size=256m \
-e NEO4J_server_memory_heap_max__size=256m \
-e NEO4J_server_memory_pagecache_size=128m neo4j:5.26.0
# 等数据库 ready 后:
uv run pytest -q --integration
docker rm -f neoquery-acceptance-db
```
`tests/docker_acceptance.py` 用于三 Workspace(alpha/beta/local)的端到端验收,alpha/beta 指向隔离库,local 指向实际库;只对 local 执行限量读取,不输出实际业务数据。验收报告另行记录真实执行结果。
## 参考
- [Neo4j 驱动事务、读模式边界](https://neo4j.com/docs/python-manual/current/transactions/)
- [Neo4j 异步取消与事务超时](https://neo4j.com/docs/api/python-driver/current/async_api.html)
- [SQLite WAL 并发与备份注意事项](https://www.sqlite.org/wal.html)
- [Alembic SQLite 迁移](https://alembic.sqlalchemy.org/en/latest/batch.html)
[alembic]
script_location = %(here)s/migrations
services:
query-api:
build: .
image: neo4j-query-service:0.1.0
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./config.local.yaml:/etc/query-service/workspaces.yaml:ro
- query-data:/data
environment:
NQ_PORT: "8080"
read_only: true
tmpfs:
- /run/query-service:uid=10001,gid=10001,mode=0700
- /tmp
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
restart: unless-stopped
networks: [neo4j]
networks:
neo4j:
external: true
name: neo4jagent_default
volumes:
query-data:
{
"name": "Entity neighbors",
"description": "Return one-hop paths for an entity with a business ID",
"cypher": "MATCH p=(n {entity_id: $entity_id})-[r]-(m) RETURN p LIMIT $limit",
"parameter_schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"entity_id": {"type": "string", "minLength": 1, "maxLength": 128},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}
},
"required": ["entity_id"]
},
"example_params": {"entity_id": "example"}
}
config_version: 1
db_path: /data/service.db
workspaces:
- id: team
name: Team graph
neo4j:
uri: bolt://neo4j:7687
database: neo4j
username: neo4j
password: replace-with-your-password
from alembic import context
with context.config.attributes["connection"].begin():
context.configure(connection=context.config.attributes["connection"], render_as_batch=True)
with context.begin_transaction():
context.run_migrations()
"""Initial local metadata schema; no Neo4j migrations."""
from alembic import op
revision = "0001"
down_revision = None
DDL = [
"""CREATE TABLE workspaces (
id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at TEXT NOT NULL)""",
"""CREATE TABLE api_keys (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id),
name TEXT NOT NULL, secret_hash TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('reader','maintainer')),
created_at TEXT NOT NULL, expires_at TEXT, revoked_at TEXT,
UNIQUE(workspace_id,id))""",
"""CREATE TABLE templates (
id TEXT NOT NULL, workspace_id TEXT NOT NULL REFERENCES workspaces(id), name TEXT NOT NULL,
description TEXT NOT NULL, current_version INTEGER NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL, PRIMARY KEY(workspace_id,id))""",
"""CREATE TABLE template_versions (
workspace_id TEXT NOT NULL, template_id TEXT NOT NULL, version INTEGER NOT NULL,
cypher TEXT NOT NULL, parameter_schema TEXT NOT NULL, example_params TEXT NOT NULL,
created_by TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,template_id,version),
FOREIGN KEY(workspace_id,template_id) REFERENCES templates(workspace_id,id),
FOREIGN KEY(workspace_id,created_by) REFERENCES api_keys(workspace_id,id))""",
"""CREATE TABLE shortcuts (
id TEXT NOT NULL, workspace_id TEXT NOT NULL, name TEXT NOT NULL,
template_id TEXT NOT NULL, template_version INTEGER NOT NULL,
params TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
created_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,id),
FOREIGN KEY(workspace_id,template_id,template_version)
REFERENCES template_versions(workspace_id,template_id,version),
FOREIGN KEY(workspace_id,created_by) REFERENCES api_keys(workspace_id,id))""",
"""CREATE TABLE access_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT NOT NULL, created_at TEXT NOT NULL,
workspace_id TEXT, key_id TEXT, operation TEXT NOT NULL, template_id TEXT,
template_version INTEGER, shortcut_id TEXT, status INTEGER NOT NULL,
duration_ms INTEGER NOT NULL, row_count INTEGER, error_code TEXT)""",
"CREATE INDEX ix_keys_workspace ON api_keys(workspace_id)",
"CREATE INDEX ix_logs_created ON access_logs(created_at)",
]
def upgrade():
for sql in DDL:
op.execute(sql)
def downgrade():
raise RuntimeError("Restore a database backup instead of destructive downgrade")
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "neo4j-query-service"
version = "0.1.0"
description = "Workspace-scoped, template-only Neo4j query API"
requires-python = ">=3.12,<3.14"
dependencies = [
"fastapi>=0.115,<1", "uvicorn>=0.34,<1", "neo4j>=5.26,<6",
"sqlalchemy>=2.0,<3", "alembic>=1.14,<2", "pydantic>=2.10,<3",
"pyyaml>=6.0,<7", "jsonschema>=4.23,<5", "typer>=0.15,<1",
]
[dependency-groups]
dev = ["pytest>=8,<10", "pytest-asyncio>=0.25,<2", "httpx>=0.28,<1", "ruff>=0.11,<1"]
[project.scripts]
query-service = "neoquery.cli:app"
query-service-bootstrap = "neoquery.bootstrap:main"
[tool.hatch.build.targets.wheel]
packages = ["src/neoquery"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 110
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I"]
"""Neo4j workspace query service."""
import asyncio
import json
import logging
import time
import uuid
from contextlib import asynccontextmanager, suppress
from typing import Annotated
from fastapi import Body, Depends, FastAPI, Query, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.exc import SQLAlchemyError
from neoquery.config import Settings
from neoquery.db import Store
from neoquery.errors import ServiceError
from neoquery.executor import Executor
from neoquery.validation import validate_definition, validate_params
log = logging.getLogger("neoquery")
bearer = HTTPBearer(auto_error=False, description="Workspace API Key")
class Input(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
class TemplateInput(Input):
name: str = Field(min_length=1, max_length=128)
description: str = Field(default="", max_length=2048)
cypher: str = Field(min_length=1, max_length=32768)
parameter_schema: dict
example_params: dict = Field(default_factory=dict)
class TemplateUpdate(TemplateInput):
expected_version: int = Field(ge=1)
class ExecuteInput(Input):
params: dict = Field(default_factory=dict)
version: int | None = Field(default=None, ge=1)
max_rows: int | None = Field(default=None, ge=1)
class ShortcutInput(Input):
name: str = Field(min_length=1, max_length=128)
template_id: str = Field(min_length=1, max_length=64)
template_version: int | None = Field(default=None, ge=1)
params: dict = Field(default_factory=dict)
class ShortcutUpdate(ShortcutInput):
expected_revision: int = Field(ge=1)
class EmptyInput(Input):
pass
def error_response(status, code, message, request_id):
return JSONResponse(
{"error": {"code": code, "message": message}, "request_id": request_id},
status_code=status,
headers={"X-Request-ID": request_id},
)
class RequestBoundary:
"""Bound uploads/responses, cancel disconnected work, and audit before sending a response."""
def __init__(self, app, store, settings):
self.app, self.store, self.settings = app, store, settings
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or not scope["path"].startswith("/api/"):
return await self.app(scope, receive, send)
started, request_id = time.monotonic(), uuid.uuid4().hex
state = scope.setdefault("state", {})
state["request_id"] = request_id
state["audit"] = {}
body, outgoing = bytearray(), []
limits = self.settings.limits
parts = scope["path"].split("/")
if len(parts) > 4 and parts[3] == "workspaces":
try:
limits = self.settings.effective_limits(self.settings.workspace(parts[4]))
except StopIteration:
pass
disconnected = False
async def capture(message):
outgoing.append(message)
async def reply_error(status, code, message):
outgoing.clear()
state["audit"]["error_code"] = code
await error_response(status, code, message, request_id)(scope, receive, capture)
app_task = disconnect_task = None
try:
async with asyncio.timeout(limits.request_timeout_seconds):
while True:
msg = await receive()
if msg["type"] == "http.disconnect":
disconnected = True
break
body.extend(msg.get("body", b""))
if len(body) > limits.max_request_bytes:
raise ServiceError(413, "request_too_large", "Request body exceeds byte limit")
if not msg.get("more_body", False):
break
if not disconnected:
delivered = False
async def buffered_receive():
nonlocal delivered
if not delivered:
delivered = True
return {"type": "http.request", "body": bytes(body), "more_body": False}
await asyncio.Event().wait()
async def watch_disconnect():
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
app_task = asyncio.create_task(self.app(scope, buffered_receive, capture))
disconnect_task = asyncio.create_task(watch_disconnect())
done, _ = await asyncio.wait(
{app_task, disconnect_task}, return_when=asyncio.FIRST_COMPLETED
)
if app_task in done:
await app_task
else:
disconnected = True
except ServiceError as error:
await reply_error(error.status, error.code, error.message)
except TimeoutError:
await reply_error(504, "request_timeout", "Request deadline exceeded")
except Exception as error:
log.error(
json.dumps(
{
"event": "request_failure",
"request_id": request_id,
"exception_type": type(error).__name__,
}
)
)
await reply_error(500, "internal_error", "Internal service error")
finally:
for task in (app_task, disconnect_task):
if task is not None:
if not task.done():
task.cancel()
with suppress(asyncio.CancelledError, Exception):
await task
if not disconnected and sum(len(m.get("body", b"")) for m in outgoing) > limits.max_response_bytes:
await reply_error(422, "response_too_large", "Response exceeds byte limit")
status = (
499
if disconnected
else next((m["status"] for m in outgoing if m["type"] == "http.response.start"), 500)
)
route = scope.get("route")
audit = {
**state["audit"],
"request_id": request_id,
"status": status,
"duration_ms": int((time.monotonic() - started) * 1000),
"operation": scope["method"] + " " + (route.path if route else "unmatched"),
}
if disconnected:
audit["error_code"] = "client_disconnected"
try:
await asyncio.to_thread(self.store.audit, audit)
except Exception:
log.error(json.dumps({"event": "audit_failed", "request_id": request_id}))
await reply_error(503, "audit_unavailable", "Audit storage unavailable")
if not disconnected:
for message in outgoing:
if message["type"] == "http.response.start":
headers = [(k, v) for k, v in message.get("headers", []) if k.lower() != b"x-request-id"]
message["headers"] = headers + [(b"x-request-id", request_id.encode())]
await send(message)
def create_app(settings: Settings, *, store=None, executor=None):
store = store or Store(settings)
executor = executor or Executor(settings)
async def cleanup():
while True:
try:
await asyncio.to_thread(store.prune_logs)
except Exception:
log.error(json.dumps({"event": "log_prune_failed"}))
await asyncio.sleep(60)
@asynccontextmanager
async def lifespan(app):
await asyncio.to_thread(store.health)
task = asyncio.create_task(cleanup())
try:
yield
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
await executor.close()
await asyncio.to_thread(store.close)
app = FastAPI(title="Neo4j Workspace Query Service", version="0.1.0", lifespan=lifespan)
app.state.store, app.state.executor, app.state.settings = store, executor, settings
app.add_middleware(RequestBoundary, store=store, settings=settings)
@app.exception_handler(ServiceError)
async def service_error(request, error):
request.state.audit["error_code"] = error.code
return error_response(error.status, error.code, error.message, request.state.request_id)
@app.exception_handler(RequestValidationError)
async def invalid_input(request, error):
if hasattr(request.state, "audit"):
request.state.audit["error_code"] = "invalid_request"
return error_response(
422,
"invalid_request",
"Request does not match the API schema",
getattr(request.state, "request_id", ""),
)
@app.exception_handler(SQLAlchemyError)
async def database_error(request, error):
request.state.audit["error_code"] = "storage_unavailable"
return error_response(
503, "storage_unavailable", "Local storage unavailable", request.state.request_id
)
async def authenticate(
request: Request,
workspace_id: str,
authorization: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)] = None,
):
if not authorization or authorization.scheme.lower() != "bearer":
raise ServiceError(401, "invalid_key", "Bearer API key required")
key = await asyncio.to_thread(store.authenticate, authorization.credentials, workspace_id)
request.state.audit.update(workspace_id=workspace_id, key_id=key["id"])
return key
async def maintainer(key=Depends(authenticate)):
if key["role"] != "maintainer":
raise ServiceError(403, "role_forbidden", "Template maintenance requires maintainer role")
return key
prefix = "/api/v1/workspaces/{workspace_id}"
@app.get("/health/live")
async def live():
return {"status": "ok"}
@app.get("/health/ready")
async def ready():
try:
await asyncio.to_thread(store.health)
except Exception:
return JSONResponse({"status": "unavailable"}, status_code=503)
return {"status": "ready"}
@app.get(prefix + "/templates")
async def templates(
workspace_id: str,
key=Depends(authenticate),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100),
):
return {"items": await asyncio.to_thread(store.list_templates, workspace_id, offset, limit)}
@app.get(prefix + "/templates/{template_id}")
async def template(
workspace_id: str,
template_id: str,
key=Depends(authenticate),
version: int | None = Query(default=None, ge=1),
):
return await asyncio.to_thread(store.template, workspace_id, template_id, version)
async def save_template(request, workspace_id, key, payload, template_id=None):
values = payload.model_dump()
examples = validate_definition(values["cypher"], values["parameter_schema"], values["example_params"])
values["example_params"] = examples
await executor.run(
workspace_id,
key["id"],
values["cypher"],
examples,
request_id=request.state.request_id,
explain_only=True,
)
result = await asyncio.to_thread(store.save_template, workspace_id, key["id"], values, template_id)
request.state.audit.update(template_id=result["id"], template_version=result["version"])
return result
@app.post(prefix + "/templates", status_code=201)
async def create_template(
request: Request, workspace_id: str, payload: TemplateInput, key=Depends(maintainer)
):
return await save_template(request, workspace_id, key, payload)
@app.put(prefix + "/templates/{template_id}")
async def update_template(
request: Request,
workspace_id: str,
template_id: str,
payload: TemplateUpdate,
key=Depends(maintainer),
):
await asyncio.to_thread(store.template, workspace_id, template_id)
return await save_template(request, workspace_id, key, payload, template_id)
@app.post(prefix + "/templates/{template_id}/disable")
async def disable(request: Request, workspace_id: str, template_id: str, key=Depends(maintainer)):
await asyncio.to_thread(store.disable_template, workspace_id, template_id)
request.state.audit["template_id"] = template_id
return {"disabled": True}
async def execute(request, workspace_id, key, definition, params, max_rows=None):
params = validate_params(definition["parameter_schema"], params)
request.state.audit.update(template_id=definition["id"], template_version=definition["version"])
result = await executor.run(
workspace_id,
key["id"],
definition["cypher"],
params,
request_id=request.state.request_id,
max_rows=max_rows,
)
request.state.audit["row_count"] = result["row_count"]
return {
"request_id": request.state.request_id,
"template_id": definition["id"],
"template_version": definition["version"],
**result,
}
@app.post(prefix + "/templates/{template_id}/execute")
async def execute_template(
request: Request,
workspace_id: str,
template_id: str,
payload: ExecuteInput,
key=Depends(authenticate),
):
definition = await asyncio.to_thread(store.template, workspace_id, template_id, payload.version, True)
return await execute(request, workspace_id, key, definition, payload.params, payload.max_rows)
@app.get(prefix + "/shortcuts")
async def shortcuts(
workspace_id: str,
key=Depends(authenticate),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100),
):
return {"items": await asyncio.to_thread(store.list_shortcuts, workspace_id, offset, limit)}
@app.get(prefix + "/shortcuts/{shortcut_id}")
async def shortcut(workspace_id: str, shortcut_id: str, key=Depends(authenticate)):
return await asyncio.to_thread(store.shortcut, workspace_id, shortcut_id)
async def save_shortcut(request, workspace_id, key, payload, shortcut_id=None):
values = payload.model_dump()
definition = await asyncio.to_thread(
store.template, workspace_id, values["template_id"], values["template_version"], True
)
values["params"] = validate_params(definition["parameter_schema"], values["params"])
values["template_version"] = definition["version"]
result = await asyncio.to_thread(store.save_shortcut, workspace_id, key["id"], values, shortcut_id)
request.state.audit.update(
shortcut_id=result["id"], template_id=definition["id"], template_version=definition["version"]
)
return result
@app.post(prefix + "/shortcuts", status_code=201)
async def create_shortcut(
request: Request, workspace_id: str, payload: ShortcutInput, key=Depends(authenticate)
):
return await save_shortcut(request, workspace_id, key, payload)
@app.put(prefix + "/shortcuts/{shortcut_id}")
async def update_shortcut(
request: Request,
workspace_id: str,
shortcut_id: str,
payload: ShortcutUpdate,
key=Depends(authenticate),
):
await asyncio.to_thread(store.shortcut, workspace_id, shortcut_id)
return await save_shortcut(request, workspace_id, key, payload, shortcut_id)
@app.delete(prefix + "/shortcuts/{shortcut_id}")
async def delete_shortcut(
request: Request, workspace_id: str, shortcut_id: str, key=Depends(authenticate)
):
await asyncio.to_thread(store.delete_shortcut, workspace_id, shortcut_id)
request.state.audit["shortcut_id"] = shortcut_id
return {"deleted": True}
@app.post(prefix + "/shortcuts/{shortcut_id}/execute")
async def execute_shortcut(
request: Request,
workspace_id: str,
shortcut_id: str,
payload: EmptyInput = Body(default_factory=EmptyInput),
key=Depends(authenticate),
):
shortcut = await asyncio.to_thread(store.shortcut, workspace_id, shortcut_id)
definition = await asyncio.to_thread(
store.template, workspace_id, shortcut["template_id"], shortcut["template_version"], True
)
request.state.audit["shortcut_id"] = shortcut_id
return await execute(request, workspace_id, key, definition, shortcut["params"])
return app
"""The ONLY module that reads environment variables; never imported by the server."""
import json
import os
import tempfile
from pathlib import Path
import yaml
from neoquery.config import Settings
SCALARS = {
"NQ_LISTEN_HOST": ("listen_host", str),
"NQ_PORT": ("port", int),
"NQ_DB_PATH": ("db_path", str),
"NQ_LOG_RETENTION_DAYS": ("log_retention_days", int),
"NQ_LOG_MAX_RECORDS": ("log_max_records", int),
}
LIMITS = {
"NQ_QUERY_TIMEOUT_SECONDS": ("query_timeout_seconds", float),
"NQ_REQUEST_TIMEOUT_SECONDS": ("request_timeout_seconds", float),
"NQ_MAX_CONCURRENCY": ("max_concurrency", int),
"NQ_WORKSPACE_CONCURRENCY": ("workspace_concurrency", int),
"NQ_KEY_CONCURRENCY": ("key_concurrency", int),
"NQ_MAX_ROWS": ("max_rows", int),
"NQ_MAX_RESPONSE_BYTES": ("max_response_bytes", int),
"NQ_MAX_REQUEST_BYTES": ("max_request_bytes", int),
}
def merge_config(source: dict, env: dict) -> Settings:
data = json.loads(json.dumps(source))
known = (
set(SCALARS) | set(LIMITS) | {"NQ_CONFIG_SOURCE", "NQ_CONFIG_OUTPUT", "NQ_WORKSPACE_OVERRIDES_JSON"}
)
if any(key.startswith("NQ_") and key not in known for key in env):
raise ValueError("Unknown NQ_ environment setting")
for key, (field, cast) in SCALARS.items():
if key in env:
data[field] = cast(env[key])
for key, (field, cast) in LIMITS.items():
if key in env:
data.setdefault("limits", {})[field] = cast(env[key])
overrides = json.loads(env.get("NQ_WORKSPACE_OVERRIDES_JSON", "{}"))
by_id = {w["id"]: w for w in data.get("workspaces", [])}
for workspace_id, patch in overrides.items():
if (
workspace_id not in by_id
or not isinstance(patch, dict)
or set(patch) - {"neo4j", "limits", "name"}
):
raise ValueError("Workspace overrides must reference existing IDs and allowed fields")
for field, value in patch.items():
if field in ("neo4j", "limits"):
by_id[workspace_id].setdefault(field, {}).update(value)
else:
by_id[workspace_id][field] = value
return Settings.model_validate(data)
def generate(source: Path, output: Path, env: dict) -> Settings:
if source.resolve() == output.resolve():
raise ValueError("Source and generated config must be different files")
config = merge_config(yaml.safe_load(source.read_text()), env)
data = config.model_dump(mode="json")
for ws, original in zip(data["workspaces"], config.workspaces, strict=True):
ws["neo4j"]["password"] = original.neo4j.password.get_secret_value()
output.parent.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(dir=output.parent, prefix=".config-")
try:
with os.fdopen(fd, "w") as file:
os.fchmod(file.fileno(), 0o600)
yaml.safe_dump(data, file, sort_keys=False)
file.flush()
os.fsync(file.fileno())
os.replace(name, output)
finally:
if Path(name).exists():
Path(name).unlink()
return config
def main():
env = dict(os.environ)
output = Path(env.get("NQ_CONFIG_OUTPUT", "/run/query-service/config.yaml"))
try:
generate(Path(env.get("NQ_CONFIG_SOURCE", "/etc/query-service/workspaces.yaml")), output, env)
except Exception:
# Configuration validation may contain credentials; do not print raw input/exception.
raise SystemExit("Invalid startup configuration; check source and NQ_ settings") from None
os.execvp("query-service", ["query-service", "serve", "--config", str(output)])
import fcntl
import json
import urllib.request
from datetime import UTC, datetime, timedelta
from pathlib import Path
import typer
import uvicorn
from neoquery.config import load_settings
from neoquery.db import Store
app = typer.Typer(no_args_is_help=True, pretty_exceptions_show_locals=False)
keys = typer.Typer(no_args_is_help=True, pretty_exceptions_show_locals=False)
db = typer.Typer(no_args_is_help=True, pretty_exceptions_show_locals=False)
app.add_typer(keys, name="keys")
app.add_typer(db, name="db")
@app.command()
def healthcheck(config: Path = typer.Option(Path("/run/query-service/config.yaml"), exists=True)):
settings = load_settings(config)
with urllib.request.urlopen(f"http://127.0.0.1:{settings.port}/health/ready", timeout=2) as response:
if response.status != 200:
raise typer.Exit(1)
def open_store(config: Path):
return Store(load_settings(config))
@app.command()
def serve(config: Path = typer.Option(..., exists=True)):
from neoquery.api import create_app
settings = load_settings(config)
# Prevent multiple servers with process-local limits using the same metadata DB.
lock_path = Path(settings.db_path + ".server.lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
with lock_path.open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise typer.BadParameter("A server is already using this metadata database") from None
store = Store(settings)
store.migrate()
uvicorn.run(
create_app(settings, store=store),
host=settings.listen_host,
port=settings.port,
workers=1,
access_log=False,
timeout_graceful_shutdown=15,
)
@db.command()
def migrate(config: Path = typer.Option(..., exists=True)):
store = open_store(config)
try:
store.migrate()
finally:
store.close()
typer.echo("Migration complete")
@keys.command()
def seed(
config: Path = typer.Option(..., exists=True),
workspace: str = typer.Option(...),
name: str = typer.Option(...),
role: str = typer.Option("reader"),
expires_in_days: int | None = typer.Option(None, min=1),
):
store = open_store(config)
try:
expires = (
(datetime.now(UTC) + timedelta(days=expires_in_days)).isoformat() if expires_in_days else None
)
result = store.seed(workspace, name, role, expires)
typer.echo(json.dumps(result))
finally:
store.close()
@keys.command("list")
def list_keys(config: Path = typer.Option(..., exists=True), workspace: str = typer.Option(...)):
store = open_store(config)
try:
typer.echo(json.dumps(store.keys(workspace)))
finally:
store.close()
@keys.command()
def revoke(config: Path = typer.Option(..., exists=True), key_id: str = typer.Option(...)):
store = open_store(config)
try:
store.revoke(key_id)
typer.echo("Key revoked")
finally:
store.close()
@db.command()
def backup(config: Path = typer.Option(..., exists=True), output: Path = typer.Option(...)):
store = open_store(config)
try:
store.backup(str(output))
typer.echo("Backup complete")
finally:
store.close()
import base64
import math
from datetime import date, datetime, time, timedelta
from neo4j.graph import Node, Path, Relationship
from neo4j.spatial import Point
from neo4j.time import Date, DateTime, Duration, Time
from neoquery.errors import ServiceError
def encode(value, depth=0):
if depth > 32:
raise ServiceError(422, "result_nesting_limit", "Result nesting exceeds limit")
def child(v):
return encode(v, depth + 1)
if value is None:
kind, data = "null", None
elif isinstance(value, bool):
kind, data = "boolean", value
elif isinstance(value, int):
kind, data = "integer", str(value)
elif isinstance(value, float):
kind, data = "float", value if math.isfinite(value) else str(value)
elif isinstance(value, str):
kind, data = "string", value
elif isinstance(value, Node):
kind, data = (
"node",
{
"element_id": value.element_id,
"labels": sorted(value.labels),
"properties": {k: child(v) for k, v in value.items()},
},
)
elif isinstance(value, Relationship):
kind, data = (
"relationship",
{
"element_id": value.element_id,
"relationship_type": value.type,
"start_node_element_id": value.start_node.element_id,
"end_node_element_id": value.end_node.element_id,
"properties": {k: child(v) for k, v in value.items()},
},
)
elif isinstance(value, Path):
kind, data = (
"path",
{
"nodes": [child(v) for v in value.nodes],
"relationships": [child(v) for v in value.relationships],
},
)
elif isinstance(value, Point):
kind, data = "point", {"srid": value.srid, "coordinates": list(value)}
elif isinstance(value, Duration):
kind, data = (
"duration",
{
"months": value.months,
"days": value.days,
"seconds": value.seconds,
"nanoseconds": value.nanoseconds,
},
)
elif isinstance(value, (DateTime, datetime)):
kind, data = "datetime", value.iso_format() if isinstance(value, DateTime) else value.isoformat()
elif isinstance(value, (Date, date)):
kind, data = "date", value.iso_format() if isinstance(value, Date) else value.isoformat()
elif isinstance(value, (Time, time)):
kind, data = "time", value.iso_format() if isinstance(value, Time) else value.isoformat()
elif isinstance(value, timedelta):
kind, data = (
"duration",
{
"months": 0,
"days": value.days,
"seconds": value.seconds,
"nanoseconds": value.microseconds * 1000,
},
)
elif isinstance(value, (bytes, bytearray)):
kind, data = "bytes", base64.b64encode(value).decode("ascii")
elif isinstance(value, dict):
kind, data = "map", {k: child(v) for k, v in value.items()}
elif isinstance(value, (tuple, list)):
kind, data = "list", [child(v) for v in value]
else:
raise ServiceError(422, "unsupported_result_type", "Unsupported Neo4j result type")
return {"type": kind, "value": data}
from pathlib import Path
from typing import Annotated
import yaml
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,63}$")]
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class Limits(StrictModel):
query_timeout_seconds: float = Field(default=10, gt=0, le=300)
request_timeout_seconds: float = Field(default=12, gt=0, le=360)
max_concurrency: int = Field(default=16, ge=1, le=1024)
workspace_concurrency: int = Field(default=4, ge=1, le=1024)
key_concurrency: int = Field(default=2, ge=1, le=1024)
max_rows: int = Field(default=1000, ge=1, le=100000)
max_response_bytes: int = Field(default=5 * 1024 * 1024, ge=1024, le=100 * 1024 * 1024)
max_request_bytes: int = Field(default=256 * 1024, ge=1024, le=10 * 1024 * 1024)
class WorkspaceLimits(StrictModel):
query_timeout_seconds: float | None = Field(default=None, gt=0)
request_timeout_seconds: float | None = Field(default=None, gt=0)
max_concurrency: int | None = Field(default=None, ge=1)
key_concurrency: int | None = Field(default=None, ge=1)
max_rows: int | None = Field(default=None, ge=1)
max_response_bytes: int | None = Field(default=None, ge=1024)
class Neo4jConfig(StrictModel):
uri: str = Field(pattern=r"^(bolt|neo4j)(\+s|\+ssc)?://[^\s]+$")
database: str = Field(min_length=1, max_length=63)
username: str = Field(min_length=1)
password: SecretStr
@model_validator(mode="after")
def check_target(self):
from urllib.parse import urlsplit
uri = urlsplit(self.uri)
if uri.username or uri.password or uri.query or uri.fragment or uri.path not in ("", "/"):
raise ValueError("Neo4j URI must contain only scheme, host and port")
if not uri.hostname or self.database.lower() == "system" or not self.password.get_secret_value():
raise ValueError("A non-system database, host and password are required")
return self
class Workspace(StrictModel):
id: Identifier
name: str = Field(min_length=1, max_length=128)
neo4j: Neo4jConfig
limits: WorkspaceLimits = Field(default_factory=WorkspaceLimits)
class Settings(StrictModel):
config_version: int = Field(default=1, ge=1, le=1)
listen_host: str = "0.0.0.0"
port: int = Field(default=8080, ge=1, le=65535)
db_path: str = "/data/service.db"
limits: Limits = Field(default_factory=Limits)
log_retention_days: int = Field(default=14, ge=1, le=3650)
log_max_records: int = Field(default=100000, ge=100)
workspaces: tuple[Workspace, ...] = Field(min_length=1)
@model_validator(mode="after")
def validate_settings(self):
ids = [w.id for w in self.workspaces]
if len(ids) != len(set(ids)):
raise ValueError("Duplicate workspace ID")
if not Path(self.db_path).is_absolute():
raise ValueError("db_path must be absolute")
return self
def workspace(self, workspace_id: str) -> Workspace:
return next(w for w in self.workspaces if w.id == workspace_id)
def effective_limits(self, workspace: Workspace) -> Limits:
values = self.limits.model_dump()
for name, value in workspace.limits.model_dump(exclude_none=True).items():
target = "workspace_concurrency" if name == "max_concurrency" else name
values[target] = min(values[target], value)
return Limits(**values)
def load_settings(path: str | Path) -> Settings:
return Settings.model_validate(yaml.safe_load(Path(path).read_text()))
import hashlib
import hmac
import json
import secrets
import sqlite3
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, event, text
from sqlalchemy.exc import IntegrityError
from neoquery.config import Settings
from neoquery.errors import ServiceError
def now() -> str:
return datetime.now(UTC).isoformat()
def identifier() -> str:
return uuid.uuid4().hex
class Store:
def __init__(self, settings: Settings):
self.settings = settings
path = Path(settings.db_path)
path.parent.mkdir(parents=True, exist_ok=True)
# Ensure the metadata (including saved query parameters) is private from creation.
path.touch(mode=0o600, exist_ok=True)
path.chmod(0o600)
self.engine = create_engine(
"sqlite:///" + str(path), connect_args={"check_same_thread": False}, pool_size=5, max_overflow=0
)
@event.listens_for(self.engine, "connect")
def pragmas(connection, _):
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA busy_timeout=5000")
def migrate(self):
root = Path(__file__).resolve().parents[2]
config = Config(str(root / "alembic.ini"))
with self.engine.connect() as connection:
connection.exec_driver_sql("PRAGMA journal_mode=WAL")
connection.commit()
config.attributes["connection"] = connection
command.upgrade(config, "head")
with self.engine.begin() as connection:
for ws in self.settings.workspaces:
connection.execute(
text(
"INSERT INTO workspaces(id,name,created_at) VALUES(:id,:name,:ts) "
"ON CONFLICT(id) DO UPDATE SET name=excluded.name"
),
{"id": ws.id, "name": ws.name, "ts": now()},
)
def close(self):
self.engine.dispose()
def health(self):
with self.engine.connect() as connection:
connection.execute(text("SELECT id FROM workspaces LIMIT 1"))
def seed(self, workspace: str, name: str, role: str, expires_at: str | None = None):
self.settings.workspace(workspace)
if role not in ("reader", "maintainer") or not name.strip():
raise ValueError("Invalid key name or role")
key_id, secret = secrets.token_hex(12), secrets.token_urlsafe(32)
with self.engine.begin() as connection:
connection.execute(
text("INSERT INTO api_keys VALUES (:id,:ws,:name,:hash,:role,:created,:expires,NULL)"),
{
"id": key_id,
"ws": workspace,
"name": name,
"hash": hashlib.sha256(secret.encode()).hexdigest(),
"role": role,
"created": now(),
"expires": expires_at,
},
)
self._audit(
connection,
{
"request_id": identifier(),
"workspace_id": workspace,
"key_id": key_id,
"operation": "keys.seed",
"status": 201,
},
)
return {"id": key_id, "workspace_id": workspace, "role": role, "key": f"nq_{key_id}.{secret}"}
def keys(self, workspace: str):
with self.engine.connect() as connection:
return [
dict(r)
for r in connection.execute(
text(
"SELECT id,workspace_id,name,role,created_at,expires_at,revoked_at FROM api_keys "
"WHERE workspace_id=:ws ORDER BY created_at,id"
),
{"ws": workspace},
).mappings()
]
def revoke(self, key_id: str):
with self.engine.begin() as connection:
row = connection.execute(
text("SELECT workspace_id FROM api_keys WHERE id=:id"), {"id": key_id}
).first()
if not row:
raise ServiceError(404, "key_not_found", "Key not found")
connection.execute(
text("UPDATE api_keys SET revoked_at=COALESCE(revoked_at,:ts) WHERE id=:id"),
{"id": key_id, "ts": now()},
)
self._audit(
connection,
{
"request_id": identifier(),
"workspace_id": row[0],
"key_id": key_id,
"operation": "keys.revoke",
"status": 200,
},
)
def authenticate(self, token: str, workspace: str):
invalid = ServiceError(401, "invalid_key", "Invalid or expired API key")
if not token.startswith("nq_") or "." not in token or len(token) > 256:
raise invalid
key_id, secret = token[3:].split(".", 1)
with self.engine.connect() as connection:
row = (
connection.execute(text("SELECT * FROM api_keys WHERE id=:id"), {"id": key_id})
.mappings()
.first()
)
digest = hashlib.sha256(secret.encode()).hexdigest()
if not hmac.compare_digest(digest, row["secret_hash"] if row else "0" * 64):
raise invalid
if not row or row["revoked_at"] or (row["expires_at"] and row["expires_at"] <= now()):
raise invalid
if row["workspace_id"] != workspace:
raise ServiceError(403, "workspace_forbidden", "Key cannot access this workspace")
try:
self.settings.workspace(workspace)
except StopIteration:
raise ServiceError(403, "workspace_disabled", "Workspace is not configured") from None
return {"id": row["id"], "workspace_id": workspace, "role": row["role"]}
@staticmethod
def _decode(row):
if row is None:
raise ServiceError(404, "not_found", "Resource not found in this workspace")
data = dict(row)
for field in ("parameter_schema", "example_params", "params"):
if field in data:
data[field] = json.loads(data[field])
if "enabled" in data:
data["enabled"] = bool(data["enabled"])
return data
def template(self, workspace, template_id, version=None, require_enabled=False):
with self.engine.connect() as connection:
row = (
connection.execute(
text(
"SELECT t.*,v.version,v.cypher,v.parameter_schema,v.example_params,v.created_by "
"FROM templates t JOIN template_versions v ON t.workspace_id=v.workspace_id "
"AND t.id=v.template_id AND v.version=COALESCE(:version,t.current_version) "
"WHERE t.workspace_id=:ws AND t.id=:id"
),
{"ws": workspace, "id": template_id, "version": version},
)
.mappings()
.first()
)
result = self._decode(row)
if require_enabled and not result["enabled"]:
raise ServiceError(409, "template_disabled", "Template is disabled")
return result
def list_templates(self, workspace, offset, limit):
with self.engine.connect() as connection:
rows = connection.execute(
text(
"SELECT t.*,v.parameter_schema FROM templates t JOIN template_versions v "
"ON t.workspace_id=v.workspace_id AND t.id=v.template_id AND t.current_version=v.version "
"WHERE t.workspace_id=:ws AND t.enabled=1 ORDER BY t.created_at,t.id LIMIT :limit OFFSET :offset"
),
{"ws": workspace, "offset": offset, "limit": limit},
).mappings()
return [self._decode(r) for r in rows]
def save_template(self, workspace, key_id, payload, template_id=None):
template_id = template_id or identifier()
expected = payload.get("expected_version")
version = 1 if expected is None else expected + 1
with self.engine.begin() as connection:
if expected is None:
connection.execute(
text("INSERT INTO templates VALUES(:id,:ws,:name,:description,1,1,:ts)"),
{
"id": template_id,
"ws": workspace,
"name": payload["name"],
"description": payload["description"],
"ts": now(),
},
)
else:
changed = connection.execute(
text(
"UPDATE templates SET name=:name,description=:description,current_version=:version "
"WHERE workspace_id=:ws AND id=:id AND current_version=:expected AND enabled=1"
),
{
"name": payload["name"],
"description": payload["description"],
"version": version,
"ws": workspace,
"id": template_id,
"expected": expected,
},
).rowcount
if not changed:
raise ServiceError(
409, "version_conflict", "Template changed, was disabled, or does not exist"
)
connection.execute(
text(
"INSERT INTO template_versions VALUES "
"(:ws,:id,:version,:cypher,:schema,:example,:key,:ts)"
),
{
"ws": workspace,
"id": template_id,
"version": version,
"cypher": payload["cypher"],
"schema": json.dumps(payload["parameter_schema"]),
"example": json.dumps(payload["example_params"]),
"key": key_id,
"ts": now(),
},
)
return self.template(workspace, template_id, version)
def disable_template(self, workspace, template_id):
with self.engine.begin() as connection:
changed = connection.execute(
text("UPDATE templates SET enabled=0 WHERE workspace_id=:ws AND id=:id"),
{"ws": workspace, "id": template_id},
).rowcount
if not changed:
raise ServiceError(404, "not_found", "Template not found")
def shortcut(self, workspace, shortcut_id):
with self.engine.connect() as connection:
return self._decode(
connection.execute(
text("SELECT * FROM shortcuts WHERE workspace_id=:ws AND id=:id"),
{"ws": workspace, "id": shortcut_id},
)
.mappings()
.first()
)
def list_shortcuts(self, workspace, offset, limit):
with self.engine.connect() as connection:
return [
self._decode(row)
for row in connection.execute(
text(
"SELECT * FROM shortcuts WHERE workspace_id=:ws ORDER BY created_at,id LIMIT :limit OFFSET :offset"
),
{"ws": workspace, "offset": offset, "limit": limit},
).mappings()
]
def save_shortcut(self, workspace, key_id, payload, shortcut_id=None):
expected = payload.get("expected_revision")
shortcut_id = shortcut_id or identifier()
args = {
"ws": workspace,
"id": shortcut_id,
"name": payload["name"],
"template": payload["template_id"],
"version": payload["template_version"],
"params": json.dumps(payload["params"]),
"key": key_id,
"ts": now(),
"expected": expected,
}
try:
with self.engine.begin() as connection:
if expected is None:
connection.execute(
text(
"INSERT INTO shortcuts VALUES "
"(:id,:ws,:name,:template,:version,:params,1,:key,:ts,:ts)"
),
args,
)
else:
changed = connection.execute(
text(
"UPDATE shortcuts SET name=:name,template_id=:template,"
"template_version=:version,params=:params,revision=revision+1,updated_at=:ts "
"WHERE workspace_id=:ws AND id=:id AND revision=:expected"
),
args,
).rowcount
if not changed:
raise ServiceError(409, "version_conflict", "Shortcut changed or does not exist")
except IntegrityError:
raise ServiceError(
422, "invalid_reference", "Template version must belong to this workspace"
) from None
return self.shortcut(workspace, shortcut_id)
def delete_shortcut(self, workspace, shortcut_id):
with self.engine.begin() as connection:
changed = connection.execute(
text("DELETE FROM shortcuts WHERE workspace_id=:ws AND id=:id"),
{"ws": workspace, "id": shortcut_id},
).rowcount
if not changed:
raise ServiceError(404, "not_found", "Shortcut not found")
@staticmethod
def _audit(connection, data):
fields = (
"request_id",
"created_at",
"workspace_id",
"key_id",
"operation",
"template_id",
"template_version",
"shortcut_id",
"status",
"duration_ms",
"row_count",
"error_code",
)
values = {key: data.get(key) for key in fields}
values["created_at"] = now()
values["duration_ms"] = data.get("duration_ms", 0)
connection.execute(
text(
"INSERT INTO access_logs ("
+ ",".join(fields)
+ ") VALUES ("
+ ",".join(":" + key for key in fields)
+ ")"
),
values,
)
def audit(self, data):
with self.engine.begin() as connection:
self._audit(connection, data)
def prune_logs(self):
cutoff = (datetime.now(UTC) - timedelta(days=self.settings.log_retention_days)).isoformat()
with self.engine.begin() as connection:
connection.execute(text("DELETE FROM access_logs WHERE created_at < :cutoff"), {"cutoff": cutoff})
connection.execute(
text(
"DELETE FROM access_logs WHERE id <= COALESCE((SELECT id FROM access_logs "
"ORDER BY id DESC LIMIT 1 OFFSET :cap),-1)"
),
{"cap": self.settings.log_max_records},
)
def backup(self, output: str):
target = Path(output)
if target.exists():
raise ValueError("Backup destination must not exist")
target.parent.mkdir(parents=True, exist_ok=True)
target.touch(mode=0o600, exist_ok=False)
with sqlite3.connect(self.settings.db_path) as source, sqlite3.connect(target) as destination:
source.backup(destination)
class ServiceError(Exception):
def __init__(self, status: int, code: str, message: str):
self.status = status
self.code = code
self.message = message
super().__init__(message)
import asyncio
import json
import time
from collections import Counter
from contextlib import contextmanager
from neo4j import READ_ACCESS, AsyncGraphDatabase
from neo4j.exceptions import DriverError, Neo4jError
from neoquery.codec import encode
from neoquery.errors import ServiceError
from neoquery.validation import check_cypher
class Admission:
def __init__(self, maximum):
self.maximum = maximum
self.total = 0
self.workspaces = Counter()
self.keys = Counter()
@contextmanager
def enter(self, workspace, key, limits):
# All callers run on one event loop. No await between checking and incrementing.
if (
self.total >= self.maximum
or self.workspaces[workspace] >= limits.workspace_concurrency
or self.keys[key] >= limits.key_concurrency
):
raise ServiceError(429, "concurrency_limit", "Query concurrency limit reached")
self.total += 1
self.workspaces[workspace] += 1
self.keys[key] += 1
try:
yield
finally:
self.total -= 1
self.workspaces[workspace] -= 1
self.keys[key] -= 1
if not self.workspaces[workspace]:
del self.workspaces[workspace]
if not self.keys[key]:
del self.keys[key]
class Executor:
def __init__(self, settings):
self.settings = settings
self.admission = Admission(settings.limits.max_concurrency)
self.drivers = {}
for workspace in settings.workspaces:
limits = settings.effective_limits(workspace)
self.drivers[workspace.id] = AsyncGraphDatabase.driver(
workspace.neo4j.uri,
auth=(workspace.neo4j.username, workspace.neo4j.password.get_secret_value()),
max_connection_pool_size=min(limits.workspace_concurrency, settings.limits.max_concurrency),
connection_acquisition_timeout=limits.request_timeout_seconds,
connection_timeout=limits.request_timeout_seconds,
max_transaction_retry_time=0,
)
async def close(self):
await asyncio.gather(*(driver.close() for driver in self.drivers.values()))
async def run(
self, workspace_id, key_id, cypher, params, *, request_id, max_rows=None, explain_only=False
):
check_cypher(cypher)
workspace = self.settings.workspace(workspace_id)
limits = self.settings.effective_limits(workspace)
count_limit = min(max_rows or limits.max_rows, limits.max_rows)
started = time.monotonic()
session = None
try:
with self.admission.enter(workspace_id, key_id, limits):
async with asyncio.timeout(limits.request_timeout_seconds):
session = self.drivers[workspace_id].session(
database=workspace.neo4j.database, default_access_mode=READ_ACCESS, fetch_size=100
)
async with session:
tx = await session.begin_transaction(
timeout=limits.query_timeout_seconds,
metadata={"request_id": request_id, "workspace": workspace_id},
)
try:
plan = await tx.run("EXPLAIN " + cypher, params)
summary = await plan.consume()
if summary.query_type != "r":
raise ServiceError(
422, "not_read_only", "Only explicitly read-only queries are allowed"
)
if explain_only:
return {"validated": True}
result = await tx.run(cypher, params)
columns = list(result.keys())
rows, truncated = [], False
# Streaming fetch; never consume the full unbounded query into a list.
# Track actual payload bytes; the HTTP boundary checks the complete envelope.
size = len(
json.dumps(columns, ensure_ascii=False, separators=(",", ":")).encode()
)
async for record in result:
if len(rows) == count_limit:
truncated = True
break
row = [encode(v) for v in record.values()]
size += (
len(json.dumps(row, ensure_ascii=False, separators=(",", ":")).encode())
+ 1
)
if size > limits.max_response_bytes:
raise ServiceError(
422, "response_too_large", "Query response exceeds byte limit"
)
rows.append(row)
return {
"columns": columns,
"rows": rows,
"row_count": len(rows),
"truncated": truncated,
"duration_ms": int((time.monotonic() - started) * 1000),
}
except asyncio.CancelledError:
session.cancel()
raise
finally:
if not tx.closed():
await tx.rollback()
except TimeoutError:
if session:
session.cancel()
raise ServiceError(504, "query_timeout", "Query deadline exceeded") from None
except asyncio.CancelledError:
if session:
session.cancel()
raise
except Neo4jError as error:
code = error.code or ""
if "TimedOut" in code or "Terminated" in code:
raise ServiceError(504, "query_timeout", "Neo4j transaction deadline exceeded") from None
if "Statement" in code:
raise ServiceError(
422, "invalid_query", "Neo4j rejected the query or parameter types"
) from None
raise ServiceError(503, "neo4j_unavailable", "Workspace database unavailable") from None
except (DriverError, OSError):
raise ServiceError(503, "neo4j_unavailable", "Workspace database unavailable") from None
import math
import re
from copy import deepcopy
from jsonschema import Draft202012Validator, validators
from jsonschema.exceptions import SchemaError
from neoquery.errors import ServiceError
DENIED = set(
"CREATE MERGE SET DELETE DETACH REMOVE DROP ALTER GRANT DENY REVOKE "
"CALL LOAD USE SHOW TERMINATE START STOP RENAME FOREACH INSERT "
"EXPLAIN PROFILE CYPHER FINISH TRANSACTIONS".split()
)
FUNCTIONS = set(
"abs acos asin atan atan2 avg ceil ceiling coalesce collect cos cot count date datetime "
"degrees duration e elementid endnode exp floor haversin head id isempty isnan keys labels "
"last left length linenumber localdatetime localtime log log10 ltrim max min nodes normalize "
"nullif pi point properties radians rand range reduce relationships replace reverse right "
"round rtrim sign sin size split sqrt startnode stdev stdevp substring sum tail tan time "
"timestamp toboolean tobooleanlist tofloat tofloatlist tointeger tointegerlist tolower "
"tostring tostringlist toupper trim type valueType percentilecont percentiledisc "
"all any none single exists shortestpath allshortestpaths".lower().split()
)
PAREN_KEYWORDS = set(
"MATCH OPTIONAL WHERE WITH RETURN DISTINCT AND OR XOR NOT IN AS CASE WHEN THEN ELSE "
"UNWIND ORDER BY SKIP LIMIT UNION ALL EXISTS".split()
)
TOKEN = re.compile(
r"(?P<space>\s+)|(?P<line>//[^\n]*)|(?P<comment>/\*[\s\S]*?\*/)|"
r"(?P<string>'(?:\\.|''|[^'\\])*'|\"(?:\\.|\"\"|[^\"\\])*\")|"
r"(?P<quoted>`(?:``|[^`])*`)|(?P<param>\$[A-Za-z_][A-Za-z0-9_]*)|"
r"(?P<word>[A-Za-z_][A-Za-z0-9_]*)|(?P<number>\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|"
r"(?P<symbol>[^\w\s])"
)
def reject(message, code="invalid_template"):
raise ServiceError(422, code, message)
def check_cypher(cypher: str) -> set[str]:
tokens, pos = [], 0
while pos < len(cypher):
match = TOKEN.match(cypher, pos)
if not match:
reject("Unsupported Cypher token; quote non-ASCII identifiers")
kind, value = match.lastgroup, match.group()
pos = match.end()
if kind not in ("space", "line", "comment"):
tokens.append((kind, value))
if not tokens:
reject("Empty query")
for index, (kind, value) in enumerate(tokens):
if kind == "word" and value.upper() in DENIED:
reject("Write, administration, procedure, database switching and query-prefix syntax is disabled")
if kind == "symbol" and value in (";", "'", '"', "`", "$", "\\"):
reject("Multiple statements or unsupported quoting")
if kind not in ("word", "quoted") or index + 1 >= len(tokens) or tokens[index + 1][1] != "(":
continue
name = value[1:-1].replace("``", "`") if kind == "quoted" else value
if kind == "word" and name.upper() in PAREN_KEYWORDS:
continue
if index > 0 and tokens[index - 1][1] == ".":
reject("Namespaced and custom functions are disabled")
if name.lower() not in FUNCTIONS:
reject("Function is outside the built-in allowlist")
return {value[1:] for kind, value in tokens if kind == "param"}
ALLOWED_SCHEMA = {
"type",
"properties",
"required",
"additionalProperties",
"items",
"enum",
"default",
"description",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"minLength",
"maxLength",
"minItems",
"maxItems",
"minProperties",
"maxProperties",
}
TYPES = {"string", "integer", "number", "boolean", "null", "array", "object"}
def check_schema(schema: dict, depth=0):
if depth > 12 or not isinstance(schema, dict) or set(schema) - ALLOWED_SCHEMA:
reject("Parameter schema uses unsupported keywords or exceeds nesting limit")
try:
Draft202012Validator.check_schema(schema)
except SchemaError:
reject("Invalid parameter schema")
kind = schema.get("type")
if not isinstance(kind, str) or kind not in TYPES:
reject("Each parameter schema must have one explicit supported type")
if kind == "object":
if schema.get("additionalProperties") is not False:
reject("Object schemas require additionalProperties=false")
for child in schema.get("properties", {}).values():
check_schema(child, depth + 1)
if kind == "array":
if "items" not in schema:
reject("Arrays require an item schema")
check_schema(schema["items"], depth + 1)
if "default" in schema:
validate_params(schema, schema["default"], root=False)
def _defaults(schema, value):
if schema["type"] == "object" and isinstance(value, dict):
result = deepcopy(value)
for name, child in schema.get("properties", {}).items():
if name not in result and "default" in child:
result[name] = deepcopy(child["default"])
if name in result:
result[name] = _defaults(child, result[name])
return result
if schema["type"] == "array" and isinstance(value, list):
return [_defaults(schema["items"], item) for item in value]
return value
def _finite(value, depth=0):
if depth > 32:
reject("Parameter nesting exceeds limit", "invalid_params")
if isinstance(value, float) and not math.isfinite(value):
reject("Non-finite parameter value", "invalid_params")
if isinstance(value, int) and not isinstance(value, bool) and not -(2**63) <= value < 2**63:
reject("Integer parameter outside Neo4j 64-bit range", "invalid_params")
if isinstance(value, dict):
for child in value.values():
_finite(child, depth + 1)
if isinstance(value, list):
for child in value:
_finite(child, depth + 1)
StrictValidator = validators.extend(
Draft202012Validator,
type_checker=Draft202012Validator.TYPE_CHECKER.redefine("integer", lambda _, value: type(value) is int),
)
def validate_params(schema, params, root=True):
if root and (schema.get("type") != "object" or not isinstance(params, dict)):
reject("Parameters must be an object", "invalid_params")
value = _defaults(schema, params)
_finite(value)
errors = list(StrictValidator(schema).iter_errors(value))
if errors:
# Do not echo sensitive parameter values in error responses or logs.
reject("Parameters do not match the template schema", "invalid_params")
return value
def validate_definition(cypher, schema, examples):
params = check_cypher(cypher)
check_schema(schema)
if schema.get("type") != "object" or params != set(schema.get("properties", {})):
reject("Declared parameter names must exactly match Cypher placeholders")
for name, child in schema.get("properties", {}).items():
if name not in schema.get("required", []) and "default" not in child:
reject("Each Cypher parameter must be required or have a default")
return validate_params(schema, examples)
from copy import deepcopy
import httpx
import pytest
import pytest_asyncio
from neoquery.api import create_app
from neoquery.config import Settings
from neoquery.db import Store
EMPTY_SCHEMA = {"type": "object", "additionalProperties": False, "properties": {}}
def pytest_addoption(parser):
parser.addoption("--integration", action="store_true", help="Use the disposable Neo4j at localhost:17687")
@pytest.fixture
def settings(tmp_path):
return Settings.model_validate(
{
"db_path": str(tmp_path / "service.db"),
"workspaces": [
{
"id": name,
"name": name,
"neo4j": {
"uri": "bolt://localhost:7687",
"database": "neo4j",
"username": "neo4j",
"password": "test-secret",
},
}
for name in ("alpha", "beta")
],
}
)
@pytest.fixture
def store(settings):
store = Store(settings)
store.migrate()
yield store
store.close()
@pytest.fixture
def tokens(store):
return {
name: store.seed(workspace, name, role)
for name, workspace, role in (
("admin", "alpha", "maintainer"),
("reader", "alpha", "reader"),
("peer", "alpha", "reader"),
("outsider", "beta", "maintainer"),
)
}
class FakeExecutor:
def __init__(self):
self.calls = []
async def run(self, workspace_id, key_id, cypher, params, **kwargs):
self.calls.append((workspace_id, cypher, deepcopy(params), kwargs))
if kwargs.get("explain_only"):
return {"validated": True}
return {
"columns": ["value"],
"rows": [[{"type": "integer", "value": "1"}]],
"row_count": 1,
"truncated": False,
"duration_ms": 0,
}
async def close(self):
pass
@pytest.fixture
def executor():
return FakeExecutor()
@pytest_asyncio.fixture
async def client(settings, store, executor):
app = create_app(settings, store=store, executor=executor)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
yield client
def headers(token):
return {"Authorization": "Bearer " + token["key"]}
def definition(**kwargs):
return {
"name": "Sample",
"description": "",
"cypher": "RETURN 1 AS value",
"parameter_schema": deepcopy(EMPTY_SCHEMA),
"example_params": {},
**kwargs,
}
"""End-to-end Docker acceptance. Local Workspace queries are strictly read-only.
Run against the disposable API container configured with alpha/beta/local workspaces.
Never prints credentials, query parameters or returned graph data.
"""
import argparse
import concurrent.futures
import json
import subprocess
import time
from pathlib import Path
import httpx
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--container", default="neoquery-acceptance-api")
parser.add_argument("--base-url", default="http://127.0.0.1:18081")
parser.add_argument("--report", type=Path, required=True)
args = parser.parse_args()
report = {"checks": {}, "local_queries": []}
def command(*parts):
result = subprocess.run(["docker", "exec", args.container, *parts], capture_output=True, text=True)
if result.returncode:
raise AssertionError("Docker management command failed (output withheld)")
return result.stdout
config = "/run/query-service/config.yaml"
def seed(workspace, role="reader"):
return json.loads(
command(
"query-service",
"keys",
"seed",
"--config",
config,
"--workspace",
workspace,
"--name",
"acceptance",
"--role",
role,
)
)
def request(method, path, key, body=None):
with httpx.Client(base_url=args.base_url, timeout=20) as client:
return client.request(method, path, headers={"Authorization": "Bearer " + key["key"]}, json=body)
def base(ws):
return f"/api/v1/workspaces/{ws}"
def template(ws, key, query, schema=None, examples=None):
response = request(
"POST",
base(ws) + "/templates",
key,
{
"name": "Acceptance query",
"cypher": query,
"parameter_schema": schema
or {"type": "object", "additionalProperties": False, "properties": {}},
"example_params": examples or {},
},
)
assert response.status_code == 201, (response.status_code, response.json().get("error"))
return response.json()
for _ in range(50):
try:
if httpx.get(args.base_url + "/health/ready").status_code == 200:
break
except httpx.HTTPError:
pass
time.sleep(0.2)
else:
raise AssertionError("API did not become ready")
initial_pid = subprocess.check_output(
["docker", "inspect", args.container, "--format", "{{.State.Pid}}"], text=True
).strip()
admin, reader, outsider = seed("alpha", "maintainer"), seed("alpha"), seed("beta")
assert request("GET", base("alpha") + "/templates", reader).status_code == 200
assert request("GET", base("alpha") + "/templates", outsider).status_code == 403
report["checks"]["workspace_isolation"] = True
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"limit": {"type": "integer", "default": 3, "minimum": 1, "maximum": 20}},
}
tpl = template("alpha", admin, "UNWIND range(1,100) AS n RETURN n LIMIT $limit", schema)
saved = request(
"POST",
base("alpha") + "/shortcuts",
reader,
{"name": "Saved acceptance query", "template_id": tpl["id"]},
).json()
assert saved["params"] == {"limit": 3} and saved["template_version"] == 1
peer = seed("alpha")
url = base("alpha") + "/shortcuts/" + saved["id"] + "/execute"
assert request("POST", url, peer, {}).json()["row_count"] == 3
assert request("POST", url, peer, {"params": {"limit": 1}}).status_code == 422
report["checks"]["shared_fixed_shortcut"] = True
def concurrent_work(index):
new_key = seed("alpha")
response = request(
"POST", base("alpha") + "/shortcuts", new_key, {"name": str(index), "template_id": tpl["id"]}
)
assert response.status_code == 201
assert request("GET", base("alpha") + "/templates", new_key).status_code == 200
return new_key
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
generated = list(pool.map(concurrent_work, range(8)))
command("query-service", "keys", "revoke", "--config", config, "--key-id", reader["id"])
assert request("GET", base("alpha") + "/templates", reader).status_code == 401
current_pid = subprocess.check_output(
["docker", "inspect", args.container, "--format", "{{.State.Pid}}"], text=True
).strip()
assert initial_pid == current_pid
report["checks"]["live_seed_revoke_without_restart"] = True
report["checks"]["concurrent_seed_shortcuts_logs"] = True
# Compile-only registration, then disconnect a real HTTP socket from an expensive read query.
expensive = template(
"alpha", admin, "UNWIND range(1,100000) AS x UNWIND range(1,100000) AS y RETURN sum(x*y)"
)
try:
httpx.post(
args.base_url + base("alpha") + f"/templates/{expensive['id']}/execute",
headers={"Authorization": "Bearer " + peer["key"]},
json={},
timeout=0.05,
)
except httpx.TimeoutException:
pass
else:
raise AssertionError("Expensive request should outlast the client timeout")
for _ in range(30):
raw = command(
"python",
"-c",
"import sqlite3,json; c=sqlite3.connect('/data/service.db'); "
'print(json.dumps(c.execute("SELECT count(*) FROM access_logs WHERE status=499").fetchone()[0]))',
)
if json.loads(raw) > 0:
break
time.sleep(0.1)
else:
raise AssertionError("Client disconnection not audited")
assert request("POST", url, peer, {}).status_code == 200
report["checks"]["real_http_disconnect_cancellation"] = True
# Existing local data is only sampled, never seeded/updated/deleted.
local_key = seed("local", "maintainer")
for cypher in ("MATCH (n) RETURN n LIMIT 3", "MATCH p=()-[]->() RETURN p LIMIT 1"):
local_tpl = template("local", local_key, cypher)
response = request("POST", base("local") + f"/templates/{local_tpl['id']}/execute", local_key, {})
assert response.status_code == 200, (response.status_code, response.json().get("error"))
payload = response.json()
report["local_queries"].append(
{
"rows": payload["row_count"],
"types": sorted({value["type"] for row in payload["rows"] for value in row}),
}
)
report["checks"]["existing_local_neo4j_read_only_queries"] = True
backup_path = f"/data/acceptance-backup-{time.time_ns()}.db"
command("query-service", "db", "backup", "--config", config, "--output", backup_path)
assert (
command(
"python",
"-c",
f"import sqlite3; print(sqlite3.connect({backup_path!r}).execute('PRAGMA integrity_check').fetchone()[0])",
).strip()
== "ok"
)
report["checks"]["online_backup"] = True
source = command(
"python",
"-c",
"from pathlib import Path; import stat; p=Path('/run/query-service/config.yaml'); print(oct(stat.S_IMODE(p.stat().st_mode)))",
)
assert source.strip() == "0o600"
command("query-service", "healthcheck", "--config", config)
assert command("id", "-u").strip() == "10001"
report["checks"]["nonroot_private_config_environment_port_healthcheck"] = True
subprocess.run(["docker", "restart", args.container], capture_output=True, check=True)
for _ in range(40):
try:
response = request("POST", url, peer, {})
if response.status_code == 200:
break
except httpx.HTTPError:
pass
time.sleep(0.2)
else:
raise AssertionError("Persisted shortcut did not survive restart")
report["checks"]["restart_persistence_and_idempotent_migration"] = True
# Check log tables without printing their contents.
all_tokens = [admin, reader, outsider, peer, local_key, *generated]
raw_logs = command(
"python",
"-c",
"import sqlite3,json; print(json.dumps(sqlite3.connect('/data/service.db').execute('SELECT * FROM access_logs').fetchall()))",
)
assert all(item["key"].split(".")[1] not in raw_logs for item in all_tokens)
report["checks"]["log_redaction"] = True
args.report.write_text(json.dumps(report, indent=2))
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()
import asyncio
from conftest import definition, headers
from sqlalchemy import text
BASE = "/api/v1/workspaces/alpha"
async def create(client, tokens, payload=None):
response = await client.post(
BASE + "/templates", headers=headers(tokens["admin"]), json=payload or definition()
)
assert response.status_code == 201, response.text
return response.json()
async def test_auth_roles_and_cross_workspace(client, tokens):
assert (await client.get(BASE + "/templates")).status_code == 401
assert (await client.get(BASE + "/templates", headers=headers(tokens["outsider"]))).status_code == 403
assert (
await client.post(BASE + "/templates", headers=headers(tokens["reader"]), json=definition())
).status_code == 403
template = await create(client, tokens)
foreign = "/api/v1/workspaces/beta/templates/" + template["id"]
assert (await client.get(foreign, headers=headers(tokens["outsider"]))).status_code == 404
async def test_template_shortcut_version_fixed_shared_and_disabled(client, tokens, executor):
template = await create(client, tokens)
shortcut_response = await client.post(
BASE + "/shortcuts",
headers=headers(tokens["reader"]),
json={"name": "saved", "template_id": template["id"]},
)
assert shortcut_response.status_code == 201, shortcut_response.text
shortcut = shortcut_response.json()
assert shortcut["template_version"] == 1 and shortcut["params"] == {}
response = await client.put(
BASE + "/templates/" + template["id"],
headers=headers(tokens["admin"]),
json=definition(cypher="RETURN 2 AS value", expected_version=1),
)
assert response.status_code == 200, response.text
response = await client.post(
BASE + "/shortcuts/" + shortcut["id"] + "/execute", headers=headers(tokens["peer"]), json={}
)
assert response.status_code == 200, response.text
assert executor.calls[-1][1] == "RETURN 1 AS value"
assert response.json()["template_version"] == 1
assert (
await client.post(
BASE + "/shortcuts/" + shortcut["id"] + "/execute",
headers=headers(tokens["peer"]),
json={"params": {}},
)
).status_code == 422
assert (
await client.put(
BASE + "/templates/" + template["id"],
headers=headers(tokens["admin"]),
json=definition(expected_version=1),
)
).status_code == 409
assert (
await client.post(
BASE + "/templates/" + template["id"] + "/disable", headers=headers(tokens["admin"])
)
).status_code == 200
assert (
await client.post(
BASE + "/shortcuts/" + shortcut["id"] + "/execute", headers=headers(tokens["peer"]), json={}
)
).status_code == 409
async def test_shortcut_crud_and_cross_reference(client, tokens):
template = await create(client, tokens)
payload = {"name": "saved", "template_id": template["id"]}
response = await client.post(
"/api/v1/workspaces/beta/shortcuts", headers=headers(tokens["outsider"]), json=payload
)
assert response.status_code == 404
result = (await client.post(BASE + "/shortcuts", headers=headers(tokens["reader"]), json=payload)).json()
path = BASE + "/shortcuts/" + result["id"]
assert (
await client.put(path, headers=headers(tokens["peer"]), json={**payload, "expected_revision": 1})
).status_code == 200
assert (
await client.put(path, headers=headers(tokens["peer"]), json={**payload, "expected_revision": 1})
).status_code == 409
assert len((await client.get(BASE + "/shortcuts", headers=headers(tokens["peer"]))).json()["items"]) == 1
assert (await client.delete(path, headers=headers(tokens["peer"]))).status_code == 200
assert (await client.get(path, headers=headers(tokens["reader"]))).status_code == 404
async def test_seed_revoke_immediate_and_no_log_secrets(client, store, tokens):
fresh = await asyncio.to_thread(store.seed, "alpha", "live", "reader")
assert (await client.get(BASE + "/templates", headers=headers(fresh))).status_code == 200
store.revoke(fresh["id"])
assert (await client.get(BASE + "/templates", headers=headers(fresh))).status_code == 401
with store.engine.connect() as connection:
logs = str(connection.execute(text("SELECT * FROM access_logs")).all())
assert fresh["key"] not in logs and fresh["key"].split(".")[1] not in logs
async def test_rejected_template_never_reaches_neo4j(client, tokens, executor):
response = await client.post(
BASE + "/templates", headers=headers(tokens["admin"]), json=definition(cypher="CREATE (n) RETURN n")
)
assert response.status_code == 422
assert not executor.calls
async def test_body_limit_and_unknown_params(client, settings, tokens):
response = await client.post(
BASE + "/templates",
headers=headers(tokens["admin"]),
content=b"x" * (settings.limits.max_request_bytes + 1),
)
assert response.status_code == 413
template = await create(client, tokens)
response = await client.post(
BASE + "/templates/" + template["id"] + "/execute",
headers=headers(tokens["reader"]),
json={"params": {"secret": "dont-echo"}},
)
assert response.status_code == 422 and "dont-echo" not in response.text
async def test_audit_failure_not_reported_as_success(client, tokens, store, monkeypatch):
def broken(data):
raise RuntimeError("disk full")
monkeypatch.setattr(store, "audit", broken)
response = await client.get(BASE + "/templates", headers=headers(tokens["reader"]))
assert response.status_code == 503 and response.json()["error"]["code"] == "audit_unavailable"
async def test_health_and_openapi(client):
assert (await client.get("/health/live")).status_code == 200
assert (await client.get("/health/ready")).status_code == 200
assert (
"/api/v1/workspaces/{workspace_id}/templates" in (await client.get("/openapi.json")).json()["paths"]
)
async def test_simultaneous_key_seed_shortcuts_and_logs(client, tokens, store):
template = await create(client, tokens)
async def save(index):
key = await asyncio.to_thread(store.seed, "alpha", str(index), "reader")
response = await client.post(
BASE + "/shortcuts",
headers=headers(key),
json={"name": str(index), "template_id": template["id"]},
)
assert response.status_code == 201, response.text
await asyncio.gather(*(save(i) for i in range(20)))
import asyncio
from datetime import date, datetime, time, timedelta
from unittest.mock import AsyncMock
import httpx
import pytest
from conftest import definition, headers
from neoquery.api import create_app
from neoquery.codec import encode
from neoquery.config import Limits, Settings
from neoquery.errors import ServiceError
from neoquery.executor import Admission, Executor
def test_concurrency_all_three_boundaries_and_recovery():
limits = Limits(max_concurrency=3, workspace_concurrency=2, key_concurrency=1)
admission = Admission(3)
with admission.enter("a", "a1", limits):
with pytest.raises(ServiceError):
with admission.enter("a", "a1", limits):
pass
with admission.enter("a", "a2", limits):
with pytest.raises(ServiceError):
with admission.enter("a", "a3", limits):
pass
with admission.enter("b", "b1", limits):
with pytest.raises(ServiceError):
with admission.enter("c", "c1", limits):
pass
assert admission.total == 0 and not admission.keys and not admission.workspaces
with pytest.raises(ValueError):
with admission.enter("a", "a1", limits):
raise ValueError()
assert admission.total == 0
@pytest.mark.parametrize(
"value,kind",
[
(2**63 - 1, "integer"),
(True, "boolean"),
(None, "null"),
(b"\x00\xff", "bytes"),
(date(2026, 9, 9), "date"),
(datetime(2026, 9, 9, 12), "datetime"),
(time(12, 30), "time"),
(timedelta(days=1, microseconds=2), "duration"),
({"type": "node", "value": [1, "x"]}, "map"),
(float("inf"), "float"),
],
)
def test_codec(value, kind):
assert encode(value)["type"] == kind
async def test_disconnect_cancels_endpoint_and_audits(settings, store, tokens):
entered, cancelled = asyncio.Event(), asyncio.Event()
class SlowExecutor:
async def run(self, *args, **kwargs):
entered.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
template = store.save_template("alpha", tokens["admin"]["id"], definition())
app = create_app(settings, store=store, executor=SlowExecutor())
queue = asyncio.Queue()
await queue.put({"type": "http.request", "body": b"{}", "more_body": False})
responses = []
async def send(message):
responses.append(message)
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": f"/api/v1/workspaces/alpha/templates/{template['id']}/execute",
"raw_path": b"/",
"query_string": b"",
"root_path": "",
"server": ("localhost", 80),
"client": ("localhost", 1000),
"headers": [
(b"content-type", b"application/json"),
(b"authorization", ("Bearer " + tokens["reader"]["key"]).encode()),
],
}
task = asyncio.create_task(app(scope, queue.get, send))
await asyncio.wait_for(entered.wait(), timeout=2)
await queue.put({"type": "http.disconnect"})
await asyncio.wait_for(task, timeout=2)
assert cancelled.is_set() and responses == []
from sqlalchemy import text
with store.engine.connect() as connection:
row = connection.execute(
text("SELECT status,error_code FROM access_logs ORDER BY id DESC LIMIT 1")
).first()
assert tuple(row) == (499, "client_disconnected")
async def test_request_timeout_cancels_work(settings, store, tokens):
data = settings.model_dump()
data["limits"]["request_timeout_seconds"] = 0.05
settings = Settings.model_validate(data)
executor = AsyncMock()
cancelled = asyncio.Event()
async def slow(*args, **kwargs):
try:
await asyncio.Event().wait()
finally:
cancelled.set()
executor.run.side_effect = slow
template = store.save_template("alpha", tokens["admin"]["id"], definition())
app = create_app(settings, store=store, executor=executor)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
response = await client.post(
f"/api/v1/workspaces/alpha/templates/{template['id']}/execute",
json={},
headers=headers(tokens["reader"]),
)
assert response.status_code == 504 and cancelled.is_set()
@pytest.mark.parametrize("query_type", [None, "rw", "w", "s", "unknown"])
async def test_explain_unknown_or_write_type_never_executes(settings, query_type):
executor = Executor(settings)
driver = AsyncMock()
session = AsyncMock()
transaction = AsyncMock()
transaction.closed = lambda: False
session.__aenter__.return_value = session
session.begin_transaction.return_value = transaction
result = AsyncMock()
result.consume.return_value.query_type = query_type
transaction.run.return_value = result
# session() is a synchronous factory in the Neo4j driver.
driver.session = lambda **kwargs: session
original = executor.drivers["alpha"]
executor.drivers["alpha"] = driver
await original.close()
try:
with pytest.raises(ServiceError) as error:
await executor.run("alpha", "key", "RETURN 1", {}, request_id="test")
assert error.value.code == "not_read_only"
assert transaction.run.await_count == 1
transaction.rollback.assert_awaited_once()
assert executor.admission.total == 0
finally:
await executor.close()
import fcntl
from pathlib import Path
from unittest.mock import patch
import yaml
from typer.testing import CliRunner
from neoquery.cli import app
def config_path(settings, tmp_path):
data = settings.model_dump(mode="json")
for workspace in data["workspaces"]:
workspace["neo4j"]["password"] = "test-secret"
path = tmp_path / "config.yaml"
path.write_text(yaml.safe_dump(data))
return path
def test_server_refuses_second_process(settings, tmp_path):
config = config_path(settings, tmp_path)
lock_path = Path(settings.db_path + ".server.lock")
with lock_path.open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with patch("neoquery.cli.uvicorn.run") as run:
result = CliRunner().invoke(app, ["serve", "--config", str(config)])
assert result.exit_code != 0
run.assert_not_called()
def test_migration_failure_prevents_server_start(settings, tmp_path):
config = config_path(settings, tmp_path)
Path(settings.db_path).write_bytes(b"This is not a SQLite database")
with patch("neoquery.cli.uvicorn.run") as run:
result = CliRunner().invoke(app, ["serve", "--config", str(config)])
assert result.exit_code != 0
run.assert_not_called()
import json
import sqlite3
from concurrent.futures import ThreadPoolExecutor
import pytest
import yaml
from pydantic import ValidationError
from sqlalchemy import text
from neoquery.bootstrap import generate, merge_config
from neoquery.config import Settings, load_settings
from neoquery.db import Store
from neoquery.errors import ServiceError
def source(settings):
data = settings.model_dump(mode="json")
for ws in data["workspaces"]:
ws["neo4j"]["password"] = "test-secret"
return data
def test_config_precedence_private_atomic_and_immutable(settings, tmp_path):
src, dest = tmp_path / "source.yaml", tmp_path / "generated.yaml"
src.write_text(yaml.safe_dump(source(settings)))
generated = generate(
src,
dest,
{
"NQ_PORT": "9000",
"NQ_MAX_ROWS": "10",
"NQ_WORKSPACE_OVERRIDES_JSON": json.dumps(
{"alpha": {"neo4j": {"password": "override-secret"}, "limits": {"max_rows": 10000}}}
),
},
)
assert generated.port == 9000 and generated.limits.max_rows == 10
assert generated.effective_limits(generated.workspace("alpha")).max_rows == 10
assert generated.workspace("alpha").neo4j.password.get_secret_value() == "override-secret"
assert dest.stat().st_mode & 0o777 == 0o600
src.write_text("broken")
assert load_settings(dest) == generated
with pytest.raises(ValidationError):
generated.port = 1
def test_bad_config_does_not_replace_existing(settings, tmp_path):
src, dest = tmp_path / "source.yaml", tmp_path / "config.yaml"
src.write_text(yaml.safe_dump(source(settings)))
generate(src, dest, {})
before = dest.read_bytes()
with pytest.raises(ValueError):
generate(src, dest, {"NQ_UNKNOWN": "not-supported"})
assert dest.read_bytes() == before
@pytest.mark.parametrize(
"mutation",
[
lambda s: s["workspaces"].append(s["workspaces"][0]),
lambda s: s["workspaces"][0]["neo4j"].update(database="system"),
lambda s: s["workspaces"][0]["neo4j"].update(uri="bolt://admin:secret@localhost:7687"),
lambda s: s.update(db_path="relative.db"),
lambda s: s.update(unknown="value"),
],
)
def test_invalid_config(settings, mutation):
data = source(settings)
mutation(data)
with pytest.raises(ValidationError):
Settings.model_validate(data)
def test_unknown_workspace_override(settings):
with pytest.raises(ValueError):
merge_config(source(settings), {"NQ_WORKSPACE_OVERRIDES_JSON": '{"new":{"name":"no"}}'})
def test_live_keys_revoke_expiry_and_no_plaintext(store, tokens):
fresh = store.seed("alpha", "new", "reader")
assert store.authenticate(fresh["key"], "alpha")["id"] == fresh["id"]
store.revoke(fresh["id"])
with pytest.raises(ServiceError) as exc:
store.authenticate(fresh["key"], "alpha")
assert exc.value.status == 401
expired = store.seed("alpha", "expired", "reader", "2020-01-01T00:00:00+00:00")
with pytest.raises(ServiceError):
store.authenticate(expired["key"], "alpha")
with store.engine.connect() as connection:
dump = json.dumps([dict(r) for r in connection.execute(text("SELECT * FROM api_keys")).mappings()])
assert fresh["key"].split(".")[1] not in dump
def test_migration_repeat_persistence_and_backup(store, tokens, tmp_path):
store.migrate()
other = Store(store.settings)
try:
assert other.authenticate(tokens["reader"]["key"], "alpha")["role"] == "reader"
target = tmp_path / "backup.db"
other.backup(str(target))
with sqlite3.connect(target) as connection:
assert connection.execute("PRAGMA integrity_check").fetchone() == ("ok",)
assert connection.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0] == 4
finally:
other.close()
def test_seed_and_log_writes_concurrent(store):
def work(index):
key = store.seed("alpha", str(index), "reader")
store.audit({"request_id": str(index), "operation": "test", "status": 200})
return store.authenticate(key["key"], "alpha")
with ThreadPoolExecutor(max_workers=8) as pool:
assert len(list(pool.map(work, range(40)))) == 40
def test_audit_retention_count(store):
values = store.settings.model_dump()
values["log_max_records"] = 100
store.settings = Settings.model_validate(values)
for index in range(120):
store.audit({"request_id": str(index), "operation": "test", "status": 200})
store.prune_logs()
with store.engine.connect() as connection:
assert connection.execute(text("SELECT COUNT(*) FROM access_logs")).scalar() == 100
def test_audit_retention_age(store):
store.audit({"request_id": "old", "operation": "test", "status": 200})
store.audit({"request_id": "new", "operation": "test", "status": 200})
with store.engine.begin() as connection:
connection.execute(
text("UPDATE access_logs SET created_at='2000-01-01T00:00:00+00:00' WHERE request_id='old'")
)
store.prune_logs()
with store.engine.connect() as connection:
assert connection.execute(text("SELECT request_id FROM access_logs")).scalars().all() == ["new"]
def test_removed_workspace_key_disabled_without_deleting_metadata(store, tokens):
values = store.settings.model_dump()
values["workspaces"] = [values["workspaces"][1]]
store.settings = Settings.model_validate(values)
with pytest.raises(ServiceError) as error:
store.authenticate(tokens["reader"]["key"], "alpha")
assert error.value.code == "workspace_disabled"
assert len(store.keys("alpha")) == 3
"""Real tests, opt in with --integration; target is deliberately fixed to disposable port 17687."""
import asyncio
from copy import deepcopy
import httpx
import pytest
import pytest_asyncio
from conftest import definition, headers
from neo4j import GraphDatabase
from neoquery.api import create_app
from neoquery.config import Settings
from neoquery.db import Store
from neoquery.errors import ServiceError
from neoquery.executor import Executor
URI = "bolt://127.0.0.1:17687"
AUTH = ("neo4j", "acceptance-only-2026")
BASE = "/api/v1/workspaces/alpha"
@pytest.fixture(scope="module")
def graph(request):
if not request.config.getoption("--integration"):
pytest.skip("requires disposable Neo4j: --integration")
with GraphDatabase.driver(URI, auth=AUTH) as driver:
driver.verify_connectivity()
with driver.session(database="neo4j") as session:
session.run("MATCH (n:NQAcceptance) DETACH DELETE n").consume()
session.run(
"CREATE (a:NQAcceptance {entity_id:'a', name:'Alpha', huge:9223372036854775807}), "
"(b:NQAcceptance {entity_id:'b', name:'Beta'}), (a)-[:NQ_LINK {weight:1.5}]->(b)"
).consume()
yield driver
with driver.session(database="neo4j") as session:
session.run("MATCH (n:NQAcceptance) DETACH DELETE n").consume()
@pytest.fixture
def real_settings(settings, graph):
data = settings.model_dump()
for workspace in data["workspaces"]:
workspace["neo4j"].update(uri=URI, password=AUTH[1])
return Settings.model_validate(data)
@pytest_asyncio.fixture
async def real(real_settings):
store = Store(real_settings)
store.migrate()
tokens = {role: store.seed("alpha", role, role) for role in ("reader", "maintainer")}
executor = Executor(real_settings)
app = create_app(real_settings, store=store, executor=executor)
async with app.router.lifespan_context(app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
yield client, tokens, executor, store
async def post_template(client, tokens, cypher, **kwargs):
response = await client.post(
BASE + "/templates", headers=headers(tokens["maintainer"]), json=definition(cypher=cypher, **kwargs)
)
assert response.status_code == 201, response.text
return response.json()
async def test_real_graph_types_and_query_contract(real, graph):
client, tokens, _, _ = real
template = await post_template(
client,
tokens,
"MATCH p=(a:NQAcceptance)-[r:NQ_LINK]->(b) "
"RETURN a,r,p,a.huge AS big,date('2026-09-09') AS d,datetime('2026-09-09T10:00:00Z') AS dt,"
"duration({days:2,seconds:3}) AS duration,point({x:1,y:2}) AS pt,"
"{items:[1,true,null]} AS nested",
)
response = await client.post(
BASE + f"/templates/{template['id']}/execute", headers=headers(tokens["reader"]), json={}
)
assert response.status_code == 200, response.text
values = response.json()["rows"][0]
assert [v["type"] for v in values] == [
"node",
"relationship",
"path",
"integer",
"date",
"datetime",
"duration",
"point",
"map",
]
assert values[3]["value"] == "9223372036854775807"
assert values[1]["value"]["start_node_element_id"] == values[0]["value"]["element_id"]
assert len(values[2]["value"]["nodes"]) == 2
async def test_real_safe_parameter_binding_and_fixed_shortcut(real, graph):
client, tokens, executor, store = real
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"name": {"type": "string", "default": "Alpha"}},
}
template = await post_template(
client, tokens, "MATCH (n:NQAcceptance) WHERE n.name=$name RETURN n", parameter_schema=schema
)
response = await client.post(
BASE + f"/templates/{template['id']}/execute",
headers=headers(tokens["reader"]),
json={"params": {"name": "Alpha' DELETE n //"}},
)
assert response.status_code == 200 and response.json()["row_count"] == 0
saved = (
await client.post(
BASE + "/shortcuts",
headers=headers(tokens["reader"]),
json={"name": "fixed", "template_id": template["id"]},
)
).json()
assert saved["params"] == {"name": "Alpha"}
response = await client.post(
BASE + f"/shortcuts/{saved['id']}/execute", headers=headers(tokens["reader"]), json={}
)
assert response.status_code == 200 and response.json()["row_count"] == 1
assert executor.admission.total == 0
async def test_real_row_limit_and_exact_truncation(real):
client, tokens, _, _ = real
template = await post_template(client, tokens, "UNWIND range(1,1000000) AS n RETURN n")
for maximum in (3, 1000, 99999):
response = await client.post(
BASE + f"/templates/{template['id']}/execute",
headers=headers(tokens["reader"]),
json={"max_rows": maximum},
)
assert response.status_code == 200, response.text
assert response.json()["row_count"] == min(maximum, 1000) and response.json()["truncated"]
exact = await post_template(client, tokens, "UNWIND range(1,3) AS n RETURN n")
result = (
await client.post(
BASE + f"/templates/{exact['id']}/execute",
headers=headers(tokens["reader"]),
json={"max_rows": 3},
)
).json()
assert result["row_count"] == 3 and not result["truncated"]
async def test_real_write_rejection_preserves_graph_and_schema(real, graph):
client, tokens, _, _ = real
def snapshot():
with graph.session(database="neo4j") as session:
counts = session.run("MATCH (n) RETURN count(n) AS n").single()["n"]
rels = session.run("MATCH ()-[r]->() RETURN count(r) AS r").single()["r"]
indexes = sorted(r["name"] for r in session.run("SHOW INDEXES YIELD name RETURN name"))
names = sorted(r["name"] for r in session.run("MATCH (n:NQAcceptance) RETURN n.name AS name"))
return counts, rels, indexes, names
before = snapshot()
for cypher in [
"CREATE (n:NQBad) RETURN n",
"MATCH (n) DETACH DELETE n",
"MATCH (n) SET n.name='bad' RETURN n",
"CREATE INDEX forbidden FOR (n:NQBad) ON (n.x)",
"CALL db.labels()",
"USE system SHOW USERS",
]:
response = await client.post(
BASE + "/templates", headers=headers(tokens["maintainer"]), json=definition(cypher=cypher)
)
assert response.status_code == 422
assert snapshot() == before
async def test_real_timeout_cancel_and_capacity_recovery(real_settings, graph):
values = real_settings.model_dump()
values["limits"].update(request_timeout_seconds=0.25, query_timeout_seconds=0.2, key_concurrency=1)
settings = Settings.model_validate(values)
executor = Executor(settings)
expensive = "UNWIND range(1,100000) AS x UNWIND range(1,100000) AS y RETURN sum(x*y)"
try:
with pytest.raises(ServiceError) as exc:
await executor.run("alpha", "a", expensive, {}, request_id="timeout-case")
assert exc.value.status == 504
assert executor.admission.total == 0
normal = await executor.run("alpha", "a", "RETURN 1 AS value", {}, request_id="after-timeout")
assert normal["row_count"] == 1
task = asyncio.create_task(executor.run("alpha", "a", expensive, {}, request_id="cancel-case"))
await asyncio.sleep(0.05)
with pytest.raises(ServiceError) as exc:
await executor.run("alpha", "a", "RETURN 1", {}, request_id="over-cap")
assert exc.value.status == 429
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert executor.admission.total == 0
assert (await executor.run("alpha", "a", "RETURN 1", {}, request_id="after-cancel"))["row_count"] == 1
finally:
await executor.close()
async def test_real_byte_limit_and_error_recovery(real_settings):
values = real_settings.model_dump()
values["limits"]["max_response_bytes"] = 2048
executor = Executor(Settings.model_validate(values))
try:
with pytest.raises(ServiceError) as exc:
await executor.run("alpha", "a", "RETURN $large AS x", {"large": "x" * 10000}, request_id="large")
assert exc.value.code == "response_too_large"
assert executor.admission.total == 0
with pytest.raises(ServiceError) as exc:
await executor.run("alpha", "a", "MATCH this is invalid RETURN 1", {}, request_id="syntax")
assert exc.value.status == 422
assert (await executor.run("alpha", "a", "RETURN 1", {}, request_id="after-error"))["row_count"] == 1
finally:
await executor.close()
async def test_unavailable_workspace_does_not_break_others(real_settings):
values = deepcopy(real_settings.model_dump())
values["workspaces"][1]["neo4j"]["uri"] = "bolt://127.0.0.1:17688"
executor = Executor(Settings.model_validate(values))
try:
with pytest.raises(ServiceError) as exc:
await executor.run("beta", "b", "RETURN 1", {}, request_id="offline")
assert exc.value.status == 503
assert (await executor.run("alpha", "a", "RETURN 1", {}, request_id="online"))["row_count"] == 1
finally:
await executor.close()
async def test_small_valid_byte_budget_still_allows_small_results(real_settings):
values = real_settings.model_dump()
values["limits"]["max_response_bytes"] = 1024
executor = Executor(Settings.model_validate(values))
try:
result = await executor.run("alpha", "key", "RETURN 1", {}, request_id="small-budget")
assert result["row_count"] == 1
finally:
await executor.close()
import pytest
from neoquery.errors import ServiceError
from neoquery.validation import check_cypher, check_schema, validate_definition, validate_params
@pytest.mark.parametrize(
"cypher",
[
"CREATE (n)",
"MATCH (n) DELETE n",
"MATCH (n) SET n.x=1 RETURN n",
"MERGE (n)",
"MATCH (n) REMOVE n.x RETURN n",
"DROP INDEX something",
"SHOW USERS",
"CALL db.labels()",
"CALL { MATCH (n) RETURN n } RETURN n",
"USE system RETURN 1",
"LOAD CSV FROM 'http://localhost' AS row RETURN row",
"RETURN 1; RETURN 2",
"EXPLAIN CREATE (n)",
"PROFILE RETURN 1",
"CYPHER 5 RETURN 1",
"cAlL/*hi*/db.labels()",
"RETURN apoc.cypher.runFirstColumn('CREATE (n)', {}, true)",
"RETURN `apoc`.`load`.`json`('http://localhost')",
"RETURN customFunction(1)",
"RETURN COUNT { CREATE (n) RETURN n }",
"RETURN EXISTS { MATCH (n) SET n.x=1 RETURN n }",
"RETURN 'unterminated",
"RETURN `unterminated",
"RETURN $`parameter`",
],
)
def test_reject_unsafe_or_unsupported(cypher):
with pytest.raises(ServiceError):
check_cypher(cypher)
@pytest.mark.parametrize(
"cypher",
[
"MATCH (n) RETURN n LIMIT $limit",
"MATCH p=(n)-[r]-(m) RETURN p LIMIT 10",
"MATCH (n) WHERE n.name = $name RETURN n",
"RETURN 'CREATE (n); CALL db.labels()' AS text",
"// CREATE (n)\nMATCH (n) /* DELETE n */ RETURN properties(n)",
"MATCH (n:`中文标签`) RETURN n.`中文属性`",
"UNWIND range(1,10) AS n RETURN sum(n)",
"MATCH (n) RETURN collect({name:n.name, labels:labels(n)})",
"RETURN 'it\\'s safe' AS v",
"RETURN $create AS value",
],
)
def test_allow_graph_queries(cypher):
check_cypher(cypher)
SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 100},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
},
"required": ["name"],
}
def test_defaults_and_injection_values():
value = "'; CREATE (n) //"
result = validate_definition(
"MATCH (n) WHERE n.name=$name RETURN n LIMIT $limit", SCHEMA, {"name": value}
)
assert result == {"name": value, "limit": 10}
@pytest.mark.parametrize(
"params",
[
{},
{"name": "x", "limit": "10"},
{"name": "x", "limit": True},
{"name": "x", "limit": 51},
{"name": "x", "extra": 1},
],
)
def test_invalid_params(params):
with pytest.raises(ServiceError):
validate_params(SCHEMA, params)
def test_schema_rejects_refs_patterns_and_permissive_objects():
for schema in [
{"type": "string", "pattern": ".*"},
{"type": "string", "$ref": "http://example.com"},
{"type": "object"},
{"type": "array"},
]:
with pytest.raises(ServiceError):
check_schema(schema)
version = 1
requires-python = ">=3.12, <3.14"
[[package]]
name = "alembic"
version = "1.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/10/181eecdd552217d0342492bd6f3b8a96e973083379aace3d3402830ddc03/alembic-1.19.2.tar.gz", hash = "sha256:297950a8a91f6770eb82bfbce9bea55c728b90a5386c6e81430191a319d138b0", size = 2082643 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/cb/9014784dcb0585977ae23b6f43331d0a33c51ac0d692506d12b4f5ee9f3b/alembic-1.19.2-py3-none-any.whl", hash = "sha256:32d553dcd577e6fe5c3c63e91468526d35e4dcecafe865d7db4e9b328fa93cb2", size = 267399 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427 },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954 },
]
[[package]]
name = "greenlet"
version = "3.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809 },
{ url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910 },
{ url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198 },
{ url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504 },
{ url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439 },
{ url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462 },
{ url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342 },
{ url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744 },
{ url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171 },
{ url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362 },
{ url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730 },
{ url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536 },
{ url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924 },
{ url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726 },
{ url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906 },
{ url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966 },
{ url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430 },
{ url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684 },
{ url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075 },
{ url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 },
]
[[package]]
name = "mako"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010 },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "neo4j"
version = "5.28.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/fc/56d23ba3eed18ad3cfece6fadc52a2386d16627e8d96554f43b14984e857/neo4j-5.28.5.tar.gz", hash = "sha256:22c17254dc99cded006ff8d1904fe4c8a09d2b67f4e02709bfeca95786b1b58d", size = 232775 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/fc/4e379b0bdecf3a22ec4581a594349f88546ee0a6e0d3cbd8941013e1411a/neo4j-5.28.5-py3-none-any.whl", hash = "sha256:b5a919bb7be2dd0ccb72acd52724964b73f1bb5f5c753a031af84dbbe170fbf3", size = 313920 },
{ url = "https://files.pythonhosted.org/packages/0c/9b/ecd432ccda4c4a1015e46a19645922cad7304cf37012982b22b2777a29c2/neo4j-5.28.5.0-py3-none-any.whl", hash = "sha256:c8ab75a72d54f2aceea35a22d1cd39203f754777c6ec8ccb3d9e6d4d8b8bdd4e", size = 313958 },
]
[[package]]
name = "neo4j-query-service"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "fastapi" },
{ name = "jsonschema" },
{ name = "neo4j" },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "sqlalchemy" },
{ name = "typer" },
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.14,<2" },
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "jsonschema", specifier = ">=4.23,<5" },
{ name = "neo4j", specifier = ">=5.26,<6" },
{ name = "pydantic", specifier = ">=2.10,<3" },
{ name = "pyyaml", specifier = ">=6.0,<7" },
{ name = "sqlalchemy", specifier = ">=2.0,<3" },
{ name = "typer", specifier = ">=0.15,<1" },
{ name = "uvicorn", specifier = ">=0.34,<1" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "pytest", specifier = ">=8,<10" },
{ name = "pytest-asyncio", specifier = ">=0.25,<2" },
{ name = "ruff", specifier = ">=0.11,<1" },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "pydantic"
version = "2.13.5"
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/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589 },
]
[[package]]
name = "pydantic-core"
version = "2.46.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516 },
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874 },
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772 },
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832 },
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645 },
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935 },
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284 },
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889 },
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006 },
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408 },
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609 },
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618 },
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475 },
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140 },
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729 },
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885 },
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768 },
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241 },
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975 },
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542 },
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692 },
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633 },
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235 },
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367 },
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420 },
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588 },
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866 },
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580 },
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980 },
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213 },
{ url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342 },
{ url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219 },
{ url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393 },
{ url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024 },
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540 },
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040 },
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479 },
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147 },
]
[[package]]
name = "pytest"
version = "9.1.1"
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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 },
]
[[package]]
name = "pytz"
version = "2026.3.post1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283 },
]
[[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 }
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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 },
]
[[package]]
name = "rpds-py"
version = "2026.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691 },
{ url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542 },
{ url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180 },
{ url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067 },
{ url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509 },
{ url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754 },
{ url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189 },
{ url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750 },
{ url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576 },
{ url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807 },
{ url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187 },
{ url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030 },
{ url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185 },
{ url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394 },
{ url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753 },
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012 },
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203 },
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984 },
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815 },
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545 },
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828 },
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678 },
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811 },
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382 },
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832 },
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011 },
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431 },
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710 },
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454 },
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063 },
]
[[package]]
name = "ruff"
version = "0.16.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638 },
{ url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762 },
{ url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082 },
{ url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304 },
{ url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612 },
{ url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465 },
{ url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576 },
{ url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993 },
{ url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748 },
{ url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096 },
{ url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494 },
{ url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663 },
{ url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461 },
{ url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808 },
{ url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212 },
{ url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402 },
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850 },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940 },
{ url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415 },
{ url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577 },
{ url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225 },
{ url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374 },
{ url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366 },
{ url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344 },
{ url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724 },
{ url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189 },
{ url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185 },
{ url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698 },
{ url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936 },
{ url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308 },
{ url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876 },
{ url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700 },
]
[[package]]
name = "starlette"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969 },
]
[[package]]
name = "typer"
version = "0.27.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750 },
]
[[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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871 },
]
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment