From 0fc6c68a998b069fa536d7b29df437dcc193437b Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 15 Sep 2026 23:49:43 +0800 Subject: [PATCH] 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. --- .env.example | 19 + .gitignore | 4 + README.md | 120 +++--- deploy/cassandra.cql | 14 + deploy/nginx.conf.example | 5 + docs/identity.md | 163 ++++++++ pyproject.toml | 9 +- scripts/demo_identity.py | 69 +++ scripts/init_local_env.py | 21 + src/adcloud_server/api/v1/events.py | 44 +- src/adcloud_server/api/v1/identity.py | 85 +++- src/adcloud_server/api/v1/users.py | 29 +- src/adcloud_server/database.py | 183 ++++++++ src/adcloud_server/dependencies.py | 62 +++ src/adcloud_server/identity_models.py | 93 +++++ src/adcloud_server/identity_responses.py | 86 ++++ src/adcloud_server/identity_service.py | 508 +++++++++++++++++++++++ src/adcloud_server/main.py | 44 +- src/adcloud_server/profile_store.py | 109 +++++ src/adcloud_server/projection.py | 130 ++++++ src/adcloud_server/settings.py | 89 ++++ src/adcloud_server/tokens.py | 52 +++ tests/test_cassandra.py | 87 ++++ tests/test_identity.py | 438 +++++++++++++++++++ tests/test_postgres.py | 105 +++++ tests/test_projection.py | 146 +++++++ uv.lock | 382 ++++++++++++++++- 27 files changed, 2993 insertions(+), 103 deletions(-) create mode 100644 deploy/cassandra.cql create mode 100644 docs/identity.md create mode 100644 scripts/demo_identity.py create mode 100644 scripts/init_local_env.py create mode 100644 src/adcloud_server/database.py create mode 100644 src/adcloud_server/dependencies.py create mode 100644 src/adcloud_server/identity_models.py create mode 100644 src/adcloud_server/identity_responses.py create mode 100644 src/adcloud_server/identity_service.py create mode 100644 src/adcloud_server/profile_store.py create mode 100644 src/adcloud_server/projection.py create mode 100644 src/adcloud_server/settings.py create mode 100644 src/adcloud_server/tokens.py create mode 100644 tests/test_cassandra.py create mode 100644 tests/test_identity.py create mode 100644 tests/test_postgres.py create mode 100644 tests/test_projection.py diff --git a/.env.example b/.env.example index 4365bf5..b2a0068 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index b29f139..d1dfbda 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ dist/ .env .env.* !.env.example +.data/ +*.db +*.db-shm +*.db-wal diff --git a/README.md b/README.md index 4c6b0a7..b42e5f2 100644 --- a/README.md +++ b/README.md @@ -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 测试会创建初始表并保留测试租户数据,需使用专用测试数据库。 diff --git a/deploy/cassandra.cql b/deploy/cassandra.cql new file mode 100644 index 0000000..ca06fe1 --- /dev/null +++ b/deploy/cassandra.cql @@ -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) +); diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example index bdbf1e3..4304c45 100644 --- a/deploy/nginx.conf.example +++ b/deploy/nginx.conf.example @@ -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; } diff --git a/docs/identity.md b/docs/identity.md new file mode 100644 index 0000000..6977779 --- /dev/null +++ b/docs/identity.md @@ -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 失效且旧标识无法匹配,密钥轮换需要单独的迁移方案。 diff --git a/pyproject.toml b/pyproject.toml index ef14bad..9e8b76f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/demo_identity.py b/scripts/demo_identity.py new file mode 100644 index 0000000..a97825a --- /dev/null +++ b/scripts/demo_identity.py @@ -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, + ) +) diff --git a/scripts/init_local_env.py b/scripts/init_local_env.py new file mode 100644 index 0000000..de30ec1 --- /dev/null +++ b/scripts/init_local_env.py @@ -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.") diff --git a/src/adcloud_server/api/v1/events.py b/src/adcloud_server/api/v1/events.py index 4e0595b..a7312d1 100644 --- a/src/adcloud_server/api/v1/events.py +++ b/src/adcloud_server/api/v1/events.py @@ -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) diff --git a/src/adcloud_server/api/v1/identity.py b/src/adcloud_server/api/v1/identity.py index cc7def3..18024dc 100644 --- a/src/adcloud_server/api/v1/identity.py +++ b/src/adcloud_server/api/v1/identity.py @@ -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", + } diff --git a/src/adcloud_server/api/v1/users.py b/src/adcloud_server/api/v1/users.py index 60df253..dbfa050 100644 --- a/src/adcloud_server/api/v1/users.py +++ b/src/adcloud_server/api/v1/users.py @@ -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) diff --git a/src/adcloud_server/database.py b/src/adcloud_server/database.py new file mode 100644 index 0000000..e8f5c37 --- /dev/null +++ b/src/adcloud_server/database.py @@ -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() diff --git a/src/adcloud_server/dependencies.py b/src/adcloud_server/dependencies.py new file mode 100644 index 0000000..9d0f89b --- /dev/null +++ b/src/adcloud_server/dependencies.py @@ -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)] diff --git a/src/adcloud_server/identity_models.py b/src/adcloud_server/identity_models.py new file mode 100644 index 0000000..1c30b1b --- /dev/null +++ b/src/adcloud_server/identity_models.py @@ -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 diff --git a/src/adcloud_server/identity_responses.py b/src/adcloud_server/identity_responses.py new file mode 100644 index 0000000..b833f19 --- /dev/null +++ b/src/adcloud_server/identity_responses.py @@ -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 diff --git a/src/adcloud_server/identity_service.py b/src/adcloud_server/identity_service.py new file mode 100644 index 0000000..1b75a53 --- /dev/null +++ b/src/adcloud_server/identity_service.py @@ -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 = "" + 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, + } diff --git a/src/adcloud_server/main.py b/src/adcloud_server/main.py index 50e52a2..efb27c2 100644 --- a/src/adcloud_server/main.py +++ b/src/adcloud_server/main.py @@ -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"], ) diff --git a/src/adcloud_server/profile_store.py b/src/adcloud_server/profile_store.py new file mode 100644 index 0000000..07af5ee --- /dev/null +++ b/src/adcloud_server/profile_store.py @@ -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 + ) diff --git a/src/adcloud_server/projection.py b/src/adcloud_server/projection.py new file mode 100644 index 0000000..1d2f225 --- /dev/null +++ b/src/adcloud_server/projection.py @@ -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() diff --git a/src/adcloud_server/settings.py b/src/adcloud_server/settings.py new file mode 100644 index 0000000..60a4d2e --- /dev/null +++ b/src/adcloud_server/settings.py @@ -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, + ) diff --git a/src/adcloud_server/tokens.py b/src/adcloud_server/tokens.py new file mode 100644 index 0000000..a2f8493 --- /dev/null +++ b/src/adcloud_server/tokens.py @@ -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 diff --git a/tests/test_cassandra.py b/tests/test_cassandra.py new file mode 100644 index 0000000..7ba0bf0 --- /dev/null +++ b/tests/test_cassandra.py @@ -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"] == {} diff --git a/tests/test_identity.py b/tests/test_identity.py new file mode 100644 index 0000000..29d61bd --- /dev/null +++ b/tests/test_identity.py @@ -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() diff --git a/tests/test_postgres.py b/tests/test_postgres.py new file mode 100644 index 0000000..0344060 --- /dev/null +++ b/tests/test_postgres.py @@ -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 diff --git a/tests/test_projection.py b/tests/test_projection.py new file mode 100644 index 0000000..59361be --- /dev/null +++ b/tests/test_projection.py @@ -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 diff --git a/uv.lock b/uv.lock index 802a24a..ec0a56a 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +]