Initial import of cf-temp-email deploy CLI
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
config.toml
|
||||||
|
config.old.toml
|
||||||
363
README.md
Normal file
363
README.md
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
# cf-temp-email
|
||||||
|
|
||||||
|
Cloudflare Temp Email 的自动化部署 CLI。它会编排 Cloudflare REST API、`npm` 和 `wrangler`,统一处理以下资源:
|
||||||
|
|
||||||
|
- Pages
|
||||||
|
- Worker
|
||||||
|
- D1
|
||||||
|
- Email Routing
|
||||||
|
- Catch-all 到 Worker
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
- 从零部署一套 Cloudflare Temp Email
|
||||||
|
- 用一个 `config.toml` 管多个 Cloudflare 账号
|
||||||
|
- 从 Cloudflare 现有资源反推配置,再继续接管部署
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
在 `cftemail/` 目录下执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run cf-temp-email init-config --config config.toml
|
||||||
|
uv run cf-temp-email check --config config.toml
|
||||||
|
uv run cf-temp-email deploy --config config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
省略子命令时默认执行 `deploy`,所以:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cf-temp-email --config config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
等价于:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cf-temp-email deploy --config config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
可用命令:
|
||||||
|
|
||||||
|
- `init-config`
|
||||||
|
- `check`
|
||||||
|
- `deploy`
|
||||||
|
- `resume`
|
||||||
|
- `discover-config`
|
||||||
|
|
||||||
|
常用示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cf-temp-email check --config config.toml --profile kotei
|
||||||
|
uv run cf-temp-email deploy --config config.toml --profile kotei
|
||||||
|
uv run cf-temp-email resume --config config.toml --profile kotei
|
||||||
|
```
|
||||||
|
|
||||||
|
### `discover-config`
|
||||||
|
|
||||||
|
用于从 Cloudflare 现有资源反推配置,并写回 `config.toml`。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cf-temp-email discover-config \
|
||||||
|
--config config.toml \
|
||||||
|
--profile kotei \
|
||||||
|
--api-token "$CF_API_TOKEN_KOTEI" \
|
||||||
|
--zone-name "kotei.us.ci"
|
||||||
|
```
|
||||||
|
|
||||||
|
当前能自动发现并写入的字段包括:
|
||||||
|
|
||||||
|
- `cloudflare.account_id`
|
||||||
|
- `cloudflare.account_name`
|
||||||
|
- `cloudflare.zone_name`
|
||||||
|
- `mail.domains`
|
||||||
|
- `mail.verified_destination_address`
|
||||||
|
- `d1.database_name`
|
||||||
|
- `worker.script_name`
|
||||||
|
- `pages.project_name`
|
||||||
|
- `pages.custom_domain`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 只有 Cloudflare 能读到的字段才会写入
|
||||||
|
- `ADMIN_PASSWORDS`、`JWT_SECRET` 之类的值无法从 Cloudflare 反读
|
||||||
|
- 如果候选资源不唯一,命令会跳过,不会乱猜
|
||||||
|
|
||||||
|
## 多账号配置
|
||||||
|
|
||||||
|
支持在一个 `config.toml` 里定义多个账号或环境,用 `profiles.<name>` 区分。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[profiles.account_a.cloudflare]
|
||||||
|
account_id = "acc-a"
|
||||||
|
api_token_env = "CF_API_TOKEN_A"
|
||||||
|
zone_name = "example.com"
|
||||||
|
|
||||||
|
[profiles.account_a.mail]
|
||||||
|
domains = ["mail.example.com", "mail2.example.com"]
|
||||||
|
|
||||||
|
[profiles.account_a.pages]
|
||||||
|
custom_domain = "email.example.com"
|
||||||
|
|
||||||
|
[profiles.account_b.cloudflare]
|
||||||
|
account_id = "acc-b"
|
||||||
|
api_token_env = "CF_API_TOKEN_B"
|
||||||
|
zone_name = "kotei.asia"
|
||||||
|
|
||||||
|
[profiles.account_b.mail]
|
||||||
|
domains = ["mail.kotei.asia", "maila.kotei.asia"]
|
||||||
|
|
||||||
|
[profiles.account_b.pages]
|
||||||
|
custom_domain = "email.kotei.asia"
|
||||||
|
```
|
||||||
|
|
||||||
|
执行时通过 `--profile` 选择:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cf-temp-email deploy --config config.toml --profile account_a
|
||||||
|
uv run cf-temp-email deploy --config config.toml --profile account_b
|
||||||
|
```
|
||||||
|
|
||||||
|
行为说明:
|
||||||
|
|
||||||
|
- CLI 覆写会写入对应的 `profiles.<name>.*`
|
||||||
|
- 每个 profile 使用独立状态文件:
|
||||||
|
`.deploy/profiles/<profile>/state.toml`
|
||||||
|
- 默认 clone 模式下,每个 profile 也会使用独立工作目录
|
||||||
|
|
||||||
|
## 域名模型
|
||||||
|
|
||||||
|
这套工具把“邮箱域名”和“前端域名”分开管理。
|
||||||
|
|
||||||
|
### 1. 邮箱域名
|
||||||
|
|
||||||
|
来自 `mail.domains`,支持多个域名,也支持二级域名。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mail]
|
||||||
|
domains = ["kotei.us.ci", "mail.kotei.us.ci"]
|
||||||
|
```
|
||||||
|
|
||||||
|
这表示系统会同时处理:
|
||||||
|
|
||||||
|
- `@kotei.us.ci`
|
||||||
|
- `@mail.kotei.us.ci`
|
||||||
|
|
||||||
|
### 2. 前端域名
|
||||||
|
|
||||||
|
来自 `pages.custom_domain`,当前一次部署只管理一个前端域名。
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[pages]
|
||||||
|
custom_domain = "temp-mail.kotei.us.ci"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 约束
|
||||||
|
|
||||||
|
- `mail.domains` 可以是多个
|
||||||
|
- `pages.custom_domain` 当前是单个
|
||||||
|
- `pages.custom_domain` 不能和 `mail.domains` 中的任意一个重复
|
||||||
|
|
||||||
|
原因是:
|
||||||
|
|
||||||
|
- `mail.domains` 需要承载 Email Routing 的 `MX/TXT`
|
||||||
|
- `pages.custom_domain` 需要承载 Pages 的 `CNAME`
|
||||||
|
|
||||||
|
同一个主机名不能同时做这两件事。
|
||||||
|
|
||||||
|
## 配置要点
|
||||||
|
|
||||||
|
### `cloudflare`
|
||||||
|
|
||||||
|
至少需要:
|
||||||
|
|
||||||
|
- `zone_name`
|
||||||
|
- `api_token` 或 `api_token_env`
|
||||||
|
|
||||||
|
更推荐使用环境变量:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[cloudflare]
|
||||||
|
api_token_env = "CF_API_TOKEN_KOTEI"
|
||||||
|
zone_name = "kotei.us.ci"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `mail.verified_destination_address`
|
||||||
|
|
||||||
|
现在是可选项。
|
||||||
|
|
||||||
|
行为如下:
|
||||||
|
|
||||||
|
- 有值:部署时会校验该地址是否已在 Cloudflare 侧可用
|
||||||
|
- 空值:跳过目标地址校验,仍继续配置 `MX/TXT` 和 Catch-all Worker
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mail]
|
||||||
|
verified_destination_address = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### `worker.vars.ADMIN_PASSWORDS`
|
||||||
|
|
||||||
|
这是必填项。部署时会使用第一个值请求:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<worker.custom_domain>/admin/*
|
||||||
|
```
|
||||||
|
|
||||||
|
用于同步:
|
||||||
|
|
||||||
|
- 用户注册开关
|
||||||
|
- 登录配置
|
||||||
|
- 其他管理员接口设置
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[worker.vars]
|
||||||
|
ADMIN_PASSWORDS = ["your-strong-password"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `worker.secrets.JWT_SECRET`
|
||||||
|
|
||||||
|
可以留空。首次部署前如果为空,脚本会自动生成并写回配置文件。
|
||||||
|
|
||||||
|
### `linuxdo`
|
||||||
|
|
||||||
|
如果不用 Linux.do,保持:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[linuxdo]
|
||||||
|
linuxdo_oauth = false
|
||||||
|
```
|
||||||
|
|
||||||
|
只有在 `linuxdo_oauth = true` 时,才需要填写:
|
||||||
|
|
||||||
|
- `client_id`
|
||||||
|
- `client_secret`
|
||||||
|
|
||||||
|
## CLI 覆写
|
||||||
|
|
||||||
|
支持显式参数覆写:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cf-temp-email deploy \
|
||||||
|
--config config.toml \
|
||||||
|
--profile kotei \
|
||||||
|
--api-token "$CF_API_TOKEN_KOTEI" \
|
||||||
|
--zone-name "kotei.us.ci" \
|
||||||
|
--pages-domain "temp-mail.kotei.us.ci" \
|
||||||
|
--worker-name "cloudflare_temp_email"
|
||||||
|
```
|
||||||
|
|
||||||
|
也支持通用 `--set`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cf-temp-email deploy \
|
||||||
|
--config config.toml \
|
||||||
|
--profile kotei \
|
||||||
|
--set worker.vars.ADMIN_PASSWORDS='["abc.123"]'
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- CLI 覆写会先写回 `config.toml`
|
||||||
|
- 然后脚本会重新读取配置并执行
|
||||||
|
|
||||||
|
## 部署阶段
|
||||||
|
|
||||||
|
部署顺序如下:
|
||||||
|
|
||||||
|
1. 检查本地工具与 Cloudflare 授权
|
||||||
|
2. 准备源码目录
|
||||||
|
3. 创建或复用 Pages 项目
|
||||||
|
4. 配置 Pages CNAME
|
||||||
|
5. 创建或复用 D1,并执行迁移
|
||||||
|
6. 生成 `worker/wrangler.toml` 并发布 Worker
|
||||||
|
7. 同步管理员接口配置
|
||||||
|
8. 配置 Email Routing DNS 与 Catch-all Worker
|
||||||
|
9. 构建并发布 Pages
|
||||||
|
10. 执行验收检查
|
||||||
|
|
||||||
|
运行状态会写入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.deploy/state.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
如果使用 profile,则写入:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.deploy/profiles/<profile>/state.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cloudflare Token 权限
|
||||||
|
|
||||||
|
最小部署权限建议包括:
|
||||||
|
|
||||||
|
1. `Cloudflare Pages: Edit`
|
||||||
|
2. `D1: Edit`
|
||||||
|
3. `Workers Scripts: Write`
|
||||||
|
4. `DNS: Write`
|
||||||
|
5. `Email Routing Rules: Edit`
|
||||||
|
|
||||||
|
如果你要使用 `discover-config`,再补这些读取权限:
|
||||||
|
|
||||||
|
1. `Zone: Read`
|
||||||
|
2. `DNS: Read`
|
||||||
|
3. `Cloudflare Pages: Read`
|
||||||
|
4. `Workers Scripts: Read`
|
||||||
|
5. `D1: Read`
|
||||||
|
6. `Email Routing Addresses: Read`
|
||||||
|
7. `Email Routing Rules: Read`
|
||||||
|
|
||||||
|
资源范围建议只限定到目标账户和目标 Zone。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. 多个邮箱域名是否需要多个 Pages?
|
||||||
|
|
||||||
|
不需要。
|
||||||
|
|
||||||
|
通常一个 Pages 前端域名就够了,例如:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mail]
|
||||||
|
domains = ["kotei.us.ci", "mail.kotei.us.ci"]
|
||||||
|
|
||||||
|
[pages]
|
||||||
|
custom_domain = "temp-mail.kotei.us.ci"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Catch-all 已经指向 Worker,为什么还会提到 destination address?
|
||||||
|
|
||||||
|
当前版本中,`verified_destination_address` 已经是可选项。留空时不会阻塞部署。
|
||||||
|
|
||||||
|
### 3. 旧的 `cf_temp_email_deploy.py` 还能用吗?
|
||||||
|
|
||||||
|
不能。当前统一入口是:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run cf-temp-email ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 建议流程
|
||||||
|
|
||||||
|
如果你是接管一个现有站点,推荐按这个顺序:
|
||||||
|
|
||||||
|
1. `discover-config`
|
||||||
|
2. 手动补 `ADMIN_PASSWORDS`
|
||||||
|
3. 检查 `mail.domains`、`pages.custom_domain`、`worker.custom_domain`
|
||||||
|
4. 运行 `check`
|
||||||
|
5. 再运行 `deploy`
|
||||||
154
config.样例.toml
Normal file
154
config.样例.toml
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# cf-temp-email 多账号配置样例
|
||||||
|
#
|
||||||
|
# 用法示例:
|
||||||
|
# uv run cf-temp-email check --config config.样例.toml --profile account_a
|
||||||
|
# uv run cf-temp-email deploy --config config.样例.toml --profile account_a
|
||||||
|
# uv run cf-temp-email deploy --config config.样例.toml --profile account_b
|
||||||
|
#
|
||||||
|
# 说明:
|
||||||
|
# 1. 根配置可以作为所有 profile 的公共默认值。
|
||||||
|
# 2. [profiles.<name>.*] 只写需要覆盖的字段即可。
|
||||||
|
# 3. 每个 profile 会使用独立的状态文件:
|
||||||
|
# .deploy/profiles/<profile>/state.toml
|
||||||
|
# 4. clone 模式下,如果没有单独指定 workspace_dir,程序会自动给每个 profile 使用独立 workspace。
|
||||||
|
|
||||||
|
# 主配置版本号,供 CLI 与状态加载逻辑识别。
|
||||||
|
config_version = 1
|
||||||
|
|
||||||
|
[source]
|
||||||
|
# "clone": 自动拉取远端仓库并构建
|
||||||
|
# "local": 使用本地源码目录
|
||||||
|
mode = "clone"
|
||||||
|
repo_url = "https://github.com/dreamhunter2333/cloudflare_temp_email.git"
|
||||||
|
repo_ref = ""
|
||||||
|
workspace_dir = ".deploy/workspace"
|
||||||
|
local_path = ""
|
||||||
|
|
||||||
|
[cloudflare]
|
||||||
|
# 根配置可以放公共默认值。
|
||||||
|
# 如果你的不同账号差异很大,也可以只在各自 profile 中填写。
|
||||||
|
account_id = ""
|
||||||
|
account_name = ""
|
||||||
|
zone_name = "example.com"
|
||||||
|
api_token = ""
|
||||||
|
api_token_env = ""
|
||||||
|
api_email = ""
|
||||||
|
api_email_env = ""
|
||||||
|
global_api_key = ""
|
||||||
|
global_api_key_env = ""
|
||||||
|
|
||||||
|
[mail]
|
||||||
|
# 邮箱域名列表。支持多个域名,也支持二级域名。
|
||||||
|
# 例如可同时支持:
|
||||||
|
# - @mail.example.com
|
||||||
|
# - @mail2.example.com
|
||||||
|
domains = ["mail.example.com"]
|
||||||
|
verified_destination_address = "inbox@example.net"
|
||||||
|
|
||||||
|
[d1]
|
||||||
|
database_name = "cf-temp-email-db"
|
||||||
|
jurisdiction = ""
|
||||||
|
adopt_existing_schema = false
|
||||||
|
|
||||||
|
[user_access]
|
||||||
|
require_login_to_create = true
|
||||||
|
allow_user_register = false
|
||||||
|
|
||||||
|
[linuxdo]
|
||||||
|
# 不想用 Linux.do 时保持 false 即可。
|
||||||
|
linuxdo_oauth = false
|
||||||
|
client_id = ""
|
||||||
|
client_secret = ""
|
||||||
|
|
||||||
|
[worker]
|
||||||
|
script_name = "temp-email-api"
|
||||||
|
use_workers_dev = true
|
||||||
|
custom_domain = "email-api.example.com"
|
||||||
|
compatibility_date = "2024-09-23"
|
||||||
|
|
||||||
|
[worker.vars]
|
||||||
|
PREFIX = ""
|
||||||
|
ENABLE_USER_CREATE_EMAIL = true
|
||||||
|
ENABLE_USER_DELETE_EMAIL = true
|
||||||
|
DEFAULT_LANG = "zh"
|
||||||
|
# 管理员密码,部署时会使用第一个值访问 /admin/* 接口。
|
||||||
|
ADMIN_PASSWORDS = ["change-me"]
|
||||||
|
|
||||||
|
[worker.secrets]
|
||||||
|
JWT_SECRET = ""
|
||||||
|
|
||||||
|
[pages]
|
||||||
|
# Pages 前端域名,必须和 mail.domains 里的域名不同。
|
||||||
|
# 例如:
|
||||||
|
# - mail.domains = ["mail.example.com"]
|
||||||
|
# - pages.custom_domain = "email.example.com"
|
||||||
|
project_name = "cf-temp-email-pages"
|
||||||
|
custom_domain = "email.example.com"
|
||||||
|
build_mode = "pages"
|
||||||
|
production_branch = "production"
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Profile 样例 1:账号 A
|
||||||
|
# Zone: example.com
|
||||||
|
# 邮箱域名:
|
||||||
|
# - @mail.example.com
|
||||||
|
# - @mail2.example.com
|
||||||
|
# ------------------------------
|
||||||
|
|
||||||
|
[profiles.account_a.cloudflare]
|
||||||
|
account_id = "acc-a"
|
||||||
|
api_token_env = "CF_API_TOKEN_A"
|
||||||
|
zone_name = "example.com"
|
||||||
|
|
||||||
|
[profiles.account_a.mail]
|
||||||
|
domains = ["mail.example.com", "mail2.example.com"]
|
||||||
|
verified_destination_address = "inbox@example.net"
|
||||||
|
|
||||||
|
[profiles.account_a.d1]
|
||||||
|
database_name = "cf-temp-email-a"
|
||||||
|
|
||||||
|
[profiles.account_a.worker]
|
||||||
|
script_name = "temp-email-api-a"
|
||||||
|
custom_domain = "email-api.example.com"
|
||||||
|
|
||||||
|
[profiles.account_a.worker.vars]
|
||||||
|
ADMIN_PASSWORDS = ["admin-password-a"]
|
||||||
|
|
||||||
|
[profiles.account_a.pages]
|
||||||
|
project_name = "cf-temp-email-pages-a"
|
||||||
|
custom_domain = "email.example.com"
|
||||||
|
|
||||||
|
# ------------------------------
|
||||||
|
# Profile 样例 2:账号 B
|
||||||
|
# Zone: kotei.asia
|
||||||
|
# 邮箱域名:
|
||||||
|
# - @mail.kotei.asia
|
||||||
|
# - @maila.kotei.asia
|
||||||
|
# Pages 前端域名:
|
||||||
|
# - email.kotei.asia
|
||||||
|
# Worker 健康检查域名:
|
||||||
|
# - email-api.kotei.asia
|
||||||
|
# ------------------------------
|
||||||
|
|
||||||
|
[profiles.account_b.cloudflare]
|
||||||
|
account_id = "acc-b"
|
||||||
|
api_token_env = "CF_API_TOKEN_B"
|
||||||
|
zone_name = "kotei.asia"
|
||||||
|
|
||||||
|
[profiles.account_b.mail]
|
||||||
|
domains = ["mail.kotei.asia", "maila.kotei.asia"]
|
||||||
|
verified_destination_address = "inbox@example.net"
|
||||||
|
|
||||||
|
[profiles.account_b.d1]
|
||||||
|
database_name = "cf-temp-email-b"
|
||||||
|
|
||||||
|
[profiles.account_b.worker]
|
||||||
|
script_name = "temp-email-api-b"
|
||||||
|
custom_domain = "email-api.kotei.asia"
|
||||||
|
|
||||||
|
[profiles.account_b.worker.vars]
|
||||||
|
ADMIN_PASSWORDS = ["admin-password-b"]
|
||||||
|
|
||||||
|
[profiles.account_b.pages]
|
||||||
|
project_name = "cf-temp-email-pages-b"
|
||||||
|
custom_domain = "email.kotei.asia"
|
||||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling>=1.27.0"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "cf-temp-email"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Automated deployment CLI for Cloudflare Temp Email."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"httpx>=0.28.1,<0.29.0",
|
||||||
|
"pydantic>=2.11.2,<3.0.0",
|
||||||
|
"tomlkit>=0.13.2,<1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3.5,<9.0.0",
|
||||||
|
"pytest-cov>=6.1.1,<7.0.0",
|
||||||
|
"respx>=0.22.0,<1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
cf-temp-email = "cf_temp_email_deploy.cli:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/cf_temp_email_deploy"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = "-ra"
|
||||||
|
testpaths = ["tests"]
|
||||||
6
src/cf_temp_email_deploy/__init__.py
Normal file
6
src/cf_temp_email_deploy/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Cloudflare Temp Email automated deployment package."""
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
6
src/cf_temp_email_deploy/__main__.py
Normal file
6
src/cf_temp_email_deploy/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from cf_temp_email_deploy.cli import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
||||||
221
src/cf_temp_email_deploy/app_admin.py
Normal file
221
src/cf_temp_email_deploy/app_admin.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""Application admin API helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.errors import ApplicationAPIError
|
||||||
|
|
||||||
|
LINUXDO_OAUTH2_ICON = (
|
||||||
|
'<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em">'
|
||||||
|
'<g><path d="m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z" fill="#EFEFEF"/>'
|
||||||
|
'<path d="m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z" fill="#FEB005"/>'
|
||||||
|
'<path d="m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z" fill="#1D1D1F"/></g></svg>'
|
||||||
|
)
|
||||||
|
LINUXDO_OAUTH2_NAME = "LINUX DO"
|
||||||
|
LINUXDO_AUTHORIZATION_URL = "https://connect.linux.do/oauth2/authorize"
|
||||||
|
LINUXDO_ACCESS_TOKEN_URL = "https://connect.linux.do/oauth2/token"
|
||||||
|
LINUXDO_USER_INFO_URL = "https://connect.linux.do/api/user"
|
||||||
|
LINUXDO_SCOPE = "user:email"
|
||||||
|
USER_SETTINGS_DEFAULTS = {
|
||||||
|
"enableMailVerify": False,
|
||||||
|
"verifyMailSender": "",
|
||||||
|
"enableMailAllowList": False,
|
||||||
|
"mailAllowList": [],
|
||||||
|
"maxAddressCount": 5,
|
||||||
|
"enableEmailCheckRegex": False,
|
||||||
|
"emailCheckRegex": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def linuxdo_oauth_callback_url(pages_domain: str) -> str:
|
||||||
|
return f"https://{pages_domain}/user/oauth2/callback"
|
||||||
|
|
||||||
|
|
||||||
|
def merge_user_settings(current: object, allow_user_register: bool) -> dict[str, Any]:
|
||||||
|
merged = dict(current) if isinstance(current, dict) else {}
|
||||||
|
for key, value in USER_SETTINGS_DEFAULTS.items():
|
||||||
|
merged.setdefault(key, value)
|
||||||
|
merged["enable"] = allow_user_register
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def build_linuxdo_oauth2_setting(
|
||||||
|
*,
|
||||||
|
pages_domain: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": LINUXDO_OAUTH2_NAME,
|
||||||
|
"icon": LINUXDO_OAUTH2_ICON,
|
||||||
|
"clientID": client_id,
|
||||||
|
"clientSecret": client_secret,
|
||||||
|
"authorizationURL": LINUXDO_AUTHORIZATION_URL,
|
||||||
|
"accessTokenURL": LINUXDO_ACCESS_TOKEN_URL,
|
||||||
|
"accessTokenFormat": "urlencoded",
|
||||||
|
"userInfoURL": LINUXDO_USER_INFO_URL,
|
||||||
|
"redirectURL": linuxdo_oauth_callback_url(pages_domain),
|
||||||
|
"logoutURL": "",
|
||||||
|
"userEmailKey": "id",
|
||||||
|
"enableEmailFormat": True,
|
||||||
|
"userEmailFormat": "^(.+)$",
|
||||||
|
"userEmailReplace": "linux_do_$1@oauth.linux.do",
|
||||||
|
"scope": LINUXDO_SCOPE,
|
||||||
|
"enableMailAllowList": False,
|
||||||
|
"mailAllowList": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_linuxdo_oauth2_setting(setting: object) -> bool:
|
||||||
|
if not isinstance(setting, dict):
|
||||||
|
return False
|
||||||
|
name = str(setting.get("name", "")).strip().lower()
|
||||||
|
authorization_url = str(setting.get("authorizationURL", "")).strip()
|
||||||
|
access_token_url = str(setting.get("accessTokenURL", "")).strip()
|
||||||
|
user_info_url = str(setting.get("userInfoURL", "")).strip()
|
||||||
|
return name in {"linux do", "linuxdo"} or (
|
||||||
|
authorization_url == LINUXDO_AUTHORIZATION_URL
|
||||||
|
and access_token_url == LINUXDO_ACCESS_TOKEN_URL
|
||||||
|
and user_info_url == LINUXDO_USER_INFO_URL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_linuxdo_oauth2_settings(
|
||||||
|
current: object,
|
||||||
|
*,
|
||||||
|
pages_domain: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
settings = current if isinstance(current, list) else []
|
||||||
|
desired = build_linuxdo_oauth2_setting(
|
||||||
|
pages_domain=pages_domain,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated: list[dict[str, Any]] = []
|
||||||
|
replaced = False
|
||||||
|
for item in settings:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if is_linuxdo_oauth2_setting(item):
|
||||||
|
merged = dict(item)
|
||||||
|
merged.update(desired)
|
||||||
|
for key in ("enableMailAllowList", "mailAllowList", "logoutURL"):
|
||||||
|
if key in item:
|
||||||
|
merged[key] = item[key]
|
||||||
|
updated.append(merged)
|
||||||
|
replaced = True
|
||||||
|
continue
|
||||||
|
updated.append(item)
|
||||||
|
|
||||||
|
if not replaced:
|
||||||
|
updated.append(desired)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationAdminClient:
|
||||||
|
"""Minimal client for post-deployment admin API configuration."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
admin_password: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
normalized_base_url = base_url.rstrip("/")
|
||||||
|
self.base_url = normalized_base_url
|
||||||
|
self.client = httpx.Client(
|
||||||
|
base_url=normalized_base_url,
|
||||||
|
timeout=timeout,
|
||||||
|
follow_redirects=True,
|
||||||
|
trust_env=False,
|
||||||
|
transport=transport,
|
||||||
|
headers={"x-admin-auth": admin_password},
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "ApplicationAdminClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def wait_until_ready(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 180.0,
|
||||||
|
poll_interval_seconds: float = 5.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
deadline = time.monotonic() + timeout_seconds
|
||||||
|
last_error: Exception | None = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return self.get_user_settings()
|
||||||
|
except (ApplicationAPIError, httpx.HTTPError) as exc:
|
||||||
|
last_error = exc
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise ApplicationAPIError(
|
||||||
|
f"管理员接口在限定时间内未就绪: {self.base_url}/admin/user_settings"
|
||||||
|
) from last_error
|
||||||
|
time.sleep(poll_interval_seconds)
|
||||||
|
|
||||||
|
def get_user_settings(self) -> dict[str, Any]:
|
||||||
|
payload = self._request_json("GET", "/admin/user_settings")
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
|
def sync_user_settings(self, *, allow_user_register: bool) -> dict[str, Any]:
|
||||||
|
merged = merge_user_settings(self.get_user_settings(), allow_user_register)
|
||||||
|
payload = self._request_json("POST", "/admin/user_settings", json=merged)
|
||||||
|
return payload if isinstance(payload, dict) else merged
|
||||||
|
|
||||||
|
def get_user_oauth2_settings(self) -> list[dict[str, Any]]:
|
||||||
|
payload = self._request_json("GET", "/admin/user_oauth2_settings")
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
return []
|
||||||
|
return [item for item in payload if isinstance(item, dict)]
|
||||||
|
|
||||||
|
def sync_linuxdo_oauth2(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
pages_domain: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
merged = merge_linuxdo_oauth2_settings(
|
||||||
|
self.get_user_oauth2_settings(),
|
||||||
|
pages_domain=pages_domain,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
)
|
||||||
|
payload = self._request_json("POST", "/admin/user_oauth2_settings", json=merged)
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
return merged
|
||||||
|
return [item for item in payload if isinstance(item, dict)]
|
||||||
|
|
||||||
|
def _request_json(self, method: str, path: str, *, json: Any | None = None) -> Any:
|
||||||
|
try:
|
||||||
|
response = self.client.request(method, path, json=json)
|
||||||
|
except httpx.HTTPError as exc: # pragma: no cover
|
||||||
|
raise ApplicationAPIError(f"请求管理员接口失败: {self.base_url}{path}") from exc
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
body = response.text.strip()
|
||||||
|
raise ApplicationAPIError(
|
||||||
|
f"管理员接口返回错误: {method.upper()} {path} -> {response.status_code} {body}".strip(),
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ApplicationAPIError(f"管理员接口返回了非法 JSON: {method.upper()} {path}") from exc
|
||||||
264
src/cf_temp_email_deploy/cli.py
Normal file
264
src/cf_temp_email_deploy/cli.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""Command line entrypoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.cloudflare import CloudflareClient
|
||||||
|
from cf_temp_email_deploy.config import (
|
||||||
|
apply_overrides,
|
||||||
|
config_from_document,
|
||||||
|
default_config_document,
|
||||||
|
load_config,
|
||||||
|
load_state,
|
||||||
|
load_toml_document,
|
||||||
|
parse_toml_value,
|
||||||
|
save_toml_document,
|
||||||
|
save_state,
|
||||||
|
)
|
||||||
|
from cf_temp_email_deploy.discovery import discover_config
|
||||||
|
from cf_temp_email_deploy.deployment import run_deployment
|
||||||
|
from cf_temp_email_deploy.environment import check_required_tools
|
||||||
|
from cf_temp_email_deploy.errors import ConfigError, DeployError
|
||||||
|
from cf_temp_email_deploy.logging_utils import configure_logging, get_logger, log_stage
|
||||||
|
from cf_temp_email_deploy.subprocess_runner import CommandRunner
|
||||||
|
|
||||||
|
COMMAND_NAMES = {"init-config", "check", "deploy", "resume", "discover-config"}
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(prog="cf-temp-email")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
init_parser = subparsers.add_parser("init-config", help="生成示例配置文件。")
|
||||||
|
init_parser.add_argument("--config", type=Path, default=Path("config.toml"))
|
||||||
|
init_parser.add_argument("--force", action="store_true")
|
||||||
|
init_parser.set_defaults(handler=handle_init_config)
|
||||||
|
|
||||||
|
for command_name, help_text, handler in (
|
||||||
|
("check", "执行环境与配置检查。", handle_check),
|
||||||
|
("deploy", "执行部署流程。", handle_deploy),
|
||||||
|
("resume", "从状态文件恢复部署流程。", handle_resume),
|
||||||
|
("discover-config", "从 Cloudflare 现有资源反推并写入配置。", handle_discover_config),
|
||||||
|
):
|
||||||
|
command_parser = subparsers.add_parser(command_name, help=help_text)
|
||||||
|
add_shared_arguments(command_parser)
|
||||||
|
command_parser.set_defaults(handler=handler)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def add_shared_arguments(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("--config", type=Path, default=Path("config.toml"))
|
||||||
|
parser.add_argument("--profile")
|
||||||
|
parser.add_argument("--verbose", action="store_true")
|
||||||
|
parser.add_argument("--api-token")
|
||||||
|
parser.add_argument("--account-id")
|
||||||
|
parser.add_argument("--account-name")
|
||||||
|
parser.add_argument("--zone-name")
|
||||||
|
parser.add_argument("--repo-ref")
|
||||||
|
parser.add_argument("--pages-domain")
|
||||||
|
parser.add_argument("--worker-name")
|
||||||
|
parser.add_argument("--d1-name")
|
||||||
|
parser.add_argument("--destination-address")
|
||||||
|
parser.add_argument("--set", dest="set_values", action="append", default=[])
|
||||||
|
|
||||||
|
|
||||||
|
def _qualify_override_key(dotted_key: str, profile: str | None = None) -> str:
|
||||||
|
if not profile:
|
||||||
|
return dotted_key
|
||||||
|
return f"profiles.{profile}.{dotted_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_cli_overrides(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
|
overrides: dict[str, Any] = {}
|
||||||
|
profile = getattr(args, "profile", None)
|
||||||
|
explicit_mapping = {
|
||||||
|
"api_token": "cloudflare.api_token",
|
||||||
|
"account_id": "cloudflare.account_id",
|
||||||
|
"account_name": "cloudflare.account_name",
|
||||||
|
"zone_name": "cloudflare.zone_name",
|
||||||
|
"repo_ref": "source.repo_ref",
|
||||||
|
"pages_domain": "pages.custom_domain",
|
||||||
|
"worker_name": "worker.script_name",
|
||||||
|
"d1_name": "d1.database_name",
|
||||||
|
"destination_address": "mail.verified_destination_address",
|
||||||
|
}
|
||||||
|
for argument_name, dotted_key in explicit_mapping.items():
|
||||||
|
value = getattr(args, argument_name, None)
|
||||||
|
if value not in (None, ""):
|
||||||
|
overrides[_qualify_override_key(dotted_key, profile)] = value
|
||||||
|
|
||||||
|
for raw_item in getattr(args, "set_values", []):
|
||||||
|
if "=" not in raw_item:
|
||||||
|
raise ConfigError(f"--set 参数缺少等号: {raw_item}")
|
||||||
|
dotted_key, raw_value = raw_item.split("=", 1)
|
||||||
|
overrides[_qualify_override_key(dotted_key, profile)] = parse_toml_value(raw_value)
|
||||||
|
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
def apply_runtime_overrides(config_path: Path, args: argparse.Namespace) -> None:
|
||||||
|
overrides = collect_cli_overrides(args)
|
||||||
|
if not overrides:
|
||||||
|
return
|
||||||
|
document = load_toml_document(config_path)
|
||||||
|
apply_overrides(document, overrides)
|
||||||
|
save_toml_document(config_path, document)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_state_path(config_path: Path, profile: str | None = None) -> Path:
|
||||||
|
deploy_dir = config_path.parent / ".deploy"
|
||||||
|
if profile:
|
||||||
|
return deploy_dir / "profiles" / profile / "state.toml"
|
||||||
|
return deploy_dir / "state.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def apply_profile_runtime_defaults(config: Any, profile: str | None = None) -> None:
|
||||||
|
if not profile:
|
||||||
|
return
|
||||||
|
if config.source.mode != "clone":
|
||||||
|
return
|
||||||
|
if config.source.workspace_dir != ".deploy/workspace":
|
||||||
|
return
|
||||||
|
config.source.workspace_dir = str(Path(".deploy") / "profiles" / profile / "workspace")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_init_config(args: argparse.Namespace) -> int:
|
||||||
|
configure_logging()
|
||||||
|
config_path = args.config
|
||||||
|
if config_path.exists() and not args.force:
|
||||||
|
raise ConfigError(f"配置文件已存在: {config_path}")
|
||||||
|
|
||||||
|
save_toml_document(config_path, default_config_document())
|
||||||
|
get_logger("cli").info("已生成配置文件: %s", config_path)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def handle_check(args: argparse.Namespace) -> int:
|
||||||
|
configure_logging(args.verbose)
|
||||||
|
apply_runtime_overrides(args.config, args)
|
||||||
|
config = load_config(args.config, profile=args.profile)
|
||||||
|
apply_profile_runtime_defaults(config, args.profile)
|
||||||
|
state_path = resolve_state_path(args.config, args.profile)
|
||||||
|
state = load_state(state_path)
|
||||||
|
|
||||||
|
log_stage("开始检查本地环境。")
|
||||||
|
runner = CommandRunner()
|
||||||
|
versions = check_required_tools(runner)
|
||||||
|
|
||||||
|
if config.source.mode == "local":
|
||||||
|
source_path = Path(config.source.local_path)
|
||||||
|
if not source_path.exists():
|
||||||
|
raise ConfigError(f"本地源码目录不存在: {source_path}")
|
||||||
|
|
||||||
|
if not config.cloudflare.resolved_api_token():
|
||||||
|
raise ConfigError("cloudflare.api_token 或 cloudflare.api_token_env 需要提供其一。")
|
||||||
|
|
||||||
|
with CloudflareClient(config.cloudflare) as cloudflare:
|
||||||
|
token_info = cloudflare.verify_token()
|
||||||
|
account: dict[str, str] = {}
|
||||||
|
if config.cloudflare.account_id or config.cloudflare.account_name:
|
||||||
|
account = cloudflare.resolve_account(
|
||||||
|
account_id=config.cloudflare.account_id,
|
||||||
|
account_name=config.cloudflare.account_name,
|
||||||
|
)
|
||||||
|
zone = cloudflare.resolve_zone(
|
||||||
|
zone_name=config.cloudflare.zone_name,
|
||||||
|
account_id=account.get("id", ""),
|
||||||
|
)
|
||||||
|
if not account:
|
||||||
|
zone_account = zone.get("account")
|
||||||
|
if isinstance(zone_account, dict):
|
||||||
|
account = {
|
||||||
|
"id": str(zone_account.get("id", "")),
|
||||||
|
"name": str(zone_account.get("name", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
for tool_name, version in versions.items():
|
||||||
|
get_logger("check").info("[tool] %s=%s", tool_name, version.version)
|
||||||
|
|
||||||
|
state.cloudflare.account_id = account.get("id", "")
|
||||||
|
state.cloudflare.account_name = account.get("name", "")
|
||||||
|
state.cloudflare.zone_id = zone.get("id", "")
|
||||||
|
state.cloudflare.zone_name = zone.get("name", config.cloudflare.zone_name)
|
||||||
|
state.mark_checkpoint("check_completed")
|
||||||
|
save_state(state_path, state)
|
||||||
|
|
||||||
|
get_logger("check").info("[cloudflare] token_status=%s", token_info.get("status", "unknown"))
|
||||||
|
get_logger("check").info("[cloudflare] account_id=%s", state.cloudflare.account_id or "<provided>")
|
||||||
|
get_logger("check").info("[cloudflare] zone_id=%s", state.cloudflare.zone_id)
|
||||||
|
get_logger("check").info("[config] worker_vars=%s", sorted(config.derived_worker_vars().keys()))
|
||||||
|
log_stage("本地环境检查完成。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def handle_discover_config(args: argparse.Namespace) -> int:
|
||||||
|
configure_logging(args.verbose)
|
||||||
|
discover_config(
|
||||||
|
config_path=args.config,
|
||||||
|
profile=args.profile,
|
||||||
|
cli_overrides=collect_cli_overrides(args),
|
||||||
|
cloudflare_client_factory=CloudflareClient,
|
||||||
|
)
|
||||||
|
get_logger("discover").info("已写入发现结果: %s", args.config)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def handle_deploy(args: argparse.Namespace) -> int:
|
||||||
|
return _handle_deployment_command(args, is_resume=False)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_resume(args: argparse.Namespace) -> int:
|
||||||
|
return _handle_deployment_command(args, is_resume=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_deployment_command(args: argparse.Namespace, *, is_resume: bool) -> int:
|
||||||
|
configure_logging(args.verbose)
|
||||||
|
apply_runtime_overrides(args.config, args)
|
||||||
|
config = load_config(args.config, profile=args.profile)
|
||||||
|
apply_profile_runtime_defaults(config, args.profile)
|
||||||
|
state_path = resolve_state_path(args.config, args.profile)
|
||||||
|
state = load_state(state_path)
|
||||||
|
if is_resume and not state.checkpoint:
|
||||||
|
raise DeployError("状态文件中缺少检查点,无法继续恢复部署。")
|
||||||
|
|
||||||
|
runner = CommandRunner()
|
||||||
|
run_deployment(
|
||||||
|
config_path=args.config,
|
||||||
|
config=config,
|
||||||
|
state_path=state_path,
|
||||||
|
state=state,
|
||||||
|
runner=runner,
|
||||||
|
cloudflare_client_factory=CloudflareClient,
|
||||||
|
is_resume=is_resume,
|
||||||
|
)
|
||||||
|
save_state(state_path, state)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_cli_argv(argv: list[str] | None) -> list[str]:
|
||||||
|
raw_args = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
if not raw_args:
|
||||||
|
return ["deploy"]
|
||||||
|
if raw_args[0] in COMMAND_NAMES:
|
||||||
|
return raw_args
|
||||||
|
if raw_args[0] in {"-h", "--help"}:
|
||||||
|
return raw_args
|
||||||
|
if raw_args[0].startswith("-"):
|
||||||
|
return ["deploy", *raw_args]
|
||||||
|
return raw_args
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(resolve_cli_argv(argv))
|
||||||
|
try:
|
||||||
|
return args.handler(args)
|
||||||
|
except DeployError as exc:
|
||||||
|
configure_logging(getattr(args, "verbose", False))
|
||||||
|
get_logger("error").error("%s", exc)
|
||||||
|
return 1
|
||||||
727
src/cf_temp_email_deploy/cloudflare.py
Normal file
727
src/cf_temp_email_deploy/cloudflare.py
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
"""Cloudflare API client helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from cf_temp_email_deploy import __version__
|
||||||
|
from cf_temp_email_deploy.errors import CloudflareAPIError, ConfigError
|
||||||
|
from cf_temp_email_deploy.models import CloudflareConfig
|
||||||
|
|
||||||
|
AuthMode = Literal["token", "global_key"]
|
||||||
|
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
||||||
|
PAGES_ACTIVE_STATUSES = {"active", "verified"}
|
||||||
|
EMAIL_ROUTING_READY_STATUSES = {"active", "enabled", "verified", "success"}
|
||||||
|
|
||||||
|
|
||||||
|
class CloudflareClient:
|
||||||
|
"""Minimal Cloudflare API client used by deployment commands."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: CloudflareConfig,
|
||||||
|
*,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
max_attempts: int = 3,
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.max_attempts = max_attempts
|
||||||
|
self.client = httpx.Client(
|
||||||
|
base_url="https://api.cloudflare.com/client/v4",
|
||||||
|
timeout=timeout,
|
||||||
|
transport=transport,
|
||||||
|
trust_env=False,
|
||||||
|
headers={"User-Agent": f"cf-temp-email/{__version__}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "CloudflareClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def verify_token(self) -> dict[str, Any]:
|
||||||
|
payload = self.request("GET", "/user/tokens/verify", auth_mode="token")
|
||||||
|
result = payload.get("result")
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise CloudflareAPIError("Token 校验返回结果格式异常。")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def resolve_account(self, *, account_id: str = "", account_name: str = "") -> dict[str, Any]:
|
||||||
|
if account_id:
|
||||||
|
return {"id": account_id, "name": account_name}
|
||||||
|
if not account_name:
|
||||||
|
raise ConfigError("cloudflare.account_id 与 cloudflare.account_name 至少需要提供其一。")
|
||||||
|
|
||||||
|
accounts = self.list_paginated(
|
||||||
|
"/accounts",
|
||||||
|
params={"name": account_name},
|
||||||
|
auth_mode="global_key",
|
||||||
|
)
|
||||||
|
matches = [item for item in accounts if item.get("name") == account_name]
|
||||||
|
if not matches:
|
||||||
|
raise CloudflareAPIError(f"未找到匹配的 Cloudflare 账户: {account_name}")
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise CloudflareAPIError(f"存在多个同名 Cloudflare 账户: {account_name}")
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
def resolve_zone(self, *, zone_name: str, account_id: str = "") -> dict[str, Any]:
|
||||||
|
params: dict[str, Any] = {"name": zone_name}
|
||||||
|
if account_id:
|
||||||
|
params["account.id"] = account_id
|
||||||
|
zones = self.list_paginated("/zones", params=params, auth_mode="token")
|
||||||
|
matches = [item for item in zones if item.get("name") == zone_name]
|
||||||
|
if not matches:
|
||||||
|
raise CloudflareAPIError(f"未找到匹配的 Zone: {zone_name}")
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise CloudflareAPIError(f"匹配到多个同名 Zone: {zone_name}")
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
def get_pages_project(self, *, account_id: str, project_name: str) -> dict[str, Any] | None:
|
||||||
|
return self._request_result_or_none(
|
||||||
|
"GET",
|
||||||
|
f"/accounts/{account_id}/pages/projects/{project_name}",
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_pages_projects(self, *, account_id: str) -> list[dict[str, Any]]:
|
||||||
|
payload = self.request(
|
||||||
|
"GET",
|
||||||
|
f"/accounts/{account_id}/pages/projects",
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
result = payload.get("result")
|
||||||
|
if not isinstance(result, list):
|
||||||
|
raise CloudflareAPIError("Pages 项目列表返回结果格式异常。")
|
||||||
|
return [item for item in result if isinstance(item, dict)]
|
||||||
|
|
||||||
|
def ensure_pages_project(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
project_name: str,
|
||||||
|
production_branch: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
existing = self.get_pages_project(account_id=account_id, project_name=project_name)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
try:
|
||||||
|
payload = self.request(
|
||||||
|
"POST",
|
||||||
|
f"/accounts/{account_id}/pages/projects",
|
||||||
|
json={"name": project_name, "production_branch": production_branch},
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
except CloudflareAPIError as exc:
|
||||||
|
if "already exists" not in str(exc).lower():
|
||||||
|
raise
|
||||||
|
existing = self.get_pages_project(account_id=account_id, project_name=project_name)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
raise
|
||||||
|
return self._result_dict(payload, "Pages 项目创建")
|
||||||
|
|
||||||
|
def get_pages_domain(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
project_name: str,
|
||||||
|
domain_name: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
return self._request_result_or_none(
|
||||||
|
"GET",
|
||||||
|
f"/accounts/{account_id}/pages/projects/{project_name}/domains/{domain_name}",
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
|
||||||
|
def ensure_pages_domain(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
project_name: str,
|
||||||
|
domain_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
existing = self.get_pages_domain(
|
||||||
|
account_id=account_id,
|
||||||
|
project_name=project_name,
|
||||||
|
domain_name=domain_name,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
try:
|
||||||
|
payload = self.request(
|
||||||
|
"POST",
|
||||||
|
f"/accounts/{account_id}/pages/projects/{project_name}/domains",
|
||||||
|
json={"name": domain_name},
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
except CloudflareAPIError as exc:
|
||||||
|
if "already exists" not in str(exc).lower():
|
||||||
|
raise
|
||||||
|
existing = self.get_pages_domain(
|
||||||
|
account_id=account_id,
|
||||||
|
project_name=project_name,
|
||||||
|
domain_name=domain_name,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
raise
|
||||||
|
return self._result_dict(payload, "Pages 域名绑定")
|
||||||
|
|
||||||
|
def wait_for_pages_domain_active(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
project_name: str,
|
||||||
|
domain_name: str,
|
||||||
|
timeout_seconds: float = 300.0,
|
||||||
|
poll_interval_seconds: float = 5.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
deadline = time.monotonic() + timeout_seconds
|
||||||
|
while True:
|
||||||
|
domain = self.get_pages_domain(
|
||||||
|
account_id=account_id,
|
||||||
|
project_name=project_name,
|
||||||
|
domain_name=domain_name,
|
||||||
|
)
|
||||||
|
if domain is None:
|
||||||
|
raise CloudflareAPIError(f"Pages 自定义域名不存在: {domain_name}")
|
||||||
|
status = str(domain.get("status", "")).lower()
|
||||||
|
if status in PAGES_ACTIVE_STATUSES:
|
||||||
|
return domain
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
raise CloudflareAPIError(f"等待 Pages 自定义域名激活超时: {domain_name}")
|
||||||
|
time.sleep(poll_interval_seconds)
|
||||||
|
|
||||||
|
def list_dns_records(
|
||||||
|
self,
|
||||||
|
zone_id: str,
|
||||||
|
*,
|
||||||
|
name: str = "",
|
||||||
|
record_type: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if name:
|
||||||
|
params["name"] = name
|
||||||
|
if record_type:
|
||||||
|
params["type"] = record_type
|
||||||
|
return self.list_paginated(
|
||||||
|
f"/zones/{zone_id}/dns_records",
|
||||||
|
params=params,
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
|
||||||
|
def ensure_cname_record(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
zone_id: str,
|
||||||
|
name: str,
|
||||||
|
content: str,
|
||||||
|
proxied: bool = True,
|
||||||
|
ttl: int = 1,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
records = [record for record in self.list_dns_records(zone_id, name=name) if record.get("name") == name]
|
||||||
|
conflicts = [record for record in records if record.get("type") != "CNAME"]
|
||||||
|
if conflicts:
|
||||||
|
raise CloudflareAPIError(f"DNS 记录冲突: {name} 已存在非 CNAME 记录。")
|
||||||
|
|
||||||
|
cname_records = [record for record in records if record.get("type") == "CNAME"]
|
||||||
|
if len(cname_records) > 1:
|
||||||
|
raise CloudflareAPIError(f"DNS 记录冲突: {name} 存在多条 CNAME 记录。")
|
||||||
|
if not cname_records:
|
||||||
|
return self._create_dns_record(
|
||||||
|
zone_id=zone_id,
|
||||||
|
payload={"type": "CNAME", "name": name, "content": content, "proxied": proxied, "ttl": ttl},
|
||||||
|
)
|
||||||
|
|
||||||
|
record = cname_records[0]
|
||||||
|
if (
|
||||||
|
record.get("content") == content
|
||||||
|
and bool(record.get("proxied", proxied)) == proxied
|
||||||
|
and int(record.get("ttl", ttl)) == ttl
|
||||||
|
):
|
||||||
|
return record
|
||||||
|
|
||||||
|
return self._update_dns_record(
|
||||||
|
zone_id=zone_id,
|
||||||
|
record_id=str(record.get("id", "")),
|
||||||
|
payload={"type": "CNAME", "name": name, "content": content, "proxied": proxied, "ttl": ttl},
|
||||||
|
)
|
||||||
|
|
||||||
|
def ensure_dns_record(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
zone_id: str,
|
||||||
|
record_type: str,
|
||||||
|
name: str,
|
||||||
|
content: str,
|
||||||
|
proxied: bool | None = None,
|
||||||
|
ttl: int | None = 1,
|
||||||
|
priority: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
records = [
|
||||||
|
record
|
||||||
|
for record in self.list_dns_records(zone_id, name=name, record_type=record_type)
|
||||||
|
if record.get("name") == name and record.get("type") == record_type
|
||||||
|
]
|
||||||
|
exact = next(
|
||||||
|
(
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if self._dns_record_matches(
|
||||||
|
record,
|
||||||
|
content=content,
|
||||||
|
proxied=proxied,
|
||||||
|
ttl=ttl,
|
||||||
|
priority=priority,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if exact is not None:
|
||||||
|
return exact
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {"type": record_type, "name": name, "content": content}
|
||||||
|
if proxied is not None:
|
||||||
|
payload["proxied"] = proxied
|
||||||
|
if ttl is not None:
|
||||||
|
payload["ttl"] = ttl
|
||||||
|
if priority is not None:
|
||||||
|
payload["priority"] = priority
|
||||||
|
|
||||||
|
if len(records) == 1 and record_type in {"TXT", "CNAME"}:
|
||||||
|
return self._update_dns_record(
|
||||||
|
zone_id=zone_id,
|
||||||
|
record_id=str(records[0].get("id", "")),
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
return self._create_dns_record(zone_id=zone_id, payload=payload)
|
||||||
|
|
||||||
|
def list_d1_databases(self, *, account_id: str, database_name: str = "") -> list[dict[str, Any]]:
|
||||||
|
databases = self.list_paginated(
|
||||||
|
f"/accounts/{account_id}/d1/database",
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
if not database_name:
|
||||||
|
return databases
|
||||||
|
return [item for item in databases if item.get("name") == database_name]
|
||||||
|
|
||||||
|
def ensure_d1_database(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
database_name: str,
|
||||||
|
jurisdiction: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
existing = self.list_d1_databases(account_id=account_id, database_name=database_name)
|
||||||
|
if existing:
|
||||||
|
if len(existing) > 1:
|
||||||
|
raise CloudflareAPIError(f"存在多个同名 D1 数据库: {database_name}")
|
||||||
|
return existing[0]
|
||||||
|
|
||||||
|
payload_data: dict[str, Any] = {"name": database_name}
|
||||||
|
if jurisdiction:
|
||||||
|
payload_data["primary_location_hint"] = jurisdiction
|
||||||
|
payload = self.request(
|
||||||
|
"POST",
|
||||||
|
f"/accounts/{account_id}/d1/database",
|
||||||
|
json=payload_data,
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
return self._result_dict(payload, "D1 数据库创建")
|
||||||
|
|
||||||
|
def query_d1(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
database_id: str,
|
||||||
|
sql: str,
|
||||||
|
params: list[Any] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
payload = self.request(
|
||||||
|
"POST",
|
||||||
|
f"/accounts/{account_id}/d1/database/{database_id}/query",
|
||||||
|
json={"sql": sql, "params": params or []},
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
result = payload.get("result")
|
||||||
|
if not isinstance(result, list):
|
||||||
|
raise CloudflareAPIError("D1 查询返回结果格式异常。")
|
||||||
|
if not result:
|
||||||
|
return []
|
||||||
|
first = result[0]
|
||||||
|
if not isinstance(first, dict):
|
||||||
|
raise CloudflareAPIError("D1 查询结果项格式异常。")
|
||||||
|
rows = first.get("results", [])
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
raise CloudflareAPIError("D1 查询结果行格式异常。")
|
||||||
|
return [row for row in rows if isinstance(row, dict)]
|
||||||
|
|
||||||
|
def get_workers_subdomain(self, *, account_id: str) -> dict[str, Any] | None:
|
||||||
|
return self._request_result_or_none(
|
||||||
|
"GET",
|
||||||
|
f"/accounts/{account_id}/workers/subdomain",
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_worker_script(self, *, account_id: str, script_name: str) -> dict[str, Any] | None:
|
||||||
|
scripts = self.list_paginated(
|
||||||
|
f"/accounts/{account_id}/workers/scripts",
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
for script in scripts:
|
||||||
|
if str(script.get("id", "")) == script_name:
|
||||||
|
return script
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_worker_scripts(self, *, account_id: str) -> list[dict[str, Any]]:
|
||||||
|
return self.list_paginated(
|
||||||
|
f"/accounts/{account_id}/workers/scripts",
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def worker_script_supports_email(script: dict[str, Any]) -> bool:
|
||||||
|
handlers = script.get("handlers")
|
||||||
|
if not isinstance(handlers, list):
|
||||||
|
return False
|
||||||
|
return "email" in {str(handler).strip().lower() for handler in handlers}
|
||||||
|
|
||||||
|
def list_email_routing_addresses(self, *, account_id: str) -> list[dict[str, Any]]:
|
||||||
|
return self._list_email_routing_paginated(f"/accounts/{account_id}/email/routing/addresses")
|
||||||
|
|
||||||
|
def get_email_routing_dns(self, *, zone_id: str) -> list[dict[str, Any]]:
|
||||||
|
payload = self.request_email_routing("GET", f"/zones/{zone_id}/email/routing/dns")
|
||||||
|
result = payload.get("result")
|
||||||
|
if isinstance(result, list):
|
||||||
|
return [item for item in result if isinstance(item, dict)]
|
||||||
|
if isinstance(result, dict):
|
||||||
|
records = result.get("records")
|
||||||
|
if isinstance(records, list):
|
||||||
|
return [item for item in records if isinstance(item, dict)]
|
||||||
|
raise CloudflareAPIError("Email Routing DNS 返回结果格式异常。")
|
||||||
|
|
||||||
|
def get_catch_all(self, *, zone_id: str) -> dict[str, Any] | None:
|
||||||
|
return self._request_email_routing_result_or_none(
|
||||||
|
"GET",
|
||||||
|
f"/zones/{zone_id}/email/routing/rules/catch_all",
|
||||||
|
)
|
||||||
|
|
||||||
|
def ensure_catch_all_worker(self, *, zone_id: str, script_name: str) -> dict[str, Any]:
|
||||||
|
current = self.get_catch_all(zone_id=zone_id)
|
||||||
|
if current is not None and self.catch_all_points_to_worker(current, script_name):
|
||||||
|
return current
|
||||||
|
payload = self.request_email_routing(
|
||||||
|
"PUT",
|
||||||
|
f"/zones/{zone_id}/email/routing/rules/catch_all",
|
||||||
|
json={
|
||||||
|
"matchers": [{"type": "all"}],
|
||||||
|
"actions": [{"type": "worker", "value": [script_name]}],
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return self._result_dict(payload, "Catch-all 更新")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def catch_all_points_to_worker(rule: dict[str, Any], script_name: str) -> bool:
|
||||||
|
actions = rule.get("actions")
|
||||||
|
if not isinstance(actions, list):
|
||||||
|
return False
|
||||||
|
for action in actions:
|
||||||
|
if not isinstance(action, dict):
|
||||||
|
continue
|
||||||
|
if action.get("type") != "worker":
|
||||||
|
continue
|
||||||
|
if script_name in CloudflareClient.extract_worker_targets(action.get("value")):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def email_address_ready(address: dict[str, Any], target: str) -> bool:
|
||||||
|
if str(address.get("email", "")).lower() != target.lower():
|
||||||
|
return False
|
||||||
|
status = str(address.get("status", "")).lower()
|
||||||
|
return status in EMAIL_ROUTING_READY_STATUSES
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_worker_targets(value: Any) -> set[str]:
|
||||||
|
stack = [value]
|
||||||
|
targets: set[str] = set()
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
if isinstance(current, str):
|
||||||
|
candidate = current.strip()
|
||||||
|
if candidate:
|
||||||
|
targets.add(candidate)
|
||||||
|
continue
|
||||||
|
if isinstance(current, list):
|
||||||
|
stack.extend(current)
|
||||||
|
continue
|
||||||
|
if isinstance(current, dict):
|
||||||
|
for key in ("worker", "name", "script", "service", "value"):
|
||||||
|
if key in current:
|
||||||
|
stack.append(current[key])
|
||||||
|
return targets
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_authentication_error(error: Exception) -> bool:
|
||||||
|
if isinstance(error, ConfigError):
|
||||||
|
message = str(error).lower()
|
||||||
|
return "旧式鉴权" in str(error) or "api_email" in message or "global_api_key" in message
|
||||||
|
if not isinstance(error, CloudflareAPIError):
|
||||||
|
return False
|
||||||
|
return CloudflareClient._should_fallback_to_global_key(error)
|
||||||
|
|
||||||
|
def request_email_routing(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return self.request(method, path, params=params, json=json, auth_mode="token")
|
||||||
|
except CloudflareAPIError as exc:
|
||||||
|
if not self._should_fallback_to_global_key(exc):
|
||||||
|
raise
|
||||||
|
return self.request(method, path, params=params, json=json, auth_mode="global_key")
|
||||||
|
|
||||||
|
def list_paginated(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
auth_mode: AuthMode,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
collected: list[dict[str, Any]] = []
|
||||||
|
page = 1
|
||||||
|
per_page = 50
|
||||||
|
while True:
|
||||||
|
current_params = dict(params or {})
|
||||||
|
current_params.setdefault("page", page)
|
||||||
|
current_params.setdefault("per_page", per_page)
|
||||||
|
payload = self.request("GET", path, params=current_params, auth_mode=auth_mode)
|
||||||
|
result = payload.get("result")
|
||||||
|
if not isinstance(result, list):
|
||||||
|
raise CloudflareAPIError(f"分页接口返回结果格式异常: {path}")
|
||||||
|
collected.extend(result)
|
||||||
|
result_info = payload.get("result_info") or {}
|
||||||
|
total_pages = result_info.get("total_pages")
|
||||||
|
if not total_pages or page >= total_pages:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return collected
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
auth_mode: AuthMode,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
last_error: CloudflareAPIError | None = None
|
||||||
|
for attempt in range(1, self.max_attempts + 1):
|
||||||
|
try:
|
||||||
|
response = self.client.request(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
params=params,
|
||||||
|
json=json,
|
||||||
|
headers=self._headers(auth_mode),
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
last_error = CloudflareAPIError(f"Cloudflare 请求失败: {exc}") # pragma: no cover
|
||||||
|
if attempt < self.max_attempts:
|
||||||
|
time.sleep(0.2 * attempt)
|
||||||
|
continue
|
||||||
|
raise last_error from exc
|
||||||
|
|
||||||
|
if response.status_code in RETRYABLE_STATUS_CODES and attempt < self.max_attempts:
|
||||||
|
time.sleep(0.2 * attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload = self._parse_payload(response)
|
||||||
|
if response.status_code >= 400 or payload.get("success") is False:
|
||||||
|
error = CloudflareAPIError(
|
||||||
|
self._build_error_message(payload, response),
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
|
if response.status_code in RETRYABLE_STATUS_CODES and attempt < self.max_attempts:
|
||||||
|
last_error = error
|
||||||
|
time.sleep(0.2 * attempt)
|
||||||
|
continue
|
||||||
|
raise error
|
||||||
|
return payload
|
||||||
|
|
||||||
|
if last_error is not None:
|
||||||
|
raise last_error
|
||||||
|
raise CloudflareAPIError("Cloudflare 请求失败,且没有返回可用结果。")
|
||||||
|
|
||||||
|
def _request_result_or_none(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
auth_mode: AuthMode,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
payload = self.request(method, path, params=params, json=json, auth_mode=auth_mode)
|
||||||
|
except CloudflareAPIError as exc:
|
||||||
|
if exc.status_code == 404:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
return self._result_dict(payload, path)
|
||||||
|
|
||||||
|
def _request_email_routing_result_or_none(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
payload = self.request_email_routing(method, path, params=params, json=json)
|
||||||
|
except CloudflareAPIError as exc:
|
||||||
|
if exc.status_code == 404:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
return self._result_dict(payload, path)
|
||||||
|
|
||||||
|
def _list_email_routing_paginated(self, path: str) -> list[dict[str, Any]]:
|
||||||
|
collected: list[dict[str, Any]] = []
|
||||||
|
page = 1
|
||||||
|
per_page = 50
|
||||||
|
while True:
|
||||||
|
payload = self.request_email_routing(
|
||||||
|
"GET",
|
||||||
|
path,
|
||||||
|
params={"page": page, "per_page": per_page},
|
||||||
|
)
|
||||||
|
result = payload.get("result")
|
||||||
|
if not isinstance(result, list):
|
||||||
|
raise CloudflareAPIError(f"分页接口返回结果格式异常: {path}")
|
||||||
|
collected.extend(item for item in result if isinstance(item, dict))
|
||||||
|
result_info = payload.get("result_info") or {}
|
||||||
|
total_pages = result_info.get("total_pages")
|
||||||
|
if not total_pages or page >= total_pages:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return collected
|
||||||
|
|
||||||
|
def _create_dns_record(self, *, zone_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response = self.request(
|
||||||
|
"POST",
|
||||||
|
f"/zones/{zone_id}/dns_records",
|
||||||
|
json=payload,
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
return self._result_dict(response, "DNS 记录创建")
|
||||||
|
|
||||||
|
def _update_dns_record(self, *, zone_id: str, record_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response = self.request(
|
||||||
|
"PUT",
|
||||||
|
f"/zones/{zone_id}/dns_records/{record_id}",
|
||||||
|
json=payload,
|
||||||
|
auth_mode="token",
|
||||||
|
)
|
||||||
|
return self._result_dict(response, "DNS 记录更新")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _dns_record_matches(
|
||||||
|
record: dict[str, Any],
|
||||||
|
*,
|
||||||
|
content: str,
|
||||||
|
proxied: bool | None,
|
||||||
|
ttl: int | None,
|
||||||
|
priority: int | None,
|
||||||
|
) -> bool:
|
||||||
|
if record.get("content") != content:
|
||||||
|
return False
|
||||||
|
if proxied is not None and bool(record.get("proxied", proxied)) != proxied:
|
||||||
|
return False
|
||||||
|
if ttl is not None:
|
||||||
|
try:
|
||||||
|
if int(record.get("ttl", ttl)) != ttl:
|
||||||
|
return False
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
if priority is not None:
|
||||||
|
try:
|
||||||
|
if int(record.get("priority", priority)) != priority:
|
||||||
|
return False
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _result_dict(payload: dict[str, Any], action: str) -> dict[str, Any]:
|
||||||
|
result = payload.get("result")
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise CloudflareAPIError(f"{action} 返回结果格式异常。")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _headers(self, auth_mode: AuthMode) -> dict[str, str]:
|
||||||
|
if auth_mode == "token":
|
||||||
|
token = self.config.resolved_api_token()
|
||||||
|
if not token:
|
||||||
|
raise ConfigError("缺少 Cloudflare API Token。")
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
email = self.config.resolved_api_email()
|
||||||
|
api_key = self.config.resolved_global_api_key()
|
||||||
|
if not email or not api_key:
|
||||||
|
raise ConfigError("旧式鉴权需要同时提供 api_email 与 global_api_key。")
|
||||||
|
return {
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_payload(response: httpx.Response) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CloudflareAPIError("Cloudflare 返回内容不是合法 JSON。", status_code=response.status_code) from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise CloudflareAPIError("Cloudflare 返回内容格式异常。", status_code=response.status_code)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_error_message(payload: dict[str, Any], response: httpx.Response) -> str:
|
||||||
|
errors = payload.get("errors") or []
|
||||||
|
fragments = [
|
||||||
|
error.get("message", str(error))
|
||||||
|
for error in errors
|
||||||
|
if isinstance(error, dict)
|
||||||
|
]
|
||||||
|
if not fragments and response.text:
|
||||||
|
fragments.append(response.text.strip())
|
||||||
|
message = "; ".join(fragment for fragment in fragments if fragment)
|
||||||
|
if not message:
|
||||||
|
message = f"HTTP {response.status_code}"
|
||||||
|
return f"Cloudflare API 返回错误: {message}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_fallback_to_global_key(error: CloudflareAPIError) -> bool:
|
||||||
|
if error.status_code not in {400, 401, 403}:
|
||||||
|
return False
|
||||||
|
message = str(error).lower()
|
||||||
|
return any(
|
||||||
|
fragment in message
|
||||||
|
for fragment in ("x-auth-key", "x-auth-email", "global api key", "authentication")
|
||||||
|
)
|
||||||
278
src/cf_temp_email_deploy/config.py
Normal file
278
src/cf_temp_email_deploy/config.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
"""Configuration and state file utilities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import tomlkit
|
||||||
|
from tomlkit import TOMLDocument
|
||||||
|
from tomlkit.exceptions import ParseError
|
||||||
|
from tomlkit.items import AbstractTable
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.errors import ConfigError
|
||||||
|
from cf_temp_email_deploy.models import DeploymentConfig, DeploymentState
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_TOML = """# 必填参数一览
|
||||||
|
# [必填] cloudflare.zone_name
|
||||||
|
# [必填] cloudflare.api_token 或 cloudflare.api_token_env
|
||||||
|
# [必填] mail.domains
|
||||||
|
# [必填] pages.custom_domain
|
||||||
|
# [必填] worker.vars.ADMIN_PASSWORDS
|
||||||
|
# [条件必填] source.local_path:当 source.mode = "local" 时填写
|
||||||
|
# [选填] source.repo_ref:留空时自动跟随上游默认分支最新提交;如需固定版本可填写 tag、branch 或 commit
|
||||||
|
|
||||||
|
# 主配置版本号,供 CLI 与状态加载逻辑识别。
|
||||||
|
config_version = 1
|
||||||
|
|
||||||
|
[source]
|
||||||
|
# [选填] "clone" 表示自动拉取远端仓库到 workspace_dir。
|
||||||
|
# [条件必填] "local" 表示直接使用 local_path 指向的本地源码目录。
|
||||||
|
mode = "clone"
|
||||||
|
# [选填] 固定源码仓库地址。
|
||||||
|
repo_url = "https://github.com/dreamhunter2333/cloudflare_temp_email.git"
|
||||||
|
# [选填] 留空时自动跟随上游默认分支最新提交。
|
||||||
|
repo_ref = ""
|
||||||
|
# [选填] 克隆源码、安装依赖、构建产物时使用的本地工作目录。
|
||||||
|
workspace_dir = ".deploy/workspace"
|
||||||
|
# [条件必填] 仅在 mode = "local" 时生效。
|
||||||
|
local_path = ""
|
||||||
|
|
||||||
|
[cloudflare]
|
||||||
|
# [选填] 已知 account_id 时优先填写该字段。
|
||||||
|
account_id = ""
|
||||||
|
# [选填] 只有在 account_id 未提供时才需要 account_name。
|
||||||
|
account_name = ""
|
||||||
|
# [必填] 承载前端域名、Worker 域名与邮件域名的 Zone。
|
||||||
|
zone_name = "example.com"
|
||||||
|
# [必填] 本工具默认使用的主 API Token。
|
||||||
|
api_token = ""
|
||||||
|
# [必填-二选一] 共享环境中可改为从环境变量读取 Token。
|
||||||
|
api_token_env = ""
|
||||||
|
# [选填] Email Routing 部分接口需要旧式鉴权时,可填写邮箱与 Global API Key。
|
||||||
|
api_email = ""
|
||||||
|
api_email_env = ""
|
||||||
|
global_api_key = ""
|
||||||
|
global_api_key_env = ""
|
||||||
|
|
||||||
|
[mail]
|
||||||
|
# [必填] 邮件接收域名,会同步写入 Email Routing 与 Worker 的 DOMAINS。
|
||||||
|
# [必填] 该主机名必须与 pages.custom_domain 分离。
|
||||||
|
domains = ["example.com"]
|
||||||
|
# [选填] 如需严格校验 Email Routing 目标地址,可填写一个已验证的真实邮箱。
|
||||||
|
# [选填] 留空时部署会跳过目标地址校验,仍继续配置 MX/SPF 与 Catch-all Worker。
|
||||||
|
verified_destination_address = "inbox@example.net"
|
||||||
|
|
||||||
|
[d1]
|
||||||
|
# [选填] 当前部署要创建或复用的远端 D1 数据库名称。
|
||||||
|
database_name = "cf-temp-email"
|
||||||
|
# [选填] 可选的 D1 地域提示;留空时使用默认位置。
|
||||||
|
jurisdiction = ""
|
||||||
|
# [选填] 仅在接管已有数据库且缺少 __deploy_history 时开启。
|
||||||
|
adopt_existing_schema = false
|
||||||
|
|
||||||
|
[user_access]
|
||||||
|
# [选填] 是否要求登录后才能创建邮箱;启用 Linux.do OAuth2 时会自动强制为 true。
|
||||||
|
require_login_to_create = true
|
||||||
|
# [选填] 是否允许用户自行注册。
|
||||||
|
allow_user_register = false
|
||||||
|
|
||||||
|
[linuxdo]
|
||||||
|
# [选填] 是否启用 LINUX DO OAuth2 登录。
|
||||||
|
linuxdo_oauth = false
|
||||||
|
# [条件必填] 当 linuxdo_oauth = true 时填写。
|
||||||
|
client_id = ""
|
||||||
|
# [条件必填] 当 linuxdo_oauth = true 时填写。
|
||||||
|
client_secret = ""
|
||||||
|
|
||||||
|
[worker]
|
||||||
|
# [选填] Worker 服务名称,会同时用于 wrangler、Pages 服务绑定与 Catch-all。
|
||||||
|
script_name = "cloudflare-temp-email"
|
||||||
|
# [选填] 保留 workers.dev 地址,用于调试与回退检查。
|
||||||
|
use_workers_dev = true
|
||||||
|
# [选填] 生产环境中的 Worker 自定义域名。
|
||||||
|
custom_domain = ""
|
||||||
|
# [选填] 写入 worker/wrangler.toml 的 compatibility_date。
|
||||||
|
compatibility_date = "2024-09-23"
|
||||||
|
|
||||||
|
[worker.vars]
|
||||||
|
# [选填] 写入 worker/wrangler.toml 的普通环境变量。
|
||||||
|
PREFIX = "tmp"
|
||||||
|
ENABLE_USER_CREATE_EMAIL = true
|
||||||
|
ENABLE_USER_DELETE_EMAIL = true
|
||||||
|
DEFAULT_LANG = "zh"
|
||||||
|
# [必填] 管理员密码列表至少保留一个值,部署后会使用首项调用管理员接口。
|
||||||
|
ADMIN_PASSWORDS = ["change-me"]
|
||||||
|
|
||||||
|
[worker.secrets]
|
||||||
|
# [选填] 标量秘密值通过 `wrangler secret put` 写入 Cloudflare。
|
||||||
|
# [选填] JWT_SECRET 留空时,会在首次部署 Worker 前自动生成安全随机值并写回 config.toml。
|
||||||
|
JWT_SECRET = ""
|
||||||
|
|
||||||
|
[pages]
|
||||||
|
# [选填] 目标账户中要创建或复用的 Pages 项目名称。
|
||||||
|
project_name = "cf-temp-email-pages"
|
||||||
|
# [必填] 前端访问域名,必须与 mail.domains 分离。
|
||||||
|
custom_domain = "email.example.com"
|
||||||
|
# [选填] "pages" 构建标准前端;"pages:nopwa" 关闭 PWA 产物。
|
||||||
|
build_mode = "pages"
|
||||||
|
production_branch = "production"
|
||||||
|
|
||||||
|
# [选填] 多账号/多环境场景可在 profiles 下定义命名配置。
|
||||||
|
# CLI 使用 `--profile <name>` 选择对应配置,未覆盖的字段会继承根配置。
|
||||||
|
#
|
||||||
|
# [profiles.prod-cn.cloudflare]
|
||||||
|
# account_id = "acc-prod-cn"
|
||||||
|
# zone_name = "kotei.asia"
|
||||||
|
# api_token_env = "CF_API_TOKEN_CN"
|
||||||
|
#
|
||||||
|
# [profiles.prod-cn.mail]
|
||||||
|
# domains = ["mail.kotei.asia"]
|
||||||
|
#
|
||||||
|
# [profiles.prod-cn.pages]
|
||||||
|
# custom_domain = "email.kotei.asia"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def default_config_document() -> TOMLDocument:
|
||||||
|
"""Return the default deployment configuration document."""
|
||||||
|
|
||||||
|
return tomlkit.parse(DEFAULT_CONFIG_TOML)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_parent_directory(path: Path) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def write_text_atomic(path: Path, text: str) -> None:
|
||||||
|
"""Write a text file atomically."""
|
||||||
|
|
||||||
|
_ensure_parent_directory(path)
|
||||||
|
with NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
||||||
|
handle.write(text)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
temp_path = Path(handle.name)
|
||||||
|
temp_path.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def load_toml_document(path: Path) -> TOMLDocument:
|
||||||
|
"""Load a TOML document from disk."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return tomlkit.parse(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ConfigError(f"配置文件不存在: {path}") from exc
|
||||||
|
except ParseError as exc:
|
||||||
|
raise ConfigError(f"TOML 解析失败: {path}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def save_toml_document(path: Path, document: TOMLDocument) -> None:
|
||||||
|
"""Persist a TOML document atomically."""
|
||||||
|
|
||||||
|
write_text_atomic(path, tomlkit.dumps(document))
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_merge_dicts(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
merged = deepcopy(dict(base))
|
||||||
|
for key, value in override.items():
|
||||||
|
current = merged.get(key)
|
||||||
|
if isinstance(current, dict) and isinstance(value, Mapping):
|
||||||
|
merged[key] = _deep_merge_dicts(current, value)
|
||||||
|
continue
|
||||||
|
merged[key] = deepcopy(value)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_profile_payload(payload: Mapping[str, Any], profile: str | None = None) -> dict[str, Any]:
|
||||||
|
base_payload = {key: deepcopy(value) for key, value in payload.items() if key != "profiles"}
|
||||||
|
if not profile:
|
||||||
|
return base_payload
|
||||||
|
|
||||||
|
profiles = payload.get("profiles", {})
|
||||||
|
if not isinstance(profiles, Mapping):
|
||||||
|
raise ConfigError("配置校验失败: profiles 必须是表。")
|
||||||
|
|
||||||
|
selected = profiles.get(profile)
|
||||||
|
if selected is None:
|
||||||
|
raise ConfigError(f"配置校验失败: 未找到 profile: {profile}")
|
||||||
|
if not isinstance(selected, Mapping):
|
||||||
|
raise ConfigError(f"配置校验失败: profiles.{profile} 必须是表。")
|
||||||
|
return _deep_merge_dicts(base_payload, selected)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path, *, profile: str | None = None) -> DeploymentConfig:
|
||||||
|
"""Load and validate the deployment configuration."""
|
||||||
|
|
||||||
|
document = load_toml_document(path)
|
||||||
|
return config_from_document(document, profile=profile)
|
||||||
|
|
||||||
|
|
||||||
|
def config_from_document(document: TOMLDocument, *, profile: str | None = None) -> DeploymentConfig:
|
||||||
|
"""Build and validate a deployment configuration from a TOML document."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = _resolve_profile_payload(document.unwrap(), profile)
|
||||||
|
return DeploymentConfig.model_validate(payload)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfigError(f"配置校验失败: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_toml_value(raw_value: str) -> Any:
|
||||||
|
"""Parse a CLI value using TOML literal syntax."""
|
||||||
|
|
||||||
|
snippet = f"value = {raw_value}\n"
|
||||||
|
try:
|
||||||
|
item = tomlkit.parse(snippet)["value"]
|
||||||
|
except ParseError:
|
||||||
|
return raw_value
|
||||||
|
return item.unwrap() if hasattr(item, "unwrap") else item
|
||||||
|
|
||||||
|
|
||||||
|
def set_dotted_value(document: TOMLDocument, dotted_key: str, value: Any) -> None:
|
||||||
|
"""Set a nested value inside a TOML document."""
|
||||||
|
|
||||||
|
parts = dotted_key.split(".")
|
||||||
|
if not all(parts):
|
||||||
|
raise ConfigError(f"非法配置键: {dotted_key}")
|
||||||
|
|
||||||
|
current: TOMLDocument | AbstractTable = document
|
||||||
|
for part in parts[:-1]:
|
||||||
|
if part not in current:
|
||||||
|
current[part] = tomlkit.table()
|
||||||
|
next_value = current[part]
|
||||||
|
if not isinstance(next_value, AbstractTable):
|
||||||
|
raise ConfigError(f"配置键 {dotted_key} 的父节点不是表: {part}")
|
||||||
|
current = next_value
|
||||||
|
|
||||||
|
current[parts[-1]] = tomlkit.item(value)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_overrides(document: TOMLDocument, overrides: Mapping[str, Any]) -> TOMLDocument:
|
||||||
|
"""Apply CLI override values to a configuration document."""
|
||||||
|
|
||||||
|
for dotted_key, value in overrides.items():
|
||||||
|
set_dotted_value(document, dotted_key, value)
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(path: Path) -> DeploymentState:
|
||||||
|
"""Load deployment state or return the default state when absent."""
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
return DeploymentState()
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = tomlkit.parse(path.read_text(encoding="utf-8"))
|
||||||
|
return DeploymentState.model_validate(document.unwrap())
|
||||||
|
except (ParseError, ValueError) as exc:
|
||||||
|
raise ConfigError(f"状态文件校验失败: {path}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(path: Path, state: DeploymentState) -> None:
|
||||||
|
"""Persist deployment state atomically."""
|
||||||
|
|
||||||
|
write_text_atomic(path, tomlkit.dumps(state.model_dump(mode="python")))
|
||||||
1089
src/cf_temp_email_deploy/deployment.py
Normal file
1089
src/cf_temp_email_deploy/deployment.py
Normal file
File diff suppressed because it is too large
Load Diff
205
src/cf_temp_email_deploy/discovery.py
Normal file
205
src/cf_temp_email_deploy/discovery.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""Discovery helpers for importing existing Cloudflare resources into config files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.cloudflare import CloudflareClient
|
||||||
|
from cf_temp_email_deploy.config import (
|
||||||
|
apply_overrides,
|
||||||
|
config_from_document,
|
||||||
|
default_config_document,
|
||||||
|
load_toml_document,
|
||||||
|
save_toml_document,
|
||||||
|
)
|
||||||
|
from cf_temp_email_deploy.errors import CloudflareAPIError, ConfigError
|
||||||
|
from cf_temp_email_deploy.logging_utils import get_logger
|
||||||
|
|
||||||
|
LOGGER = get_logger("discover")
|
||||||
|
|
||||||
|
|
||||||
|
def discover_config(
|
||||||
|
*,
|
||||||
|
config_path,
|
||||||
|
profile: str | None,
|
||||||
|
cli_overrides: dict[str, Any],
|
||||||
|
cloudflare_client_factory: type[CloudflareClient] = CloudflareClient,
|
||||||
|
) -> None:
|
||||||
|
document = load_toml_document(config_path) if config_path.exists() else default_config_document()
|
||||||
|
if cli_overrides:
|
||||||
|
apply_overrides(document, cli_overrides)
|
||||||
|
|
||||||
|
config = config_from_document(document, profile=profile)
|
||||||
|
if not config.cloudflare.resolved_api_token():
|
||||||
|
raise ConfigError("discover-config 需要 cloudflare.api_token 或 cloudflare.api_token_env。")
|
||||||
|
if not config.cloudflare.zone_name:
|
||||||
|
raise ConfigError("discover-config 需要 cloudflare.zone_name。")
|
||||||
|
|
||||||
|
with cloudflare_client_factory(config.cloudflare) as cloudflare:
|
||||||
|
discovered = _discover_overrides(cloudflare, config.cloudflare.zone_name, config.cloudflare.account_id)
|
||||||
|
|
||||||
|
target_overrides = {
|
||||||
|
_qualify_profile_key(profile, dotted_key): value for dotted_key, value in discovered.items()
|
||||||
|
}
|
||||||
|
apply_overrides(document, target_overrides)
|
||||||
|
save_toml_document(config_path, document)
|
||||||
|
|
||||||
|
|
||||||
|
def _qualify_profile_key(profile: str | None, dotted_key: str) -> str:
|
||||||
|
if not profile:
|
||||||
|
return dotted_key
|
||||||
|
return f"profiles.{profile}.{dotted_key}"
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_overrides(
|
||||||
|
cloudflare: CloudflareClient,
|
||||||
|
zone_name: str,
|
||||||
|
configured_account_id: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
token_info = cloudflare.verify_token()
|
||||||
|
LOGGER.info("[discover] token_status=%s", token_info.get("status", "unknown"))
|
||||||
|
|
||||||
|
zone = cloudflare.resolve_zone(zone_name=zone_name, account_id=configured_account_id)
|
||||||
|
zone_account = zone.get("account") if isinstance(zone.get("account"), dict) else {}
|
||||||
|
account_id = configured_account_id or str(zone_account.get("id", ""))
|
||||||
|
account_name = str(zone_account.get("name", ""))
|
||||||
|
if not account_id:
|
||||||
|
raise CloudflareAPIError("无法从 Zone 信息中解析 account_id。")
|
||||||
|
|
||||||
|
overrides: dict[str, Any] = {
|
||||||
|
"cloudflare.zone_name": str(zone.get("name", zone_name)),
|
||||||
|
"cloudflare.account_id": account_id,
|
||||||
|
}
|
||||||
|
if account_name:
|
||||||
|
overrides["cloudflare.account_name"] = account_name
|
||||||
|
|
||||||
|
zone_id = str(zone.get("id", ""))
|
||||||
|
dns_records = cloudflare.list_dns_records(zone_id)
|
||||||
|
mail_domains = _discover_mail_domains(dns_records)
|
||||||
|
if mail_domains:
|
||||||
|
overrides["mail.domains"] = mail_domains
|
||||||
|
|
||||||
|
verified_destination = _discover_verified_destination(cloudflare, account_id)
|
||||||
|
if verified_destination:
|
||||||
|
overrides["mail.verified_destination_address"] = verified_destination
|
||||||
|
|
||||||
|
d1_name = _discover_d1_name(cloudflare, account_id)
|
||||||
|
if d1_name:
|
||||||
|
overrides["d1.database_name"] = d1_name
|
||||||
|
|
||||||
|
pages = _discover_pages(cloudflare, account_id, dns_records)
|
||||||
|
if pages.get("project_name"):
|
||||||
|
overrides["pages.project_name"] = pages["project_name"]
|
||||||
|
if pages.get("custom_domain"):
|
||||||
|
overrides["pages.custom_domain"] = pages["custom_domain"]
|
||||||
|
|
||||||
|
worker = _discover_worker(cloudflare, zone_id, account_id)
|
||||||
|
if worker.get("script_name"):
|
||||||
|
overrides["worker.script_name"] = worker["script_name"]
|
||||||
|
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_mail_domains(dns_records: list[dict[str, Any]]) -> list[str]:
|
||||||
|
domains = {
|
||||||
|
str(record.get("name", "")).strip()
|
||||||
|
for record in dns_records
|
||||||
|
if str(record.get("type", "")).upper() == "MX"
|
||||||
|
and "mx.cloudflare.net" in str(record.get("content", "")).lower()
|
||||||
|
and str(record.get("name", "")).strip()
|
||||||
|
}
|
||||||
|
return sorted(domains)
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_verified_destination(cloudflare: CloudflareClient, account_id: str) -> str:
|
||||||
|
try:
|
||||||
|
addresses = cloudflare.list_email_routing_addresses(account_id=account_id)
|
||||||
|
except Exception as exc:
|
||||||
|
if cloudflare.is_authentication_error(exc):
|
||||||
|
LOGGER.warning("[discover] 当前鉴权无法读取 Email Routing 地址,跳过 verified_destination_address。")
|
||||||
|
return ""
|
||||||
|
raise
|
||||||
|
for address in addresses:
|
||||||
|
email = str(address.get("email", "")).strip()
|
||||||
|
if email and cloudflare.email_address_ready(address, email):
|
||||||
|
return email
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_d1_name(cloudflare: CloudflareClient, account_id: str) -> str:
|
||||||
|
databases = cloudflare.list_d1_databases(account_id=account_id)
|
||||||
|
if len(databases) == 1:
|
||||||
|
return str(databases[0].get("name", "")).strip()
|
||||||
|
if len(databases) > 1:
|
||||||
|
LOGGER.warning("[discover] 检测到多个 D1 数据库,跳过自动写入 database_name。")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_pages(
|
||||||
|
cloudflare: CloudflareClient,
|
||||||
|
account_id: str,
|
||||||
|
dns_records: list[dict[str, Any]],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
projects = cloudflare.list_pages_projects(account_id=account_id)
|
||||||
|
if not projects:
|
||||||
|
return {}
|
||||||
|
if len(projects) > 1:
|
||||||
|
LOGGER.warning("[discover] 检测到多个 Pages 项目,将尽量通过 DNS 反推。")
|
||||||
|
|
||||||
|
cname_by_content = {
|
||||||
|
str(record.get("content", "")).strip().lower(): str(record.get("name", "")).strip()
|
||||||
|
for record in dns_records
|
||||||
|
if str(record.get("type", "")).upper() == "CNAME"
|
||||||
|
}
|
||||||
|
matches: list[dict[str, str]] = []
|
||||||
|
for project in projects:
|
||||||
|
subdomain = str(project.get("subdomain", "")).strip().lower()
|
||||||
|
if not subdomain:
|
||||||
|
continue
|
||||||
|
custom_domain = cname_by_content.get(subdomain, "")
|
||||||
|
matches.append(
|
||||||
|
{
|
||||||
|
"project_name": str(project.get("name", "")).strip(),
|
||||||
|
"custom_domain": custom_domain,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
exact = [item for item in matches if item["project_name"] and item["custom_domain"]]
|
||||||
|
if len(exact) == 1:
|
||||||
|
return exact[0]
|
||||||
|
if len(projects) == 1:
|
||||||
|
return {
|
||||||
|
"project_name": str(projects[0].get("name", "")).strip(),
|
||||||
|
"custom_domain": exact[0]["custom_domain"] if exact else "",
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_worker(cloudflare: CloudflareClient, zone_id: str, account_id: str) -> dict[str, str]:
|
||||||
|
catch_all = None
|
||||||
|
try:
|
||||||
|
catch_all = cloudflare.get_catch_all(zone_id=zone_id)
|
||||||
|
except Exception as exc:
|
||||||
|
if not cloudflare.is_authentication_error(exc):
|
||||||
|
raise
|
||||||
|
LOGGER.warning("[discover] 当前鉴权无法读取 Catch-all,尝试从 Worker 列表反推脚本。")
|
||||||
|
|
||||||
|
if isinstance(catch_all, dict):
|
||||||
|
actions = catch_all.get("actions")
|
||||||
|
if isinstance(actions, list):
|
||||||
|
for action in actions:
|
||||||
|
if not isinstance(action, dict):
|
||||||
|
continue
|
||||||
|
if action.get("type") != "worker":
|
||||||
|
continue
|
||||||
|
targets = sorted(cloudflare.extract_worker_targets(action.get("value")))
|
||||||
|
if targets:
|
||||||
|
return {"script_name": targets[0]}
|
||||||
|
|
||||||
|
scripts = cloudflare.list_worker_scripts(account_id=account_id)
|
||||||
|
email_scripts = [item for item in scripts if cloudflare.worker_script_supports_email(item)]
|
||||||
|
if len(email_scripts) == 1:
|
||||||
|
return {"script_name": str(email_scripts[0].get("id", "")).strip()}
|
||||||
|
if len(email_scripts) > 1:
|
||||||
|
LOGGER.warning("[discover] 检测到多个带 email handler 的 Worker,跳过自动写入 script_name。")
|
||||||
|
return {}
|
||||||
53
src/cf_temp_email_deploy/environment.py
Normal file
53
src/cf_temp_email_deploy/environment.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Local environment checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.errors import EnvironmentCheckError
|
||||||
|
from cf_temp_email_deploy.subprocess_runner import CommandRunner, CommandSpec
|
||||||
|
|
||||||
|
MINIMUM_NODE_VERSION = (20, 19, 0)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolVersion:
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_semver(raw_version: str) -> tuple[int, int, int]:
|
||||||
|
"""Extract a semantic version tuple from command output."""
|
||||||
|
|
||||||
|
match = re.search(r"(\d+)\.(\d+)\.(\d+)", raw_version)
|
||||||
|
if not match:
|
||||||
|
raise EnvironmentCheckError(f"无法解析版本号: {raw_version.strip()}")
|
||||||
|
return tuple(int(part) for part in match.groups())
|
||||||
|
|
||||||
|
|
||||||
|
def check_tool_version(runner: CommandRunner, command_name: str, version_flag: str = "--version") -> ToolVersion:
|
||||||
|
"""Execute a tool version command and return the parsed output."""
|
||||||
|
|
||||||
|
result = runner.run_checked(CommandSpec(args=(command_name, version_flag)))
|
||||||
|
version_text = (result.stdout or result.stderr).strip()
|
||||||
|
if not version_text:
|
||||||
|
raise EnvironmentCheckError(f"无法读取 {command_name} 的版本输出。")
|
||||||
|
return ToolVersion(name=command_name, version=version_text)
|
||||||
|
|
||||||
|
|
||||||
|
def check_required_tools(runner: CommandRunner) -> dict[str, ToolVersion]:
|
||||||
|
"""Validate required local tools and versions."""
|
||||||
|
|
||||||
|
versions = {
|
||||||
|
"git": check_tool_version(runner, "git"),
|
||||||
|
"node": check_tool_version(runner, "node"),
|
||||||
|
"npm": check_tool_version(runner, "npm"),
|
||||||
|
}
|
||||||
|
node_version = parse_semver(versions["node"].version)
|
||||||
|
if node_version < MINIMUM_NODE_VERSION:
|
||||||
|
minimum = ".".join(str(part) for part in MINIMUM_NODE_VERSION)
|
||||||
|
current = ".".join(str(part) for part in node_version)
|
||||||
|
raise EnvironmentCheckError(f"Node.js 版本过低,当前为 {current},最低要求为 {minimum}。")
|
||||||
|
return versions
|
||||||
|
|
||||||
54
src/cf_temp_email_deploy/errors.py
Normal file
54
src/cf_temp_email_deploy/errors.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Project specific exceptions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class DeployError(Exception):
|
||||||
|
"""Base exception for the deployment CLI."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(DeployError):
|
||||||
|
"""Raised when configuration content is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentCheckError(DeployError):
|
||||||
|
"""Raised when the local environment does not satisfy requirements."""
|
||||||
|
|
||||||
|
|
||||||
|
class CloudflareAPIError(DeployError):
|
||||||
|
"""Raised when Cloudflare API responses cannot be accepted."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
class CommandExecutionError(DeployError):
|
||||||
|
"""Raised when a subprocess returns an unsuccessful result."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
command: tuple[str, ...],
|
||||||
|
returncode: int | None,
|
||||||
|
stdout: str,
|
||||||
|
stderr: str,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.command = command
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
class AcceptanceCheckError(DeployError):
|
||||||
|
"""Raised when post-deployment acceptance checks fail."""
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationAPIError(DeployError):
|
||||||
|
"""Raised when application admin API responses cannot be accepted."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
62
src/cf_temp_email_deploy/logging_utils.py
Normal file
62
src/cf_temp_email_deploy/logging_utils.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Logging helpers used across the project."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shlex
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LOGGER_NAME = "cf_temp_email_deploy"
|
||||||
|
LOG_FORMAT = "%(asctime)s | %(levelname)s | %(message)s"
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(verbose: bool = False) -> logging.Logger:
|
||||||
|
"""Configure and return the package root logger."""
|
||||||
|
|
||||||
|
logger = logging.getLogger(LOGGER_NAME)
|
||||||
|
level = logging.DEBUG if verbose else logging.INFO
|
||||||
|
logger.setLevel(level)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
if not logger.handlers:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter(LOG_FORMAT))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
for handler in logger.handlers:
|
||||||
|
handler.setLevel(level)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str | None = None) -> logging.Logger:
|
||||||
|
"""Return a child logger inside the package namespace."""
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return logging.getLogger(LOGGER_NAME)
|
||||||
|
return logging.getLogger(f"{LOGGER_NAME}.{name}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_stage(message: str) -> None:
|
||||||
|
"""Emit a stage-level log message."""
|
||||||
|
|
||||||
|
get_logger("stage").info("[stage] %s", message)
|
||||||
|
|
||||||
|
|
||||||
|
def log_command(command: tuple[str, ...], cwd: Path | None) -> None:
|
||||||
|
"""Emit a log line before a subprocess starts."""
|
||||||
|
|
||||||
|
location = str(cwd) if cwd else "."
|
||||||
|
get_logger("command").info("[command] cwd=%s cmd=%s", location, shlex.join(command))
|
||||||
|
|
||||||
|
|
||||||
|
def log_command_result(command: tuple[str, ...], returncode: int, duration_seconds: float) -> None:
|
||||||
|
"""Emit a log line after a subprocess finishes."""
|
||||||
|
|
||||||
|
get_logger("command").info(
|
||||||
|
"[command-result] code=%s duration=%.3fs cmd=%s",
|
||||||
|
returncode,
|
||||||
|
duration_seconds,
|
||||||
|
shlex.join(command),
|
||||||
|
)
|
||||||
|
|
||||||
258
src/cf_temp_email_deploy/models.py
Normal file
258
src/cf_temp_email_deploy/models.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""Typed configuration and state models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
def _now_isoformat() -> str:
|
||||||
|
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
class StrictModel(BaseModel):
|
||||||
|
"""Shared Pydantic configuration."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceConfig(StrictModel):
|
||||||
|
mode: Literal["clone", "local"] = "clone"
|
||||||
|
repo_url: str = "https://github.com/dreamhunter2333/cloudflare_temp_email.git"
|
||||||
|
repo_ref: str = ""
|
||||||
|
workspace_dir: str = ".deploy/workspace"
|
||||||
|
local_path: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CloudflareConfig(StrictModel):
|
||||||
|
account_id: str = ""
|
||||||
|
account_name: str = ""
|
||||||
|
zone_name: str = "example.com"
|
||||||
|
api_token: str = ""
|
||||||
|
api_token_env: str = ""
|
||||||
|
api_email: str = ""
|
||||||
|
api_email_env: str = ""
|
||||||
|
global_api_key: str = ""
|
||||||
|
global_api_key_env: str = ""
|
||||||
|
|
||||||
|
def resolved_api_token(self, environ: dict[str, str] | None = None) -> str:
|
||||||
|
environment = environ or os.environ
|
||||||
|
if self.api_token:
|
||||||
|
return self.api_token
|
||||||
|
if self.api_token_env:
|
||||||
|
return environment.get(self.api_token_env, "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def resolved_api_email(self, environ: dict[str, str] | None = None) -> str:
|
||||||
|
environment = environ or os.environ
|
||||||
|
if self.api_email:
|
||||||
|
return self.api_email
|
||||||
|
if self.api_email_env:
|
||||||
|
return environment.get(self.api_email_env, "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def resolved_global_api_key(self, environ: dict[str, str] | None = None) -> str:
|
||||||
|
environment = environ or os.environ
|
||||||
|
if self.global_api_key:
|
||||||
|
return self.global_api_key
|
||||||
|
if self.global_api_key_env:
|
||||||
|
return environment.get(self.global_api_key_env, "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class MailConfig(StrictModel):
|
||||||
|
domains: list[str] = Field(default_factory=lambda: ["mail.example.com"])
|
||||||
|
verified_destination_address: str = "inbox@example.net"
|
||||||
|
|
||||||
|
|
||||||
|
class D1Config(StrictModel):
|
||||||
|
database_name: str = "cf-temp-email"
|
||||||
|
jurisdiction: str = ""
|
||||||
|
adopt_existing_schema: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UserAccessConfig(StrictModel):
|
||||||
|
require_login_to_create: bool = True
|
||||||
|
allow_user_register: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxdoConfig(StrictModel):
|
||||||
|
linuxdo_oauth: bool = False
|
||||||
|
client_id: str = ""
|
||||||
|
client_secret: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerConfig(StrictModel):
|
||||||
|
script_name: str = "cloudflare-temp-email"
|
||||||
|
use_workers_dev: bool = True
|
||||||
|
custom_domain: str = ""
|
||||||
|
compatibility_date: str = "2024-09-23"
|
||||||
|
vars: dict[str, Any] = Field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"PREFIX": "tmp",
|
||||||
|
"ENABLE_USER_CREATE_EMAIL": True,
|
||||||
|
"ENABLE_USER_DELETE_EMAIL": True,
|
||||||
|
"DEFAULT_LANG": "zh",
|
||||||
|
"ADMIN_PASSWORDS": ["change-me"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
secrets: dict[str, str] = Field(default_factory=lambda: {"JWT_SECRET": ""})
|
||||||
|
|
||||||
|
|
||||||
|
class PagesConfig(StrictModel):
|
||||||
|
project_name: str = "cf-temp-email-pages"
|
||||||
|
custom_domain: str = "mail.example.com"
|
||||||
|
build_mode: Literal["pages", "pages:nopwa"] = "pages"
|
||||||
|
production_branch: str = "production"
|
||||||
|
|
||||||
|
|
||||||
|
class DeploymentConfig(StrictModel):
|
||||||
|
config_version: int = 1
|
||||||
|
source: SourceConfig = Field(default_factory=SourceConfig)
|
||||||
|
cloudflare: CloudflareConfig = Field(default_factory=CloudflareConfig)
|
||||||
|
mail: MailConfig = Field(default_factory=MailConfig)
|
||||||
|
d1: D1Config = Field(default_factory=D1Config)
|
||||||
|
user_access: UserAccessConfig = Field(default_factory=UserAccessConfig)
|
||||||
|
linuxdo: LinuxdoConfig = Field(default_factory=LinuxdoConfig)
|
||||||
|
worker: WorkerConfig = Field(default_factory=WorkerConfig)
|
||||||
|
pages: PagesConfig = Field(default_factory=PagesConfig)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_model(self) -> "DeploymentConfig":
|
||||||
|
if not self.mail.domains:
|
||||||
|
raise ValueError("mail.domains 至少需要一个域名。")
|
||||||
|
if self.source.mode == "local" and not self.source.local_path:
|
||||||
|
raise ValueError("source.mode=local 时需要填写 source.local_path。")
|
||||||
|
if not self.cloudflare.zone_name:
|
||||||
|
raise ValueError("cloudflare.zone_name 不能为空。")
|
||||||
|
if not self.pages.custom_domain:
|
||||||
|
raise ValueError("pages.custom_domain 不能为空。")
|
||||||
|
if not self.worker.custom_domain and not self.worker.use_workers_dev:
|
||||||
|
raise ValueError("worker.custom_domain 与 worker.use_workers_dev 不能同时关闭。")
|
||||||
|
if not self.admin_passwords():
|
||||||
|
raise ValueError("worker.vars.ADMIN_PASSWORDS 至少需要一个非空密码。")
|
||||||
|
if self.linuxdo.linuxdo_oauth and not self.linuxdo.client_id.strip():
|
||||||
|
raise ValueError("linuxdo.linuxdo_oauth=true 时需要填写 linuxdo.client_id。")
|
||||||
|
if self.linuxdo.linuxdo_oauth and not self.linuxdo.client_secret.strip():
|
||||||
|
raise ValueError("linuxdo.linuxdo_oauth=true 时需要填写 linuxdo.client_secret。")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def build_frontend_url(self, pages_hostname: str | None = None) -> str:
|
||||||
|
hostname = self.pages.custom_domain or pages_hostname or f"{self.pages.project_name}.pages.dev"
|
||||||
|
return f"https://{hostname}"
|
||||||
|
|
||||||
|
def admin_passwords(self) -> list[str]:
|
||||||
|
raw_value = self.worker.vars.get("ADMIN_PASSWORDS", [])
|
||||||
|
if isinstance(raw_value, str):
|
||||||
|
value = raw_value.strip()
|
||||||
|
return [value] if value else []
|
||||||
|
if not isinstance(raw_value, list):
|
||||||
|
return []
|
||||||
|
passwords: list[str] = []
|
||||||
|
for item in raw_value:
|
||||||
|
value = str(item).strip()
|
||||||
|
if value:
|
||||||
|
passwords.append(value)
|
||||||
|
return passwords
|
||||||
|
|
||||||
|
def effective_require_login_to_create(self) -> bool:
|
||||||
|
return self.user_access.require_login_to_create or self.linuxdo.linuxdo_oauth
|
||||||
|
|
||||||
|
def linuxdo_callback_url(self) -> str:
|
||||||
|
return f"https://{self.pages.custom_domain}/user/oauth2/callback"
|
||||||
|
|
||||||
|
def derived_worker_vars(self, pages_hostname: str | None = None) -> dict[str, Any]:
|
||||||
|
values = dict(self.worker.vars)
|
||||||
|
values["DOMAINS"] = list(self.mail.domains)
|
||||||
|
values.setdefault("DEFAULT_DOMAINS", list(self.mail.domains))
|
||||||
|
values["FRONTEND_URL"] = self.build_frontend_url(pages_hostname)
|
||||||
|
values["DISABLE_ANONYMOUS_USER_CREATE_EMAIL"] = self.effective_require_login_to_create()
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
class CloudflareState(StrictModel):
|
||||||
|
account_id: str = ""
|
||||||
|
account_name: str = ""
|
||||||
|
zone_id: str = ""
|
||||||
|
zone_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SourceState(StrictModel):
|
||||||
|
source_dir: str = ""
|
||||||
|
commit_sha: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PagesState(StrictModel):
|
||||||
|
project_id: str = ""
|
||||||
|
project_name: str = ""
|
||||||
|
subdomain: str = ""
|
||||||
|
custom_domain: str = ""
|
||||||
|
custom_domain_status: str = ""
|
||||||
|
cname_record_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class D1MigrationState(StrictModel):
|
||||||
|
file_name: str
|
||||||
|
sha256: str
|
||||||
|
applied_at: str = Field(default_factory=_now_isoformat)
|
||||||
|
|
||||||
|
|
||||||
|
class D1State(StrictModel):
|
||||||
|
database_id: str = ""
|
||||||
|
database_name: str = ""
|
||||||
|
schema_version: str = ""
|
||||||
|
migrations: list[D1MigrationState] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerState(StrictModel):
|
||||||
|
script_name: str = ""
|
||||||
|
workers_dev_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationState(StrictModel):
|
||||||
|
configured: bool = False
|
||||||
|
admin_base_url: str = ""
|
||||||
|
allow_user_register: bool = False
|
||||||
|
require_login_to_create: bool = False
|
||||||
|
linuxdo_oauth_enabled: bool = False
|
||||||
|
linuxdo_redirect_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class EmailRoutingState(StrictModel):
|
||||||
|
destination_address: str = ""
|
||||||
|
dns_record_ids: list[str] = Field(default_factory=list)
|
||||||
|
catch_all_enabled: bool = False
|
||||||
|
catch_all_rule_id: str = ""
|
||||||
|
catch_all_worker: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DeploymentEvent(StrictModel):
|
||||||
|
stage: str
|
||||||
|
status: Literal["started", "completed", "failed"]
|
||||||
|
message: str = ""
|
||||||
|
timestamp: str = Field(default_factory=_now_isoformat)
|
||||||
|
|
||||||
|
|
||||||
|
class DeploymentState(StrictModel):
|
||||||
|
config_version: int = 1
|
||||||
|
checkpoint: str = ""
|
||||||
|
last_updated_at: str = Field(default_factory=_now_isoformat)
|
||||||
|
cloudflare: CloudflareState = Field(default_factory=CloudflareState)
|
||||||
|
source: SourceState = Field(default_factory=SourceState)
|
||||||
|
pages: PagesState = Field(default_factory=PagesState)
|
||||||
|
d1: D1State = Field(default_factory=D1State)
|
||||||
|
worker: WorkerState = Field(default_factory=WorkerState)
|
||||||
|
application: ApplicationState = Field(default_factory=ApplicationState)
|
||||||
|
email_routing: EmailRoutingState = Field(default_factory=EmailRoutingState)
|
||||||
|
events: list[DeploymentEvent] = Field(default_factory=list)
|
||||||
|
|
||||||
|
def mark_checkpoint(self, checkpoint: str) -> None:
|
||||||
|
self.checkpoint = checkpoint
|
||||||
|
self.last_updated_at = _now_isoformat()
|
||||||
|
|
||||||
|
def record_event(self, stage: str, status: Literal["started", "completed", "failed"], message: str = "") -> None:
|
||||||
|
self.events.append(DeploymentEvent(stage=stage, status=status, message=message))
|
||||||
|
self.last_updated_at = _now_isoformat()
|
||||||
79
src/cf_temp_email_deploy/project_layout.py
Normal file
79
src/cf_temp_email_deploy/project_layout.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""Helpers for locating the upstream project layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.errors import ConfigError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProjectLayout:
|
||||||
|
"""Resolved directory layout for the upstream project."""
|
||||||
|
|
||||||
|
root_dir: Path
|
||||||
|
worker_dir: Path
|
||||||
|
frontend_dir: Path
|
||||||
|
pages_dir: Path
|
||||||
|
db_dir: Path
|
||||||
|
worker_wrangler_template: Path
|
||||||
|
worker_wrangler_path: Path
|
||||||
|
worker_patch_path: Path
|
||||||
|
pages_wrangler_path: Path
|
||||||
|
schema_path: Path
|
||||||
|
migration_paths: tuple[Path, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frontend_dist_dir(self) -> Path:
|
||||||
|
return self.frontend_dir / "dist"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telegraf_dir(self) -> Path:
|
||||||
|
return self.worker_dir / "node_modules" / "telegraf"
|
||||||
|
|
||||||
|
|
||||||
|
def detect_project_layout(root_dir: Path) -> ProjectLayout:
|
||||||
|
"""Resolve the expected Cloudflare Temp Email project structure."""
|
||||||
|
|
||||||
|
base_dir = root_dir.expanduser().resolve()
|
||||||
|
worker_dir = _require_directory(base_dir / "worker")
|
||||||
|
frontend_dir = _require_directory(base_dir / "frontend")
|
||||||
|
pages_dir = _require_directory(base_dir / "pages")
|
||||||
|
db_dir = _require_directory(base_dir / "db")
|
||||||
|
worker_wrangler_template = _require_file(worker_dir / "wrangler.toml.template")
|
||||||
|
pages_wrangler_path = pages_dir / "wrangler.toml"
|
||||||
|
schema_path = _require_file(db_dir / "schema.sql")
|
||||||
|
worker_patch_path = _require_file(worker_dir / "patches" / "telegraf@4.16.3.patch")
|
||||||
|
migration_paths = tuple(
|
||||||
|
sorted(
|
||||||
|
path
|
||||||
|
for path in db_dir.glob("*.sql")
|
||||||
|
if path.name != "schema.sql"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ProjectLayout(
|
||||||
|
root_dir=base_dir,
|
||||||
|
worker_dir=worker_dir,
|
||||||
|
frontend_dir=frontend_dir,
|
||||||
|
pages_dir=pages_dir,
|
||||||
|
db_dir=db_dir,
|
||||||
|
worker_wrangler_template=worker_wrangler_template,
|
||||||
|
worker_wrangler_path=worker_dir / "wrangler.toml",
|
||||||
|
worker_patch_path=worker_patch_path,
|
||||||
|
pages_wrangler_path=pages_wrangler_path,
|
||||||
|
schema_path=schema_path,
|
||||||
|
migration_paths=migration_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_directory(path: Path) -> Path:
|
||||||
|
if not path.is_dir():
|
||||||
|
raise ConfigError(f"缺少目录: {path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _require_file(path: Path) -> Path:
|
||||||
|
if not path.is_file():
|
||||||
|
raise ConfigError(f"缺少文件: {path}")
|
||||||
|
return path
|
||||||
117
src/cf_temp_email_deploy/source.py
Normal file
117
src/cf_temp_email_deploy/source.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""Source preparation helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.errors import ConfigError
|
||||||
|
from cf_temp_email_deploy.models import DeploymentConfig, DeploymentState, SourceConfig
|
||||||
|
from cf_temp_email_deploy.subprocess_runner import CommandRunner, CommandSpec
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PreparedSource:
|
||||||
|
"""Prepared source directory and its commit identity."""
|
||||||
|
|
||||||
|
source_dir: Path
|
||||||
|
commit_sha: str
|
||||||
|
|
||||||
|
def apply_to_state(self, state: DeploymentState) -> None:
|
||||||
|
state.source.source_dir = str(self.source_dir)
|
||||||
|
state.source.commit_sha = self.commit_sha
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_source(config: DeploymentConfig, runner: CommandRunner) -> PreparedSource:
|
||||||
|
"""Prepare deployment source code according to the configured mode."""
|
||||||
|
|
||||||
|
if config.source.mode == "clone":
|
||||||
|
return prepare_cloned_source(config.source, runner)
|
||||||
|
return prepare_local_source(config.source, runner)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_cloned_source(source: SourceConfig, runner: CommandRunner) -> PreparedSource:
|
||||||
|
"""Clone or refresh the configured repository and checkout the target ref."""
|
||||||
|
|
||||||
|
workspace_dir = Path(source.workspace_dir).expanduser().resolve()
|
||||||
|
repo_dir = workspace_dir / "source"
|
||||||
|
if repo_dir.exists() and not (repo_dir / ".git").exists():
|
||||||
|
raise ConfigError(f"源码目录存在但不是 Git 仓库: {repo_dir}")
|
||||||
|
|
||||||
|
if not repo_dir.exists():
|
||||||
|
runner.run_checked(CommandSpec(args=("git", "clone", source.repo_url, str(repo_dir))))
|
||||||
|
|
||||||
|
runner.run_checked(CommandSpec(args=("git", "-C", str(repo_dir), "fetch", "--tags", "origin")))
|
||||||
|
if not _checkout_ref(repo_dir, source.repo_ref, runner):
|
||||||
|
raise ConfigError(f"无法切换到目标源码版本: {source.repo_ref}")
|
||||||
|
return PreparedSource(source_dir=repo_dir, commit_sha=resolve_commit_sha(repo_dir, runner))
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_local_source(source: SourceConfig, runner: CommandRunner) -> PreparedSource:
|
||||||
|
"""Validate a local source directory and collect its commit SHA."""
|
||||||
|
|
||||||
|
if not source.local_path:
|
||||||
|
raise ConfigError("source.mode=local 时需要填写 source.local_path。")
|
||||||
|
|
||||||
|
source_dir = Path(source.local_path).expanduser().resolve()
|
||||||
|
if not source_dir.exists():
|
||||||
|
raise ConfigError(f"本地源码目录不存在: {source_dir}")
|
||||||
|
if not (source_dir / ".git").exists():
|
||||||
|
raise ConfigError(f"本地源码目录不是 Git 仓库: {source_dir}")
|
||||||
|
return PreparedSource(source_dir=source_dir, commit_sha=resolve_commit_sha(source_dir, runner))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_commit_sha(repo_dir: Path, runner: CommandRunner) -> str:
|
||||||
|
"""Resolve the current HEAD commit SHA for a Git repository."""
|
||||||
|
|
||||||
|
result = runner.run_checked(CommandSpec(args=("git", "-C", str(repo_dir), "rev-parse", "HEAD")))
|
||||||
|
commit_sha = result.stdout.strip()
|
||||||
|
if not commit_sha:
|
||||||
|
raise ConfigError(f"无法读取 Git 提交哈希: {repo_dir}")
|
||||||
|
return commit_sha
|
||||||
|
|
||||||
|
|
||||||
|
def _checkout_ref(repo_dir: Path, repo_ref: str, runner: CommandRunner) -> bool:
|
||||||
|
if not repo_ref:
|
||||||
|
return _checkout_latest_remote_head(repo_dir, runner)
|
||||||
|
|
||||||
|
candidates = (
|
||||||
|
("git", "-C", str(repo_dir), "checkout", "--detach", repo_ref),
|
||||||
|
("git", "-C", str(repo_dir), "checkout", "--detach", f"origin/{repo_ref}"),
|
||||||
|
)
|
||||||
|
for args in candidates:
|
||||||
|
result = runner.run(CommandSpec(args=args))
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True
|
||||||
|
fetch_result = runner.run(
|
||||||
|
CommandSpec(args=("git", "-C", str(repo_dir), "fetch", "origin", repo_ref))
|
||||||
|
)
|
||||||
|
if fetch_result.returncode != 0:
|
||||||
|
return False
|
||||||
|
checkout_result = runner.run(
|
||||||
|
CommandSpec(args=("git", "-C", str(repo_dir), "checkout", "--detach", "FETCH_HEAD"))
|
||||||
|
)
|
||||||
|
return checkout_result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _checkout_latest_remote_head(repo_dir: Path, runner: CommandRunner) -> bool:
|
||||||
|
symbolic_ref = runner.run(
|
||||||
|
CommandSpec(args=("git", "-C", str(repo_dir), "symbolic-ref", "refs/remotes/origin/HEAD"))
|
||||||
|
)
|
||||||
|
candidates: list[tuple[str, ...]] = []
|
||||||
|
remote_head = symbolic_ref.stdout.strip()
|
||||||
|
if symbolic_ref.returncode == 0 and remote_head:
|
||||||
|
candidates.append(("git", "-C", str(repo_dir), "checkout", "--detach", remote_head))
|
||||||
|
|
||||||
|
candidates.extend(
|
||||||
|
[
|
||||||
|
("git", "-C", str(repo_dir), "checkout", "--detach", "origin/HEAD"),
|
||||||
|
("git", "-C", str(repo_dir), "checkout", "--detach", "origin/main"),
|
||||||
|
("git", "-C", str(repo_dir), "checkout", "--detach", "origin/master"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for args in candidates:
|
||||||
|
result = runner.run(CommandSpec(args=args))
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
87
src/cf_temp_email_deploy/subprocess_runner.py
Normal file
87
src/cf_temp_email_deploy/subprocess_runner.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Subprocess execution helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping, Sequence
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.errors import CommandExecutionError
|
||||||
|
from cf_temp_email_deploy.logging_utils import log_command, log_command_result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandSpec:
|
||||||
|
args: Sequence[str]
|
||||||
|
cwd: Path | None = None
|
||||||
|
env: Mapping[str, str] | None = None
|
||||||
|
timeout: float | None = None
|
||||||
|
input_text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CommandResult:
|
||||||
|
args: tuple[str, ...]
|
||||||
|
returncode: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
duration_seconds: float = field(compare=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRunner:
|
||||||
|
"""Execute subprocess commands with logging and structured errors."""
|
||||||
|
|
||||||
|
def run(self, spec: CommandSpec) -> CommandResult:
|
||||||
|
command = tuple(spec.args)
|
||||||
|
environment = os.environ.copy()
|
||||||
|
if spec.env:
|
||||||
|
environment.update(spec.env)
|
||||||
|
|
||||||
|
log_command(command, spec.cwd)
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=spec.cwd,
|
||||||
|
env=environment,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
input=spec.input_text,
|
||||||
|
timeout=spec.timeout,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise CommandExecutionError(
|
||||||
|
f"命令执行超时: {command[0]}",
|
||||||
|
command=command,
|
||||||
|
returncode=None,
|
||||||
|
stdout=exc.stdout or "",
|
||||||
|
stderr=exc.stderr or "",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
duration = time.perf_counter() - started_at
|
||||||
|
log_command_result(command, completed.returncode, duration)
|
||||||
|
return CommandResult(
|
||||||
|
args=command,
|
||||||
|
returncode=completed.returncode,
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
duration_seconds=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_checked(self, spec: CommandSpec) -> CommandResult:
|
||||||
|
"""Execute a command and require a zero exit code."""
|
||||||
|
|
||||||
|
result = self.run(spec)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise CommandExecutionError(
|
||||||
|
f"命令执行失败: {result.args[0]}",
|
||||||
|
command=result.args,
|
||||||
|
returncode=result.returncode,
|
||||||
|
stdout=result.stdout,
|
||||||
|
stderr=result.stderr,
|
||||||
|
)
|
||||||
|
return result
|
||||||
36
src/cf_temp_email_deploy/wrangler.py
Normal file
36
src/cf_temp_email_deploy/wrangler.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Wrangler command helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def build_wrangler_command(*args: str) -> tuple[str, ...]:
|
||||||
|
"""Build a local wrangler invocation using npm exec."""
|
||||||
|
|
||||||
|
return ("npm", "exec", "--", "wrangler", *args)
|
||||||
|
|
||||||
|
|
||||||
|
def build_wrangler_secret_put_command(secret_name: str) -> tuple[str, ...]:
|
||||||
|
"""Build a command that stores a Worker secret from standard input."""
|
||||||
|
|
||||||
|
return build_wrangler_command("secret", "put", secret_name)
|
||||||
|
|
||||||
|
|
||||||
|
def build_wrangler_d1_execute_command(database_name: str, sql_file: Path) -> tuple[str, ...]:
|
||||||
|
"""Build a command that executes a SQL file against a remote D1 database."""
|
||||||
|
|
||||||
|
return build_wrangler_command(
|
||||||
|
"d1",
|
||||||
|
"execute",
|
||||||
|
database_name,
|
||||||
|
"--file",
|
||||||
|
str(sql_file),
|
||||||
|
"--remote",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_wrangler_pages_deploy_command(branch: str) -> tuple[str, ...]:
|
||||||
|
"""Build a command that deploys a Pages project using the configured wrangler file."""
|
||||||
|
|
||||||
|
return build_wrangler_command("pages", "deploy", "--branch", branch)
|
||||||
101
tests/test_app_admin.py
Normal file
101
tests/test_app_admin.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.app_admin import (
|
||||||
|
LINUXDO_ACCESS_TOKEN_URL,
|
||||||
|
LINUXDO_AUTHORIZATION_URL,
|
||||||
|
LINUXDO_SCOPE,
|
||||||
|
LINUXDO_USER_INFO_URL,
|
||||||
|
build_linuxdo_oauth2_setting,
|
||||||
|
merge_linuxdo_oauth2_settings,
|
||||||
|
merge_user_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_user_settings_preserves_existing_fields_and_updates_enable() -> None:
|
||||||
|
current = {
|
||||||
|
"enable": True,
|
||||||
|
"enableMailVerify": True,
|
||||||
|
"verifyMailSender": "noreply@example.com",
|
||||||
|
"enableMailAllowList": True,
|
||||||
|
"mailAllowList": ["allowed@example.com"],
|
||||||
|
"maxAddressCount": 9,
|
||||||
|
"enableEmailCheckRegex": True,
|
||||||
|
"emailCheckRegex": ".*",
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = merge_user_settings(current, False)
|
||||||
|
|
||||||
|
assert merged["enable"] is False
|
||||||
|
assert merged["enableMailVerify"] is True
|
||||||
|
assert merged["verifyMailSender"] == "noreply@example.com"
|
||||||
|
assert merged["enableMailAllowList"] is True
|
||||||
|
assert merged["mailAllowList"] == ["allowed@example.com"]
|
||||||
|
assert merged["maxAddressCount"] == 9
|
||||||
|
assert merged["enableEmailCheckRegex"] is True
|
||||||
|
assert merged["emailCheckRegex"] == ".*"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_linuxdo_oauth2_setting_matches_expected_fields() -> None:
|
||||||
|
setting = build_linuxdo_oauth2_setting(
|
||||||
|
pages_domain="email.example.com",
|
||||||
|
client_id="linuxdo-client-id",
|
||||||
|
client_secret="linuxdo-client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert setting["name"] == "LINUX DO"
|
||||||
|
assert setting["clientID"] == "linuxdo-client-id"
|
||||||
|
assert setting["clientSecret"] == "linuxdo-client-secret"
|
||||||
|
assert setting["authorizationURL"] == LINUXDO_AUTHORIZATION_URL
|
||||||
|
assert setting["accessTokenURL"] == LINUXDO_ACCESS_TOKEN_URL
|
||||||
|
assert setting["userInfoURL"] == LINUXDO_USER_INFO_URL
|
||||||
|
assert setting["redirectURL"] == "https://email.example.com/user/oauth2/callback"
|
||||||
|
assert setting["scope"] == LINUXDO_SCOPE
|
||||||
|
assert setting["enableEmailFormat"] is True
|
||||||
|
assert setting["userEmailReplace"] == "linux_do_$1@oauth.linux.do"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_linuxdo_oauth2_settings_appends_when_missing() -> None:
|
||||||
|
merged = merge_linuxdo_oauth2_settings(
|
||||||
|
[{"name": "GitHub", "clientID": "github-client"}],
|
||||||
|
pages_domain="email.example.com",
|
||||||
|
client_id="linuxdo-client-id",
|
||||||
|
client_secret="linuxdo-client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(merged) == 2
|
||||||
|
assert merged[0]["name"] == "GitHub"
|
||||||
|
assert merged[1]["name"] == "LINUX DO"
|
||||||
|
assert merged[1]["redirectURL"] == "https://email.example.com/user/oauth2/callback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_linuxdo_oauth2_settings_replaces_existing_and_preserves_selected_fields() -> None:
|
||||||
|
current = [
|
||||||
|
{
|
||||||
|
"name": "linux do",
|
||||||
|
"clientID": "old-client-id",
|
||||||
|
"clientSecret": "old-client-secret",
|
||||||
|
"authorizationURL": LINUXDO_AUTHORIZATION_URL,
|
||||||
|
"accessTokenURL": LINUXDO_ACCESS_TOKEN_URL,
|
||||||
|
"userInfoURL": LINUXDO_USER_INFO_URL,
|
||||||
|
"redirectURL": "https://old.example.com/user/oauth2/callback",
|
||||||
|
"logoutURL": "https://connect.linux.do/logout",
|
||||||
|
"enableMailAllowList": True,
|
||||||
|
"mailAllowList": ["allowed@example.com"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
merged = merge_linuxdo_oauth2_settings(
|
||||||
|
current,
|
||||||
|
pages_domain="email.example.com",
|
||||||
|
client_id="linuxdo-client-id",
|
||||||
|
client_secret="linuxdo-client-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(merged) == 1
|
||||||
|
assert merged[0]["name"] == "LINUX DO"
|
||||||
|
assert merged[0]["clientID"] == "linuxdo-client-id"
|
||||||
|
assert merged[0]["clientSecret"] == "linuxdo-client-secret"
|
||||||
|
assert merged[0]["redirectURL"] == "https://email.example.com/user/oauth2/callback"
|
||||||
|
assert merged[0]["logoutURL"] == "https://connect.linux.do/logout"
|
||||||
|
assert merged[0]["enableMailAllowList"] is True
|
||||||
|
assert merged[0]["mailAllowList"] == ["allowed@example.com"]
|
||||||
275
tests/test_cli.py
Normal file
275
tests/test_cli.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cf_temp_email_deploy import cli
|
||||||
|
from cf_temp_email_deploy.environment import ToolVersion
|
||||||
|
from cf_temp_email_deploy.wrangler import build_wrangler_command
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCloudflareClient:
|
||||||
|
def __init__(self, config: object) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.resolve_account_called = False
|
||||||
|
|
||||||
|
def __enter__(self) -> "FakeCloudflareClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def verify_token(self) -> dict[str, str]:
|
||||||
|
return {"status": "active"}
|
||||||
|
|
||||||
|
def resolve_account(self, *, account_id: str = "", account_name: str = "") -> dict[str, str]:
|
||||||
|
self.resolve_account_called = True
|
||||||
|
return {"id": account_id or "acc-1", "name": account_name or "demo-account"}
|
||||||
|
|
||||||
|
def resolve_zone(self, *, zone_name: str, account_id: str = "") -> dict[str, str]:
|
||||||
|
payload = {"id": "zone-1", "name": zone_name}
|
||||||
|
if not account_id:
|
||||||
|
payload["account"] = {"id": "acc-from-zone", "name": "zone-account"}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def list_dns_records(self, zone_id: str, *, name: str = "", record_type: str = "") -> list[dict[str, str]]:
|
||||||
|
_ = zone_id
|
||||||
|
_ = name
|
||||||
|
_ = record_type
|
||||||
|
return [
|
||||||
|
{"type": "MX", "name": "mail.kotei.us.ci", "content": "route1.mx.cloudflare.net"},
|
||||||
|
{"type": "MX", "name": "maila.kotei.us.ci", "content": "route2.mx.cloudflare.net"},
|
||||||
|
{"type": "CNAME", "name": "email.kotei.us.ci", "content": "cf-temp-email-pages.pages.dev"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_email_routing_addresses(self, *, account_id: str) -> list[dict[str, str]]:
|
||||||
|
_ = account_id
|
||||||
|
return [{"email": "verified@kotei.us.ci", "status": "verified"}]
|
||||||
|
|
||||||
|
def email_address_ready(self, address: dict[str, str], target: str) -> bool:
|
||||||
|
return address["email"] == target and address["status"] == "verified"
|
||||||
|
|
||||||
|
def list_d1_databases(self, *, account_id: str, database_name: str = "") -> list[dict[str, str]]:
|
||||||
|
_ = account_id
|
||||||
|
_ = database_name
|
||||||
|
return [{"name": "cf-temp-email-kotei"}]
|
||||||
|
|
||||||
|
def list_pages_projects(self, *, account_id: str) -> list[dict[str, str]]:
|
||||||
|
_ = account_id
|
||||||
|
return [{"name": "cf-temp-email-pages", "subdomain": "cf-temp-email-pages.pages.dev"}]
|
||||||
|
|
||||||
|
def get_catch_all(self, *, zone_id: str) -> dict[str, object]:
|
||||||
|
_ = zone_id
|
||||||
|
return {"actions": [{"type": "worker", "value": ["temp-email-api"]}]}
|
||||||
|
|
||||||
|
def extract_worker_targets(self, value: object) -> set[str]:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return {str(item) for item in value}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def list_worker_scripts(self, *, account_id: str) -> list[dict[str, object]]:
|
||||||
|
_ = account_id
|
||||||
|
return [{"id": "temp-email-api", "handlers": ["fetch", "email"]}]
|
||||||
|
|
||||||
|
def worker_script_supports_email(self, script: dict[str, object]) -> bool:
|
||||||
|
handlers = script.get("handlers", [])
|
||||||
|
return isinstance(handlers, list) and "email" in handlers
|
||||||
|
|
||||||
|
def is_authentication_error(self, error: Exception) -> bool:
|
||||||
|
_ = error
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_config_and_check_command(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli,
|
||||||
|
"check_required_tools",
|
||||||
|
lambda runner: {
|
||||||
|
"git": ToolVersion(name="git", version="git version 2.43.0"),
|
||||||
|
"node": ToolVersion(name="node", version="v24.14.0"),
|
||||||
|
"npm": ToolVersion(name="npm", version="11.9.0"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(cli, "CloudflareClient", FakeCloudflareClient)
|
||||||
|
|
||||||
|
assert cli.main(["init-config", "--config", str(config_path)]) == 0
|
||||||
|
assert (
|
||||||
|
cli.main(
|
||||||
|
[
|
||||||
|
"check",
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"--api-token",
|
||||||
|
"token-value",
|
||||||
|
"--account-id",
|
||||||
|
"acc-1",
|
||||||
|
"--zone-name",
|
||||||
|
"example.com",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
state_path = tmp_path / ".deploy" / "state.toml"
|
||||||
|
assert state_path.exists()
|
||||||
|
state_text = state_path.read_text(encoding="utf-8")
|
||||||
|
config_text = config_path.read_text(encoding="utf-8")
|
||||||
|
assert "zone-1" in state_text
|
||||||
|
assert 'api_token = "token-value"' in config_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_wrangler_command_uses_npm_exec() -> None:
|
||||||
|
assert build_wrangler_command("deploy", "--minify") == (
|
||||||
|
"npm",
|
||||||
|
"exec",
|
||||||
|
"--",
|
||||||
|
"wrangler",
|
||||||
|
"deploy",
|
||||||
|
"--minify",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_parser_uses_cf_temp_email_program_name() -> None:
|
||||||
|
parser = cli.build_parser()
|
||||||
|
|
||||||
|
assert parser.prog == "cf-temp-email"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_cli_argv_defaults_to_deploy() -> None:
|
||||||
|
assert cli.resolve_cli_argv([]) == ["deploy"]
|
||||||
|
assert cli.resolve_cli_argv(["--config", "config.toml"]) == ["deploy", "--config", "config.toml"]
|
||||||
|
assert cli.resolve_cli_argv(["deploy", "--config", "config.toml"]) == ["deploy", "--config", "config.toml"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_defaults_to_deploy_when_subcommand_is_omitted(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def fake_handle_deploy(args: object) -> int:
|
||||||
|
captured["command"] = getattr(args, "command", "")
|
||||||
|
captured["config"] = getattr(args, "config", None)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "handle_deploy", fake_handle_deploy)
|
||||||
|
|
||||||
|
assert cli.main(["--config", str(config_path)]) == 0
|
||||||
|
assert captured["command"] == "deploy"
|
||||||
|
assert captured["config"] == config_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_command_can_resolve_zone_without_explicit_account(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli,
|
||||||
|
"check_required_tools",
|
||||||
|
lambda runner: {
|
||||||
|
"git": ToolVersion(name="git", version="git version 2.43.0"),
|
||||||
|
"node": ToolVersion(name="node", version="v24.14.0"),
|
||||||
|
"npm": ToolVersion(name="npm", version="11.9.0"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(cli, "CloudflareClient", FakeCloudflareClient)
|
||||||
|
|
||||||
|
assert cli.main(["init-config", "--config", str(config_path)]) == 0
|
||||||
|
assert (
|
||||||
|
cli.main(
|
||||||
|
[
|
||||||
|
"check",
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"--api-token",
|
||||||
|
"token-value",
|
||||||
|
"--zone-name",
|
||||||
|
"osozos.top",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
state_path = tmp_path / ".deploy" / "state.toml"
|
||||||
|
state_text = state_path.read_text(encoding="utf-8")
|
||||||
|
assert "acc-from-zone" in state_text
|
||||||
|
assert "osozos.top" in state_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_command_supports_profile_scoped_config_and_state(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cli,
|
||||||
|
"check_required_tools",
|
||||||
|
lambda runner: {
|
||||||
|
"git": ToolVersion(name="git", version="git version 2.43.0"),
|
||||||
|
"node": ToolVersion(name="node", version="v24.14.0"),
|
||||||
|
"npm": ToolVersion(name="npm", version="11.9.0"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(cli, "CloudflareClient", FakeCloudflareClient)
|
||||||
|
|
||||||
|
assert cli.main(["init-config", "--config", str(config_path)]) == 0
|
||||||
|
assert (
|
||||||
|
cli.main(
|
||||||
|
[
|
||||||
|
"check",
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"--profile",
|
||||||
|
"account_b",
|
||||||
|
"--api-token",
|
||||||
|
"token-b",
|
||||||
|
"--account-id",
|
||||||
|
"acc-b",
|
||||||
|
"--zone-name",
|
||||||
|
"kotei.asia",
|
||||||
|
"--pages-domain",
|
||||||
|
"email.kotei.asia",
|
||||||
|
"--set",
|
||||||
|
'mail.domains=["mail.kotei.asia","maila.kotei.asia"]',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_state_path = tmp_path / ".deploy" / "profiles" / "account_b" / "state.toml"
|
||||||
|
assert profile_state_path.exists()
|
||||||
|
config_text = config_path.read_text(encoding="utf-8")
|
||||||
|
state_text = profile_state_path.read_text(encoding="utf-8")
|
||||||
|
assert '[profiles.account_b.cloudflare]' in config_text
|
||||||
|
assert 'api_token = "token-b"' in config_text
|
||||||
|
assert 'domains = ["mail.kotei.asia", "maila.kotei.asia"]' in config_text
|
||||||
|
assert "acc-b" in state_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_config_writes_detected_cloudflare_resources(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
monkeypatch.setattr(cli, "CloudflareClient", FakeCloudflareClient)
|
||||||
|
|
||||||
|
assert cli.main(["init-config", "--config", str(config_path)]) == 0
|
||||||
|
assert (
|
||||||
|
cli.main(
|
||||||
|
[
|
||||||
|
"discover-config",
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"--profile",
|
||||||
|
"kotei",
|
||||||
|
"--api-token",
|
||||||
|
"token-value",
|
||||||
|
"--zone-name",
|
||||||
|
"kotei.us.ci",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
config_text = config_path.read_text(encoding="utf-8")
|
||||||
|
assert '[profiles.kotei.cloudflare]' in config_text
|
||||||
|
assert 'zone_name = "kotei.us.ci"' in config_text
|
||||||
|
assert 'account_id = "acc-from-zone"' in config_text
|
||||||
|
assert 'verified_destination_address = "verified@kotei.us.ci"' in config_text
|
||||||
|
assert 'domains = ["mail.kotei.us.ci", "maila.kotei.us.ci"]' in config_text
|
||||||
|
assert 'project_name = "cf-temp-email-pages"' in config_text
|
||||||
|
assert 'custom_domain = "email.kotei.us.ci"' in config_text
|
||||||
|
assert 'script_name = "temp-email-api"' in config_text
|
||||||
295
tests/test_cloudflare.py
Normal file
295
tests/test_cloudflare.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.cloudflare import CloudflareClient
|
||||||
|
from cf_temp_email_deploy.errors import CloudflareAPIError, ConfigError
|
||||||
|
from cf_temp_email_deploy.models import CloudflareConfig
|
||||||
|
|
||||||
|
|
||||||
|
def build_config() -> CloudflareConfig:
|
||||||
|
return CloudflareConfig(
|
||||||
|
account_name="demo-account",
|
||||||
|
zone_name="example.com",
|
||||||
|
api_token="token-value",
|
||||||
|
api_email="demo@example.com",
|
||||||
|
global_api_key="global-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_verify_token_uses_bearer_token() -> None:
|
||||||
|
route = respx.get("https://api.cloudflare.com/client/v4/user/tokens/verify").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"success": True, "result": {"status": "active"}},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with CloudflareClient(build_config()) as client:
|
||||||
|
result = client.verify_token()
|
||||||
|
|
||||||
|
assert route.called is True
|
||||||
|
assert route.calls[0].request.headers["Authorization"] == "Bearer token-value"
|
||||||
|
assert result["status"] == "active"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_resolve_account_uses_global_key_headers() -> None:
|
||||||
|
route = respx.get("https://api.cloudflare.com/client/v4/accounts").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"success": True,
|
||||||
|
"result": [{"id": "acc-1", "name": "demo-account"}],
|
||||||
|
"result_info": {"page": 1, "per_page": 50, "count": 1, "total_pages": 1},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with CloudflareClient(build_config()) as client:
|
||||||
|
result = client.resolve_account(account_name="demo-account")
|
||||||
|
|
||||||
|
request = route.calls[0].request
|
||||||
|
assert request.headers["X-Auth-Email"] == "demo@example.com"
|
||||||
|
assert request.headers["X-Auth-Key"] == "global-key"
|
||||||
|
assert result["id"] == "acc-1"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_resolve_zone_uses_account_filter() -> None:
|
||||||
|
route = respx.get("https://api.cloudflare.com/client/v4/zones").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"success": True,
|
||||||
|
"result": [{"id": "zone-1", "name": "example.com"}],
|
||||||
|
"result_info": {"page": 1, "per_page": 50, "count": 1, "total_pages": 1},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with CloudflareClient(build_config()) as client:
|
||||||
|
result = client.resolve_zone(zone_name="example.com", account_id="acc-1")
|
||||||
|
|
||||||
|
request = route.calls[0].request
|
||||||
|
assert "account.id=acc-1" in str(request.url)
|
||||||
|
assert request.headers["Authorization"] == "Bearer token-value"
|
||||||
|
assert result["id"] == "zone-1"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_email_routing_request_falls_back_to_global_key() -> None:
|
||||||
|
route = respx.get("https://api.cloudflare.com/client/v4/zones/zone-1/email/routing/rules/catch_all").mock(
|
||||||
|
side_effect=[
|
||||||
|
httpx.Response(
|
||||||
|
403,
|
||||||
|
json={
|
||||||
|
"success": False,
|
||||||
|
"errors": [{"message": "Use X-Auth-Email and X-Auth-Key for this endpoint."}],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"success": True, "result": {"tag": "catch_all"}},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with CloudflareClient(build_config()) as client:
|
||||||
|
result = client.request_email_routing("GET", "/zones/zone-1/email/routing/rules/catch_all")
|
||||||
|
|
||||||
|
assert len(route.calls) == 2
|
||||||
|
assert route.calls[0].request.headers["Authorization"] == "Bearer token-value"
|
||||||
|
assert route.calls[1].request.headers["X-Auth-Key"] == "global-key"
|
||||||
|
assert result["result"]["tag"] == "catch_all"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_request_raises_cloudflare_error_for_unsuccessful_payload() -> None:
|
||||||
|
respx.get("https://api.cloudflare.com/client/v4/user/tokens/verify").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
403,
|
||||||
|
json={"success": False, "errors": [{"message": "forbidden"}]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with CloudflareClient(build_config()) as client:
|
||||||
|
try:
|
||||||
|
client.verify_token()
|
||||||
|
except CloudflareAPIError as exc:
|
||||||
|
assert "forbidden" in str(exc)
|
||||||
|
else: # pragma: no cover
|
||||||
|
raise AssertionError("expected CloudflareAPIError")
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_ensure_pages_project_creates_when_missing() -> None:
|
||||||
|
get_route = respx.get(
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts/acc-1/pages/projects/demo-pages"
|
||||||
|
).mock(return_value=httpx.Response(404, json={"success": False, "errors": [{"message": "not found"}]}))
|
||||||
|
post_route = respx.post(
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts/acc-1/pages/projects"
|
||||||
|
).mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"success": True,
|
||||||
|
"result": {"id": "proj-1", "name": "demo-pages", "subdomain": "demo-pages.pages.dev"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with CloudflareClient(build_config()) as client:
|
||||||
|
result = client.ensure_pages_project(
|
||||||
|
account_id="acc-1",
|
||||||
|
project_name="demo-pages",
|
||||||
|
production_branch="production",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert get_route.called is True
|
||||||
|
assert post_route.called is True
|
||||||
|
assert result["subdomain"] == "demo-pages.pages.dev"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_ensure_cname_record_updates_existing_record() -> None:
|
||||||
|
list_route = respx.get("https://api.cloudflare.com/client/v4/zones/zone-1/dns_records").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"success": True,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": "dns-1",
|
||||||
|
"type": "CNAME",
|
||||||
|
"name": "email.example.com",
|
||||||
|
"content": "old.pages.dev",
|
||||||
|
"proxied": True,
|
||||||
|
"ttl": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"result_info": {"page": 1, "per_page": 50, "count": 1, "total_pages": 1},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
update_route = respx.put(
|
||||||
|
"https://api.cloudflare.com/client/v4/zones/zone-1/dns_records/dns-1"
|
||||||
|
).mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"success": True,
|
||||||
|
"result": {
|
||||||
|
"id": "dns-1",
|
||||||
|
"type": "CNAME",
|
||||||
|
"name": "email.example.com",
|
||||||
|
"content": "new.pages.dev",
|
||||||
|
"proxied": True,
|
||||||
|
"ttl": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with CloudflareClient(build_config()) as client:
|
||||||
|
result = client.ensure_cname_record(
|
||||||
|
zone_id="zone-1",
|
||||||
|
name="email.example.com",
|
||||||
|
content="new.pages.dev",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list_route.called is True
|
||||||
|
assert update_route.called is True
|
||||||
|
assert result["content"] == "new.pages.dev"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_ensure_catch_all_worker_skips_when_already_targeted() -> None:
|
||||||
|
get_route = respx.get(
|
||||||
|
"https://api.cloudflare.com/client/v4/zones/zone-1/email/routing/rules/catch_all"
|
||||||
|
).mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"success": True,
|
||||||
|
"result": {
|
||||||
|
"id": "rule-1",
|
||||||
|
"enabled": True,
|
||||||
|
"actions": [{"type": "worker", "value": ["email-api"]}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
put_route = respx.put(
|
||||||
|
"https://api.cloudflare.com/client/v4/zones/zone-1/email/routing/rules/catch_all"
|
||||||
|
).mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"success": True, "result": {"id": "rule-1", "enabled": True}},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with CloudflareClient(build_config()) as client:
|
||||||
|
result = client.ensure_catch_all_worker(zone_id="zone-1", script_name="email-api")
|
||||||
|
|
||||||
|
assert get_route.called is True
|
||||||
|
assert put_route.called is False
|
||||||
|
assert result["id"] == "rule-1"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_wait_for_pages_domain_active_polls_until_ready() -> None:
|
||||||
|
route = respx.get(
|
||||||
|
"https://api.cloudflare.com/client/v4/accounts/acc-1/pages/projects/demo-pages/domains/email.example.com"
|
||||||
|
).mock(
|
||||||
|
side_effect=[
|
||||||
|
httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"success": True, "result": {"name": "email.example.com", "status": "pending"}},
|
||||||
|
),
|
||||||
|
httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"success": True, "result": {"name": "email.example.com", "status": "active"}},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with CloudflareClient(build_config()) as client:
|
||||||
|
result = client.wait_for_pages_domain_active(
|
||||||
|
account_id="acc-1",
|
||||||
|
project_name="demo-pages",
|
||||||
|
domain_name="email.example.com",
|
||||||
|
timeout_seconds=1.0,
|
||||||
|
poll_interval_seconds=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(route.calls) == 2
|
||||||
|
assert result["status"] == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def test_catch_all_points_to_worker_accepts_nested_value_shapes() -> None:
|
||||||
|
rule = {
|
||||||
|
"enabled": True,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "worker",
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"service": {
|
||||||
|
"name": "email-api",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
assert CloudflareClient.catch_all_points_to_worker(rule, "email-api") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_authentication_error_accepts_global_key_config_errors() -> None:
|
||||||
|
error = ConfigError("旧式鉴权需要同时提供 api_email 与 global_api_key。")
|
||||||
|
|
||||||
|
assert CloudflareClient.is_authentication_error(error) is True
|
||||||
160
tests/test_config.py
Normal file
160
tests/test_config.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.config import (
|
||||||
|
apply_overrides,
|
||||||
|
default_config_document,
|
||||||
|
load_config,
|
||||||
|
parse_toml_value,
|
||||||
|
save_toml_document,
|
||||||
|
save_state,
|
||||||
|
set_dotted_value,
|
||||||
|
)
|
||||||
|
from cf_temp_email_deploy.errors import ConfigError
|
||||||
|
from cf_temp_email_deploy.models import DeploymentState
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_toml_value_supports_structured_literals() -> None:
|
||||||
|
assert parse_toml_value('"abc"') == "abc"
|
||||||
|
assert parse_toml_value("true") is True
|
||||||
|
assert parse_toml_value("[1, 2, 3]") == [1, 2, 3]
|
||||||
|
assert parse_toml_value("plain-text") == "plain-text"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_overrides_and_load_config(tmp_path: Path) -> None:
|
||||||
|
document = default_config_document()
|
||||||
|
apply_overrides(
|
||||||
|
document,
|
||||||
|
{
|
||||||
|
"cloudflare.zone_name": "example.org",
|
||||||
|
"mail.domains": ["mail.example.org"],
|
||||||
|
"worker.vars.PREFIX": "custom",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
save_toml_document(config_path, document)
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
assert config.cloudflare.zone_name == "example.org"
|
||||||
|
assert config.mail.domains == ["mail.example.org"]
|
||||||
|
assert config.worker.vars["PREFIX"] == "custom"
|
||||||
|
assert config.derived_worker_vars()["DOMAINS"] == ["mail.example.org"]
|
||||||
|
assert config.derived_worker_vars()["DISABLE_ANONYMOUS_USER_CREATE_EMAIL"] is True
|
||||||
|
assert config.source.repo_ref == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_config_marks_jwt_secret_optional() -> None:
|
||||||
|
document = default_config_document()
|
||||||
|
|
||||||
|
assert document["worker"]["secrets"]["JWT_SECRET"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_config_includes_user_access_and_linuxdo_sections() -> None:
|
||||||
|
document = default_config_document()
|
||||||
|
|
||||||
|
assert document["user_access"]["require_login_to_create"] is True
|
||||||
|
assert document["user_access"]["allow_user_register"] is False
|
||||||
|
assert document["linuxdo"]["linuxdo_oauth"] is False
|
||||||
|
assert document["linuxdo"]["client_id"] == ""
|
||||||
|
assert document["linuxdo"]["client_secret"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_requires_linuxdo_credentials_when_oauth_enabled(tmp_path: Path) -> None:
|
||||||
|
document = default_config_document()
|
||||||
|
document["linuxdo"]["linuxdo_oauth"] = True
|
||||||
|
document["linuxdo"]["client_id"] = ""
|
||||||
|
document["linuxdo"]["client_secret"] = ""
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
save_toml_document(config_path, document)
|
||||||
|
|
||||||
|
with pytest.raises(ConfigError, match="linuxdo.client_id"):
|
||||||
|
load_config(config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_requires_admin_passwords(tmp_path: Path) -> None:
|
||||||
|
document = default_config_document()
|
||||||
|
document["worker"]["vars"]["ADMIN_PASSWORDS"] = []
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
save_toml_document(config_path, document)
|
||||||
|
|
||||||
|
with pytest.raises(ConfigError, match="ADMIN_PASSWORDS"):
|
||||||
|
load_config(config_path)
|
||||||
|
|
||||||
|
def test_load_config_requires_worker_domain_or_workers_dev(tmp_path: Path) -> None:
|
||||||
|
document = default_config_document()
|
||||||
|
document["worker"]["use_workers_dev"] = False
|
||||||
|
document["worker"]["custom_domain"] = ""
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
save_toml_document(config_path, document)
|
||||||
|
|
||||||
|
with pytest.raises(ConfigError, match="worker.custom_domain"):
|
||||||
|
load_config(config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_merges_selected_profile(tmp_path: Path) -> None:
|
||||||
|
document = default_config_document()
|
||||||
|
document["cloudflare"]["zone_name"] = "base.example.com"
|
||||||
|
document["mail"]["domains"] = ["base.example.com"]
|
||||||
|
document["pages"]["custom_domain"] = "app.base.example.com"
|
||||||
|
document["profiles"] = {
|
||||||
|
"account_b": {
|
||||||
|
"cloudflare": {
|
||||||
|
"account_id": "acc-b",
|
||||||
|
"zone_name": "kotei.asia",
|
||||||
|
"api_token": "token-b",
|
||||||
|
},
|
||||||
|
"mail": {"domains": ["mail.kotei.asia", "maila.kotei.asia"]},
|
||||||
|
"pages": {"custom_domain": "email.kotei.asia"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
save_toml_document(config_path, document)
|
||||||
|
|
||||||
|
config = load_config(config_path, profile="account_b")
|
||||||
|
|
||||||
|
assert config.cloudflare.account_id == "acc-b"
|
||||||
|
assert config.cloudflare.zone_name == "kotei.asia"
|
||||||
|
assert config.mail.domains == ["mail.kotei.asia", "maila.kotei.asia"]
|
||||||
|
assert config.pages.custom_domain == "email.kotei.asia"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_rejects_missing_profile(tmp_path: Path) -> None:
|
||||||
|
config_path = tmp_path / "config.toml"
|
||||||
|
save_toml_document(config_path, default_config_document())
|
||||||
|
|
||||||
|
with pytest.raises(ConfigError, match="未找到 profile"):
|
||||||
|
load_config(config_path, profile="missing")
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_dotted_value_rejects_scalar_parent() -> None:
|
||||||
|
document = default_config_document()
|
||||||
|
set_dotted_value(document, "cloudflare.account_id", "abc")
|
||||||
|
|
||||||
|
try:
|
||||||
|
set_dotted_value(document, "cloudflare.account_id.value", "x")
|
||||||
|
except Exception as exc: # pragma: no cover - narrow assertion below
|
||||||
|
assert "父节点不是表" in str(exc)
|
||||||
|
else: # pragma: no cover
|
||||||
|
raise AssertionError("expected parent table validation failure")
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_state_roundtrip(tmp_path: Path) -> None:
|
||||||
|
state_path = tmp_path / ".deploy" / "state.toml"
|
||||||
|
state = DeploymentState()
|
||||||
|
state.mark_checkpoint("environment_checked")
|
||||||
|
state.worker.script_name = "worker-a"
|
||||||
|
state.worker.workers_dev_url = "https://worker-a.example.workers.dev"
|
||||||
|
|
||||||
|
save_state(state_path, state)
|
||||||
|
loaded_text = state_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "environment_checked" in loaded_text
|
||||||
|
assert "worker-a" in loaded_text
|
||||||
1143
tests/test_deployment.py
Normal file
1143
tests/test_deployment.py
Normal file
File diff suppressed because it is too large
Load Diff
16
tests/test_environment.py
Normal file
16
tests/test_environment.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.environment import parse_semver
|
||||||
|
from cf_temp_email_deploy.errors import EnvironmentCheckError
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_semver_accepts_prefixed_versions() -> None:
|
||||||
|
assert parse_semver("v24.14.0") == (24, 14, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_semver_rejects_invalid_versions() -> None:
|
||||||
|
with pytest.raises(EnvironmentCheckError):
|
||||||
|
parse_semver("unknown")
|
||||||
|
|
||||||
102
tests/test_source.py
Normal file
102
tests/test_source.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.models import DeploymentConfig, DeploymentState
|
||||||
|
from cf_temp_email_deploy.source import prepare_source
|
||||||
|
from cf_temp_email_deploy.subprocess_runner import CommandRunner
|
||||||
|
|
||||||
|
|
||||||
|
def run_git(args: tuple[str, ...], cwd: Path) -> str:
|
||||||
|
completed = subprocess.run(args, cwd=cwd, check=True, capture_output=True, text=True)
|
||||||
|
return completed.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def create_git_repository(path: Path) -> str:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
run_git(("git", "init", "-b", "main"), path)
|
||||||
|
run_git(("git", "config", "user.name", "Codex"), path)
|
||||||
|
run_git(("git", "config", "user.email", "codex@example.com"), path)
|
||||||
|
(path / "README.md").write_text("demo\n", encoding="utf-8")
|
||||||
|
run_git(("git", "add", "README.md"), path)
|
||||||
|
run_git(("git", "commit", "-m", "initial"), path)
|
||||||
|
return run_git(("git", "rev-parse", "HEAD"), path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_local_source_records_commit(tmp_path: Path) -> None:
|
||||||
|
repo_dir = tmp_path / "local-repo"
|
||||||
|
expected_sha = create_git_repository(repo_dir)
|
||||||
|
|
||||||
|
config = DeploymentConfig.model_validate(
|
||||||
|
{
|
||||||
|
"source": {
|
||||||
|
"mode": "local",
|
||||||
|
"local_path": str(repo_dir),
|
||||||
|
},
|
||||||
|
"cloudflare": {"zone_name": "example.com"},
|
||||||
|
"mail": {"domains": ["mail.example.com"], "verified_destination_address": "target@example.com"},
|
||||||
|
"pages": {"custom_domain": "mail.example.com"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared = prepare_source(config, CommandRunner())
|
||||||
|
|
||||||
|
assert prepared.source_dir == repo_dir.resolve()
|
||||||
|
assert prepared.commit_sha == expected_sha
|
||||||
|
|
||||||
|
state = DeploymentState()
|
||||||
|
prepared.apply_to_state(state)
|
||||||
|
assert state.source.source_dir == str(repo_dir.resolve())
|
||||||
|
assert state.source.commit_sha == expected_sha
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_clone_source_clones_and_checks_out_ref(tmp_path: Path) -> None:
|
||||||
|
origin_dir = tmp_path / "origin-repo"
|
||||||
|
expected_sha = create_git_repository(origin_dir)
|
||||||
|
workspace_dir = tmp_path / "workspace"
|
||||||
|
|
||||||
|
config = DeploymentConfig.model_validate(
|
||||||
|
{
|
||||||
|
"source": {
|
||||||
|
"mode": "clone",
|
||||||
|
"repo_url": str(origin_dir),
|
||||||
|
"repo_ref": "main",
|
||||||
|
"workspace_dir": str(workspace_dir),
|
||||||
|
},
|
||||||
|
"cloudflare": {"zone_name": "example.com"},
|
||||||
|
"mail": {"domains": ["mail.example.com"], "verified_destination_address": "target@example.com"},
|
||||||
|
"pages": {"custom_domain": "mail.example.com"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared = prepare_source(config, CommandRunner())
|
||||||
|
|
||||||
|
assert prepared.source_dir == (workspace_dir / "source").resolve()
|
||||||
|
assert prepared.commit_sha == expected_sha
|
||||||
|
assert (prepared.source_dir / "README.md").read_text(encoding="utf-8") == "demo\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_clone_source_uses_latest_default_branch_when_repo_ref_empty(tmp_path: Path) -> None:
|
||||||
|
origin_dir = tmp_path / "origin-repo"
|
||||||
|
expected_sha = create_git_repository(origin_dir)
|
||||||
|
workspace_dir = tmp_path / "workspace"
|
||||||
|
|
||||||
|
config = DeploymentConfig.model_validate(
|
||||||
|
{
|
||||||
|
"source": {
|
||||||
|
"mode": "clone",
|
||||||
|
"repo_url": str(origin_dir),
|
||||||
|
"repo_ref": "",
|
||||||
|
"workspace_dir": str(workspace_dir),
|
||||||
|
},
|
||||||
|
"cloudflare": {"zone_name": "example.com"},
|
||||||
|
"mail": {"domains": ["example.com"], "verified_destination_address": "target@example.com"},
|
||||||
|
"pages": {"custom_domain": "email.example.com"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared = prepare_source(config, CommandRunner())
|
||||||
|
|
||||||
|
assert prepared.source_dir == (workspace_dir / "source").resolve()
|
||||||
|
assert prepared.commit_sha == expected_sha
|
||||||
19
tests/test_subprocess_runner.py
Normal file
19
tests/test_subprocess_runner.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cf_temp_email_deploy.errors import CommandExecutionError
|
||||||
|
from cf_temp_email_deploy.subprocess_runner import CommandRunner, CommandSpec
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_runner_captures_stdout() -> None:
|
||||||
|
runner = CommandRunner()
|
||||||
|
result = runner.run_checked(CommandSpec(args=("python3", "-c", "print('ok')")))
|
||||||
|
assert result.stdout.strip() == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_runner_raises_for_non_zero_exit() -> None:
|
||||||
|
runner = CommandRunner()
|
||||||
|
with pytest.raises(CommandExecutionError):
|
||||||
|
runner.run_checked(CommandSpec(args=("python3", "-c", "raise SystemExit(3)")))
|
||||||
|
|
||||||
495
uv.lock
generated
Normal file
495
uv.lock
generated
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.2.25"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cf-temp-email"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "tomlkit" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "respx" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
|
||||||
|
{ name = "pydantic", specifier = ">=2.11.2,<3.0.0" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.5,<9.0.0" },
|
||||||
|
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.1.1,<7.0.0" },
|
||||||
|
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.22.0,<1.0.0" },
|
||||||
|
{ name = "tomlkit", specifier = ">=0.13.2,<1.0.0" },
|
||||||
|
]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.13.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
toml = [
|
||||||
|
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.12.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.41.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "8.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "6.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage", extra = ["toml"] },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "respx"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomli"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomlkit"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user