From 4100e9cf7205e1625a0eec64714d4c9073b4239e Mon Sep 17 00:00:00 2001 From: mmc <853506518@qq.com> Date: Thu, 26 Mar 2026 08:06:02 +0800 Subject: [PATCH] Initial import of cf-temp-email deploy CLI --- .gitignore | 6 + README.md | 363 ++++++ config.样例.toml | 154 +++ pyproject.toml | 32 + src/cf_temp_email_deploy/__init__.py | 6 + src/cf_temp_email_deploy/__main__.py | 6 + src/cf_temp_email_deploy/app_admin.py | 221 ++++ src/cf_temp_email_deploy/cli.py | 264 ++++ src/cf_temp_email_deploy/cloudflare.py | 727 +++++++++++ src/cf_temp_email_deploy/config.py | 278 ++++ src/cf_temp_email_deploy/deployment.py | 1089 ++++++++++++++++ src/cf_temp_email_deploy/discovery.py | 205 +++ src/cf_temp_email_deploy/environment.py | 53 + src/cf_temp_email_deploy/errors.py | 54 + src/cf_temp_email_deploy/logging_utils.py | 62 + src/cf_temp_email_deploy/models.py | 258 ++++ src/cf_temp_email_deploy/project_layout.py | 79 ++ src/cf_temp_email_deploy/source.py | 117 ++ src/cf_temp_email_deploy/subprocess_runner.py | 87 ++ src/cf_temp_email_deploy/wrangler.py | 36 + tests/test_app_admin.py | 101 ++ tests/test_cli.py | 275 ++++ tests/test_cloudflare.py | 295 +++++ tests/test_config.py | 160 +++ tests/test_deployment.py | 1143 +++++++++++++++++ tests/test_environment.py | 16 + tests/test_source.py | 102 ++ tests/test_subprocess_runner.py | 19 + uv.lock | 495 +++++++ 29 files changed, 6703 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.样例.toml create mode 100644 pyproject.toml create mode 100644 src/cf_temp_email_deploy/__init__.py create mode 100644 src/cf_temp_email_deploy/__main__.py create mode 100644 src/cf_temp_email_deploy/app_admin.py create mode 100644 src/cf_temp_email_deploy/cli.py create mode 100644 src/cf_temp_email_deploy/cloudflare.py create mode 100644 src/cf_temp_email_deploy/config.py create mode 100644 src/cf_temp_email_deploy/deployment.py create mode 100644 src/cf_temp_email_deploy/discovery.py create mode 100644 src/cf_temp_email_deploy/environment.py create mode 100644 src/cf_temp_email_deploy/errors.py create mode 100644 src/cf_temp_email_deploy/logging_utils.py create mode 100644 src/cf_temp_email_deploy/models.py create mode 100644 src/cf_temp_email_deploy/project_layout.py create mode 100644 src/cf_temp_email_deploy/source.py create mode 100644 src/cf_temp_email_deploy/subprocess_runner.py create mode 100644 src/cf_temp_email_deploy/wrangler.py create mode 100644 tests/test_app_admin.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_cloudflare.py create mode 100644 tests/test_config.py create mode 100644 tests/test_deployment.py create mode 100644 tests/test_environment.py create mode 100644 tests/test_source.py create mode 100644 tests/test_subprocess_runner.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96839ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +.pytest_cache/ +__pycache__/ +*.pyc +config.toml +config.old.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3bd54c --- /dev/null +++ b/README.md @@ -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.` 区分。 + +示例: + +```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..*` +- 每个 profile 使用独立状态文件: + `.deploy/profiles//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:///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//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` diff --git a/config.样例.toml b/config.样例.toml new file mode 100644 index 0000000..2626b39 --- /dev/null +++ b/config.样例.toml @@ -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..*] 只写需要覆盖的字段即可。 +# 3. 每个 profile 会使用独立的状态文件: +# .deploy/profiles//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" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..195f9f5 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/cf_temp_email_deploy/__init__.py b/src/cf_temp_email_deploy/__init__.py new file mode 100644 index 0000000..ef9f3a4 --- /dev/null +++ b/src/cf_temp_email_deploy/__init__.py @@ -0,0 +1,6 @@ +"""Cloudflare Temp Email automated deployment package.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" + diff --git a/src/cf_temp_email_deploy/__main__.py b/src/cf_temp_email_deploy/__main__.py new file mode 100644 index 0000000..2fb4426 --- /dev/null +++ b/src/cf_temp_email_deploy/__main__.py @@ -0,0 +1,6 @@ +from cf_temp_email_deploy.cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/src/cf_temp_email_deploy/app_admin.py b/src/cf_temp_email_deploy/app_admin.py new file mode 100644 index 0000000..b610c0f --- /dev/null +++ b/src/cf_temp_email_deploy/app_admin.py @@ -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 = ( + '' + '' + '' + '' +) +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 diff --git a/src/cf_temp_email_deploy/cli.py b/src/cf_temp_email_deploy/cli.py new file mode 100644 index 0000000..37f39ba --- /dev/null +++ b/src/cf_temp_email_deploy/cli.py @@ -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 "") + 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 diff --git a/src/cf_temp_email_deploy/cloudflare.py b/src/cf_temp_email_deploy/cloudflare.py new file mode 100644 index 0000000..b6396c5 --- /dev/null +++ b/src/cf_temp_email_deploy/cloudflare.py @@ -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") + ) diff --git a/src/cf_temp_email_deploy/config.py b/src/cf_temp_email_deploy/config.py new file mode 100644 index 0000000..206770e --- /dev/null +++ b/src/cf_temp_email_deploy/config.py @@ -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 ` 选择对应配置,未覆盖的字段会继承根配置。 +# +# [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"))) diff --git a/src/cf_temp_email_deploy/deployment.py b/src/cf_temp_email_deploy/deployment.py new file mode 100644 index 0000000..1645975 --- /dev/null +++ b/src/cf_temp_email_deploy/deployment.py @@ -0,0 +1,1089 @@ +"""Deployment orchestration.""" + +from __future__ import annotations + +import hashlib +import os +import secrets +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +import httpx +import tomlkit + +from cf_temp_email_deploy.app_admin import ( + ApplicationAdminClient, + is_linuxdo_oauth2_setting, + linuxdo_oauth_callback_url, +) +from cf_temp_email_deploy.cloudflare import CloudflareClient +from cf_temp_email_deploy.config import load_toml_document, save_state, save_toml_document, set_dotted_value +from cf_temp_email_deploy.environment import check_required_tools +from cf_temp_email_deploy.errors import ( + AcceptanceCheckError, + ApplicationAPIError, + CloudflareAPIError, + ConfigError, + DeployError, +) +from cf_temp_email_deploy.logging_utils import get_logger, log_stage +from cf_temp_email_deploy.models import D1MigrationState, DeploymentConfig, DeploymentState +from cf_temp_email_deploy.project_layout import ProjectLayout, detect_project_layout +from cf_temp_email_deploy.source import PreparedSource, prepare_source, resolve_commit_sha +from cf_temp_email_deploy.subprocess_runner import CommandRunner, CommandSpec +from cf_temp_email_deploy.wrangler import ( + build_wrangler_command, + build_wrangler_d1_execute_command, + build_wrangler_pages_deploy_command, + build_wrangler_secret_put_command, +) + +LOGGER = get_logger("deploy") +DEFAULT_PAGES_COMPATIBILITY_DATE = "2024-05-13" +HISTORY_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS __deploy_history ( + file_name TEXT PRIMARY KEY, + sha256 TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +) +""".strip() +CHECKPOINT_ORDER = { + "": 0, + "check_completed": 1, + "source_completed": 2, + "pages_project_completed": 3, + "dns_completed": 4, + "d1_completed": 5, + "worker_completed": 6, + "application_completed": 7, + "email_routing_completed": 8, + "pages_completed": 9, + "acceptance_completed": 10, +} +WORKER_PROPAGATION_WAIT_SECONDS = 30 +CATCH_ALL_MAX_ATTEMPTS = 3 +CATCH_ALL_RETRY_DELAY_SECONDS = 10 +EMAIL_ROUTING_FALLBACK_MX_RECORDS = ( + ("amir.mx.cloudflare.net", 13), + ("linda.mx.cloudflare.net", 86), + ("isaac.mx.cloudflare.net", 24), +) +EMAIL_ROUTING_FALLBACK_SPF = "v=spf1 include:_spf.mx.cloudflare.net ~all" +JWT_SECRET_TOKEN_BYTES = 48 + + +@dataclass +class DeploymentContext: + config_path: Path + state_path: Path + config: DeploymentConfig + state: DeploymentState + runner: CommandRunner + is_resume: bool = False + prepared_source: PreparedSource | None = None + layout: ProjectLayout | None = None + + +class DeploymentManager: + """Orchestrate the Cloudflare Temp Email deployment workflow.""" + + def __init__( + self, + context: DeploymentContext, + cloudflare: CloudflareClient, + ) -> None: + self.context = context + self.cloudflare = cloudflare + + @property + def config(self) -> DeploymentConfig: + return self.context.config + + @property + def runner(self) -> CommandRunner: + return self.context.runner + + @property + def state(self) -> DeploymentState: + return self.context.state + + def run(self) -> None: + self._run_stage("check", "check_completed", "执行环境与 Cloudflare 预检查。", self._perform_preflight) + self._prepare_source_stage() + self._prepare_pages_project_stage() + self._configure_cname_stage() + self._prepare_d1_stage() + self._deploy_worker_stage() + self._configure_application_stage() + self._configure_email_routing_stage() + self._deploy_pages_stage() + self._run_stage("acceptance", "acceptance_completed", "执行部署验收。", self._run_acceptance) + + def _run_stage( + self, + stage_name: str, + checkpoint: str, + description: str, + action: Callable[[], None], + ) -> None: + log_stage(description) + self.state.record_event(stage_name, "started", description) + self._save_state() + try: + action() + except Exception as exc: + self.state.record_event(stage_name, "failed", str(exc)) + self._save_state() + raise + self._advance_checkpoint(checkpoint) + self.state.record_event(stage_name, "completed", description) + self._save_state() + + def _perform_preflight(self) -> None: + versions = check_required_tools(self.runner) + for tool_name, version in versions.items(): + LOGGER.info("[tool] %s=%s", tool_name, version.version) + + if self.config.source.mode == "local": + source_path = Path(self.config.source.local_path).expanduser() + if not source_path.exists(): + raise ConfigError(f"本地源码目录不存在: {source_path}") + + if not self.config.cloudflare.resolved_api_token(): + raise ConfigError("cloudflare.api_token 或 cloudflare.api_token_env 需要提供其一。") + + token_info = self.cloudflare.verify_token() + account: dict[str, str] = {} + if self.config.cloudflare.account_id or self.config.cloudflare.account_name: + account = self.cloudflare.resolve_account( + account_id=self.config.cloudflare.account_id, + account_name=self.config.cloudflare.account_name, + ) + zone = self.cloudflare.resolve_zone( + zone_name=self.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", "")), + } + + self.state.cloudflare.account_id = account.get("id", "") + self.state.cloudflare.account_name = account.get("name", "") + self.state.cloudflare.zone_id = str(zone.get("id", "")) + self.state.cloudflare.zone_name = str(zone.get("name", self.config.cloudflare.zone_name)) + self._ensure_user_access_consistency() + LOGGER.info("[cloudflare] token_status=%s", token_info.get("status", "unknown")) + LOGGER.info("[cloudflare] account_id=%s", self.state.cloudflare.account_id or "") + LOGGER.info("[cloudflare] zone_id=%s", self.state.cloudflare.zone_id) + + def _prepare_source_stage(self) -> None: + self._run_stage("source", "source_completed", "准备源码目录与项目结构。", self._prepare_source) + + def _prepare_source(self) -> None: + prepared = prepare_source(self.config, self.runner) + prepared.apply_to_state(self.state) + self.context.prepared_source = prepared + self.context.layout = detect_project_layout(prepared.source_dir) + + def _prepare_pages_project_stage(self) -> None: + if ( + self._has_checkpoint("pages_project_completed") + and self.state.pages.project_name == self.config.pages.project_name + and self.state.pages.subdomain + ): + project = self.cloudflare.get_pages_project( + account_id=self._require_account_id(), + project_name=self.config.pages.project_name, + ) + if project is not None: + LOGGER.info("[stage] 跳过已完成阶段: Pages 项目预建。") + return + LOGGER.warning("[pages-project] 状态文件中的 Pages 项目已不存在,重新执行项目预建与域名配置。") + self.state.pages.project_id = "" + self.state.pages.subdomain = "" + self.state.pages.cname_record_id = "" + self.state.pages.custom_domain_status = "" + self._run_stage( + "pages_project", + "pages_project_completed", + "创建或复用 Pages 项目。", + self._prepare_pages_project, + ) + + def _prepare_pages_project(self) -> None: + project = self.cloudflare.ensure_pages_project( + account_id=self._require_account_id(), + project_name=self.config.pages.project_name, + production_branch=self.config.pages.production_branch, + ) + self.state.pages.project_id = str(project.get("id", "")) + self.state.pages.project_name = str(project.get("name", self.config.pages.project_name)) + self.state.pages.subdomain = str(project.get("subdomain", "")) or f"{self.config.pages.project_name}.pages.dev" + + def _configure_cname_stage(self) -> None: + if ( + self._has_checkpoint("dns_completed") + and self.state.pages.custom_domain == self.config.pages.custom_domain + and self.state.pages.cname_record_id + ): + LOGGER.info("[stage] 跳过已完成阶段: Pages CNAME 配置。") + return + self._run_stage("dns", "dns_completed", "配置 Pages 自定义域名的 CNAME。", self._configure_cname) + + def _configure_cname(self) -> None: + cname_record = self.cloudflare.ensure_cname_record( + zone_id=self._require_zone_id(), + name=self.config.pages.custom_domain, + content=self._require_pages_subdomain(), + ) + self.state.pages.custom_domain = self.config.pages.custom_domain + self.state.pages.cname_record_id = str(cname_record.get("id", "")) + + def _prepare_d1_stage(self) -> None: + if ( + self._has_checkpoint("d1_completed") + and self.state.d1.database_name == self.config.d1.database_name + and self.state.d1.database_id + ): + databases = self.cloudflare.list_d1_databases( + account_id=self._require_account_id(), + database_name=self.config.d1.database_name, + ) + if len(databases) == 1: + database_id = str(databases[0].get("uuid") or databases[0].get("id") or "") + if database_id == self.state.d1.database_id: + LOGGER.info("[stage] 跳过已完成阶段: D1 数据库准备。") + return + LOGGER.warning("[d1] 状态文件中的 D1 数据库已不存在或标识不一致,重新执行数据库准备。") + self._run_stage("d1", "d1_completed", "创建或复用 D1 并执行数据库迁移。", self._prepare_d1) + + def _prepare_d1(self) -> None: + layout = self._require_layout() + self._ensure_worker_wranger_runtime() + database = self.cloudflare.ensure_d1_database( + account_id=self._require_account_id(), + database_name=self.config.d1.database_name, + jurisdiction=self.config.d1.jurisdiction, + ) + self.state.d1.database_id = str(database.get("uuid") or database.get("id") or "") + self.state.d1.database_name = str(database.get("name", self.config.d1.database_name)) + self._write_worker_wrangler(layout) + self._ensure_d1_history_table() + + history = self._load_d1_history() + existing_tables = self._list_application_tables() + schema_name = layout.schema_path.name + deployment_files = [layout.schema_path, *layout.migration_paths] + fresh_database = not history and not existing_tables + + if not history and existing_tables: + if not self.config.d1.adopt_existing_schema: + raise DeployError( + "目标 D1 数据库已存在业务表,但缺少 __deploy_history。" + "如需接管,请显式开启 d1.adopt_existing_schema。" + ) + for sql_file in deployment_files: + self._record_d1_history(sql_file.name, _sha256_path(sql_file)) + history = {sql_file.name: _sha256_path(sql_file) for sql_file in deployment_files} + else: + if schema_name not in history: + self._execute_remote_sql_file(layout.schema_path) + self._record_d1_history(schema_name, _sha256_path(layout.schema_path)) + history[schema_name] = _sha256_path(layout.schema_path) + + if fresh_database: + for migration_path in layout.migration_paths: + migration_sha = _sha256_path(migration_path) + self._record_d1_history(migration_path.name, migration_sha) + history[migration_path.name] = migration_sha + else: + for migration_path in layout.migration_paths: + migration_sha = _sha256_path(migration_path) + if history.get(migration_path.name) == migration_sha: + continue + self._execute_remote_sql_file(migration_path) + self._record_d1_history(migration_path.name, migration_sha) + history[migration_path.name] = migration_sha + + ordered_migrations = [ + D1MigrationState(file_name=path.name, sha256=history[path.name]) + for path in layout.migration_paths + if path.name in history + ] + self.state.d1.migrations = ordered_migrations + if ordered_migrations: + self.state.d1.schema_version = ordered_migrations[-1].file_name + elif schema_name in history: + self.state.d1.schema_version = schema_name + + def _deploy_worker_stage(self) -> None: + if ( + self._has_checkpoint("worker_completed") + and self.state.worker.script_name == self.config.worker.script_name + ): + worker = self.cloudflare.get_worker_script( + account_id=self._require_account_id(), + script_name=self.config.worker.script_name, + ) + if worker is not None and self.cloudflare.worker_script_supports_email(worker): + LOGGER.info("[stage] 跳过已完成阶段: Worker 发布。") + return + LOGGER.warning("[worker] 状态文件中的 Worker 脚本缺失或缺少 email handler,重新执行 Worker 发布。") + self._run_stage("worker", "worker_completed", "安装依赖并发布 Worker。", self._deploy_worker) + + def _deploy_worker(self) -> None: + layout = self._require_layout() + self._ensure_runtime_config_secrets() + self._ensure_worker_wranger_runtime() + self._apply_telegraf_patch(layout) + self._write_worker_wrangler(layout) + self._remove_generated_wrangler_redirects(layout.worker_dir, layout.root_dir) + + for secret_name, secret_value in self.config.worker.secrets.items(): + if secret_value == "": + continue + self.runner.run_checked( + CommandSpec( + args=build_wrangler_secret_put_command(secret_name), + cwd=layout.worker_dir, + env=self._wrangler_env(), + input_text=f"{secret_value}\n", + ) + ) + + self.runner.run_checked( + CommandSpec( + args=build_wrangler_command("deploy", "--minify"), + cwd=layout.worker_dir, + env=self._wrangler_env(), + ) + ) + + workers_dev_url = "" + if self.config.worker.use_workers_dev: + subdomain = self.cloudflare.get_workers_subdomain(account_id=self._require_account_id()) or {} + account_subdomain = str(subdomain.get("subdomain", "")) + if account_subdomain: + workers_dev_url = f"https://{self.config.worker.script_name}.{account_subdomain}.workers.dev" + self.state.worker.script_name = self.config.worker.script_name + self.state.worker.workers_dev_url = workers_dev_url + + def _ensure_runtime_config_secrets(self) -> None: + self._ensure_user_access_consistency() + self._ensure_worker_jwt_secret() + + def _ensure_user_access_consistency(self) -> None: + effective_value = self.config.effective_require_login_to_create() + if self.config.user_access.require_login_to_create == effective_value: + return + + self.config.user_access.require_login_to_create = effective_value + if self.context.config_path.exists(): + document = load_toml_document(self.context.config_path) + set_dotted_value(document, "user_access.require_login_to_create", effective_value) + save_toml_document(self.context.config_path, document) + LOGGER.info("[user-access] 已根据 Linux.do OAuth2 配置自动修正 require_login_to_create。") + return + + LOGGER.warning("[user-access] 已在当前部署中修正 require_login_to_create,但配置文件不存在,无法写回。") + + def _ensure_worker_jwt_secret(self) -> None: + current_value = self.config.worker.secrets.get("JWT_SECRET", "") + if current_value.strip(): + return + + generated_value = secrets.token_urlsafe(JWT_SECRET_TOKEN_BYTES) + self.config.worker.secrets["JWT_SECRET"] = generated_value + + if self.context.config_path.exists(): + document = load_toml_document(self.context.config_path) + set_dotted_value(document, "worker.secrets.JWT_SECRET", generated_value) + save_toml_document(self.context.config_path, document) + LOGGER.info("[worker] JWT_SECRET 为空,已自动生成并写回配置文件。") + return + + LOGGER.warning("[worker] JWT_SECRET 为空,已为当前部署自动生成随机值,但配置文件不存在,无法写回。") + + def _configure_application_stage(self) -> None: + expected_redirect_url = self.config.linuxdo_callback_url() if self.config.linuxdo.linuxdo_oauth else "" + if ( + self._has_checkpoint("application_completed") + and self.state.application.configured + and self.state.application.allow_user_register == self.config.user_access.allow_user_register + and self.state.application.require_login_to_create == self.config.effective_require_login_to_create() + and self.state.application.linuxdo_oauth_enabled == self.config.linuxdo.linuxdo_oauth + and self.state.application.linuxdo_redirect_url == expected_redirect_url + ): + LOGGER.info("[stage] 跳过已完成阶段: 应用访问控制配置。") + return + self._run_stage( + "application", + "application_completed", + "同步用户访问控制与 Linux.do OAuth2 配置。", + self._configure_application, + ) + + def _configure_application(self) -> None: + admin_base_url = self._require_application_base_url() + admin_password = self._require_admin_password() + + with ApplicationAdminClient(admin_base_url, admin_password) as admin_client: + admin_client.wait_until_ready() + admin_client.sync_user_settings( + allow_user_register=self.config.user_access.allow_user_register + ) + if self.config.linuxdo.linuxdo_oauth: + admin_client.sync_linuxdo_oauth2( + pages_domain=self.config.pages.custom_domain, + client_id=self.config.linuxdo.client_id, + client_secret=self.config.linuxdo.client_secret, + ) + + self.state.application.configured = True + self.state.application.admin_base_url = admin_base_url + self.state.application.allow_user_register = self.config.user_access.allow_user_register + self.state.application.require_login_to_create = self.config.effective_require_login_to_create() + self.state.application.linuxdo_oauth_enabled = self.config.linuxdo.linuxdo_oauth + self.state.application.linuxdo_redirect_url = ( + self.config.linuxdo_callback_url() if self.config.linuxdo.linuxdo_oauth else "" + ) + + def _configure_email_routing_stage(self) -> None: + if ( + self._has_checkpoint("email_routing_completed") + and self.state.email_routing.destination_address == self.config.mail.verified_destination_address + and self.state.email_routing.catch_all_worker == self.config.worker.script_name + and self.state.email_routing.catch_all_enabled + ): + LOGGER.info("[stage] 跳过已完成阶段: Email Routing 与 Catch-all。") + return + self._run_stage( + "email_routing", + "email_routing_completed", + "校验 Email Routing、写入 DNS 并更新 Catch-all。", + self._configure_email_routing, + ) + + def _configure_email_routing(self) -> None: + target_address = self.config.mail.verified_destination_address.strip() + if target_address: + self._validate_email_routing_destination(target_address) + else: + LOGGER.warning( + "[email-routing] verified_destination_address 未配置,跳过目标地址校验,仅继续同步 DNS 与 Catch-all。" + ) + dns_record_ids = self._ensure_email_routing_dns_records() + + self.state.email_routing.destination_address = target_address + self.state.email_routing.dns_record_ids = sorted(set(dns_record_ids)) + catch_all = self._ensure_catch_all_worker_with_retry( + stage_name="email_routing", + wait_for_worker=True, + skip_on_failure=True, + ) + if catch_all is None: + self.state.email_routing.catch_all_enabled = False + self.state.email_routing.catch_all_rule_id = "" + self.state.email_routing.catch_all_worker = "" + return + self._record_catch_all_state(catch_all) + + def _validate_email_routing_destination(self, target_address: str) -> None: + try: + addresses = self.cloudflare.list_email_routing_addresses(account_id=self._require_account_id()) + except (CloudflareAPIError, ConfigError) as exc: + if not self.cloudflare.is_authentication_error(exc): + raise + LOGGER.warning( + "[email-routing] 当前鉴权无法读取目标地址列表,跳过 verified_destination_address 校验: %s", + exc, + ) + return + + if any(self.cloudflare.email_address_ready(item, target_address) for item in addresses): + return + raise DeployError("verified_destination_address 尚未处于可用状态,无法启用 Email Routing。") + + def _ensure_email_routing_dns_records(self) -> list[str]: + try: + dns_records = self.cloudflare.get_email_routing_dns(zone_id=self._require_zone_id()) + except (CloudflareAPIError, ConfigError) as exc: + if not self.cloudflare.is_authentication_error(exc): + raise + if self._email_routing_dns_already_present(): + LOGGER.warning( + "[email-routing] 当前鉴权无法读取 Email Routing DNS 模板,已检测到现有 MX/SPF 记录,继续执行 Catch-all 更新。" + ) + return [] + LOGGER.warning( + "[email-routing] 当前鉴权无法读取 Email Routing DNS 模板,改用 Cloudflare 官方文档中的默认 MX/SPF 记录。" + ) + return self._ensure_email_routing_dns_records_with_fallback_template() + + dns_record_ids: list[str] = [] + for record in _normalize_email_routing_dns_records(dns_records): + ensured = self.cloudflare.ensure_dns_record( + zone_id=self._require_zone_id(), + record_type=record["type"], + name=record["name"], + content=record["content"], + ttl=record.get("ttl", 1), + priority=record.get("priority"), + proxied=False, + ) + record_id = str(ensured.get("id", "")) + if record_id: + dns_record_ids.append(record_id) + return dns_record_ids + + def _ensure_email_routing_dns_records_with_fallback_template(self) -> list[str]: + records = _build_fallback_email_routing_dns_records(self.config.mail.domains) + dns_record_ids: list[str] = [] + for record in records: + ensured = self.cloudflare.ensure_dns_record( + zone_id=self._require_zone_id(), + record_type=record["type"], + name=record["name"], + content=record["content"], + ttl=record["ttl"], + priority=record.get("priority"), + proxied=False, + ) + record_id = str(ensured.get("id", "")) + if record_id: + dns_record_ids.append(record_id) + return dns_record_ids + + def _email_routing_dns_already_present(self) -> bool: + zone_id = self._require_zone_id() + for domain in self.config.mail.domains: + records = self.cloudflare.list_dns_records(zone_id, name=domain) + mx_ready = any( + str(record.get("type", "")).upper() == "MX" + and "mx.cloudflare.net" in str(record.get("content", "")).lower() + for record in records + ) + txt_ready = any( + str(record.get("type", "")).upper() == "TXT" + and "_spf.mx.cloudflare.net" in str(record.get("content", "")).lower() + for record in records + ) + if not (mx_ready and txt_ready): + return False + return True + + def _deploy_pages_stage(self) -> None: + if ( + self._has_checkpoint("pages_completed") + and self.state.pages.project_name == self.config.pages.project_name + and self.state.pages.custom_domain == self.config.pages.custom_domain + and self.state.pages.custom_domain_status.lower() == "active" + ): + LOGGER.info("[stage] 跳过已完成阶段: Pages 前端发布。") + return + self._run_stage("pages", "pages_completed", "构建并发布 Pages 前端。", self._deploy_pages) + + def _deploy_pages(self) -> None: + layout = self._require_layout() + self._ensure_frontend_runtime() + build_command = "build:pages:nopwa" if self.config.pages.build_mode == "pages:nopwa" else "build:pages" + self.runner.run_checked( + CommandSpec(args=("npm", "run", build_command), cwd=layout.frontend_dir) + ) + + self._write_pages_wrangler(layout) + self._remove_generated_wrangler_redirects(layout.pages_dir, layout.root_dir) + self._ensure_pages_runtime() + self.runner.run_checked( + CommandSpec( + args=build_wrangler_pages_deploy_command(self.config.pages.production_branch), + cwd=layout.pages_dir, + env=self._wrangler_env(), + ) + ) + + self.cloudflare.ensure_pages_domain( + account_id=self._require_account_id(), + project_name=self.config.pages.project_name, + domain_name=self.config.pages.custom_domain, + ) + domain = self.cloudflare.wait_for_pages_domain_active( + account_id=self._require_account_id(), + project_name=self.config.pages.project_name, + domain_name=self.config.pages.custom_domain, + ) + self.state.pages.custom_domain = self.config.pages.custom_domain + self.state.pages.custom_domain_status = str(domain.get("status", "")) + + def _run_acceptance(self) -> None: + frontend_url = self.config.build_frontend_url( + self.state.pages.custom_domain or self.state.pages.subdomain + ) + worker_url = "" + if self.config.worker.custom_domain: + worker_url = f"https://{self.config.worker.custom_domain}" + elif self.state.worker.workers_dev_url: + worker_url = self.state.worker.workers_dev_url + + with httpx.Client(follow_redirects=True, timeout=20.0, trust_env=False) as client: + frontend_response = self._http_get(client, frontend_url) + if frontend_response.status_code != 200: + raise AcceptanceCheckError(f"前端首页访问失败: {frontend_url}") + + if worker_url: + health_response = self._http_get(client, f"{worker_url.rstrip('/')}/health_check") + if health_response.status_code != 200 or "OK" not in health_response.text: + raise AcceptanceCheckError(f"Worker 健康检查失败: {worker_url}") + + self._run_application_acceptance() + + catch_all = self.cloudflare.get_catch_all(zone_id=self._require_zone_id()) + if catch_all is None or not self.cloudflare.catch_all_points_to_worker( + catch_all, + self.config.worker.script_name, + ): + catch_all = self._ensure_catch_all_worker_with_retry( + stage_name="acceptance", + wait_for_worker=False, + skip_on_failure=False, + ) + if catch_all is None or not self.cloudflare.catch_all_points_to_worker( + catch_all, + self.config.worker.script_name, + ): + raise AcceptanceCheckError("Catch-all 当前未指向目标 Worker。") + self._record_catch_all_state(catch_all) + + def _ensure_catch_all_worker_with_retry( + self, + *, + stage_name: str, + wait_for_worker: bool, + skip_on_failure: bool, + ) -> dict[str, Any] | None: + zone_id = self._require_zone_id() + script_name = self.config.worker.script_name + + if wait_for_worker: + LOGGER.info( + "[%s] Worker 发布完成,等待 %s 秒后开始配置 Catch-all。", + stage_name, + WORKER_PROPAGATION_WAIT_SECONDS, + ) + time.sleep(WORKER_PROPAGATION_WAIT_SECONDS) + + last_error: CloudflareAPIError | None = None + for attempt in range(1, CATCH_ALL_MAX_ATTEMPTS + 1): + try: + catch_all = self.cloudflare.ensure_catch_all_worker( + zone_id=zone_id, + script_name=script_name, + ) + except CloudflareAPIError as exc: + last_error = exc + LOGGER.warning( + "[%s] Catch-all 配置失败,第 %s/%s 次尝试: %s", + stage_name, + attempt, + CATCH_ALL_MAX_ATTEMPTS, + exc, + ) + if attempt < CATCH_ALL_MAX_ATTEMPTS: + time.sleep(CATCH_ALL_RETRY_DELAY_SECONDS) + continue + + LOGGER.info("[%s] Catch-all 已指向 Worker: %s", stage_name, script_name) + return catch_all + + if skip_on_failure: + LOGGER.warning( + "[%s] Catch-all 连续失败 %s 次,当前阶段跳过该配置,后续阶段继续执行。", + stage_name, + CATCH_ALL_MAX_ATTEMPTS, + ) + return None + + if last_error is None: + raise AcceptanceCheckError("Catch-all 配置未返回结果。") + raise AcceptanceCheckError( + f"Catch-all 连续失败 {CATCH_ALL_MAX_ATTEMPTS} 次,仍未指向目标 Worker: {last_error}" + ) from last_error + + def _run_application_acceptance(self) -> None: + admin_base_url = self._require_application_base_url() + admin_password = self._require_admin_password() + + try: + with ApplicationAdminClient(admin_base_url, admin_password) as admin_client: + user_settings = admin_client.get_user_settings() + if bool(user_settings.get("enable", False)) != self.config.user_access.allow_user_register: + raise AcceptanceCheckError("用户注册开关与配置文件不一致。") + + if not self.config.linuxdo.linuxdo_oauth: + return + + oauth_settings = admin_client.get_user_oauth2_settings() + except ApplicationAPIError as exc: + raise AcceptanceCheckError(f"管理员接口验收失败: {exc}") from exc + + redirect_url = linuxdo_oauth_callback_url(self.config.pages.custom_domain) + for item in oauth_settings: + if not is_linuxdo_oauth2_setting(item): + continue + if str(item.get("redirectURL", "")).strip() != redirect_url: + raise AcceptanceCheckError("Linux.do OAuth2 回调地址与配置文件不一致。") + return + raise AcceptanceCheckError("管理员接口中缺少 Linux.do OAuth2 配置。") + + def _record_catch_all_state(self, catch_all: dict[str, Any]) -> None: + self.state.email_routing.catch_all_enabled = bool(catch_all.get("enabled", True)) + self.state.email_routing.catch_all_rule_id = str(catch_all.get("id", "")) + self.state.email_routing.catch_all_worker = self.config.worker.script_name + + def _ensure_worker_wranger_runtime(self) -> None: + layout = self._require_layout() + if (layout.worker_dir / "node_modules" / ".bin" / "wrangler").exists(): + return + self.runner.run_checked(CommandSpec(args=("npm", "install"), cwd=layout.worker_dir)) + + def _ensure_frontend_runtime(self) -> None: + layout = self._require_layout() + if (layout.frontend_dir / "node_modules" / ".bin" / "vite").exists(): + return + self.runner.run_checked(CommandSpec(args=("npm", "install"), cwd=layout.frontend_dir)) + + def _ensure_pages_runtime(self) -> None: + layout = self._require_layout() + if (layout.pages_dir / "node_modules" / ".bin" / "wrangler").exists(): + return + self.runner.run_checked(CommandSpec(args=("npm", "install"), cwd=layout.pages_dir)) + + def _apply_telegraf_patch(self, layout: ProjectLayout) -> None: + if not layout.telegraf_dir.exists(): + raise ConfigError(f"缺少 telegraf 安装目录: {layout.telegraf_dir}") + patch_path = layout.worker_patch_path + check_result = self.runner.run( + CommandSpec( + args=("git", "apply", "--check", str(patch_path)), + cwd=layout.telegraf_dir, + ) + ) + if check_result.returncode == 0: + self.runner.run_checked( + CommandSpec( + args=("git", "apply", str(patch_path)), + cwd=layout.telegraf_dir, + ) + ) + return + + reverse_result = self.runner.run( + CommandSpec( + args=("git", "apply", "--reverse", "--check", str(patch_path)), + cwd=layout.telegraf_dir, + ) + ) + if reverse_result.returncode == 0: + return + raise DeployError("telegraf 补丁检查失败,当前依赖版本与预期不匹配。") + + def _write_worker_wrangler(self, layout: ProjectLayout) -> None: + template_defaults = _load_worker_template_defaults(layout.worker_wrangler_template) + document = tomlkit.document() + document["name"] = self.config.worker.script_name + document["main"] = str(template_defaults.get("main", "src/worker.ts")) + document["compatibility_date"] = self.config.worker.compatibility_date + document["compatibility_flags"] = template_defaults.get("compatibility_flags", ["nodejs_compat"]) + document["keep_vars"] = bool(template_defaults.get("keep_vars", True)) + + if self.config.worker.custom_domain: + document["routes"] = [ + { + "pattern": self.config.worker.custom_domain, + "custom_domain": True, + } + ] + + vars_table = tomlkit.table() + for key, value in self.config.derived_worker_vars(self.state.pages.subdomain).items(): + vars_table[key] = tomlkit.item(value) + document["vars"] = vars_table + + d1_bindings = tomlkit.aot() + d1_table = tomlkit.table() + d1_table["binding"] = "DB" + d1_table["database_name"] = self.config.d1.database_name + d1_table["database_id"] = self.state.d1.database_id + d1_bindings.append(d1_table) + document["d1_databases"] = d1_bindings + save_toml_document(layout.worker_wrangler_path, document) + + def _write_pages_wrangler(self, layout: ProjectLayout) -> None: + existing_date = _read_existing_compatibility_date(layout.pages_wrangler_path) or DEFAULT_PAGES_COMPATIBILITY_DATE + document = tomlkit.document() + document["name"] = self.config.pages.project_name + document["pages_build_output_dir"] = os.path.relpath(layout.frontend_dist_dir, layout.pages_dir) + document["compatibility_date"] = existing_date + + services = tomlkit.aot() + service_table = tomlkit.table() + service_table["binding"] = "BACKEND" + service_table["service"] = self.config.worker.script_name + service_table["environment"] = "production" + services.append(service_table) + document["services"] = services + save_toml_document(layout.pages_wrangler_path, document) + + def _remove_generated_wrangler_redirects(self, *base_dirs: Path) -> None: + for base_dir in base_dirs: + redirect_path = base_dir / ".wrangler" / "deploy" / "config.json" + if not redirect_path.exists(): + continue + try: + redirect_path.unlink() + except OSError as exc: + raise DeployError(f"无法清理 Wrangler 生成配置重定向文件: {redirect_path}") from exc + LOGGER.info("[wrangler] 已清理生成配置重定向文件: %s", redirect_path) + + def _ensure_d1_history_table(self) -> None: + self.cloudflare.query_d1( + account_id=self._require_account_id(), + database_id=self._require_database_id(), + sql=HISTORY_TABLE_SQL, + ) + + def _load_d1_history(self) -> dict[str, str]: + rows = self.cloudflare.query_d1( + account_id=self._require_account_id(), + database_id=self._require_database_id(), + sql="SELECT file_name, sha256 FROM __deploy_history ORDER BY file_name", + ) + return { + str(row.get("file_name", "")): str(row.get("sha256", "")) + for row in rows + if row.get("file_name") + } + + def _record_d1_history(self, file_name: str, sha256: str) -> None: + self.cloudflare.query_d1( + account_id=self._require_account_id(), + database_id=self._require_database_id(), + sql=( + "INSERT OR REPLACE INTO __deploy_history (file_name, sha256, applied_at) " + "VALUES (?, ?, CURRENT_TIMESTAMP)" + ), + params=[file_name, sha256], + ) + + def _list_application_tables(self) -> list[str]: + rows = self.cloudflare.query_d1( + account_id=self._require_account_id(), + database_id=self._require_database_id(), + sql=( + "SELECT name FROM sqlite_master " + "WHERE type = 'table' " + "AND name NOT LIKE 'sqlite_%' " + "AND name NOT LIKE '_cf_%' " + "AND name != '__deploy_history' " + "ORDER BY name" + ), + ) + return [str(row.get("name", "")) for row in rows if row.get("name")] + + def _execute_remote_sql_file(self, sql_file: Path) -> None: + layout = self._require_layout() + relative_file = Path(os.path.relpath(sql_file, layout.worker_dir)) + self.runner.run_checked( + CommandSpec( + args=build_wrangler_d1_execute_command(self.config.d1.database_name, relative_file), + cwd=layout.worker_dir, + env=self._wrangler_env(), + ) + ) + + def _http_get(self, client: httpx.Client, url: str) -> httpx.Response: + try: + return client.get(url) + except httpx.HTTPError as exc: # pragma: no cover + raise AcceptanceCheckError(f"HTTP 访问失败: {url}") from exc + + def _require_layout(self) -> ProjectLayout: + if self.context.layout is not None: + return self.context.layout + if self.state.source.source_dir: + source_dir = Path(self.state.source.source_dir) + if not self.state.source.commit_sha and source_dir.exists(): + self.state.source.commit_sha = resolve_commit_sha(source_dir, self.runner) + self.context.prepared_source = PreparedSource( + source_dir=source_dir, + commit_sha=self.state.source.commit_sha, + ) + self.context.layout = detect_project_layout(source_dir) + return self.context.layout + raise ConfigError("源码目录尚未准备完成。") + + def _require_account_id(self) -> str: + account_id = self.state.cloudflare.account_id + if not account_id: + raise ConfigError("account_id 缺失,请先执行预检查。") + return account_id + + def _require_zone_id(self) -> str: + zone_id = self.state.cloudflare.zone_id + if not zone_id: + raise ConfigError("zone_id 缺失,请先执行预检查。") + return zone_id + + def _require_database_id(self) -> str: + database_id = self.state.d1.database_id + if not database_id: + raise ConfigError("D1 数据库 ID 缺失。") + return database_id + + def _require_pages_subdomain(self) -> str: + subdomain = self.state.pages.subdomain + if not subdomain: + raise ConfigError("Pages 子域名缺失。") + return subdomain + + def _has_checkpoint(self, checkpoint: str) -> bool: + current = CHECKPOINT_ORDER.get(self.state.checkpoint, 0) + target = CHECKPOINT_ORDER.get(checkpoint, 0) + return current >= target + + def _advance_checkpoint(self, checkpoint: str) -> None: + if self._has_checkpoint(checkpoint): + return + self.state.mark_checkpoint(checkpoint) + + def _save_state(self) -> None: + save_state(self.context.state_path, self.state) + + def _require_admin_password(self) -> str: + passwords = self.config.admin_passwords() + if not passwords: + raise ConfigError("worker.vars.ADMIN_PASSWORDS 至少需要一个非空密码。") + return passwords[0] + + def _require_application_base_url(self) -> str: + if self.config.worker.custom_domain: + return f"https://{self.config.worker.custom_domain}" + if self.state.worker.workers_dev_url: + return self.state.worker.workers_dev_url + raise ConfigError("缺少可访问的 Worker 地址,无法调用管理员接口。") + + def _wrangler_env(self) -> dict[str, str]: + token = self.config.cloudflare.resolved_api_token() + if not token: + raise ConfigError("Cloudflare API Token 缺失,无法执行 Wrangler 命令。") + return { + "CLOUDFLARE_API_TOKEN": token, + "CLOUDFLARE_ACCOUNT_ID": self._require_account_id(), + } + + +def run_deployment( + *, + config_path: Path, + config: DeploymentConfig, + state_path: Path, + state: DeploymentState, + runner: CommandRunner, + cloudflare_client_factory: Callable[[Any], CloudflareClient] = CloudflareClient, + is_resume: bool = False, +) -> DeploymentState: + """Run the deployment workflow and return the updated state.""" + + context = DeploymentContext( + config_path=config_path, + state_path=state_path, + config=config, + state=state, + runner=runner, + is_resume=is_resume, + ) + with cloudflare_client_factory(config.cloudflare) as cloudflare: + manager = DeploymentManager(context, cloudflare) + manager.run() + return context.state + + +def _sha256_path(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _normalize_email_routing_dns_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + for record in records: + source = record.get("record") if isinstance(record.get("record"), dict) else record + if not isinstance(source, dict): + continue + record_type = str(source.get("type", "")).upper() + name = str(source.get("name") or source.get("hostname") or "") + content = str(source.get("content") or source.get("value") or "") + if not record_type or not name or not content: + continue + normalized_record: dict[str, Any] = { + "type": record_type, + "name": name, + "content": content, + "ttl": int(source.get("ttl", 1) or 1), + } + if source.get("priority") not in (None, ""): + normalized_record["priority"] = int(source["priority"]) + normalized.append(normalized_record) + return normalized + + +def _build_fallback_email_routing_dns_records(domains: list[str]) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + for domain in domains: + for content, priority in EMAIL_ROUTING_FALLBACK_MX_RECORDS: + normalized.append( + { + "type": "MX", + "name": domain, + "content": content, + "ttl": 300, + "priority": priority, + } + ) + normalized.append( + { + "type": "TXT", + "name": domain, + "content": EMAIL_ROUTING_FALLBACK_SPF, + "ttl": 300, + } + ) + return normalized + + +def _load_worker_template_defaults(template_path: Path) -> dict[str, Any]: + document = tomlkit.parse(template_path.read_text(encoding="utf-8")) + return { + "main": document.get("main", "src/worker.ts"), + "compatibility_flags": document.get("compatibility_flags", ["nodejs_compat"]), + "keep_vars": document.get("keep_vars", True), + } + + +def _read_existing_compatibility_date(path: Path) -> str: + if not path.exists(): + return "" + try: + document = tomlkit.parse(path.read_text(encoding="utf-8")) + except tomlkit.exceptions.ParseError: + return "" + value = document.get("compatibility_date", "") + return str(value) if value else "" diff --git a/src/cf_temp_email_deploy/discovery.py b/src/cf_temp_email_deploy/discovery.py new file mode 100644 index 0000000..55e8883 --- /dev/null +++ b/src/cf_temp_email_deploy/discovery.py @@ -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 {} diff --git a/src/cf_temp_email_deploy/environment.py b/src/cf_temp_email_deploy/environment.py new file mode 100644 index 0000000..07335e6 --- /dev/null +++ b/src/cf_temp_email_deploy/environment.py @@ -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 + diff --git a/src/cf_temp_email_deploy/errors.py b/src/cf_temp_email_deploy/errors.py new file mode 100644 index 0000000..d073f3a --- /dev/null +++ b/src/cf_temp_email_deploy/errors.py @@ -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 diff --git a/src/cf_temp_email_deploy/logging_utils.py b/src/cf_temp_email_deploy/logging_utils.py new file mode 100644 index 0000000..cdd5735 --- /dev/null +++ b/src/cf_temp_email_deploy/logging_utils.py @@ -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), + ) + diff --git a/src/cf_temp_email_deploy/models.py b/src/cf_temp_email_deploy/models.py new file mode 100644 index 0000000..3a34e6a --- /dev/null +++ b/src/cf_temp_email_deploy/models.py @@ -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() diff --git a/src/cf_temp_email_deploy/project_layout.py b/src/cf_temp_email_deploy/project_layout.py new file mode 100644 index 0000000..f36917f --- /dev/null +++ b/src/cf_temp_email_deploy/project_layout.py @@ -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 diff --git a/src/cf_temp_email_deploy/source.py b/src/cf_temp_email_deploy/source.py new file mode 100644 index 0000000..a77f77d --- /dev/null +++ b/src/cf_temp_email_deploy/source.py @@ -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 diff --git a/src/cf_temp_email_deploy/subprocess_runner.py b/src/cf_temp_email_deploy/subprocess_runner.py new file mode 100644 index 0000000..c6bc70d --- /dev/null +++ b/src/cf_temp_email_deploy/subprocess_runner.py @@ -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 diff --git a/src/cf_temp_email_deploy/wrangler.py b/src/cf_temp_email_deploy/wrangler.py new file mode 100644 index 0000000..986e79d --- /dev/null +++ b/src/cf_temp_email_deploy/wrangler.py @@ -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) diff --git a/tests/test_app_admin.py b/tests/test_app_admin.py new file mode 100644 index 0000000..944382c --- /dev/null +++ b/tests/test_app_admin.py @@ -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"] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..093da9e --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_cloudflare.py b/tests/test_cloudflare.py new file mode 100644 index 0000000..ad28285 --- /dev/null +++ b/tests/test_cloudflare.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..e50d574 --- /dev/null +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 0000000..191a3fc --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,1143 @@ +from __future__ import annotations + +from pathlib import Path + +import httpx + +from cf_temp_email_deploy import app_admin as app_admin_module +from cf_temp_email_deploy import deployment as deployment_module +from cf_temp_email_deploy.deployment import run_deployment +from cf_temp_email_deploy.errors import CloudflareAPIError +from cf_temp_email_deploy.models import DeploymentConfig, DeploymentState +from cf_temp_email_deploy.source import PreparedSource +from cf_temp_email_deploy.subprocess_runner import CommandResult, CommandSpec + + +class FakeRunner: + def __init__(self) -> None: + self.commands: list[tuple[tuple[str, ...], Path | None, str | None]] = [] + + def run(self, spec: CommandSpec) -> CommandResult: + return self._dispatch(spec) + + def run_checked(self, spec: CommandSpec) -> CommandResult: + result = self._dispatch(spec) + if result.returncode != 0: # pragma: no cover + raise AssertionError(f"unexpected failure: {result.args}") + return result + + def _dispatch(self, spec: CommandSpec) -> CommandResult: + args = tuple(spec.args) + self.commands.append((args, spec.cwd, spec.input_text)) + cwd = spec.cwd + + if args == ("git", "--version"): + return CommandResult(args=args, returncode=0, stdout="git version 2.43.0\n", stderr="", duration_seconds=0.0) + + if args == ("node", "--version"): + return CommandResult(args=args, returncode=0, stdout="v24.14.0\n", stderr="", duration_seconds=0.0) + + if args == ("npm", "--version"): + return CommandResult(args=args, returncode=0, stdout="11.9.0\n", stderr="", duration_seconds=0.0) + + if args[:2] == ("npm", "install") and cwd is not None: + bin_dir = cwd / "node_modules" / ".bin" + bin_dir.mkdir(parents=True, exist_ok=True) + if cwd.name == "worker": + (bin_dir / "wrangler").write_text("", encoding="utf-8") + (cwd / "node_modules" / "telegraf").mkdir(parents=True, exist_ok=True) + if cwd.name == "frontend": + (bin_dir / "vite").write_text("", encoding="utf-8") + if cwd.name == "pages": + (bin_dir / "wrangler").write_text("", encoding="utf-8") + return CommandResult(args=args, returncode=0, stdout="", stderr="", duration_seconds=0.0) + + if args[:3] == ("npm", "run", "build:pages") and cwd is not None: + (cwd / "dist").mkdir(parents=True, exist_ok=True) + (cwd / "dist" / "index.html").write_text("ok", encoding="utf-8") + return CommandResult(args=args, returncode=0, stdout="", stderr="", duration_seconds=0.0) + + if args[:3] == ("npm", "run", "build:pages:nopwa") and cwd is not None: + (cwd / "dist").mkdir(parents=True, exist_ok=True) + (cwd / "dist" / "index.html").write_text("ok", encoding="utf-8") + return CommandResult(args=args, returncode=0, stdout="", stderr="", duration_seconds=0.0) + + if args[:3] == ("git", "apply", "--check"): + return CommandResult(args=args, returncode=0, stdout="", stderr="", duration_seconds=0.0) + + if args[:4] == ("git", "apply", "--reverse", "--check"): + return CommandResult(args=args, returncode=1, stdout="", stderr="", duration_seconds=0.0) + + return CommandResult(args=args, returncode=0, stdout="", stderr="", duration_seconds=0.0) + + +class FakeCloudflareClient: + def __init__(self, config: object) -> None: + self.config = config + self.history: dict[str, str] = {} + self.tables: list[str] = [] + self.catch_all_attempts = 0 + self.pages_project_exists = True + self.worker_exists = True + self.d1_exists = True + self.catch_all = { + "id": "rule-1", + "enabled": True, + "actions": [{"type": "worker", "value": ["email-api"]}], + } + + 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]: + 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, object]: + return { + "id": "zone-1", + "name": zone_name, + "account": {"id": account_id or "acc-1", "name": "demo-account"}, + } + + def ensure_pages_project( + self, + *, + account_id: str, + project_name: str, + production_branch: str, + ) -> dict[str, str]: + self.pages_project_exists = True + return {"id": "proj-1", "name": project_name, "subdomain": f"{project_name}.pages.dev"} + + def get_pages_project(self, *, account_id: str, project_name: str) -> dict[str, str] | None: + if not self.pages_project_exists: + return None + return {"id": "proj-1", "name": project_name, "subdomain": f"{project_name}.pages.dev"} + + def ensure_cname_record(self, *, zone_id: str, name: str, content: str, proxied: bool = True, ttl: int = 1) -> dict[str, str]: + return {"id": "dns-cname", "name": name, "content": content} + + def ensure_d1_database(self, *, account_id: str, database_name: str, jurisdiction: str = "") -> dict[str, str]: + self.d1_exists = True + return {"uuid": "db-1", "name": database_name} + + def list_d1_databases(self, *, account_id: str, database_name: str = "") -> list[dict[str, str]]: + if not self.d1_exists: + return [] + database = {"uuid": "db-1", "name": database_name or "temp-email"} + if database_name and database["name"] != database_name: + database["name"] = database_name + return [database] + + def query_d1( + self, + *, + account_id: str, + database_id: str, + sql: str, + params: list[object] | None = None, + ) -> list[dict[str, str]]: + if "SELECT file_name, sha256 FROM __deploy_history" in sql: + return [ + {"file_name": file_name, "sha256": sha256} + for file_name, sha256 in sorted(self.history.items()) + ] + if "SELECT name FROM sqlite_master" in sql: + return [{"name": name} for name in self.tables] + if "INSERT OR REPLACE INTO __deploy_history" in sql: + assert params is not None + self.history[str(params[0])] = str(params[1]) + return [] + return [] + + def get_workers_subdomain(self, *, account_id: str) -> dict[str, str]: + return {"subdomain": "acct-workers"} + + def get_worker_script(self, *, account_id: str, script_name: str) -> dict[str, str] | None: + if not self.worker_exists: + return None + return {"id": script_name, "handlers": ["fetch", "email", "scheduled"]} + + def list_email_routing_addresses(self, *, account_id: str) -> list[dict[str, str]]: + return [{"email": "verified@example.com", "status": "verified"}] + + def email_address_ready(self, address: dict[str, str], target: str) -> bool: + return address["email"] == target and address["status"] == "verified" + + def get_email_routing_dns(self, *, zone_id: str) -> list[dict[str, object]]: + return [ + { + "type": "MX", + "name": "email.example.com", + "content": "route1.mx.cloudflare.net", + "priority": 10, + }, + { + "type": "TXT", + "name": "email.example.com", + "content": "v=spf1 include:_spf.mx.cloudflare.net ~all", + }, + ] + + 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, str]: + suffix = priority if priority is not None else "default" + return {"id": f"{record_type.lower()}-{suffix}", "name": name, "content": content} + + def ensure_catch_all_worker(self, *, zone_id: str, script_name: str) -> dict[str, object]: + self.catch_all_attempts += 1 + self.catch_all = { + "id": "rule-1", + "enabled": True, + "actions": [{"type": "worker", "value": [script_name]}], + } + return self.catch_all + + def ensure_pages_domain(self, *, account_id: str, project_name: str, domain_name: str) -> dict[str, str]: + return {"name": domain_name, "status": "pending"} + + 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, str]: + return {"name": domain_name, "status": "active"} + + def get_catch_all(self, *, zone_id: str) -> dict[str, object]: + return self.catch_all + + def catch_all_points_to_worker(self, rule: dict[str, object], script_name: str) -> bool: + actions = rule.get("actions", []) + return isinstance(actions, list) and any( + isinstance(action, dict) + and action.get("type") == "worker" + and script_name in action.get("value", []) + for action in actions + ) + + @staticmethod + def is_authentication_error(error: Exception) -> bool: + return isinstance(error, CloudflareAPIError) and "Authentication error" in str(error) + + @staticmethod + def worker_script_supports_email(script: dict[str, object]) -> bool: + handlers = script.get("handlers") + return isinstance(handlers, list) and "email" in handlers + + +class FakeCloudflareClientAuthLimited(FakeCloudflareClient): + def __init__(self, config: object) -> None: + super().__init__(config) + self.existing_records = [ + { + "id": "mx-1", + "type": "MX", + "name": "email.example.com", + "content": "route1.mx.cloudflare.net", + }, + { + "id": "txt-1", + "type": "TXT", + "name": "email.example.com", + "content": "v=spf1 include:_spf.mx.cloudflare.net ~all", + }, + ] + + def list_email_routing_addresses(self, *, account_id: str) -> list[dict[str, str]]: + raise CloudflareAPIError("Cloudflare API 返回错误: Authentication error", status_code=403) + + def get_email_routing_dns(self, *, zone_id: str) -> list[dict[str, object]]: + raise CloudflareAPIError("Cloudflare API 返回错误: Authentication error", status_code=403) + + def list_dns_records(self, zone_id: str, *, name: str = "", record_type: str = "") -> list[dict[str, str]]: + records = [record for record in self.existing_records if not name or record["name"] == name] + if record_type: + records = [record for record in records if record["type"] == record_type] + return records + + +class FakeCloudflareClientAuthLimitedNoDnsTemplate(FakeCloudflareClient): + def list_email_routing_addresses(self, *, account_id: str) -> list[dict[str, str]]: + raise CloudflareAPIError("Cloudflare API 返回错误: Authentication error", status_code=403) + + def get_email_routing_dns(self, *, zone_id: str) -> list[dict[str, object]]: + raise CloudflareAPIError("Cloudflare API 返回错误: Authentication error", status_code=403) + + def list_dns_records(self, zone_id: str, *, name: str = "", record_type: str = "") -> list[dict[str, str]]: + return [] + + +class FakeCloudflareClientCatchAllFlaky(FakeCloudflareClient): + def ensure_catch_all_worker(self, *, zone_id: str, script_name: str) -> dict[str, object]: + self.catch_all_attempts += 1 + if self.catch_all_attempts < 3: + raise CloudflareAPIError("Cloudflare API 返回错误: Workers Script Info not found", status_code=404) + self.catch_all = { + "id": "rule-1", + "enabled": True, + "actions": [{"type": "worker", "value": [script_name]}], + } + return self.catch_all + + +class FakeCloudflareClientCatchAllDeferred(FakeCloudflareClient): + def __init__(self, config: object) -> None: + super().__init__(config) + self.catch_all = { + "id": "rule-1", + "enabled": True, + "actions": [{"type": "drop"}], + } + + def ensure_catch_all_worker(self, *, zone_id: str, script_name: str) -> dict[str, object]: + self.catch_all_attempts += 1 + if self.catch_all_attempts <= 3: + raise CloudflareAPIError("Cloudflare API 返回错误: Workers Script Info not found", status_code=404) + self.catch_all = { + "id": "rule-1", + "enabled": True, + "actions": [{"type": "worker", "value": [script_name]}], + } + return self.catch_all + + +class FakeCloudflareClientMissingPagesProject(FakeCloudflareClient): + def __init__(self, config: object) -> None: + super().__init__(config) + self.pages_project_exists = False + + +class FakeCloudflareClientMissingWorkerScript(FakeCloudflareClient): + def __init__(self, config: object) -> None: + super().__init__(config) + self.worker_exists = False + + +class FakeCloudflareClientMissingD1(FakeCloudflareClient): + def __init__(self, config: object) -> None: + super().__init__(config) + self.d1_exists = False + + +class FakeHTTPClient: + def __init__(self, *args: object, **kwargs: object) -> None: + return None + + def __enter__(self) -> "FakeHTTPClient": + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + def get(self, url: str) -> httpx.Response: + request = httpx.Request("GET", url) + if url.endswith("/health_check"): + return httpx.Response(200, text="OK", request=request) + return httpx.Response(200, text="ok", request=request) + + +class FakeApplicationAdminClient: + instances: list["FakeApplicationAdminClient"] = [] + user_settings: dict[str, object] = {} + oauth2_settings: list[dict[str, object]] = [] + + def __init__(self, base_url: str, admin_password: str, *, timeout: float = 30.0, transport: object = None) -> None: + _ = timeout + _ = transport + self.base_url = base_url + self.admin_password = admin_password + type(self).instances.append(self) + + @classmethod + def reset(cls) -> None: + cls.instances = [] + cls.user_settings = {} + cls.oauth2_settings = [] + + def __enter__(self) -> "FakeApplicationAdminClient": + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + def wait_until_ready( + self, + *, + timeout_seconds: float = 180.0, + poll_interval_seconds: float = 5.0, + ) -> dict[str, object]: + _ = timeout_seconds + _ = poll_interval_seconds + return self.get_user_settings() + + def get_user_settings(self) -> dict[str, object]: + return dict(type(self).user_settings) + + def sync_user_settings(self, *, allow_user_register: bool) -> dict[str, object]: + type(self).user_settings = app_admin_module.merge_user_settings( + type(self).user_settings, + allow_user_register, + ) + return self.get_user_settings() + + def get_user_oauth2_settings(self) -> list[dict[str, object]]: + return [dict(item) for item in type(self).oauth2_settings] + + def sync_linuxdo_oauth2( + self, + *, + pages_domain: str, + client_id: str, + client_secret: str, + ) -> list[dict[str, object]]: + type(self).oauth2_settings = app_admin_module.merge_linuxdo_oauth2_settings( + type(self).oauth2_settings, + pages_domain=pages_domain, + client_id=client_id, + client_secret=client_secret, + ) + return self.get_user_oauth2_settings() + + +def write_file(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def create_source_tree(root: Path) -> None: + write_file( + root / "worker" / "package.json", + '{"devDependencies":{"wrangler":"^4.0.0"},"dependencies":{"telegraf":"4.16.3"}}\n', + ) + write_file( + root / "worker" / "wrangler.toml.template", + '\n'.join( + [ + 'name = "cloudflare_temp_email"', + 'main = "src/worker.ts"', + 'compatibility_date = "2025-04-01"', + 'compatibility_flags = ["nodejs_compat"]', + "keep_vars = true", + ] + ) + + "\n", + ) + write_file(root / "worker" / "patches" / "telegraf@4.16.3.patch", "diff --git a/a b/a\n") + write_file(root / "frontend" / "package.json", '{"devDependencies":{"vite":"^7.0.0"}}\n') + write_file(root / "pages" / "package.json", '{"devDependencies":{"wrangler":"^4.0.0"}}\n') + write_file( + root / "pages" / "wrangler.toml", + '\n'.join( + [ + 'name = "temp-email-pages"', + 'pages_build_output_dir = "../frontend/dist"', + 'compatibility_date = "2024-05-13"', + ] + ) + + "\n", + ) + write_file(root / "db" / "schema.sql", "CREATE TABLE mails(id INTEGER PRIMARY KEY);\n") + write_file(root / "db" / "2024-01-13-patch.sql", "ALTER TABLE mails ADD COLUMN subject TEXT;\n") + write_file(root / "db" / "2024-04-03-patch.sql", "ALTER TABLE mails ADD COLUMN sender TEXT;\n") + + +def build_config(source_dir: Path) -> DeploymentConfig: + return DeploymentConfig.model_validate( + { + "source": {"mode": "local", "local_path": str(source_dir)}, + "cloudflare": {"zone_name": "example.com", "api_token": "token-value"}, + "mail": { + "domains": ["email.example.com"], + "verified_destination_address": "verified@example.com", + }, + "d1": {"database_name": "temp-email"}, + "user_access": { + "require_login_to_create": True, + "allow_user_register": False, + }, + "linuxdo": {"linuxdo_oauth": False}, + "worker": { + "script_name": "email-api", + "custom_domain": "email-api.example.com", + "vars": { + "PREFIX": "tmp", + "ENABLE_USER_CREATE_EMAIL": True, + "ENABLE_USER_DELETE_EMAIL": True, + "DEFAULT_LANG": "zh", + "ADMIN_PASSWORDS": ["admin-secret"], + }, + "secrets": {"JWT_SECRET": "secret-value"}, + }, + "pages": { + "project_name": "demo-pages", + "custom_domain": "email.example.com", + "build_mode": "pages", + "production_branch": "production", + }, + } + ) + + +def patch_deployment_runtime( + monkeypatch, + source_dir: Path, + *, + admin_client_class: type[FakeApplicationAdminClient] = FakeApplicationAdminClient, +) -> None: + admin_client_class.reset() + monkeypatch.setattr( + deployment_module, + "prepare_source", + lambda config, runner: PreparedSource(source_dir=source_dir, commit_sha="abc123"), + ) + monkeypatch.setattr(deployment_module, "ApplicationAdminClient", admin_client_class) + monkeypatch.setattr(deployment_module.httpx, "Client", FakeHTTPClient) + + +def test_run_deployment_executes_full_pipeline(monkeypatch, tmp_path: Path) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + state = DeploymentState() + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClient, + ) + + assert result.checkpoint == "acceptance_completed" + assert result.pages.project_id == "proj-1" + assert result.pages.subdomain == "demo-pages.pages.dev" + assert result.d1.database_id == "db-1" + assert result.worker.script_name == "email-api" + assert result.worker.workers_dev_url == "https://email-api.acct-workers.workers.dev" + assert result.application.configured is True + assert result.application.allow_user_register is False + assert result.application.require_login_to_create is True + assert result.email_routing.catch_all_enabled is True + assert [migration.file_name for migration in result.d1.migrations] == [ + "2024-01-13-patch.sql", + "2024-04-03-patch.sql", + ] + + worker_wrangler = (source_dir / "worker" / "wrangler.toml").read_text(encoding="utf-8") + pages_wrangler = (source_dir / "pages" / "wrangler.toml").read_text(encoding="utf-8") + assert 'name = "email-api"' in worker_wrangler + assert 'database_id = "db-1"' in worker_wrangler + assert 'service = "email-api"' in pages_wrangler + + command_args = [args for args, _, _ in runner.commands] + assert ("npm", "install") in [args[:2] for args in command_args] + assert ("npm", "exec", "--", "wrangler", "deploy", "--minify") in command_args + assert ("npm", "exec", "--", "wrangler", "pages", "deploy", "--branch", "production") in command_args + assert any(args[:6] == ("npm", "exec", "--", "wrangler", "d1", "execute") for args in command_args) + assert any( + args[:7] == ("npm", "exec", "--", "wrangler", "secret", "put", "JWT_SECRET") + and input_text == "secret-value\n" + for args, _, input_text in runner.commands + ) + assert result.events[0].status == "started" + assert result.events[-1].status == "completed" + assert FakeApplicationAdminClient.user_settings["enable"] is False + assert sleep_calls == [30] + + +def test_run_deployment_removes_generated_wrangler_redirects(monkeypatch, tmp_path: Path) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + root_redirect = source_dir / ".wrangler" / "deploy" / "config.json" + pages_redirect = source_dir / "pages" / ".wrangler" / "deploy" / "config.json" + write_file(root_redirect, '{"configPath":"dist/wrangler.jsonc"}\n') + write_file(pages_redirect, '{"configPath":"dist/wrangler.jsonc"}\n') + + config = build_config(source_dir) + state = DeploymentState() + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClient, + ) + + assert result.checkpoint == "acceptance_completed" + assert not root_redirect.exists() + assert not pages_redirect.exists() + assert sleep_calls == [30] + + +def test_run_deployment_generates_jwt_secret_when_empty(monkeypatch, tmp_path: Path) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + config.worker.secrets["JWT_SECRET"] = "" + state = DeploymentState() + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + config_path.write_text( + '\n'.join( + [ + '[source]', + f'mode = "local"', + f'local_path = "{source_dir}"', + "", + "[cloudflare]", + 'zone_name = "example.com"', + 'api_token = "token-value"', + "", + "[mail]", + 'domains = ["email.example.com"]', + 'verified_destination_address = "verified@example.com"', + "", + "[d1]", + 'database_name = "temp-email"', + "", + "[worker]", + 'script_name = "email-api"', + 'custom_domain = "email-api.example.com"', + "", + "[worker.vars]", + 'PREFIX = "tmp"', + "", + "[worker.secrets]", + 'JWT_SECRET = ""', + "", + "[pages]", + 'project_name = "demo-pages"', + 'custom_domain = "email.example.com"', + 'build_mode = "pages"', + 'production_branch = "production"', + "", + ] + ), + encoding="utf-8", + ) + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClient, + ) + + generated_secret = result.worker.script_name and config.worker.secrets["JWT_SECRET"] + config_text = config_path.read_text(encoding="utf-8") + + assert result.checkpoint == "acceptance_completed" + assert isinstance(generated_secret, str) + assert generated_secret + assert len(generated_secret) >= 64 + assert generated_secret in config_text + assert 'JWT_SECRET = ""' not in config_text + assert any( + args[:7] == ("npm", "exec", "--", "wrangler", "secret", "put", "JWT_SECRET") + and input_text == f"{generated_secret}\n" + for args, _, input_text in runner.commands + ) + assert sleep_calls == [30] + + +def test_run_deployment_resume_skips_completed_stages(monkeypatch, tmp_path: Path) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + state = DeploymentState(checkpoint="worker_completed") + state.source.source_dir = str(source_dir) + state.source.commit_sha = "abc123" + state.pages.project_id = "proj-1" + state.pages.project_name = config.pages.project_name + state.pages.subdomain = "demo-pages.pages.dev" + state.pages.custom_domain = config.pages.custom_domain + state.pages.cname_record_id = "dns-cname" + state.d1.database_id = "db-1" + state.d1.database_name = config.d1.database_name + state.worker.script_name = config.worker.script_name + state.worker.workers_dev_url = "https://email-api.acct-workers.workers.dev" + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClient, + is_resume=True, + ) + + command_args = [args for args, _, _ in runner.commands] + assert ("npm", "exec", "--", "wrangler", "deploy", "--minify") not in command_args + assert not any(args[:6] == ("npm", "exec", "--", "wrangler", "d1", "execute") for args in command_args) + assert ("npm", "exec", "--", "wrangler", "pages", "deploy", "--branch", "production") in command_args + assert result.checkpoint == "acceptance_completed" + assert result.email_routing.catch_all_worker == "email-api" + assert sleep_calls == [30] + + +def test_run_deployment_can_update_catch_all_when_email_routing_auth_is_limited( + monkeypatch, + tmp_path: Path, +) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + state = DeploymentState(checkpoint="worker_completed") + state.source.source_dir = str(source_dir) + state.source.commit_sha = "abc123" + state.pages.project_id = "proj-1" + state.pages.project_name = config.pages.project_name + state.pages.subdomain = "demo-pages.pages.dev" + state.pages.custom_domain = config.pages.custom_domain + state.pages.cname_record_id = "dns-cname" + state.d1.database_id = "db-1" + state.d1.database_name = config.d1.database_name + state.worker.script_name = config.worker.script_name + state.worker.workers_dev_url = "https://email-api.acct-workers.workers.dev" + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClientAuthLimited, + is_resume=True, + ) + + assert result.checkpoint == "acceptance_completed" + assert result.email_routing.catch_all_enabled is True + assert result.email_routing.catch_all_worker == "email-api" + assert result.email_routing.dns_record_ids == [] + assert sleep_calls == [30] + + +def test_run_deployment_can_fallback_to_documented_email_routing_dns_records( + monkeypatch, + tmp_path: Path, +) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + state = DeploymentState(checkpoint="worker_completed") + state.source.source_dir = str(source_dir) + state.source.commit_sha = "abc123" + state.pages.project_id = "proj-1" + state.pages.project_name = config.pages.project_name + state.pages.subdomain = "demo-pages.pages.dev" + state.pages.custom_domain = config.pages.custom_domain + state.pages.cname_record_id = "dns-cname" + state.d1.database_id = "db-1" + state.d1.database_name = config.d1.database_name + state.worker.script_name = config.worker.script_name + state.worker.workers_dev_url = "https://email-api.acct-workers.workers.dev" + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClientAuthLimitedNoDnsTemplate, + is_resume=True, + ) + + assert result.checkpoint == "acceptance_completed" + assert sorted(result.email_routing.dns_record_ids) == [ + "mx-13", + "mx-24", + "mx-86", + "txt-default", + ] + assert result.email_routing.catch_all_worker == "email-api" + assert sleep_calls == [30] + + +def test_run_deployment_retries_catch_all_before_success(monkeypatch, tmp_path: Path) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + state = DeploymentState(checkpoint="worker_completed") + state.source.source_dir = str(source_dir) + state.source.commit_sha = "abc123" + state.pages.project_id = "proj-1" + state.pages.project_name = config.pages.project_name + state.pages.subdomain = "demo-pages.pages.dev" + state.pages.custom_domain = config.pages.custom_domain + state.pages.cname_record_id = "dns-cname" + state.d1.database_id = "db-1" + state.d1.database_name = config.d1.database_name + state.worker.script_name = config.worker.script_name + state.worker.workers_dev_url = "https://email-api.acct-workers.workers.dev" + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClientCatchAllFlaky, + is_resume=True, + ) + + assert result.checkpoint == "acceptance_completed" + assert result.email_routing.catch_all_worker == "email-api" + assert sleep_calls == [30, 10, 10] + + +def test_run_deployment_can_defer_catch_all_until_acceptance(monkeypatch, tmp_path: Path) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + state = DeploymentState(checkpoint="worker_completed") + state.source.source_dir = str(source_dir) + state.source.commit_sha = "abc123" + state.pages.project_id = "proj-1" + state.pages.project_name = config.pages.project_name + state.pages.subdomain = "demo-pages.pages.dev" + state.pages.custom_domain = config.pages.custom_domain + state.pages.cname_record_id = "dns-cname" + state.d1.database_id = "db-1" + state.d1.database_name = config.d1.database_name + state.worker.script_name = config.worker.script_name + state.worker.workers_dev_url = "https://email-api.acct-workers.workers.dev" + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClientCatchAllDeferred, + is_resume=True, + ) + + assert result.checkpoint == "acceptance_completed" + assert result.email_routing.catch_all_enabled is True + assert result.email_routing.catch_all_worker == "email-api" + assert sleep_calls == [30, 10, 10] + + +def test_run_deployment_resume_recreates_missing_pages_project(monkeypatch, tmp_path: Path) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + state = DeploymentState(checkpoint="worker_completed") + state.source.source_dir = str(source_dir) + state.source.commit_sha = "abc123" + state.pages.project_id = "missing-project" + state.pages.project_name = config.pages.project_name + state.pages.subdomain = "missing.pages.dev" + state.pages.custom_domain = config.pages.custom_domain + state.pages.cname_record_id = "stale-cname" + state.d1.database_id = "db-1" + state.d1.database_name = config.d1.database_name + state.worker.script_name = config.worker.script_name + state.worker.workers_dev_url = "https://email-api.acct-workers.workers.dev" + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClientMissingPagesProject, + is_resume=True, + ) + + assert result.checkpoint == "acceptance_completed" + assert result.pages.project_id == "proj-1" + assert result.pages.subdomain == "demo-pages.pages.dev" + assert result.pages.cname_record_id == "dns-cname" + assert sleep_calls == [30] + + +def test_run_deployment_resume_redeploys_missing_worker(monkeypatch, tmp_path: Path) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + state = DeploymentState(checkpoint="worker_completed") + state.source.source_dir = str(source_dir) + state.source.commit_sha = "abc123" + state.pages.project_id = "proj-1" + state.pages.project_name = config.pages.project_name + state.pages.subdomain = "demo-pages.pages.dev" + state.pages.custom_domain = config.pages.custom_domain + state.pages.cname_record_id = "dns-cname" + state.d1.database_id = "db-1" + state.d1.database_name = config.d1.database_name + state.worker.script_name = config.worker.script_name + state.worker.workers_dev_url = "https://email-api.acct-workers.workers.dev" + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClientMissingWorkerScript, + is_resume=True, + ) + + command_args = [args for args, _, _ in runner.commands] + assert ("npm", "exec", "--", "wrangler", "deploy", "--minify") in command_args + assert result.checkpoint == "acceptance_completed" + assert result.worker.script_name == "email-api" + assert sleep_calls == [30] + + +def test_run_deployment_resume_recreates_missing_d1(monkeypatch, tmp_path: Path) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + state = DeploymentState(checkpoint="worker_completed") + state.source.source_dir = str(source_dir) + state.source.commit_sha = "abc123" + state.pages.project_id = "proj-1" + state.pages.project_name = config.pages.project_name + state.pages.subdomain = "demo-pages.pages.dev" + state.pages.custom_domain = config.pages.custom_domain + state.pages.cname_record_id = "dns-cname" + state.d1.database_id = "stale-db" + state.d1.database_name = config.d1.database_name + state.worker.script_name = config.worker.script_name + state.worker.workers_dev_url = "https://email-api.acct-workers.workers.dev" + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClientMissingD1, + is_resume=True, + ) + + command_args = [args for args, _, _ in runner.commands] + assert any(args[:6] == ("npm", "exec", "--", "wrangler", "d1", "execute") for args in command_args) + assert result.checkpoint == "acceptance_completed" + assert result.d1.database_id == "db-1" + worker_wrangler = (source_dir / "worker" / "wrangler.toml").read_text(encoding="utf-8") + assert 'database_id = "db-1"' in worker_wrangler + assert sleep_calls == [30] + + +def test_run_deployment_syncs_linuxdo_oauth_settings(monkeypatch, tmp_path: Path) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + config.linuxdo.linuxdo_oauth = True + config.linuxdo.client_id = "linuxdo-client-id" + config.linuxdo.client_secret = "linuxdo-client-secret" + config.user_access.require_login_to_create = False + state = DeploymentState() + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + config_path.write_text( + '\n'.join( + [ + "[source]", + f'local_path = "{source_dir}"', + 'mode = "local"', + "", + "[cloudflare]", + 'zone_name = "example.com"', + 'api_token = "token-value"', + "", + "[mail]", + 'domains = ["email.example.com"]', + 'verified_destination_address = "verified@example.com"', + "", + "[d1]", + 'database_name = "temp-email"', + "", + "[user_access]", + "require_login_to_create = false", + "allow_user_register = false", + "", + "[linuxdo]", + "linuxdo_oauth = true", + 'client_id = "linuxdo-client-id"', + 'client_secret = "linuxdo-client-secret"', + "", + "[worker]", + 'script_name = "email-api"', + 'custom_domain = "email-api.example.com"', + "", + "[worker.vars]", + 'PREFIX = "tmp"', + 'ENABLE_USER_CREATE_EMAIL = true', + 'ENABLE_USER_DELETE_EMAIL = true', + 'DEFAULT_LANG = "zh"', + 'ADMIN_PASSWORDS = ["admin-secret"]', + "", + "[worker.secrets]", + 'JWT_SECRET = "secret-value"', + "", + "[pages]", + 'project_name = "demo-pages"', + 'custom_domain = "email.example.com"', + 'build_mode = "pages"', + 'production_branch = "production"', + "", + ] + ), + encoding="utf-8", + ) + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClient, + ) + + config_text = config_path.read_text(encoding="utf-8") + + assert result.checkpoint == "acceptance_completed" + assert result.application.linuxdo_oauth_enabled is True + assert result.application.require_login_to_create is True + assert result.application.linuxdo_redirect_url == "https://email.example.com/user/oauth2/callback" + assert "require_login_to_create = true" in config_text + assert FakeApplicationAdminClient.user_settings["enable"] is False + assert len(FakeApplicationAdminClient.oauth2_settings) == 1 + assert FakeApplicationAdminClient.oauth2_settings[0]["clientID"] == "linuxdo-client-id" + assert FakeApplicationAdminClient.oauth2_settings[0]["redirectURL"] == "https://email.example.com/user/oauth2/callback" + assert sleep_calls == [30] + + +def test_run_deployment_allows_empty_verified_destination_address(monkeypatch, tmp_path: Path) -> None: + source_dir = tmp_path / "source" + create_source_tree(source_dir) + config = build_config(source_dir) + config.mail.verified_destination_address = "" + state = DeploymentState() + state_path = tmp_path / ".deploy" / "state.toml" + config_path = tmp_path / "config.toml" + runner = FakeRunner() + sleep_calls: list[int] = [] + + patch_deployment_runtime(monkeypatch, source_dir) + monkeypatch.setattr(deployment_module.time, "sleep", sleep_calls.append) + + result = run_deployment( + config_path=config_path, + config=config, + state_path=state_path, + state=state, + runner=runner, + cloudflare_client_factory=FakeCloudflareClient, + ) + + assert result.checkpoint == "acceptance_completed" + assert result.email_routing.destination_address == "" + assert result.email_routing.catch_all_enabled is True + assert result.email_routing.catch_all_worker == "email-api" + assert sleep_calls == [30] diff --git a/tests/test_environment.py b/tests/test_environment.py new file mode 100644 index 0000000..a7e7fd5 --- /dev/null +++ b/tests/test_environment.py @@ -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") + diff --git a/tests/test_source.py b/tests/test_source.py new file mode 100644 index 0000000..44e6538 --- /dev/null +++ b/tests/test_source.py @@ -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 diff --git a/tests/test_subprocess_runner.py b/tests/test_subprocess_runner.py new file mode 100644 index 0000000..7a82b02 --- /dev/null +++ b/tests/test_subprocess_runner.py @@ -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)"))) + diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0f7bb51 --- /dev/null +++ b/uv.lock @@ -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" }, +]