Implement identity graph stitching and configurable profile storage

Issue persistent signed anonymous IDs and accept cookie/token-bound events.
Resolve verified email, phone, OAuth, and namespaced third-party IDs into
transactionally merged master identities with historical event aliases.
Protect shared-device account switching, tenant isolation, and event retries.

Store sparse canonical profiles as PostgreSQL JSONB or local SQLite JSON.
Support an optional versioned Cassandra wide-column projection through a
transactional outbox, retry worker, and immediate canonical read fallback.
Document deployment settings and provide opt-in database integration tests.

Validation: 43 local tests passed; 6 external database tests skipped.
Ruff checks, formatting, and offline uv lock verification passed.
This commit is contained in:
2026-09-15 23:49:43 +08:00
parent c30a136102
commit 0fc6c68a99
27 changed files with 2993 additions and 103 deletions
+19
View File
@@ -1 +1,20 @@
# Deployment profile selection. Canonical graph always uses DATABASE_URL.
DATABASE_URL=sqlite:///.data/adcloud.db
PROFILE_BACKEND=relational
TENANT_ID=default
# Generate once per deployment; keep stable across workers/restarts.
IDENTITY_SECRET=
# Trusted backend only; never ship this value to browser/SDK builds.
SERVICE_TOKEN=
COOKIE_SECURE=false
COOKIE_SAMESITE=lax
COOKIE_MAX_AGE=31536000
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:5174
# Used only when PROFILE_BACKEND=cassandra.
CASSANDRA_HOSTS=127.0.0.1
CASSANDRA_PORT=9042
CASSANDRA_KEYSPACE=adcloud
CASSANDRA_USERNAME=
CASSANDRA_PASSWORD=
CASSANDRA_TLS=true
CASSANDRA_CA_FILE=
+4
View File
@@ -9,3 +9,7 @@ dist/
.env
.env.*
!.env.example
.data/
*.db
*.db-shm
*.db-wal
+52 -68
View File
@@ -1,86 +1,60 @@
# AdCloud Server
面向开发者和 coding agent 的广告云埋点与广告推送后端。使用 FastAPI 开发,依赖和虚拟环境统一由 **uv** 管理。
面向开发者和 coding agent 的广告云埋点与广告推送后端。使用 FastAPI 开发,Python 依赖和虚拟环境统一由 **uv** 管理。
已实现 `/v1` Gateway 路由、匿名身份签发、ID 映射图谱、可信身份 Stitch、稀疏画像及历史事件回溯。
画像可按部署设置选择关系库读取或 Cassandra 宽表投影;身份图谱保持关系库事务一致性。
广告业务目前仅有路由探测,尚未实现投放引擎。邮箱/手机号 OTP 和具体 OAuth 登录需要由调用方接入验证。
## 本地开发(无需安装数据库服务)
```sh
uv sync --locked
uv run python scripts/init_local_env.py
uv run uvicorn adcloud_server.main:app --reload --host 127.0.0.1 --port 8000
```
初始化脚本生成被 Git 忽略的 `.env`,权限 0600,包含本地开发所需随机 secret,不输出 secret 值,不覆盖已有文件。
SQLite 数据存放于 `.data/`,同样不提交。身份服务缺少 IDENTITY_SECRET 时返回 503,系统健康接口仍可用。
环境变量优先于 `.env`。生产 HTTPS 部署将 `COOKIE_SECURE` 设置为 true。
- 系统健康检查:`http://localhost:8000/health`
- API 文档:`http://localhost:8000/docs`
- OpenAPI:`http://localhost:8000/openapi.json`
另一个终端可运行 `uv run python scripts/demo_identity.py`,演示匿名访问、埋点、临时画像、模拟验证后 Stitch 及继承结果。
演示脚本使用合成的已验证身份,不代替实际 OTP/OAuth 校验。
## HTTP Gateway
```text
HTTPS :443 (Nginx,终止 TLS)
├── /v1/events/* → events 路由模块
├── /v1/users/* → users 路由模块
├── /v1/ads/* → ads 路由模块
├── /v1/identity/* → identity 路由模块
├── /v1/events/* → 事件接收、历史查询
├── /v1/users/* → 稀疏用户画像
├── /v1/ads/* → 广告业务模块(待实现)
├── /v1/identity/* → 匿名 ID、解析、拼接与图谱
└── /v1/... → 后续业务模块
↓
FastAPI 127.0.0.1:8000
```
目前使用模块化单体:四个业务模块共用一个 FastAPI 进程和版本入口。
Nginx 为各业务域定义了独立 upstream,后续拆分服务时可以替换地址。
Gateway 保留完整路径和查询参数,不剥离 `/v1` 或业务前缀。
未知路径返回 404,不支持的 HTTP 方法返回 405。
各模块当前共用一个进程,独立注册在 `api/v1/router.py` 中;Nginx 为各域预留独立 upstream。
未知路由返回 404、不支持的方法返回 405。保留各模块 `/health` 探测以及旧系统 `/health`。
默认 CORS 允许 localhost:3000、5173、5174,启用 credentials;匿名写请求检查浏览器 Origin。
| 路径 | 模块职责 | 当前已实现接口 |
| --- | --- | --- |
| `/v1/events/*` | 埋点事件接收与查询 | `GET /v1/events/health` |
| `/v1/users/*` | 平台用户资料管理 | `GET /v1/users/health` |
| `/v1/ads/*` | 广告管理与投放 | `GET /v1/ads/health` |
| `/v1/identity/*` | 登录、令牌与身份权限 | `GET /v1/identity/health` |
`deploy/nginx.conf.example` 放在 Nginx `http {}` 上下文,替换域名和证书路径后在部署环境执行 `nginx -t`。
精确匹配 `/v1/events` 防止 POST 被重定向为带尾斜杠路径,所有转发保留 `/v1`、业务前缀和查询参数。
配置未在本机部署或验证,未启动 443、未申请证书。API 文档仅通过内部地址访问。
各模块健康接口用于验证路由挂载,返回 `{"status":"ok","module":"events"}` 等响应;不代表数据库或外部依赖已就绪。
当前阶段仅实现 Gateway 和模块入口,尚未实现业务写入、数据持久化、登录或鉴权。
## Identity Resolution / Stitching
```text
src/adcloud_server/
main.py # 应用工厂、CORS、系统健康接口
schemas.py # 公共响应模型
api/v1/router.py # /v1 统一入口和模块注册
api/v1/events.py
api/v1/users.py
api/v1/ads.py
api/v1/identity.py
```
详见 [身份图谱与存储配置](docs/identity.md),包含接口、合并策略、共享设备处理和 PostgreSQL / Cassandra 部署选择。
增加新模块时,在 `api/v1/` 新增 `APIRouter`,并在 `router.py` 中注册前缀和 OpenAPI 标签。
若部署为独立服务,再增加对应的 Gateway upstream 和 location。
## 本地开发
```sh
uv sync --locked
uv run uvicorn adcloud_server.main:app --reload --host 127.0.0.1 --port 8000
```
- 系统健康检查:`http://localhost:8000/health`(保留 SDK 和控制台兼容)
- 模块探测:`http://localhost:8000/v1/events/health`
- API 文档:`http://localhost:8000/docs`
- OpenAPI:`http://localhost:8000/openapi.json`
默认允许来自 `http://localhost:3000`、`http://localhost:5173`、`http://localhost:5174` 的浏览器请求。
`CORS_ORIGINS` 接受逗号分隔的来源;环境配置示例见 `.env.example`。
```sh
CORS_ORIGINS=https://advertiser.example,https://developer.example uv run uvicorn adcloud_server.main:app
```
CORS 已配置写请求方法和 `Authorization`、`Content-Type`、`X-Request-ID` 请求头。
允许预检不代表相应业务端点已实现;CORS 也不代替身份认证和权限校验。
## HTTPS 入口配置
`deploy/nginx.conf.example` 是放入 Nginx `http {}` 上下文的配置模板。
替换 `server_name` 和证书文件路径,然后在目标服务器使用 `nginx -t` 检查,加载配置。
模板未在本机启动 443,也未申请或部署证书。API 文档仅通过内部 FastAPI 地址访问。
Nginx 在公网边缘重写转发头,FastAPI 只监听回环地址。目标服务器启动后端:
```sh
uv run uvicorn adcloud_server.main:app --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips 127.0.0.1
```
模板允许最大 1 MiB 请求体,连接超时 5 秒、读写超时 30 秒;可按后续埋点批量策略调整。
参考:[FastAPI 多模块路由](https://fastapi.tiangolo.com/tutorial/bigger-applications/)、[Nginx proxy_pass 路径规则](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass)。
- Cookie / token 只证明匿名设备持有,不能自行提交 `verified=true` 合并账户。
- Stitch、图谱、画像读写和历史查询仅允许携带 SERVICE_TOKEN 的可信后端。
- 匿名事件保留原始 ID,通过 alias 在合并后即时归入 Master ID。
- PostgreSQL JSONB / SQLite JSON 省略空属性;Cassandra 可选宽表投影通过事务 outbox 同步和故障重试。
- 图谱主库始终是关系库,不提供纯 Cassandra、HBase 或 Bigtable 图谱实现。
## 检查
@@ -90,4 +64,14 @@ uv run ruff check .
uv run ruff format --check .
```
提交 `uv.lock`,使用 `uv sync --locked` 复现依赖。
默认测试使用临时 SQLite 和可恢复的投影替身,不安装、不启动数据库服务。
PostgreSQL、Cassandra 联调入口默认跳过,等提供专用测试环境后使用:
```sh
uv sync --locked --extra postgres --extra cassandra
```
- 设置 `ADCLOUD_TEST_POSTGRES_URL` 为专用测试 PostgreSQL 的 SQLAlchemy URL,然后运行 `uv run --extra postgres pytest tests/test_postgres.py`。
- 设置 `ADCLOUD_TEST_CASSANDRA_HOSTS`、`ADCLOUD_TEST_CASSANDRA_KEYSPACE` 和可选的 PORT、USERNAME、PASSWORD、TLS、CA_FILE,同前缀环境变量配置;先在专用集群应用 `deploy/cassandra.cql`,再运行 `uv run --extra cassandra pytest tests/test_cassandra.py`。
联调测试使用独立随机租户,不自动创建 Cassandra 集群。PostgreSQL 测试会创建初始表并保留测试租户数据,需使用专用测试数据库。
+14
View File
@@ -0,0 +1,14 @@
-- Example for a single datacenter named datacenter1; set production RF appropriately.
CREATE KEYSPACE IF NOT EXISTS adcloud
WITH replication = {'class': 'NetworkTopologyStrategy', 'datacenter1': 1};
-- Each immutable profile revision is a separate bounded partition.
-- Sparse attributes are individual fields; absent attributes occupy no rows.
CREATE TABLE IF NOT EXISTS adcloud.identity_profile_fields (
tenant_id text,
master_id text,
revision int,
field text,
value text,
PRIMARY KEY ((tenant_id, master_id, revision), field)
);
+5
View File
@@ -28,6 +28,11 @@ server {
proxy_send_timeout 30s;
# No URI suffix on proxy_pass: preserve /v1/... and query strings.
# Exact roots prevent nginx from redirecting POST /v1/events to a trailing slash.
location = /v1/events { proxy_pass http://adcloud_events; }
location = /v1/users { proxy_pass http://adcloud_users; }
location = /v1/ads { proxy_pass http://adcloud_ads; }
location = /v1/identity { proxy_pass http://adcloud_identity; }
location ^~ /v1/events/ { proxy_pass http://adcloud_events; }
location ^~ /v1/users/ { proxy_pass http://adcloud_users; }
location ^~ /v1/ads/ { proxy_pass http://adcloud_ads; }
+163
View File
@@ -0,0 +1,163 @@
# Identity Resolution / Identity Stitching
## 标识和可信边界
- `anonymous_id`:服务端随机 UUID,浏览器用持久化的 HttpOnly Cookie 携带,移动端用签名 token 携带。
- `master_id`:身份连通分量的规范 ID。匿名访问时已有一个 Master ID,登录关联后可保留它,或归并到已有账号的 Master ID。
- 强身份:验证后的邮箱、国际格式手机号、命名空间下的 OAuth subject / 第三方 ID。
- MAID、设备指纹、IP 和 Cookie 只表示设备/访问连续性,不用于自动合并两个已知账号。本实现生成随机匿名 ID,不生成设备指纹,也不伪造系统 MAID。
`POST /v1/identity/stitch` 只接受可信后端的 Bearer service token。
调用方必须先完成邮箱/手机号 OTP 验证,或校验 OAuth 令牌的签名、issuer、audience、subject 和已验证邮箱声明,再把已验证标识传入。
请求体没有可以由浏览器自行声明的 `verified=true` 字段。未验证的留资邮箱应先作为临时属性存储,完成验证后再添加强身份连线。
当前实现提供内部 Stitch 接口和鉴权,不包含验证码发送、密码登录或具体 OAuth provider 回调。
`SERVICE_TOKEN` 只放在可信服务端。它是单个部署/租户的管理凭据,不能交给广告商、开发者、浏览器或第三方 SDK。
移动端匿名 token 仅证明匿名访问 ID 的持有,不是登录令牌。应保存到应用安全存储。
## 图谱模型
```text
匿名设备 A ─┐
├─ alias → Master A ← 已验证 Email X
匿名设备 B ─┘ ← 已验证 Phone Y
← OAuth (issuer/client, subject)
```
邮箱、手机号、OAuth/第三方 ID 唯一性键包含租户和标识类型;OAuth/第三方 ID 额外包含 provider 命名空间。
命名空间应包含 issuer 与 client/app 作用域,避免不同 provider 或 pairwise subject 命名冲突。
邮箱使用 email-validator 规范化域名,保留 local-part 大小写;不擅自套用 Gmail 去点、去加号规则。
手机号必须以 `+` 国家码输入,验证后规范化为 E.164。
ID 查找键是带服务端 secret 的 HMAC-SHA256,不保存邮箱/手机号/OAuth subject 的原文;图谱 API 只展示脱敏摘要。
用户画像属性与事件属性仍是业务提供的 JSON 数据。
`TENANT_ID` 在部署中配置,不从客户端可任意指定的请求头读取。一个应用实例服务一个租户。
同一个数据库可以容纳不同部署实例的多个租户,查询和唯一约束均带 `tenant_id`。
## Stitch 流程
1. 首次访问调用 `/v1/identity/anonymous`,或直接发送 `/v1/events`;系统创建签名 ID 并设置 Cookie。
2. 匿名事件绑定原始节点,偏好标签/临时属性由可信后端通过用户画像 PATCH 写入。
3. 登录验证成功后,可信后端携带匿名 token 和全部已验证标识调用 `/v1/identity/stitch`。
4. 若任意强身份已经连接到 Master ID,归并匿名分量;多个强身份可以传递连接多个分量。
5. 同一事务中更新所有别名、合并稀疏属性和标签、记录连接证据,并写入画像投影 outbox。
6. 事件保留原始节点。查询通过别名映射返回当前 Master ID 的全部历史,无需等待后台批量重写事件。
邮箱、手机号或 OAuth 任意一个可信连接即可解析到同一 Master ID。`/resolve` 仅查询,不隐式合并;多个 ID 解析到不同账号时返回 `ambiguous=true`,由已验证的 Stitch 请求明确连接。
合并目标优先使用可信后端提供的 `account_master_id`;否则使用已连接强身份中最早创建的账号;没有匹配项时保留当前匿名 Master ID。
原有目标账号的冲突属性优先,匿名属性填补缺失字段;请求中明确提供的属性最后覆盖。标签去重合并。
PATCH 的属性值 `null` 表示删除字段;Stitch 中 `null` 被省略。嵌套对象中空值也被省略。
输入允许 200 个顶层属性、64 KiB 属性文档,单次标签最多 100 个、累计最多 500 个。
如果合并后超过限制,返回 409 并整体回滚,不静默丢弃数据。
旧 Master ID 保留为别名,因此旧链接和旧事件仍能解析到新的规范账号。
### 共享浏览器 / 账号切换
如果 Cookie 已归属账号 A,而输入的强身份和 A 没有交集,系统创建新匿名节点再连接 B,返回 `rotated_anonymous_id=true`,不会把 A 的历史给 B。
对已有账号新增手机或 OAuth 标识时,可信后端传入当前已认证账号的 `account_master_id`。
退出登录应调用 `/v1/identity/anonymous?rotate=true`,随后使用返回的新 token / Cookie。
若 Stitch 由登录后端代发,该后端需要将返回 Cookie 设置到同一 API 域,或将返回的匿名 token 交回客户端更新;不要让客户端继续使用旧账号的 token。
## API
| 方法和路径 | 调用方 | 用途 |
| --- | --- | --- |
| `POST /v1/identity/anonymous` | 浏览器 / 移动端 | 创建或复用匿名 ID;`rotate=true` 重置 |
| `POST /v1/events` | 浏览器 / 移动端 | 接收事件;缺少 ID 时自动创建 |
| `POST /v1/identity/stitch` | 可信后端 | 已验证标识与匿名 ID 拼接 |
| `POST /v1/identity/resolve` | 可信后端 | 按已验证标识解析 Master ID |
| `GET /v1/identity/{master_id}/graph` | 可信后端 | 图谱成员、脱敏标识和合并连线 |
| `GET /v1/identity/storage` | 可信后端 | 查看实际部署存储设置,不返回凭据 |
| `GET /v1/users/{master_id}` | 可信后端 | 画像、标签、历史行为计数 |
| `PATCH /v1/users/{master_id}` | 可信后端 | 更新临时/正式画像,空值删除字段 |
| `GET /v1/events?master_id=...` | 可信后端 | 全部关联行为,支持 limit / offset |
首次加载页面时可以直接调用:
```js
await fetch("https://YOUR_API_HOST/v1/identity/anonymous", {
method: "POST",
credentials: "include"
});
```
后端第一次收到匿名请求就签发 ID;第三方 React SDK 的自动初始化集成尚未修改。
浏览器请求需使用 `credentials: "include"`。匿名写接口检查 Origin;CORS 使用明确允许来源并启用凭据。
Cookie 默认 Secure、HttpOnly、SameSite=Lax,有效期默认一年;本地 HTTP 开发明确设置 `COOKIE_SECURE=false`。
跨站点 Cookie 如需 SameSite=None,必须同时启用 Secure。浏览器第三方 Cookie 策略可能阻止 Cookie,应优先将 API 放在第一方域名下。
不设置父域 Cookie,不通过第三方设备指纹跨站点识别用户。
事件可提供 UUID `event_id` 进行幂等重试。相同 ID 的不同主体或内容返回 409;省略 event_id 时每次生成新事件。
`occurred_at` 如果提供,必须含时区且不能晚于服务端当前时间。
Stitch 示例(只在 OTP/OAuth 验证后由可信后端执行):
```json
{
"anonymous_token": "服务端此前签发的 token",
"identifiers": [
{"kind": "email", "value": "alice@example.com"},
{"kind": "phone", "value": "+14155552671"},
{"kind": "oauth", "namespace": "https://issuer.example/client-id", "value": "verified-subject"}
],
"attributes": {"preferred_category": "running"},
"tags": ["runner"]
}
```
## 部署选择:JSONB / Cassandra 宽表
| 配置 | 身份图谱和规范画像 | 画像读取 |
| --- | --- | --- |
| 本地 SQLite + relational | SQLite JSON | 关系库 |
| PostgreSQL + relational | PostgreSQL JSONB | 关系库 |
| PostgreSQL + cassandra | PostgreSQL JSONB | Cassandra 当前版本,缺失时回退 JSONB |
选择 **Cassandra** 作为宽表后端,不额外引入 HBase 和 Bigtable 的运维/云依赖。
身份图谱需要跨节点唯一性和事务合并,保持关系库为唯一事实来源;本实现不支持完全移除关系库的纯 Cassandra 图谱。
宽表是可选择的画像投影,不是通过双写伪装成两个独立的事实来源。
Cassandra 的数据建模以查询为中心,[并不提供关系库式 JOIN](https://cassandra.apache.org/doc/latest/cassandra/developing/data-modeling/data-modeling_rdbms.html)。
因此部署设置切换的是画像读取/投影后端,图谱主库始终由 DATABASE_URL 指定。
关系库配置:
```dotenv
DATABASE_URL=postgresql+psycopg://USER:PASSWORD@HOST:5432/adcloud
PROFILE_BACKEND=relational
COOKIE_SECURE=true
```
```sh
uv sync --locked --extra postgres
```
启用宽表:
```dotenv
PROFILE_BACKEND=cassandra
CASSANDRA_HOSTS=node1,node2,node3
CASSANDRA_KEYSPACE=adcloud
CASSANDRA_TLS=true
```
1. `uv sync --locked --extra postgres --extra cassandra` 安装两个驱动。
2. 在 Cassandra 集群应用 `deploy/cassandra.cql`,按实际 datacenter 名、复制因子、认证和 TLS 配置调整;模板 RF=1 仅用于开发。
3. 修改部署配置并重启后端。数据库仍保留 PostgreSQL。
4. 切换时执行 `uv run --extra postgres --extra cassandra adcloud-profiles --backfill --once`,将现有画像加入 outbox 并处理一批任务。
5. 持续运行 `uv run --extra postgres --extra cassandra adcloud-profiles`,重试失败任务并处理剩余同步任务。
`.env` 自动加载,环境变量优先。两种模式切换需要重启,不在请求中热切换连接或丢弃历史。
切回 relational 时直接从 PostgreSQL 读取完整画像;无需从 Cassandra 迁移回来。
每个 `(tenant_id, master_id, revision)` 是一个有限宽表分区,属性按字段写入,空字段不占用行。
同分区 batch 写入字段和完成标记,以 LOCAL_QUORUM 读写,只读取关系库指定的当前 revision;旧版本不能覆盖新值。
投影副本 TTL 为 7 天,过期/延迟/故障时回退规范画像;需要时可 backfill 重建。
outbox 与 Stitch 同事务提交;后台发布失败时任务不会确认,下次重试。已归并的旧节点和过期 revision 不再发布。
`projection_status` 显示 current / pending / unavailable,便于监控。
当前 PostgreSQL 写入以租户行锁串行保护合并及唯一标识,SQLite 用 BEGIN IMMEDIATE。这是首版偏向正确性的实现,高吞吐埋点场景后续需拆分写入路径并细化图谱锁,不能把当前实现视为已做广告云规模压测。
JSONB 使用 [SQLAlchemy PostgreSQL 方言](https://docs.sqlalchemy.org/en/20/dialects/postgresql.html#jsonb);跨 worker 合并由 [数据库行锁](https://www.postgresql.org/docs/current/explicit-locking.html) 保护。
IDENTITY_SECRET 同时用于匿名 token 签名和强标识 HMAC。每个部署生成一次并持久保管,所有 worker 保持一致;改变它会使旧 token 失效且旧标识无法匹配,密钥轮换需要单独的迁移方案。
+8 -1
View File
@@ -4,7 +4,14 @@ version = "0.1.0"
description = "AdCloud tracking and ad delivery backend for developers and coding agents"
readme = "README.md"
requires-python = ">=3.12"
dependencies = ["fastapi>=0.115,<1", "uvicorn>=0.30,<1"]
dependencies = ["fastapi>=0.115,<1", "uvicorn>=0.30,<1", "sqlalchemy>=2.0,<3", "email-validator>=2.2,<3", "phonenumbers>=9,<10", "python-dotenv>=1,<2"]
[project.optional-dependencies]
postgres = ["psycopg[binary]>=3.2,<4"]
cassandra = ["cassandra-driver>=3.29,<4"]
[project.scripts]
adcloud-profiles = "adcloud_server.projection:main"
[dependency-groups]
dev = ["httpx>=0.27,<1", "pytest>=8,<10", "ruff>=0.9,<1"]
+69
View File
@@ -0,0 +1,69 @@
"""Exercise a running local backend without printing service/anonymous tokens."""
import argparse
import http.cookiejar
import json
import urllib.request
from uuid import uuid4
from adcloud_server.settings import Settings
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
args = parser.parse_args()
settings = Settings.from_env()
if not settings.service_token:
raise SystemExit("Configure SERVICE_TOKEN in .env first")
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
)
def api(path, body=None, trusted=False, method=None):
headers = {"Content-Type": "application/json"}
if trusted:
headers["Authorization"] = "Bearer " + settings.service_token
request = urllib.request.Request(
args.base_url.rstrip("/") + path,
data=None if body is None else json.dumps(body).encode(),
headers=headers,
method=method,
)
with opener.open(request, timeout=10) as response:
return json.load(response)
anon = api("/v1/identity/anonymous?rotate=true", {})
api("/v1/events", {"name": "product.view", "properties": {"sku": "demo-running-shoe"}})
api(
"/v1/users/" + anon["master_id"],
{"attributes": {"interest": "running", "unused": None}, "tags": ["runner"]},
trusted=True,
method="PATCH",
)
# Synthetic verified identity for this local demo, not an OTP implementation.
stitched = api(
"/v1/identity/stitch",
{
"anonymous_token": anon["anonymous_token"],
"identifiers": [
{"kind": "email", "value": "demo-" + uuid4().hex + "@example.com"}
],
},
trusted=True,
)
profile = api("/v1/users/" + stitched["master_id"], trusted=True)
assert profile["attributes"] == {"interest": "running"}
assert profile["tags"] == ["runner"] and profile["event_count"] == 1
print(
json.dumps(
{
"master_id": profile["master_id"],
"attributes": profile["attributes"],
"tags": profile["tags"],
"event_count": profile["event_count"],
},
ensure_ascii=False,
indent=2,
)
)
+21
View File
@@ -0,0 +1,21 @@
"""Create ignored local-development secrets without printing their values."""
import os
import secrets
from pathlib import Path
root = Path(__file__).resolve().parents[1]
text = (root / ".env.example").read_text()
text = text.replace(
"IDENTITY_SECRET=\n", "IDENTITY_SECRET=" + secrets.token_urlsafe(48) + "\n"
)
text = text.replace(
"SERVICE_TOKEN=\n", "SERVICE_TOKEN=" + secrets.token_urlsafe(48) + "\n"
)
try:
descriptor = os.open(root / ".env", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
raise SystemExit(".env already exists; left unchanged") from None
with os.fdopen(descriptor, "w") as output:
output.write(text)
print("Created local .env with generated secrets. Values are not printed.")
+43 -1
View File
@@ -1,5 +1,18 @@
from fastapi import APIRouter
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request, Response
from adcloud_server.dependencies import (
IdentityDependency,
allowed_browser_origin,
anonymous_token,
set_anonymous_cookie,
trusted_service,
)
from adcloud_server.identity_models import (
EventRequest,
)
from adcloud_server.identity_responses import EventHistoryResponse, EventResponse
from adcloud_server.schemas import ModuleHealthResponse
router = APIRouter()
@@ -9,3 +22,32 @@ router = APIRouter()
def health() -> ModuleHealthResponse:
"""Verify routing to the events module; not a dependency readiness check."""
return ModuleHealthResponse(module="events")
@router.post(
"", response_model=EventResponse, dependencies=[Depends(allowed_browser_origin)]
)
def record_event(
body: EventRequest,
request: Request,
response: Response,
service: IdentityDependency,
):
"""Receive a cookie/token-bound event; missing IDs get a new anonymous ID."""
result = service.record_event(anonymous_token(request, body.anonymous_token), body)
set_anonymous_cookie(response, service, result)
return result
@router.get(
"", response_model=EventHistoryResponse, dependencies=[Depends(trusted_service)]
)
def list_events(
master_id: UUID,
response: Response,
service: IdentityDependency,
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0, le=100000),
):
response.headers["Cache-Control"] = "no-store"
return service.history(str(master_id), limit, offset)
+84 -1
View File
@@ -1,5 +1,25 @@
from fastapi import APIRouter
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request, Response
from adcloud_server.dependencies import (
IdentityDependency,
allowed_browser_origin,
anonymous_token,
set_anonymous_cookie,
trusted_service,
)
from adcloud_server.identity_models import (
ResolveRequest,
StitchRequest,
)
from adcloud_server.identity_responses import (
AnonymousResponse,
GraphResponse,
ResolveResponse,
StitchResponse,
StorageResponse,
)
from adcloud_server.schemas import ModuleHealthResponse
router = APIRouter()
@@ -9,3 +29,66 @@ router = APIRouter()
def health() -> ModuleHealthResponse:
"""Verify routing to the identity module; not a dependency readiness check."""
return ModuleHealthResponse(module="identity")
@router.post(
"/anonymous",
response_model=AnonymousResponse,
dependencies=[Depends(allowed_browser_origin)],
)
def anonymous(
request: Request,
response: Response,
service: IdentityDependency,
rotate: bool = Query(False),
):
"""Create/reuse a signed anonymous ID; rotate on logout/account switching."""
result = service.anonymous(anonymous_token(request), rotate=rotate)
set_anonymous_cookie(response, service, result)
return result
@router.post(
"/stitch", response_model=StitchResponse, dependencies=[Depends(trusted_service)]
)
def stitch(
body: StitchRequest,
request: Request,
response: Response,
service: IdentityDependency,
):
"""Trusted backend only: every supplied identifier must already be verified."""
result = service.stitch(anonymous_token(request, body.anonymous_token), body)
set_anonymous_cookie(response, service, result)
return result
@router.post(
"/resolve", response_model=ResolveResponse, dependencies=[Depends(trusted_service)]
)
def resolve(body: ResolveRequest, response: Response, service: IdentityDependency):
response.headers["Cache-Control"] = "no-store"
return service.resolve(body.identifiers)
@router.get(
"/{master_id}/graph",
response_model=GraphResponse,
dependencies=[Depends(trusted_service)],
)
def graph(master_id: UUID, response: Response, service: IdentityDependency):
response.headers["Cache-Control"] = "no-store"
return service.graph(str(master_id))
@router.get(
"/storage", response_model=StorageResponse, dependencies=[Depends(trusted_service)]
)
def storage(response: Response, service: IdentityDependency):
response.headers["Cache-Control"] = "no-store"
return {
"graph_backend": "sqlite" if service.db.sqlite else "postgresql",
"profile_backend": service.settings.profile_backend,
"canonical_profile_format": "json" if service.db.sqlite else "jsonb",
"wide_table_is_projection": service.settings.profile_backend == "cassandra",
}
+28 -1
View File
@@ -1,5 +1,10 @@
from fastapi import APIRouter
from uuid import UUID
from fastapi import APIRouter, Depends, Response
from adcloud_server.dependencies import IdentityDependency, trusted_service
from adcloud_server.identity_models import ProfilePatch
from adcloud_server.identity_responses import ProfileDocument, ProfileResponse
from adcloud_server.schemas import ModuleHealthResponse
router = APIRouter()
@@ -9,3 +14,25 @@ router = APIRouter()
def health() -> ModuleHealthResponse:
"""Verify routing to the users module; not a dependency readiness check."""
return ModuleHealthResponse(module="users")
@router.get(
"/{master_id}",
response_model=ProfileResponse,
dependencies=[Depends(trusted_service)],
)
def profile(master_id: UUID, response: Response, service: IdentityDependency):
response.headers["Cache-Control"] = "no-store"
return service.profile(str(master_id))
@router.patch(
"/{master_id}",
response_model=ProfileDocument,
dependencies=[Depends(trusted_service)],
)
def update_profile(
master_id: UUID, body: ProfilePatch, response: Response, service: IdentityDependency
):
response.headers["Cache-Control"] = "no-store"
return service.patch_profile(str(master_id), body)
+183
View File
@@ -0,0 +1,183 @@
from contextlib import contextmanager
from pathlib import Path
from threading import RLock
from sqlalchemy import (
JSON,
Column,
ForeignKeyConstraint,
Index,
Integer,
MetaData,
String,
Table,
create_engine,
select,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.engine import make_url
from sqlalchemy.pool import StaticPool
metadata = MetaData()
document_type = JSON().with_variant(JSONB(), "postgresql")
tenants = Table(
"identity_tenants", metadata, Column("id", String(64), primary_key=True)
)
profiles = Table(
"identity_profiles",
metadata,
Column("tenant_id", String(64), primary_key=True),
Column("id", String(36), primary_key=True),
Column("document", document_type, nullable=False),
Column("revision", Integer, nullable=False),
Column("created_at", String(40), nullable=False),
)
aliases = Table(
"identity_aliases",
metadata,
Column("tenant_id", String(64), primary_key=True),
Column("id", String(36), primary_key=True),
Column("canonical_id", String(36), nullable=False),
ForeignKeyConstraint(
["tenant_id", "canonical_id"],
["identity_profiles.tenant_id", "identity_profiles.id"],
),
)
Index("ix_identity_aliases_canonical", aliases.c.tenant_id, aliases.c.canonical_id)
identifiers = Table(
"identity_identifiers",
metadata,
Column("tenant_id", String(64), primary_key=True),
Column("digest", String(64), primary_key=True),
Column("kind", String(16), nullable=False),
Column("namespace", String(256), nullable=False),
Column("display", String(256), nullable=False),
Column("subject_id", String(36), nullable=False),
Column("created_at", String(40), nullable=False),
ForeignKeyConstraint(
["tenant_id", "subject_id"],
["identity_aliases.tenant_id", "identity_aliases.id"],
),
)
Index(
"ix_identity_identifiers_subject", identifiers.c.tenant_id, identifiers.c.subject_id
)
edges = Table(
"identity_edges",
metadata,
Column("tenant_id", String(64), primary_key=True),
Column("id", String(36), primary_key=True),
Column("source_id", String(36), nullable=False),
Column("target_id", String(36), nullable=False),
Column("evidence", document_type, nullable=False),
Column("created_at", String(40), nullable=False),
)
events = Table(
"identity_events",
metadata,
Column("tenant_id", String(64), primary_key=True),
Column("id", String(36), primary_key=True),
Column("subject_id", String(36), nullable=False),
Column("anonymous_id", String(36), nullable=False),
Column("name", String(128), nullable=False),
Column("properties", document_type, nullable=False),
Column("occurred_at", String(40), nullable=False),
ForeignKeyConstraint(
["tenant_id", "subject_id"],
["identity_aliases.tenant_id", "identity_aliases.id"],
),
)
Index(
"ix_identity_events_subject_time",
events.c.tenant_id,
events.c.subject_id,
events.c.occurred_at,
events.c.id,
)
outbox = Table(
"identity_profile_outbox",
metadata,
Column("tenant_id", String(64), primary_key=True),
Column("id", String(36), primary_key=True),
Column("master_id", String(36), nullable=False),
Column("revision", Integer, nullable=False),
Column("document", document_type, nullable=False),
Column("created_at", String(40), nullable=False),
Column("delivered", Integer, nullable=False, default=0),
)
Index(
"ix_identity_outbox_pending",
outbox.c.tenant_id,
outbox.c.delivered,
outbox.c.created_at,
)
class Database:
def __init__(self, url: str, tenant_id: str):
parsed = make_url(url)
self.sqlite = parsed.get_backend_name() == "sqlite"
if parsed.get_backend_name() not in ("sqlite", "postgresql"):
raise ValueError("DATABASE_URL must use SQLite or PostgreSQL")
if self.sqlite and parsed.database not in (None, "", ":memory:"):
Path(parsed.database).parent.mkdir(parents=True, exist_ok=True)
options = {"hide_parameters": True, "pool_pre_ping": True}
if self.sqlite:
options["connect_args"] = {"check_same_thread": False, "timeout": 30}
if parsed.database in (None, "", ":memory:"):
options["poolclass"] = StaticPool
self.engine = create_engine(url, **options)
self.tenant_id = tenant_id
self._lock = RLock()
def initialize(self):
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
statement = (
(sqlite_insert if self.sqlite else pg_insert)(tenants)
.values(id=self.tenant_id)
.on_conflict_do_nothing(index_elements=[tenants.c.id])
)
# First startup can occur in several workers at once. Serialize schema
# creation separately from per-tenant identity transactions.
with self._lock, self.engine.connect() as conn:
if self.sqlite:
conn.exec_driver_sql("BEGIN IMMEDIATE")
else:
conn.begin()
conn.execute(text("SELECT pg_advisory_xact_lock(471923004)"))
try:
metadata.create_all(conn)
conn.execute(statement)
conn.commit()
except BaseException:
conn.rollback()
raise
@contextmanager
def transaction(self):
# SQLite's write lock also serializes separate development processes.
# PostgreSQL serializes this tenant with a database row lock (all workers).
with self._lock, self.engine.connect() as conn:
if self.sqlite:
conn.exec_driver_sql("PRAGMA foreign_keys=ON")
conn.commit()
conn.exec_driver_sql("BEGIN IMMEDIATE")
else:
conn.begin()
conn.execute(
select(tenants.c.id)
.where(tenants.c.id == self.tenant_id)
.with_for_update()
)
try:
yield conn
conn.commit()
except BaseException:
conn.rollback()
raise
def close(self):
self.engine.dispose()
+62
View File
@@ -0,0 +1,62 @@
import hmac
from typing import Annotated
from fastapi import Depends, HTTPException, Request, Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from adcloud_server.identity_service import IdentityService
COOKIE_NAME = "adcloud_anonymous"
bearer = HTTPBearer(auto_error=False)
def identity_service(request: Request) -> IdentityService:
service = getattr(request.app.state, "identity_service", None)
if service is None:
raise HTTPException(
503, "Identity service requires IDENTITY_SECRET configuration"
)
return service
def trusted_service(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
):
token = request.app.state.settings.service_token
if not token:
raise HTTPException(503, "Trusted service authentication is not configured")
if credentials is None or not hmac.compare_digest(
credentials.credentials.encode(), token.encode()
):
raise HTTPException(
401,
"Trusted service token required",
headers={"WWW-Authenticate": "Bearer"},
)
def allowed_browser_origin(request: Request):
origin = request.headers.get("origin")
if origin and origin not in request.app.state.settings.cors_origins:
raise HTTPException(403, "Browser origin is not allowed")
def anonymous_token(request: Request, explicit: str | None = None):
return explicit if explicit is not None else request.cookies.get(COOKIE_NAME)
def set_anonymous_cookie(response: Response, service: IdentityService, result):
response.set_cookie(
COOKIE_NAME,
result["anonymous_token"],
max_age=service.settings.cookie_max_age,
httponly=True,
secure=service.settings.cookie_secure,
samesite=service.settings.cookie_samesite,
path="/",
)
response.headers["Cache-Control"] = "no-store"
IdentityDependency = Annotated[IdentityService, Depends(identity_service)]
+93
View File
@@ -0,0 +1,93 @@
import json
import re
from datetime import UTC, datetime
from typing import Literal
from uuid import UUID
from pydantic import (
BaseModel,
ConfigDict,
Field,
JsonValue,
field_validator,
model_validator,
)
def validate_attributes(value: dict[str, JsonValue]):
if len(value) > 200 or any(
not re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", key) for key in value
):
raise ValueError("Attributes allow up to 200 named fields")
if len(json.dumps(value, ensure_ascii=False, allow_nan=False).encode()) > 65536:
raise ValueError("Attributes exceed 64 KiB")
return value
class Identifier(BaseModel):
model_config = ConfigDict(extra="forbid")
kind: Literal["email", "phone", "oauth", "external"]
value: str = Field(min_length=1, max_length=320)
namespace: str = Field(default="", max_length=256)
@model_validator(mode="after")
def validate_namespace(self):
if self.kind in ("oauth", "external") and not self.namespace.strip():
raise ValueError("OAuth/external IDs require a provider namespace")
if self.kind in ("email", "phone") and self.namespace:
raise ValueError("Email/phone IDs do not accept a provider namespace")
return self
class ProfilePatch(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
attributes: dict[str, JsonValue] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list, max_length=100)
@field_validator("attributes")
@classmethod
def attributes_valid(cls, value):
return validate_attributes(value)
@field_validator("tags")
@classmethod
def tags_valid(cls, value):
if any(not tag.strip() or len(tag) > 128 for tag in value):
raise ValueError("Tags must contain 1 to 128 characters")
return sorted({tag.strip() for tag in value})
class StitchRequest(ProfilePatch):
identifiers: list[Identifier] = Field(min_length=1, max_length=20)
anonymous_token: str | None = Field(default=None, max_length=2048)
account_master_id: UUID | None = None
class ResolveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
identifiers: list[Identifier] = Field(min_length=1, max_length=20)
class EventRequest(BaseModel):
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
event_id: UUID | None = None
name: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.:-]+$")
properties: dict[str, JsonValue] = Field(default_factory=dict)
occurred_at: datetime | None = None
anonymous_token: str | None = Field(default=None, max_length=2048)
@field_validator("properties")
@classmethod
def properties_valid(cls, value):
if len(json.dumps(value, ensure_ascii=False, allow_nan=False).encode()) > 32768:
raise ValueError("Event properties exceed 32 KiB")
return value
@field_validator("occurred_at")
@classmethod
def time_valid(cls, value):
if value and (value.tzinfo is None or value > datetime.now(UTC)):
raise ValueError(
"occurred_at requires a timezone and cannot be in the future"
)
return value
+86
View File
@@ -0,0 +1,86 @@
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, JsonValue
class AnonymousResponse(BaseModel):
anonymous_id: UUID
anonymous_token: str
master_id: UUID
class StitchResponse(AnonymousResponse):
merged_master_ids: list[UUID]
rotated_anonymous_id: bool
revision: int
class ResolveResponse(BaseModel):
master_id: UUID | None
matches: list[UUID]
ambiguous: bool
class ProfileDocument(BaseModel):
master_id: UUID
attributes: dict[str, JsonValue]
tags: list[str]
revision: int
class ProfileResponse(ProfileDocument):
event_count: int
profile_backend: Literal["relational", "cassandra"]
projection_status: Literal["not_enabled", "current", "pending", "unavailable"]
class GraphIdentifier(BaseModel):
kind: Literal["email", "phone", "oauth", "external"]
namespace: str
display: str
subject_id: UUID
created_at: datetime
verified: Literal[True] = True
class GraphEdge(BaseModel):
source_id: UUID
target_id: UUID
created_at: datetime
class GraphResponse(BaseModel):
master_id: UUID
member_ids: list[UUID]
identifiers: list[GraphIdentifier]
edges: list[GraphEdge]
class EventResponse(AnonymousResponse):
event_id: UUID
duplicate: bool
class HistoryEvent(BaseModel):
id: UUID
anonymous_id: UUID
name: str
properties: dict[str, JsonValue]
occurred_at: datetime
class EventHistoryResponse(BaseModel):
master_id: UUID
total: int
items: list[HistoryEvent]
limit: int
offset: int
class StorageResponse(BaseModel):
graph_backend: Literal["postgresql", "sqlite"]
profile_backend: Literal["relational", "cassandra"]
canonical_profile_format: Literal["jsonb", "json"]
wide_table_is_projection: bool
+508
View File
@@ -0,0 +1,508 @@
import hashlib
import hmac
import json
from datetime import UTC, datetime
from uuid import uuid4
import phonenumbers
from email_validator import EmailNotValidError, validate_email
from fastapi import HTTPException
from sqlalchemy import func, insert, select, update
from adcloud_server.database import (
Database,
aliases,
edges,
events,
identifiers,
outbox,
profiles,
)
from adcloud_server.identity_models import Identifier, validate_attributes
from adcloud_server.settings import Settings
from adcloud_server.tokens import AnonymousTokens
def now():
return datetime.now(UTC).isoformat()
def sparse(value):
if isinstance(value, dict):
return {key: sparse(item) for key, item in value.items() if item is not None}
if isinstance(value, list):
return [sparse(item) for item in value]
return value
def fill_missing(target, source):
result = dict(source)
for key, value in target.items():
result[key] = (
fill_missing(value, source[key])
if isinstance(value, dict) and isinstance(source.get(key), dict)
else value
)
return result
class IdentityService:
def __init__(self, settings: Settings, database: Database, projection=None):
self.settings = settings
self.db = database
self.tenant = settings.tenant_id
self.tokens = AnonymousTokens(
settings.identity_secret, self.tenant, settings.cookie_max_age
)
self.projection = projection
def normalized(self, identifier: Identifier):
value = identifier.value.strip()
namespace = identifier.namespace.strip()
try:
if identifier.kind == "email":
# Preserve the local-part's case: distinct case-sensitive mailboxes
# must not be silently merged. Domain/IDNA normalization is safe.
value = validate_email(value, check_deliverability=False).normalized
local, domain = value.rsplit("@", 1)
display = local[:1] + "***@" + domain
elif identifier.kind == "phone":
if not value.startswith("+"):
raise ValueError(
"Phone numbers require international +country format"
)
phone = phonenumbers.parse(value, None)
if not phonenumbers.is_valid_number(phone):
raise ValueError("Invalid phone number")
value = phonenumbers.format_number(
phone, phonenumbers.PhoneNumberFormat.E164
)
display = "***" + value[-4:]
else:
if not value or not namespace:
raise ValueError("Provider namespace and subject are required")
display = "<redacted>"
except (
ValueError,
EmailNotValidError,
phonenumbers.NumberParseException,
) as error:
raise HTTPException(422, "Invalid identity identifier") from error
payload = json.dumps(
[self.tenant, identifier.kind, namespace, value],
separators=(",", ":"),
ensure_ascii=False,
).encode()
digest = hmac.new(
self.settings.identity_secret.encode(),
b"identifier:" + payload,
hashlib.sha256,
).hexdigest()
return {
"digest": digest,
"kind": identifier.kind,
"namespace": namespace,
"display": display,
}
def _scope(self, table):
return table.c.tenant_id == self.tenant
def _root(self, conn, subject_id):
root = conn.execute(
select(aliases.c.canonical_id).where(
self._scope(aliases), aliases.c.id == subject_id
)
).scalar_one_or_none()
if root is None:
raise HTTPException(404, "User not found")
return root
def _members(self, root):
return select(aliases.c.id).where(
self._scope(aliases), aliases.c.canonical_id == root
)
def _profile(self, conn, root):
return (
conn.execute(
select(profiles).where(self._scope(profiles), profiles.c.id == root)
)
.mappings()
.one()
)
def _queue(self, conn, root, revision, document):
if self.settings.profile_backend == "cassandra":
conn.execute(
insert(outbox).values(
tenant_id=self.tenant,
id=str(uuid4()),
master_id=root,
revision=revision,
document=document,
created_at=now(),
delivered=0,
)
)
def _new(self, conn):
subject = str(uuid4())
document = {"attributes": {}, "tags": []}
conn.execute(
insert(profiles).values(
tenant_id=self.tenant,
id=subject,
document=document,
revision=1,
created_at=now(),
)
)
conn.execute(
insert(aliases).values(
tenant_id=self.tenant, id=subject, canonical_id=subject
)
)
self._queue(conn, subject, 1, document)
return subject
def _anonymous(self, conn, token, *, strict=False):
if token:
try:
subject = self.tokens.verify(token)
self._root(conn, subject)
return subject
except (ValueError, HTTPException) as error:
if strict:
raise HTTPException(
401, "Invalid or expired anonymous token"
) from error
elif strict:
raise HTTPException(401, "Anonymous token required")
return self._new(conn)
def _result(self, conn, subject):
return {
"anonymous_id": subject,
"anonymous_token": self.tokens.issue(subject),
"master_id": self._root(conn, subject),
}
def anonymous(self, token=None, *, rotate=False):
with self.db.transaction() as conn:
subject = self._new(conn) if rotate else self._anonymous(conn, token)
return self._result(conn, subject)
def _lookup(self, conn, normalized):
digests = [item["digest"] for item in normalized]
return set(
conn.execute(
select(aliases.c.canonical_id)
.select_from(
identifiers.join(
aliases,
(identifiers.c.tenant_id == aliases.c.tenant_id)
& (identifiers.c.subject_id == aliases.c.id),
)
)
.where(self._scope(identifiers), identifiers.c.digest.in_(digests))
).scalars()
)
def resolve(self, supplied):
normalized = [self.normalized(item) for item in supplied]
with self.db.transaction() as conn:
matches = sorted(self._lookup(conn, normalized))
return {
"master_id": matches[0] if len(matches) == 1 else None,
"matches": matches,
"ambiguous": len(matches) > 1,
}
def _save_profile(self, conn, root, document):
try:
validate_attributes(document["attributes"])
if len(document["tags"]) > 500:
raise ValueError("Too many tags")
except ValueError as error:
raise HTTPException(
409, "Merged profile exceeds supported limits"
) from error
revision = self._profile(conn, root)["revision"] + 1
conn.execute(
update(profiles)
.where(self._scope(profiles), profiles.c.id == root)
.values(document=document, revision=revision)
)
self._queue(conn, root, revision, document)
return revision
def stitch(self, token, request):
normalized = list(
{
item["digest"]: item
for item in (
self.normalized(identifier) for identifier in request.identifiers
)
}.values()
)
with self.db.transaction() as conn:
subject = self._anonymous(conn, token, strict=True)
source = self._root(conn, subject)
matches = self._lookup(conn, normalized)
own_digests = set(
conn.execute(
select(identifiers.c.digest).where(
self._scope(identifiers),
identifiers.c.subject_id.in_(self._members(source)),
)
).scalars()
)
account = (
self._root(conn, str(request.account_master_id))
if request.account_master_id
else None
)
# Cookie/device continuity alone cannot unite two logged-in people.
rotated = bool(
own_digests
and not own_digests.intersection(item["digest"] for item in normalized)
and account != source
)
if rotated:
subject = self._new(conn)
source = subject
if account:
matches.add(account)
if matches:
target = account or min(
matches,
key=lambda root: (self._profile(conn, root)["created_at"], root),
)
else:
target = source
losers = sorted((matches | {source}) - {target})
document = self._profile(conn, target)["document"]
attributes = document["attributes"]
tags = set(document["tags"])
for loser in losers:
old = self._profile(conn, loser)["document"]
attributes = fill_missing(attributes, old["attributes"])
tags.update(old["tags"])
conn.execute(
update(aliases)
.where(self._scope(aliases), aliases.c.canonical_id == loser)
.values(canonical_id=target)
)
conn.execute(
update(profiles)
.where(self._scope(profiles), profiles.c.id == loser)
.values(document={"attributes": {}, "tags": []})
)
conn.execute(
insert(edges).values(
tenant_id=self.tenant,
id=str(uuid4()),
source_id=loser,
target_id=target,
evidence=[
{
"kind": item["kind"],
"namespace": item["namespace"],
"digest": item["digest"],
}
for item in normalized
],
created_at=now(),
)
)
# Verified server-supplied traits override inherited traits; null fields
# are omitted during stitch. PATCH can explicitly remove a field.
attributes = {**attributes, **sparse(request.attributes)}
tags.update(request.tags)
revision = self._save_profile(
conn, target, {"attributes": attributes, "tags": sorted(tags)}
)
for item in normalized:
found = conn.execute(
select(identifiers.c.digest).where(
self._scope(identifiers), identifiers.c.digest == item["digest"]
)
).scalar_one_or_none()
if found is None:
conn.execute(
insert(identifiers).values(
tenant_id=self.tenant,
subject_id=target,
created_at=now(),
**item,
)
)
result = self._result(conn, subject)
result.update(
{
"merged_master_ids": losers,
"rotated_anonymous_id": rotated,
"revision": revision,
}
)
return result
def patch_profile(self, master_id, patch):
with self.db.transaction() as conn:
root = self._root(conn, master_id)
document = self._profile(conn, root)["document"]
attributes = {**document["attributes"], **patch.attributes}
document = {
"attributes": sparse(attributes),
"tags": sorted(set(document["tags"]) | set(patch.tags)),
}
revision = self._save_profile(conn, root, document)
return {"master_id": root, **document, "revision": revision}
def profile(self, master_id):
with self.db.transaction() as conn:
root = self._root(conn, master_id)
row = self._profile(conn, root)
document = row["document"]
count = conn.execute(
select(func.count())
.select_from(events)
.where(
self._scope(events), events.c.subject_id.in_(self._members(root))
)
).scalar_one()
projection_status = "not_enabled"
if self.projection is not None:
try:
projected = self.projection.get(self.tenant, root, row["revision"])
projection_status = "current" if projected is not None else "pending"
if projected is not None:
document = projected
except Exception: # noqa: BLE001 -- Any projection failure must preserve canonical reads.
# Canonical JSONB data provides read-after-stitch consistency even
# if Cassandra is behind or unavailable. No exception/PII leakage.
projection_status = "unavailable"
return {
"master_id": root,
**document,
"revision": row["revision"],
"event_count": count,
"profile_backend": self.settings.profile_backend,
"projection_status": projection_status,
}
def graph(self, master_id):
with self.db.transaction() as conn:
root = self._root(conn, master_id)
members = sorted(conn.execute(self._members(root)).scalars())
linked = (
conn.execute(
select(
identifiers.c.kind,
identifiers.c.namespace,
identifiers.c.display,
identifiers.c.subject_id,
identifiers.c.created_at,
)
.where(
self._scope(identifiers), identifiers.c.subject_id.in_(members)
)
.order_by(identifiers.c.created_at, identifiers.c.digest)
)
.mappings()
.all()
)
connections = (
conn.execute(
select(edges.c.source_id, edges.c.target_id, edges.c.created_at)
.where(
self._scope(edges),
edges.c.source_id.in_(members),
edges.c.target_id.in_(members),
)
.order_by(edges.c.created_at, edges.c.id)
)
.mappings()
.all()
)
return {
"master_id": root,
"member_ids": members,
"identifiers": [dict(item) for item in linked],
"edges": [dict(item) for item in connections],
}
def record_event(self, token, request):
with self.db.transaction() as conn:
subject = self._anonymous(conn, token, strict=bool(request.anonymous_token))
event_id = str(request.event_id or uuid4())
existing = (
conn.execute(
select(events).where(self._scope(events), events.c.id == event_id)
)
.mappings()
.one_or_none()
)
occurred_at = (
request.occurred_at.astimezone(UTC).isoformat()
if request.occurred_at
else now()
)
if existing:
if (
existing["anonymous_id"] != subject
or existing["name"] != request.name
or existing["properties"] != request.properties
or (request.occurred_at and existing["occurred_at"] != occurred_at)
):
raise HTTPException(
409, "Event ID already used by a different event"
)
else:
conn.execute(
insert(events).values(
tenant_id=self.tenant,
id=event_id,
subject_id=subject,
anonymous_id=subject,
name=request.name,
properties=request.properties,
occurred_at=occurred_at,
)
)
result = self._result(conn, subject)
result.update({"event_id": event_id, "duplicate": existing is not None})
return result
def history(self, master_id, limit=100, offset=0):
with self.db.transaction() as conn:
root = self._root(conn, master_id)
scope = [self._scope(events), events.c.subject_id.in_(self._members(root))]
rows = (
conn.execute(
select(
events.c.id,
events.c.anonymous_id,
events.c.name,
events.c.properties,
events.c.occurred_at,
)
.where(*scope)
.order_by(events.c.occurred_at, events.c.id)
.limit(limit)
.offset(offset)
)
.mappings()
.all()
)
count = conn.execute(
select(func.count()).select_from(events).where(*scope)
).scalar_one()
return {
"master_id": root,
"total": count,
"items": [dict(item) for item in rows],
"limit": limit,
"offset": offset,
}
+33 -11
View File
@@ -1,29 +1,51 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from adcloud_server.api.v1.router import router as v1_router
from adcloud_server.database import Database
from adcloud_server.identity_service import IdentityService
from adcloud_server.profile_store import create_profile_store
from adcloud_server.schemas import HealthResponse
from adcloud_server.settings import Settings
def create_app() -> FastAPI:
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or Settings.from_env()
@asynccontextmanager
async def lifespan(application):
database = None
projection = None
application.state.identity_service = None
try:
if settings.identity_secret:
database = Database(settings.database_url, settings.tenant_id)
database.initialize()
projection = create_profile_store(settings)
application.state.identity_service = IdentityService(
settings, database, projection
)
yield
finally:
if projection is not None:
projection.close()
if database is not None:
database.close()
application = FastAPI(
title="AdCloud Server",
version="0.1.0",
description="Versioned HTTP API for AdCloud tracking and ad delivery.",
description="Versioned identity graph, stitching, and tracking API.",
redirect_slashes=False,
lifespan=lifespan,
)
application.state.settings = settings
application.add_middleware(
CORSMiddleware,
allow_origins=[
origin.strip()
for origin in os.getenv(
"CORS_ORIGINS",
"http://localhost:3000,http://localhost:5173,http://localhost:5174",
).split(",")
if origin.strip()
],
allow_origins=list(settings.cors_origins),
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
)
+109
View File
@@ -0,0 +1,109 @@
import json
import re
import ssl
from threading import RLock
from adcloud_server.settings import Settings
class CassandraProfileStore:
"""Sparse, versioned wide-column projection; relational graph is canonical."""
def __init__(self, settings: Settings):
if not re.fullmatch(r"[a-z][a-z0-9_]{0,47}", settings.cassandra_keyspace):
raise ValueError("Invalid Cassandra keyspace")
self.settings = settings
self._cluster = None
self._session = None
self._lock = RLock()
self._read = None
self._write = None
def _connect(self):
with self._lock:
if self._session is not None:
return
from cassandra import ConsistencyLevel
from cassandra.auth import PlainTextAuthProvider
from cassandra.cluster import Cluster
options = {
"contact_points": list(self.settings.cassandra_hosts),
"port": self.settings.cassandra_port,
"connect_timeout": 5,
"control_connection_timeout": 5,
}
if self.settings.cassandra_username:
options["auth_provider"] = PlainTextAuthProvider(
self.settings.cassandra_username, self.settings.cassandra_password
)
if self.settings.cassandra_tls:
options["ssl_context"] = ssl.create_default_context(
cafile=self.settings.cassandra_ca_file
)
cluster = Cluster(**options)
try:
session = cluster.connect(self.settings.cassandra_keyspace)
session.default_timeout = 5
read = session.prepare(
"SELECT field, value FROM identity_profile_fields WHERE tenant_id=? AND master_id=? AND revision=?"
)
write = session.prepare(
"INSERT INTO identity_profile_fields (tenant_id, master_id, revision, field, value) VALUES (?, ?, ?, ?, ?) USING TTL 604800"
)
read.consistency_level = ConsistencyLevel.LOCAL_QUORUM
write.consistency_level = ConsistencyLevel.LOCAL_QUORUM
except BaseException:
cluster.shutdown()
raise
self._cluster, self._session = cluster, session
self._read, self._write = read, write
def put(self, tenant, master_id, revision, document):
self._connect()
from cassandra import ConsistencyLevel
from cassandra.query import BatchStatement, BatchType
# One immutable revision is one partition. The marker and fields are
# published together, so consumers never read a partial snapshot.
batch = BatchStatement(
batch_type=BatchType.LOGGED, consistency_level=ConsistencyLevel.LOCAL_QUORUM
)
fields = {
"a:" + key: json.dumps(value, ensure_ascii=False, allow_nan=False)
for key, value in document["attributes"].items()
}
fields["tags"] = json.dumps(document["tags"], ensure_ascii=False)
fields["_complete"] = str(revision)
for field, value in fields.items():
batch.add(self._write, (tenant, master_id, revision, field, value))
self._session.execute(batch)
def get(self, tenant, master_id, revision):
self._connect()
fields = {
row.field: row.value
for row in self._session.execute(self._read, (tenant, master_id, revision))
}
if fields.get("_complete") != str(revision) or "tags" not in fields:
return None
return {
"attributes": {
key[2:]: json.loads(value)
for key, value in fields.items()
if key.startswith("a:")
},
"tags": json.loads(fields["tags"]),
}
def close(self):
if self._cluster is not None:
self._cluster.shutdown()
def create_profile_store(settings: Settings):
return (
CassandraProfileStore(settings)
if settings.profile_backend == "cassandra"
else None
)
+130
View File
@@ -0,0 +1,130 @@
import argparse
import time
from uuid import uuid4
from sqlalchemy import insert, select, update
from adcloud_server.database import Database, aliases, outbox, profiles
from adcloud_server.identity_service import now
from adcloud_server.profile_store import create_profile_store
from adcloud_server.settings import Settings
class ProfileProjector:
def __init__(self, database, store):
self.db = database
self.store = store
self.tenant = database.tenant_id
def backfill(self):
with self.db.transaction() as conn:
rows = (
conn.execute(
select(profiles)
.join(
aliases,
(profiles.c.tenant_id == aliases.c.tenant_id)
& (profiles.c.id == aliases.c.id),
)
.where(
profiles.c.tenant_id == self.tenant,
aliases.c.id == aliases.c.canonical_id,
)
)
.mappings()
.all()
)
for row in rows:
conn.execute(
insert(outbox).values(
tenant_id=self.tenant,
id=str(uuid4()),
master_id=row["id"],
revision=row["revision"],
document=row["document"],
created_at=now(),
delivered=0,
)
)
return len(rows)
def flush(self, limit=100):
processed = 0
for _ in range(limit):
with self.db.transaction() as conn:
job = (
conn.execute(
select(outbox)
.where(
outbox.c.tenant_id == self.tenant, outbox.c.delivered == 0
)
.order_by(outbox.c.created_at, outbox.c.id)
.limit(1)
)
.mappings()
.one_or_none()
)
if job is None:
break
root = conn.execute(
select(aliases.c.canonical_id).where(
aliases.c.tenant_id == self.tenant,
aliases.c.id == job["master_id"],
)
).scalar_one()
revision = conn.execute(
select(profiles.c.revision).where(
profiles.c.tenant_id == self.tenant, profiles.c.id == root
)
).scalar_one()
# Old merged masters and outdated revisions need no projection.
if root == job["master_id"] and revision == job["revision"]:
self.store.put(self.tenant, root, revision, job["document"])
conn.execute(
update(outbox)
.where(outbox.c.tenant_id == self.tenant, outbox.c.id == job["id"])
.values(delivered=1)
)
processed += 1
return processed
def main():
parser = argparse.ArgumentParser(
description="Project canonical identity profiles into Cassandra"
)
parser.add_argument("--once", action="store_true")
parser.add_argument(
"--backfill",
action="store_true",
help="Enqueue current profiles after changing storage settings",
)
args = parser.parse_args()
settings = Settings.from_env()
if not settings.identity_secret or settings.profile_backend != "cassandra":
parser.error("Set IDENTITY_SECRET and PROFILE_BACKEND=cassandra")
store = create_profile_store(settings)
db = Database(settings.database_url, settings.tenant_id)
try:
db.initialize()
projector = ProfileProjector(db, store)
if args.backfill:
print("Queued profiles:", projector.backfill())
while True:
try:
count = projector.flush()
if args.once:
print("Processed outbox jobs:", count)
return
except Exception as error: # noqa: BLE001 -- Retry failed jobs without dropping the outbox.
if args.once:
raise SystemExit(
"Projection failed: " + type(error).__name__
) from None
print("Projection retry required:", type(error).__name__, flush=True)
time.sleep(1)
except KeyboardInterrupt:
return
finally:
store.close()
db.close()
+89
View File
@@ -0,0 +1,89 @@
import os
from dataclasses import dataclass, field
from typing import Literal
@dataclass(frozen=True)
class Settings:
database_url: str = field(default="sqlite:///.data/adcloud.db", repr=False)
profile_backend: Literal["relational", "cassandra"] = "relational"
tenant_id: str = "default"
identity_secret: str = field(default="", repr=False)
service_token: str = field(default="", repr=False)
cookie_secure: bool = True
cookie_samesite: Literal["lax", "strict", "none"] = "lax"
cookie_max_age: int = 31536000
cors_origins: tuple[str, ...] = (
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:5174",
)
cassandra_hosts: tuple[str, ...] = ("127.0.0.1",)
cassandra_port: int = 9042
cassandra_keyspace: str = "adcloud"
cassandra_username: str = ""
cassandra_password: str = field(default="", repr=False)
cassandra_tls: bool = True
cassandra_ca_file: str | None = None
def __post_init__(self):
if self.profile_backend not in ("relational", "cassandra"):
raise ValueError("PROFILE_BACKEND must be relational or cassandra")
if self.cookie_samesite not in ("lax", "strict", "none"):
raise ValueError("Invalid COOKIE_SAMESITE")
if self.cookie_samesite == "none" and not self.cookie_secure:
raise ValueError("SameSite=None requires COOKIE_SECURE=true")
if any(origin == "*" for origin in self.cors_origins):
raise ValueError("Credentialed CORS requires explicit origins")
if not self.tenant_id or len(self.tenant_id) > 64:
raise ValueError("Invalid tenant ID")
for name, secret in [
("IDENTITY_SECRET", self.identity_secret),
("SERVICE_TOKEN", self.service_token),
]:
if secret and len(secret) < 32:
raise ValueError(f"{name} must contain at least 32 characters")
if not 60 <= self.cookie_max_age <= 31536000:
raise ValueError("COOKIE_MAX_AGE must be between 60 and 31536000")
@classmethod
def from_env(cls):
from dotenv import load_dotenv
load_dotenv(dotenv_path=".env", override=False)
def flag(name: str, default: bool):
raw = os.getenv(name, str(default)).lower()
if raw not in ("true", "false"):
raise ValueError(f"{name} must be true or false")
return raw == "true"
return cls(
database_url=os.getenv("DATABASE_URL", "sqlite:///.data/adcloud.db"),
profile_backend=os.getenv("PROFILE_BACKEND", "relational"),
tenant_id=os.getenv("TENANT_ID", "default"),
identity_secret=os.getenv("IDENTITY_SECRET", ""),
service_token=os.getenv("SERVICE_TOKEN", ""),
cookie_secure=flag("COOKIE_SECURE", True),
cookie_samesite=os.getenv("COOKIE_SAMESITE", "lax"),
cookie_max_age=int(os.getenv("COOKIE_MAX_AGE", "31536000")),
cors_origins=tuple(
x.strip()
for x in os.getenv(
"CORS_ORIGINS",
"http://localhost:3000,http://localhost:5173,http://localhost:5174",
).split(",")
if x.strip()
),
cassandra_hosts=tuple(
x.strip()
for x in os.getenv("CASSANDRA_HOSTS", "127.0.0.1").split(",")
if x.strip()
),
cassandra_port=int(os.getenv("CASSANDRA_PORT", "9042")),
cassandra_keyspace=os.getenv("CASSANDRA_KEYSPACE", "adcloud"),
cassandra_username=os.getenv("CASSANDRA_USERNAME", ""),
cassandra_password=os.getenv("CASSANDRA_PASSWORD", ""),
cassandra_tls=flag("CASSANDRA_TLS", True),
cassandra_ca_file=os.getenv("CASSANDRA_CA_FILE") or None,
)
+52
View File
@@ -0,0 +1,52 @@
import base64
import hashlib
import hmac
import json
import time
from uuid import UUID
class AnonymousTokens:
def __init__(self, secret: str, tenant_id: str, max_age: int):
self.key = secret.encode()
self.tenant_id = tenant_id
self.max_age = max_age
def issue(self, anonymous_id: str) -> str:
raw = json.dumps(
{
"v": 1,
"tenant": self.tenant_id,
"id": anonymous_id,
"exp": int(time.time()) + self.max_age,
},
separators=(",", ":"),
).encode()
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
signature = hmac.new(
self.key, b"anonymous:" + payload.encode(), hashlib.sha256
).hexdigest()
return payload + "." + signature
def verify(self, token: str) -> str:
try:
if len(token) > 2048:
raise ValueError
payload, signature = token.split(".")
expected = hmac.new(
self.key, b"anonymous:" + payload.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise ValueError
data = json.loads(
base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
)
if (
data["v"] != 1
or data["tenant"] != self.tenant_id
or data["exp"] <= time.time()
):
raise ValueError
return str(UUID(data["id"]))
except (ValueError, KeyError, TypeError, UnicodeError) as error:
raise ValueError("Invalid or expired anonymous token") from error
+87
View File
@@ -0,0 +1,87 @@
"""Optional supplied Cassandra environment; never install/start a server here."""
import os
from uuid import uuid4
import pytest
from adcloud_server.database import Database
from adcloud_server.identity_models import ProfilePatch, StitchRequest
from adcloud_server.identity_service import IdentityService
from adcloud_server.profile_store import CassandraProfileStore
from adcloud_server.projection import ProfileProjector
from adcloud_server.settings import Settings
pytestmark = pytest.mark.skipif(
not os.getenv("ADCLOUD_TEST_CASSANDRA_HOSTS"),
reason="No Cassandra integration cluster configured",
)
@pytest.fixture
def components(tmp_path):
settings = Settings(
database_url="sqlite:///" + str(tmp_path / "graph.db"),
profile_backend="cassandra",
tenant_id="test-" + str(uuid4()),
identity_secret="test-cassandra-" + "x" * 48,
cassandra_hosts=tuple(os.environ["ADCLOUD_TEST_CASSANDRA_HOSTS"].split(",")),
cassandra_port=int(os.getenv("ADCLOUD_TEST_CASSANDRA_PORT", "9042")),
cassandra_keyspace=os.environ["ADCLOUD_TEST_CASSANDRA_KEYSPACE"],
cassandra_username=os.getenv("ADCLOUD_TEST_CASSANDRA_USERNAME", ""),
cassandra_password=os.getenv("ADCLOUD_TEST_CASSANDRA_PASSWORD", ""),
cassandra_tls=os.getenv("ADCLOUD_TEST_CASSANDRA_TLS", "true").lower() == "true",
cassandra_ca_file=os.getenv("ADCLOUD_TEST_CASSANDRA_CA_FILE") or None,
)
db = Database(settings.database_url, settings.tenant_id)
db.initialize()
store = CassandraProfileStore(settings)
try:
yield IdentityService(settings, db, store), ProfileProjector(db, store), store
finally:
store.close()
db.close()
def test_cassandra_sparse_snapshot_roundtrip(components):
service, _worker, store = components
master = str(uuid4())
document = {
"attributes": {"nested": {"interest": "running"}, "enabled": True, "score": 0},
"tags": ["runner"],
}
store.put(service.tenant, master, 1, document)
assert store.get(service.tenant, master, 1) == document
assert store.get(service.tenant, master, 2) is None
def test_cassandra_immutable_revision_prevents_stale_attribute_resurrection(components):
service, _worker, store = components
master = str(uuid4())
old = {"attributes": {"remove": "old"}, "tags": []}
new = {"attributes": {}, "tags": []}
store.put(service.tenant, master, 2, new)
store.put(service.tenant, master, 1, old)
assert store.get(service.tenant, master, 2) == new
def test_cassandra_worker_publishes_stitched_profile(components):
service, worker, _store = components
anon = service.anonymous()
stitched = service.stitch(
anon["anonymous_token"],
StitchRequest(
identifiers=[{"kind": "email", "value": "alice@example.com"}],
attributes={"interest": "running"},
),
)
assert service.profile(stitched["master_id"])["projection_status"] == "pending"
worker.flush()
current = service.profile(stitched["master_id"])
assert current["projection_status"] == "current"
assert current["attributes"] == {"interest": "running"}
service.patch_profile(
stitched["master_id"], ProfilePatch(attributes={"interest": None})
)
worker.flush()
assert service.profile(stitched["master_id"])["attributes"] == {}
+438
View File
@@ -0,0 +1,438 @@
from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from adcloud_server.database import Database, identifiers
from adcloud_server.identity_models import StitchRequest
from adcloud_server.identity_service import IdentityService
from adcloud_server.main import create_app
from adcloud_server.settings import Settings
SECRET = "identity-test-secret-" + "x" * 32
SERVICE_TOKEN = "trusted-test-token-" + "y" * 32
AUTH = {"Authorization": "Bearer " + SERVICE_TOKEN}
@pytest.fixture
def settings(tmp_path):
return Settings(
database_url="sqlite:///" + str(tmp_path / "identity.db"),
identity_secret=SECRET,
service_token=SERVICE_TOKEN,
cookie_secure=False,
)
@pytest.fixture
def client(settings):
with TestClient(create_app(settings)) as test_client:
yield test_client
def anonymous(client):
response = client.post("/v1/identity/anonymous")
assert response.status_code == 200, response.text
return response.json()
def stitch(client, identity, token=None, **kwargs):
body = {"identifiers": identity, **kwargs}
if token:
body["anonymous_token"] = token
response = client.post("/v1/identity/stitch", json=body, headers=AUTH)
assert response.status_code == 200, response.text
return response.json()
def email(value="alice@example.com"):
return {"kind": "email", "value": value}
def profile(client, master):
response = client.get("/v1/users/" + master, headers=AUTH)
assert response.status_code == 200, response.text
return response.json()
def history(client, master):
response = client.get("/v1/events", params={"master_id": master}, headers=AUTH)
assert response.status_code == 200, response.text
return response.json()
def test_first_event_seeds_persistent_cookie_and_reuses_it(client):
first = client.post(
"/v1/events", json={"name": "page.view", "properties": {"path": "/shoes"}}
)
assert first.status_code == 200
assert "HttpOnly" in first.headers["set-cookie"]
assert "Max-Age=31536000" in first.headers["set-cookie"]
assert "SameSite=lax" in first.headers["set-cookie"]
assert first.headers["cache-control"] == "no-store"
second = anonymous(client)
assert first.json()["anonymous_id"] == second["anonymous_id"]
assert profile(client, second["master_id"])["event_count"] == 1
def test_cross_device_stitch_inherits_events_sparse_traits_and_tags(client):
known = anonymous(client)
stitch(client, [email()], attributes={"preferred_color": "blue"}, tags=["member"])
client.post("/v1/identity/anonymous?rotate=true")
visitor = anonymous(client)
client.patch(
"/v1/users/" + visitor["master_id"],
headers=AUTH,
json={
"attributes": {
"interest": "running",
"missing": None,
"nested": {"present": 1, "empty": None},
"preferred_color": "red",
},
"tags": ["runner"],
},
)
event = client.post(
"/v1/events", json={"name": "product.view", "properties": {"sku": "shoe-1"}}
).json()
result = stitch(client, [email()])
assert result["master_id"] == known["master_id"]
assert visitor["master_id"] in result["merged_master_ids"]
assert result["rotated_anonymous_id"] is False
merged = profile(client, result["master_id"])
assert merged["attributes"] == {
"preferred_color": "blue",
"interest": "running",
"nested": {"present": 1},
}
assert merged["tags"] == ["member", "runner"]
assert merged["event_count"] == 1
assert history(client, visitor["master_id"])["items"][0]["id"] == event["event_id"]
graph = client.get(
"/v1/identity/" + result["master_id"] + "/graph", headers=AUTH
).json()
assert len(graph["member_ids"]) == 2
assert len(graph["edges"]) == 1
assert graph["identifiers"][0]["display"] == "a***@example.com"
def test_transitive_verified_bridge_resolves_every_alias(client):
anonymous(client)
a = stitch(client, [email()])
client.post("/v1/identity/anonymous?rotate=true")
phone = {"kind": "phone", "value": "+1 (415) 555-2671"}
b = stitch(client, [phone])
client.post("/v1/identity/anonymous?rotate=true")
oauth = {
"kind": "oauth",
"namespace": "https://issuer.example/client-a",
"value": "subject-123",
}
c = stitch(client, [oauth])
joined = stitch(client, [email(), phone, oauth])
assert joined["master_id"] == a["master_id"]
for old in [a, b, c]:
assert profile(client, old["master_id"])["master_id"] == a["master_id"]
resolved = client.post(
"/v1/identity/resolve",
headers=AUTH,
json={
"identifiers": [email(), {"kind": "phone", "value": "+14155552671"}, oauth]
},
).json()
assert resolved == {
"master_id": a["master_id"],
"matches": [a["master_id"]],
"ambiguous": False,
}
def test_shared_browser_switches_accounts_without_merging_them(client):
a = stitch_after_anonymous(client, email())
client.post("/v1/events", json={"name": "alice.view"})
b = stitch(client, [email("bob@example.com")])
assert b["rotated_anonymous_id"] is True
assert a["master_id"] != b["master_id"]
assert profile(client, b["master_id"])["event_count"] == 0
assert profile(client, a["master_id"])["event_count"] == 1
def stitch_after_anonymous(client, identifier):
anonymous(client)
return stitch(client, [identifier])
def test_authenticated_backend_can_add_new_identity_to_current_account(client):
a = stitch_after_anonymous(client, email())
result = stitch(
client,
[{"kind": "phone", "value": "+14155552671"}],
account_master_id=a["master_id"],
)
assert result["master_id"] == a["master_id"]
assert result["rotated_anonymous_id"] is False
assert (
len(
client.get(
"/v1/identity/" + a["master_id"] + "/graph", headers=AUTH
).json()["identifiers"]
)
== 2
)
def test_namespace_isolates_third_party_subject_ids(client):
first = {"kind": "oauth", "namespace": "issuer/client-a", "value": "same-subject"}
second = {"kind": "oauth", "namespace": "issuer/client-b", "value": "same-subject"}
a = stitch_after_anonymous(client, first)
client.post("/v1/identity/anonymous?rotate=true")
b = stitch(client, [second])
assert a["master_id"] != b["master_id"]
resolved = client.post(
"/v1/identity/resolve", headers=AUTH, json={"identifiers": [first, second]}
).json()
assert resolved["ambiguous"] is True
assert resolved["master_id"] is None
def test_email_domain_normalization_preserves_case_sensitive_local_parts(client):
first = stitch_after_anonymous(client, email("Alice@EXAMPLE.com"))
resolved = client.post(
"/v1/identity/resolve",
headers=AUTH,
json={"identifiers": [email("Alice@example.com")]},
).json()
assert resolved["master_id"] == first["master_id"]
different = client.post(
"/v1/identity/resolve",
headers=AUTH,
json={"identifiers": [email("alice@example.com")]},
).json()
assert different["master_id"] is None
def test_stitch_requires_trusted_backend_and_signed_anonymous_proof(client):
anon = anonymous(client)
response = client.post("/v1/identity/stitch", json={"identifiers": [email()]})
assert response.status_code == 401
response = client.post(
"/v1/identity/stitch",
headers=AUTH,
json={"identifiers": [email()], "anonymous_token": anon["anonymous_id"]},
)
assert response.status_code == 401
response = client.get("/v1/users/" + anon["master_id"])
assert response.status_code == 401
response = client.post(
"/v1/identity/stitch",
headers=AUTH,
json={"identifiers": [{**email(), "verified": True}]},
)
assert response.status_code == 422
@pytest.mark.parametrize(
"identifier",
[
email("not-an-email"),
{"kind": "phone", "value": "4155552671"},
{"kind": "phone", "value": "+10000000000"},
{"kind": "oauth", "value": "subject"},
{"kind": "maid", "value": "device-id"},
],
)
def test_invalid_or_weak_identifiers_cannot_create_edges(client, identifier):
anon = anonymous(client)
response = client.post(
"/v1/identity/stitch", headers=AUTH, json={"identifiers": [identifier]}
)
assert response.status_code == 422
assert (
client.get("/v1/identity/" + anon["master_id"] + "/graph", headers=AUTH).json()[
"identifiers"
]
== []
)
def test_browser_origin_and_credentials(client):
denied = client.post(
"/v1/events",
headers={"Origin": "https://untrusted.example"},
json={"name": "page.view"},
)
assert denied.status_code == 403
preflight = client.options(
"/v1/events",
headers={
"Origin": "http://localhost:5174",
"Access-Control-Request-Method": "POST",
},
)
assert preflight.status_code == 200
assert preflight.headers["access-control-allow-credentials"] == "true"
assert preflight.headers["access-control-allow-origin"] == "http://localhost:5174"
def test_event_replay_is_idempotent_and_cannot_steal_another_subjects_event(client):
event_id = str(uuid4())
body = {"event_id": event_id, "name": "page.view"}
first = client.post("/v1/events", json=body).json()
replay = client.post("/v1/events", json=body).json()
assert replay["duplicate"] is True
assert profile(client, first["master_id"])["event_count"] == 1
client.post("/v1/identity/anonymous?rotate=true")
assert client.post("/v1/events", json=body).status_code == 409
assert (
client.post(
"/v1/events",
json={"name": "page.view", "occurred_at": "2020-01-01T00:00:00"},
).status_code
== 422
)
def test_profile_patch_omits_nulls_and_deletes_explicitly(client):
anon = anonymous(client)
client.patch(
"/v1/users/" + anon["master_id"],
headers=AUTH,
json={"attributes": {"keep": 1, "remove": "later"}},
)
result = client.patch(
"/v1/users/" + anon["master_id"],
headers=AUTH,
json={"attributes": {"remove": None}},
).json()
assert result["attributes"] == {"keep": 1}
def test_persistence_survives_restart(settings):
with TestClient(create_app(settings)) as first:
anon = anonymous(first)
first.post("/v1/events", json={"name": "page.view"})
stitched = stitch(first, [email()])
with TestClient(create_app(settings)) as second:
recorded = second.post(
"/v1/events",
json={"name": "page.return", "anonymous_token": anon["anonymous_token"]},
).json()
assert recorded["master_id"] == stitched["master_id"]
assert profile(second, stitched["master_id"])["event_count"] == 2
service = second.app.state.identity_service
with service.db.transaction() as conn:
stored = conn.execute(select(identifiers)).mappings().one()
assert "alice" not in stored["digest"]
assert "value" not in stored
def test_tenants_cannot_resolve_or_reuse_each_others_ids(settings):
other = Settings(
database_url=settings.database_url,
tenant_id="other",
identity_secret=SECRET,
service_token=SERVICE_TOKEN,
cookie_secure=False,
)
with (
TestClient(create_app(settings)) as first,
TestClient(create_app(other)) as second,
):
a = stitch_after_anonymous(first, email())
b = stitch_after_anonymous(second, email())
assert a["master_id"] != b["master_id"]
assert (
second.get("/v1/users/" + a["master_id"], headers=AUTH).status_code == 404
)
assert (
second.post(
"/v1/events",
json={"name": "forged", "anonymous_token": a["anonymous_token"]},
).status_code
== 401
)
def test_parallel_workers_stitch_same_email_into_one_master(settings):
databases = [Database(settings.database_url, settings.tenant_id) for _ in range(2)]
for db in databases:
db.initialize()
services = [IdentityService(settings, db) for db in databases]
tokens = [service.anonymous()["anonymous_token"] for service in services]
request = StitchRequest(identifiers=[email()])
try:
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(
pool.map(lambda i: services[i].stitch(tokens[i], request), [0, 1])
)
assert results[0]["master_id"] == results[1]["master_id"]
assert len(services[0].graph(results[0]["master_id"])["identifiers"]) == 1
finally:
for db in databases:
db.close()
def test_merge_limit_failure_rolls_back_all_alias_and_profile_changes(client):
a = stitch_after_anonymous(client, email())
client.patch(
"/v1/users/" + a["master_id"],
headers=AUTH,
json={"attributes": {"a" + str(i): i for i in range(150)}},
)
client.post("/v1/identity/anonymous?rotate=true")
b = anonymous(client)
client.patch(
"/v1/users/" + b["master_id"],
headers=AUTH,
json={"attributes": {"b" + str(i): i for i in range(150)}},
)
result = client.post(
"/v1/identity/stitch", headers=AUTH, json={"identifiers": [email()]}
)
assert result.status_code == 409
assert profile(client, b["master_id"])["master_id"] == b["master_id"]
assert len(profile(client, a["master_id"])["attributes"]) == 150
assert len(profile(client, b["master_id"])["attributes"]) == 150
def test_missing_configuration_disables_identity_but_keeps_gateway_available(tmp_path):
with TestClient(
create_app(Settings(database_url="sqlite:///" + str(tmp_path / "unused.db")))
) as client:
assert client.get("/health").status_code == 200
assert client.post("/v1/identity/anonymous").status_code == 503
assert (
client.post(
"/v1/identity/stitch", json={"identifiers": [email()]}
).status_code
== 503
)
assert not (tmp_path / "unused.db").exists()
def test_secure_cookie_configuration():
with TestClient(
create_app(Settings(database_url="sqlite://", identity_secret=SECRET)),
base_url="https://testserver",
) as client:
result = client.post("/v1/identity/anonymous")
assert "Secure" in result.headers["set-cookie"]
assert anonymous(client)["master_id"] == result.json()["master_id"]
with pytest.raises(ValueError):
Settings(cookie_secure=False, cookie_samesite="none")
with pytest.raises(ValueError):
Settings(profile_backend="unknown")
def test_parallel_workers_initialize_empty_database_safely(settings):
databases = [Database(settings.database_url, settings.tenant_id) for _ in range(2)]
try:
with ThreadPoolExecutor(max_workers=2) as pool:
list(pool.map(lambda db: db.initialize(), databases))
assert IdentityService(settings, databases[0]).anonymous()["master_id"]
finally:
for db in databases:
db.close()
+105
View File
@@ -0,0 +1,105 @@
"""Optional real PostgreSQL integration: set ADCLOUD_TEST_POSTGRES_URL."""
import os
from concurrent.futures import ThreadPoolExecutor
from uuid import uuid4
import pytest
from sqlalchemy import text
from adcloud_server.database import Database
from adcloud_server.identity_models import EventRequest, ProfilePatch, StitchRequest
from adcloud_server.identity_service import IdentityService
from adcloud_server.settings import Settings
pytestmark = pytest.mark.skipif(
not os.getenv("ADCLOUD_TEST_POSTGRES_URL"),
reason="No PostgreSQL integration URL configured",
)
@pytest.fixture
def service():
settings = Settings(
database_url=os.environ["ADCLOUD_TEST_POSTGRES_URL"],
tenant_id="test-" + str(uuid4()),
identity_secret="postgres-test-" + "x" * 48,
profile_backend="cassandra",
)
db = Database(settings.database_url, settings.tenant_id)
db.initialize()
try:
yield IdentityService(settings, db)
finally:
db.close()
def test_postgres_jsonb_stitch_and_immediate_history(service):
known = service.anonymous()
identity = StitchRequest(
identifiers=[{"kind": "email", "value": "alice@example.com"}]
)
service.stitch(known["anonymous_token"], identity)
visitor = service.anonymous(rotate=True)
service.patch_profile(
visitor["master_id"],
ProfilePatch(
attributes={"nested": {"interest": "running"}, "absent": None},
tags=["runner"],
),
)
event = service.record_event(
visitor["anonymous_token"], EventRequest(name="product.view")
)
stitched = service.stitch(visitor["anonymous_token"], identity)
assert stitched["master_id"] == known["master_id"]
assert service.profile(known["master_id"])["attributes"] == {
"nested": {"interest": "running"}
}
assert service.history(visitor["master_id"])["items"][0]["id"] == event["event_id"]
with service.db.transaction() as conn:
result = conn.execute(
text(
"SELECT pg_typeof(document)::text FROM identity_profiles WHERE tenant_id=:tenant LIMIT 1"
),
{"tenant": service.tenant},
).scalar_one()
assert result == "jsonb"
def test_postgres_multiple_process_connections_serialize_identity_merges(service):
second_db = Database(service.settings.database_url, service.tenant)
other = IdentityService(service.settings, second_db)
tokens = [
service.anonymous()["anonymous_token"],
other.anonymous()["anonymous_token"],
]
instances = [service, other]
request = StitchRequest(identifiers=[{"kind": "phone", "value": "+14155552671"}])
try:
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(
pool.map(lambda i: instances[i].stitch(tokens[i], request), [0, 1])
)
assert results[0]["master_id"] == results[1]["master_id"]
assert len(service.graph(results[0]["master_id"])["identifiers"]) == 1
finally:
second_db.close()
def test_postgres_failed_transaction_rolls_back_graph_and_outbox(service, monkeypatch):
visitor = service.anonymous()
def fail(*args):
raise OSError("Simulated outbox failure")
monkeypatch.setattr(service, "_queue", fail)
with pytest.raises(OSError):
service.stitch(
visitor["anonymous_token"],
StitchRequest(
identifiers=[{"kind": "email", "value": "alice@example.com"}]
),
)
assert service.graph(visitor["master_id"])["identifiers"] == []
assert service.profile(visitor["master_id"])["revision"] == 1
+146
View File
@@ -0,0 +1,146 @@
import copy
from dataclasses import replace
import pytest
from sqlalchemy import select
from sqlalchemy.dialects import postgresql
from sqlalchemy.schema import CreateTable
from adcloud_server.database import Database, outbox, profiles
from adcloud_server.identity_models import ProfilePatch, StitchRequest
from adcloud_server.identity_service import IdentityService
from adcloud_server.projection import ProfileProjector
from adcloud_server.settings import Settings
class RecoverableStore:
def __init__(self):
self.snapshots = {}
self.unavailable = False
def put(self, tenant, master, revision, document):
if self.unavailable:
raise OSError("Simulated storage outage")
self.snapshots[tenant, master, revision] = copy.deepcopy(document)
def get(self, tenant, master, revision):
if self.unavailable:
raise OSError("Simulated storage outage")
return self.snapshots.get((tenant, master, revision))
@pytest.fixture
def components(tmp_path):
settings = Settings(
database_url="sqlite:///" + str(tmp_path / "profiles.db"),
identity_secret="x" * 48,
profile_backend="cassandra",
)
db = Database(settings.database_url, settings.tenant_id)
db.initialize()
store = RecoverableStore()
service = IdentityService(settings, db, store)
try:
yield service, ProfileProjector(db, store), store
finally:
db.close()
def test_wide_table_outage_preserves_immediate_stitched_profile_and_retries(components):
service, worker, store = components
anon = service.anonymous()
result = service.stitch(
anon["anonymous_token"],
StitchRequest(
identifiers=[{"kind": "email", "value": "alice@example.com"}],
attributes={"interest": "running"},
tags=["runner"],
),
)
assert service.profile(result["master_id"])["projection_status"] == "pending"
store.unavailable = True
assert service.profile(result["master_id"])["attributes"] == {"interest": "running"}
with pytest.raises(OSError):
worker.flush()
with service.db.transaction() as conn:
pending = (
conn.execute(select(outbox).where(outbox.c.delivered == 0)).mappings().all()
)
assert any(job["revision"] == result["revision"] for job in pending)
store.unavailable = False
worker.flush()
current = service.profile(result["master_id"])
assert current["projection_status"] == "current"
assert current["tags"] == ["runner"]
assert worker.flush() == 0
def test_out_of_order_projection_cannot_resurrect_deleted_attributes(components):
service, worker, store = components
anon = service.anonymous()
first = service.patch_profile(
anon["master_id"], ProfilePatch(attributes={"remove": "old", "keep": 1})
)
store.put(
service.tenant,
anon["master_id"],
first["revision"],
{"attributes": first["attributes"], "tags": []},
)
last = service.patch_profile(
anon["master_id"], ProfilePatch(attributes={"remove": None})
)
# A stale snapshot never satisfies the latest revision lookup.
assert service.profile(anon["master_id"])["attributes"] == {"keep": 1}
assert service.profile(anon["master_id"])["projection_status"] == "pending"
worker.flush()
assert service.profile(anon["master_id"])["revision"] == last["revision"]
assert service.profile(anon["master_id"])["attributes"] == {"keep": 1}
def test_relational_to_wide_table_switch_backfills_existing_profiles(components):
service, worker, _store = components
relational = IdentityService(
replace(service.settings, profile_backend="relational"), service.db
)
anon = relational.anonymous()
relational.patch_profile(
anon["master_id"], ProfilePatch(attributes={"nested": {"count": 3}})
)
with service.db.transaction() as conn:
assert conn.execute(select(outbox)).first() is None
assert worker.backfill() == 1
worker.flush()
assert service.profile(anon["master_id"])["projection_status"] == "current"
assert service.profile(anon["master_id"])["attributes"] == {"nested": {"count": 3}}
def test_merge_failure_never_leaves_partial_outbox_records(components, monkeypatch):
service, worker, _store = components
a = service.anonymous()
service.stitch(
a["anonymous_token"],
StitchRequest(identifiers=[{"kind": "email", "value": "alice@example.com"}]),
)
b = service.anonymous(rotate=True)
worker.flush()
def fail(*args):
raise OSError("Simulated transaction failure")
monkeypatch.setattr(service, "_queue", fail)
with pytest.raises(OSError):
service.stitch(
b["anonymous_token"],
StitchRequest(
identifiers=[{"kind": "email", "value": "alice@example.com"}]
),
)
assert service.profile(b["master_id"])["master_id"] == b["master_id"]
assert worker.flush() == 0
assert len(service.graph(a["master_id"])["member_ids"]) == 1
def test_postgres_document_column_is_jsonb():
ddl = str(CreateTable(profiles).compile(dialect=postgresql.dialect()))
assert "document JSONB NOT NULL" in ddl
Generated
+362 -20
View File
@@ -2,6 +2,54 @@ version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "adcloud-server"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "email-validator" },
{ name = "fastapi" },
{ name = "phonenumbers" },
{ name = "python-dotenv" },
{ name = "sqlalchemy" },
{ name = "uvicorn" },
]
[package.optional-dependencies]
cassandra = [
{ name = "cassandra-driver" },
]
postgres = [
{ name = "psycopg", extra = ["binary"] },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "cassandra-driver", marker = "extra == 'cassandra'", specifier = ">=3.29,<4" },
{ name = "email-validator", specifier = ">=2.2,<3" },
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "phonenumbers", specifier = ">=9,<10" },
{ name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2,<4" },
{ name = "python-dotenv", specifier = ">=1,<2" },
{ name = "sqlalchemy", specifier = ">=2.0,<3" },
{ name = "uvicorn", specifier = ">=0.30,<1" },
]
provides-extras = ["postgres", "cassandra"]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.27,<1" },
{ name = "pytest", specifier = ">=8,<10" },
{ name = "ruff", specifier = ">=0.9,<1" },
]
[[package]]
name = "annotated-doc"
version = "0.0.5"
@@ -33,6 +81,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "cassandra-driver"
version = "3.30.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "geomet" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ed/4e16210e194660f107929ee494f1cf18557252655067d9b39029c241be2d/cassandra_driver-3.30.1.tar.gz", hash = "sha256:0c6a3e1428f7c6a9aa6c944b9c47a37cd2cdbeb5b5a82d42c33afdd56f14e398", size = 289233, upload-time = "2026-07-06T20:14:31.37Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/d4/8a80155cfe1312bb5d750e5a7b0384a1346fde2ca07cbff869f6627db071/cassandra_driver-3.30.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9ff317f79b09eba52a44d46d8c4773e47533dfdd8b9ba4984268dc4a1fe5206e", size = 3649965, upload-time = "2026-07-06T20:13:35.813Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ed/d9bca63e72e8219243e75821e72ace3046da0fe9a8b22375cf0c0efff427/cassandra_driver-3.30.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab853bd2806899c5612c3e4ac2a355a7811084d06692e4460255e3b0a4fa556a", size = 3475640, upload-time = "2026-07-06T20:13:39.842Z" },
{ url = "https://files.pythonhosted.org/packages/a8/43/9bc21a9a3881c7eb9c19d0a6af8331a3fda0f18e82be83b49330f072d546/cassandra_driver-3.30.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e10784b4f7d77332dc1a5c72855977a0d797160fd8ba50785414caed07fce95b", size = 3226293, upload-time = "2026-07-06T20:13:43.675Z" },
{ url = "https://files.pythonhosted.org/packages/89/c1/d050e11c324c0d0be7213947bed7c1cb65ef50ab4f12b3e881614217fce2/cassandra_driver-3.30.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af5c6f5d7d5a86725b908aba12d9554df60234c15bfe053a8175b15db791ada8", size = 3483718, upload-time = "2026-07-06T20:13:48.038Z" },
{ url = "https://files.pythonhosted.org/packages/49/ee/779b732de46608018ac90613a5fe4954a4901c6efa19f38ab2213e9cf4ef/cassandra_driver-3.30.1-cp312-cp312-win_amd64.whl", hash = "sha256:e43d873de5f8dadf4c8dcc4fab87d66a8495137e844ae3c184fbc90a54c05bc0", size = 2662678, upload-time = "2026-07-06T20:13:51.566Z" },
{ url = "https://files.pythonhosted.org/packages/23/4f/13533a1f642213d84ae5896b85f71d0eb1b3449647ecd8a0127adba0a7f8/cassandra_driver-3.30.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0ea769cf9925206b8458db10477b78699660fbdaed7262157e43b8f21817f838", size = 3636690, upload-time = "2026-07-06T20:13:55.901Z" },
{ url = "https://files.pythonhosted.org/packages/54/dc/20972270725376e920d333abfb89260ebf1c1d68491e1464a064061040a2/cassandra_driver-3.30.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f43abccb5b5a761582e4ba6d44a7ef4274b61b0bd3f8e3159af40ca2c10c0fa", size = 3466913, upload-time = "2026-07-06T20:14:00.199Z" },
{ url = "https://files.pythonhosted.org/packages/e4/bd/1b9d30155656ce660838440b72d1e85b3e4301d5e6facaf327a83aeca96a/cassandra_driver-3.30.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec04eebfadc74039b1f7df84356a822539992c7dc0b013fedbb5a33d9295af86", size = 3220329, upload-time = "2026-07-06T20:14:04.196Z" },
{ url = "https://files.pythonhosted.org/packages/f8/df/569fb36b5b9810c750c03802be23afaea52099b55e9f47379f1ced180b97/cassandra_driver-3.30.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0995dccff3df739a1df6e10da9f3fc95bbbc8b38bacc4cfb6172655f8351cba", size = 3476643, upload-time = "2026-07-06T20:14:07.848Z" },
{ url = "https://files.pythonhosted.org/packages/d3/af/a6eab42d55ac693f105113651951cad6830cd1230434eb8c2f241d184796/cassandra_driver-3.30.1-cp313-cp313-win_amd64.whl", hash = "sha256:eba7931a615207aab84ebaa000330024f1c614aa86ca58d119556edd338883ce", size = 2665168, upload-time = "2026-07-06T20:14:10.952Z" },
{ url = "https://files.pythonhosted.org/packages/2c/52/2c06e18f3d4c4fe3263a5e6cc1e747b592b9d2d2546e20add7dac43dc1b6/cassandra_driver-3.30.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:87822d6cce2c586aa72733a9dd1858276cee795834f7ad949c783bf19cf7a3a7", size = 3618485, upload-time = "2026-07-06T20:14:14.863Z" },
{ url = "https://files.pythonhosted.org/packages/16/a5/ce2fc121344a55af03800cf388d2672f8eae9ebe0b5d3143417b4c5585a0/cassandra_driver-3.30.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb90ec9ac9d44cbfe17f1e9cb643dbc5f401e62980835aa6669a2fd5792863b", size = 3446763, upload-time = "2026-07-06T20:14:18.661Z" },
{ url = "https://files.pythonhosted.org/packages/19/b5/0b9c71f8e5255905df92487ca5c366f397fd5806e369fc804ab55e3e02cc/cassandra_driver-3.30.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921403aa76c66d78d00c4a3dff542f989fa584b7450e3cb1dd2c267b4a5a55f0", size = 3277349, upload-time = "2026-07-06T20:14:22.387Z" },
{ url = "https://files.pythonhosted.org/packages/c3/b9/d1ce6bc0c990005ab45ac984aa7f3b93c7fd2ba2b83d5a9b02df6c134481/cassandra_driver-3.30.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61bf2ded460fb0d2e909b70c9d193588b71148540bd85e29fe6954e00bb1a869", size = 3496154, upload-time = "2026-07-06T20:14:26.295Z" },
{ url = "https://files.pythonhosted.org/packages/43/e9/c8aabe3f2b7ab862c2741b67a3296ce8ae677b85cb53ce61773b909b37ba/cassandra_driver-3.30.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c32513a2f2c6832d4fe3464e11be59dc21096dba296b18ec3454bc8354a78ba", size = 2716997, upload-time = "2026-07-06T20:14:29.651Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
@@ -61,32 +136,37 @@ wheels = [
]
[[package]]
name = "adcloud-server"
version = "0.1.0"
source = { editable = "." }
name = "deprecated"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
{ name = "uvicorn" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[package.dev-dependencies]
dev = [
{ name = "httpx" },
{ name = "pytest" },
{ name = "ruff" },
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "uvicorn", specifier = ">=0.30,<1" },
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.27,<1" },
{ name = "pytest", specifier = ">=8,<10" },
{ name = "ruff", specifier = ">=0.9,<1" },
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
@@ -105,6 +185,73 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]]
name = "geomet"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/8c/dde022aa6747b114f6b14a7392871275dea8867e2bd26cddb80cc6d66620/geomet-1.1.0.tar.gz", hash = "sha256:51e92231a0ef6aaa63ac20c443377ba78a303fd2ecd179dc3567de79f3c11605", size = 28732, upload-time = "2023-11-14T15:43:36.764Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/90/3bc780df088d439714af8295196a4332a26559ae66fd99865e36f92efa9e/geomet-1.1.0-py3-none-any.whl", hash = "sha256:4372fe4e286a34acc6f2e9308284850bd8c4aa5bc12065e2abbd4995900db12f", size = 31522, upload-time = "2023-11-14T15:43:35.305Z" },
]
[[package]]
name = "greenlet"
version = "3.5.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" },
{ url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" },
{ url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" },
{ url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" },
{ url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" },
{ url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" },
{ url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" },
{ url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" },
{ url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" },
{ url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" },
{ url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" },
{ url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" },
{ url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" },
{ url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" },
{ url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" },
{ url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" },
{ url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" },
{ url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" },
{ url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" },
{ url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" },
{ url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" },
{ url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" },
{ url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" },
{ url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" },
{ url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" },
{ url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" },
{ url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" },
{ url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" },
{ url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" },
{ url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" },
{ url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" },
{ url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" },
{ url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" },
{ url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" },
{ url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" },
{ url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -169,6 +316,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "phonenumbers"
version = "9.0.39"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/d9/f73babd005dd7569a81789f4db9cc82efaf001af018d5752bf6d7ea14e19/phonenumbers-9.0.39.tar.gz", hash = "sha256:41a7ff0257d5a7a096a8e263da7efc0a96a6162c84ba64af2ebf8972d09e70e1", size = 2310991, upload-time = "2026-09-10T11:26:52.485Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/53/238c99f028b9c638ceae8218a33904e4b84d60e1c3bd707c0ff231b388cf/phonenumbers-9.0.39-py2.py3-none-any.whl", hash = "sha256:d83b878d0188c81a5f5e8572cedcc7262bd0ea3806eb7c8e35d8666d262a8af0", size = 2594744, upload-time = "2026-09-10T11:26:49.561Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -178,6 +334,64 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psycopg"
version = "3.3.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/73/8fb739d0f6bba247b9b93c9840c402a4f88545be5f1d4b02b23366371c00/psycopg-3.3.5.tar.gz", hash = "sha256:d0a3d9ccf5788af054cbd745278cb02401b5c312aeaafbf2c6144460aec47da4", size = 166508, upload-time = "2026-08-31T22:45:43.151Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/2e/d0a645bcaadde68bd6d93c43f02f14b0191bdda367ce3f7722abe3da744a/psycopg-3.3.5-py3-none-any.whl", hash = "sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e", size = 213598, upload-time = "2026-08-31T22:39:02.184Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.5"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/83/ba396428a4fb6b70f0dd41315ad86a2c14441b7214afd47b2d49cb450b78/psycopg_binary-3.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25105f9b46bdf2a30fcb67f56976ed66f6855941ae16bc024192609b917d493c", size = 4700169, upload-time = "2026-08-31T22:41:39.712Z" },
{ url = "https://files.pythonhosted.org/packages/4f/56/b23c5978e55cf4effdc5a3e13a17d69a580a487993e01b7c3768dd24281c/psycopg_binary-3.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0249c3e960cdee686000eb77169fb6590105c05bacc37e057ccdffdcd8e6ebde", size = 4763037, upload-time = "2026-08-31T22:41:47.571Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d8/b41108bfe194b4098076c8b873b05ec2ca9582445eccfd9075970d7b948e/psycopg_binary-3.3.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5698ab5941a4d138c30fef858588e651fe7d583280cd6e41832825ad9e747750", size = 5546232, upload-time = "2026-08-31T22:41:55.817Z" },
{ url = "https://files.pythonhosted.org/packages/21/d1/0f244dfef389e52e9dc3056f2a9033d1f6901e97d24d9a9c8b836e32ab6b/psycopg_binary-3.3.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:682a17a57415c3ca1731eec018ed031f012ffcb81ba74806eb219cb396065672", size = 5227752, upload-time = "2026-08-31T22:42:03.776Z" },
{ url = "https://files.pythonhosted.org/packages/31/88/a4781365f09807fb91435e2d00096f3f6ae5d06bce15ef3c3c385dc22772/psycopg_binary-3.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2a61e8147902771df7efe14062a3c8736347850d0d8befcf048235752504f2e", size = 6824658, upload-time = "2026-08-31T22:42:13.263Z" },
{ url = "https://files.pythonhosted.org/packages/65/f1/072c4a46287644694731b3e40fab120ebacf6d153cce7ebf7a5b208f5561/psycopg_binary-3.3.5-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f7e1e45aad410e20de45df2b159df68ff6c8dbf47a3501f806c4489b27f4ad2b", size = 5061439, upload-time = "2026-08-31T22:42:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/e2/d6/1776a95c16941b8bbce89407cc7cf9ea3fd557efb503a40873c9e2b6394f/psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c09775c549b40b274206e1b043c5e5b5af39666e85c98382a30bd05d23ab677b", size = 4588586, upload-time = "2026-08-31T22:42:25.23Z" },
{ url = "https://files.pythonhosted.org/packages/82/64/44ec87b9a74faebe966856307b65c0d35ee6321ce509cdd0e8d6a3f5338b/psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cf0e5e63ee86098299c673992053d556c489ba9ae6aca6cb6e24d16a8e0b09e6", size = 4265161, upload-time = "2026-08-31T22:42:31.864Z" },
{ url = "https://files.pythonhosted.org/packages/24/88/cf181df5651395a80afc520f5fefac72beb035c0c9d58ab7fcc880c9b221/psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c065531e8c1815276f50dbfa283e3a7f022671414cdda6fa9a16794dd53b28f9", size = 3998727, upload-time = "2026-08-31T22:42:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/43/1e/c72a1107647db4bb3f534c7fe1b57b1957d9c099e795f5aa81f4a2e0c312/psycopg_binary-3.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:df9853b832b7b916e02ef68e0d5403a7dab2d5c1ddfe94f22b1b155eb862622f", size = 4310119, upload-time = "2026-08-31T22:42:46.403Z" },
{ url = "https://files.pythonhosted.org/packages/03/8d/452620608cafff164737e20b42ebffee8151b865ee171ba0d6692a560a44/psycopg_binary-3.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:35885e333020fc152d27bea1a494bef13b2e68f6fd92b6229015e93539152008", size = 3648197, upload-time = "2026-08-31T22:42:51.575Z" },
{ url = "https://files.pythonhosted.org/packages/e0/1c/e718752cc63cf4e99e4a10fd36e3a3364dabdd0819484a24c0d79fbb9685/psycopg_binary-3.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e85d50b87257fb117675a19ee59daa7bf9a57f6431500adf7059df799232ef4", size = 4704421, upload-time = "2026-08-31T22:42:59.564Z" },
{ url = "https://files.pythonhosted.org/packages/af/cf/a0e748e27c09b92738e4460582d121ba1908be3e36791e150f435e54b332/psycopg_binary-3.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5becd311f9af8d180bad372f51fb2252fd02cb2073056e2b170c9274f95fe7f", size = 4765054, upload-time = "2026-08-31T22:43:05.421Z" },
{ url = "https://files.pythonhosted.org/packages/39/62/0cbac0266d56c94dd1f702d9af2b5d54bf80b6e658048f4f2c5bd63dd7e3/psycopg_binary-3.3.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:19e5bf9872dbd164c220567fd385ba2309c7d9df1541f78343510c6b0f36a1b7", size = 5547137, upload-time = "2026-08-31T22:43:11.768Z" },
{ url = "https://files.pythonhosted.org/packages/fb/3a/73c6f8871f38fc07a9c0b4cbc9467beb116a2783cecf87dfa53900a396dc/psycopg_binary-3.3.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb3b3bffebfe07110730626e76238161124f35ac87b748d663316a28d22f58b0", size = 5227577, upload-time = "2026-08-31T22:43:20.767Z" },
{ url = "https://files.pythonhosted.org/packages/59/7b/9f17b9f4d297b774dc574199c4dcd02dadc32a00c4056265918de5c70635/psycopg_binary-3.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2111f880add40fb03c60556069ad68e884a0908a74d2debafc603caf93b73552", size = 6824606, upload-time = "2026-08-31T22:43:33.698Z" },
{ url = "https://files.pythonhosted.org/packages/94/86/d84dadd94a004dbbb43ce0579f1f766fdc6b8cbef745090e24bea77b5283/psycopg_binary-3.3.5-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:40505676b1526b9ea387dace034040a8c8b0bcf984cd6bd4720a2ab15e813586", size = 5060854, upload-time = "2026-08-31T22:43:42.258Z" },
{ url = "https://files.pythonhosted.org/packages/30/d0/e5078be2c7d2490c0d6cd4cb7b3601aec44be2fa8b3fe927e9419b06004f/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5816472e3bb05615f33a741e0835043d1f4bf9709ff30d2f4aed71815cfc6b5e", size = 4589511, upload-time = "2026-08-31T22:43:50.012Z" },
{ url = "https://files.pythonhosted.org/packages/f1/01/08dfb5b18fa482e025864fd91a022310d0782c42e4cf5dda5d0e010b6790/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:358748fc4c8ccdc0e2bdf55420494930e19c3ade586ea9c3a6de3dad1f897311", size = 4268144, upload-time = "2026-08-31T22:43:56.993Z" },
{ url = "https://files.pythonhosted.org/packages/88/b9/cb01dc1d63f3241b49b2ca7fb9f98d0f5c76127f0dbb9440635a6ad0233e/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1ef2e498be47800f6202b9a2304c22646325ca6d54001b7c785bcfdb24a1e8ab", size = 4001036, upload-time = "2026-08-31T22:44:04.429Z" },
{ url = "https://files.pythonhosted.org/packages/95/49/7c17dd832c05b380562ff2ff5f6ab2bcaeca8b7fff2c2b355854368a5bdb/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88e01aa2e938a45655a8a5213fc3a44ba78cb4cab8a569b3e0bcb3d1d0eaba16", size = 4313112, upload-time = "2026-08-31T22:44:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/7c/2c/b0b2f887185d6a2ec0b3bef948cc07656d2d1a5d96fa7f2bb03f6ef06ca4/psycopg_binary-3.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:ba466011569297114449df9d523438e1adeedf3e4f31ffb78e897ec3fef3076b", size = 3647313, upload-time = "2026-08-31T22:44:19.139Z" },
{ url = "https://files.pythonhosted.org/packages/46/7f/4e2395da194558533bd9c31f35e4dc58ecbbae6a7176b0d2f72d629e8a51/psycopg_binary-3.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f8b132c7243ef5f503f0b6f986bf16d38a51b0df1c6ba2577743f128be03e3", size = 4712612, upload-time = "2026-08-31T22:44:25.723Z" },
{ url = "https://files.pythonhosted.org/packages/ae/94/fdb2093c8ccd7048449156db526ad746740cf34fe9c01e5dc1b7a7a8b257/psycopg_binary-3.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c0cac998b9b1e82dec853d2e53b3d34d56a525cf231f9441a636cfd5992929a9", size = 4775139, upload-time = "2026-08-31T22:44:36.719Z" },
{ url = "https://files.pythonhosted.org/packages/31/52/5195e87960715f7be2005761b72d56fce4e7757d5333fd40384f071c2de1/psycopg_binary-3.3.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:479b96fd78149cfa10369dc53fbfb89ee729be13146b584a23dbc7e164c0cf1e", size = 5556807, upload-time = "2026-08-31T22:44:42.668Z" },
{ url = "https://files.pythonhosted.org/packages/f7/42/2d616210a91e1327516ed5ae71961aaa31bb740e4a61d7190f2221685a40/psycopg_binary-3.3.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f45d77e398542ce0937d9fa3cd9d84e9c5fc6b34c50a66404ae840bada312750", size = 5236206, upload-time = "2026-08-31T22:44:47.943Z" },
{ url = "https://files.pythonhosted.org/packages/08/2e/e54b0d4cc263b3526e3728bb50a69660d5e79e52255ccd2a1a71e40e6f9a/psycopg_binary-3.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98a388509306e5e08a4203253ac52846bc1b034e5cbd0ae6da1211593cc28594", size = 6838066, upload-time = "2026-08-31T22:44:55.701Z" },
{ url = "https://files.pythonhosted.org/packages/77/80/ec22a110f81a44c411965982097efeee86006bdd5a1f51f628318106c84c/psycopg_binary-3.3.5-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab39e2794b95af61a2ff69e33e5ab6ac5df36e9ffea9a3b18e38b2aaca8c5ad5", size = 5072036, upload-time = "2026-08-31T22:45:02.838Z" },
{ url = "https://files.pythonhosted.org/packages/93/55/7bc3c3ac769ab4fe0c619f2179b4aab3ae043f4d478283dd8279d24c0be4/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9c071bf78e5c2e6efa40bc9089a954d7b41221347a72f35c6bf2d8c96e632f75", size = 4604058, upload-time = "2026-08-31T22:45:10.444Z" },
{ url = "https://files.pythonhosted.org/packages/a3/69/8e7414f7dc10b2959e664330cdcf393e412f67355975dafa876cef265264/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:14fdfd65a96ecbd8b586d14546105641f4a6ac7cbe335c786830ea4de94bbe60", size = 4284766, upload-time = "2026-08-31T22:45:17.881Z" },
{ url = "https://files.pythonhosted.org/packages/c1/72/33f293c1d3ee9114f47e9ef4880f31c9de864e84a9b09454c26e106c25ed/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8dbd694f3741dd4ac5bc60b70e17f7841aefb3f0f38cef4d2756de270e03af43", size = 4011958, upload-time = "2026-08-31T22:45:24.792Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c9/8e38840e5a7d006987bbc9acb29951912253fa50549a4db92b3aa535f089/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:14f432430fd9e1a9e7d9ab2fe14956c77f5d074ebdc556a1ad04e9a1bd3fca04", size = 4323273, upload-time = "2026-08-31T22:45:32.973Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c7/b7ebf601c307f93e7c4c4ebac0edc9db3b2729ca038efe700a18f86b5517/psycopg_binary-3.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab", size = 3745885, upload-time = "2026-08-31T22:45:40.537Z" },
]
[[package]]
name = "pydantic"
version = "2.13.5"
@@ -293,6 +507,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
]
[[package]]
name = "ruff"
version = "0.16.7"
@@ -318,6 +541,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.53"
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/4c/de/59fc3d2e63dfd367e803417c40e70e35b6c0a0997420cb082d351cdd052b/sqlalchemy-2.0.53.tar.gz", hash = "sha256:b568a8ac0778988c2710476017ff74fb5dcb9b9322d757189c3d7fc4cc8026fa", size = 9966200, upload-time = "2026-09-14T20:37:31.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/a9/fa8d769bdbcdf9db40ce1491da397bb7ea45e381791800319b4e7c5d6635/sqlalchemy-2.0.53-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ca22c441645ead511eb73f1efdb0f3a723f138e28f8362224813f64a8097e15", size = 2187584, upload-time = "2026-09-14T22:43:16.399Z" },
{ url = "https://files.pythonhosted.org/packages/52/a7/4534510e1deda24a505504194d805f22ef592bb51243a4f4b2f97f116593/sqlalchemy-2.0.53-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d26a8411cedab7102f31d766711f3e5fd93bf7d3a1470200bacaaaac7faaa826", size = 3448731, upload-time = "2026-09-14T22:49:36.249Z" },
{ url = "https://files.pythonhosted.org/packages/b7/a0/30c213493d215e685d6ba8a0d8ead0fb908e731f07f924ae2ab77297aec8/sqlalchemy-2.0.53-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9049aaeae45d30dfa3bfba105175a358bf32e9fa563e281a016cf44d8aaa6b5f", size = 3466629, upload-time = "2026-09-14T23:06:58.975Z" },
{ url = "https://files.pythonhosted.org/packages/07/90/f1b1ce1ec34f6c8b81c557ffda2a7fe191c0bed64e649a720e5feee7c5ab/sqlalchemy-2.0.53-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0417dac5a5bbc7bff3fd95f53ff193c0fdcbdde4b3119ca2f46651190e65144c", size = 3401504, upload-time = "2026-09-14T22:49:37.94Z" },
{ url = "https://files.pythonhosted.org/packages/0e/6c/e3721f102b8549831036465f368e7bf3e96dcfa9920ee36d9a69fba89e88/sqlalchemy-2.0.53-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6499513a0ca3984f7e4c4af22dbccc156eeda8846a4c4a2287a5b5e33b3544ff", size = 3432576, upload-time = "2026-09-14T23:07:00.982Z" },
{ url = "https://files.pythonhosted.org/packages/8c/13/9e5ab604abf0f8a229daa68b413ef717a76042db6b43ea0b3fbeddc3b045/sqlalchemy-2.0.53-cp312-cp312-win32.whl", hash = "sha256:dd915d37ae2442be08d6e1317814ac9be173341a1ad83ce133a77b4abba2d09d", size = 2140815, upload-time = "2026-09-14T23:03:36.688Z" },
{ url = "https://files.pythonhosted.org/packages/a2/de/0cd77e71f217cb6d849bd830b093710cf3cb2dc3a2d0af2361cf8d12b54a/sqlalchemy-2.0.53-cp312-cp312-win_amd64.whl", hash = "sha256:bb684d78694f77991cfc6fd8ba3bba093f06d7efbc354ea84b077a0d2116464d", size = 2169991, upload-time = "2026-09-14T23:03:38.168Z" },
{ url = "https://files.pythonhosted.org/packages/89/0e/394bbfadbbad7e427a86bc2f553eeea0bdd1f3f268aafed8cdf78af8cb41/sqlalchemy-2.0.53-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95b04f3113d8e02b711bbb3f6be5493ed94585b086b5b68571ea324e175022e5", size = 2184241, upload-time = "2026-09-14T22:43:17.736Z" },
{ url = "https://files.pythonhosted.org/packages/78/70/0ab026a838a0213b0b62521a49abbcd66d56d8e54ae8726d2aa268cbabb6/sqlalchemy-2.0.53-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acc9ee56130c32b609f562ac4041f77311aa6b3c51d507bd3ac93e280631eeed", size = 3398629, upload-time = "2026-09-14T22:49:39.568Z" },
{ url = "https://files.pythonhosted.org/packages/c8/4c/294eb6a87d5133874a04d2e7257fcf843176d6fbd1ba4ef5b3152fff861e/sqlalchemy-2.0.53-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f3a9a4e82bf984d840c26dafd19688a4e2bfc7401d423cbec960ec737c610f", size = 3417528, upload-time = "2026-09-14T23:07:02.923Z" },
{ url = "https://files.pythonhosted.org/packages/0a/a3/ce985c9aee8e123174a022dd72aaa435b9c077a7d9f973d56ad2ad566638/sqlalchemy-2.0.53-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2ff97dfff33e9c81e57be565ac58b9299affb566e10570cca668e4d67c69567a", size = 3349198, upload-time = "2026-09-14T22:49:41.2Z" },
{ url = "https://files.pythonhosted.org/packages/bf/bc/9dbae8af3dc6f14b14d08c3aeecbe1f3c5884ed1240f7509e8a2270297e2/sqlalchemy-2.0.53-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0cdefc36945decf40c199ee39f9df3f0765754b9625cf136118b04206854a05a", size = 3382852, upload-time = "2026-09-14T23:07:04.554Z" },
{ url = "https://files.pythonhosted.org/packages/29/04/228ab9a0115b8219c4af3071fead000b6c5c11696d2eae24163b6ff249d5/sqlalchemy-2.0.53-cp313-cp313-win32.whl", hash = "sha256:b06667e375da212f2ff4e7e54bf547ffeb63700c0743fc21c107f7d64f632922", size = 2138920, upload-time = "2026-09-14T23:03:39.638Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7f/8b93de13745cc35540cbbd45e5d2247787fe9fec0ac7c9e64c7842f8f4dc/sqlalchemy-2.0.53-cp313-cp313-win_amd64.whl", hash = "sha256:88c296bd7b6e653d428aa0c97e806ac1e0ad25c385f4dab627b832ba46b7ad91", size = 2166486, upload-time = "2026-09-14T23:03:41.26Z" },
{ url = "https://files.pythonhosted.org/packages/bf/c4/164f0b33493a694a525e174d1ff978264167315d85f5128657d67522932a/sqlalchemy-2.0.53-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bc02d73633f5cee4ebecfc6c20b09a28bdfd940cc2ccf5bf119a6a7de6b5a3a3", size = 2185890, upload-time = "2026-09-14T22:43:19.088Z" },
{ url = "https://files.pythonhosted.org/packages/bf/85/15503b7c81567602a88a45ae14ecaef2f9372bf82e4cd2e3c7588e8cdea5/sqlalchemy-2.0.53-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b06e32d92ed898240793982820c46ae22bdec4f24dcb4ae4a6859001674db186", size = 3394772, upload-time = "2026-09-14T22:49:42.787Z" },
{ url = "https://files.pythonhosted.org/packages/fe/bb/a35e0ccb4f1d6f37b7b9d8810de787cbd429c427082e77e0ecdedf99b313/sqlalchemy-2.0.53-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03dfba40d1de2da62370d5e85ba13ffb8e75f16e8af9e93170a9cf4f394636a5", size = 3402720, upload-time = "2026-09-14T23:07:06.829Z" },
{ url = "https://files.pythonhosted.org/packages/40/06/adb43aa08a16e8abf55148a2efd17a66291a217c461455d7b4de053fd092/sqlalchemy-2.0.53-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:659daf25d61973ad6dff7bbfe9fccd116da2ab27618f57c3b9bfb97cb02000f5", size = 3341444, upload-time = "2026-09-14T22:49:44.316Z" },
{ url = "https://files.pythonhosted.org/packages/ee/07/15eaf0ae3573a9fb3ad0e5437f8cb36e03e415839ba6e77c645f555fb0b2/sqlalchemy-2.0.53-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:577195de5eccba4fea6c289e7537e4d309d9767d16b31afe82deda1f241feb80", size = 3373151, upload-time = "2026-09-14T23:07:09.063Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c7/b5c078b0d36adb22cee1aca21e745e20e69705357c1f0dae2d8d728360fa/sqlalchemy-2.0.53-cp314-cp314-win32.whl", hash = "sha256:eeb39947f8d3a0531afdcb422f659e6ac09653b50813dbedc9baf84ce2358320", size = 2142012, upload-time = "2026-09-14T23:03:42.677Z" },
{ url = "https://files.pythonhosted.org/packages/59/4d/81b6bf1eaa969fc74b54104f6059d08f52383416654e8149b55ad168f2fb/sqlalchemy-2.0.53-cp314-cp314-win_amd64.whl", hash = "sha256:414a71d25b18cd3d1d5ab18b512a550a34398dcfbd68336adff5cd1e93fc7f0d", size = 2169212, upload-time = "2026-09-14T23:03:44.125Z" },
{ url = "https://files.pythonhosted.org/packages/eb/27/06028ccab7e03887c68c5031a8e4e7bfc85f30c87449c79dcba49e7ccd55/sqlalchemy-2.0.53-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:41548248e8a5897d3a5f67ad8c1edb78efe72ec00dcc985b5c19b7850949d039", size = 2208469, upload-time = "2026-09-14T21:24:39.051Z" },
{ url = "https://files.pythonhosted.org/packages/fc/3c/6ee5fe746b0820335d30c5172527e9a2c7409ac632376d15fded38ff715c/sqlalchemy-2.0.53-py3-none-any.whl", hash = "sha256:87eb05fc61a0835dfe1cbf135d0f99cb2764ad09350d92fdd3fe1b827a557d65", size = 1958002, upload-time = "2026-09-14T22:40:04.431Z" },
]
[[package]]
name = "starlette"
version = "1.6.0"
@@ -352,6 +610,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "tzdata"
version = "2026.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
]
[[package]]
name = "uvicorn"
version = "0.53.0"
@@ -364,3 +631,78 @@ sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" },
]
[[package]]
name = "wrapt"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/42/a6/6375d56c44d590ef24acf0f8f5bf7ed768ff7a510b959306ec412611e90f/wrapt-2.4.1.tar.gz", hash = "sha256:fd6390aab9e8aa40c52eff3c180f098e8d9f5894b1fd4c4fd2c207067b33ed16", size = 164597, upload-time = "2026-09-10T23:12:16.811Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/1f/a2f3225c5ecf522684c1d051aea8ce8253f240e55be75826b787777afd6c/wrapt-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7e86fbc2ac8a363ea04abf631fad82720e16b17a25020f32dbe9b24a2ed2b0e3", size = 98890, upload-time = "2026-09-10T23:10:07.876Z" },
{ url = "https://files.pythonhosted.org/packages/68/6c/eb45660fd4d92cce11ec923f55bb2e647a6c18d30e53734eb07a3c530e31/wrapt-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24389748f0b9d5b67e478fad4fc8b3f1108422ef80716e48eead6cebcebbff08", size = 98742, upload-time = "2026-09-10T23:10:09.236Z" },
{ url = "https://files.pythonhosted.org/packages/a3/4f/17a89a580cb0082e61b8375074d5c9e5d38e4aa83ee19b6174ee472d17c2/wrapt-2.4.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30d11c289b013bf384ff1a1a6553f150d0b855901708a9bef667a5680f8247c9", size = 236301, upload-time = "2026-09-10T23:10:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/01/ca/4700eb008a34bf02de328806ddde15fc84c8d1e65d3dcafb92a935a50319/wrapt-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9356dbb59199a0e4709de35fa4a1ac1a88ef6da99711a397f5b009233faff326", size = 237805, upload-time = "2026-09-10T23:10:12.594Z" },
{ url = "https://files.pythonhosted.org/packages/75/5d/26c1740299b29e190d5f4b4a99eb001401042a9d9ab338e3f8e1ce140ecb/wrapt-2.4.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8342f332dada211f64b74609e332d727b13315e9a83177f7918bf68c59f815f2", size = 217037, upload-time = "2026-09-10T23:10:14.028Z" },
{ url = "https://files.pythonhosted.org/packages/3a/55/ec72991153a2ae8b40238bc44cec7c3ddf7706ef6e2d314b0c6f5c7febce/wrapt-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2f86e328c482bc5383b4eda5094be0bed3617fc3076aa9225ff1a9eb6372de9b", size = 234659, upload-time = "2026-09-10T23:10:15.384Z" },
{ url = "https://files.pythonhosted.org/packages/51/32/7cfa1e070dcda76ea56a3e252341cba1cd1e9412baf23cd7adfebf1114e2/wrapt-2.4.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4dc92697444ee380544fbb43c86612d8486529aadf917c22b5524141d4af074c", size = 214590, upload-time = "2026-09-10T23:10:16.744Z" },
{ url = "https://files.pythonhosted.org/packages/79/10/248841cb30107f6f32c53a02662e1c3e0c7c06bea0b8ebfaaee94885dcee/wrapt-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:edd03758a7578526642508b8833d43496fdfba0f64e0025dfca153a7c1777735", size = 225103, upload-time = "2026-09-10T23:10:18.346Z" },
{ url = "https://files.pythonhosted.org/packages/90/02/5b2bf7b35b008a39939a2908e85dba3b867596e535fc9f12ddf3ba1fcaf6/wrapt-2.4.1-cp312-cp312-win32.whl", hash = "sha256:5d83e412665aeb1e854eefbf1564d0d67872d9994b502a0bce96e6ff7f4970b7", size = 93441, upload-time = "2026-09-10T23:10:19.81Z" },
{ url = "https://files.pythonhosted.org/packages/d4/2a/47be56772bfb07ef242d6e924049688e38384af2b2fc99b0f31180988448/wrapt-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:b4e7efdd476ac631a0181551fd9aace844765ea3ce2b5133b194fae4421e8ad0", size = 98808, upload-time = "2026-09-10T23:10:21.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ec/40e4c9735626afd1dc0eeb310310278361f192800503cda0f5b3d8d24db4/wrapt-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:38819761401baa2d11916d7265b82f23265f8fe5a31c431dd7c24a8863c65f88", size = 95240, upload-time = "2026-09-10T23:10:22.39Z" },
{ url = "https://files.pythonhosted.org/packages/5c/1a/9b5aa3c2391aa6d00fffa085c2219e8fc91cd4d2b9b080d2b4e4b6b93f42/wrapt-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:55f36bb1461f93beaf18d818e568b5de343dd8fade7456773d201a38ee723bd3", size = 98597, upload-time = "2026-09-10T23:10:23.723Z" },
{ url = "https://files.pythonhosted.org/packages/fe/07/dc98150c2f9ee5b5fcbd841765e178ea6cc6c43733ab8d1f5181a4fb9f3d/wrapt-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:53e15cd74bd6b84d7fa90b93dda7334d85f4641fae97632de8aa61d268dfd145", size = 98842, upload-time = "2026-09-10T23:10:25.109Z" },
{ url = "https://files.pythonhosted.org/packages/08/c2/0e772a570e8d75c1b3ec45930a3023492fa68223f8f5e3485af4844c10c3/wrapt-2.4.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be6cdd7121adc89a6f52e3c2f4e26a2d4dcdc1c0fde47e3156234db8939e4cdc", size = 234642, upload-time = "2026-09-10T23:10:26.644Z" },
{ url = "https://files.pythonhosted.org/packages/76/25/4ce4d02dd95ff9ed972a2fc396d04fec636daa5a4ac18cc73a3dc20aa6c3/wrapt-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03b5598edd435373278731d0d53449ce7a9626bc48d5548e4a71124ee3e526a1", size = 235484, upload-time = "2026-09-10T23:10:28.098Z" },
{ url = "https://files.pythonhosted.org/packages/fd/80/436ef1df620ef8edd9ef057b4d55a0cd704435ff66852a0ef26cbaa02a58/wrapt-2.4.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fdc997819a6df4c65bdb0a1c5601c98b479ee34cc2f94ffa98a767034ad6366d", size = 214371, upload-time = "2026-09-10T23:10:29.841Z" },
{ url = "https://files.pythonhosted.org/packages/6a/2c/cc5b7503843399087db3106a7cf60d60d0900f69539e96148b9fff116291/wrapt-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3bfc6907ebed560d2d3f677c3b17bf6199679163b6c6e475035c9dca497d1697", size = 232347, upload-time = "2026-09-10T23:10:31.511Z" },
{ url = "https://files.pythonhosted.org/packages/86/fb/17668b1ca572c44b458c09d76064d35f5f57d5ac7629248871604bef816c/wrapt-2.4.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a0c3b217332cf0c4df085fec41c126bf7507d6c1ca0efb3ba8d2b3fd234d4e73", size = 212792, upload-time = "2026-09-10T23:10:32.968Z" },
{ url = "https://files.pythonhosted.org/packages/b8/2b/0c5de10d7259e07c478c44bf2fbe179c6878727d09edf0632b97884801db/wrapt-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a23b89621cfeb3329b1a290596bf402e61d7d5a647a65ca8ea735e48771b71d4", size = 223585, upload-time = "2026-09-10T23:10:34.43Z" },
{ url = "https://files.pythonhosted.org/packages/aa/36/013dce1c687f8f1c87b1e78f403d04c80ae9b2ae77de4ddba16069a3e7d1/wrapt-2.4.1-cp313-cp313-win32.whl", hash = "sha256:bc67d4872af5ab2dc1b88904097b92ac00e7658fdf010dee36807807fe882ac4", size = 93425, upload-time = "2026-09-10T23:10:35.901Z" },
{ url = "https://files.pythonhosted.org/packages/b7/1d/d53bcf5910209a45191c8bc173ff0e00830416547d8038772dc444932ee0/wrapt-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:1fe758b9c2d49138231ec3efabd106fec665f86fec50b3d02d7edd75f08a69ca", size = 98569, upload-time = "2026-09-10T23:10:37.316Z" },
{ url = "https://files.pythonhosted.org/packages/e8/1f/759d0c522918f9dfb620136622d8e1ce619570adda0de8fa2cfbb55cbb86/wrapt-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c974c36e8205255a3947dad9c2fe000431b57dd522946e56c192174a7f92a0f", size = 95272, upload-time = "2026-09-10T23:10:38.657Z" },
{ url = "https://files.pythonhosted.org/packages/08/05/ed5aa8991c5e9969e2ca17f0e44eb324a9757f44fa982bfc13844cfe9e1d/wrapt-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6c35541cc729964c65c2b9b1f9cf317811039abc2da13b4557f20f21cdc292b", size = 98876, upload-time = "2026-09-10T23:10:40.078Z" },
{ url = "https://files.pythonhosted.org/packages/f1/5f/f8bf07c3b9a2ad01d28d5ca419e28819bb16937d129167d97c446c05875d/wrapt-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6b9df84f0a96763159cccb8e3b0ed83cc950c7c8bf82d6e45428372a805b3224", size = 99054, upload-time = "2026-09-10T23:10:41.484Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7a/518e5f3ebd18472652e5332bf37728cd6634ff81a0aa568969d05cc66099/wrapt-2.4.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78af62413095d0a57077606654ce85e273deda8e2bfa28fdb04257b962d94ef2", size = 237430, upload-time = "2026-09-10T23:10:43.007Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2b/503835d183c1ca99268e0b5a98af8f487d528ebabb124f4003cd96e73b1f/wrapt-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60702ebc914d0bb01aa48f5c1785ceaaaa505e0a841b444a69d6ceb5de4097e", size = 237988, upload-time = "2026-09-10T23:10:44.466Z" },
{ url = "https://files.pythonhosted.org/packages/fa/9d/a11a3e1eb18ae1d9adef1c39f8e8e36fd5b86db33938bebf8e6bc5fd9b06/wrapt-2.4.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8c4b44e4be7680fc496816e824b6ed134d781c4da296e44b75399196e5c248f1", size = 218554, upload-time = "2026-09-10T23:10:45.969Z" },
{ url = "https://files.pythonhosted.org/packages/e1/61/61a6f983737ba81008561ab634373bba25b986761d3a0f0aded8352ae3e8/wrapt-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fb5b3f94258bcf71db902795f4a71151c7a74d9fe21fffa00752d2bd286866f8", size = 235476, upload-time = "2026-09-10T23:10:47.528Z" },
{ url = "https://files.pythonhosted.org/packages/42/68/acb7cba46e0f662eaae421487807d2fed28ba52df9a00a3f04db16675e38/wrapt-2.4.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f1556a96b20d5bfdc9cb5dcd0a3ec65cbbac8a4eebcd3cd34efdc91c9519f660", size = 216454, upload-time = "2026-09-10T23:10:49.126Z" },
{ url = "https://files.pythonhosted.org/packages/8b/52/3a4dab8d4803df595de82f19775535d28c036746340c1b498fc8ecba39aa/wrapt-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:094a606d0bf1c4b847b3a743d22fc69cb164015b8c70cf6f20a534d30889ed47", size = 225346, upload-time = "2026-09-10T23:10:51.052Z" },
{ url = "https://files.pythonhosted.org/packages/5a/48/d72d051757fb7955021d8174014679f951fd5067187d2faed95520e1f8d6/wrapt-2.4.1-cp314-cp314-win32.whl", hash = "sha256:b0ee076be124406a7f97ca663c4a3ba32b6bcdd9102ca82497feaca79e9cb33c", size = 93870, upload-time = "2026-09-10T23:10:52.495Z" },
{ url = "https://files.pythonhosted.org/packages/a1/00/1a1bf4d8b60b9e7ac009969595438549ae6e33b2e3e174b78d26a833aad3/wrapt-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:d2d6f9abaa52de05090a2b4a4c1d0e858a268c4de69eec277506af9fbcccff91", size = 98910, upload-time = "2026-09-10T23:10:53.878Z" },
{ url = "https://files.pythonhosted.org/packages/1d/59/b3169c3bcdcff70a6d02ed2b6366097b083d31391d616c407efe9a943a82/wrapt-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:3152b2e94d733a9bd70dbd1c95f148266f079a70e9ffca2a9c5dc421ade1b4e6", size = 96013, upload-time = "2026-09-10T23:10:55.607Z" },
{ url = "https://files.pythonhosted.org/packages/b1/91/d29a063c2ca6e779305cd444dd5acaac60833d73b2d94c7b1d0820c27e3e/wrapt-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:37ff91b390400463ecd4d080ea823314510b6843ac53e6e35cc09bba1805d466", size = 102069, upload-time = "2026-09-10T23:10:57.179Z" },
{ url = "https://files.pythonhosted.org/packages/72/26/7435de516099b528e2232770b459d6706002736f41d8f092bbb11ea01575/wrapt-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e0a518cac3e789443af54fc77f23e66f17d80192c281b160b42058f11fabccc5", size = 102647, upload-time = "2026-09-10T23:10:58.852Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a7/cca7fe0f26aa11cbbf09b2e6f4f774db167feeca43ad22fa1772afdccbad/wrapt-2.4.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ecdeb8a1ec13397421e6f925412186bd87ab86d63517e6825b6d7bccf781f26", size = 275614, upload-time = "2026-09-10T23:11:00.464Z" },
{ url = "https://files.pythonhosted.org/packages/77/22/f3b5f4428ae679b1e2c0d4833519045d8983a9df0ad24df96b195d44dd1b/wrapt-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c15c4ede3fde08723cabab0a892d4b75d35b5a6f0a51c84e6542ac0bdc0507aa", size = 287064, upload-time = "2026-09-10T23:11:02.111Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b8/24274ec01b6ab6d0672f4312bfa29b5f1ffd9d56f16c8da67d5683883956/wrapt-2.4.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e32c5951c36fed88b6c603dc0bab209a62dc3217bd8762413634725fe28f2f3c", size = 255440, upload-time = "2026-09-10T23:11:03.746Z" },
{ url = "https://files.pythonhosted.org/packages/8b/df/c63a36f0f03d70c7b92f60ec1ad3757088167914e1a262f5dcc7df233d51/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:898513db90d55a4ed3009312d41c12932b8163edbae3535280046d47aaff774f", size = 281681, upload-time = "2026-09-10T23:11:05.41Z" },
{ url = "https://files.pythonhosted.org/packages/bc/c7/4930b6a369d9b3da59f5ada4ae9e659e2d4a68fe72c5d1ce15e5bead2d24/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:42d01574bd4bcafc3476e77a95c6c0dd6167101991401325fc5591e2db21a91d", size = 252409, upload-time = "2026-09-10T23:11:06.867Z" },
{ url = "https://files.pythonhosted.org/packages/68/d7/f6bc5703061598c4a2ad4f7a2efce696782be6e9d5afed714a59f5902ead/wrapt-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:68a403adbeb4dd2654d6d108e43e69f0f90e6d34cb7588e0e6589109f6985e67", size = 270559, upload-time = "2026-09-10T23:11:08.385Z" },
{ url = "https://files.pythonhosted.org/packages/68/a3/952337a153e634f530079334c47dcc935b20f8ea22f364729fc2a8cf1821/wrapt-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:7cb3035b332bc9d21478600ba82c7c71e2d074ed9b4e76364297f54598792227", size = 96241, upload-time = "2026-09-10T23:11:09.862Z" },
{ url = "https://files.pythonhosted.org/packages/09/28/48f0651547a125dd84e312b86268d4150c565a29f81ff9ca6fd81eba4be4/wrapt-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:707d2bef68deddd0fc74a81103d91b286a69ac5a9ff0f0a6dad66f8787e86697", size = 102655, upload-time = "2026-09-10T23:11:11.388Z" },
{ url = "https://files.pythonhosted.org/packages/21/a6/977441bed5e5afbffe7a789067ac2f5fd2e9f848884122a23db9ccae6a70/wrapt-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:28cb1c2713b4377bf03ddcb3e76d8216e4bb334199066c6122be7eed2f72de8c", size = 98495, upload-time = "2026-09-10T23:11:12.952Z" },
{ url = "https://files.pythonhosted.org/packages/7c/1c/03bcbc3dbf6ac11231f0434f2494aa7b80657b130be1a1a639192a3e44c9/wrapt-2.4.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:96a5023fa63ca2f095f7776c8bfaa1694547577f762646c375abafd8f8ae649b", size = 98878, upload-time = "2026-09-10T23:11:14.506Z" },
{ url = "https://files.pythonhosted.org/packages/87/26/5ad9bd421b8cdce0b2227a61f09586423765da3c284f8bbf0f69fc149fdf/wrapt-2.4.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b21924949dedc3ac63725e09b9b0a130e771975f03432789344ed3422c46008", size = 99090, upload-time = "2026-09-10T23:11:16.101Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c7/fa768261966d587650004ac37bef541540014b1fe23fbf4919c497ebc98c/wrapt-2.4.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2f725af353bb3319c528ee69dbff838599426974288b281a3258d5397b18cb63", size = 237803, upload-time = "2026-09-10T23:11:17.738Z" },
{ url = "https://files.pythonhosted.org/packages/0f/37/9c1c876bd88fef8d065b3e8bf125cad9291d70ed3695cedb41b36b2453ec/wrapt-2.4.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20327e162ef7953fae56b31a43ca457f94ed1fc7a206d01e3d5c8412d1b91572", size = 238338, upload-time = "2026-09-10T23:11:19.742Z" },
{ url = "https://files.pythonhosted.org/packages/91/5b/47b2f2f08b90d9d3b4e1790ccae0d4e4de40e73f6614c9a52465a04f02a5/wrapt-2.4.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2cedb743dbdfb9b6d4f11acd8cb6329460264429777e81ea2e86b1b72ea503af", size = 220668, upload-time = "2026-09-10T23:11:21.422Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/da7844c7a3f7c01f3496e690e15ce9d01c0949278c64d4287ec472c90f72/wrapt-2.4.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5a54744b1193505f19016194979b773ee3754a199e9ad90db18f2e9a18fffa16", size = 235812, upload-time = "2026-09-10T23:11:23.211Z" },
{ url = "https://files.pythonhosted.org/packages/69/af/ac4aa9f795721a54a21bac329201f7df1b199b71ef064187a266d4d29836/wrapt-2.4.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b0d38d9cc23e9781e6584f303e5c5a9f0d45b85de92ad45678c4dde8d49d9535", size = 218195, upload-time = "2026-09-10T23:11:24.983Z" },
{ url = "https://files.pythonhosted.org/packages/78/aa/cff7670ec1cfa75b951171cfb41d45c7bdb94e928620a1599ec2b00cd2dd/wrapt-2.4.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a524ca32f0bcdde2b728d9f81f9527d4dd24b13f15d180f05d08cdc01d1cebe0", size = 225713, upload-time = "2026-09-10T23:11:26.576Z" },
{ url = "https://files.pythonhosted.org/packages/77/c6/64f138aef50b5ea1dfacca2a023b32ba3e41ef087bbd92e8997a95f4a0b8/wrapt-2.4.1-cp315-cp315-win32.whl", hash = "sha256:47c267617551e906de72f6e7265aa3bce84c63d44513d2d2e735e943422aa0a2", size = 93880, upload-time = "2026-09-10T23:11:28.253Z" },
{ url = "https://files.pythonhosted.org/packages/8d/aa/afe7484a0f7232e87f2ebb65286c1025d43cbe1de4e4051b127b024a92f7/wrapt-2.4.1-cp315-cp315-win_amd64.whl", hash = "sha256:ee437bd7fd050823ef731aad20e968ca8fe670b95e1841f5d201bfdbad4a4e96", size = 98919, upload-time = "2026-09-10T23:11:29.832Z" },
{ url = "https://files.pythonhosted.org/packages/83/21/6865f976c6a1082ca61e2792bc6a2d6a0ed03cd750a46f837753127191f8/wrapt-2.4.1-cp315-cp315-win_arm64.whl", hash = "sha256:ebf3b703752b53366fd02b7fbd8c447428e0349c9af3fc17c7a05076dbdd749a", size = 96021, upload-time = "2026-09-10T23:11:31.448Z" },
{ url = "https://files.pythonhosted.org/packages/8d/48/baf784cc9f1fe554f96daf15af06c7c466d76469d5540e357cf564755606/wrapt-2.4.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:0f5990f5db090f069fcd577cc61cc8d463db73cde132abba9033b0a264fc06d0", size = 102028, upload-time = "2026-09-10T23:11:33.102Z" },
{ url = "https://files.pythonhosted.org/packages/82/35/fb809c95bf5512196aeda4c640aabf4e92f607340b8b2556555690a52b7a/wrapt-2.4.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef19a2590b195ac294deff8ee350a027a479afdd2ef2170ce3900af92406e110", size = 102640, upload-time = "2026-09-10T23:11:35.145Z" },
{ url = "https://files.pythonhosted.org/packages/a1/3f/bd462010ccbbe298479f320bf2bb02996706003e9c20a15790b65a186f8b/wrapt-2.4.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:432f402f9b6014403cacf9fd18a6bf089c77964330eae951a155131ab0414f5d", size = 275999, upload-time = "2026-09-10T23:11:36.863Z" },
{ url = "https://files.pythonhosted.org/packages/56/1e/bd043b2bad2943336e090583ff6cea2f3e8776bde02f073ccf760b16eee1/wrapt-2.4.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb7c0f8bdd21e954bad89d554cfb7431c2f8179842853f199841ab90cbebe914", size = 287576, upload-time = "2026-09-10T23:11:38.79Z" },
{ url = "https://files.pythonhosted.org/packages/1c/d9/9937bbd61ca4e7f58a7e8a38f5ff3562334f741de51b87f0e031df441254/wrapt-2.4.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e82a1669d63b79a2b53041bbb6ea096b5763cab3ca698ed7ac244b3acb7cd51", size = 256763, upload-time = "2026-09-10T23:11:40.668Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a8/0f78cc7e7fc6313cdba32df06f4b29a45a4a2aed3499040d6c1628d3f339/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b19e71c914c435d2c5652caea386c9bf2807979e957183f5bb06d5ed5fa8b55e", size = 282244, upload-time = "2026-09-10T23:11:42.483Z" },
{ url = "https://files.pythonhosted.org/packages/be/0d/30317d738b9898a7fbedc193657f151a468b0fa7235f4abd58a646a99cd8/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a1e4870d3368c6c918f38e308d5dcea970ea5b908ce889c21412f3a517ebf0c3", size = 254153, upload-time = "2026-09-10T23:11:44.357Z" },
{ url = "https://files.pythonhosted.org/packages/12/53/fe1f251a89f471ca7c5e17f2edbd8efd48fea6f1c08430dcf60986ea4183/wrapt-2.4.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2286e8e4a937966706463d1bcea30dfefee529e6e73e097a18e9e066a07ae6c2", size = 271234, upload-time = "2026-09-10T23:11:46.132Z" },
{ url = "https://files.pythonhosted.org/packages/bd/7c/d63758cd7fa0d18c415a87312a07cdd9f0102eb824aaab5c7450b3ec08e5/wrapt-2.4.1-cp315-cp315t-win32.whl", hash = "sha256:80afa3b7010e82899044a2a189c468a0cd8c89980398a59ba3b96b451a6dc5e9", size = 96236, upload-time = "2026-09-10T23:11:47.942Z" },
{ url = "https://files.pythonhosted.org/packages/f7/2e/1785e6d2696db08b07c053b1f4ccb2b5e8f9d12e9bf2c7497693be6b4504/wrapt-2.4.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3593b43fabab6b59fe77e38f7e40974aae120c30cea3fd1ceaea6621ee96be00", size = 102662, upload-time = "2026-09-10T23:11:49.831Z" },
{ url = "https://files.pythonhosted.org/packages/2f/fd/3f4ac5c4754948355e050f952ae7a64cdca94891e49fe9927c3f90a73334/wrapt-2.4.1-cp315-cp315t-win_arm64.whl", hash = "sha256:ac1939ccf3e1c33f463706fbf52db5075f5eefb6042bcc1f9cf48c2c20ef478c", size = 98500, upload-time = "2026-09-10T23:11:51.501Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a2/edcfc8d9a30375791b775715f8501364588f65b494ec4f6930568a19e765/wrapt-2.4.1-py3-none-any.whl", hash = "sha256:1e84ec5d89a0a07a0ef6bcd343f5c8ecdc95601d71de3058cdc63274e86c193c", size = 75317, upload-time = "2026-09-10T23:12:14.82Z" },
]