Implement identity graph stitching and configurable profile storage
Issue persistent signed anonymous IDs and accept cookie/token-bound events. Resolve verified email, phone, OAuth, and namespaced third-party IDs into transactionally merged master identities with historical event aliases. Protect shared-device account switching, tenant isolation, and event retries. Store sparse canonical profiles as PostgreSQL JSONB or local SQLite JSON. Support an optional versioned Cassandra wide-column projection through a transactional outbox, retry worker, and immediate canonical read fallback. Document deployment settings and provide opt-in database integration tests. Validation: 43 local tests passed; 6 external database tests skipped. Ruff checks, formatting, and offline uv lock verification passed.
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -9,3 +9,7 @@ dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
@@ -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 测试会创建初始表并保留测试租户数据,需使用专用测试数据库。
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Identity Resolution / Identity Stitching
|
||||
|
||||
## 标识和可信边界
|
||||
|
||||
- `anonymous_id`:服务端随机 UUID,浏览器用持久化的 HttpOnly Cookie 携带,移动端用签名 token 携带。
|
||||
- `master_id`:身份连通分量的规范 ID。匿名访问时已有一个 Master ID,登录关联后可保留它,或归并到已有账号的 Master ID。
|
||||
- 强身份:验证后的邮箱、国际格式手机号、命名空间下的 OAuth subject / 第三方 ID。
|
||||
- MAID、设备指纹、IP 和 Cookie 只表示设备/访问连续性,不用于自动合并两个已知账号。本实现生成随机匿名 ID,不生成设备指纹,也不伪造系统 MAID。
|
||||
|
||||
`POST /v1/identity/stitch` 只接受可信后端的 Bearer service token。
|
||||
调用方必须先完成邮箱/手机号 OTP 验证,或校验 OAuth 令牌的签名、issuer、audience、subject 和已验证邮箱声明,再把已验证标识传入。
|
||||
请求体没有可以由浏览器自行声明的 `verified=true` 字段。未验证的留资邮箱应先作为临时属性存储,完成验证后再添加强身份连线。
|
||||
当前实现提供内部 Stitch 接口和鉴权,不包含验证码发送、密码登录或具体 OAuth provider 回调。
|
||||
|
||||
`SERVICE_TOKEN` 只放在可信服务端。它是单个部署/租户的管理凭据,不能交给广告商、开发者、浏览器或第三方 SDK。
|
||||
移动端匿名 token 仅证明匿名访问 ID 的持有,不是登录令牌。应保存到应用安全存储。
|
||||
|
||||
## 图谱模型
|
||||
|
||||
```text
|
||||
匿名设备 A ─┐
|
||||
├─ alias → Master A ← 已验证 Email X
|
||||
匿名设备 B ─┘ ← 已验证 Phone Y
|
||||
← OAuth (issuer/client, subject)
|
||||
```
|
||||
|
||||
邮箱、手机号、OAuth/第三方 ID 唯一性键包含租户和标识类型;OAuth/第三方 ID 额外包含 provider 命名空间。
|
||||
命名空间应包含 issuer 与 client/app 作用域,避免不同 provider 或 pairwise subject 命名冲突。
|
||||
邮箱使用 email-validator 规范化域名,保留 local-part 大小写;不擅自套用 Gmail 去点、去加号规则。
|
||||
手机号必须以 `+` 国家码输入,验证后规范化为 E.164。
|
||||
ID 查找键是带服务端 secret 的 HMAC-SHA256,不保存邮箱/手机号/OAuth subject 的原文;图谱 API 只展示脱敏摘要。
|
||||
用户画像属性与事件属性仍是业务提供的 JSON 数据。
|
||||
|
||||
`TENANT_ID` 在部署中配置,不从客户端可任意指定的请求头读取。一个应用实例服务一个租户。
|
||||
同一个数据库可以容纳不同部署实例的多个租户,查询和唯一约束均带 `tenant_id`。
|
||||
|
||||
## Stitch 流程
|
||||
|
||||
1. 首次访问调用 `/v1/identity/anonymous`,或直接发送 `/v1/events`;系统创建签名 ID 并设置 Cookie。
|
||||
2. 匿名事件绑定原始节点,偏好标签/临时属性由可信后端通过用户画像 PATCH 写入。
|
||||
3. 登录验证成功后,可信后端携带匿名 token 和全部已验证标识调用 `/v1/identity/stitch`。
|
||||
4. 若任意强身份已经连接到 Master ID,归并匿名分量;多个强身份可以传递连接多个分量。
|
||||
5. 同一事务中更新所有别名、合并稀疏属性和标签、记录连接证据,并写入画像投影 outbox。
|
||||
6. 事件保留原始节点。查询通过别名映射返回当前 Master ID 的全部历史,无需等待后台批量重写事件。
|
||||
|
||||
邮箱、手机号或 OAuth 任意一个可信连接即可解析到同一 Master ID。`/resolve` 仅查询,不隐式合并;多个 ID 解析到不同账号时返回 `ambiguous=true`,由已验证的 Stitch 请求明确连接。
|
||||
|
||||
合并目标优先使用可信后端提供的 `account_master_id`;否则使用已连接强身份中最早创建的账号;没有匹配项时保留当前匿名 Master ID。
|
||||
原有目标账号的冲突属性优先,匿名属性填补缺失字段;请求中明确提供的属性最后覆盖。标签去重合并。
|
||||
PATCH 的属性值 `null` 表示删除字段;Stitch 中 `null` 被省略。嵌套对象中空值也被省略。
|
||||
输入允许 200 个顶层属性、64 KiB 属性文档,单次标签最多 100 个、累计最多 500 个。
|
||||
如果合并后超过限制,返回 409 并整体回滚,不静默丢弃数据。
|
||||
|
||||
旧 Master ID 保留为别名,因此旧链接和旧事件仍能解析到新的规范账号。
|
||||
|
||||
### 共享浏览器 / 账号切换
|
||||
|
||||
如果 Cookie 已归属账号 A,而输入的强身份和 A 没有交集,系统创建新匿名节点再连接 B,返回 `rotated_anonymous_id=true`,不会把 A 的历史给 B。
|
||||
对已有账号新增手机或 OAuth 标识时,可信后端传入当前已认证账号的 `account_master_id`。
|
||||
退出登录应调用 `/v1/identity/anonymous?rotate=true`,随后使用返回的新 token / Cookie。
|
||||
若 Stitch 由登录后端代发,该后端需要将返回 Cookie 设置到同一 API 域,或将返回的匿名 token 交回客户端更新;不要让客户端继续使用旧账号的 token。
|
||||
|
||||
## API
|
||||
|
||||
| 方法和路径 | 调用方 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `POST /v1/identity/anonymous` | 浏览器 / 移动端 | 创建或复用匿名 ID;`rotate=true` 重置 |
|
||||
| `POST /v1/events` | 浏览器 / 移动端 | 接收事件;缺少 ID 时自动创建 |
|
||||
| `POST /v1/identity/stitch` | 可信后端 | 已验证标识与匿名 ID 拼接 |
|
||||
| `POST /v1/identity/resolve` | 可信后端 | 按已验证标识解析 Master ID |
|
||||
| `GET /v1/identity/{master_id}/graph` | 可信后端 | 图谱成员、脱敏标识和合并连线 |
|
||||
| `GET /v1/identity/storage` | 可信后端 | 查看实际部署存储设置,不返回凭据 |
|
||||
| `GET /v1/users/{master_id}` | 可信后端 | 画像、标签、历史行为计数 |
|
||||
| `PATCH /v1/users/{master_id}` | 可信后端 | 更新临时/正式画像,空值删除字段 |
|
||||
| `GET /v1/events?master_id=...` | 可信后端 | 全部关联行为,支持 limit / offset |
|
||||
|
||||
首次加载页面时可以直接调用:
|
||||
|
||||
```js
|
||||
await fetch("https://YOUR_API_HOST/v1/identity/anonymous", {
|
||||
method: "POST",
|
||||
credentials: "include"
|
||||
});
|
||||
```
|
||||
|
||||
后端第一次收到匿名请求就签发 ID;第三方 React SDK 的自动初始化集成尚未修改。
|
||||
浏览器请求需使用 `credentials: "include"`。匿名写接口检查 Origin;CORS 使用明确允许来源并启用凭据。
|
||||
Cookie 默认 Secure、HttpOnly、SameSite=Lax,有效期默认一年;本地 HTTP 开发明确设置 `COOKIE_SECURE=false`。
|
||||
跨站点 Cookie 如需 SameSite=None,必须同时启用 Secure。浏览器第三方 Cookie 策略可能阻止 Cookie,应优先将 API 放在第一方域名下。
|
||||
不设置父域 Cookie,不通过第三方设备指纹跨站点识别用户。
|
||||
|
||||
事件可提供 UUID `event_id` 进行幂等重试。相同 ID 的不同主体或内容返回 409;省略 event_id 时每次生成新事件。
|
||||
`occurred_at` 如果提供,必须含时区且不能晚于服务端当前时间。
|
||||
|
||||
Stitch 示例(只在 OTP/OAuth 验证后由可信后端执行):
|
||||
|
||||
```json
|
||||
{
|
||||
"anonymous_token": "服务端此前签发的 token",
|
||||
"identifiers": [
|
||||
{"kind": "email", "value": "alice@example.com"},
|
||||
{"kind": "phone", "value": "+14155552671"},
|
||||
{"kind": "oauth", "namespace": "https://issuer.example/client-id", "value": "verified-subject"}
|
||||
],
|
||||
"attributes": {"preferred_category": "running"},
|
||||
"tags": ["runner"]
|
||||
}
|
||||
```
|
||||
|
||||
## 部署选择:JSONB / Cassandra 宽表
|
||||
|
||||
| 配置 | 身份图谱和规范画像 | 画像读取 |
|
||||
| --- | --- | --- |
|
||||
| 本地 SQLite + relational | SQLite JSON | 关系库 |
|
||||
| PostgreSQL + relational | PostgreSQL JSONB | 关系库 |
|
||||
| PostgreSQL + cassandra | PostgreSQL JSONB | Cassandra 当前版本,缺失时回退 JSONB |
|
||||
|
||||
选择 **Cassandra** 作为宽表后端,不额外引入 HBase 和 Bigtable 的运维/云依赖。
|
||||
身份图谱需要跨节点唯一性和事务合并,保持关系库为唯一事实来源;本实现不支持完全移除关系库的纯 Cassandra 图谱。
|
||||
宽表是可选择的画像投影,不是通过双写伪装成两个独立的事实来源。
|
||||
Cassandra 的数据建模以查询为中心,[并不提供关系库式 JOIN](https://cassandra.apache.org/doc/latest/cassandra/developing/data-modeling/data-modeling_rdbms.html)。
|
||||
因此部署设置切换的是画像读取/投影后端,图谱主库始终由 DATABASE_URL 指定。
|
||||
|
||||
关系库配置:
|
||||
|
||||
```dotenv
|
||||
DATABASE_URL=postgresql+psycopg://USER:PASSWORD@HOST:5432/adcloud
|
||||
PROFILE_BACKEND=relational
|
||||
COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
```sh
|
||||
uv sync --locked --extra postgres
|
||||
```
|
||||
|
||||
启用宽表:
|
||||
|
||||
```dotenv
|
||||
PROFILE_BACKEND=cassandra
|
||||
CASSANDRA_HOSTS=node1,node2,node3
|
||||
CASSANDRA_KEYSPACE=adcloud
|
||||
CASSANDRA_TLS=true
|
||||
```
|
||||
|
||||
1. `uv sync --locked --extra postgres --extra cassandra` 安装两个驱动。
|
||||
2. 在 Cassandra 集群应用 `deploy/cassandra.cql`,按实际 datacenter 名、复制因子、认证和 TLS 配置调整;模板 RF=1 仅用于开发。
|
||||
3. 修改部署配置并重启后端。数据库仍保留 PostgreSQL。
|
||||
4. 切换时执行 `uv run --extra postgres --extra cassandra adcloud-profiles --backfill --once`,将现有画像加入 outbox 并处理一批任务。
|
||||
5. 持续运行 `uv run --extra postgres --extra cassandra adcloud-profiles`,重试失败任务并处理剩余同步任务。
|
||||
|
||||
`.env` 自动加载,环境变量优先。两种模式切换需要重启,不在请求中热切换连接或丢弃历史。
|
||||
切回 relational 时直接从 PostgreSQL 读取完整画像;无需从 Cassandra 迁移回来。
|
||||
|
||||
每个 `(tenant_id, master_id, revision)` 是一个有限宽表分区,属性按字段写入,空字段不占用行。
|
||||
同分区 batch 写入字段和完成标记,以 LOCAL_QUORUM 读写,只读取关系库指定的当前 revision;旧版本不能覆盖新值。
|
||||
投影副本 TTL 为 7 天,过期/延迟/故障时回退规范画像;需要时可 backfill 重建。
|
||||
outbox 与 Stitch 同事务提交;后台发布失败时任务不会确认,下次重试。已归并的旧节点和过期 revision 不再发布。
|
||||
`projection_status` 显示 current / pending / unavailable,便于监控。
|
||||
|
||||
当前 PostgreSQL 写入以租户行锁串行保护合并及唯一标识,SQLite 用 BEGIN IMMEDIATE。这是首版偏向正确性的实现,高吞吐埋点场景后续需拆分写入路径并细化图谱锁,不能把当前实现视为已做广告云规模压测。
|
||||
JSONB 使用 [SQLAlchemy PostgreSQL 方言](https://docs.sqlalchemy.org/en/20/dialects/postgresql.html#jsonb);跨 worker 合并由 [数据库行锁](https://www.postgresql.org/docs/current/explicit-locking.html) 保护。
|
||||
|
||||
IDENTITY_SECRET 同时用于匿名 token 签名和强标识 HMAC。每个部署生成一次并持久保管,所有 worker 保持一致;改变它会使旧 token 失效且旧标识无法匹配,密钥轮换需要单独的迁移方案。
|
||||
+8
-1
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,508 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import phonenumbers
|
||||
from email_validator import EmailNotValidError, validate_email
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, insert, select, update
|
||||
|
||||
from adcloud_server.database import (
|
||||
Database,
|
||||
aliases,
|
||||
edges,
|
||||
events,
|
||||
identifiers,
|
||||
outbox,
|
||||
profiles,
|
||||
)
|
||||
from adcloud_server.identity_models import Identifier, validate_attributes
|
||||
from adcloud_server.settings import Settings
|
||||
from adcloud_server.tokens import AnonymousTokens
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def sparse(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: sparse(item) for key, item in value.items() if item is not None}
|
||||
if isinstance(value, list):
|
||||
return [sparse(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def fill_missing(target, source):
|
||||
result = dict(source)
|
||||
for key, value in target.items():
|
||||
result[key] = (
|
||||
fill_missing(value, source[key])
|
||||
if isinstance(value, dict) and isinstance(source.get(key), dict)
|
||||
else value
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class IdentityService:
|
||||
def __init__(self, settings: Settings, database: Database, projection=None):
|
||||
self.settings = settings
|
||||
self.db = database
|
||||
self.tenant = settings.tenant_id
|
||||
self.tokens = AnonymousTokens(
|
||||
settings.identity_secret, self.tenant, settings.cookie_max_age
|
||||
)
|
||||
self.projection = projection
|
||||
|
||||
def normalized(self, identifier: Identifier):
|
||||
value = identifier.value.strip()
|
||||
namespace = identifier.namespace.strip()
|
||||
try:
|
||||
if identifier.kind == "email":
|
||||
# Preserve the local-part's case: distinct case-sensitive mailboxes
|
||||
# must not be silently merged. Domain/IDNA normalization is safe.
|
||||
value = validate_email(value, check_deliverability=False).normalized
|
||||
local, domain = value.rsplit("@", 1)
|
||||
display = local[:1] + "***@" + domain
|
||||
elif identifier.kind == "phone":
|
||||
if not value.startswith("+"):
|
||||
raise ValueError(
|
||||
"Phone numbers require international +country format"
|
||||
)
|
||||
phone = phonenumbers.parse(value, None)
|
||||
if not phonenumbers.is_valid_number(phone):
|
||||
raise ValueError("Invalid phone number")
|
||||
value = phonenumbers.format_number(
|
||||
phone, phonenumbers.PhoneNumberFormat.E164
|
||||
)
|
||||
display = "***" + value[-4:]
|
||||
else:
|
||||
if not value or not namespace:
|
||||
raise ValueError("Provider namespace and subject are required")
|
||||
display = "<redacted>"
|
||||
except (
|
||||
ValueError,
|
||||
EmailNotValidError,
|
||||
phonenumbers.NumberParseException,
|
||||
) as error:
|
||||
raise HTTPException(422, "Invalid identity identifier") from error
|
||||
payload = json.dumps(
|
||||
[self.tenant, identifier.kind, namespace, value],
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode()
|
||||
digest = hmac.new(
|
||||
self.settings.identity_secret.encode(),
|
||||
b"identifier:" + payload,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return {
|
||||
"digest": digest,
|
||||
"kind": identifier.kind,
|
||||
"namespace": namespace,
|
||||
"display": display,
|
||||
}
|
||||
|
||||
def _scope(self, table):
|
||||
return table.c.tenant_id == self.tenant
|
||||
|
||||
def _root(self, conn, subject_id):
|
||||
root = conn.execute(
|
||||
select(aliases.c.canonical_id).where(
|
||||
self._scope(aliases), aliases.c.id == subject_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if root is None:
|
||||
raise HTTPException(404, "User not found")
|
||||
return root
|
||||
|
||||
def _members(self, root):
|
||||
return select(aliases.c.id).where(
|
||||
self._scope(aliases), aliases.c.canonical_id == root
|
||||
)
|
||||
|
||||
def _profile(self, conn, root):
|
||||
return (
|
||||
conn.execute(
|
||||
select(profiles).where(self._scope(profiles), profiles.c.id == root)
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
def _queue(self, conn, root, revision, document):
|
||||
if self.settings.profile_backend == "cassandra":
|
||||
conn.execute(
|
||||
insert(outbox).values(
|
||||
tenant_id=self.tenant,
|
||||
id=str(uuid4()),
|
||||
master_id=root,
|
||||
revision=revision,
|
||||
document=document,
|
||||
created_at=now(),
|
||||
delivered=0,
|
||||
)
|
||||
)
|
||||
|
||||
def _new(self, conn):
|
||||
subject = str(uuid4())
|
||||
document = {"attributes": {}, "tags": []}
|
||||
conn.execute(
|
||||
insert(profiles).values(
|
||||
tenant_id=self.tenant,
|
||||
id=subject,
|
||||
document=document,
|
||||
revision=1,
|
||||
created_at=now(),
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
insert(aliases).values(
|
||||
tenant_id=self.tenant, id=subject, canonical_id=subject
|
||||
)
|
||||
)
|
||||
self._queue(conn, subject, 1, document)
|
||||
return subject
|
||||
|
||||
def _anonymous(self, conn, token, *, strict=False):
|
||||
if token:
|
||||
try:
|
||||
subject = self.tokens.verify(token)
|
||||
self._root(conn, subject)
|
||||
return subject
|
||||
except (ValueError, HTTPException) as error:
|
||||
if strict:
|
||||
raise HTTPException(
|
||||
401, "Invalid or expired anonymous token"
|
||||
) from error
|
||||
elif strict:
|
||||
raise HTTPException(401, "Anonymous token required")
|
||||
return self._new(conn)
|
||||
|
||||
def _result(self, conn, subject):
|
||||
return {
|
||||
"anonymous_id": subject,
|
||||
"anonymous_token": self.tokens.issue(subject),
|
||||
"master_id": self._root(conn, subject),
|
||||
}
|
||||
|
||||
def anonymous(self, token=None, *, rotate=False):
|
||||
with self.db.transaction() as conn:
|
||||
subject = self._new(conn) if rotate else self._anonymous(conn, token)
|
||||
return self._result(conn, subject)
|
||||
|
||||
def _lookup(self, conn, normalized):
|
||||
digests = [item["digest"] for item in normalized]
|
||||
return set(
|
||||
conn.execute(
|
||||
select(aliases.c.canonical_id)
|
||||
.select_from(
|
||||
identifiers.join(
|
||||
aliases,
|
||||
(identifiers.c.tenant_id == aliases.c.tenant_id)
|
||||
& (identifiers.c.subject_id == aliases.c.id),
|
||||
)
|
||||
)
|
||||
.where(self._scope(identifiers), identifiers.c.digest.in_(digests))
|
||||
).scalars()
|
||||
)
|
||||
|
||||
def resolve(self, supplied):
|
||||
normalized = [self.normalized(item) for item in supplied]
|
||||
with self.db.transaction() as conn:
|
||||
matches = sorted(self._lookup(conn, normalized))
|
||||
return {
|
||||
"master_id": matches[0] if len(matches) == 1 else None,
|
||||
"matches": matches,
|
||||
"ambiguous": len(matches) > 1,
|
||||
}
|
||||
|
||||
def _save_profile(self, conn, root, document):
|
||||
try:
|
||||
validate_attributes(document["attributes"])
|
||||
if len(document["tags"]) > 500:
|
||||
raise ValueError("Too many tags")
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
409, "Merged profile exceeds supported limits"
|
||||
) from error
|
||||
revision = self._profile(conn, root)["revision"] + 1
|
||||
conn.execute(
|
||||
update(profiles)
|
||||
.where(self._scope(profiles), profiles.c.id == root)
|
||||
.values(document=document, revision=revision)
|
||||
)
|
||||
self._queue(conn, root, revision, document)
|
||||
return revision
|
||||
|
||||
def stitch(self, token, request):
|
||||
normalized = list(
|
||||
{
|
||||
item["digest"]: item
|
||||
for item in (
|
||||
self.normalized(identifier) for identifier in request.identifiers
|
||||
)
|
||||
}.values()
|
||||
)
|
||||
with self.db.transaction() as conn:
|
||||
subject = self._anonymous(conn, token, strict=True)
|
||||
source = self._root(conn, subject)
|
||||
matches = self._lookup(conn, normalized)
|
||||
own_digests = set(
|
||||
conn.execute(
|
||||
select(identifiers.c.digest).where(
|
||||
self._scope(identifiers),
|
||||
identifiers.c.subject_id.in_(self._members(source)),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
account = (
|
||||
self._root(conn, str(request.account_master_id))
|
||||
if request.account_master_id
|
||||
else None
|
||||
)
|
||||
# Cookie/device continuity alone cannot unite two logged-in people.
|
||||
rotated = bool(
|
||||
own_digests
|
||||
and not own_digests.intersection(item["digest"] for item in normalized)
|
||||
and account != source
|
||||
)
|
||||
if rotated:
|
||||
subject = self._new(conn)
|
||||
source = subject
|
||||
if account:
|
||||
matches.add(account)
|
||||
if matches:
|
||||
target = account or min(
|
||||
matches,
|
||||
key=lambda root: (self._profile(conn, root)["created_at"], root),
|
||||
)
|
||||
else:
|
||||
target = source
|
||||
losers = sorted((matches | {source}) - {target})
|
||||
document = self._profile(conn, target)["document"]
|
||||
attributes = document["attributes"]
|
||||
tags = set(document["tags"])
|
||||
for loser in losers:
|
||||
old = self._profile(conn, loser)["document"]
|
||||
attributes = fill_missing(attributes, old["attributes"])
|
||||
tags.update(old["tags"])
|
||||
conn.execute(
|
||||
update(aliases)
|
||||
.where(self._scope(aliases), aliases.c.canonical_id == loser)
|
||||
.values(canonical_id=target)
|
||||
)
|
||||
conn.execute(
|
||||
update(profiles)
|
||||
.where(self._scope(profiles), profiles.c.id == loser)
|
||||
.values(document={"attributes": {}, "tags": []})
|
||||
)
|
||||
conn.execute(
|
||||
insert(edges).values(
|
||||
tenant_id=self.tenant,
|
||||
id=str(uuid4()),
|
||||
source_id=loser,
|
||||
target_id=target,
|
||||
evidence=[
|
||||
{
|
||||
"kind": item["kind"],
|
||||
"namespace": item["namespace"],
|
||||
"digest": item["digest"],
|
||||
}
|
||||
for item in normalized
|
||||
],
|
||||
created_at=now(),
|
||||
)
|
||||
)
|
||||
# Verified server-supplied traits override inherited traits; null fields
|
||||
# are omitted during stitch. PATCH can explicitly remove a field.
|
||||
attributes = {**attributes, **sparse(request.attributes)}
|
||||
tags.update(request.tags)
|
||||
revision = self._save_profile(
|
||||
conn, target, {"attributes": attributes, "tags": sorted(tags)}
|
||||
)
|
||||
for item in normalized:
|
||||
found = conn.execute(
|
||||
select(identifiers.c.digest).where(
|
||||
self._scope(identifiers), identifiers.c.digest == item["digest"]
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if found is None:
|
||||
conn.execute(
|
||||
insert(identifiers).values(
|
||||
tenant_id=self.tenant,
|
||||
subject_id=target,
|
||||
created_at=now(),
|
||||
**item,
|
||||
)
|
||||
)
|
||||
result = self._result(conn, subject)
|
||||
result.update(
|
||||
{
|
||||
"merged_master_ids": losers,
|
||||
"rotated_anonymous_id": rotated,
|
||||
"revision": revision,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def patch_profile(self, master_id, patch):
|
||||
with self.db.transaction() as conn:
|
||||
root = self._root(conn, master_id)
|
||||
document = self._profile(conn, root)["document"]
|
||||
attributes = {**document["attributes"], **patch.attributes}
|
||||
document = {
|
||||
"attributes": sparse(attributes),
|
||||
"tags": sorted(set(document["tags"]) | set(patch.tags)),
|
||||
}
|
||||
revision = self._save_profile(conn, root, document)
|
||||
return {"master_id": root, **document, "revision": revision}
|
||||
|
||||
def profile(self, master_id):
|
||||
with self.db.transaction() as conn:
|
||||
root = self._root(conn, master_id)
|
||||
row = self._profile(conn, root)
|
||||
document = row["document"]
|
||||
count = conn.execute(
|
||||
select(func.count())
|
||||
.select_from(events)
|
||||
.where(
|
||||
self._scope(events), events.c.subject_id.in_(self._members(root))
|
||||
)
|
||||
).scalar_one()
|
||||
projection_status = "not_enabled"
|
||||
if self.projection is not None:
|
||||
try:
|
||||
projected = self.projection.get(self.tenant, root, row["revision"])
|
||||
projection_status = "current" if projected is not None else "pending"
|
||||
if projected is not None:
|
||||
document = projected
|
||||
except Exception: # noqa: BLE001 -- Any projection failure must preserve canonical reads.
|
||||
# Canonical JSONB data provides read-after-stitch consistency even
|
||||
# if Cassandra is behind or unavailable. No exception/PII leakage.
|
||||
projection_status = "unavailable"
|
||||
return {
|
||||
"master_id": root,
|
||||
**document,
|
||||
"revision": row["revision"],
|
||||
"event_count": count,
|
||||
"profile_backend": self.settings.profile_backend,
|
||||
"projection_status": projection_status,
|
||||
}
|
||||
|
||||
def graph(self, master_id):
|
||||
with self.db.transaction() as conn:
|
||||
root = self._root(conn, master_id)
|
||||
members = sorted(conn.execute(self._members(root)).scalars())
|
||||
linked = (
|
||||
conn.execute(
|
||||
select(
|
||||
identifiers.c.kind,
|
||||
identifiers.c.namespace,
|
||||
identifiers.c.display,
|
||||
identifiers.c.subject_id,
|
||||
identifiers.c.created_at,
|
||||
)
|
||||
.where(
|
||||
self._scope(identifiers), identifiers.c.subject_id.in_(members)
|
||||
)
|
||||
.order_by(identifiers.c.created_at, identifiers.c.digest)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
connections = (
|
||||
conn.execute(
|
||||
select(edges.c.source_id, edges.c.target_id, edges.c.created_at)
|
||||
.where(
|
||||
self._scope(edges),
|
||||
edges.c.source_id.in_(members),
|
||||
edges.c.target_id.in_(members),
|
||||
)
|
||||
.order_by(edges.c.created_at, edges.c.id)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"master_id": root,
|
||||
"member_ids": members,
|
||||
"identifiers": [dict(item) for item in linked],
|
||||
"edges": [dict(item) for item in connections],
|
||||
}
|
||||
|
||||
def record_event(self, token, request):
|
||||
with self.db.transaction() as conn:
|
||||
subject = self._anonymous(conn, token, strict=bool(request.anonymous_token))
|
||||
event_id = str(request.event_id or uuid4())
|
||||
existing = (
|
||||
conn.execute(
|
||||
select(events).where(self._scope(events), events.c.id == event_id)
|
||||
)
|
||||
.mappings()
|
||||
.one_or_none()
|
||||
)
|
||||
occurred_at = (
|
||||
request.occurred_at.astimezone(UTC).isoformat()
|
||||
if request.occurred_at
|
||||
else now()
|
||||
)
|
||||
if existing:
|
||||
if (
|
||||
existing["anonymous_id"] != subject
|
||||
or existing["name"] != request.name
|
||||
or existing["properties"] != request.properties
|
||||
or (request.occurred_at and existing["occurred_at"] != occurred_at)
|
||||
):
|
||||
raise HTTPException(
|
||||
409, "Event ID already used by a different event"
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
insert(events).values(
|
||||
tenant_id=self.tenant,
|
||||
id=event_id,
|
||||
subject_id=subject,
|
||||
anonymous_id=subject,
|
||||
name=request.name,
|
||||
properties=request.properties,
|
||||
occurred_at=occurred_at,
|
||||
)
|
||||
)
|
||||
result = self._result(conn, subject)
|
||||
result.update({"event_id": event_id, "duplicate": existing is not None})
|
||||
return result
|
||||
|
||||
def history(self, master_id, limit=100, offset=0):
|
||||
with self.db.transaction() as conn:
|
||||
root = self._root(conn, master_id)
|
||||
scope = [self._scope(events), events.c.subject_id.in_(self._members(root))]
|
||||
rows = (
|
||||
conn.execute(
|
||||
select(
|
||||
events.c.id,
|
||||
events.c.anonymous_id,
|
||||
events.c.name,
|
||||
events.c.properties,
|
||||
events.c.occurred_at,
|
||||
)
|
||||
.where(*scope)
|
||||
.order_by(events.c.occurred_at, events.c.id)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
count = conn.execute(
|
||||
select(func.count()).select_from(events).where(*scope)
|
||||
).scalar_one()
|
||||
return {
|
||||
"master_id": root,
|
||||
"total": count,
|
||||
"items": [dict(item) for item in rows],
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
+33
-11
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"] == {}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user