From 1ccb4e58d2cdbe81c906afc5934cbaa79b5f443a Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:34:57 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .cursor/rules/python-rule.mdc | 7 + .github/workflows/ci.yml | 44 + .github/workflows/publish.yml | 40 + .gitignore | 58 + CONTRIBUTING.md | 53 + LICENSE | 201 ++ README.md | 1530 +++++++++++++ README.wehub.md | 7 + conf/config.yaml | 34 + docs/configuration.md | 579 +++++ docs/dependency-injection.md | 627 +++++ docs/metrics.md | 125 + docs/multi-worker.md | 136 ++ docs/pypi-publishing.md | 162 ++ docs/rest-api-async-tasks.md | 505 ++++ docs/rest-api-response-format.md | 263 +++ docs/scheduler.md | 271 +++ docs/scheduler_refactor_analysis.md | 354 +++ examples/convention_app.py | 440 ++++ examples/dependency_injection_example.py | 206 ++ myboot/__init__.py | 16 + myboot/cli.py | 531 +++++ myboot/core/__init__.py | 33 + myboot/core/_internal/__init__.py | 10 + myboot/core/_internal/ast_analyzer.py | 99 + myboot/core/_internal/component_scanner.py | 123 + myboot/core/_internal/scan_cache.py | 96 + myboot/core/application.py | 835 +++++++ myboot/core/auto_configuration.py | 962 ++++++++ myboot/core/config.py | 226 ++ myboot/core/container.py | 198 ++ myboot/core/decorators.py | 436 ++++ myboot/core/di/__init__.py | 17 + myboot/core/di/container.py | 232 ++ myboot/core/di/decorators.py | 147 ++ myboot/core/di/providers.py | 73 + myboot/core/di/registry.py | 290 +++ myboot/core/logger.py | 416 ++++ myboot/core/scheduler.py | 627 +++++ myboot/core/server.py | 451 ++++ myboot/exceptions.py | 154 ++ myboot/jobs/__init__.py | 11 + myboot/jobs/scheduled_job.py | 203 ++ myboot/metrics.py | 344 +++ myboot/utils/__init__.py | 30 + myboot/utils/async_utils.py | 187 ++ myboot/utils/common.py | 340 +++ myboot/utils/naming.py | 29 + myboot/utils/path_utils.py | 88 + myboot/utils/worker_sync.py | 187 ++ myboot/web/__init__.py | 17 + myboot/web/decorators.py | 286 +++ myboot/web/exceptions.py | 231 ++ myboot/web/middleware.py | 327 +++ myboot/web/models.py | 204 ++ myboot/web/response.py | 173 ++ pyproject.toml | 170 ++ tests/__init__.py | 3 + tests/conftest.py | 41 + tests/integration/__init__.py | 0 .../integration/fixtures/multiworker/main.py | 57 + .../fixtures/multiworker/mwapp/__init__.py | 1 + .../fixtures/multiworker/mwapp/api.py | 27 + .../fixtures/multiworker/mwapp/clients.py | 15 + .../fixtures/multiworker/mwapp/hooks.py | 33 + .../fixtures/multiworker/mwapp/jobs.py | 30 + tests/integration/test_metrics_multiworker.py | 93 + .../test_multiworker_integration.py | 240 ++ tests/unit/__init__.py | 0 tests/unit/test_all_workers_jobs.py | 173 ++ tests/unit/test_client_auto_close.py | 110 + tests/unit/test_config.py | 598 +++++ tests/unit/test_di.py | 498 ++++ tests/unit/test_logger.py | 649 ++++++ tests/unit/test_metrics.py | 285 +++ tests/unit/test_multiworker.py | 631 +++++ tests/unit/test_scheduler.py | 336 +++ tests/unit/test_web.py | 698 ++++++ tests/unit/test_worker_sync.py | 135 ++ uv.lock | 2039 +++++++++++++++++ 80 files changed, 21133 insertions(+) create mode 100644 .cursor/rules/python-rule.mdc create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 conf/config.yaml create mode 100644 docs/configuration.md create mode 100644 docs/dependency-injection.md create mode 100644 docs/metrics.md create mode 100644 docs/multi-worker.md create mode 100644 docs/pypi-publishing.md create mode 100644 docs/rest-api-async-tasks.md create mode 100644 docs/rest-api-response-format.md create mode 100644 docs/scheduler.md create mode 100644 docs/scheduler_refactor_analysis.md create mode 100644 examples/convention_app.py create mode 100644 examples/dependency_injection_example.py create mode 100644 myboot/__init__.py create mode 100644 myboot/cli.py create mode 100644 myboot/core/__init__.py create mode 100644 myboot/core/_internal/__init__.py create mode 100644 myboot/core/_internal/ast_analyzer.py create mode 100644 myboot/core/_internal/component_scanner.py create mode 100644 myboot/core/_internal/scan_cache.py create mode 100644 myboot/core/application.py create mode 100644 myboot/core/auto_configuration.py create mode 100644 myboot/core/config.py create mode 100644 myboot/core/container.py create mode 100644 myboot/core/decorators.py create mode 100644 myboot/core/di/__init__.py create mode 100644 myboot/core/di/container.py create mode 100644 myboot/core/di/decorators.py create mode 100644 myboot/core/di/providers.py create mode 100644 myboot/core/di/registry.py create mode 100644 myboot/core/logger.py create mode 100644 myboot/core/scheduler.py create mode 100644 myboot/core/server.py create mode 100644 myboot/exceptions.py create mode 100644 myboot/jobs/__init__.py create mode 100644 myboot/jobs/scheduled_job.py create mode 100644 myboot/metrics.py create mode 100644 myboot/utils/__init__.py create mode 100644 myboot/utils/async_utils.py create mode 100644 myboot/utils/common.py create mode 100644 myboot/utils/naming.py create mode 100644 myboot/utils/path_utils.py create mode 100644 myboot/utils/worker_sync.py create mode 100644 myboot/web/__init__.py create mode 100644 myboot/web/decorators.py create mode 100644 myboot/web/exceptions.py create mode 100644 myboot/web/middleware.py create mode 100644 myboot/web/models.py create mode 100644 myboot/web/response.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/fixtures/multiworker/main.py create mode 100644 tests/integration/fixtures/multiworker/mwapp/__init__.py create mode 100644 tests/integration/fixtures/multiworker/mwapp/api.py create mode 100644 tests/integration/fixtures/multiworker/mwapp/clients.py create mode 100644 tests/integration/fixtures/multiworker/mwapp/hooks.py create mode 100644 tests/integration/fixtures/multiworker/mwapp/jobs.py create mode 100644 tests/integration/test_metrics_multiworker.py create mode 100644 tests/integration/test_multiworker_integration.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_all_workers_jobs.py create mode 100644 tests/unit/test_client_auto_close.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_di.py create mode 100644 tests/unit/test_logger.py create mode 100644 tests/unit/test_metrics.py create mode 100644 tests/unit/test_multiworker.py create mode 100644 tests/unit/test_scheduler.py create mode 100644 tests/unit/test_web.py create mode 100644 tests/unit/test_worker_sync.py create mode 100644 uv.lock diff --git a/.cursor/rules/python-rule.mdc b/.cursor/rules/python-rule.mdc new file mode 100644 index 0000000..037cf9a --- /dev/null +++ b/.cursor/rules/python-rule.mdc @@ -0,0 +1,7 @@ +--- +alwaysApply: true +--- + +- 生成文档除了 README.md 以外其他都放在 docs 目录 +- 单元测试文件存放在 docs +- 不要自动生成测试文件,只有在用户手动提示的情况下再自动创建 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8cf19b8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main, "optimize/**"] + pull_request: + branches: [main] + +jobs: + test: + name: pytest (py${{ matrix.python-version }} / ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --extra dev --extra test + + - name: Run unit tests + run: uv run pytest tests/unit -q --cov=myboot --cov-report=term-missing + + - name: Run integration tests + shell: bash + # 退出码 5 = 未收集到用例(集成测试目录暂为空时不视为失败) + run: uv run pytest tests/integration -q -m integration || [ $? -eq 5 ] + + - name: Upload coverage report + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: .coverage + include-hidden-files: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..65dbbca --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" # 匹配所有以 v 开头的 tag,如 v1.0.0, v0.1.0 + +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # 用于 trusted publishing (OIDC) + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build hatchling + + - name: Build distribution packages + run: | + python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + # 使用 trusted publishing (推荐方式,无需 token) + # 需要在 PyPI 上配置 trusted publishing + # 参考: https://docs.pypi.org/trusted-publishers/ + print-hash: true # 显示上传文件的哈希值,便于验证 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..892eed7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# 虚拟环境 +venv/ +ENV/ +env/ +.venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# 日志 +logs/ +*.log + +# 数据库 +*.db +*.sqlite +*.sqlite3 + +# 配置文件(可能包含敏感信息) +.env +.env.local + +# 测试 +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# 系统文件 +.DS_Store +Thumbs.db +.myboot_cache_*.json + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..05a8129 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# 贡献指南 + +感谢你对 MyBoot 的关注!欢迎通过 Issue 和 Pull Request 参与贡献。 + +## 开发环境 + +- Python 3.9+(CI 覆盖 3.9–3.13) +- [uv](https://github.com/astral-sh/uv) 作为包管理器 + +```bash +git clone https://github.com/TrumanDu/myboot.git +cd myboot +uv sync --extra dev --extra test +``` + +## 运行测试 + +```bash +# 全量单元测试 +uv run pytest tests/unit -q + +# 含覆盖率 +uv run pytest tests/unit -q --cov=myboot --cov-report=term-missing + +# 集成测试(涉及真实多进程,较慢) +uv run pytest tests/integration -q -m integration +``` + +提交 PR 前请确保 `tests/unit` 全部通过。CI 会在 ubuntu/windows × Python 3.9–3.13 矩阵上运行。 + +## 测试约定 + +- `tests/unit/` 中包含**特征测试**(characterization tests):固化框架公开行为,作为兼容性闸门。如果你的改动有意变更某个公开行为,请同步更新对应测试并在注释中说明原因(参考现有 `issue #14` 风格的注释)。 +- 修复 bug 时请附带回归测试。 + +## 代码风格 + +- 遵循现有代码风格(中文 docstring、loguru 日志) +- 格式化:`uv run black myboot tests`,import 排序:`uv run isort myboot tests` +- 公开 API 需带类型注解 + +## 提交规范 + +- 提交信息使用 `type: subject` 格式,type 取 `feat` / `fix` / `test` / `docs` / `refactor` / `chore` +- 一个 PR 聚焦一件事;破坏性变更需在 PR 描述中明确标注 + +## 报告问题 + +提 Issue 时请附上: + +- myboot 版本、Python 版本、操作系统 +- 最小复现代码 +- 完整报错堆栈(启动时加 `logging.level: DEBUG` 获取更多信息) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..41238da --- /dev/null +++ b/README.md @@ -0,0 +1,1530 @@ +# MyBoot - 类似 Spring Boot 的 Python 快速开发框架 + +[![Python Version](https://img.shields.io/badge/python-3.9+-blue.svg)](https://python.org) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +[![PyPI Version](https://img.shields.io/pypi/v/myboot.svg)](https://pypi.org/project/myboot/) + +MyBoot 是一个功能丰富的 Python Web 框架,提供类似 Spring Boot 的自动配置和快速开发功能。它集成了 Web API、定时任务、日志管理、配置管理等核心功能,让您能够快速构建现代化的 Python 应用程序。 + +## ✨ 主要特性 + +- 🚀 **快速启动**: 类似 Spring Boot 的自动配置和快速启动 +- 🎯 **约定优于配置**: 遵循约定,减少配置工作,自动发现和注册组件 +- 🌐 **Web API**: 基于 FastAPI 的高性能 Web API 开发 +- ⚡ **高性能服务器**: 默认使用 Hypercorn 服务器,支持 HTTP/2 和多进程 +- ⏰ **定时任务**: 强大的任务调度系统,支持 Cron 表达式和间隔任务(详见 [任务调度器使用说明](docs/scheduler.md)) +- 📝 **日志管理**: 基于 loguru 的强大日志系统,支持结构化日志和第三方库日志控制 +- ⚙️ **配置管理**: 基于 Dynaconf 的强大配置系统,支持 YAML 配置、环境变量覆盖和远程配置(详见 [配置管理使用说明](docs/configuration.md)) +- 🔧 **中间件支持**: 丰富的中间件生态,包括 CORS、限流、安全等 +- 📊 **健康检查**: 内置健康检查、就绪检查和存活检查 +- 🎯 **依赖注入**: 简单的依赖注入和组件管理 +- 🔄 **优雅关闭**: 支持优雅关闭和资源清理 +- 📚 **自动文档**: 自动生成 API 文档和交互式界面 + +## 🚀 快速开始 + +### 安装 + +```bash +pip install myboot +``` + +### 命令行工具 + +MyBoot 提供了便捷的命令行工具用于初始化项目: + +```bash +# 显示帮助信息 +myboot --help + +# 初始化新项目(交互式) +myboot init + +# 使用指定模板初始化项目 +myboot init --name my-app --template basic # 基础模板 +myboot init --name my-app --template api # API 项目模板 +myboot init --name my-app --template full # 完整项目模板 + +# 显示框架信息 +myboot info +``` + +### 创建应用 + +使用 `myboot init` 初始化项目后,在 `main.py` 中创建应用: + +```python +"""main.py - 应用入口文件""" +from myboot.core.application import create_app + +# 创建应用实例 +app = create_app(name="我的应用") + +# 运行应用 +if __name__ == "__main__": + app.run() +``` + +在 `app/api/` 目录中定义路由(使用 `@rest_controller` 装饰器): + +```python +"""app/api/routes.py""" +from myboot.core.decorators import rest_controller, get, post + +@rest_controller('/api') +class HelloController: + """Hello 控制器""" + + @get('/') + def hello(self): + """Hello World 接口 - GET /api""" + return {"message": "Hello, MyBoot!", "status": "success"} + +@rest_controller('/api/users') +class UserController: + """用户控制器 - 支持依赖注入""" + + def __init__(self, user_service: UserService): + self.user_service = user_service + + @get('/{user_id}') + def get_user(self, user_id: int): + """获取用户 - GET /api/users/{user_id}""" + return self.user_service.get_user(user_id) +``` + +### 运行应用 + +应用入口文件位于项目根目录的 `main.py`: + +```bash +# 直接运行 +python main.py + +# 启用自动重载(开发环境) +python main.py --reload + +# 指定端口和主机 +python main.py --host 0.0.0.0 --port 8080 +``` + +访问 http://localhost:8000 查看您的应用! + +## 🎯 约定优于配置 + +MyBoot 框架的核心设计理念是"约定优于配置",让您能够快速开发而无需复杂的配置。 + +### 自动发现和注册 + +```python +from myboot.core.decorators import service, rest_controller, get, cron, component + +@service() +class UserService: + """用户服务 - 自动注册为 'user_service'""" + def get_user(self, user_id): + return {"id": user_id, "name": f"用户{user_id}"} + +@rest_controller('/api/users') +class UserController: + """用户控制器 - 支持依赖注入""" + + def __init__(self, user_service: UserService): + self.user_service = user_service + + @get('/{user_id}') + def get_user(self, user_id: int): + """获取用户 - GET /api/users/{user_id}""" + return self.user_service.get_user(user_id) + +@component() +class ScheduledJobs: + """定时任务组件 - 使用 @component 装饰器定义定时任务""" + + @cron('0 */5 * * * *') + def cleanup_task(self): + """清理任务 - 自动注册定时任务""" + print("执行清理任务") +``` + +### 零配置启动 + +```python +from myboot.core.application import Application + +# 创建应用,自动发现和配置所有组件 +app = Application( + name="我的应用", + auto_configuration=True, # 启用自动配置 + auto_discover_package="app" # 自动发现 app 包 +) + +# 直接运行,无需手动注册 +app.run() +``` + +### 依赖注入和服务管理 + +MyBoot 提供了基于 `dependency_injector` 的强大依赖注入机制,支持自动依赖解析和注入,让您可以轻松管理服务之间的依赖关系。 + +#### 自动依赖注入 + +框架会自动检测服务的依赖关系并自动注入,无需手动获取: + +```python +from myboot.core.decorators import service + +@service() +class UserService: + def __init__(self): + self.users = {} + +@service() +class EmailService: + def send_email(self, to: str, subject: str): + print(f"发送邮件到 {to}: {subject}") + +@service() +class OrderService: + # 自动注入 UserService 和 EmailService + def __init__(self, user_service: UserService, email_service: EmailService): + self.user_service = user_service + self.email_service = email_service + + def create_order(self, user_id: int): + user = self.user_service.get_user(user_id) + self.email_service.send_email(user['email'], "订单创建", "您的订单已创建") +``` + +**特性:** + +- ✅ 自动检测依赖关系 +- ✅ 自动处理依赖顺序 +- ✅ 支持多级依赖 +- ✅ 支持可选依赖(`Optional[Type]`) +- ✅ 自动检测循环依赖 +- ✅ 向后兼容,现有代码无需修改 + +**更多信息:** 查看 [依赖注入使用指南](docs/dependency-injection.md) + +#### 获取服务 (get_service) + +服务是通过 `@service()` 装饰器自动注册的。**推荐方式:在控制器构造函数中通过类型注解自动注入。** + +**方式一:依赖注入(推荐)** + +```python +from myboot.core.decorators import rest_controller, get, service + +@service() +class UserService: + def get_user(self, user_id: int): + return {"user_id": user_id} + +@rest_controller('/api/users') +class UserController: + def __init__(self, user_service: UserService): + # 通过构造函数自动注入服务 + self.user_service = user_service + + @get('/{user_id}') + def get_user(self, user_id: int): + return self.user_service.get_user(user_id) +``` + +**方式二:通过全局函数(适用于非控制器场景)** + +```python +from myboot.core.application import get_service + +# 在启动钩子或其他地方获取服务 +def some_function(): + user_service = get_service('user_service') + return user_service.get_user(1) +``` + +#### 获取客户端 (get_client) + +客户端是通过 `@client()` 装饰器自动注册的。**推荐方式:在控制器构造函数中通过类型注解自动注入。** + +**方式一:依赖注入(推荐)** + +```python +from myboot.core.decorators import rest_controller, get, client + +@client() +class RedisClient: + def get(self, key: str): + return None + +@rest_controller('/api/products') +class ProductController: + def __init__(self, redis_client: RedisClient): + # 通过构造函数自动注入客户端 + self.redis_client = redis_client + + @get('/') + def get_products(self): + cache_data = self.redis_client.get('products') + return {"products": cache_data or []} +``` + +**方式二:通过全局函数(适用于非控制器场景)** + +```python +from myboot.core.application import get_client + +# 在启动钩子或其他地方获取客户端 +def some_function(): + redis_client = get_client('redis_client') + return redis_client.get('products') +``` + +#### 完整示例 + +```python +from myboot.core.decorators import service, client, rest_controller, get, post + +# 定义服务 +@service() +class UserService: + """用户服务 - 自动注册为 'user_service'""" + def get_user(self, user_id: int): + return {"id": user_id, "name": f"用户{user_id}"} + + def create_user(self, name: str, email: str): + return {"name": name, "email": email} + +@service() +class EmailService: + """邮件服务""" + def send_email(self, to: str, subject: str, body: str): + print(f"发送邮件到 {to}") + +# 定义客户端 +@client('redis_client') +class RedisClient: + """Redis 客户端 - 注册为 'redis_client'""" + def get(self, key: str): + return None + +# 控制器中使用依赖注入 +@rest_controller('/api/users') +class UserController: + """用户控制器 - 自动注入服务和客户端""" + + def __init__(self, user_service: UserService, email_service: EmailService, redis_client: RedisClient): + self.user_service = user_service + self.email_service = email_service + self.redis_client = redis_client + + @get('/{user_id}') + def get_user(self, user_id: int): + """获取用户""" + # 先检查缓存 + cache_key = f"user:{user_id}" + cached = self.redis_client.get(cache_key) + if cached: + return cached + return self.user_service.get_user(user_id) + + @post('/') + def create_user(self, name: str, email: str): + """创建用户""" + user = self.user_service.create_user(name, email) + self.email_service.send_email(email, "欢迎", f"欢迎 {name}") + return {"message": "用户创建成功", "user": user} +``` + +#### 服务命名规则 + +- **默认命名**: 如果未指定名称,服务名会自动转换为类名的小写形式,并使用下划线分隔 + - `UserService` → `'user_service'` + - `EmailService` → `'email_service'` + - `DatabaseClient` → `'database_client'` + - `RedisClient` → `'redis_client'` +- **自定义命名**: 可以通过装饰器参数指定名称 + - `@service('email_service')` → `'email_service'` + - `@client('redis_client')` → `'redis_client'` + +#### 注意事项 + +1. **推荐依赖注入**: 在控制器中推荐使用构造函数依赖注入,代码更清晰、可测试性更好 +2. **服务必须已注册**: 确保服务或客户端已经通过装饰器注册 +3. **全局函数适用场景**: `get_service()` 和 `get_client()` 适用于启动钩子、工具函数等非控制器场景 +4. **路由定义**: 所有路由必须在 `@rest_controller` 装饰的类中定义 + +### 约定规则 + +- **服务命名**: 类名自动转换为下划线分隔的小写形式作为服务名(如 `UserService` → `user_service`) +- **路由映射**: 使用 `@rest_controller` 装饰器定义路由,方法装饰器 `@get`、`@post` 等定义具体端点 +- **任务调度**: 在 `@component` 类中使用 `@cron`、`@interval`、`@once` 装饰器(详见 [任务调度器使用说明](docs/scheduler.md)) +- **组件扫描**: 自动扫描指定包中的所有组件 + +## ⚡ 高性能服务器 + +MyBoot 默认使用 Hypercorn 作为 ASGI 服务器,提供卓越的性能和特性: + +### 服务器特性 + +- **高性能**: 基于 Hypercorn 的高性能 ASGI 服务器 +- **HTTP/2 支持**: 支持现代 HTTP 协议 +- **WebSocket 支持**: 支持实时通信 +- **多进程支持**: 支持多工作进程,适合生产环境 +- **自动重载**: 开发环境支持自动重载 +- **优雅关闭**: 支持优雅关闭和资源清理 + +### 使用示例 + +```python +from myboot.core.application import Application + +# 创建应用 +app = Application(name="我的应用") + +# 开发环境(单进程 + 自动重载) +app.run(host="0.0.0.0", port=8000, reload=True, workers=1) + +# 生产环境(多进程) +app.run(host="0.0.0.0", port=8000, workers=4) + +# 或者直接运行 main.py +# python main.py --reload # 开发环境 +# python main.py --workers 4 # 生产环境 +``` + +## ⚙️ 配置管理 + +MyBoot 使用 Dynaconf 提供强大的配置管理功能。完整说明(文件优先级、环境变量 `__` 规则、常用配置项)见 [配置管理使用说明](docs/configuration.md)。 + +### 基本使用 + +```python +from myboot.core.config import get_settings, get_config + +# 直接使用 Dynaconf settings(自动查找配置文件) +settings = get_settings() +app_name = settings.app.name +server_port = settings.server.port + +# 使用便捷函数 +database_url = get_config('database.url', 'sqlite:///./app.db') +debug_mode = get_config('app.debug', False) + +# 指定配置文件路径 +settings = get_settings('custom_config.yaml') + +# 通过环境变量指定配置文件 +# export CONFIG_FILE=/path/to/config.yaml +# 或 +# export CONFIG_FILE=https://example.com/config.yaml +``` + +### 环境变量覆盖 + +环境变量可以直接覆盖配置值(使用 `__` 作为分隔符),优先级高于所有配置文件: + +```bash +# 使用环境变量覆盖配置值 +export APP__NAME="MyApp" +export SERVER__PORT=9000 +export LOGGING__LEVEL=DEBUG + +# 嵌套配置使用双下划线分隔 +export SERVER__CORS__ALLOW_ORIGINS='["http://localhost:3000"]' +``` + +**注意**:环境变量覆盖配置值的优先级最高,会覆盖所有配置文件中的对应值。 + +### 远程配置 + +```python +from myboot.core.config import get_settings + +# 从远程 URL 加载配置 +settings = get_settings('https://example.com/config.yaml') +``` + +### 配置优先级 + +MyBoot 按照以下优先级查找和加载配置文件: + +1. **环境变量 `CONFIG_FILE`**(最高优先级) + + - 通过环境变量指定配置文件路径或 URL + + ```bash + export CONFIG_FILE=/path/to/config.yaml + # 或 + export CONFIG_FILE=https://example.com/config.yaml + ``` + +2. **参数指定的配置文件** + + - 通过 `create_app()` 或 `get_settings()` 的 `config_file` 参数指定 + + ```python + app = create_app(name="我的应用", config_file="custom_config.yaml") + ``` + +3. **项目根目录 `/conf` 目录下的配置文件** + + - `项目根目录/conf/config.yaml` + - `项目根目录/conf/config.yml` + +4. **项目根目录下的配置文件** + + - `项目根目录/config.yaml` + - `项目根目录/config.yml` + +5. **默认配置** + - 内置的默认配置值 + +**注意**:环境变量还可以直接覆盖配置值(使用 `__` 作为分隔符),优先级高于所有配置文件: + +```bash +export APP__NAME="MyApp" +export SERVER__PORT=9000 +export LOGGING__LEVEL=DEBUG +``` + +## 📖 详细文档 + +- [📚 完整文档](docs/README.md) - 文档中心 +- [⚡ REST API 异步任务](docs/rest-api-async-tasks.md) - REST API 中使用异步任务指南 +- [🔧 依赖注入](docs/dependency-injection.md) - 依赖注入使用指南 +- [⏰ 任务调度器](docs/scheduler.md) - Cron / 间隔 / 一次性任务与配置说明 +- [⚙️ 配置管理](docs/configuration.md) - YAML、环境变量、.env 与远程配置优先级 +- [🧩 多 Worker 模式](docs/multi-worker.md) - 作用域、worker 钩子、primary-first 协调、任务级 all_workers +- [📊 Metrics 指南](docs/metrics.md) - Prometheus 指标、多进程聚合、自定义指标 API + +## 🐳 Docker 部署 + +`myboot init`(`api` / `full` 模板)会自动生成 `Dockerfile`、`.dockerignore` +和 `docker-compose.yaml`,开箱即可容器化: + +```bash +docker build -t my-app . +docker run -p 8000:8000 my-app + +# 或用 compose +docker compose up --build +``` + +`Dockerfile` 采用多阶段构建(uv 装依赖 → python:3.12-slim 运行)。容器中可用 +环境变量覆盖配置(`__` 为层级分隔符),无需改镜像: + +```bash +docker run -p 8080:8080 -e SERVER__PORT=8080 -e LOGGING__LEVEL=DEBUG my-app +``` + +## 🧩 多 Worker 能力(0.2.0+) + +多 worker 模式下每个 worker 进程拥有独立的 service / client 实例(issue #11)。 +完整说明见 [多 Worker 模式指南](docs/multi-worker.md),能力速览: + +- **作用域**:`@service(scope="singleton"|"request"|"factory")` 声明实例生命周期; +- **Worker 钩子**:`@on_worker_start` / `@on_worker_stop` 每个 worker 各触发一次; +- **Client 自动清理**:client 定义 `close()` 方法即可在 worker 停止时被框架自动调用,建连可直接写在 `__init__`; +- **Primary-first 协调**:`run_primary_first(name, primary_fn, secondary_fn)`——primary 干重活(如下载模型),其余 worker 等完成后轻量加载; +- **任务级 all_workers**:`@interval(hours=24, all_workers=True)` 让单个定时任务在每个 worker 都执行(如刷新进程内缓存),其余任务保持仅 primary 执行一份。 + +## 📊 Prometheus Metrics(0.2.0+) + +`pip install myboot[metrics]` 后配置 `metrics.enabled: true` 即自动获得 +`/metrics` 端点与 HTTP 请求指标,多 worker 自动聚合;业务代码可用 +`get_counter` / `time_stage` 记录自定义指标。详见 [Metrics 指南](docs/metrics.md)。 + +另外 0.2.0 起框架**自动加载项目根目录 `.env`**(真实环境变量优先), +`main.py` 无需手动 `load_dotenv()`,详见 [配置管理](docs/configuration.md)。 + +## ⬆️ 从 0.1.x 升级到 0.2.0 + +本次升级保持公开 API 兼容、不影响现有功能,仅以下两处为有意的破坏性变更: + +1. **定时任务默认 job_id 格式变更**:未显式指定 `job_id` 时,默认 ID 从 + `cron_{函数名}` 改为 `cron_{模块名}.{限定名}`(`interval_`/`date_` 同理)。 + 不同类中的同名方法不再冲突。若你的代码按旧格式硬编码 ID 调用 + `remove_job` / `get_job_info`,需改用新格式(或在注册时显式传入 `job_id`)。 +2. **异常类收敛**:`ValidationError`、`ConfigurationError` 统一为 + `myboot.exceptions` 中的定义,`myboot.web.exceptions` 仍可 import(同一类)。 + `myboot.web.exceptions.ValidationError` 旧有的 `error_type` 参数已移除, + 以核心版签名 `(message, field, value, details)` 为准。 + +其余修复(多 worker 实例独立化、内置配置默认值生效、`create_http_exception` +/ 模块级函数路由 / `get_job_info` 等死路径修复、logger 同时支持 `%s` 与 `{}` +占位符)均为行为修复,不需要改动你的代码。 + +### 1. Web API 开发 + +**重要**:路由必须在 `@rest_controller` 装饰的类中定义,支持依赖注入。 + +#### REST 控制器(推荐方式) + +```python +from myboot.core.decorators import rest_controller, get, post, put, delete, service +from myboot.web.models import BaseResponse + + +@service() +class UserService: + """用户服务""" + def get_users(self): + return [] + + def get_user(self, user_id: int): + return {"user_id": user_id, "name": f"用户{user_id}"} + + def create_user(self, name: str, email: str): + return {"name": name, "email": email} + + def update_user(self, user_id: int, **kwargs): + return {"user_id": user_id, **kwargs} + + def delete_user(self, user_id: int): + return {"user_id": user_id} + + +@rest_controller('/api/users') +class UserController: + """用户控制器 - 自动注入 UserService""" + + def __init__(self, user_service: UserService): + self.user_service = user_service + + @get('/') + def get_users(self): + """获取用户列表 - GET /api/users""" + users = self.user_service.get_users() + return BaseResponse(success=True, message="获取用户列表成功", data={"users": users}) + + @get('/{user_id}') + def get_user(self, user_id: int): + """获取单个用户 - GET /api/users/{user_id}""" + user = self.user_service.get_user(user_id) + return BaseResponse(success=True, message="获取用户成功", data=user) + + @post('/') + def create_user(self, name: str, email: str): + """创建用户 - POST /api/users""" + user = self.user_service.create_user(name, email) + return BaseResponse(success=True, message="用户创建成功", data=user) + + @put('/{user_id}') + def update_user(self, user_id: int, name: str = None, email: str = None): + """更新用户 - PUT /api/users/{user_id}""" + update_data = {} + if name: + update_data['name'] = name + if email: + update_data['email'] = email + user = self.user_service.update_user(user_id, **update_data) + return BaseResponse(success=True, message=f"用户 {user_id} 更新成功", data=user) + + @delete('/{user_id}') + def delete_user(self, user_id: int): + """删除用户 - DELETE /api/users/{user_id}""" + user = self.user_service.delete_user(user_id) + return BaseResponse(success=True, message=f"用户 {user_id} 删除成功", data=user) + + +# 约定优于配置说明: +# 1. 使用 @rest_controller 装饰器定义控制器类和基础路径 +# 2. 使用 @get, @post, @put, @delete 装饰器定义路由方法 +# 3. 构造函数参数自动进行依赖注入 +# 4. 框架自动发现和注册控制器 +# 5. 统一的响应格式和错误处理 +``` + +#### REST 控制器 + +使用 `@rest_controller` 装饰器可以创建 REST 控制器类,为类中的方法提供统一的基础路径。类中的方法需要显式使用 `@get`、`@post`、`@put`、`@delete`、`@patch` 等装饰器才会生成路由。 + +**基本用法:** + +```python +from myboot.core.decorators import rest_controller, get, post, put, delete +from myboot.web.models import BaseResponse + +@rest_controller('/api/users') +class UserController: + """用户控制器""" + + def __init__(self): + # 可以在这里初始化服务、客户端等 + pass + + @get('/') + def list_users(self): + """获取用户列表 - GET /api/users""" + return BaseResponse( + success=True, + message="获取用户列表成功", + data={"users": []} + ) + + @get('/{user_id}') + def get_user(self, user_id: int): + """获取单个用户 - GET /api/users/{user_id}""" + return BaseResponse( + success=True, + message="获取用户成功", + data={"user_id": user_id, "name": f"用户{user_id}"} + ) + + @post('/') + def create_user(self, name: str, email: str): + """创建用户 - POST /api/users""" + return BaseResponse( + success=True, + message="用户创建成功", + data={"name": name, "email": email} + ) + + @put('/{user_id}') + def update_user(self, user_id: int, name: str = None, email: str = None): + """更新用户 - PUT /api/users/{user_id}""" + return BaseResponse( + success=True, + message=f"用户 {user_id} 更新成功", + data={"user_id": user_id, "name": name, "email": email} + ) + + @delete('/{user_id}') + def delete_user(self, user_id: int): + """删除用户 - DELETE /api/users/{user_id}""" + return BaseResponse( + success=True, + message=f"用户 {user_id} 删除成功", + data={"user_id": user_id} + ) +``` + +**路径合并规则:** + +- 方法路径以 `//` 开头:作为绝对路径使用(去掉一个 `/`) +- 方法路径以 `/` 开头:去掉开头的 `/` 后追加到基础路径 +- 方法路径不以 `/` 开头:直接追加到基础路径 + +**示例:** + +```python +@rest_controller('/api/reports') +class ReportController: + """报告控制器""" + + @post('/generate') # 最终路径: POST /api/reports/generate + def create_report(self, report_type: str): + return {"message": "报告生成任务已创建", "type": report_type} + + @get('/status/{job_id}') # 最终路径: GET /api/reports/status/{job_id} + def get_status(self, job_id: str): + return {"status": "completed", "job_id": job_id} + + @get('//health') # 最终路径: GET /health (绝对路径) + def health_check(self): + return {"status": "ok"} +``` + +**在控制器中使用依赖注入:** + +```python +from myboot.core.decorators import rest_controller, get, post, service, client +from myboot.web.models import BaseResponse + +@service() +class ProductService: + def get_all(self): + return [] + def create(self, name: str, price: float): + return {"name": name, "price": price} + +@client() +class RedisClient: + def set(self, key: str, value): + pass + +@rest_controller('/api/products') +class ProductController: + """产品控制器 - 使用依赖注入""" + + def __init__(self, product_service: ProductService, redis_client: RedisClient): + # 通过构造函数自动注入 + self.product_service = product_service + self.redis_client = redis_client + + @get('/') + def list_products(self): + """获取产品列表""" + products = self.product_service.get_all() + return BaseResponse(success=True, data={"products": products}) + + @post('/') + def create_product(self, name: str, price: float): + """创建产品""" + product = self.product_service.create(name, price) + self.redis_client.set(f"product:{product['name']}", product) + return BaseResponse(success=True, data={"product": product}) +``` + +**注意事项:** + +1. **显式装饰器**:类中的方法必须显式使用 `@get`、`@post` 等装饰器才会生成路由 +2. **路径合并**:方法路径会自动与基础路径合并,形成最终的路由路径 +3. **自动注册**:控制器类会被自动发现和注册,无需手动配置 +4. **依赖注入**:在构造函数中声明类型注解,框架自动注入服务和客户端 + +#### 数据模型 + +```python +from pydantic import BaseModel +from typing import Optional +from myboot.core.decorators import rest_controller, post +from myboot.web.models import BaseResponse + +class User(BaseModel): + """用户数据模型""" + id: Optional[int] = None + name: str + email: str + age: Optional[int] = None + +@rest_controller('/api/users') +class UserController: + @post('/') + def create_user(self, user: User): + """创建用户""" + return BaseResponse(success=True, message="用户创建成功", data=user.dict()) +``` + +#### 分页处理 + +```python +from myboot.core.decorators import rest_controller, get +from myboot.web.models import BaseResponse +from typing import Optional + +@rest_controller('/api/users') +class UserController: + @get('/') + def get_users(self, page: int = 1, size: int = 10, search: Optional[str] = None): + """获取用户列表(分页) - GET /api/users""" + # 处理分页逻辑 + return BaseResponse( + success=True, + message="获取用户列表成功", + data={"users": [], "total": 0, "page": page, "size": size} + ) +``` + +### 2. 定时任务 + +**重要**:定时任务必须在 `@component` 装饰的类中定义,支持依赖注入。完整说明见 [任务调度器使用说明](docs/scheduler.md)。 + +#### Cron 表达式任务 + +```python +from myboot.core.decorators import component, cron, interval, once +from myboot.core.config import get_config + +@component() +class ScheduledJobs: + """定时任务组件""" + + @cron("0 0 * * * *", enabled=True) # 每小时执行 + def hourly_task(self): + print("每小时任务") + + # 从配置文件读取 enabled 状态 + @cron("0 0 2 * * *", enabled=get_config('jobs.cleanup_task.enabled', True)) + def daily_backup(self): + """每天凌晨2点执行""" + print("每日备份") +``` + +#### 间隔任务 + +```python +@component() +class MonitorJobs: + """监控任务组件""" + + @interval(seconds=30, enabled=True) # 每30秒执行 + def heartbeat(self): + print("心跳检测") + + @interval(minutes=5, enabled=get_config('jobs.monitor.enabled', True)) + def monitor(self): + """每5分钟执行""" + print("系统监控") +``` + +#### 一次性任务 + +```python +@component() +class OneTimeJobs: + """一次性任务组件""" + + @once("2025-12-31 23:59:59", enabled=True) + def new_year_task(self): + """新年任务 - 过期后不再执行""" + print("新年任务") +``` + +#### 带依赖注入的定时任务 + +```python +from myboot.core.decorators import component, service, cron + +@service() +class DataService: + def sync_data(self): + print("同步数据...") + +@component() +class DataSyncJobs: + """数据同步任务 - 自动注入 DataService""" + + def __init__(self, data_service: DataService): + self.data_service = data_service + + @cron("0 2 * * *") # 每天凌晨2点 + def sync_daily(self): + self.data_service.sync_data() +``` + +### 3. 配置管理 + +文件合并顺序、环境变量命名与 API 说明见 [配置管理使用说明](docs/configuration.md)。 + +#### 配置文件 (config.yaml) + +```yaml +# 应用配置 +app: + name: "我的应用" + version: "1.0.0" + debug: true + +# 服务器配置 +server: + host: "0.0.0.0" + port: 8000 + reload: true + +# 数据库配置 +database: + url: "sqlite:///./app.db" + pool_size: 10 + +# 任务调度配置 +scheduler: + enabled: true # 是否启用调度器 + timezone: "Asia/Shanghai" # 时区设置(可选,需要安装 pytz) + max_workers: 10 # 最大工作线程数 + +# 任务启用配置(可选) +jobs: + heartbeat: + enabled: true + cleanup_task: + enabled: false + monitor: + enabled: true + +# 日志配置 +logging: + level: "INFO" # 日志级别: DEBUG, INFO, WARNING, ERROR, CRITICAL + format: "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}" + file: "logs/app.log" # 可选,如果配置会自动添加文件 handler + # 第三方库日志级别配置 + third_party: + urllib3: "WARNING" + requests: "WARNING" + hypercorn: "WARNING" +``` + +#### 配置使用 + +```python +from myboot.core.config import get_settings, get_config, get_config_bool, get_config_str + +# 方式一:使用 get_settings() 获取完整配置对象 +settings = get_settings() +port = settings.get("server.port", 8000) +debug = settings.get("app.debug", False) + +# 方式二:使用便捷函数(推荐) +port = get_config("server.port", 8000) +debug = get_config_bool("app.debug", False) +db_url = get_config_str("database.url", "sqlite:///./app.db") + +# 在应用实例中也可以直接使用 +port = app.config.get("server.port", 8000) +``` + +#### 调度器配置 + +Cron 表达式格式、星期字段含义及多 Worker 行为见 [任务调度器使用说明](docs/scheduler.md)。 + +```yaml +# 任务调度配置 +scheduler: + enabled: true # 是否启用调度器 + timezone: "Asia/Shanghai" # 时区设置(需要安装 pytz) + max_workers: 10 # 最大工作线程数 +``` + +```python +# 获取调度器配置 +config = app.scheduler.get_config() +print(config) # {'enabled': True, 'timezone': 'Asia/Shanghai', ...} + +# 列出所有任务 +jobs = app.scheduler.list_all_jobs() +for job in jobs: + print(job) + +# 获取单个任务信息 +job_info = app.scheduler.get_job_info('cron_app.jobs.ScheduledJobs.heartbeat') # 0.2.0 起默认 ID 为 cron_{模块名}.{限定名} +print(job_info) +``` + +#### 任务启用控制 + +任务装饰器支持 `enabled` 参数,可以控制任务是否启用: + +```python +from myboot.core.decorators import component, cron, interval, once +from myboot.core.config import get_config + +@component() +class TaskControlDemo: + """任务控制示例""" + + # 方式一:直接指定 + @cron("0 */1 * * * *", enabled=True) # 启用 + def enabled_task(self): + print("启用状态") + + @interval(minutes=2, enabled=False) # 禁用 + def disabled_task(self): + print("禁用状态") + + # 方式二:从配置文件读取 + @once("2025-01-01 00:00:00", enabled=get_config('jobs.my_task.enabled', True)) + def configurable_task(self): + print("可配置任务") +``` + +**注意**: + +- 定时任务必须在 `@component` 装饰的类中定义 +- 如果 `enabled` 为 `None`,默认启用 +- 一次性任务如果时间已过期,将自动标记为过期不再执行 +- 已执行的一次性任务不会重复执行 + +### 4. 日志管理 + +MyBoot 使用 [loguru](https://github.com/Delgan/loguru) 作为日志系统,提供强大的日志功能和优雅的 API。 + +#### 基本使用 + +```python +# 使用框架导出的 logger +from myboot.core.logger import logger + +logger.info("应用启动") +logger.error("发生错误") +logger.debug("调试信息") +logger.warning("警告信息") +``` + +#### 日志配置 + +日志系统会在应用启动时自动根据配置文件初始化,无需手动配置。 + +**配置文件示例 (config.yaml):** + +```yaml +# 日志配置 +logging: + # 日志级别: DEBUG, INFO, WARNING, ERROR, CRITICAL + level: "INFO" + + # 日志格式(支持 loguru 格式或标准 logging 格式,会自动转换) + # 如果设置了 json: true,此选项将被忽略 + format: "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}" + + # 是否使用 JSON 格式输出(适用于日志聚合和分析工具) + # 设置为 true 时,日志以 JSON 格式输出,包含完整的结构化信息 + json: false + + # 日志文件路径(可选,如果配置会自动添加文件 handler,支持自动轮转) + file: "logs/app.log" + + # 第三方库日志级别配置(用于控制第三方库的日志输出) + third_party: + urllib3: "WARNING" # 只显示 WARNING 及以上级别 + requests: "WARNING" # 只显示 WARNING 及以上级别 + hypercorn: "WARNING" # 只显示 WARNING 及以上级别 + hypercorn.error: "WARNING" # hypercorn.error logger + asyncio: "INFO" # 显示 INFO 及以上级别 +``` + +#### JSON 格式日志 + +启用 JSON 格式后,日志会以结构化 JSON 格式输出,便于日志聚合和分析工具(如 ELK、Loki、Grafana 等)处理: + +```yaml +logging: + level: "INFO" + json: true # 启用 JSON 格式输出 + file: "logs/app.log" +``` + +JSON 格式日志包含以下字段: + +- `text`: 格式化的日志文本 +- `record`: 完整的日志记录对象 + - `time`: 时间戳 + - `level`: 日志级别 + - `message`: 日志消息 + - `name`: logger 名称 + - `module`: 模块名 + - `function`: 函数名 + - `file`: 文件名和路径 + - `line`: 行号 + - `process`: 进程信息 + - `thread`: 线程信息 + - `exception`: 异常信息(如果有) + +#### 日志格式说明 + +**Loguru 格式(推荐):** + +```python +format: "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}" +``` + +**标准 logging 格式(会自动转换):** + +```python +format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +``` + +#### 高级功能 + +```python +from myboot.core.logger import logger + +# 结构化日志 +logger.info("用户登录", user_id=123, username="admin") + +# 异常日志(自动包含堆栈跟踪) +try: + 1 / 0 +except: + logger.exception("发生错误") + +# 绑定上下文信息 +logger.bind(user_id=123).info("用户操作") + +# 临时修改日志级别 +with logger.contextualize(level="DEBUG"): + logger.debug("这是调试信息") + +# 添加自定义 handler(保留用户自定义 loguru 的能力) +from myboot.core.logger import logger +logger.add("custom.log", rotation="100 MB", retention="30 days") +``` + +#### 手动初始化(可选) + +如果需要手动初始化日志系统: + +```python +from myboot.core.logger import setup_logging + +# 使用默认配置初始化 +setup_logging() + +# 使用指定配置文件初始化 +setup_logging("custom_config.yaml") +``` + +#### 配置参数说明 + +| 参数 | 类型 | 说明 | 默认值 | +| ------------------------------- | ---- | -------------------------------------------------------------------- | --------------- | +| `logging.level` | str | 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL) | INFO | +| `logging.format` | str | 日志格式(支持 loguru 格式或标准 logging 格式,json 为 true 时忽略) | loguru 默认格式 | +| `logging.json` | bool | 是否使用 JSON 格式输出(适用于日志聚合和分析工具) | false | +| `logging.file` | str | 日志文件路径,如果配置会自动添加文件 handler | 无 | +| `logging.third_party.{library}` | str | 第三方库日志级别,支持设置任意第三方库的日志级别 | 无 | + +#### 文件日志特性 + +如果配置了 `logging.file`,loguru 会自动提供: + +- **自动轮转**: 当日志文件达到 10MB 时自动轮转 +- **自动压缩**: 旧日志文件自动压缩为 zip +- **自动清理**: 保留 7 天的日志文件 +- **异常信息**: 自动包含完整的堆栈跟踪 + +#### 第三方库日志控制 + +通过 `logging.third_party` 配置可以控制第三方库的日志输出级别: + +```yaml +logging: + third_party: + urllib3: "WARNING" # 隐藏 urllib3 的 INFO 和 DEBUG 日志 + requests: "WARNING" # 隐藏 requests 的 INFO 和 DEBUG 日志 + hypercorn: "WARNING" # 隐藏 hypercorn 的 INFO 和 DEBUG 日志 + asyncio: "INFO" # 只显示 asyncio 的 INFO 及以上级别 +``` + +这样可以有效减少第三方库的噪音日志,让日志更加清晰。 + +### 5. 中间件 + +MyBoot 支持通过装饰器定义中间件,中间件会自动注册: + +```python +from myboot.core.decorators import middleware +from fastapi import Request + +@middleware(order=1, path_filter='/api/*') +def api_middleware(request: Request, next_handler): + """API 中间件 - 只处理 /api/* 路径""" + # 前置处理 + print(f"处理请求: {request.method} {request.url}") + + # 调用下一个处理器 + response = next_handler(request) + + # 后置处理 + print(f"响应状态: {response.status_code}") + return response + +@middleware(order=2, methods=['POST', 'PUT']) +def post_middleware(request: Request, next_handler): + """POST/PUT 中间件 - 只处理 POST 和 PUT 请求""" + # 可以在这里添加请求验证、日志记录等 + return next_handler(request) +``` + +**中间件参数说明:** + +- `order`: 执行顺序,数字越小越先执行(默认 0) +- `path_filter`: 路径过滤,支持字符串、字符串列表或正则表达式,如 `'/api/*'`, `['/api/*', '/admin/*']` +- `methods`: HTTP 方法过滤,如 `['GET', 'POST']`(默认 None,处理所有方法) +- `condition`: 条件函数,接收 request 对象,返回 bool 决定是否执行中间件 + +**注意**:CORS 中间件可以通过配置文件启用,无需手动添加。 + +### 6. 生命周期钩子 + +```python +from myboot.core.application import create_app + +app = create_app(name="我的应用") + +# 添加启动钩子 +def startup_hook(): + """应用启动时执行""" + print("应用启动") + +app.add_startup_hook(startup_hook) + +# 添加关闭钩子 +def shutdown_hook(): + """应用关闭时执行""" + print("应用关闭") + +app.add_shutdown_hook(shutdown_hook) +``` + +## 📁 项目结构 + +### 标准项目结构(推荐) + +使用 `myboot init` 命令创建的标准项目结构: + +``` +my-app/ +├── main.py # 应用入口(根目录) +├── pyproject.toml # 项目配置文件 +├── .gitignore # Git 忽略文件 +├── app/ # 应用代码 +│ ├── api/ # API 路由 +│ ├── service/ # 业务逻辑层 +│ ├── model/ # 数据模型 +│ ├── jobs/ # 定时任务 +│ └── client/ # 客户端(第三方API调用等) +├── conf/ # 配置文件目录 +│ └── config.yaml # 主配置文件 +└── tests/ # 测试代码 +``` + +### 目录说明 + +- **main.py**: 应用入口文件,位于项目根目录 +- **app/api/**: API 路由层,存放所有路由定义 +- **app/service/**: 业务逻辑层,存放业务服务类 +- **app/model/**: 数据模型层,存放 Pydantic 模型等 +- **app/jobs/**: 定时任务组件,存放使用 `@component` 装饰的类,类中方法可使用 `@cron`、`@interval` 等装饰器 +- **app/client/**: 客户端层,存放第三方服务客户端(如 Redis、HTTP 客户端等) +- **conf/**: 配置文件目录,存放 YAML 配置文件 +- **tests/**: 测试代码目录 + +## 🔧 高级功能 + +### 1. 自定义中间件 + +使用装饰器定义中间件(推荐方式): + +```python +from myboot.core.decorators import middleware +from fastapi import Request + +@middleware(order=1) +def custom_middleware(request: Request, next_handler): + """自定义中间件""" + # 前置处理 + print(f"请求: {request.method} {request.url}") + + # 调用下一个处理器 + response = next_handler(request) + + # 后置处理 + print(f"响应: {response.status_code}") + return response +``` + +或者使用 FastAPI 的 BaseHTTPMiddleware: + +```python +from myboot.web.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware +from fastapi import Request + +class CustomMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + # 中间件逻辑 + response = await call_next(request) + return response + +# 在应用初始化时添加 +app.add_middleware(Middleware(CustomMiddleware)) +``` + +### 2. 异步任务 + +在 REST API 中使用异步任务,避免阻塞请求响应。 + +#### 快速启动后台任务 + +```python +from myboot.core.decorators import post +from myboot.utils.async_utils import asyn_run +import time + +def process_data(data: dict): + """耗时的数据处理任务""" + print(f"开始处理数据: {data}") + time.sleep(5) # 模拟耗时操作 + print(f"数据处理完成: {data}") + return {"processed": True, "data": data} + +@post('/api/tasks') +def create_task(data: dict): + """创建异步任务 - 立即返回,任务在后台执行""" + asyn_run(process_data, data, task_name="数据处理任务") + return {"message": "任务已创建,正在后台处理"} +``` + +#### 使用 ScheduledJob + +对于需要跟踪任务状态的场景: + +```python +from myboot.core.decorators import post, get, rest_controller +from myboot.jobs.scheduled_job import ScheduledJob +from myboot.core.scheduler import get_scheduler +import threading + +@rest_controller('/api/reports') +class ReportController: + """报告控制器""" + + def __init__(self): + self.scheduler = get_scheduler() + + @post('/generate') + def create_report(self, report_type: str): + """创建报告生成任务""" + # 创建自定义 ScheduledJob + class ReportJob(ScheduledJob): + def __init__(self, report_type: str): + super().__init__( + name=f"生成{report_type}报告", + timeout=300 # 5分钟超时 + ) + self.report_type = report_type + + def run(self, *args, **kwargs): + """生成报告任务""" + import time + print(f"开始生成 {self.report_type} 报告") + time.sleep(10) # 模拟报告生成 + return {"type": self.report_type, "status": "completed"} + + # 创建任务实例 + job = ReportJob(report_type) + + # 添加到调度器(用于状态跟踪,非定时任务) + job_id = self.scheduler.add_job_object(job) + thread = threading.Thread(target=job.execute) + thread.daemon = True + thread.start() + + return {"message": "报告生成任务已创建", "job_id": job_id} + + @get('/status/{job_id}') + def get_status(self, job_id: str): + """查询任务状态""" + job = self.scheduler.get_scheduled_job(job_id) + if job: + return job.get_info() + return {"error": "任务不存在"} +``` + +更多详细内容请参考 [REST API 异步任务文档](docs/rest-api-async-tasks.md)。 + +### 3. 任务管理 + +#### 调度器任务管理 + +更多 API 与排查说明见 [任务调度器使用说明](docs/scheduler.md)。 + +```python +# 获取调度器配置 +config = app.scheduler.get_config() +print(config) # {'enabled': True, 'timezone': 'Asia/Shanghai', 'running': True, 'job_count': 3} + +# 列出所有任务 +jobs = app.scheduler.list_all_jobs() +for job in jobs: + print(f"任务ID: {job['job_id']}, 类型: {job['type']}, 函数: {job['func_name']}") + +# 获取单个任务信息 +job_info = app.scheduler.get_job_info('cron_app.jobs.ScheduledJobs.heartbeat') # 0.2.0 起默认 ID 为 cron_{模块名}.{限定名} +if job_info: + print(f"Cron表达式: {job_info.get('cron')}") + print(f"是否已执行: {job_info.get('executed', False)}") + print(f"是否已过期: {job_info.get('expired', False)}") + +# 检查调度器是否启用 +if app.scheduler.is_enabled(): + print("调度器已启用") +``` + +#### 使用 ScheduledJob 管理任务 + +```python +from myboot.jobs.scheduled_job import ScheduledJob +from myboot.core.scheduler import get_scheduler +import threading + +# 获取调度器 +scheduler = get_scheduler() + +# 创建自定义 ScheduledJob +class MyTask(ScheduledJob): + def __init__(self, data: dict): + super().__init__(name="my_task") + self.data = data + + def run(self, *args, **kwargs): + """任务函数""" + print(f"处理数据: {self.data}") + return {"status": "completed", "data": self.data} + +# 创建任务实例 +job = MyTask({"key": "value"}) + +# 对于非定时任务,可以直接执行 +# 如果需要跟踪状态,可以添加到调度器 +job_id = scheduler.add_job_object(job) + +# 执行任务 +result = job.execute() + +# 获取任务状态 +status = job.status + +# 获取任务信息 +job_info = job.get_info() + +# 获取所有 ScheduledJob 信息 +all_jobs = [job.get_info() for job in scheduler.get_all_scheduled_jobs()] + +# 在后台执行任务 +thread = threading.Thread(target=job.execute) +thread.daemon = True +thread.start() +``` + +#### 任务特性 + +- **过期任务处理**: 一次性任务如果时间已过期,将自动标记为过期不再执行 +- **已执行任务**: 一次性任务执行后不会重复执行 +- **时区支持**: 支持配置时区(需要安装 pytz) +- **任务状态查询**: 可以查询任务的执行状态、是否过期等信息 + +## 📚 示例应用 + +- **基础示例** (`examples/convention_app.py`): 展示基本功能 +- **依赖注入示例** (`examples/dependency_injection_example.py`): 展示依赖注入功能 + +## 🤝 贡献 + +欢迎贡献代码!请查看 [CONTRIBUTING.md](CONTRIBUTING.md) 了解如何参与。 + +## 📄 许可证 + +本项目采用 Apache-2.0 license 许可证。查看 [LICENSE](LICENSE) 文件了解详情。 + +## 🙏 致谢 + +感谢以下开源项目: + +- [FastAPI](https://fastapi.tiangolo.com/) - 现代、快速的 Web 框架 +- [APScheduler](https://apscheduler.readthedocs.io/) - Python 任务调度库 +- [Pydantic](https://pydantic-docs.helpmanual.io/) - 数据验证库 +- [Loguru](https://github.com/Delgan/loguru) - 现代、强大的日志库 + +## 📞 支持 + +如果您遇到问题或有建议,请: + +1. 查看 [文档](https://github.com/TrumanDu/myboot) +2. 搜索 [Issues](https://github.com/TrumanDu/myboot/issues) +3. 创建新的 [Issue](https://github.com/TrumanDu/myboot/issues/new) + +--- + +**MyBoot** - 让企业级应用开发更简单、更快速! +🚀 + +要解决的问题 + +1. [x] 配置文件 +2. [x] 日志问题 +3. [x] web 快速开发框架 +4. [x] 自动注入 +5. [x] 异步任务 +6. [x] job 管理 diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..e9022e4 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`TrumanDu/myboot` +- 原始仓库:https://github.com/TrumanDu/myboot +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/conf/config.yaml b/conf/config.yaml new file mode 100644 index 0000000..5ecc06c --- /dev/null +++ b/conf/config.yaml @@ -0,0 +1,34 @@ +# MyBoot 应用配置文件 + +# 应用配置 +app: + name: "MyBoot App" + version: "0.1.0" + +# 服务器配置 +server: + port: 8000 + reload: false + workers: 1 + keep_alive_timeout: 60 + graceful_timeout: 30 + # CORS 配置(可选,如果不存在则不启用 CORS) + cors: + allow_origins: ["*"] + allow_credentials: true + allow_methods: ["*"] + allow_headers: ["*"] + response_format: + enabled: true # 是否启用自动格式化 + exclude_paths: # 排除的路径(这些路径不会自动格式化) + - "/docs" + +# 日志配置 +logging: + level: "debug" + +# 任务调度配置 +scheduler: + enabled: true + timezone: "UTC" + max_workers: 10 diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..f1e343a --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,579 @@ +# 配置管理使用说明 + +MyBoot 使用 [Dynaconf](https://www.dynaconf.com/) 管理配置,支持 YAML 文件、多文件合并、环境变量覆盖与远程配置文件。实现见 `myboot/core/config.py`。 + +## 1. 快速开始 + +在项目根目录或 `conf/` 目录放置 `config.yaml`,应用启动时自动加载: + +```yaml +# conf/config.yaml +app: + name: "MyBoot App" + version: "0.1.0" + +server: + port: 8000 + reload: false + +logging: + level: "INFO" +``` + +代码中读取: + +```python +from myboot.core.config import get_settings, get_config, get_config_bool + +# 点号路径(推荐) +port = get_config("server.port", 8000) +debug = get_config_bool("app.debug", False) + +# Dynaconf 对象 +settings = get_settings() +name = settings.app.name # 属性访问 +port = settings.get("server.port") # 与 get_config 等价 +``` + +通过 `create_app` 创建应用时,配置挂在 `app.config` 上,与全局 `get_settings()` 为同一实例(首次初始化后): + +```python +from myboot import create_app + +app = create_app(name="我的应用", config_file="conf/config.yaml") +port = app.config.get("server.port", 8000) +``` + +## 2. 配置文件位置与合并 + +### 2.1 自动发现的文件(按加载顺序) + +Dynaconf **按列表顺序加载**,**后加载的文件覆盖先加载的同名字段**: + +| 顺序 | 路径 | 说明 | +| ---- | -------------------------------------------------------------------------- | ------------------------ | +| 1 | `{项目根}/config.yaml` 或 `config.yml` | 基底配置 | +| 2 | `{项目根}/conf/config.yaml` 或 `conf/config.yml` | 覆盖根目录同名键 | +| 3 | `create_app(config_file=...)` / `get_settings(config_file=...)` 传入的路径 | 覆盖上述文件 | +| 4 | 环境变量 `CONFIG_FILE` 指向的路径或 URL | **文件来源中优先级最高** | + +示例:根目录 `server.port: 8000`,`conf/config.yaml` 中 `server.port: 9000`,最终以 **9000** 为准(若未再被 `CONFIG_FILE` 或环境变量键覆盖)。 + +同一嵌套对象(如 `server.cors`)在多个文件中出现时,还会受全局 `merge_enabled` 与 `dynaconf_merge` 影响,见下文 **第 3 节**。 + +### 2.2 指定配置文件 + +```bash +# 本地文件 +export CONFIG_FILE=/etc/myboot/production.yaml + +# 远程 URL(会下载到系统临时目录缓存,失败时尝试使用缓存) +export CONFIG_FILE=https://example.com/config.yaml +``` + +```python +from myboot import create_app + +app = create_app(config_file="conf/config.prod.yaml") +``` + +远程配置下载逻辑见 `_download_config`:网络失败且存在缓存时回退到缓存文件。 + +### 2.3 内置默认值 + +未在任何 YAML 或环境变量中声明时,使用 `config.py` 中 `default_settings`(节选): + +| 键 | 默认值 | +| -------------------------------- | -------------- | +| `app.name` | `"MyBoot App"` | +| `app.version` | `"0.1.0"` | +| `server.host` | `"0.0.0.0"` | +| `server.port` | `8000` | +| `server.reload` | `true` | +| `server.workers` | `1` | +| `server.response_format.enabled` | `true` | +| `logging.level` | `"INFO"` | +| `scheduler.enabled` | `true` | +| `scheduler.timezone` | `"UTC"` | +| `scheduler.max_workers` | `10` | + +## 3. 字典/列表合并与 `dynaconf_merge` + +MyBoot 在 `create_settings()` 中启用了 Dynaconf 全局合并: + +```python +# myboot/core/config.py +merge_enabled=True, +``` + +因此,**多个配置文件**(或带 `@merge` 的环境变量)加载到**同一嵌套字典/列表**时,默认会**深合并**,而不是简单地把后一个文件里的整块 YAML 盖掉前一个。 + +### 3.1 默认合并行为(`merge_enabled=True`) + +| 类型 | 后加载文件中的同路径值 | 结果 | +| --------------------------------- | ---------------------- | ---------------------------------------- | +| 标量(`str` / `int` / `bool` 等) | 新值 | **覆盖**旧值 | +| 字典 | 新键与旧键 | **递归合并**(保留旧文件中未出现的子键) | +| 列表 | 新元素 | **拼接合并**(可能产生重复项) | + +**示例**:根目录与 `conf/` 均定义 `server.cors` 时,若后加载文件只改 `allow_origins`,未写 `allow_methods`,合并后仍会保留先前的 `allow_methods`、`allow_headers`。 + +```yaml +# config.yaml(先加载) +server: + cors: + allow_origins: ["*"] + allow_methods: ["*"] + allow_headers: ["*"] + +# conf/config.yaml(后加载,未使用 dynaconf_merge: false) +server: + cors: + allow_origins: ["http://localhost:3000"] +``` + +合并结果等价于: + +```yaml +server: + cors: + allow_origins: ["*", "http://localhost:3000"] # 列表被拼接 + allow_methods: ["*"] + allow_headers: ["*"] +``` + +### 3.2 `dynaconf_merge: false` 的作用 + +在需要**整块替换**某个字典(或列表)而不是与旧值合并时,在该字典(或列表)内加上 **`dynaconf_merge: false`**。 +该标记是 Dynaconf 的**合并控制元数据**,不会作为业务配置键出现在 `get_config()` 结果中。 + +| 写法 | 作用范围 | +| ----------------------------- | ---------------------------------------------------- | +| 写在某个 **dict / list 内部** | 仅该节点:后加载内容**整体替换**先加载的同级对象 | +| 写在 **YAML 文件顶层** | 整个后加载文件相对先前来源按「替换」策略处理(慎用) | + +**示例:用 `false` 完全替换 `server.cors`** + +```yaml +# config.yaml +server: + cors: + allow_origins: ["*"] + allow_methods: ["*"] + allow_headers: ["*"] + +# conf/config.prod.yaml(后加载) +server: + cors: + allow_origins: ["https://api.example.com"] + allow_credentials: true + dynaconf_merge: false # 整块替换 cors,不保留 allow_methods / allow_headers +``` + +最终 `server.cors` 仅包含: + +```yaml +allow_origins: ["https://api.example.com"] +allow_credentials: true +``` + +**对比**:若去掉 `dynaconf_merge: false`,后加载文件里未写的 `allow_methods`、`allow_headers` 会**继续保留**,且 `allow_origins` 可能与旧列表**拼接**。 + +### 3.3 `dynaconf_merge: true` + +显式声明「与已有字典/列表合并」。在 `merge_enabled=True` 时,多数嵌套 dict/list **已默认合并**,一般仅在需要强调或配合 `@merge` 环境变量时使用: + +```yaml +server: + cors: + allow_origins: ["http://localhost:3000"] + dynaconf_merge: true +``` + +环境变量也可使用 Dynaconf 的 `@merge` 标记(TOML/JSON 形式),例如: + +```bash +export SERVER__CORS='@merge {"allow_origins": ["http://localhost:3000"]}' +``` + +### 3.4 何时用 `false`、何时用环境变量 + +| 场景 | 建议 | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------- | +| 生产配置要**换一整段** `server.cors` / `logging.third_party` | 后加载 YAML 中对应该段加 `dynaconf_merge: false` | +| 只改**一两个嵌套字段** | 直接写子键,或 `SERVER__CORS__ALLOW_ORIGINS=...` | +| 列表不想与旧值拼接 | 对该 list 使用 `dynaconf_merge: false`,或写完整列表并 `false` | +| 不确定合并结果 | 用 `get_settings().server.cors` 或 `get_config("server.cors")` 在本地打印验证 | + +### 3.5 注意 + +1. **`dynaconf_merge` 只影响合并策略**,不改变「后加载文件优先」的顺序(见第 2 节)。 +2. **标量字段**始终是覆盖,与 `dynaconf_merge` 无关。 +3. 对**深层嵌套**的精细控制,除 `dynaconf_merge: false` 外,也可用环境变量 `__` 逐键覆盖(见第 5 节环境变量)。 +4. 更多语法见 [Dynaconf 合并文档](https://www.dynaconf.com/merging/)。 + +## 4. 优先级总览 + +从高到低(后者覆盖前者): + +``` +环境变量键(如 SERVER__PORT) > CONFIG_FILE 指向的文件 > config_file 参数 > conf/config.yaml > 根目录 config.yaml > default_settings +``` + +```mermaid +flowchart LR + A[default_settings] --> B[根目录 config.yaml] + B --> C[conf/config.yaml] + C --> D[config_file 参数] + D --> E[CONFIG_FILE] + E --> F[环境变量 SERVER__PORT 等] +``` + +## 5. 环境变量 + +### 5.1 规则 + +| 项 | 说明 | +| ------------ | ----------------------------------------------------------- | +| 前缀 | **无**(`envvar_prefix=False`),变量名即配置路径的大写形式 | +| 嵌套分隔符 | **双下划线 `__`**,对应 YAML 中的层级 | +| 单下划线 `_` | 仅作为**同一层键名**的一部分(如 `keep_alive_timeout`) | +| 类型 | `env_parse_values=True`,自动尝试解析布尔、数字等 | +| 未知变量 | `ignore_unknown_envvars=True`,**仅当该键已在配置树中存在时**才接受环境变量覆盖(见下) | + +#### 已知键限制(`ignore_unknown_envvars=True`) + +MyBoot 在 `config.py` 中启用了 `ignore_unknown_envvars=True`。环境变量(含 `.env` 加载进 `os.environ` 的项)**不会**凭空新增配置项,只能**覆盖**下列来源里**已经出现过**的键路径: + +1. 已加载的 YAML(`config.yaml`、`conf/config.yaml`、`CONFIG_FILE` 等) +2. `config.py` 中 `default_settings` 内置项(如 `app.name`、`server.port`、`logging.level`、`scheduler.timezone` 等) + +因此: + +- ✅ `SERVER__PORT=9000` 有效(`server.port` 在默认配置或 YAML 中已有) +- ❌ `DATABASE__URL=...` **无效**,若 YAML / 默认配置里**没有** `database` 段——变量会进入 `os.environ`,但 **MyBoot 不会读入** `get_config("database.url")` +- ❌ 任意「只在 `.env` 里出现、YAML 从未声明」的键都会被**静默忽略** + +**正确做法**:需要靠 `.env` 注入的新配置,先在 YAML 中**声明结构**(值可写占位符),再用环境变量覆盖: + +```yaml +# conf/config.yaml — 先声明键结构 +database: + url: "" # 占位,真实值放在 .env / .local.env +app: + secret_key: "" # 敏感项同理 +jobs: + cleanup_task: + enabled: true +``` + +```bash +# .local.env — 再覆盖 +DATABASE__URL=postgresql://user:pass@127.0.0.1:5432/mydb +APP__SECRET_KEY=your-local-secret-key +JOBS__CLEANUP_TASK__ENABLED=false +``` + +若希望环境变量**无需**在 YAML 预声明即可生效,需修改 `myboot/core/config.py` 将 `ignore_unknown_envvars` 设为 `False`(当前框架默认未开启)。 + +### 5.2 示例 + +| YAML 路径 | 环境变量 | +| --------------------------- | --------------------------------------------------------- | +| `app.name` | `APP__NAME=MyApp` | +| `server.port` | `SERVER__PORT=9000` | +| `logging.level` | `LOGGING__LEVEL=DEBUG` | +| `server.keep_alive_timeout` | `SERVER__KEEP_ALIVE_TIMEOUT=60` | +| `server.cors.allow_origins` | `SERVER__CORS__ALLOW_ORIGINS='["http://localhost:3000"]'` | + +```bash +export APP__NAME="生产应用" +export SERVER__PORT=8080 +export LOGGING__LEVEL=WARNING +export SCHEDULER__TIMEZONE=Asia/Shanghai +``` + +**不要**用单下划线表示嵌套层级,例如 `SERVER_PORT` **不会**映射到 `server.port`(除非你在 YAML 里真有名为 `server_port` 的顶层键)。 + +### 5.3 布尔值 + +`get_config_bool` 将字符串视为真:`true`、`1`、`yes`、`on`(不区分大小写)。 + +### 5.4 MyBoot 与 `.env` 的关系(0.2.0 起自动加载) + +**0.2.0 起 MyBoot 自动加载项目根目录的 `.env` 文件**(通过 Dynaconf 的 +`load_dotenv`),`main.py` 无需再手动调用 `load_dotenv()`。 + +加载语义: + +- 路径:**项目根目录**(含 `pyproject.toml` 的目录)下的 `.env`; +- 优先级:**真实环境变量 > `.env` > YAML 配置 > 内置默认值** + (`dotenv_override=False`:`.env` 不覆盖已存在的真实环境变量,与容器部署习惯一致); +- 仍受 **`ignore_unknown_envvars=True`** 约束:`.env` 里的变量名必须对应 + **YAML 或内置默认配置中已声明过的键路径**,否则不会进入 `get_config()` + (见上文 **5.1 已知键限制**)。 + +```mermaid +flowchart LR + A[".env(项目根)"] -->|"框架自动加载"| C["os.environ"] + B["真实环境变量"] -->|"优先"| C + C --> D["Dynaconf / MyBoot"] + E["config.yaml"] --> D +``` + +**注意:** + +| 事项 | 说明 | +| ------------------- | -------------------------------------------------------------------- | +| 修改 `.env` 后 | 需**重启进程**;不会热更新 | +| 版本控制 | `.env` 加入 `.gitignore`;可提交 `.env.example` 作模板 | +| 键必须已声明 | `.env` 中每一项都须在 YAML(或内置默认值)中有对应路径,见 5.1 | +| 多个 env 文件 | 框架只自动加载根目录 `.env`;需要 `.local.env` 分层时仍可在 main.py 顶部自行 `load_dotenv(..., override=True)` | + +### 5.5 旧版本(≤0.1.x)手动加载方式 + +0.1.x 不自动加载 `.env`,需在 `create_app()` 之前手动调用: + +```python +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv(Path(__file__).resolve().parent / ".env") + +from myboot import create_app # 必须在 load_dotenv 之后 +app = create_app(name="我的应用") +``` + +升级到 0.2.0 后这段代码可以删除(保留也无害——重复加载是幂等的)。 + +### 5.6 `.env` 文件如何书写 + +`.env` 使用 **dotenv 格式**(不是 YAML):每行 `键=值`,`#` 开头为注释。**不要**写 `export`(那是 shell 脚本写法)。 + +> **重要**:`.env` 只用于**覆盖**已有配置键,不能代替 YAML 做「首次声明」。未在 YAML / `default_settings` 中出现的键(如仅写 `DATABASE__URL` 而 `config.yaml` 里没有 `database.url`)将被忽略。 + +#### 命名规则(与 MyBoot 一致) + +- 嵌套配置用 **`__`(双下划线)** 对应 YAML 层级。 +- 键名一般**大写**(与常见约定一致;写入环境后 Dynaconf 会映射到配置树)。 +- 单下划线 `_` 只表示键名的一部分,例如 `KEEP_ALIVE_TIMEOUT`。 + +#### 示例 `.env`(非敏感、可提交 `.env.example`) + +```bash +# 应用 +APP__NAME=MyBoot App +APP__VERSION=0.1.0 +APP__DEBUG=false + +# 服务 +SERVER__HOST=0.0.0.0 +SERVER__PORT=8000 +SERVER__RELOAD=true + +# 日志 +LOGGING__LEVEL=INFO + +# 调度器 +SCHEDULER__ENABLED=true +SCHEDULER__TIMEZONE=Asia/Shanghai +``` + +#### 示例 `.local.env`(敏感信息,勿提交 Git) + +以下变量均要求 `conf/config.yaml`(或根目录 `config.yaml`)中**已有同名路径**;示例见上一节 YAML 占位声明。 + +```bash +# 覆盖 .env 中的端口(server.port 已在 YAML/默认配置中存在) +SERVER__PORT=9000 + +# 以下键须先在 YAML 中声明 database.url、app.secret_key +DATABASE__URL=postgresql://user:password@127.0.0.1:5432/mydb +APP__SECRET_KEY=your-local-secret-key +``` + +#### 与 YAML 的对应关系 + +| `.env` 中的写法 | 等价 YAML 路径 | 代码读取 | +| ------------------------------------- | --------------------------- | ----------------------------------------- | +| `SERVER__PORT=9000` | `server.port` | `get_config("server.port")` | +| `LOGGING__LEVEL=DEBUG` | `logging.level` | `get_config("logging.level")` | +| `DATABASE__URL=...` | `database.url`(**须先在 YAML 声明**) | `get_config("database.url")` | +| `SERVER__CORS__ALLOW_ORIGINS='["*"]'` | `server.cors.allow_origins` | `get_config("server.cors.allow_origins")` | + +#### 值的书写建议 + +```bash +# 字符串含空格或特殊字符时用引号 +APP__NAME="My Boot App" + +# 布尔(会被 env_parse_values 解析) +APP__DEBUG=true +SERVER__RELOAD=false + +# 数字 +SERVER__PORT=8080 +SCHEDULER__MAX_WORKERS=20 + +# 列表 / 字典建议用 JSON 字符串(尤其嵌套较深时) +SERVER__CORS__ALLOW_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000"] +``` + +**错误示例(不会映射到预期配置):** + +```bash +# 错误:单下划线不能表示 server.port +SERVER_PORT=8000 + +# 错误:dotenv 行内不要用 export +export SERVER__PORT=8000 + +# 错误:YAML 缩进语法不能用在 .env +server: + port: 8000 + +# 错误:YAML 中未声明 database 段时,此行会被 ignore_unknown_envvars 忽略 +DATABASE__URL=postgresql://... +``` + +**内置默认已存在、可直接用 `.env` 覆盖的键(无需 YAML)** 包括:`app.name`、`app.version`、`server.host`、`server.port`、`server.reload`、`server.workers`、`logging.level`、`scheduler.enabled`、`scheduler.timezone`、`scheduler.max_workers` 等,完整列表见 `myboot/core/config.py` 中 `default_settings`。 + +#### 验证是否加载成功 + +```python +# 在 load_dotenv 且 create_app 之后执行 +from myboot.core.config import get_config + +# server.port 在 YAML/默认配置中存在 → 能读到 .env 覆盖值 +print(get_config("server.port")) + +# database.url 仅当 YAML 中已声明 database.url 时才能读到 .env 值 +print(get_config("database.url", "(YAML 未声明或 .env 未覆盖)")) +``` + +`echo $DATABASE__URL` 能看到 shell 环境变量,但 **`get_config` 仍可能取不到**——说明键未进入 MyBoot 配置树,请检查 YAML 是否已声明该路径。 + +或在 shell 中确认环境变量已写入(Windows Git Bash): + +```bash +echo $SERVER__PORT +``` + +## 6. 读取 API + +| 函数 / 对象 | 用途 | +| ------------------------------------- | ---------------------------------------- | +| `get_settings(config_file=None)` | 获取全局 `Dynaconf` 单例 | +| `get_config(key, default=None)` | 按点号路径取值 | +| `get_config_str(key, default="")` | 转为字符串 | +| `get_config_int(key, default=0)` | 转为整数,失败返回 default | +| `get_config_bool(key, default=False)` | 转为布尔 | +| `reload_config()` | 清空单例,下次 `get_settings()` 重新加载 | +| `app.config` | 应用持有的同一配置对象 | + +```python +# 运行时修改(仅当前进程内存,不写回文件) +app.config.set("server.port", 9001) +``` + +### 6.1 单例注意 + +`get_settings()` 使用模块级单例,**首次调用**时确定加载了哪些文件;之后传入不同的 `config_file` 不会生效,除非先调用 `reload_config()`。 + +```python +from myboot.core.config import get_settings, reload_config + +settings = get_settings() # 已固定加载结果 +reload_config() +settings = get_settings("other.yaml") # 重新加载 +``` + +## 7. 常用配置项 + +与框架行为直接相关的键(完整示例可参考 `conf/config.yaml` 与 README): + +### 7.1 `app` + +| 键 | 说明 | +| ------------- | -------------------- | +| `app.name` | 应用名称 | +| `app.version` | 版本号 | +| `app.debug` | 调试开关(按需使用) | + +### 7.2 `server` + +| 键 | 说明 | +| -------------------------------------- | ----------------------------------- | +| `server.host` | 监听地址 | +| `server.port` | 端口 | +| `server.reload` | 热重载 | +| `server.workers` | Worker 数量 | +| `server.keep_alive_timeout` | Keep-Alive 超时 | +| `server.graceful_timeout` | 优雅关闭超时 | +| `server.cors` | CORS 子对象,存在时启用 CORS 中间件 | +| `server.response_format.enabled` | 是否统一响应格式 | +| `server.response_format.exclude_paths` | 排除路径列表 | + +### 7.3 `logging` + +| 键 | 说明 | +| --------------------- | -------------------- | +| `logging.level` | 日志级别 | +| `logging.format` | 日志格式 | +| `logging.file` | 日志文件路径(可选) | +| `logging.third_party` | 第三方库日志级别映射 | + +### 7.4 `scheduler` + +| 键 | 说明 | +| -------------------------- | ---------------------------------- | +| `scheduler.enabled` | 是否启用调度器 | +| `scheduler.timezone` | 时区(建议安装 `pytz`) | +| `scheduler.max_workers` | 调度线程池大小 | +| `scheduler.on_all_workers` | 多进程时是否在每个 worker 运行调度 | + +Cron 与任务装饰器详见 [scheduler.md](./scheduler.md)。 + +### 7.5 `jobs`(可选) + +用于在 `@cron` / `@interval` / `@once` 的 `enabled` 参数中按任务开关: + +```yaml +jobs: + cleanup_task: + enabled: false +``` + +```python +from myboot.core.config import get_config + +@cron("0 2 * * *", enabled=get_config("jobs.cleanup_task.enabled", True)) +def cleanup(self): + ... +``` + +## 8. 配置与装饰器、组件 + +- **定时任务**:`enabled=get_config('jobs.xxx.enabled', True)` 在类定义时求值,修改环境变量后需**重启进程**才生效。 +- **日志**:`Application` 构造时调用 `setup_logging(self.config)`,之后改 `logging.*` 需自行处理或重启。 +- **依赖注入**:`get_config` 可在模块级或组件方法内使用;与 `@component` 无冲突。 + +## 9. 最佳实践 + +1. **开发**用 `conf/config.yaml`,**生产**用 `CONFIG_FILE` 或环境变量注入敏感项,避免密钥进仓库。 +2. **嵌套键**统一用 `__` 环境变量,避免与 `snake_case` 字段混淆。 +3. **显式设置** `logging.level`、`scheduler.timezone`,减少环境差异。 +4. 列表、字典类配置在环境变量中优先使用 **JSON 字符串**(如 CORS origins)。 +5. 需要切换配置源时调用 `reload_config()`,或保证在任意 `get_settings()` 之前设置好 `CONFIG_FILE`。 +6. 多文件拆分时保持「基底 → 环境专用」的加载顺序意识:后加载覆盖先加载。 +7. 多文件共用同一嵌套段(如 `server.cors`)且不想**拼接列表、保留旧子键**时,在后加载文件中对应该段添加 **`dynaconf_merge: false`**(见第 3 节)。 +8. 本地敏感项用 **`.local.env`** + `main.py` 中 `load_dotenv`(见第 5.5、5.6 节);敏感值放 `.env`,但**键名须先在 YAML 声明**(`ignore_unknown_envvars=True`),结构可进 `config.yaml`,秘密不进 Git。 + +## 10. 相关文档与代码 + +| 资源 | 说明 | +| ---------------------------------------------------- | ---------------------------- | +| `myboot/core/config.py` | 配置加载与便捷函数 | +| `conf/config.yaml` | 项目默认配置示例 | +| [scheduler.md](./scheduler.md) | 调度器配置与 Cron | +| [dependency-injection.md](./dependency-injection.md) | 组件与 `get_config` 结合示例 | diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md new file mode 100644 index 0000000..ab29fc8 --- /dev/null +++ b/docs/dependency-injection.md @@ -0,0 +1,627 @@ +# 依赖注入使用指南 + +MyBoot 框架提供了基于 `dependency_injector` 的自动依赖注入功能,让您可以轻松管理服务之间的依赖关系,无需手动获取和传递依赖。 + +## 目录 + +- [快速开始](#快速开始) +- [基本用法](#基本用法) + - [声明依赖](#1-声明依赖) + - [服务命名规则](#2-服务命名规则) + - [多级依赖](#3-多级依赖) + - [可选依赖](#4-可选依赖) + - [Client 依赖注入](#5-client-依赖注入) + - [Component 组件](#6-component-组件) +- [高级特性](#高级特性) +- [最佳实践](#最佳实践) +- [常见问题](#常见问题) + +## 快速开始 + +### 安装依赖 + +确保已安装 `dependency_injector`: + +```bash +pip install dependency-injector +``` + +### 基本示例 + +```python +from myboot.core.decorators import service + +@service() +class UserService: + """用户服务""" + def __init__(self): + self.users = {} + + def get_user(self, user_id: int): + return self.users.get(user_id) + +@service() +class EmailService: + """邮件服务""" + def send_email(self, to: str, subject: str): + print(f"发送邮件到 {to}: {subject}") + +@service() +class OrderService: + """订单服务 - 自动注入 UserService 和 EmailService""" + def __init__(self, user_service: UserService, email_service: EmailService): + self.user_service = user_service + self.email_service = email_service + + def create_order(self, user_id: int, product: str): + user = self.user_service.get_user(user_id) + self.email_service.send_email(user['email'], "订单创建", f"您的订单 {product} 已创建") +``` + +框架会自动: + +1. 检测 `OrderService` 的依赖(`UserService` 和 `EmailService`) +2. 按正确的顺序初始化服务 +3. 自动注入依赖到 `OrderService` 的构造函数 + +## 基本用法 + +### 1. 声明依赖 + +通过类型注解声明依赖是最简单的方式: + +```python +@service() +class ProductService: + def __init__(self, user_service: UserService, cache_service: CacheService): + self.user_service = user_service + self.cache_service = cache_service +``` + +框架会自动: + +- 从类型注解中识别依赖的服务类 +- 将类名转换为服务名(如 `UserService` → `user_service`) +- 自动注入对应的服务实例 + +### 2. 服务命名规则 + +服务名称遵循以下规则: + +- **默认命名**:类名自动转换为下划线分隔的小写形式 + + - `UserService` → `user_service` + - `EmailService` → `email_service` + - `DatabaseClient` → `database_client` + +- **自定义命名**:通过装饰器参数指定 + ```python + @service('custom_user_service') + class UserService: + pass + ``` + +### 3. 多级依赖 + +支持多级依赖,框架会自动处理依赖顺序: + +```python +@service() +class DatabaseClient: + def __init__(self): + self.connection = None + +@service() +class UserRepository: + def __init__(self, db: DatabaseClient): + self.db = db + +@service() +class UserService: + def __init__(self, user_repo: UserRepository): + self.user_repo = user_repo +``` + +依赖顺序:`DatabaseClient` → `UserRepository` → `UserService` + +### 4. 可选依赖 + +使用 `Optional` 类型注解声明可选依赖: + +```python +from typing import Optional + +@service() +class CacheService: + pass + +@service() +class ProductService: + # cache_service 是可选的,如果不存在则为 None + def __init__(self, cache_service: Optional[CacheService] = None): + self.cache_service = cache_service + if self.cache_service: + # 使用缓存服务 + pass +``` + +### 5. Client 依赖注入 + +除了 Service 之间的依赖注入,框架还支持将 Client 注入到 Controller 或 Service 中: + +```python +from myboot.core.decorators import client, service, rest_controller, get + +@client() +class HttpClient: + """HTTP 客户端""" + def request(self, url: str): + return {"url": url} + +@client(name="redis_client") # 自定义名称 +class RedisClient: + """Redis 客户端""" + def get(self, key: str): + return None + +@service() +class UserService: + """注入 Client 到 Service""" + def __init__(self, http_client: HttpClient): + self.http_client = http_client + +@rest_controller("/api") +class UserController: + """注入 Client 和 Service 到 Controller""" + def __init__(self, user_service: UserService, redis_client: RedisClient): + self.user_service = user_service + self.redis_client = redis_client + + @get("/users") + def list_users(self): + return [] +``` + +#### Client 命名规则 + +- **默认命名**:类名自动转换为下划线形式 + + - `HttpClient` → `http_client` + - `RedisClient` → `redis_client` + +- **自定义命名**:通过装饰器参数指定 + ```python + @client(name="my_redis") + class RedisClient: + pass + ``` + +#### Client 查找方式 + +框架支持多种方式查找 Client 依赖: + +```python +@client(name="my_http") # 自定义名称 +class HttpClient: + pass + +@rest_controller("/api") +class MyController: + # 以下方式都可以成功注入: + + # 方式1:按自定义名称(参数名匹配) + def __init__(self, my_http: HttpClient): + pass + + # 方式2:按自动转换名称 + def __init__(self, http_client: HttpClient): + pass + + # 方式3:按类型匹配(参数名任意) + def __init__(self, client: HttpClient): + pass + + # 方式4:显式指定名称 + def __init__(self, x: Provide['my_http']): + pass +``` + +### 6. Component 组件 + +`@component` 装饰器用于注册通用组件,支持依赖注入。它可用于任意需要托管的类(工具类、配置类、包含定时任务的类等)。 + +#### 基本用法 + +```python +from myboot.core.decorators import component + +@component() +class EmailHelper: + """邮件工具类""" + def send(self, to: str, content: str): + print(f"发送邮件到 {to}: {content}") +``` + +#### 带依赖注入 + +```python +from myboot.core.decorators import component, client + +@client() +class SmtpClient: + def send_mail(self, to: str, subject: str, body: str): + pass + +@component(name='email_helper') +class EmailHelper: + """带依赖注入的组件""" + def __init__(self, smtp_client: SmtpClient): + self.smtp = smtp_client + + def send(self, to: str, subject: str, body: str): + self.smtp.send_mail(to, subject, body) +``` + +#### 包含定时任务的组件 + +**重要**:定时任务(`@cron`、`@interval`、`@once`)**必须**在 `@component` 装饰的类中定义。这是定义定时任务的唯一方式,不再支持模块级函数或 `@service` 类中的定时任务。 + +```python +from myboot.core.decorators import component, service, cron, interval + +@service() +class DataService: + def sync(self): + print("同步数据...") + + def health_check(self): + print("健康检查...") + +@component() +class DataSyncJobs: + """数据同步任务集合 - 自动注入 DataService""" + + def __init__(self, data_service: DataService): + self.data_service = data_service + + @cron("0 2 * * *") # 每天凌晨 2 点 + def sync_daily_data(self): + """每日数据同步""" + self.data_service.sync() + + @interval(hours=1) # 每小时 + def check_data_health(self): + """数据健康检查""" + self.data_service.health_check() +``` + +**注意**: +- 定时任务方法会在组件注册时自动扫描并注册到调度器 +- 组件支持依赖注入,可以在构造函数中注入所需的服务 + +#### Component 配置选项 + +```python +@component( + name='my_component', # 组件名称,默认使用类名的 snake_case + scope='singleton', # 生命周期:'singleton'(默认)或 'prototype' + lazy=False, # 是否懒加载 + primary=False # 当按类型获取有多个匹配时,是否为首选 +) +class MyComponent: + pass +``` + +#### 从容器获取组件 + +```python +from myboot.core.application import app + +# 方式1:通过 container 获取 +email_helper = app().container.get('email_helper') + +# 方式2:通过 Application 直接获取 +email_helper = app().get_component('email_helper') + +# 方式3:依赖注入(推荐) +@component() +class NotificationService: + def __init__(self, email_helper: EmailHelper): + self.email_helper = email_helper +``` + +## 高级特性 + +### 1. 显式指定服务名 + +如果服务名与类名转换规则不匹配,可以使用 `Provide` 类型提示: + +```python +from myboot.core.di import Provide + +@service('custom_user_service') +class UserService: + pass + +@service() +class OrderService: + def __init__(self, user_service: Provide['custom_user_service']): + self.user_service = user_service +``` + +### 2. 服务生命周期 + +通过 `scope` 参数控制服务的生命周期: + +```python +# 单例模式(默认) +@service(scope='singleton') +class UserService: + pass + +# 工厂模式(每次创建新实例) +@service(scope='factory') +class TaskService: + pass +``` + +### 3. 循环依赖检测 + +框架会自动检测循环依赖并抛出清晰的错误: + +```python +@service() +class ServiceA: + def __init__(self, service_b: ServiceB): + pass + +@service() +class ServiceB: + def __init__(self, service_a: ServiceA): + pass +``` + +错误信息: + +``` +ValueError: 检测到循环依赖: service_a -> service_b -> service_a。 +请重构代码以消除循环依赖。 +``` + +### 4. 获取服务实例 + +在路由或其他地方获取服务实例: + +```python +from myboot.core.application import get_service + +@get('/users/{user_id}') +def get_user(user_id: int): + user_service = get_service('user_service') + return user_service.get_user(user_id) +``` + +## 最佳实践 + +### 1. 使用类型注解 + +推荐使用类型注解声明依赖,代码更清晰: + +```python +# ✅ 推荐 +@service() +class OrderService: + def __init__(self, user_service: UserService, email_service: EmailService): + self.user_service = user_service + self.email_service = email_service + +# ❌ 不推荐(需要手动获取) +@service() +class OrderService: + def __init__(self): + from myboot.core.application import get_service + self.user_service = get_service('user_service') + self.email_service = get_service('email_service') +``` + +### 2. 避免循环依赖 + +设计服务时避免循环依赖: + +```python +# ✅ 好的设计 +@service() +class UserService: + def __init__(self, user_repo: UserRepository): + self.user_repo = user_repo + +@service() +class OrderService: + def __init__(self, user_service: UserService, order_repo: OrderRepository): + self.user_service = user_service + self.order_repo = order_repo + +# ❌ 避免循环依赖 +@service() +class ServiceA: + def __init__(self, service_b: ServiceB): + pass + +@service() +class ServiceB: + def __init__(self, service_a: ServiceA): + pass +``` + +### 3. 使用接口而非具体实现 + +虽然 Python 没有接口,但可以通过抽象基类或协议定义接口: + +```python +from abc import ABC, abstractmethod + +class IUserRepository(ABC): + @abstractmethod + def get_user(self, user_id: int): + pass + +@service() +class UserRepository(IUserRepository): + def get_user(self, user_id: int): + return {"id": user_id} + +@service() +class UserService: + def __init__(self, user_repo: IUserRepository): + self.user_repo = user_repo +``` + +### 4. 合理使用可选依赖 + +对于非必需的依赖,使用 `Optional`: + +```python +from typing import Optional + +@service() +class ProductService: + def __init__( + self, + db: DatabaseClient, # 必需依赖 + cache: Optional[CacheService] = None # 可选依赖 + ): + self.db = db + self.cache = cache +``` + +## 常见问题 + +### Q1: 依赖注入失败怎么办? + +如果依赖注入失败,框架会自动回退到传统方式(直接实例化)。检查日志中的错误信息: + +1. **依赖的服务未注册**:确保依赖的服务已使用 `@service()` 装饰器 +2. **服务名不匹配**:检查服务名是否正确(类名转下划线命名) +3. **循环依赖**:重构代码消除循环依赖 + +### Q2: 如何调试依赖关系? + +框架会在日志中输出依赖关系信息: + +``` +已注册服务提供者: user_service (依赖: set()) +已注册服务提供者: order_service (依赖: {'user_service', 'email_service'}) +``` + +### Q3: 可以在运行时动态获取服务吗? + +可以,使用 `get_service()` 函数: + +```python +from myboot.core.application import get_service + +def some_function(): + user_service = get_service('user_service') + if user_service: + # 使用服务 + pass +``` + +### Q4: 支持异步服务吗? + +目前依赖注入主要支持同步服务。对于异步服务,建议在服务内部处理异步逻辑。 + +### Q5: 如何测试带依赖的服务? + +在测试中,可以手动创建服务实例并注入 mock 对象: + +```python +def test_order_service(): + # 创建 mock 依赖 + mock_user_service = MockUserService() + mock_email_service = MockEmailService() + + # 创建服务实例 + order_service = OrderService(mock_user_service, mock_email_service) + + # 测试 + assert order_service is not None +``` + +## 完整示例 + +```python +from myboot.core.decorators import service, get +from myboot.core.application import get_service +from typing import Optional + +# 基础服务 +@service() +class DatabaseClient: + def __init__(self): + self.connection = "connected" + print("✅ DatabaseClient 已初始化") + +@service() +class CacheService: + def __init__(self): + self.cache = {} + print("✅ CacheService 已初始化") + +# 仓储层 +@service() +class UserRepository: + def __init__(self, db: DatabaseClient): + self.db = db + print("✅ UserRepository 已初始化(依赖: DatabaseClient)") + + def find_by_id(self, user_id: int): + return {"id": user_id, "name": f"用户{user_id}"} + +# 服务层 +@service() +class UserService: + def __init__( + self, + user_repo: UserRepository, + cache: Optional[CacheService] = None + ): + self.user_repo = user_repo + self.cache = cache + print("✅ UserService 已初始化(依赖: UserRepository, CacheService)") + + def get_user(self, user_id: int): + # 尝试从缓存获取 + if self.cache and user_id in self.cache.cache: + return self.cache.cache[user_id] + + # 从数据库获取 + user = self.user_repo.find_by_id(user_id) + + # 存入缓存 + if self.cache: + self.cache.cache[user_id] = user + + return user + +# 路由层 +@get('/users/{user_id}') +def get_user(user_id: int): + user_service = get_service('user_service') + return user_service.get_user(user_id) +``` + +## 总结 + +依赖注入功能让您能够: + +- ✅ 自动管理服务依赖关系 +- ✅ 无需手动获取和传递依赖 +- ✅ 支持多级依赖和可选依赖 +- ✅ 自动检测循环依赖 +- ✅ 支持 Client 注入到 Service 和 Controller +- ✅ 支持 Component 组件,可包含定时任务 +- ✅ 支持多种依赖查找方式(名称、类型) +- ✅ 保持向后兼容,现有代码无需修改 + +开始使用依赖注入,让代码更加清晰和可维护! diff --git a/docs/metrics.md b/docs/metrics.md new file mode 100644 index 0000000..c2e1144 --- /dev/null +++ b/docs/metrics.md @@ -0,0 +1,125 @@ +# Prometheus Metrics 指南 + +myboot 0.2.0 起内置 Prometheus 指标支持:零代码获得 `/metrics` 端点与 HTTP +请求指标,多 worker 模式下自动聚合全部进程的指标,业务代码可用轻量 API 记录 +自定义指标。 + +## 1. 开启 + +```bash +pip install myboot[metrics] # prometheus-client 为可选依赖 +``` + +```yaml +# conf/config.yaml +metrics: + enabled: true # 默认 false,不开启则一切如旧 +``` + +不需要在 main.py 写任何 metrics 代码——框架自动完成 `/metrics` 挂载、HTTP +中间件注册、多进程聚合配置与进程退出清理。 + +> `metrics.enabled: true` 但未安装 prometheus-client 时只打 warning, +> 应用照常启动(指标功能禁用)。 + +## 2. 配置项 + +| 配置 | 默认 | 说明 | +|---|---|---| +| `metrics.enabled` | `false` | 总开关 | +| `metrics.path` | `/metrics` | 暴露端点路径 | +| `metrics.http_metrics` | `true` | 是否注册内置 HTTP 请求指标中间件 | +| `metrics.multiproc_dir` | 自动(系统临时目录) | 多进程指标共享目录,一般无需配置 | + +## 3. 内置 HTTP 指标 + +开启后自动采集(无需写代码): + +``` +# 请求计数(按方法、路由模板、状态码) +myboot_http_requests_total{method="POST", path="/api/items/{id}", status="200"} + +# 请求延迟直方图 +myboot_http_request_duration_seconds_bucket{method="POST", path="/api/items/{id}", le="0.1"} +``` + +- `path` 标签使用**路由模板**(`/api/items/{id}`),而非真实 URL + (`/api/items/123`),避免标签基数爆炸; +- 未匹配任何路由的请求(如 404)统一归入 `path="unmatched"`; +- `/metrics` 端点自身不计入统计。 + +## 4. 自定义指标 API + +```python +from myboot.metrics import get_counter, get_histogram, observe_stage, time_stage + +# 计数器(同名重复调用返回同一对象,无需担心重复注册) +cache_miss = get_counter("cache_miss_total", "缓存未命中数", ["source"]) +cache_miss.labels(source="redis").inc() + +# 直方图 +latency = get_histogram("external_api_seconds", "外部接口耗时", ["api"]) +latency.labels(api="item-feature").observe(0.123) + +# 阶段计时:内置 myboot_stage_duration_seconds{stage} 直方图的便捷封装 +with time_stage("sasrec"): + candidates = model.infer(request) + +with time_stage("rerank"): + result = reranker.score(candidates) + +# 或手动上报耗时(秒) +observe_stage("persist", 0.05) +``` + +重要特性:**未开启 `metrics.enabled` 或未安装 prometheus-client 时,以上调用 +全部是静默 no-op**——业务代码不需要任何条件判断,开发环境关掉指标也不会报错。 + +## 5. 多 Worker 聚合原理 + +多进程下 Prometheus 官方方案要求: + +1. `PROMETHEUS_MULTIPROC_DIR` 必须在 `prometheus_client` 首次 import **之前**设置; +2. 各 worker 把指标值写入该目录的 mmap 文件,`/metrics` 端点用 + `MultiProcessCollector` 聚合读取; +3. worker 退出时调用 `mark_process_dead` 清理 gauge 类残留。 + +这三步时序极易写错,myboot 已全部内置:环境变量在 `Application.__init__` +阶段设置(fork 子进程继承、spawn 子进程重新执行 `__init__`,两种模式都来得及), +陈旧文件由父进程启动时清理,`mark_process_dead` 挂在 lifespan 关闭末尾。 + +唯一注意事项:**不要在 `create_app()` 之前 import prometheus_client** +(框架检测到会打 warning)。 + +### Windows 限制 + +Windows 多 worker(spawn + terminate)下 multiproc 模式不可靠,框架自动降级为 +各进程独立指标:`/metrics` 只反映处理该次请求的 worker。Linux/macOS 生产部署 +不受影响;Windows 单 worker 完全正常。 + +## 6. Prometheus 抓取配置 + +```yaml +scrape_configs: + - job_name: my-app + scrape_interval: 15s + static_configs: + - targets: ["my-app:8000"] # 抓取到的即全 worker 聚合值 +``` + +## 7. 常用查询示例(PromQL) + +```promql +# QPS(按路由) +sum(rate(myboot_http_requests_total[1m])) by (path) + +# P99 延迟 +histogram_quantile(0.99, sum(rate(myboot_http_request_duration_seconds_bucket[5m])) by (path, le)) + +# 错误率 +sum(rate(myboot_http_requests_total{status=~"5.."}[5m])) + / sum(rate(myboot_http_requests_total[5m])) + +# 自定义阶段耗时 P95 +histogram_quantile(0.95, sum(rate(myboot_stage_duration_seconds_bucket[5m])) by (stage, le)) +``` diff --git a/docs/multi-worker.md b/docs/multi-worker.md new file mode 100644 index 0000000..8c74db4 --- /dev/null +++ b/docs/multi-worker.md @@ -0,0 +1,136 @@ +# 多 Worker 模式指南 + +myboot 0.2.0 起,多 worker(`workers > 1`)模式下**每个 worker 进程独立完成组件实例化**: +client / service / controller 都在各自的 worker 进程内创建,不再共享父进程 fork 前的实例。 +本文是多 worker 相关能力的一站式说明。 + +## 启动方式 + +```python +# main.py +app = create_app(name="my-app", auto_discover_package="app") + +if __name__ == "__main__": + app.run(workers=4, app_path="main:app.get_fastapi_app()") +``` + +```yaml +# conf/config.yaml 亦可 +server: + workers: 4 +``` + +无需再手动调用 `auto_discover()` / `apply_auto_configuration()`——框架在每个 +worker 内自动完成引导(`bootstrap_worker()`)。 + +## Worker 信息 + +```python +app.worker_id # 当前 worker ID,从 1 开始;单进程为 1 +app.worker_count # worker 总数 +app.is_primary_worker # 是否主 worker(适合"只跑一份"的逻辑) +``` + +## Client 生命周期 + +**建连可以直接写在 `__init__` 里**——0.2.0 起 client 在各 worker 内实例化, +不存在 fork 共享连接问题: + +```python +@client() +class RedisClient: + def __init__(self): + self.conn = redis.Redis(...) # 安全:本 worker 进程内新建 + + def close(self): # 可选:定义即获得自动清理 + self.conn.close() +``` + +定义了 `close()` 方法(同步或 async)的 client,框架会在 worker 停止时 +(`worker_stop_hooks` 之后、`shutdown_hooks` 之前)自动调用。若你也在自己的 +shutdown 钩子里关闭,建议把 `close()` 实现为幂等——框架的兜底调用会把二次 +close 的异常降为 warning,不影响关闭流程。 + +## Worker 生命周期钩子 + +```python +from myboot.core.decorators import on_worker_start, on_worker_stop + +@on_worker_start +def warm_up(): + """每个 worker 启动时各触发一次(startup_hooks 之后、调度器启动之前)""" + +@on_worker_stop(order=1) +def cleanup(): + """每个 worker 停止时各触发一次(调度器停止之后)""" +``` + +> Windows 限制:多 worker 下父进程以 `terminate()` 硬终止 worker, +> `@on_worker_stop` 与 client 自动 close 在 Windows 上不保证执行; +> 关键清理逻辑不要只依赖它们。 + +## Primary-first 初始化协调:`run_primary_first` + +典型场景:4 个 worker 都去下载同一个 3GB 模型是浪费——应该 primary 下载, +其余 worker 等它完成后直接从本地加载: + +```python +from myboot.utils import run_primary_first +from myboot.core.decorators import on_worker_start + +@on_worker_start +def load_model(): + run_primary_first( + "sasrec-model", # 协调标识(同一标识共享一次协调) + primary_fn=download_and_load, # 仅 primary 执行 + secondary_fn=load_from_local, # 其余 worker 等 primary 完成后执行 + timeout=600, # 等待上限(秒) + ) +``` + +语义保证: + +- `secondary_fn` 缺省时其余 worker 也执行 `primary_fn`(即"等完再做同样的事"); +- primary 失败 → 其余 worker 立即抛 `RuntimeError`(含原始异常信息),不会傻等到超时; +- 等待超时 → `TimeoutError`; +- 单进程模式退化为直接调用 `primary_fn()`,代码无需分支; +- 基于临时目录标记文件实现,跨 spawn/fork、跨平台(不依赖 fcntl), + 但**不支持跨机器**协调。 + +## 定时任务与多 worker:任务级 `all_workers` + +默认所有定时任务**只在 primary worker 执行一份**(防止重复)。但"刷新本 worker +内存态"的任务必须每个 worker 各跑一份,否则其余 worker 永远用旧数据: + +```python +@component() +class RefreshJobs: + @interval(hours=24, all_workers=True) # 每个 worker 各执行 + def refresh_local_cache(self): + self.items = load_items() # 刷新的是本进程内存 + + @cron("0 2 * * *") # 默认:仅 primary 执行一份 + def daily_report(self): + send_report() +``` + +怎么选: + +| 任务类型 | 配置 | 原因 | +|---|---|---| +| 刷新进程内缓存/内存清单 | `all_workers=True` | 数据在各 worker 自己内存里 | +| 发报表 / 写数据库 / 调外部 API | 默认 | 跑多份会重复副作用 | + +全局开关 `scheduler.on_all_workers: true`(所有任务都在全部 worker 跑)仍然有效, +但通常任务级参数是更对的粒度。`scheduler.enabled: false` 全局禁用一切定时任务。 + +## 服务作用域 `scope` + +```python +@service() # 默认 singleton:每个 worker 进程内一个实例 +@service(scope="request") # 每个请求(asyncio 任务上下文)一个实例 +@service(scope="factory") # 每次注入解析都创建新实例 +``` + +`request` 作用域适合存放请求级上下文,避免单例服务里的共享可变状态。 +`@client` 同样支持 `scope` 参数。 diff --git a/docs/pypi-publishing.md b/docs/pypi-publishing.md new file mode 100644 index 0000000..95a6aa6 --- /dev/null +++ b/docs/pypi-publishing.md @@ -0,0 +1,162 @@ +# PyPI 自动发布配置指南 + +本文档说明如何配置 GitHub Actions 自动发布到 PyPI。 + +## 工作流说明 + +项目已配置 GitHub Actions 工作流 (`.github/workflows/publish.yml`),当创建新的 tag(以 `v` 开头,如 `v1.0.0`)时,会自动构建并发布到 PyPI。 + +## 配置 Trusted Publishing(推荐方式) + +Trusted Publishing 是 PyPI 推荐的安全发布方式,无需使用 API token,通过 OIDC 进行身份验证。 + +### 步骤 1: 在 PyPI 上配置 Trusted Publisher + +1. 登录 [PyPI](https://pypi.org/) +2. 进入您的项目页面 +3. 点击左侧菜单的 **"Publishing"** 或 **"Manage"** → **"Publishing"** +4. 在 **"Trusted publishers"** 部分,点击 **"Add"** +5. 填写以下信息: + - **PyPI project name**: `myboot`(您的项目名称) + - **Publisher name**: 自定义名称,如 `github-actions` + - **Workflow filename**: `publish.yml` + - **Environment name**: 留空(或填写特定环境名称) + - **GitHub Owner**: 您的 GitHub 用户名或组织名 + - **GitHub Repository**: `myboot`(您的仓库名) + - **Workflow filename**: `publish.yml` +6. 点击 **"Add"** 保存 + +### 步骤 2: 验证配置 + +配置完成后,当您创建新的 tag 时,GitHub Actions 会自动触发发布流程: + +```bash +# 创建并推送 tag +git tag v1.0.0 +git push origin v1.0.0 +``` + +工作流会自动: + +1. 检出代码 +2. 安装构建依赖 +3. 构建分发包(wheel 和 sdist) +4. 使用 trusted publishing 发布到 PyPI + +## 使用 API Token 方式(备选) + +如果您不想使用 trusted publishing,也可以使用传统的 API token 方式: + +### 步骤 1: 创建 PyPI API Token + +1. 登录 [PyPI](https://pypi.org/) +2. 进入 **"Account settings"** → **"API tokens"** +3. 点击 **"Add API token"** +4. 填写: + - **Token name**: 如 `github-actions-publish` + - **Scope**: 选择 **"Project: myboot"**(项目范围)或 **"Entire account"**(账户范围) +5. 复制生成的 token(只显示一次,请妥善保存) + +### 步骤 2: 在 GitHub 中配置 Secret + +1. 进入您的 GitHub 仓库 +2. 点击 **"Settings"** → **"Secrets and variables"** → **"Actions"** +3. 点击 **"New repository secret"** +4. 填写: + - **Name**: `PYPI_API_TOKEN` + - **Secret**: 粘贴刚才复制的 API token +5. 点击 **"Add secret"** + +### 步骤 3: 修改工作流文件 + +修改 `.github/workflows/publish.yml`,添加 `password` 参数: + +```yaml +- name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + print-hash: true +``` + +**注意**: 使用 API token 会禁用 trusted publishing,但两种方式不能同时使用。 + +## Tag 命名规则 + +工作流配置为匹配以 `v` 开头的 tag: + +- ✅ `v1.0.0` - 匹配 +- ✅ `v0.1.0` - 匹配 +- ✅ `v2.3.4` - 匹配 +- ❌ `1.0.0` - 不匹配(缺少 `v` 前缀) +- ❌ `release-1.0.0` - 不匹配 + +## 发布流程 + +1. **更新版本号**: 在 `pyproject.toml` 中更新 `version` 字段 +2. **提交更改**: 提交并推送代码到仓库 +3. **创建 Tag**: 创建并推送以 `v` 开头的 tag +4. **自动发布**: GitHub Actions 会自动触发发布流程 + +```bash +# 示例流程 +# 1. 更新 pyproject.toml 中的版本号 +# version = "1.0.0" + +# 2. 提交更改 +git add pyproject.toml +git commit -m "Bump version to 1.0.0" +git push + +# 3. 创建并推送 tag +git tag v1.0.0 +git push origin v1.0.0 +``` + +## 验证发布 + +发布完成后,您可以: + +1. 在 [PyPI 项目页面](https://pypi.org/project/myboot/) 查看新版本 +2. 使用 pip 安装测试: + ```bash + pip install myboot==1.0.0 + ``` + +## 故障排查 + +### 问题 1: Trusted Publishing 配置失败 + +**错误信息**: `403 Client Error: Invalid or non-existent authentication information` + +**解决方案**: + +- 检查 PyPI 上的 trusted publisher 配置是否正确 +- 确认 GitHub 仓库名称、工作流文件名等信息匹配 +- 确保工作流文件中的 `permissions` 包含 `id-token: write` + +### 问题 2: 版本已存在 + +**错误信息**: `File already exists` + +**解决方案**: + +- 检查 PyPI 上是否已存在该版本 +- 如果确实需要重新发布,需要先删除 PyPI 上的版本(不推荐) +- 或者使用新的版本号 + +### 问题 3: 构建失败 + +**错误信息**: 构建步骤失败 + +**解决方案**: + +- 检查 `pyproject.toml` 配置是否正确 +- 确认所有必需的文件都已包含在分发包中 +- 查看 GitHub Actions 日志获取详细错误信息 + +## 参考资源 + +- [PyPI Trusted Publishers 文档](https://docs.pypi.org/trusted-publishers/) +- [pypa/gh-action-pypi-publish 项目](https://github.com/pypa/gh-action-pypi-publish) +- [PyPI 发布指南](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/) diff --git a/docs/rest-api-async-tasks.md b/docs/rest-api-async-tasks.md new file mode 100644 index 0000000..8af6d10 --- /dev/null +++ b/docs/rest-api-async-tasks.md @@ -0,0 +1,505 @@ +# REST API 中使用异步任务 + +在 REST API 中,当需要执行耗时操作时,可以使用异步任务来避免阻塞请求响应。MyBoot 提供了多种方式来在 REST API 中使用异步任务。 + +## 目录 + +- [快速启动后台任务](#快速启动后台任务) +- [使用 ScheduledJob](#使用-scheduledjob) +- [异步路由处理](#异步路由处理) +- [任务状态查询](#任务状态查询) +- [完整示例](#完整示例) + +## 快速启动后台任务 + +使用 `async_run` 函数可以快速在后台启动异步任务,适用于不需要跟踪任务状态的场景。 + +### 基本用法 + +```python +from myboot.core.decorators import post, rest_controller +from myboot.utils.async_utils import async_run +import time + +def process_data(data: dict): + """耗时的数据处理任务""" + print(f"开始处理数据: {data}") + time.sleep(5) # 模拟耗时操作 + print(f"数据处理完成: {data}") + return {"processed": True, "data": data} + +@rest_controller('/api/tasks') +class TaskController: + """任务控制器""" + + @post('/process') + def create_process_task(self, data: dict): + """创建数据处理任务""" + # 立即返回,任务在后台执行 + task = async_run(process_data, data, task_name="数据处理任务") + + return { + "message": "任务已创建,正在后台处理", + "task_id": str(id(task)), + "status": "pending" + } +``` + +### 带参数的任务 + +```python +from myboot.utils.async_utils import async_run + +def send_email(to: str, subject: str, content: str): + """发送邮件任务""" + print(f"发送邮件到 {to}: {subject}") + # 模拟邮件发送 + time.sleep(2) + return {"sent": True, "to": to} + +@post('/api/emails') +def send_email_async(to: str, subject: str, content: str): + """异步发送邮件""" + # 启动后台任务 + async_run(send_email, to, subject, content, task_name=f"发送邮件给{to}") + + return { + "message": "邮件发送任务已创建", + "recipient": to + } +``` + +## 使用 ScheduledJob + +对于需要跟踪和管理任务状态的场景,建议使用 `ScheduledJob`。 + +### 使用 ScheduledJob + +```python +from myboot.core.decorators import post, get, rest_controller +from myboot.jobs.scheduled_job import ScheduledJob +from myboot.core.scheduler import get_scheduler +import time + +@rest_controller('/api/reports') +class ReportController: + """报告控制器""" + + def __init__(self): + self.scheduler = get_scheduler() + + @post('/generate') + def generate_report_task(self, report_type: str, filters: dict = None): + """创建报告生成任务""" + # 创建自定义 ScheduledJob + class ReportJob(ScheduledJob): + def __init__(self, report_type: str, filters: dict): + super().__init__( + name=f"生成{report_type}报告", + description=f"生成类型为 {report_type} 的报告", + max_retries=3, + timeout=300 # 5分钟超时 + ) + self.report_type = report_type + self.filters = filters or {} + + def run(self, *args, **kwargs): + """生成报告任务""" + print(f"开始生成 {self.report_type} 报告") + time.sleep(10) # 模拟报告生成 + return { + "type": self.report_type, + "filters": self.filters, + "status": "completed" + } + + # 创建任务实例 + job = ReportJob(report_type, filters) + + # 添加到调度器(用于状态跟踪,非定时任务) + job_id = self.scheduler.add_job_object(job) + + # 在后台执行任务 + import threading + thread = threading.Thread(target=job.execute) + thread.daemon = True + thread.start() + + return { + "message": "报告生成任务已创建", + "job_id": job_id, + "status": "pending" + } + + @get('/status/{job_id}') + def get_report_status(self, job_id: str): + """查询任务状态""" + job = self.scheduler.get_scheduled_job(job_id) + + if not job: + return { + "error": "任务不存在" + } + + job_info = job.get_info() + return { + "job_id": job_id, + "status": job_info["status"], + "progress": self._calculate_progress(job_info), + "created_at": job_info["created_at"], + "started_at": job_info["started_at"], + "completed_at": job_info["completed_at"] + } + + def _calculate_progress(self, job_info: dict) -> float: + """计算任务进度(示例)""" + if job_info["status"] == "completed": + return 100.0 + elif job_info["status"] == "running": + # 可以根据实际业务逻辑计算进度 + return 50.0 + else: + return 0.0 +``` + +### 使用自定义 ScheduledJob 类 + +```python +from myboot.jobs.scheduled_job import ScheduledJob +from myboot.core.decorators import post, get, rest_controller +from myboot.core.scheduler import get_scheduler + +class DataImportJob(ScheduledJob): + """数据导入任务""" + + def __init__(self, file_path: str, **kwargs): + super().__init__( + name="数据导入", + description=f"从文件 {file_path} 导入数据", + **kwargs + ) + self.file_path = file_path + + def run(self, *args, **kwargs): + """执行数据导入""" + import time + print(f"开始导入文件: {self.file_path}") + + # 模拟数据导入过程 + for i in range(10): + time.sleep(1) + print(f"导入进度: {(i+1)*10}%") + + return { + "file_path": self.file_path, + "records_imported": 1000, + "status": "completed" + } + +@rest_controller('/api/import') +class ImportController: + """数据导入控制器""" + + def __init__(self): + self.scheduler = get_scheduler() + + @post('/start') + def start_import(self, file_path: str): + """启动数据导入任务""" + job = DataImportJob(file_path) + + # 添加到调度器(用于状态跟踪,非定时任务) + job_id = self.scheduler.add_job_object(job) + + # 在后台执行 + import threading + thread = threading.Thread(target=job.execute) + thread.daemon = True + thread.start() + + return { + "message": "数据导入任务已启动", + "job_id": job_id, + "file_path": file_path + } + + @get('/jobs') + def list_jobs(self): + """列出所有任务""" + # 获取所有 ScheduledJob 对象 + jobs = self.scheduler.get_all_scheduled_jobs() + all_jobs = [job.get_info() for job in jobs] + + return { + "jobs": all_jobs, + "total": len(all_jobs) + } +``` + +## 任务状态查询 + +### 使用调度器查询 + +```python +from myboot.core.decorators import get, rest_controller +from myboot.core.scheduler import get_scheduler + +@rest_controller('/api/jobs') +class JobStatusController: + """任务状态控制器""" + + def __init__(self): + self.scheduler = get_scheduler() + + @get('/{job_id}') + def get_job_status(self, job_id: str): + """获取任务状态""" + job = self.scheduler.get_scheduled_job(job_id) + + if not job: + return { + "error": "任务不存在" + } + + return job.get_info() + + @get('/') + def list_all_jobs(self): + """列出所有任务""" + # 获取所有 ScheduledJob 对象 + jobs = self.scheduler.get_all_scheduled_jobs() + all_jobs = [job.get_info() for job in jobs] + + # 计算统计信息 + total = len(all_jobs) + running = sum(1 for j in all_jobs if j["status"] == "running") + completed = sum(1 for j in all_jobs if j["status"] == "completed") + failed = sum(1 for j in all_jobs if j["status"] == "failed") + + statistics = { + "total": total, + "running": running, + "completed": completed, + "failed": failed, + "success_rate": completed / total if total > 0 else 0 + } + + return { + "jobs": all_jobs, + "statistics": statistics + } + + @get('/statistics') + def get_statistics(self): + """获取任务统计信息""" + # 获取所有 ScheduledJob 对象 + jobs = self.scheduler.get_all_scheduled_jobs() + all_jobs = [job.get_info() for job in jobs] + + total = len(all_jobs) + running = sum(1 for j in all_jobs if j["status"] == "running") + completed = sum(1 for j in all_jobs if j["status"] == "completed") + failed = sum(1 for j in all_jobs if j["status"] == "failed") + + return { + "total": total, + "running": running, + "completed": completed, + "failed": failed, + "success_rate": completed / total if total > 0 else 0 + } +``` + +以下是一个完整的示例,展示如何在 REST API 中实现文件上传和异步处理: + +```python +from myboot.core.decorators import post, get, rest_controller +from myboot.jobs.scheduled_job import ScheduledJob +from myboot.core.scheduler import get_scheduler +from myboot.utils.async_utils import async_run +import time +import uuid + +@rest_controller('/api/files') +class FileController: + """文件处理控制器""" + + def __init__(self): + self.scheduler = get_scheduler() + self._file_storage = {} # 简单的存储,实际应使用数据库 + + @post('/upload') + def upload_file(self, file_path: str, options: dict = None): + """上传文件并创建处理任务""" + # 生成任务 ID + task_id = str(uuid.uuid4()) + + # 创建自定义 ScheduledJob + class FileProcessJob(ScheduledJob): + def __init__(self, file_path: str, options: dict, task_id: str): + super().__init__( + name=f"处理文件-{task_id}", + description=f"处理上传的文件: {file_path}", + max_retries=3, + timeout=600 # 10分钟超时 + ) + self.file_path = file_path + self.options = options or {} + + def run(self, *args, **kwargs): + """处理上传的文件""" + print(f"开始处理文件: {self.file_path}") + + # 模拟文件处理过程 + for i in range(20): + time.sleep(0.5) + print(f"处理进度: {(i+1)*5}%") + + return { + "file_path": self.file_path, + "processed": True, + "records": 1000, + "options": self.options + } + + # 创建处理任务 + job = FileProcessJob(file_path, options, task_id) + + # 添加到调度器(用于状态跟踪,非定时任务) + job_id = self.scheduler.add_job_object(job) + + # 保存文件信息 + self._file_storage[task_id] = { + "job_id": job_id, + "file_path": file_path, + "status": "pending", + "created_at": time.time() + } + + # 在后台执行任务 + import threading + thread = threading.Thread(target=self._execute_job, args=(job, task_id)) + thread.daemon = True + thread.start() + + return { + "message": "文件上传成功,处理任务已创建", + "task_id": task_id, + "job_id": job_id, + "status": "pending" + } + + def _execute_job(self, job, task_id: str): + """执行任务并更新状态""" + try: + result = job.execute() + self._file_storage[task_id]["status"] = "completed" + self._file_storage[task_id]["result"] = result + except Exception as e: + self._file_storage[task_id]["status"] = "failed" + self._file_storage[task_id]["error"] = str(e) + + @get('/status/{task_id}') + def get_file_status(self, task_id: str): + """查询文件处理状态""" + if task_id not in self._file_storage: + return { + "error": "任务不存在" + } + + file_info = self._file_storage[task_id] + job = self.scheduler.get_scheduled_job(file_info["job_id"]) + job_info = job.get_info() if job else None + + return { + "task_id": task_id, + "file_path": file_info["file_path"], + "status": file_info.get("status", "unknown"), + "job_info": job_info, + "result": file_info.get("result"), + "error": file_info.get("error") + } + + @get('/tasks') + def list_tasks(self): + """列出所有文件处理任务""" + return { + "tasks": list(self._file_storage.values()), + "total": len(self._file_storage) + } +``` + +## 最佳实践 + +### 1. 选择合适的异步方式 + +- **简单任务,无需跟踪**:使用 `async_run` +- **需要跟踪状态**:使用 `ScheduledJob`(继承并实现 `run` 方法) +- **需要定时执行**:使用 `@component` + `@cron`/`@interval`/`@once` 装饰器 + +### 2. 任务超时设置 + +任务超时功能支持跨平台(Windows、Linux、macOS),使用 `ThreadPoolExecutor` 实现: + +```python +class MyTask(ScheduledJob): + def __init__(self): + super().__init__( + name="我的任务", + timeout=300 # 设置5分钟超时 + ) + + def run(self, *args, **kwargs): + # 任务逻辑 + pass +``` + +**注意**: +- 超时功能在 Windows、Linux 和 macOS 上均可正常工作 +- 超时后会抛出 `TimeoutError` 异常 +- 由于 Python GIL 的限制,超时后任务线程可能仍在后台运行,但不会再等待其结果 + +### 3. 错误处理和重试 + +```python +class MyTask(ScheduledJob): + def __init__(self): + super().__init__( + name="我的任务", + max_retries=3, + retry_delay=5.0 # 失败后等待5秒再重试 + ) + + def run(self, *args, **kwargs): + # 任务逻辑 + pass +``` + +### 4. 资源清理 + +```python +from myboot.utils.async_utils import cleanup_async_executor + +# 在应用关闭时清理 +@app.add_shutdown_hook +def shutdown_hook(): + cleanup_async_executor() +``` + +### 5. 任务状态管理 + +建议使用数据库或 Redis 来持久化任务状态,而不是内存存储。 + +## 注意事项 + +1. **线程安全**:`Scheduler` 是线程安全的,可以在多个线程中使用 +2. **任务执行**:使用 `threading.Thread` 在后台执行任务,避免阻塞主线程 +3. **资源管理**:长时间运行的应用应定期清理已完成的任务 +4. **错误处理**:确保任务函数有适当的错误处理,避免任务失败影响系统 +5. **ScheduledJob 使用**:对于非定时任务,可以直接创建 `ScheduledJob` 实例并执行,无需添加到调度器 + +## 相关文档 + +- [异步工具使用指南](../myboot/utils/async_utils.py) +- [调度器文档](../myboot/core/scheduler.py) +- [ScheduledJob 基类文档](../myboot/jobs/scheduled_job.py) diff --git a/docs/rest-api-response-format.md b/docs/rest-api-response-format.md new file mode 100644 index 0000000..3a5bd48 --- /dev/null +++ b/docs/rest-api-response-format.md @@ -0,0 +1,263 @@ +# REST API 统一响应格式 + +MyBoot 框架提供了统一的 REST API 响应格式封装,确保所有 API 返回一致的格式。 + +## 响应格式结构 + +### 成功响应 + +```json +{ + "success": true, + "code": 200, + "message": "操作成功", + "data": { + // 实际业务数据 + } +} +``` + +### 错误响应 + +```json +{ + "success": false, + "code": 422, + "message": "参数校验失败", + "data": { + "fieldErrors": [ + { + "field": "username", + "message": "用户名长度必须在3-20个字符之间" + } + ] + } +} +``` + +## 使用方法 + +### 1. 自动格式化(推荐) + +默认情况下,框架会自动将所有路由的响应包装为统一格式。你只需要在路由函数中返回业务数据即可: + +```python +from myboot.core.decorators import get, post +from myboot.core.application import app + +@get('/users') +def get_users(): + """获取用户列表""" + users = [{"id": 1, "name": "张三"}, {"id": 2, "name": "李四"}] + return {"users": users} # 自动包装为统一格式 +``` + +实际返回: + +```json +{ + "success": true, + "code": 200, + "message": "操作成功", + "data": { + "users": [ + { "id": 1, "name": "张三" }, + { "id": 2, "name": "李四" } + ] + } +} +``` + +### 2. 手动使用响应包装器 + +如果你需要自定义响应消息,可以使用响应包装器: + +```python +from myboot.web.response import response + +@get('/users/{user_id}') +def get_user(user_id: int): + """获取单个用户""" + user = {"id": user_id, "name": "张三"} + + # 使用响应包装器 + return response.success( + data=user, + message="查询成功", + code=200 + ) +``` + +### 3. 便捷方法 + +响应包装器提供了多个便捷方法: + +```python +from myboot.web.response import response + +# 创建成功(201) +@post('/users') +def create_user(name: str, email: str): + user = create_user_service(name, email) + return response.created(data=user, message="用户创建成功") + +# 更新成功 +@put('/users/{user_id}') +def update_user(user_id: int, name: str): + user = update_user_service(user_id, name) + return response.updated(data=user, message="用户更新成功") + +# 删除成功 +@delete('/users/{user_id}') +def delete_user(user_id: int): + delete_user_service(user_id) + return response.deleted(message="用户删除成功") + +# 分页响应 +@get('/users') +def get_users(page: int = 1, size: int = 10): + users, total = get_users_service(page, size) + return response.pagination( + data=users, + total=total, + page=page, + size=size, + message="查询成功" + ) +``` + +## 配置选项 + +### 启用/禁用自动格式化 + +在配置文件中设置: + +```yaml +server: + response_format: + enabled: true # 是否启用自动格式化 + exclude_paths: # 排除的路径(这些路径不会自动格式化) + - "/custom/path" + - "/another/path" +``` + +或者在代码中(通过配置参数): + +```python +app = Application( + name="My App" +) +# 配置在 config.yaml 中设置: +# server: +# response_format: +# enabled: true +# exclude_paths: +# - "/custom/path" +``` + +### 默认排除的路径 + +以下路径默认不会被格式化(系统路径和文档路径): + +- `/docs` +- `/openapi.json` +- `/redoc` +- `/health` +- `/health/ready` +- `/health/live` + +## 异常响应格式 + +框架已经统一了异常响应格式,所有异常都会自动返回统一格式: + +### 验证错误(422) + +```json +{ + "success": false, + "code": 422, + "message": "Validation Error", + "data": { + "fieldErrors": [ + { + "field": "username", + "message": "用户名格式不正确" + } + ] + } +} +``` + +### HTTP 错误(4xx, 5xx) + +```json +{ + "success": false, + "code": 404, + "message": "HTTP Error", + "data": {} +} +``` + +### 服务器错误(500) + +```json +{ + "success": false, + "code": 500, + "message": "Internal Server Error", + "data": { + "type": "ExceptionClassName" + } +} +``` + +## 注意事项 + +1. **已经是统一格式的响应不会被重复包装**:如果你手动返回统一格式的响应,中间件会检测并直接返回。 + +2. **非 JSON 响应不会格式化**:只有 `JSONResponse` 类型的响应会被格式化。 + +3. **排除路径不会被格式化**:配置在排除列表中的路径不会被自动格式化,适用于需要返回原始格式的接口。 + +4. **中间件执行顺序**:响应格式化中间件是最后添加的,因此会最先执行(FastAPI 中间件是 LIFO)。 + +## 示例代码 + +完整示例: + +```python +from myboot.core.application import Application +from myboot.core.decorators import get, post, put, delete +from myboot.web.response import response + +app = Application(name="My API") + +# 自动格式化 +@get('/users') +def get_users(): + return {"users": [...]} # 自动包装 + +# 手动格式化(自定义消息) +@get('/users/{user_id}') +def get_user(user_id: int): + user = get_user_by_id(user_id) + return response.success(data=user, message="查询成功") + +# 创建操作 +@post('/users') +def create_user(name: str, email: str): + user = create_user(name, email) + return response.created(data=user, message="用户创建成功") + +# 分页 +@get('/posts') +def get_posts(page: int = 1, size: int = 10): + posts, total = get_posts_paged(page, size) + return response.pagination( + data=posts, + total=total, + page=page, + size=size + ) +``` diff --git a/docs/scheduler.md b/docs/scheduler.md new file mode 100644 index 0000000..5801a98 --- /dev/null +++ b/docs/scheduler.md @@ -0,0 +1,271 @@ +# 任务调度器使用说明 + +MyBoot 内置基于 [APScheduler](https://apscheduler.readthedocs.io/) 的任务调度器,支持 Cron、固定间隔与一次性任务。应用启动时,若已注册任务且调度器已启用,会自动启动调度。 + +## 1. 快速开始(推荐) + +定时任务**必须**定义在 `@component` 装饰的类中,由自动配置在组件注册时扫描并加入 `app.scheduler`。 + +```python +from myboot.core.decorators import component, cron, interval, once +from myboot.core.config import get_config + +@component() +class DataSyncJobs: + """定时任务组件""" + + @cron("0 2 * * *") # 每天 02:00(5 位 Cron) + def sync_daily(self): + print("每日同步") + + @interval(minutes=30) # 每 30 分钟 + def health_check(self): + print("健康检查") + + @once("2025-12-31 23:59:59") # 指定时刻执行一次 + def year_end_task(self): + print("年末任务") + + @cron("0 */5 * * *", enabled=get_config("jobs.report.enabled", True)) + def report(self): + """enabled 可从配置读取,为 False 时不注册""" + print("报表任务") +``` + +**要求与约定:** + +| 项 | 说明 | +|----|------| +| 类装饰器 | 必须使用 `@component()`,不支持模块级函数或 `@service` 类中的 `@cron` | +| 方法可见性 | 以 `_` 开头的私有方法不会被扫描 | +| 依赖注入 | 可在组件 `__init__` 中注入 `@service`,在任务方法内使用 | +| 包扫描 | 任务类需位于自动发现包内(默认 `app`),见项目启动与 `auto_discover` 配置 | + +应用生命周期中,存在已注册任务且调度器启用时,会在启动钩子阶段调用 `scheduler.start()`,关闭时 `scheduler.stop()`。 + +## 2. 配置 + +在 `config.yaml` 或环境变量中配置(环境变量嵌套键使用双下划线 `__`,详见 [配置管理使用说明](./configuration.md))。 + +```yaml +scheduler: + enabled: true # 是否允许启动调度器 + timezone: "Asia/Shanghai" # 任务触发时区(建议显式设置) + max_workers: 10 # 线程池大小 + on_all_workers: false # 多 worker 时是否在非 primary 进程也启用 +``` + +| 配置项 | 环境变量示例 | 默认值 | 说明 | +|--------|----------------|--------|------| +| `scheduler.enabled` | `SCHEDULER__ENABLED=false` | `true` | 为 `false` 时调度器不启动 | +| `scheduler.timezone` | `SCHEDULER__TIMEZONE=Asia/Shanghai` | `UTC` | 所有触发器使用的时区 | +| `scheduler.max_workers` | `SCHEDULER__MAX_WORKERS=20` | `10` | 并发执行任务的上限 | +| `scheduler.on_all_workers` | `SCHEDULER__ON_ALL_WORKERS=true` | `false` | 见下文「多 Worker」 | + +`config.py` 中默认片段: + +```yaml +scheduler: + enabled: true + timezone: "UTC" + max_workers: 10 +``` + +## 3. Cron 表达式 + +解析逻辑见 `myboot/core/scheduler.py` 中 `_parse_cron`: + +1. 优先使用 `CronTrigger.from_crontab`(**5 位**,Unix/APScheduler 风格) +2. 解析失败时回退为手动构造 `CronTrigger`(支持 **5 位** 或 **6 位**) + +### 3.1 五位格式(推荐) + +顺序:**分 · 时 · 日 · 月 · 周** + +``` +分 时 日 月 周 +│ │ │ │ └── 星期(0=周一 … 6=周日,见下表) +│ │ │ └────── 月(1-12 或 *) +│ │ └────────── 日(1-31 或 *) +│ └────────────── 时(0-23) +└────────────────── 分(0-59) +``` + +**星期字段(APScheduler `from_crontab`)与 Linux crontab 不同:** + +| 值 | APScheduler(本项目 5 位默认路径) | 传统 Linux crontab | +|----|-----------------------------------|---------------------| +| 0 | 周一 | 周日 | +| 1 | 周二 | 周一 | +| 2 | 周三 | 周二 | +| … | … | … | +| 6 | 周日 | 周六 | + +> 使用 5 位表达式时,请按 **APScheduler 星期编号**理解,避免与系统 `crontab` 的「周日=0」混淆。 + +**常用示例(5 位,时区以 `scheduler.timezone` 为准):** + +| 表达式 | 含义 | +|--------|------| +| `0 * * * *` | 每小时整点 | +| `0 2 * * *` | 每天 02:00 | +| `0 23 * * 1` | 每周二 23:00(周字段 `1` = 周二) | +| `30 8 * * 0` | 每周一 08:30 | +| `*/15 * * * *` | 每 15 分钟 | +| `0 9-17 * * 1-5` | 工作日 09:00–17:00 每小时整点 | + +### 3.2 六位格式(兼容旧写法) + +顺序:**秒 · 分 · 时 · 日 · 月 · 周** + +仅在 `from_crontab` 无法解析时走手动分支,例如: + +| 表达式 | 含义 | +|--------|------| +| `0 0 * * * *` | 每小时整点(秒=0,分=0) | +| `0 */5 * * * *` | 每 5 分钟 | +| `0 0 2 * * *` | 每天 02:00:00 | + +### 3.3 字段支持 + +支持 APScheduler 常见写法:`*`、`,`、`-`、`/` 及范围。复杂表达式以 [APScheduler Cron 文档](https://apscheduler.readthedocs.io/en/stable/modules/triggers/cron.html) 为准。 + +## 4. 装饰器 API + +### 4.1 `@cron` + +```python +@cron(cron_expression: str, enabled: bool | None = None, **kwargs) +``` + +- `cron_expression`:5 位或 6 位 Cron 字符串 +- `enabled`:`False` 时跳过注册;`None` 默认启用 +- `**kwargs`:传给 APScheduler `add_job` 的额外参数(如 `name`、`max_instances`) + +### 4.2 `@interval` + +```python +@interval(seconds=None, minutes=None, hours=None, enabled=None, **kwargs) +``` + +三者至少指定其一,内部统一换算为秒。例如 `@interval(minutes=5)` 每 5 分钟执行。 + +### 4.3 `@once` + +```python +@once(run_date: str, enabled=None, **kwargs) +``` + +`run_date` 支持格式: + +- `YYYY-MM-DD HH:MM:SS` +- `YYYY-MM-DD HH:MM` +- `YYYY-MM-DD`(当天 00:00:00) + +时间为 **naive**,由调度器全局时区 `scheduler.timezone` 解释。 + +## 5. 多 Worker 与进程模型 + +多进程部署(`server.workers > 1`)时: + +- 默认**仅 primary worker**(`MYBOOT_IS_PRIMARY_WORKER=1`)启用调度器,避免重复执行 +- 设置 `scheduler.on_all_workers: true` 可在每个 worker 都运行调度(一般仅特殊场景需要) + +任务在**线程池**中执行;`max_instances` 默认为 3(APScheduler `job_defaults`),同一任务并发实例数受此限制。 + +## 6. 编程式 API + +除装饰器外,可通过 `app.scheduler` 或 `get_scheduler()` 动态管理任务。 + +```python +from myboot.core.scheduler import get_scheduler + +scheduler = get_scheduler() # 注意:与 Application 可能不是同一实例,推荐用 app.scheduler + +job_id = scheduler.add_cron_job(func=my_func, cron="0 2 * * *") +scheduler.add_interval_job(func=my_func, interval=60) +scheduler.add_date_job(func=my_func, run_date="2025-12-31 23:59:59") + +scheduler.remove_job(job_id) +info = scheduler.get_job_info(job_id) +all_jobs = scheduler.list_all_jobs() +``` + +| 方法 | 说明 | +|------|------| +| `add_cron_job(func, cron, job_id=None, **kwargs)` | 添加 Cron 任务 | +| `add_interval_job(func, interval, job_id=None, **kwargs)` | `interval` 单位为秒 | +| `add_date_job(func, run_date, job_id=None, **kwargs)` | 一次性任务 | +| `remove_job(job_id)` | 移除任务 | +| `get_job_info(job_id)` | 任务类型、下次执行时间等 | +| `list_all_jobs()` | 所有任务摘要 | +| `start()` / `stop()` | 启停(应用生命周期通常自动处理) | +| `is_enabled()` / `is_running()` / `has_jobs()` | 状态查询 | +| `get_config()` | 当前调度器配置摘要 | + +未指定 `job_id` 时,默认生成 `cron_{模块名}.{限定名}`(如 +`cron_app.jobs.SyncJobs.daily_sync`),`interval_`/`date_` 前缀同理。 +限定名包含类名,因此不同类中的同名方法不会冲突(0.2.0 起,issue #14; +此前为 `cron_{函数名}`,同名方法会因 ID 冲突导致启动失败)。 + +注册时即校验 ID 唯一性:显式传入重复的 `job_id` 会抛出 `SchedulerError`; +自动生成的 ID 重复(同一函数注册多次)会追加 8 位随机后缀并记录 warning。 + +### 6.1 `ScheduledJob` 类 + +继承 `myboot.jobs.scheduled_job.ScheduledJob` 可实现带重试、状态跟踪的任务,并通过 `add_scheduled_job` 注册: + +```python +from myboot.jobs.scheduled_job import ScheduledJob + +class CleanupJob(ScheduledJob): + def run(self): + # 业务逻辑 + return "ok" + +job = CleanupJob(name="cleanup", trigger="0 3 * * *") # 或 trigger={'type': 'cron', 'cron': '...'} +app.scheduler.add_scheduled_job(job) +``` + +`trigger` 可为 Cron 字符串或字典:`cron` / `interval`(seconds/minutes/hours/days)/ `date`(`run_date`)。 + +## 7. 运维与排查 + +```python +# 应用内 +print(app.scheduler.get_config()) +for job in app.scheduler.list_all_jobs(): + print(job["job_id"], job.get("type"), job.get("next_run_time")) + +# 健康检查等可结合 application 状态中的 scheduler 字段 +``` + +日志:调度器绑定 logger 名 `scheduler`,注册任务时会输出 `已添加 Cron 任务` 等信息。 + +**任务未执行时检查:** + +1. `scheduler.enabled` 是否为 `true` +2. 多 worker 下当前进程是否为 primary(或已开启 `on_all_workers`) +3. 装饰器 `enabled=False` 是否被跳过 +4. Cron 位数与星期编号是否符合上文约定 +5. `timezone` 是否与预期一致 +6. 应用启动时 `has_jobs()` 是否为真(无任务不会 `start()`) + +## 8. 最佳实践 + +1. **统一用 5 位 Cron**,减少与 6 位混用带来的理解成本。 +2. **显式设置 `scheduler.timezone`**(如 `Asia/Shanghai`),避免默认 UTC 造成「时间差 8 小时」。 +3. **任务逻辑保持幂等**;错过触发时 APScheduler 有 `misfire_grace_time`(默认 30 秒),但仍可能补跑。 +4. **长耗时任务**注意 `max_workers` 与 `max_instances`,避免占满线程池。 +5. **配置开关**用 `enabled=get_config('jobs.xxx.enabled', True)`,便于按环境关闭任务。 +6. **IO 密集或阻塞操作**在任务内自行控制超时与异常,避免拖垮调度线程。 + +## 9. 相关文档与代码 + +| 资源 | 说明 | +|------|------| +| [scheduler_refactor_analysis.md](./scheduler_refactor_analysis.md) | APScheduler 重构与能力对照(偏设计) | +| [dependency-injection.md](./dependency-injection.md) | 含定时任务的 `@component` 示例 | +| `myboot/core/scheduler.py` | 调度器实现 | +| `myboot/core/decorators.py` | `@cron` / `@interval` / `@once` | +| `examples/convention_app.py` | 完整示例 `ScheduledJobs` 组件 | diff --git a/docs/scheduler_refactor_analysis.md b/docs/scheduler_refactor_analysis.md new file mode 100644 index 0000000..af4145c --- /dev/null +++ b/docs/scheduler_refactor_analysis.md @@ -0,0 +1,354 @@ +# 调度器重构可行性分析:使用 APScheduler + +> **使用说明**:日常开发与配置 Cron/间隔任务请参阅 [scheduler.md](./scheduler.md)。 + +## 1. 当前实现分析 + +### 1.1 现有功能 +- **任务类型支持**: + - Cron 任务(6位表达式:秒 分 时 日 月 周) + - 间隔任务(interval,支持秒/分/小时) + - 一次性任务(date,指定日期时间执行) + +- **API 接口**: + - `add_cron_job(func, cron, job_id, **kwargs)` + - `add_interval_job(func, interval, job_id, **kwargs)` + - `add_date_job(func, run_date, job_id, **kwargs)` + - `remove_job(job_id)` + - `get_job(job_id)` + - `list_jobs()` + - `start()` / `stop()` + - `add_scheduled_job(job: ScheduledJob)` + +- **集成点**: + - 与 `Application` 生命周期集成(启动/停止) + - 自动配置系统自动注册装饰器标记的任务 + - 支持 `ScheduledJob` 类对象 + - 配置支持(时区、最大工作线程数、启用/禁用) + +### 1.2 当前实现的问题 +- 自定义实现,维护成本高 +- Cron 表达式解析逻辑复杂,可能存在边界情况 +- 时区处理需要手动实现(依赖 pytz) +- 任务执行状态跟踪有限 +- 缺少任务持久化能力 +- 错误处理和重试机制简单 + +## 2. APScheduler 能力分析 + +### 2.1 APScheduler 核心特性 +- **触发器(Triggers)**: + - `CronTrigger`:完整的 Cron 表达式支持(5位或6位) + - `IntervalTrigger`:固定间隔执行 + - `DateTrigger`:指定日期时间执行 + +- **执行器(Executors)**: + - `ThreadPoolExecutor`:线程池执行 + - `ProcessPoolExecutor`:进程池执行 + - `AsyncIOExecutor`:异步执行 + - `GeventExecutor`:Gevent 协程执行 + +- **任务存储(Job Stores)**: + - `MemoryJobStore`:内存存储(默认) + - `SQLAlchemyJobStore`:数据库持久化 + - `RedisJobStore`:Redis 持久化 + - `MongoDBJobStore`:MongoDB 持久化 + +- **调度器(Schedulers)**: + - `BlockingScheduler`:阻塞式调度器 + - `BackgroundScheduler`:后台线程调度器(推荐) + - `AsyncIOScheduler`:异步调度器 + - `GeventScheduler`:Gevent 调度器 + - `TornadoScheduler`:Tornado 调度器 + - `TwistedScheduler`:Twisted 调度器 + +### 2.2 APScheduler 优势 +- ✅ 成熟稳定,社区活跃 +- ✅ 完整的 Cron 表达式支持(包括复杂表达式) +- ✅ 内置时区支持(无需手动处理) +- ✅ 任务持久化能力 +- ✅ 丰富的任务状态和事件监听 +- ✅ 更好的错误处理和日志 +- ✅ 支持任务暂停/恢复 +- ✅ 支持任务修改(修改触发器) + +## 3. 功能映射关系 + +### 3.1 任务类型映射 + +| 当前实现 | APScheduler | 映射方式 | +|---------|------------|---------| +| `add_cron_job(func, cron, ...)` | `scheduler.add_job(func, CronTrigger.from_crontab(cron), ...)` | ✅ 直接映射 | +| `add_interval_job(func, interval, ...)` | `scheduler.add_job(func, IntervalTrigger(seconds=interval), ...)` | ✅ 直接映射 | +| `add_date_job(func, run_date, ...)` | `scheduler.add_job(func, DateTrigger(run_date=...), ...)` | ✅ 直接映射 | + +### 3.2 API 接口映射 + +| 当前方法 | APScheduler 对应方法 | 兼容性 | +|---------|-------------------|--------| +| `add_cron_job()` | `add_job(..., trigger=CronTrigger(...))` | ✅ 可封装保持兼容 | +| `add_interval_job()` | `add_job(..., trigger=IntervalTrigger(...))` | ✅ 可封装保持兼容 | +| `add_date_job()` | `add_job(..., trigger=DateTrigger(...))` | ✅ 可封装保持兼容 | +| `remove_job(job_id)` | `remove_job(job_id)` | ✅ 完全兼容 | +| `get_job(job_id)` | `get_job(job_id)` | ✅ 完全兼容 | +| `list_jobs()` | `get_jobs()` | ✅ 可封装保持兼容 | +| `start()` | `start()` | ✅ 完全兼容 | +| `stop()` | `shutdown()` | ⚠️ 需要封装(方法名不同) | + +### 3.3 配置映射 + +| 当前配置 | APScheduler 配置 | 映射方式 | +|---------|-----------------|---------| +| `scheduler.enabled` | 通过 `start()` 控制 | ✅ 逻辑控制 | +| `scheduler.timezone` | `timezone` 参数 | ✅ 直接映射 | +| `scheduler.max_workers` | `executors['default']['max_workers']` | ✅ 直接映射 | + +## 4. 重构方案设计 + +### 4.1 方案一:完全封装(推荐) + +**设计思路**:保持现有 API 接口不变,内部使用 APScheduler 实现。 + +**优点**: +- ✅ 完全向后兼容,现有代码无需修改 +- ✅ 可以逐步迁移,降低风险 +- ✅ 保留自定义扩展能力 + +**实现要点**: +```python +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +from apscheduler.triggers.date import DateTrigger + +class Scheduler: + def __init__(self, config): + # 创建 APScheduler 实例 + self._scheduler = BackgroundScheduler( + timezone=config.get('scheduler.timezone', 'UTC'), + executors={ + 'default': { + 'type': 'threadpool', + 'max_workers': config.get('scheduler.max_workers', 10) + } + } + ) + # 保持兼容性的映射 + self._jobs = {} # job_id -> APScheduler Job + self._scheduled_jobs = {} # ScheduledJob 对象 + + def add_cron_job(self, func, cron, job_id=None, **kwargs): + # 转换 cron 表达式格式(6位 -> APScheduler 格式) + trigger = self._parse_cron(cron) + job = self._scheduler.add_job( + func, + trigger=trigger, + id=job_id, + **kwargs + ) + self._jobs[job_id] = job + return job_id +``` + +### 4.2 方案二:直接使用 APScheduler + +**设计思路**:直接暴露 APScheduler 的 API,简化封装层。 + +**优点**: +- ✅ 代码更简洁 +- ✅ 可以直接使用 APScheduler 的高级特性 + +**缺点**: +- ❌ 需要修改现有代码 +- ❌ 破坏向后兼容性 + +### 4.3 推荐方案:方案一(完全封装) + +**理由**: +1. 保持 API 兼容性,现有代码无需修改 +2. 可以逐步增强功能(如添加持久化) +3. 保留扩展空间 + +## 5. 实施步骤 + +### 5.1 阶段一:基础重构(保持兼容) +1. **创建新的 Scheduler 类**(基于 APScheduler) + - 实现所有现有 API 方法 + - 保持方法签名和返回值一致 + - 内部使用 APScheduler + +2. **Cron 表达式转换** + - 当前格式:`"秒 分 时 日 月 周"`(6位) + - APScheduler 格式:`"分 时 日 月 周"`(5位)或 `CronTrigger` 对象 + - 需要实现转换函数 + +3. **测试验证** + - 单元测试覆盖所有 API + - 集成测试验证现有功能 + - 确保装饰器和自动配置正常工作 + +### 5.2 阶段二:功能增强(可选) +1. **添加任务持久化** + - 支持 SQLAlchemyJobStore + - 支持 RedisJobStore + +2. **增强任务管理** + - 任务暂停/恢复 + - 任务修改触发器 + - 任务执行历史 + +3. **事件监听** + - 任务执行成功/失败事件 + - 任务错过执行事件 + +### 5.3 阶段三:优化和文档 +1. 性能优化 +2. 文档更新 +3. 示例代码更新 + +## 6. 关键技术点 + +### 6.1 Cron 表达式转换 + +**当前格式**(6位): +``` +"秒 分 时 日 月 周" +例如:"0 0 * * * *" # 每小时 +``` + +**APScheduler 格式**(5位或 CronTrigger): +```python +# 方式1:5位字符串(标准 Cron) +"分 时 日 月 周" +"0 * * * *" # 每小时 + +# 方式2:CronTrigger 对象(推荐) +CronTrigger(second=0, minute=0, hour='*', day='*', month='*', day_of_week='*') +``` + +**转换函数**: +```python +def _parse_cron(self, cron_expr: str) -> CronTrigger: + """将6位 Cron 表达式转换为 APScheduler CronTrigger""" + parts = cron_expr.split() + if len(parts) == 6: + second, minute, hour, day, month, weekday = parts + return CronTrigger( + second=second, + minute=minute, + hour=hour, + day=day, + month=month, + day_of_week=weekday + ) + elif len(parts) == 5: + # 标准5位格式,秒默认为0 + return CronTrigger.from_crontab(cron_expr) + else: + raise ValueError(f"无效的 Cron 表达式: {cron_expr}") +``` + +### 6.2 ScheduledJob 集成 + +需要将 `ScheduledJob.execute()` 方法适配到 APScheduler: + +```python +def add_scheduled_job(self, job: ScheduledJob, job_id: Optional[str] = None): + # 包装 execute 方法,保持状态跟踪 + def wrapped_execute(): + return job.execute() + + # 根据 trigger 类型添加任务 + if isinstance(job.trigger, str): + return self.add_cron_job(wrapped_execute, job.trigger, job_id) + # ... 其他类型 +``` + +### 6.3 时区处理 + +APScheduler 内置时区支持,无需手动处理: + +```python +from pytz import timezone + +scheduler = BackgroundScheduler( + timezone=timezone('Asia/Shanghai') # 直接支持时区对象 +) +``` + +### 6.4 任务状态管理 + +APScheduler 提供任务状态: +- `pending`:等待执行 +- `running`:正在执行 +- `completed`:已完成 +- `failed`:执行失败 + +可以通过 `get_job(job_id).next_run_time` 获取下次执行时间。 + +## 7. 风险评估 + +### 7.1 兼容性风险 +- **风险**:Cron 表达式格式差异 +- **缓解**:实现转换函数,确保兼容 + +### 7.2 行为差异风险 +- **风险**:APScheduler 的执行时机可能与当前实现略有差异 +- **缓解**:充分测试,特别是边界情况 + +### 7.3 性能风险 +- **风险**:APScheduler 可能有额外的开销 +- **缓解**:性能测试,通常 APScheduler 性能更好 + +## 8. 测试策略 + +### 8.1 单元测试 +- 所有 API 方法的测试 +- Cron 表达式转换测试 +- 任务添加/删除测试 + +### 8.2 集成测试 +- 装饰器自动注册测试 +- ScheduledJob 集成测试 +- 应用生命周期集成测试 + +### 8.3 兼容性测试 +- 现有示例代码运行测试 +- 现有项目迁移测试 + +## 9. 结论 + +### 9.1 可行性评估 +✅ **高度可行** + +**理由**: +1. APScheduler 功能完全覆盖现有需求 +2. API 可以完全兼容 +3. 项目已依赖 APScheduler +4. 重构收益明显(稳定性、功能、维护性) + +### 9.2 推荐方案 +**采用方案一(完全封装)**,分阶段实施: +1. 第一阶段:基础重构,保持兼容 +2. 第二阶段:功能增强(可选) +3. 第三阶段:优化和文档 + +### 9.3 预期收益 +- ✅ 减少代码量(约 200+ 行 → 100+ 行) +- ✅ 提高稳定性(使用成熟库) +- ✅ 增强功能(持久化、事件监听等) +- ✅ 降低维护成本 +- ✅ 更好的错误处理 + +### 9.4 实施建议 +1. **先创建新实现**,保留旧实现作为备份 +2. **充分测试**后再替换 +3. **逐步迁移**,可以先支持两种实现并存 +4. **文档更新**,说明新特性 + +## 10. 参考资源 + +- APScheduler 官方文档:https://apscheduler.readthedocs.io/ +- APScheduler GitHub:https://github.com/agronholm/apscheduler +- Cron 表达式参考:https://en.wikipedia.org/wiki/Cron + diff --git a/examples/convention_app.py b/examples/convention_app.py new file mode 100644 index 0000000..c38ac36 --- /dev/null +++ b/examples/convention_app.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +约定优于配置示例应用 + +展示 MyBoot 框架的约定优于配置特性 +""" + +import sys +import threading +from pathlib import Path + +from myboot.core.application import Application +from myboot.core.config import get_config +from myboot.core.decorators import ( + get, post, put, delete, + cron, interval, once, + service, client, middleware, + rest_controller, component +) +from myboot.jobs.scheduled_job import ScheduledJob +from myboot.core.scheduler import get_scheduler + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +# 创建应用实例 +app = Application( + name="约定优于配置示例", + auto_configuration=True, # 启用自动配置 + auto_discover_package="examples.convention_app" +) + + +# ==================== 服务层 ==================== + +@service() +class UserService: + """用户服务 - 自动注册为 'user_service'""" + + def __init__(self): + self.users = {} + print("✅ UserService 已初始化") + + def get_user(self, user_id: int): + """获取用户""" + return self.users.get(user_id, {"id": user_id, "name": f"用户{user_id}"}) + + def create_user(self, name: str, email: str): + """创建用户""" + user_id = len(self.users) + 1 + user = {"id": user_id, "name": name, "email": email} + self.users[user_id] = user + return user + + def update_user(self, user_id: int, **kwargs): + """更新用户""" + if user_id in self.users: + self.users[user_id].update(kwargs) + return self.users[user_id] + return None + + def delete_user(self, user_id: int): + """删除用户""" + return self.users.pop(user_id, None) + + +@service('email_service') +class EmailService: + """邮件服务 - 注册为 'email_service'""" + + def __init__(self): + print("✅ EmailService 已初始化") + + def send_email(self, to: str, subject: str, body: str): + """发送邮件""" + print(f"📧 发送邮件到 {to}: {subject}") + return {"status": "sent", "to": to, "subject": subject} + + +# ==================== 客户端层 ==================== + +@client() +class DatabaseClient: + """数据库客户端 - 自动注册为 'database_client'""" + + def __init__(self): + print("✅ DatabaseClient 已初始化") + + def connect(self): + """连接数据库""" + print("🔗 连接数据库") + return True + + def query(self, sql: str): + """执行查询""" + print(f"📊 执行查询: {sql}") + return [] + + +@client('redis_client') +class RedisClient: + """Redis 客户端 - 注册为 'redis_client'""" + + def __init__(self): + print("✅ RedisClient 已初始化") + self.value = None + + def get(self, key: str): + """获取缓存""" + print(f"📦 获取缓存: {key}") + return self.value + + def set(self, key: str, value: str): + """设置缓存""" + print(f"💾 设置缓存: {key} = {value}") + self.value = value + + +# ==================== 中间件 ==================== + +@middleware(order=1) +async def logging_middleware(request, next_handler): + """日志中间件 - 自动注册,处理所有请求""" + print(f"📝 请求日志: {request.method} {request.url}") + response = await next_handler(request) + print(f"📝 响应日志: {response.status_code}") + return response + + +@middleware(order=2) +async def timing_middleware(request, next_handler): + """计时中间件 - 自动注册,处理所有请求""" + import time + start_time = time.time() + response = await next_handler(request) + end_time = time.time() + elapsed = end_time - start_time + print(f"⏱️ 请求耗时: {elapsed:.3f}s - {request.method} {request.url.path}") + return response + + +@middleware(order=3, path_filter='/api/*') +async def api_middleware(request, next_handler): + """API 中间件 - 只处理 /api/* 路径的请求""" + print(f"🔌 API 请求: {request.method} {request.url.path}") + # 可以在这里添加 API 特定的逻辑,如认证、限流等 + response = await next_handler(request) + return response + + +@middleware(order=4, methods=['POST', 'PUT', 'PATCH']) +async def modify_middleware(request, next_handler): + """修改操作中间件 - 只处理 POST、PUT、PATCH 请求""" + print(f"✏️ 修改操作: {request.method} {request.url.path}") + response = await next_handler(request) + return response + + +@middleware( + order=5, + path_filter=['/users/*', '/api/products/*'], + condition=lambda req: req.headers.get('user-agent', '').startswith('Mozilla') +) +async def browser_only_middleware(request, next_handler): + """浏览器专用中间件 - 只处理浏览器请求且匹配特定路径""" + print(f"🌐 浏览器请求: {request.headers.get('user-agent', 'Unknown')}") + response = await next_handler(request) + return response + + +# ==================== 定时任务组件 ==================== +# 注意:定时任务必须在 @component 装饰的类中定义,支持依赖注入 + + +@component() +class ScheduledJobs: + """定时任务组件 - 使用 @component 装饰器,支持依赖注入""" + + def __init__(self): + print("✅ ScheduledJobs 已初始化") + + @cron('0 */1 * * * *', enabled=True) # 每分钟执行 + def heartbeat(self): + """心跳任务""" + print("💓 心跳检测 - 系统运行正常") + + @interval(minutes=10, enabled=get_config('jobs.cleanup_task.enabled', True)) + def cleanup_task(self): + """清理任务 - 从配置文件读取 enabled 状态""" + print("🧹 执行清理任务") + + @once('2025-12-31 23:59:59') + def new_year_task(self): + """新年任务""" + print("🎉 新年任务执行") + + +# ==================== REST 控制器 ==================== +# 注意:路由必须在 @rest_controller 装饰的类中定义 + +@rest_controller('/') +class HomeController: + """首页控制器""" + + @get('/') + def home(self): + """首页 - GET /""" + return { + "message": "欢迎使用 MyBoot 约定优于配置示例", + "features": [ + "自动发现和注册组件", + "约定优于配置", + "零配置启动", + "REST 控制器", + "依赖注入", + "定时任务" + ] + } + + +@rest_controller('/api/users') +class UserController: + """用户控制器 - 使用依赖注入""" + + def __init__(self, user_service: UserService, email_service: EmailService): + self.user_service = user_service + self.email_service = email_service + + @get('/') + def list_users(self): + """获取用户列表 - GET /api/users""" + return {"users": list(self.user_service.users.values())} + + @get('/{user_id}') + def get_user(self, user_id: int): + """获取单个用户 - GET /api/users/{user_id}""" + return self.user_service.get_user(user_id) + + @post('/') + def create_user(self, name: str, email: str): + """创建用户 - POST /api/users""" + user = self.user_service.create_user(name, email) + self.email_service.send_email(email, "欢迎注册", f"欢迎 {name} 注册我们的服务!") + return {"message": "用户创建成功", "user": user} + + @put('/{user_id}') + def update_user(self, user_id: int, name: str = None, email: str = None): + """更新用户 - PUT /api/users/{user_id}""" + update_data = {} + if name: + update_data['name'] = name + if email: + update_data['email'] = email + + user = self.user_service.update_user(user_id, **update_data) + if user: + return {"message": "用户更新成功", "user": user} + return {"error": "用户不存在"} + + @delete('/{user_id}') + def delete_user(self, user_id: int): + """删除用户 - DELETE /api/users/{user_id}""" + user = self.user_service.delete_user(user_id) + if user: + return {"message": "用户删除成功", "user": user} + return {"error": "用户不存在"} + + +@rest_controller('/api/products') +class ProductController: + """产品控制器 - 使用依赖注入""" + + def __init__(self, redis_client: RedisClient): + self.redis_client = redis_client + self.products = { + 1: {"id": 1, "name": "产品1", "price": 100}, + 2: {"id": 2, "name": "产品2", "price": 200} + } + + @get('/') + def list_products(self): + """获取产品列表 - GET /api/products""" + if self.redis_client: + print(self.redis_client.get('app_status')) + return {"products": list(self.products.values())} + + @get('/{product_id}') + def get_product(self, product_id: int): + """获取单个产品 - GET /api/products/{product_id}""" + return self.products.get(product_id, {"error": "产品不存在"}) + + @post('/') + def create_product(self, name: str, price: float): + """创建产品 - POST /api/products""" + product_id = max(self.products.keys()) + 1 + product = {"id": product_id, "name": name, "price": price} + self.products[product_id] = product + return {"message": "产品创建成功", "product": product} + + @put('/{product_id}') + def update_product(self, product_id: int, name: str = None, price: float = None): + """更新产品 - PUT /api/products/{product_id}""" + if product_id not in self.products: + return {"error": "产品不存在"} + + if name: + self.products[product_id]['name'] = name + if price: + self.products[product_id]['price'] = price + + return {"message": "产品更新成功", "product": self.products[product_id]} + + @delete('/{product_id}') + def delete_product(self, product_id: int): + """删除产品 - DELETE /api/products/{product_id}""" + if product_id in self.products: + product = self.products.pop(product_id) + return {"message": "产品删除成功", "product": product} + return {"error": "产品不存在"} + + +def generate_report(report_type: str): + """生成报告任务""" + import time + print(f"开始生成 {report_type} 报告") + time.sleep(10) # 模拟报告生成 + return {"type": report_type, "status": "completed"} + + +@rest_controller('/api/reports') +class ReportController: + """报告控制器""" + + def __init__(self): + self.scheduler = get_scheduler() + + @post('/generate') + def create_report(self, report_type: str): + """创建报告生成任务""" + # 创建自定义 ScheduledJob + class ReportJob(ScheduledJob): + def __init__(self, report_type: str): + super().__init__( + name=f"生成{report_type}报告", + timeout=300 # 5分钟超时 + ) + self.report_type = report_type + + def run(self, *args, **kwargs): + return generate_report(self.report_type) + + # 创建任务实例 + job = ReportJob(report_type) + + # 添加到调度器并执行 + job_id = self.scheduler.add_scheduled_job(job) + thread = threading.Thread(target=job.execute) + thread.daemon = True + thread.start() + + return {"message": "报告生成任务已创建", "job_id": job_id} + + @get('/status/{job_id}') + def get_status(self, job_id: str): + """查询任务状态""" + job = self.scheduler.get_scheduled_job(job_id) + if job: + return job.get_info() + return {"error": "任务不存在"} + + +# ==================== 启动钩子 ==================== +@app.add_startup_hook +def startup_hook(): + """启动钩子""" + print("🚀 应用启动钩子执行") + + from myboot.core.application import get_client + + # 初始化数据库连接 + db_client = get_client('database_client') + if db_client: + db_client.connect() + + # 初始化 Redis 连接 + redis_client = get_client('redis_client') + if redis_client: + redis_client.set('app_status', 'running') + + +@app.add_shutdown_hook +def shutdown_hook(): + """关闭钩子""" + print("🛑 应用关闭钩子执行") + + +# ==================== 主程序 ==================== + +if __name__ == "__main__": + print("=" * 60) + print("🎯 MyBoot 约定优于配置示例") + print("=" * 60) + print() + print("✨ 特性展示:") + print(" • 自动发现和注册组件") + print(" • 约定优于配置") + print(" • 零配置启动") + print(" • 自动服务注入") + print(" • 自动路由注册") + print(" • 自动任务调度") + print() + print("🌐 访问地址:") + print(" • 应用: http://localhost:8000") + print(" • API 文档: http://localhost:8000/docs") + print(" • 健康检查: http://localhost:8000/health") + print() + print("📚 API 端点(通过 @rest_controller 定义):") + print(" • GET / - 首页") + print(" • GET /api/users - 用户列表") + print(" • GET /api/users/{id} - 获取用户") + print(" • POST /api/users - 创建用户") + print(" • PUT /api/users/{id} - 更新用户") + print(" • DELETE /api/users/{id} - 删除用户") + print(" • GET /api/products - 产品列表") + print(" • GET /api/products/{id} - 获取产品") + print(" • POST /api/products - 创建产品") + print(" • PUT /api/products/{id} - 更新产品") + print(" • DELETE /api/products/{id} - 删除产品") + print() + print("⏰ 定时任务(通过 @component 组件定义):") + print(" • 心跳检测 (每分钟)") + print(" • 清理任务 (每10分钟)") + print(" • 新年任务 (2025-12-31 23:59:59)") + print() + print("=" * 60) + + # 启动应用 + app.run(host="0.0.0.0", port=8000, reload=False) diff --git a/examples/dependency_injection_example.py b/examples/dependency_injection_example.py new file mode 100644 index 0000000..9aa4e2f --- /dev/null +++ b/examples/dependency_injection_example.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +依赖注入示例应用 + +展示 MyBoot 框架的依赖注入功能 +""" + +import sys +from pathlib import Path +from typing import Optional + +from myboot.core.application import Application +from myboot.core.decorators import service, rest_controller, get, post + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +# 创建应用实例 +app = Application( + name="依赖注入示例", + auto_configuration=True, + auto_discover_package="examples.dependency_injection_example" +) + + +# ==================== 基础服务层 ==================== + +@service() +class DatabaseClient: + """数据库客户端""" + + def __init__(self): + self.connection = "connected" + print("✅ DatabaseClient 已初始化") + + def query(self, sql: str): + print(f"📊 执行查询: {sql}") + return [{"id": 1, "name": "用户1"}] + + +@service() +class CacheService: + """缓存服务""" + + def __init__(self): + self.cache = {} + print("✅ CacheService 已初始化") + + def get(self, key: str): + return self.cache.get(key) + + def set(self, key: str, value: any): + self.cache[key] = value + + +# ==================== 仓储层 ==================== + +@service() +class UserRepository: + """用户仓储 - 依赖 DatabaseClient""" + + def __init__(self, db: DatabaseClient): + self.db = db + print("✅ UserRepository 已初始化(依赖: DatabaseClient)") + + def find_by_id(self, user_id: int): + result = self.db.query(f"SELECT * FROM users WHERE id = {user_id}") + return result[0] if result else {"id": user_id, "name": f"用户{user_id}"} + + +# ==================== 服务层 ==================== + +@service() +class UserService: + """用户服务 - 依赖 UserRepository 和可选的 CacheService""" + + def __init__( + self, + user_repo: UserRepository, + cache: Optional[CacheService] = None + ): + self.user_repo = user_repo + self.cache = cache + print("✅ UserService 已初始化(依赖: UserRepository, CacheService)") + + def get_user(self, user_id: int): + # 尝试从缓存获取 + if self.cache: + cached = self.cache.get(f"user:{user_id}") + if cached: + print(f"📦 从缓存获取用户 {user_id}") + return cached + + # 从数据库获取 + user = self.user_repo.find_by_id(user_id) + + # 存入缓存 + if self.cache: + self.cache.set(f"user:{user_id}", user) + print(f"💾 用户 {user_id} 已存入缓存") + + return user + + +@service() +class EmailService: + """邮件服务""" + + def __init__(self): + print("✅ EmailService 已初始化") + + def send_email(self, to: str, subject: str, body: str): + print(f"📧 发送邮件到 {to}: {subject} - {body}") + return {"status": "sent", "to": to, "subject": subject} + + +@service() +class OrderService: + """订单服务 - 依赖 UserService 和 EmailService""" + + def __init__(self, user_service: UserService, email_service: EmailService): + self.user_service = user_service + self.email_service = email_service + print("✅ OrderService 已初始化(依赖: UserService, EmailService)") + + def create_order(self, user_id: int, product: str): + # 获取用户信息 + user = self.user_service.get_user(user_id) + + # 发送邮件通知 + self.email_service.send_email( + to=user.get('email', 'user@example.com'), + subject="订单创建", + body=f"您的订单 {product} 已创建" + ) + + return { + "order_id": 12345, + "user_id": user_id, + "product": product, + "status": "created" + } + + +# ==================== REST 控制器 ==================== +# 注意:路由必须在 @rest_controller 装饰的类中定义,支持依赖注入 + +@rest_controller('/') +class HomeController: + """首页控制器""" + + @get('/') + def home(self): + """首页(依赖注入示例)""" + return { + "message": "依赖注入示例应用", + "features": [ + "自动依赖注入", + "多级依赖支持", + "可选依赖支持", + "循环依赖检测", + "REST 控制器" + ], + "endpoints": [ + "GET /api/users/{user_id} - 获取用户信息", + "POST /api/orders - 创建订单" + ] + } + + +@rest_controller('/api/users') +class UserController: + """用户控制器 - 自动注入 UserService""" + + def __init__(self, user_service: UserService): + self.user_service = user_service + + @get('/{user_id}') + def get_user(self, user_id: int): + """获取用户信息 - GET /api/users/{user_id}""" + return self.user_service.get_user(user_id) + + +@rest_controller('/api/orders') +class OrderController: + """订单控制器 - 自动注入 OrderService""" + + def __init__(self, order_service: OrderService): + self.order_service = order_service + + @post('/') + def create_order(self, user_id: int, product: str): + """创建订单 - POST /api/orders""" + return self.order_service.create_order(user_id, product) + + +if __name__ == "__main__": + print("\n" + "="*60) + print("依赖注入示例应用") + print("="*60 + "\n") + + # 运行应用 + app.run(host="0.0.0.0", port=8000, reload=False) + diff --git a/myboot/__init__.py b/myboot/__init__.py new file mode 100644 index 0000000..635956d --- /dev/null +++ b/myboot/__init__.py @@ -0,0 +1,16 @@ +""" +MyBoot 应用包 + +核心应用代码,包含: +- main.py: 应用入口 +- api/: 路由与视图层 +- core/: 核心基础设施 +- models/: ORM 模型层 +- services/: 业务逻辑层 +- jobs/: 定时任务 +- utils/: 工具函数 +""" + +__all__ = [ + +] \ No newline at end of file diff --git a/myboot/cli.py b/myboot/cli.py new file mode 100644 index 0000000..612b246 --- /dev/null +++ b/myboot/cli.py @@ -0,0 +1,531 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +MyBoot CLI 工具 + +提供项目模板初始化功能 +""" + +import click +import sys +from pathlib import Path + + +@click.group() +def cli(): + """MyBoot 命令行工具 - 项目模板初始化""" + pass + + +@cli.command() +@click.option('--name', prompt='项目名称', help='项目名称') +@click.option('--dir', default='.', help='项目目录(默认为当前目录)') +@click.option('--template', type=click.Choice(['basic', 'api', 'full']), default='basic', + help='项目模板: basic(基础), api(API项目), full(完整项目)') +@click.option('--force', is_flag=True, help='如果目录已存在则覆盖') +def init(name: str, dir: str, template: str, force: bool): + """初始化新的 MyBoot 项目""" + + project_dir = Path(dir) / name + + # 检查目录是否存在 + if project_dir.exists() and not force: + click.echo(f"❌ 错误: 目录 '{project_dir}' 已存在", err=True) + click.echo(" 使用 --force 选项可以覆盖现有目录") + sys.exit(1) + + click.echo(f"📦 正在初始化项目: {name}") + click.echo(f" 模板: {template}") + click.echo(f" 目录: {project_dir}") + click.echo() + + try: + # 创建目录结构 + dirs = ['app', 'app/api', 'app/service', 'app/model', 'app/jobs', 'app/client', 'conf', 'tests'] + for d in dirs: + (project_dir / d).mkdir(parents=True, exist_ok=True) + # 创建 __init__.py + init_file = project_dir / d / '__init__.py' + if not init_file.exists(): + init_file.write_text('', encoding='utf-8') + + click.echo("✓ 创建目录结构") + + # 创建配置文件 + config_content = f"""# {name} 配置文件 + +# 应用配置 +app: + name: "{name}" + version: "0.1.0" + +# 服务器配置 +server: + port: 8000 + reload: true + workers: 1 + keep_alive_timeout: 5 + graceful_timeout: 30 + response_format: + enabled: true + exclude_paths: + - "/docs" + +# 日志配置 +logging: + level: "INFO" + +# 任务调度配置 +scheduler: + enabled: true + timezone: "UTC" + max_workers: 10 +""" + config_file = project_dir / 'conf' / 'config.yaml' + config_file.write_text(config_content, encoding='utf-8') + click.echo("✓ 创建配置文件: conf/config.yaml") + + # 创建主应用文件(放在根目录) + app_content = f'''"""主应用文件""" +from myboot.core.application import create_app + +app = create_app(name="{name}") + +if __name__ == "__main__": + app.run() +''' + main_file = project_dir / 'main.py' + main_file.write_text(app_content, encoding='utf-8') + click.echo("✓ 创建主应用文件: main.py") + + # 根据模板创建不同的文件 + if template in ['api', 'full']: + # 创建示例路由 + api_content = '''"""API 路由示例""" +from myboot.core.decorators import rest_controller, get + + +@rest_controller('/api') +class HelloController: + """Hello 控制器""" + + @get('/') + def hello(self): + """Hello World 接口 - GET /api""" + return {"message": "Hello, MyBoot!", "status": "success"} + + @get('/health') + def health(self): + """健康检查接口 - GET /api/health""" + return {"status": "healthy", "service": "running"} +''' + routes_file = project_dir / 'app' / 'api' / 'routes.py' + routes_file.write_text(api_content, encoding='utf-8') + click.echo("✓ 创建示例路由: app/api/routes.py") + + if template == 'full': + # 创建示例服务 + service_content = '''"""服务层示例""" +from typing import Dict, Any + + +class UserService: + """用户服务示例""" + + def get_user(self, user_id: int) -> Dict[str, Any]: + """获取用户信息""" + return { + "id": user_id, + "name": "示例用户", + "email": "user@example.com" + } + + def create_user(self, name: str, email: str) -> Dict[str, Any]: + """创建用户""" + return { + "id": 1, + "name": name, + "email": email, + "status": "created" + } +''' + service_file = project_dir / 'app' / 'service' / 'user_service.py' + service_file.write_text(service_content, encoding='utf-8') + click.echo("✓ 创建示例服务: app/service/user_service.py") + + # 创建示例模型 + model_content = '''"""数据模型示例""" +from pydantic import BaseModel, ConfigDict, EmailStr +from typing import Optional + + +class User(BaseModel): + """用户模型""" + model_config = ConfigDict( + json_schema_extra={ + "example": { + "name": "张三", + "email": "zhangsan@example.com" + } + } + ) + + id: Optional[int] = None + name: str + email: EmailStr + status: str = "active" +''' + model_file = project_dir / 'app' / 'model' / 'user.py' + model_file.write_text(model_content, encoding='utf-8') + click.echo("✓ 创建示例模型: app/model/user.py") + + # 创建示例客户端 + client_content = '''"""客户端示例""" +from typing import Dict, Any +import requests + + +class ApiClient: + """API 客户端示例""" + + def __init__(self, base_url: str): + """初始化客户端""" + self.base_url = base_url + + def get(self, endpoint: str) -> Dict[str, Any]: + """GET 请求""" + response = requests.get(f"{self.base_url}/{endpoint}") + response.raise_for_status() + return response.json() + + def post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]: + """POST 请求""" + response = requests.post(f"{self.base_url}/{endpoint}", json=data) + response.raise_for_status() + return response.json() +''' + client_file = project_dir / 'app' / 'client' / 'api_client.py' + client_file.write_text(client_content, encoding='utf-8') + click.echo("✓ 创建示例客户端: app/client/api_client.py") + + # 创建示例定时任务 + job_content = '''"""定时任务示例""" +from myboot.core.decorators import cron + + +@cron("0 */5 * * * *") # 每5分钟执行一次 +def cleanup_task(): + """清理任务示例""" + print("执行清理任务...") + + +@cron("0 0 * * * *") # 每小时执行一次 +def hourly_task(): + """每小时任务示例""" + print("执行每小时任务...") +''' + job_file = project_dir / 'app' / 'jobs' / 'tasks.py' + job_file.write_text(job_content, encoding='utf-8') + click.echo("✓ 创建示例任务: app/jobs/tasks.py") + + # 创建 README + readme_content = f"""# {name} + +MyBoot 项目 + +## 快速开始 + +### 安装依赖 + +```bash +pip install myboot +``` + +### 运行应用 + +```bash +# 使用默认设置启动 +python main.py + +# 或者使用 myboot 命令(如果已安装) +myboot run +``` + +### 开发模式 + +```bash +# 启用自动重载 +python main.py --reload + +# 或者 +myboot dev --reload +``` + +## 项目结构 + +``` +{name}/ +├── main.py # 应用入口 +├── pyproject.toml # 项目配置文件 +├── .gitignore # Git 忽略文件 +├── app/ # 应用代码 +│ ├── api/ # API 路由 +│ ├── service/ # 业务逻辑层 +│ ├── model/ # 数据模型 +│ ├── jobs/ # 定时任务 +│ └── client/ # 客户端(第三方API调用等) +├── conf/ # 配置文件 +│ └── config.yaml # 主配置文件 +└── tests/ # 测试代码 +``` + +## 配置说明 + +配置文件位于 `conf/config.yaml`,支持以下配置: + +- **app**: 应用配置(名称、版本等) +- **server**: 服务器配置(端口、工作进程等) +- **logging**: 日志配置 +- **scheduler**: 任务调度配置 + +## 更多信息 + +- [MyBoot 文档](https://github.com/your-org/myboot) +- [快速开始指南](https://github.com/your-org/myboot/docs) +""" + readme_file = project_dir / 'README.md' + readme_file.write_text(readme_content, encoding='utf-8') + click.echo("✓ 创建 README: README.md") + + # 创建 .gitignore + gitignore_content = """# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# MyBoot +.pytest_cache/ +.coverage +htmlcov/ +""" + gitignore_file = project_dir / '.gitignore' + gitignore_file.write_text(gitignore_content, encoding='utf-8') + click.echo("✓ 创建 .gitignore") + + # 创建 pyproject.toml + project_name = name.lower().replace(' ', '-').replace('_', '-') + pyproject_content = f"""[project] +name = "{project_name}" +version = "0.1.0" +description = "{name} - MyBoot 项目" +authors = [ + {{name = "Your Name", email = "your.email@example.com"}} +] +readme = "README.md" +license = {{text = "MIT"}} +requires-python = ">=3.9" +keywords = ["myboot", "web", "api"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "myboot>=0.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "pytest-asyncio>=0.21.0", + "black>=23.0", + "isort>=5.12", + "flake8>=6.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.black] +line-length = 88 +target-version = ['py39', 'py310', 'py311', 'py312'] +include = '\\.pyi?$' + +[tool.isort] +profile = "black" +line_length = 88 +known_first_party = ["app"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--verbose", +] +""" + pyproject_file = project_dir / 'pyproject.toml' + pyproject_file.write_text(pyproject_content, encoding='utf-8') + click.echo("✓ 创建 pyproject.toml") + + # 创建 Docker 支持文件(issue #1) + dockerfile_content = f"""# 多阶段构建:构建层安装依赖,运行层只保留运行所需内容 +FROM python:3.12-slim AS builder + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /app +COPY pyproject.toml ./ +# 如有 uv.lock 一并复制可获得可复现构建(无锁文件时此层退化为解析安装) +RUN uv venv /opt/venv && \\ + VIRTUAL_ENV=/opt/venv uv pip install --no-cache . + +FROM python:3.12-slim + +ENV VIRTUAL_ENV=/opt/venv \\ + PATH="/opt/venv/bin:$PATH" \\ + PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY --from=builder /opt/venv /opt/venv +COPY . . + +EXPOSE 8000 + +# 配置可用环境变量覆盖(__ 为层级分隔符),如 SERVER__PORT=8080 +CMD ["python", "main.py"] +""" + dockerfile = project_dir / 'Dockerfile' + dockerfile.write_text(dockerfile_content, encoding='utf-8') + click.echo("✓ 创建 Dockerfile") + + dockerignore_content = """__pycache__/ +*.py[cod] +.venv/ +venv/ +.git/ +.gitignore +.pytest_cache/ +.coverage +htmlcov/ +*.log +logs/ +.vscode/ +.idea/ +Dockerfile +docker-compose.yaml +""" + dockerignore_file = project_dir / '.dockerignore' + dockerignore_file.write_text(dockerignore_content, encoding='utf-8') + click.echo("✓ 创建 .dockerignore") + + compose_content = f"""services: + {project_name}: + build: . + ports: + - "8000:8000" + environment: + # MyBoot 配置支持环境变量覆盖,__ 为层级分隔符 + - LOGGING__LEVEL=INFO + # - SERVER__WORKERS=4 + # - CONFIG_FILE=/app/conf/config.prod.yaml + restart: unless-stopped +""" + compose_file = project_dir / 'docker-compose.yaml' + compose_file.write_text(compose_content, encoding='utf-8') + click.echo("✓ 创建 docker-compose.yaml") + + click.echo() + click.echo(f"✅ 项目 '{name}' 初始化完成!") + click.echo() + click.echo("下一步:") + click.echo(f" cd {name}") + click.echo(" python main.py") + click.echo() + click.echo("Docker 部署:") + click.echo(f" docker build -t {project_name} .") + click.echo(f" docker run -p 8000:8000 {project_name}") + click.echo() + + except Exception as e: + click.echo(f"❌ 初始化失败: {e}", err=True) + import traceback + if '--debug' in sys.argv: + traceback.print_exc() + sys.exit(1) + + +@cli.command() +def info(): + """显示 MyBoot 信息""" + click.echo("🎯 MyBoot - 类似 Spring Boot 的企业级Python快速开发框架") + click.echo() + click.echo("✨ 主要特性:") + click.echo(" • 快速启动和自动配置") + click.echo(" • 约定优于配置") + click.echo(" • 高性能 Hypercorn 服务器") + click.echo(" • Web API 开发") + click.echo(" • 定时任务调度") + click.echo(" • 日志管理") + click.echo(" • 配置管理") + click.echo() + click.echo("🚀 快速开始:") + click.echo(" myboot init # 初始化新项目") + click.echo(" myboot init --template api # 使用 API 模板") + click.echo(" myboot init --template full # 使用完整模板") + click.echo() + + +if __name__ == '__main__': + cli() diff --git a/myboot/core/__init__.py b/myboot/core/__init__.py new file mode 100644 index 0000000..5ac8aee --- /dev/null +++ b/myboot/core/__init__.py @@ -0,0 +1,33 @@ +""" +核心基础设施模块 + +包含配置、数据库、日志、调度器等核心功能 +""" + +from .config import ( + get_settings, + get_config, + get_config_str, + get_config_int, + get_config_bool, + reload_config +) +from .logger import Logger, get_logger, logger, setup_logging +from .scheduler import Scheduler, get_scheduler +from .auto_configuration import AutoConfigurationError + +__all__ = [ + "get_settings", + "get_config", + "get_config_str", + "get_config_int", + "get_config_bool", + "reload_config", + "Logger", + "get_logger", + "logger", # loguru logger,建议直接使用 + "setup_logging", + "Scheduler", + "get_scheduler", + "AutoConfigurationError", +] \ No newline at end of file diff --git a/myboot/core/_internal/__init__.py b/myboot/core/_internal/__init__.py new file mode 100644 index 0000000..a85d3f3 --- /dev/null +++ b/myboot/core/_internal/__init__.py @@ -0,0 +1,10 @@ +""" +auto_configuration 内部实现包 + +将 auto_configuration.py 的可分离职责拆分到此包,便于维护与测试: +- ast_analyzer: AST 静态分析(解析装饰器、扫描文件/包元数据) +- component_scanner: 模块发现/导入逻辑 +- scan_cache: 缓存读写与缓存文件路径计算 + +注意:本包内的模块不应反向导入 auto_configuration,以避免循环导入。 +""" diff --git a/myboot/core/_internal/ast_analyzer.py b/myboot/core/_internal/ast_analyzer.py new file mode 100644 index 0000000..3bd12d7 --- /dev/null +++ b/myboot/core/_internal/ast_analyzer.py @@ -0,0 +1,99 @@ +""" +AST 静态分析 + +在发现阶段使用 AST 静态分析替代 import(不执行模块代码): +- parse_decorators: 提取节点上的装饰器名称 +- scan_file_ast: 扫描单个文件,提取被 MyBoot 装饰器标注的类/函数元数据 +- scan_package_ast: 递归扫描整个包 + +这些函数为纯分析函数,由调用方传入组件元数据容器与装饰器映射, +不依赖 auto_configuration 模块,避免循环导入。 +""" + +import os +import ast +from pathlib import Path +from typing import Dict, List + +from loguru import logger as loguru_logger + +logger = loguru_logger.bind(name=__name__) + + +def parse_decorators(node: ast.AST) -> List[str]: + """解析装饰器名称""" + decorators = [] + decorator_list = getattr(node, 'decorator_list', []) + for dec in decorator_list: + if isinstance(dec, ast.Name): + decorators.append(dec.id) + elif isinstance(dec, ast.Call): + if isinstance(dec.func, ast.Name): + decorators.append(dec.func.id) + elif isinstance(dec.func, ast.Attribute): + decorators.append(dec.func.attr) + return decorators + + +def scan_file_ast( + file_path: Path, + module_name: str, + component_metadata: Dict[str, List[dict]], + decorator_mapping: Dict[str, str], +) -> None: + """使用 AST 静态分析扫描单个文件(不执行 import) + + 将发现的组件元数据就地追加到 component_metadata 中。 + """ + try: + with open(file_path, 'r', encoding='utf-8') as f: + source = f.read() + tree = ast.parse(source, filename=str(file_path)) + except Exception as e: + logger.warning(f"AST 解析失败 {file_path}: {e}") + return + + # 只遍历模块顶层节点(避免 ast.walk 的问题) + # 注意:job 方法(@cron/@interval/@once)只能在 @component 类中定义 + # job 方法的注册在 _auto_register_components 中动态进行,不在 AST 扫描阶段处理 + for node in tree.body: + if isinstance(node, ast.ClassDef): + decorators = parse_decorators(node) + for dec_name in decorators: + if dec_name in decorator_mapping: + component_type = decorator_mapping[dec_name] + component_metadata[component_type].append({ + 'module': module_name, + 'class_name': node.name, + 'type': f'class_{dec_name}' + }) + + elif isinstance(node, ast.FunctionDef): + # 模块级函数 + decorators = parse_decorators(node) + for dec_name in decorators: + if dec_name in decorator_mapping: + component_type = decorator_mapping[dec_name] + component_metadata[component_type].append({ + 'module': module_name, + 'func_name': node.name, + 'type': f'function_{dec_name}' + }) + + +def scan_package_ast( + package_path: Path, + component_metadata: Dict[str, List[dict]], + decorator_mapping: Dict[str, str], +) -> None: + """使用 AST 递归扫描包(不执行 import) + + 将发现的组件元数据就地追加到 component_metadata 中。 + """ + for item in package_path.rglob("*.py"): + if item.name.startswith("__"): + continue + # 计算模块名 + rel_path = item.relative_to(package_path.parent) + module_name = str(rel_path.with_suffix('')).replace(os.sep, '.') + scan_file_ast(item, module_name, component_metadata, decorator_mapping) diff --git a/myboot/core/_internal/component_scanner.py b/myboot/core/_internal/component_scanner.py new file mode 100644 index 0000000..66198ac --- /dev/null +++ b/myboot/core/_internal/component_scanner.py @@ -0,0 +1,123 @@ +""" +组件模块发现与导入 + +负责注册阶段的延迟导入逻辑: +- import_modules: 批量导入模块(支持并行/串行),并报告慢模块 +- build_discovered_components: 将组件元数据转换为包含实际类/函数对象的组件条目 + +这些函数为纯函数,由 AutoConfigurationManager 调用并传入所需状态, +不依赖 auto_configuration 模块,避免循环导入。 +""" + +import time +import importlib +from typing import Dict, List, Any, Set + +from loguru import logger as loguru_logger + +logger = loguru_logger.bind(name=__name__) + + +def _import_single(module_name: str): + """导入单个模块并返回结果""" + try: + start = time.perf_counter() + module = importlib.import_module(module_name) + elapsed = (time.perf_counter() - start) * 1000 + return module_name, module, elapsed, None + except Exception as e: + return module_name, None, 0, e + + +def import_modules(modules_to_import: Set[str], parallel_import: bool = False) -> Dict[str, Any]: + """批量导入模块(支持并行/串行),返回 模块名 -> 模块 的映射 + + 导入失败的模块会记录 error 日志并被跳过;导入耗时超过 100ms 的模块 + 会汇总到一条 warning 日志中。 + """ + imported_modules: Dict[str, Any] = {} + slow_modules = [] # 记录慢模块 + + if parallel_import and len(modules_to_import) > 1: + # 并行导入(对 I/O 密集的模块有帮助) + from concurrent.futures import ThreadPoolExecutor, as_completed + with ThreadPoolExecutor(max_workers=min(8, len(modules_to_import))) as executor: + futures = {executor.submit(_import_single, m): m for m in modules_to_import} + for future in as_completed(futures): + module_name, module, elapsed, error = future.result() + if error: + logger.error(f"导入模块失败 {module_name}: {error}") + else: + imported_modules[module_name] = module + if elapsed > 100: + slow_modules.append((module_name, elapsed)) + else: + # 串行导入 + for module_name in modules_to_import: + module_name, module, elapsed, error = _import_single(module_name) + if error: + logger.error(f"导入模块失败 {module_name}: {error}") + else: + imported_modules[module_name] = module + if elapsed > 100: + slow_modules.append((module_name, elapsed)) + + # 输出慢模块报告 + if slow_modules: + slow_modules.sort(key=lambda x: x[1], reverse=True) + report = ", ".join([f"{name}({ms:.0f}ms)" for name, ms in slow_modules[:10]]) + logger.warning(f"慢模块导入: {report}") + + return imported_modules + + +def build_discovered_components( + component_metadata: Dict[str, List[dict]], + parallel_import: bool = False, +) -> Dict[str, List[dict]]: + """将组件元数据转换为包含实际类对象的组件条目 + + 收集需导入的模块并去重,执行导入后将元数据解析为实际的类/函数/方法对象。 + 返回与 component_metadata 同结构的 discovered_components 字典。 + """ + # 收集需要导入的模块(去重) + modules_to_import: Set[str] = set() + for items in component_metadata.values(): + for item in items: + modules_to_import.add(item['module']) + + # 批量导入模块 + imported_modules = import_modules(modules_to_import, parallel_import) + + # 将元数据转换为包含实际类对象的组件 + discovered_components: Dict[str, List[dict]] = {key: [] for key in component_metadata.keys()} + for component_type, items in component_metadata.items(): + for item in items: + module = imported_modules.get(item['module']) + if not module: + continue + + entry = {'module': item['module'], 'type': item['type']} + + if 'class_name' in item: + cls = getattr(module, item['class_name'], None) + if cls: + entry['class'] = cls + else: + continue + + if 'func_name' in item: + func = getattr(module, item['func_name'], None) + if func: + entry['function'] = func + else: + continue + + if 'method_name' in item: + entry['method_name'] = item['method_name'] + if 'class' in entry: + entry['method'] = getattr(entry['class'], item['method_name'], None) + + discovered_components[component_type].append(entry) + + return discovered_components diff --git a/myboot/core/_internal/scan_cache.py b/myboot/core/_internal/scan_cache.py new file mode 100644 index 0000000..3c71534 --- /dev/null +++ b/myboot/core/_internal/scan_cache.py @@ -0,0 +1,96 @@ +""" +扫描缓存读写 + +负责 AST 分析结果(组件元数据)的缓存持久化: +- 缓存版本号 _CACHE_VERSION(修改扫描逻辑时递增以使旧缓存失效) +- 缓存文件路径计算 +- 源文件清单收集(用于失效判断) +- 缓存有效性检查、保存与加载 + +这些函数为纯函数,由 AutoConfigurationManager 调用并传入所需状态, +不依赖 auto_configuration 模块,避免循环导入。 +""" + +import os +import json +from pathlib import Path +from typing import Dict, List, Optional + +from loguru import logger as loguru_logger + +logger = loguru_logger.bind(name=__name__) + + +# 缓存版本号,修改扫描逻辑时递增以使旧缓存失效 +# 3.1: 新增 worker_hooks 组件类型(@on_worker_start/@on_worker_stop) +_CACHE_VERSION = "3.1" + + +def get_cache_path(app_root: str, package_name: str) -> Path: + """获取缓存文件路径""" + return Path(app_root) / f".myboot_cache_{package_name}.json" + + +def collect_source_files(package_path: Path) -> Dict[str, float]: + """收集扫描范围内的源文件及其修改时间(支持单文件或目录)""" + if package_path.is_file(): + return {str(package_path): package_path.stat().st_mtime} + + files = {} + for item in package_path.rglob("*.py"): + if not item.name.startswith("__"): + files[str(item)] = item.stat().st_mtime + return files + + +def is_cache_valid(cache_path: Path, package_path: Path) -> bool: + """检查缓存是否有效""" + if not cache_path.exists(): + return False + try: + with open(cache_path, 'r', encoding='utf-8') as f: + cache = json.load(f) + if cache.get('version') != _CACHE_VERSION: + return False + current_files = collect_source_files(package_path) + cached_files = cache.get('source_files', {}) + return current_files == cached_files + except Exception: + return False + + +def save_cache(cache_path: Path, package_path: Path, component_metadata: Dict[str, List[dict]]) -> None: + """保存元数据缓存(不包含类对象)""" + try: + cache = { + 'version': _CACHE_VERSION, + 'source_files': collect_source_files(package_path), + 'components': component_metadata + } + with open(cache_path, 'w', encoding='utf-8') as f: + json.dump(cache, f, indent=2) + except Exception as e: + logger.warning(f"保存缓存失败: {e}") + + +def load_cache(cache_path: Path, component_keys) -> Optional[Dict[str, List[dict]]]: + """从缓存加载元数据(不导入模块) + + 成功时返回加载并规范化后的组件元数据字典(补齐 component_keys 中缺失的 + 组件类型键,避免后续 KeyError);失败时返回 None。 + + Args: + cache_path: 缓存文件路径 + component_keys: 需要补齐的组件类型键集合(通常为当前元数据字典的 keys) + """ + try: + with open(cache_path, 'r', encoding='utf-8') as f: + cache = json.load(f) + loaded = cache.get('components', {}) + # 规范化:补齐缺失的组件类型键,避免后续 KeyError + for key in component_keys: + loaded.setdefault(key, []) + return loaded + except Exception as e: + logger.warning(f"加载缓存失败: {e}") + return None diff --git a/myboot/core/application.py b/myboot/core/application.py new file mode 100644 index 0000000..438b562 --- /dev/null +++ b/myboot/core/application.py @@ -0,0 +1,835 @@ +""" +MyBoot 应用程序主类 + +提供类似 Spring Boot 的自动配置和快速启动功能 +""" + +import asyncio +import os +import signal +from contextlib import asynccontextmanager +from typing import Any, Callable, Dict, List, Optional, Type + +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from loguru import logger + +from .auto_configuration import auto_discover, apply_auto_configuration +from .config import get_settings +from .container import Container +from .logger import setup_logging +from .scheduler import Scheduler +from .server import ServerManager +from ..exceptions import MyBootException +from ..utils import get_local_ip +from ..web.middleware import Middleware + +# 全局应用实例注册表(用于在路由函数中获取当前应用实例) +_current_app: Optional['Application'] = None + + +def app() -> 'Application': + """获取当前应用实例""" + if _current_app is None: + raise RuntimeError("应用实例未初始化,请确保应用已创建并启动") + return _current_app + + +class Application: + """MyBoot 应用程序主类""" + + def __init__( + self, + name: str = "MyBoot App", + config_file: Optional[str] = None, + **kwargs + ): + """ + 初始化应用程序 + + Args: + name: 应用程序名称 + config_file: 配置文件路径 + **kwargs: 其他配置参数 + """ + self.name = name + self.config = get_settings(config_file) + + # 应用配置 + self._apply_config(kwargs) + + # 获取应用版本号(从配置文件读取,默认 0.0.1) + self.version = self.config.get("app.version", "0.0.1") + + # 初始化 loguru 日志系统(包括第三方库日志级别配置) + setup_logging(self.config) + + # Prometheus 多进程指标环境(须在 prometheus_client 被 import 之前配置) + from ..metrics import setup_multiproc_env + setup_multiproc_env(self.config, self.name) + + self.logger = logger.bind(name=self.name) + + # Worker 信息(多进程模式下由环境变量设置) + self._worker_id = int(os.environ.get("MYBOOT_WORKER_ID", "1")) + self._worker_count = int(os.environ.get("MYBOOT_WORKER_COUNT", "1")) + self._is_primary_worker = os.environ.get("MYBOOT_IS_PRIMARY_WORKER", "1") == "1" + + # Scheduler 默认只在 primary worker 启动(可通过 scheduler.on_all_workers + # 全局配置覆盖)。任务级 all_workers=True 的任务在非 primary worker 注册时, + # 注册门控会调用 scheduler.enable() 按需启用调度器实例。 + scheduler_on_all_workers = self.config.get("scheduler.on_all_workers", False) + scheduler_globally_enabled = self.config.get("scheduler.enabled", True) + self._scheduler_enabled = ( + self._is_primary_worker or scheduler_on_all_workers + ) and scheduler_globally_enabled + self.scheduler = Scheduler(config=self.config, enabled=self._scheduler_enabled) + + # 中间件列表 + self.middlewares: List[Middleware] = [] + + # 路由处理器 + self.route_handlers: Dict[str, Callable] = {} + + # 服务注册表 + self.services: Dict[str, Any] = {} + + # 模型注册表 + self.models: Dict[str, Any] = {} + + # 客户端注册表 + self.clients: Dict[str, Any] = {} + + # 组件注册表 + self.components: Dict[str, Any] = {} + + # 统一容器接口(支持从 container、services、clients 中获取实例) + self.container = Container(self) + + # 启动钩子 + self.startup_hooks: List[Callable] = [] + self.shutdown_hooks: List[Callable] = [] + + # Worker 生命周期钩子(@on_worker_start / @on_worker_stop) + # 每个 worker 进程的 lifespan 中各触发一次;单 worker 模式下也触发一次 + self.worker_start_hooks: List[Callable] = [] + self.worker_stop_hooks: List[Callable] = [] + + # FastAPI 应用实例 + self._fastapi_app: Optional[FastAPI] = None + + # 服务器实例 + self._server: Optional[Any] = None + + # 注册信号处理器 + self._register_signal_handlers() + + # 创建 FastAPI 应用 + self._fastapi_app = self._create_fastapi_app() + + # 自动配置标志 + self.auto_configuration_enabled = kwargs.get('auto_configuration', True) + self.auto_discover_package = kwargs.get('auto_discover_package', 'app') + + # 服务器管理器 + self.server_manager = ServerManager() + + # 注册为当前应用实例 + global _current_app + _current_app = self + + def _apply_config(self, kwargs: Dict[str, Any]) -> None: + """应用配置参数""" + for key, value in kwargs.items(): + self.config.set(key, value) + + def _register_signal_handlers(self) -> None: + """注册信号处理器""" + signal.signal(signal.SIGINT, self._signal_handler) + signal.signal(signal.SIGTERM, self._signal_handler) + + def _signal_handler(self, signum: int, frame) -> None: + """信号处理器""" + self.logger.info(f"收到信号 {signum},开始优雅关闭...") + asyncio.create_task(self.shutdown()) + + def add_middleware(self, middleware: Middleware) -> None: + """添加中间件""" + self.middlewares.append(middleware) + self.logger.debug(f"已添加中间件: {middleware.__class__.__name__}") + + def add_startup_hook(self, hook: Callable) -> None: + """添加启动钩子""" + self.startup_hooks.append(hook) + self.logger.debug(f"已添加启动钩子: {hook.__name__}") + + def add_shutdown_hook(self, hook: Callable) -> None: + """添加关闭钩子""" + self.shutdown_hooks.append(hook) + self.logger.debug(f"已添加关闭钩子: {hook.__name__}") + + def add_worker_start_hook(self, hook: Callable) -> None: + """添加 worker 启动钩子 + + 在每个 worker 进程的 lifespan 启动阶段触发(startup_hooks 之后、 + 调度器启动之前),单 worker 模式下也触发一次。 + """ + self.worker_start_hooks.append(hook) + self.logger.debug(f"已添加 worker 启动钩子: {hook.__name__}") + + def add_worker_stop_hook(self, hook: Callable) -> None: + """添加 worker 停止钩子 + + 在每个 worker 进程的 lifespan 关闭阶段触发(调度器停止之后、 + shutdown_hooks 之前)。 + + 注意:Windows 多 worker 模式下父进程通过 terminate()(硬终止) + 清理 worker 进程,lifespan 关闭阶段可能不会执行, + 因此 worker 停止钩子在 Windows 上不保证触发。 + """ + self.worker_stop_hooks.append(hook) + self.logger.debug(f"已添加 worker 停止钩子: {hook.__name__}") + + def register_service(self, name: str, service: Any) -> None: + """注册服务""" + self.services[name] = service + self.logger.debug(f"已注册服务: {name}") + + def get_service(self, name: str) -> Any: + """获取服务 + + 优先返回 app.services 中的单例实例;非单例(request/factory)服务 + 不在 app.services 中预存,回退到 DI 容器按需解析。 + """ + if name in self.services: + return self.services[name] + di_container = getattr(self, 'di_container', None) + if di_container is not None and di_container.has_service(name): + return di_container.get_service(name) + return None + + def has_service(self, name: str) -> bool: + """检查是否有服务""" + return name in self.services + + def get_client(self, name: str) -> Any: + """获取客户端""" + return self.clients.get(name) + + def has_client(self, name: str) -> bool: + """检查是否有客户端""" + return name in self.clients + + def get_component(self, name: str) -> Any: + """获取组件""" + return self.components.get(name) + + def has_component(self, name: str) -> bool: + """检查是否有组件""" + return name in self.components + + def route( + self, + path: str, + methods: Optional[List[str]] = None, + **kwargs + ) -> Callable: + """ + 装饰器:注册路由 + + Args: + path: 路由路径 + methods: HTTP 方法列表 + **kwargs: 其他 FastAPI 路由参数 + """ + if methods is None: + methods = ["GET"] + + def decorator(func: Callable) -> Callable: + # 存储路由处理器 + route_key = f"{','.join(methods)}:{path}" + self.route_handlers[route_key] = func + + self.logger.debug(f"已注册路由: {methods} {path} -> {func.__name__}") + return func + + return decorator + + def get(self, path: str, **kwargs) -> Callable: + """GET 路由装饰器""" + return self.route(path, ["GET"], **kwargs) + + def post(self, path: str, **kwargs) -> Callable: + """POST 路由装饰器""" + return self.route(path, ["POST"], **kwargs) + + def put(self, path: str, **kwargs) -> Callable: + """PUT 路由装饰器""" + return self.route(path, ["PUT"], **kwargs) + + def delete(self, path: str, **kwargs) -> Callable: + """DELETE 路由装饰器""" + return self.route(path, ["DELETE"], **kwargs) + + def patch(self, path: str, **kwargs) -> Callable: + """PATCH 路由装饰器""" + return self.route(path, ["PATCH"], **kwargs) + + def _create_fastapi_app(self) -> FastAPI: + """创建 FastAPI 应用实例""" + + @asynccontextmanager + async def lifespan(app: FastAPI): + """应用生命周期管理""" + + # 执行启动钩子 + for hook in self.startup_hooks: + try: + if asyncio.iscoroutinefunction(hook): + await hook() + else: + hook() + except Exception as e: + self.logger.error(f"启动钩子执行失败: {e}", exc_info=True) + + # 执行 worker 启动钩子(每个 worker 进程的 lifespan 各触发一次, + # 位于 startup_hooks 之后、调度器启动之前) + for hook in self.worker_start_hooks: + try: + if asyncio.iscoroutinefunction(hook): + await hook() + else: + hook() + except Exception as e: + self.logger.error(f"Worker 启动钩子执行失败: {e}", exc_info=True) + + # 启动调度器 + if self.scheduler.has_jobs(): + self.scheduler.start() + self.logger.info("✅ 任务调度器已启动") + + yield + + # 关闭 + self.logger.info(f"🛑 关闭 {self.name}...") + + # 停止调度器 + if self.scheduler.is_running(): + self.scheduler.stop() + self.logger.info("✅ 任务调度器已停止") + + # 执行 worker 停止钩子(调度器停止之后、shutdown_hooks 之前) + # 注意:Windows 多 worker 模式下父进程使用 terminate() 硬终止 + # worker,lifespan 关闭阶段可能不执行,stop 钩子不保证触发 + for hook in self.worker_stop_hooks: + try: + if asyncio.iscoroutinefunction(hook): + await hook() + else: + hook() + except Exception as e: + self.logger.error(f"Worker 停止钩子执行失败: {e}", exc_info=True) + + # 自动关闭 client:对具有 close() 方法的 client 实例兜底调用 + # (worker_stop_hooks 之后,shutdown_hooks 之前)。 + # 应用若已自行 close,建议实现为幂等;二次 close 的异常降为 warning + for client_name, client_instance in list(self.clients.items()): + close_method = getattr(client_instance, "close", None) + if not callable(close_method): + continue + try: + if asyncio.iscoroutinefunction(close_method): + await close_method() + else: + close_method() + self.logger.debug(f"Client 已自动关闭: {client_name}") + except Exception as e: + self.logger.warning(f"Client '{client_name}' close() 异常(已忽略): {e}") + + # 执行关闭钩子 + for hook in self.shutdown_hooks: + try: + if asyncio.iscoroutinefunction(hook): + await hook() + else: + hook() + except Exception as e: + self.logger.error(f"关闭钩子执行失败: {e}", exc_info=True) + + # Prometheus multiproc:标记本进程退出,清理 gauge 残留文件 + # (内部已兜底 try/except,此处再防御一层确保关闭流程不被打断) + try: + from ..metrics import mark_current_process_dead + mark_current_process_dead() + except Exception: + pass + + # 创建 FastAPI 应用 + + app = FastAPI( + title=self.name, + version=self.version, + lifespan=lifespan, + ) + + # 添加 CORS 中间件(如果配置了 server.cors) + cors_config = self.config.get("server.cors") + if cors_config: + app.add_middleware( + CORSMiddleware, + allow_origins=cors_config.get("allow_origins", ["*"]), + allow_credentials=cors_config.get("allow_credentials", True), + allow_methods=cors_config.get("allow_methods", ["*"]), + allow_headers=cors_config.get("allow_headers", ["*"]), + ) + self.logger.debug("CORS 中间件已启用") + + # Prometheus 指标(metrics.enabled 时启用,可选依赖缺失只告警不报错) + from ..metrics import is_available as metrics_available, is_enabled as metrics_enabled + metrics_path = str(self.config.get("metrics.path", "/metrics") or "/metrics") + metrics_ready = False + if metrics_enabled(self.config): + if metrics_available(): + metrics_ready = True + from ..metrics import _coerce_bool + if _coerce_bool(self.config.get("metrics.http_metrics", True), True): + from ..metrics import HttpMetricsMiddleware + app.add_middleware(HttpMetricsMiddleware, metrics_path=metrics_path) + self.logger.debug("HTTP 指标中间件已启用") + else: + self.logger.warning( + "metrics.enabled=true 但未安装 prometheus-client," + "指标功能不可用。安装方式: pip install myboot[metrics]" + ) + + # 添加自定义中间件 + for middleware in self.middlewares: + app.add_middleware(middleware.middleware_class, **middleware.kwargs) + + # 添加响应格式化中间件(最后添加,因为它会最先执行) + # FastAPI 中间件是后进先出(LIFO),所以最后添加的中间件会最先处理响应 + response_format_enabled = self.config.get("server.response_format.enabled", True) + if response_format_enabled: + from myboot.web.middleware import ResponseFormatterMiddleware + exclude_paths = list(self.config.get("server.response_format.exclude_paths", []) or []) + if metrics_ready: + # metrics 端点输出 Prometheus 文本格式,不做 JSON 包装 + exclude_paths.append(metrics_path) + app.add_middleware( + ResponseFormatterMiddleware, + exclude_paths=exclude_paths, + auto_wrap=True + ) + self.logger.debug("响应格式化中间件已启用") + + # 注册路由 + self._register_routes(app) + + # 注册异常处理器 + self._register_exception_handlers(app) + + # 添加健康检查端点 + self._add_health_endpoints(app) + + # 挂载 Prometheus 指标端点(懒初始化,首次请求才 import prometheus_client) + if metrics_ready: + from ..metrics import make_metrics_asgi_app + app.mount(metrics_path, make_metrics_asgi_app()) + self.logger.debug(f"Prometheus 指标端点已挂载: {metrics_path}") + + return app + + def _register_routes(self, app: FastAPI) -> None: + """注册路由到 FastAPI 应用""" + for route_key, handler in self.route_handlers.items(): + methods, path = route_key.split(":", 1) + method_list = methods.split(",") + + # 添加路由到 FastAPI + app.add_api_route( + path, + handler, + methods=method_list, + name=handler.__name__ + ) + + def _register_exception_handlers(self, app: FastAPI) -> None: + """注册异常处理器""" + + def _extract_error_messages(errors: list) -> list: + """从错误列表中提取错误消息""" + messages = [] + for error in errors: + msg = error.get('msg', 'Validation Error') + # 移除 "Value error, " 前缀 + if msg.startswith('Value error, '): + msg = msg[len('Value error, '):] + messages.append(msg) + return messages + + @app.exception_handler(MyBootException) + async def myboot_exception_handler(request: Request, exc: MyBootException): + """MyBoot 异常处理器""" + self.logger.error(f"MyBoot 异常: {exc}", exc_info=True) + return JSONResponse( + status_code=500, + content={ + "success": False, + "code": 500, + "message": "Internal Server Error", + "data": { + "type": exc.__class__.__name__ + } + } + ) + + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + """HTTP 异常处理器""" + self.logger.warning(f"HTTP 异常: {exc.status_code} - {exc.detail}") + return JSONResponse( + status_code=exc.status_code, + content={ + "success": False, + "code": exc.status_code, + "message": "HTTP Error" + } + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + """请求验证异常处理器 - 返回友好的错误信息""" + errors = exc.errors() + # 只提取错误消息 + error_messages = _extract_error_messages(errors) + + # 使用第一个错误消息作为主要错误信息 + error_msg = error_messages[0] if error_messages else 'Validation Error' + + self.logger.warning(f"请求验证失败: {error_messages}") + return JSONResponse( + status_code=422, + content={ + "success": False, + "code": 422, + "message": error_msg, + "data": { + "fieldErrors": error_messages + } + } + ) + + @app.exception_handler(Exception) + async def global_exception_handler(request: Request, exc: Exception): + """全局异常处理器""" + self.logger.error(f"未处理的异常: {exc}", exc_info=True) + return JSONResponse( + status_code=500, + content={ + "success": False, + "code": 500, + "message": "Internal Server Error", + "data": { + "type": exc.__class__.__name__ + } + } + ) + + def _add_health_endpoints(self, app: FastAPI) -> None: + """添加健康检查端点""" + + @app.get("/health") + async def health_check(): + """健康检查端点""" + return { + "status": "healthy", + "app": self.name, + "version": self.version, + "uptime": "running" + } + + @app.get("/health/ready") + async def readiness_check(): + """就绪检查端点""" + return { + "status": "ready", + "app": self.name, + "services": { + "scheduler": self.scheduler.is_running() if self.scheduler.has_jobs() else "disabled" + } + } + + @app.get("/health/live") + async def liveness_check(): + """存活检查端点""" + return { + "status": "alive", + "app": self.name + } + + def run( + self, + host: str = "0.0.0.0", + port: int = 8000, + reload: bool = False, + workers: int = 1, + app_path: Optional[str] = None, + **kwargs + ) -> None: + """ + 运行应用程序 + + Args: + host: 主机地址 + port: 端口号 + reload: 是否开启热重载 + workers: 工作进程数 + app_path: 应用模块路径(多 workers 模式必需),格式为 "module.path:app_name" + 例如: "main:app" 表示从 main.py 导入 app 变量 + 如果 app 是通过 get_fastapi_app() 获取,使用 "main:app.get_fastapi_app()" + **kwargs: 其他服务器参数 + + Example: + # 单进程模式(默认) + app.run(host="0.0.0.0", port=8000) + + # 多进程模式(4个 workers) + app.run( + host="0.0.0.0", + port=8000, + workers=4, + app_path="main:app.get_fastapi_app()" + ) + """ + # 从配置中获取参数 + host = self.config.get("server.host", host) + port = self.config.get("server.port", port) + reload = self.config.get("server.reload", reload) + workers = self.config.get("server.workers", workers) + app_path = self.config.get("server.app_path", app_path) + + # 白名单方式收集 server.* 中的 Hypercorn 配置 + # 别名转换(如 max_incomplete_request_size)由 server.py 的 HYPERCORN_CONFIG_ALIASES 统一处理 + server_section = self.config.get("server") or {} + server_kwargs = {} + for k in ("backlog", "read_timeout", "keep_alive_timeout", "graceful_timeout", + "reloader", "use_reloader", "max_incomplete_request_size"): + if k in server_section: + server_kwargs[k] = server_section[k] + # 合并:配置文件 < run(**kwargs) 覆盖 + run_kwargs = {**server_kwargs, **kwargs} + + # 自动发现和配置 + # 多 worker 模式(workers > 1 且提供了 app_path)下,父进程只做 AST 自动发现 + # (无副作用,预热 .myboot_cache_*.json 供子进程使用),实例化(apply_auto_configuration) + # 延迟到每个 worker 进程内的 bootstrap_worker() 执行, + # 避免 fork 模式下所有 worker 共享父进程预先创建的 client/service 实例(issue #11)。 + # 单 worker 模式与缺少 app_path 的回退路径(父进程自己 serve)保持原有行为不变。 + if self.auto_configuration_enabled: + self.logger.info("🔍 开始自动发现组件...") + auto_discover(self.auto_discover_package) + if workers > 1 and app_path: + self.logger.info("⏩ 多 worker 模式:自动配置延迟到各 worker 进程内执行") + else: + apply_auto_configuration(self) + + # 获取真实 IP 用于日志显示(服务器仍然使用配置的 host 绑定) + display_host = get_local_ip() if host == "0.0.0.0" else host + + # 显示服务器信息 + self.logger.info(f"🌐 服务器启动: http://{display_host}:{port}") + self.logger.info(f"📚 API 文档: http://{display_host}:{port}/docs") + self.logger.info(f"🔍 健康检查: http://{display_host}:{port}/health") + self.logger.info(f"⚙️ 服务器类型: Hypercorn") + self.logger.info(f"🔧 工作进程: {workers}") + + if workers > 1 and not app_path: + self.logger.warning( + "⚠️ 多 workers 模式需要提供 app_path 参数," + "例如: app.run(workers=4, app_path='main:app.get_fastapi_app()')" + ) + + # 启动服务器 + try: + self.server_manager.start_server( + app=self._fastapi_app, + host=host, + port=port, + reload=reload, + workers=workers, + app_path=app_path, + **run_kwargs + ) + except KeyboardInterrupt: + self.logger.info("收到中断信号,正在关闭...") + finally: + asyncio.run(self.shutdown()) + + async def shutdown(self) -> None: + """优雅关闭应用程序""" + if self._server: + # 服务器关闭逻辑 + pass + + # 停止调度器 + if self.scheduler.is_running(): + self.scheduler.stop() + + self.logger.info("应用程序已关闭") + + def add_route(self, path: str, handler: Callable, methods: List[str] = None, **kwargs) -> None: + """添加路由到 FastAPI 应用""" + if self._fastapi_app is None: + self._fastapi_app = self._create_fastapi_app() + + if methods is None: + methods = ['GET'] + + # 使用 FastAPI 的 add_api_route 方法 + self._fastapi_app.add_api_route(path, handler, methods=methods, **kwargs) + + def get_fastapi_app(self) -> FastAPI: + """获取 FastAPI 应用实例""" + if self._fastapi_app is None: + self._fastapi_app = self._create_fastapi_app() + return self._fastapi_app + + def bootstrap_worker(self) -> FastAPI: + """Worker 进程内引导(多 worker 模式下由 server._worker_serve 调用) + + spawn(Windows)与 fork(Linux/macOS)两种模式在此收敛: + + 1. 重读 MYBOOT_WORKER_ID 等环境变量——它们在 _worker_serve 中设置, + 晚于 Application 构造(spawn 子进程在 multiprocessing 引导阶段 + 重新 import 用户主模块时即构造 Application),因此 __init__ 读到 + 的是默认值,必须在这里刷新。 + 2. 重算 scheduler 门控并重建 Scheduler——修复「每个 worker 都自认 + primary、调度器多跑」的问题;同时丢弃 fork 继承的、绑定到 + fork 前实例的任务注册。 + 3. 防御性重置——若检测到 fork 继承的预引导状态(父进程曾手动执行 + apply_auto_configuration),清空 DI 容器与各注册表并重建 + FastAPI 应用,丢弃绑定到 fork 前控制器实例的路由。 + 4. 在 worker 进程内执行自动配置——所有 client/service/component/ + controller 在本 worker 内实例化(issue #11 核心修复),同时修复 + Windows spawn 模式下 worker 无用户路由的问题。 + + Returns: + 本 worker 进程内完成引导的 FastAPI 应用实例 + """ + # 1. 重读 worker 环境变量 + self._worker_id = int(os.environ.get("MYBOOT_WORKER_ID", "1")) + self._worker_count = int(os.environ.get("MYBOOT_WORKER_COUNT", "1")) + self._is_primary_worker = os.environ.get("MYBOOT_IS_PRIMARY_WORKER", "1") == "1" + + # 2. 重算调度器门控并重建调度器(默认仅 primary worker 启用, + # 可通过 scheduler.on_all_workers 配置覆盖;任务级 all_workers=True + # 的任务在本 worker 注册时由注册门控按需调用 scheduler.enable()) + scheduler_on_all_workers = self.config.get("scheduler.on_all_workers", False) + scheduler_globally_enabled = self.config.get("scheduler.enabled", True) + self._scheduler_enabled = ( + self._is_primary_worker or scheduler_on_all_workers + ) and scheduler_globally_enabled + self.scheduler = Scheduler(config=self.config, enabled=self._scheduler_enabled) + + # 3. 防御性重置:fork 子进程若继承了父进程已引导的状态 + # (正常延迟引导路径下这些注册表都是空的,不会进入此分支) + di_container = getattr(self, 'di_container', None) + inherited = bool( + (di_container is not None and di_container.service_providers) + or self.services or self.clients or self.components + ) + if inherited: + self.logger.warning( + "检测到 fork 继承的预引导状态,重置注册表后在 worker 内重建实例..." + ) + if di_container is not None: + di_container.clear() + self.services.clear() + self.clients.clear() + self.components.clear() + if hasattr(self, '_client_type_map'): + self._client_type_map.clear() + if hasattr(self, '_component_type_map'): + self._component_type_map.clear() + # 注意:worker 钩子由 _auto_register_worker_hooks 重新注册, + # 先清空避免重复(此分支下父进程已经 apply 过一次) + self.worker_start_hooks.clear() + self.worker_stop_hooks.clear() + # 重建 FastAPI 应用,丢弃绑定到 fork 前控制器实例的路由 + self._fastapi_app = self._create_fastapi_app() + + # 4. 在 worker 进程内执行自动配置 + # - spawn 子进程:全局管理器是全新的,auto_discover 执行扫描(命中父进程缓存) + # - fork 子进程:管理器已发现完成,auto_discover 幂等守卫直接返回 + if self.auto_configuration_enabled: + self.logger.info(f"🔍 Worker-{self._worker_id} 开始进程内引导...") + auto_discover(self.auto_discover_package) + apply_auto_configuration(self) + + return self.get_fastapi_app() + + # ==================== Worker 信息 ==================== + + @property + def worker_id(self) -> int: + """ + 获取当前 Worker ID(从 1 开始) + + 单进程模式下返回 1 + """ + return self._worker_id + + @property + def worker_count(self) -> int: + """ + 获取 Worker 总数 + + 单进程模式下返回 1 + """ + return self._worker_count + + @property + def is_primary_worker(self) -> bool: + """ + 是否为主 Worker(Worker ID = 1) + + 适用于只需要在一个 worker 执行的任务(如定时任务、初始化任务) + + Example: + if app.is_primary_worker: + # 只在主 worker 执行 + await init_cache() + """ + return self._is_primary_worker + + @property + def is_multi_worker_mode(self) -> bool: + """是否运行在多 Worker 模式""" + return self._worker_count > 1 + + +# 便捷函数 +def create_app( + name: str = "MyBoot App", + config_file: Optional[str] = None, + **kwargs +) -> Application: + """创建 MyBoot 应用程序实例""" + return Application(name, config_file, **kwargs) + + +def get_service(name: str): + return _current_app.get_service(name) + + +def get_client(name: str): + return _current_app.get_client(name) + + +def get_container() -> Container: + """获取容器实例""" + if _current_app is None: + raise RuntimeError("应用实例未初始化,请确保应用已创建并启动") + return _current_app.container diff --git a/myboot/core/auto_configuration.py b/myboot/core/auto_configuration.py new file mode 100644 index 0000000..913ad92 --- /dev/null +++ b/myboot/core/auto_configuration.py @@ -0,0 +1,962 @@ +""" +自动配置模块 + +实现约定优于配置的设计理念,提供自动发现和配置功能 + +设计参考 Spring Boot: +- 使用 AST 静态分析替代 import(发现阶段不执行代码) +- 延迟导入:只在注册阶段才导入需要的模块 +- 缓存 AST 分析结果,缓存命中时完全跳过分析 +""" + +import os +import time +import inspect +from pathlib import Path +from typing import Dict, List, Type, Any + +from loguru import logger as loguru_logger + +from ._internal import ast_analyzer, component_scanner, scan_cache +from ._internal.scan_cache import _CACHE_VERSION + +logger = loguru_logger.bind(name=__name__) + + +class AutoConfigurationError(Exception): + """自动配置失败异常 + + 当自动注册组件失败时抛出此异常,导致应用启动失败 + """ + pass + +# MyBoot 装饰器到组件类型的映射 +# 注意:@cron/@interval/@once 装饰器只能在 @component 类中使用 +# job 方法的注册在 _auto_register_components 中动态进行 +_DECORATOR_MAPPING = { + 'service': 'services', + 'client': 'clients', + 'model': 'models', + 'component': 'components', + 'rest_controller': 'rest_controllers', + 'route': 'routes', + 'get': 'routes', + 'post': 'routes', + 'put': 'routes', + 'delete': 'routes', + 'patch': 'routes', + 'middleware': 'middleware', + 'on_worker_start': 'worker_hooks', + 'on_worker_stop': 'worker_hooks', +} + + +from ..utils.naming import camel_to_snake as _camel_to_snake + + +def _find_project_root() -> str: + """查找项目根目录""" + # 从当前文件所在目录开始向上查找 + current_dir = Path(__file__).parent.absolute() + + # 向上查找,直到找到包含 pyproject.toml 或 requirements.txt 的目录 + while current_dir.parent != current_dir: + if (current_dir / 'pyproject.toml').exists() or (current_dir / 'requirements.txt').exists(): + return str(current_dir) + current_dir = current_dir.parent + + # 如果没找到,返回当前工作目录 + return os.getcwd() + + +class AutoConfigurationManager: + """ + 自动配置管理器 + + 设计参考 Spring Boot: + - 发现阶段:使用 AST 静态分析,不执行 import + - 注册阶段:延迟导入,只导入需要的模块 + - 缓存:缓存 AST 分析结果,缓存命中时完全跳过分析 + """ + + def __init__(self, app_root: str = None, use_cache: bool = True, parallel_import: bool = False): + self.app_root = app_root or _find_project_root() + self.use_cache = use_cache + self.parallel_import = parallel_import + # 组件元数据(不包含实际类对象,只有模块路径和名称) + self._component_metadata: Dict[str, List[dict]] = { + 'routes': [], + 'middleware': [], + 'services': [], + 'models': [], + 'clients': [], + 'components': [], + 'rest_controllers': [], + 'worker_hooks': [] + } + # 已加载的组件(包含实际类对象,延迟填充) + self.discovered_components: Dict[str, List[dict]] = { + 'routes': [], + 'middleware': [], + 'services': [], + 'models': [], + 'clients': [], + 'components': [], + 'rest_controllers': [], + 'worker_hooks': [] + } + self.auto_configured = False + self._modules_loaded = False + + def _get_cache_path(self, package_name: str) -> Path: + """获取缓存文件路径""" + return scan_cache.get_cache_path(self.app_root, package_name) + + def _collect_source_files(self, package_path: Path) -> Dict[str, float]: + """收集所有源文件及其修改时间""" + return scan_cache.collect_source_files(package_path) + + def _is_cache_valid(self, cache_path: Path, package_path: Path) -> bool: + """检查缓存是否有效""" + return scan_cache.is_cache_valid(cache_path, package_path) + + def _save_cache(self, cache_path: Path, package_path: Path) -> None: + """保存元数据缓存(不包含类对象)""" + scan_cache.save_cache(cache_path, package_path, self._component_metadata) + + def _load_cache(self, cache_path: Path) -> bool: + """从缓存加载元数据(不导入模块)""" + loaded = scan_cache.load_cache(cache_path, self._component_metadata.keys()) + if loaded is None: + return False + self._component_metadata = loaded + return True + + def _parse_decorators(self, node) -> List[str]: + """解析装饰器名称""" + return ast_analyzer.parse_decorators(node) + + def _scan_file_ast(self, file_path: Path, module_name: str) -> None: + """使用 AST 静态分析扫描单个文件(不执行 import)""" + ast_analyzer.scan_file_ast( + file_path, module_name, self._component_metadata, _DECORATOR_MAPPING + ) + + def _scan_package_ast(self, package_path: Path) -> None: + """使用 AST 递归扫描包(不执行 import)""" + ast_analyzer.scan_package_ast( + package_path, self._component_metadata, _DECORATOR_MAPPING + ) + + def _load_modules(self) -> None: + """延迟加载模块,将元数据转换为实际的类对象""" + if self._modules_loaded: + return + + self.discovered_components = component_scanner.build_discovered_components( + self._component_metadata, self.parallel_import + ) + + self._modules_loaded = True + + def _resolve_scan_target(self, package_name: str): + """解析扫描目标:单模块文件或包目录 + + Returns: + (scan_path, scan_type, module_name) + scan_type 为 'file' 或 'package';目录扫描时 module_name 为 None + """ + app_root = Path(self.app_root) + + if package_name.endswith('.py'): + file_path = app_root / package_name.replace('/', os.sep) + if file_path.is_file(): + module_name = str( + file_path.relative_to(app_root).with_suffix('') + ).replace(os.sep, '.') + return file_path, 'file', module_name + + dotted_path = app_root / package_name.replace('.', os.sep) + py_file = dotted_path.with_suffix('.py') + if py_file.is_file(): + return py_file, 'file', package_name + + dir_path = dotted_path if dotted_path.is_dir() else app_root / package_name + if dir_path.is_dir(): + return dir_path, 'package', None + + return None, None, None + + def auto_discover(self, package_name: str = "app") -> None: + """ + 自动发现应用组件(AST 静态分析,不执行 import) + + 模块的实际导入延迟到 apply_auto_configuration 时进行。 + package_name 支持目录包(如 app、examples/convention)或单模块 + (如 examples.convention_app、examples/convention_app.py)。 + """ + # 幂等守卫:fork 子进程(bootstrap_worker)重复调用时直接返回, + # 避免向 _component_metadata 追加重复条目 + if self.auto_configured: + logger.debug("自动发现已完成,跳过重复扫描") + return + + start_time = time.perf_counter() + logger.info(f"开始自动发现 {package_name} 包中的组件...") + + try: + scan_path, scan_type, module_name = self._resolve_scan_target(package_name) + if scan_path is None: + logger.warning(f"扫描目标不存在: {package_name}") + return + + cache_path = self._get_cache_path(package_name) + + # 尝试使用缓存(只读取元数据,不导入模块) + if self.use_cache and self._is_cache_valid(cache_path, scan_path): + if self._load_cache(cache_path): + self.auto_configured = True + elapsed = (time.perf_counter() - start_time) * 1000 + logger.info(f"自动发现完成(从缓存加载),耗时: {elapsed:.2f}ms") + return + + # AST 静态分析扫描(不执行 import) + if scan_type == 'file': + self._scan_file_ast(scan_path, module_name) + else: + self._scan_package_ast(scan_path) + + # 保存缓存 + if self.use_cache: + self._save_cache(cache_path, scan_path) + + self.auto_configured = True + elapsed = (time.perf_counter() - start_time) * 1000 + logger.info(f"自动发现完成(AST 扫描),耗时: {elapsed:.2f}ms") + + except Exception as e: + logger.error(f"自动发现失败: {e}", exc_info=True) + + def apply_auto_configuration(self, app) -> None: + """应用自动配置(此时才执行模块导入)""" + if not self.auto_configured: + logger.warning("自动发现未完成,跳过自动配置") + return + + start_time = time.perf_counter() + + # 延迟加载模块(将元数据转换为实际类对象) + load_start = time.perf_counter() + self._load_modules() + load_elapsed = (time.perf_counter() - load_start) * 1000 + logger.info(f"模块加载完成,耗时: {load_elapsed:.2f}ms") + + self._auto_register_routes(app) + self._auto_register_models(app) + self._auto_register_clients(app) + self._auto_register_services(app) # 先注册服务,支持依赖注入 + self._auto_register_components(app) # 注册组件并注册其中的 job 方法 + self._auto_register_middleware(app) + self._auto_register_rest_controllers(app) + # 最后注册 worker 钩子(此时容器/服务已就绪,钩子内可解析依赖) + self._auto_register_worker_hooks(app) + + elapsed = (time.perf_counter() - start_time) * 1000 + logger.info(f"自动配置应用完成,耗时: {elapsed:.2f}ms") + + def _auto_register_rest_controllers(self, app) -> None: + """自动注册 REST 控制器 + + 只注册显式使用 @get、@post 等装饰器的方法,不自动根据方法名生成路由 + """ + import inspect as inspect_module + + for controller_info in self.discovered_components['rest_controllers']: + try: + cls = controller_info['class'] + controller_config = getattr(cls, '__myboot_rest_controller__') + base_path = controller_config['base_path'] + base_kwargs = controller_config.get('kwargs', {}) + + # 创建控制器实例(支持依赖注入) + instance = self._get_class_instance(cls, app) + + # 检查类中的所有方法,只处理显式使用路由装饰器的方法 + for method_name, method in inspect_module.getmembers( + instance, + predicate=lambda x: inspect_module.ismethod(x) and not x.__name__.startswith('_') + ): + # 只处理有 __myboot_route__ 属性的方法(即显式使用 @get、@post 等装饰器的方法) + if hasattr(method, '__myboot_route__'): + route_config = getattr(method, '__myboot_route__') + method_path = route_config['path'] + methods = route_config.get('methods', ['GET']) + route_kwargs = {**base_kwargs, **route_config.get('kwargs', {})} + route_kwargs.setdefault( + 'operation_id', + f"{controller_info['module'].replace('.', '_')}_" + f"{cls.__name__}_{method_name}" + ) + + # 合并基础路径和方法路径 + # 如果方法路径是绝对路径(以 // 开头),则直接使用(去掉一个 /) + # 否则,将方法路径追加到基础路径 + if method_path.startswith('//'): + # 绝对路径,去掉一个 / 后使用 + full_path = method_path[1:] + elif method_path.startswith('/'): + # 以 / 开头但非绝对路径,去掉开头的 / 后追加到基础路径 + full_path = f"{base_path}{method_path}".replace('//', '/') + else: + # 相对路径,追加到基础路径 + full_path = f"{base_path}/{method_path}".replace('//', '/') + + # 注册路由 + app.add_route( + path=full_path, + handler=method, + methods=methods, + **route_kwargs + ) + + logger.debug(f"自动注册 REST 路由: {methods} {full_path} -> {controller_info['module']}.{cls.__name__}.{method_name}") + + logger.info(f"自动注册 REST 控制器: {controller_info['module']}.{cls.__name__}") + except Exception as e: + logger.error(f"自动注册 REST 控制器失败 {controller_info['module']}: {e}", exc_info=True) + raise AutoConfigurationError( + f"自动注册 REST 控制器失败 '{controller_info['module']}': {e}" + ) from e + + def _auto_register_routes(self, app) -> None: + """自动注册路由""" + for route_info in self.discovered_components['routes']: + try: + # 前缀匹配:AST 扫描产生的类型为 function_get/function_post/ + # function_route 等(issue #8 修复同款逻辑,精确匹配会漏掉 + # 模块级 @get/@post 函数路由) + if route_info['type'].startswith('function_'): + # 函数路由 + func = route_info['function'] + route_config = getattr(func, '__myboot_route__') + app.add_route( + path=route_config['path'], + handler=func, + methods=route_config.get('methods', ['GET']), + **route_config.get('kwargs', {}) + ) + logger.debug(f"自动注册路由: {route_info['module']}.{func.__name__}") + elif route_info['type'].startswith('class_'): + # 类路由(class 条目没有 'function' 键,日志只在各方法处打印) + cls = route_info['class'] + route_config = getattr(cls, '__myboot_route__') + instance = cls() + for method_name, method in inspect.getmembers(instance, predicate=inspect.ismethod): + if hasattr(method, '__myboot_route__'): + method_config = getattr(method, '__myboot_route__') + app.add_route( + path=method_config['path'], + handler=method, + methods=method_config.get('methods', ['GET']), + **method_config.get('kwargs', {}) + ) + logger.debug(f"自动注册路由: {route_info['module']}.{cls.__name__}.{method_name}") + except Exception as e: + logger.error(f"自动注册路由失败 {route_info['module']}: {e}", exc_info=True) + raise AutoConfigurationError( + f"自动注册路由失败 '{route_info['module']}': {e}" + ) from e + + def _is_job_enabled(self, _func, job_config: dict) -> bool: + """ + 检查任务是否启用 + + Args: + _func: 任务函数(保留用于未来扩展,如从函数名读取配置) + job_config: 任务配置 + + Returns: + bool: 任务是否启用 + """ + # 检查装饰器中直接指定的 enabled + enabled = job_config.get('enabled') + if enabled is not None: + # 支持布尔值和字符串 + if isinstance(enabled, bool): + return enabled + if isinstance(enabled, str): + return enabled.lower() in ('true', '1', 'yes', 'on', 'enabled') + return bool(enabled) + + # 默认启用 + return True + + def _should_register_job_on_this_worker(self, app, job_config: dict) -> bool: + """ + 判断任务是否应在当前 worker 进程注册(任务级 all_workers 门控) + + 规则: + - scheduler.enabled=false:全局禁用,任何 worker 都不注册 + - primary worker:注册全部任务(现状行为) + - 非 primary worker: + - scheduler.on_all_workers=true(全局配置):注册全部任务 + - 任务声明 all_workers=True:注册该任务 + - 其他:跳过 + """ + config = getattr(app, 'config', None) + if config is not None and not config.get('scheduler.enabled', True): + return False + if getattr(app, 'is_primary_worker', True): + return True + if config is not None and config.get('scheduler.on_all_workers', False): + return True + return bool(job_config.get('all_workers', False)) + + def _get_class_instance(self, cls: Type, app) -> Any: + """ + 获取类实例,支持依赖注入 + + 从 di_container 获取 service 依赖,从 app.components、app.clients 获取依赖 + 找不到直接报错,不尝试初始化 + + Args: + cls: 类 + app: 应用实例 + + Returns: + 类实例 + + Raises: + RuntimeError: 如果 di_container 不存在或依赖注入失败 + KeyError: 如果必需依赖未注册 + """ + # 检查 di_container 是否存在 + if not hasattr(app, 'di_container'): + raise RuntimeError(f"无法实例化 {cls.__name__}:应用未配置依赖注入容器") + + di_container = app.di_container + + # 检查类是否有 @service 装饰器 + if hasattr(cls, '__myboot_service__'): + service_config = getattr(cls, '__myboot_service__') + service_name = service_config.get('name', _camel_to_snake(cls.__name__)) + if di_container.has_service(service_name): + return di_container.get_service(service_name) + raise KeyError(f"服务 '{service_name}' 未在 DI 容器中注册") + + # 检查类是否有 @client 装饰器 + if hasattr(cls, '__myboot_client__'): + client_config = getattr(cls, '__myboot_client__') + client_name = client_config.get('name', _camel_to_snake(cls.__name__)) + if hasattr(app, 'clients') and client_name in app.clients: + return app.clients[client_name] + raise KeyError(f"客户端 '{client_name}' 未注册") + + # 检查类是否有 @component 装饰器且已注册 + if hasattr(cls, '__myboot_component__'): + component_config = getattr(cls, '__myboot_component__') + component_name = component_config.get('name', _camel_to_snake(cls.__name__)) + if hasattr(app, 'components') and component_name in app.components: + return app.components[component_name] + # 组件尚未注册,继续创建新实例 + + # 检查构造函数是否有依赖 + from myboot.core.di.decorators import get_injectable_params + params = get_injectable_params(cls.__init__) + + if not params: + # 没有依赖参数,直接实例化 + return cls() + + # 有依赖参数,从 DI 容器和 clients 获取依赖 + dependencies = {} + for param_name, param_info in params.items(): + dependency_name = param_info.get('service_name') + if not dependency_name: + continue + + is_optional = param_info.get('is_optional', False) + dependency_instance = None + found = False + + # 优先从 DI 容器获取 service + if di_container.has_service(dependency_name): + try: + dependency_instance = di_container.get_service(dependency_name) + logger.debug(f"从 DI 容器注入 service 依赖: {param_name} = {dependency_name}") + found = True + except Exception as e: + if not is_optional: + raise RuntimeError( + f"无法实例化 {cls.__name__}:" + f"获取 service 依赖 '{dependency_name}' (参数 '{param_name}') 失败: {e}" + ) from e + logger.debug(f"获取可选 service 依赖 '{dependency_name}' 失败: {e}") + + # 如果没找到 service,尝试从 components 获取 + if not found and hasattr(app, 'components'): + # 先按名称查找 + if dependency_name in app.components: + dependency_instance = app.components[dependency_name] + logger.debug(f"从 components 注入依赖: {param_name} = {dependency_name}") + found = True + # 再尝试按类型查找 + elif hasattr(app, '_component_type_map'): + param_type = param_info.get('type') + if param_type and param_type in app._component_type_map: + actual_name = app._component_type_map[param_type] + dependency_instance = app.components[actual_name] + found = True + + # 如果没找到 component,尝试从 clients 获取 + if not found and hasattr(app, 'clients'): + # 先按名称查找 + if dependency_name in app.clients: + dependency_instance = app.clients[dependency_name] + logger.debug(f"从 clients 注入依赖: {param_name} = {dependency_name}") + found = True + # 再尝试按类型查找 + elif hasattr(app, '_client_type_map'): + param_type = param_info.get('type') + if param_type and param_type in app._client_type_map: + actual_name = app._client_type_map[param_type] + dependency_instance = app.clients[actual_name] + found = True + + # 如果都没找到且不是可选的,报错 + if not found and not is_optional: + raise KeyError( + f"无法实例化 {cls.__name__}:" + f"必需依赖 '{dependency_name}' (参数 '{param_name}') 未在 DI 容器、components 或 clients 中注册" + ) + + if found and dependency_instance is not None: + dependencies[param_name] = dependency_instance + + # 使用依赖实例化 + logger.debug(f"使用依赖注入实例化 {cls.__name__}: {list(dependencies.keys())}") + return cls(**dependencies) + + def _auto_register_middleware(self, app) -> None: + """自动注册中间件""" + from myboot.web.middleware import FunctionMiddleware + + if not self.discovered_components['middleware']: + return + + # 按照 order 排序中间件 + middleware_list = [] + for middleware_info in self.discovered_components['middleware']: + try: + func = middleware_info['function'] + middleware_config = getattr(func, '__myboot_middleware__') + order = middleware_config.get('order', 0) + middleware_list.append({ + 'func': func, + 'config': middleware_config, + 'order': order, + 'module': middleware_info['module'] + }) + except Exception as e: + logger.error(f"解析中间件配置失败 {middleware_info['module']}: {e}", exc_info=True) + raise AutoConfigurationError( + f"解析中间件配置失败 '{middleware_info['module']}': {e}" + ) from e + + # 按 order 排序 + middleware_list.sort(key=lambda x: x['order']) + + # 注册中间件(FastAPI 的中间件是后进先出的,所以需要反向注册) + for middleware_item in reversed(middleware_list): + try: + func = middleware_item['func'] + config = middleware_item['config'] + module = middleware_item['module'] + + # 创建动态中间件类,包装 FunctionMiddleware + middleware_name = config.get('name', func.__name__) + + # 使用闭包捕获变量 + def make_init(middleware_func, middleware_config): + def __init__(self, app): + FunctionMiddleware.__init__( + self, + app=app, + middleware_func=middleware_func, + path_filter=middleware_config.get('path_filter'), + methods=middleware_config.get('methods'), + condition=middleware_config.get('condition'), + order=middleware_config.get('order', 0), + **middleware_config.get('kwargs', {}) + ) + return __init__ + + # 动态创建中间件类 + middleware_class = type( + f"Middleware_{middleware_name}", + (FunctionMiddleware,), + {'__init__': make_init(func, config)} + ) + + # 添加到 FastAPI 应用 + app._fastapi_app.add_middleware(middleware_class) + + logger.info( + f"自动注册中间件: '{middleware_name}' " + f"(order={config.get('order', 0)}, " + f"module={module})" + ) + except Exception as e: + logger.error(f"自动注册中间件失败 {middleware_item['module']}: {e}", exc_info=True) + raise AutoConfigurationError( + f"自动注册中间件失败 '{middleware_item['module']}': {e}" + ) from e + + logger.info(f"成功注册 {len(middleware_list)} 个中间件") + + def _auto_register_services(self, app) -> None: + """自动注册服务(支持依赖注入)""" + try: + from myboot.core.di import DependencyContainer + + # 检查应用是否有依赖注入容器 + if not hasattr(app, 'di_container'): + app.di_container = DependencyContainer() + + di_container = app.di_container + + # 第一步:注册所有服务到容器(不创建实例) + for service_info in self.discovered_components['services']: + try: + cls = service_info['class'] + service_config = getattr(cls, '__myboot_service__') + service_name = service_config.get('name', cls.__name__.lower()) + + # 获取服务作用域(默认单例) + scope = service_config.get('scope', 'singleton') + + # 注册到依赖注入容器 + di_container.register_service( + service_class=cls, + service_name=service_name, + scope=scope, + config=service_config + ) + + logger.debug(f"已注册服务到容器: '{service_name}' ({service_info['module']}.{cls.__name__})") + except Exception as e: + logger.error(f"注册服务到容器失败 {service_info['module']}: {e}", exc_info=True) + + # 第二步:构建依赖注入容器 + try: + di_container.build_container() + logger.info("依赖注入容器构建成功") + except Exception as e: + logger.error(f"构建依赖注入容器失败: {e}", exc_info=True) + raise AutoConfigurationError(f"构建依赖注入容器失败: {e}") from e + + # 第三步:获取所有服务实例并注册到应用上下文 + for service_info in self.discovered_components['services']: + try: + cls = service_info['class'] + service_config = getattr(cls, '__myboot_service__') + service_name = service_config.get('name', cls.__name__.lower()) + + # 非单例(request/factory)服务跳过预实例化: + # 按需通过 app.di_container / Container.get 解析, + # 不会出现在 app.services 字典中 + scope = service_config.get('scope', 'singleton') + if scope != 'singleton': + logger.info( + f"自动注册服务({scope} 作用域,按需解析): '{service_name}' " + f"({service_info['module']}.{cls.__name__})" + ) + continue + + # 从容器获取服务实例(自动注入依赖) + instance = di_container.get_service(service_name) + app.services[service_name] = instance + + # 确保当前应用实例已注册 + from myboot.core.application import _current_app + if _current_app != app: + # 更新当前应用实例 + import myboot.core.application + myboot.core.application._current_app = app + + logger.info(f"自动注册服务(依赖注入): '{service_name}' ({service_info['module']}.{cls.__name__})") + except Exception as e: + logger.error(f"获取服务实例失败 {service_info['module']}: {e}", exc_info=True) + raise AutoConfigurationError( + f"自动注册服务失败 '{service_name}' ({service_info['module']}): {e}" + ) from e + + except ImportError as e: + logger.error(f"dependency_injector 未安装: {e}", exc_info=True) + raise AutoConfigurationError( + "依赖注入需要 dependency_injector 库,请运行: pip install dependency-injector" + ) from e + except AutoConfigurationError: + # 重新抛出 AutoConfigurationError + raise + except Exception as e: + logger.error(f"依赖注入服务注册失败: {e}", exc_info=True) + raise AutoConfigurationError(f"依赖注入服务注册失败: {e}") from e + + def _auto_register_models(self, app) -> None: + """自动注册模型""" + for model_info in self.discovered_components['models']: + try: + cls = model_info['class'] + model_config = getattr(cls, '__myboot_model__') + + # 注册模型到应用上下文 + model_name = model_config.get('name', cls.__name__.lower()) + app.models[model_name] = cls + + logger.info(f"自动注册模型: {model_info['module']}") + except Exception as e: + logger.error(f"自动注册模型失败 {model_info['module']}: {e}", exc_info=True) + raise AutoConfigurationError( + f"自动注册模型失败 '{model_info['module']}': {e}" + ) from e + + def _auto_register_clients(self, app) -> None: + """自动注册客户端""" + # 初始化类型到名称的映射(用于按类型查找) + if not hasattr(app, '_client_type_map'): + app._client_type_map = {} + + # 初始化 DI 容器(如果还没有) + if not hasattr(app, 'di_container'): + from myboot.core.di import DependencyContainer + app.di_container = DependencyContainer() + + for client_info in self.discovered_components['clients']: + try: + cls = client_info['class'] + client_config = getattr(cls, '__myboot_client__') + # 优先使用用户自定义名称,否则使用 _camel_to_snake 自动生成 + client_name = client_config.get('name', _camel_to_snake(cls.__name__)) + auto_name = _camel_to_snake(cls.__name__) + + # 非单例(request/factory)客户端:注册类到 DI 容器按需解析, + # 不在此处创建实例,也不出现在 app.clients 字典中 + scope = client_config.get('scope', 'singleton') + if scope != 'singleton': + app.di_container.register_service( + service_class=cls, + service_name=client_name, + scope=scope, + config=client_config + ) + if auto_name != client_name and not app.di_container.has_service(auto_name): + app.di_container.register_service( + service_class=cls, + service_name=auto_name, + scope=scope, + config=client_config + ) + logger.info( + f"自动注册客户端({scope} 作用域,按需解析): '{client_name}' " + f"({client_info['module']}.{cls.__name__})" + ) + continue + + # 创建客户端实例并注册到应用上下文 + instance = cls() + app.clients[client_name] = instance + + # 同时记录类型映射,用于按类型查找 + app._client_type_map[cls] = client_name + # 也用自动转换的名称注册(如果不同),方便按类型名查找 + if auto_name != client_name and auto_name not in app.clients: + app.clients[auto_name] = instance + + # 将 client 实例注册到 DI 容器(作为已创建的单例) + # 这样 Service 可以通过 DI 容器获取 Client 依赖 + app.di_container.register_instance(client_name, instance) + if auto_name != client_name: + app.di_container.register_instance(auto_name, instance) + + logger.info(f"自动注册客户端: '{client_name}' ({client_info['module']}.{cls.__name__})") + except Exception as e: + logger.error(f"自动注册客户端失败 {client_info['module']}: {e}", exc_info=True) + raise AutoConfigurationError( + f"自动注册客户端失败 '{client_info['module']}': {e}" + ) from e + + def _auto_register_components(self, app) -> None: + """自动注册组件(支持依赖注入)""" + # 初始化类型到名称的映射(用于按类型查找) + if not hasattr(app, '_component_type_map'): + app._component_type_map = {} + + for component_info in self.discovered_components['components']: + try: + cls = component_info['class'] + component_config = getattr(cls, '__myboot_component__') + + # 获取组件配置 + component_name = component_config.get('name', _camel_to_snake(cls.__name__)) + lazy = component_config.get('lazy', False) + # scope 配置用于未来支持 prototype 模式 + + # 懒加载的组件跳过立即实例化 + if lazy: + # 记录组件信息,延迟创建 + app._lazy_components = getattr(app, '_lazy_components', {}) + app._lazy_components[component_name] = { + 'class': cls, + 'config': component_config, + 'module': component_info['module'] + } + logger.debug(f"已注册懒加载组件: '{component_name}' ({component_info['module']}.{cls.__name__})") + continue + + # 创建组件实例(支持依赖注入) + instance = self._get_class_instance(cls, app) + + # 注册到组件注册表 + app.components[component_name] = instance + + # 记录类型映射,用于按类型查找 + app._component_type_map[cls] = component_name + + # 也用自动转换的名称注册(如果不同) + auto_name = _camel_to_snake(cls.__name__) + if auto_name != component_name and auto_name not in app.components: + app.components[auto_name] = instance + + # 将组件实例注册到 DI 容器(作为已创建的单例) + # 这样其他组件可以通过 DI 容器获取依赖 + if hasattr(app, 'di_container'): + app.di_container.register_instance(component_name, instance) + if auto_name != component_name: + app.di_container.register_instance(auto_name, instance) + + # 注册组件内的 job 方法(@cron/@interval/@once) + self._register_component_jobs(app, instance, cls, component_info['module']) + + logger.info(f"自动注册组件: '{component_name}' ({component_info['module']}.{cls.__name__})") + except Exception as e: + logger.error(f"自动注册组件失败 {component_info['module']}: {e}", exc_info=True) + raise AutoConfigurationError( + f"自动注册组件失败 '{component_info['module']}': {e}" + ) from e + + def _register_component_jobs(self, app, instance, cls: Type, module_name: str) -> None: + """ + 注册组件内的 job 方法 + + 扫描组件实例中使用 @cron/@interval/@once 装饰器的方法,并注册到调度器 + + Args: + app: 应用实例 + instance: 组件实例 + cls: 组件类 + module_name: 模块名称 + """ + import inspect as inspect_module + + for method_name, method in inspect_module.getmembers(instance, predicate=inspect_module.ismethod): + # 跳过私有方法 + if method_name.startswith('_'): + continue + + # 检查是否有 job 装饰器 + if not hasattr(method, '__myboot_job__'): + continue + + job_config = getattr(method, '__myboot_job__') + + # 检查任务是否启用 + if not self._is_job_enabled(method, job_config): + logger.info(f"任务已禁用,跳过注册: {cls.__name__}.{method_name} ({module_name})") + continue + + # 任务级 all_workers 门控:非 primary worker 只注册声明了 + # all_workers=True 的任务(scheduler.on_all_workers=true 可全局覆盖) + if not self._should_register_job_on_this_worker(app, job_config): + logger.debug( + f"当前 worker 非 primary 且任务未声明 all_workers,跳过注册: " + f"{cls.__name__}.{method_name} ({module_name})" + ) + continue + + # 非 primary worker 上放行的 all_workers 任务:确保调度器实例启用 + # (非 primary worker 的调度器默认 enabled=False,仅靠注册门控 + # 决定实际任务集;scheduler.enabled=false 时上面门控已拦截) + if not getattr(app, 'is_primary_worker', True): + app.scheduler.enable() + + # 注册任务 + try: + if job_config['type'] == 'cron': + app.scheduler.add_cron_job( + func=method, + cron=job_config['cron'], + **job_config.get('kwargs', {}) + ) + elif job_config['type'] == 'interval': + app.scheduler.add_interval_job( + func=method, + interval=job_config['interval'], + **job_config.get('kwargs', {}) + ) + elif job_config['type'] == 'once': + app.scheduler.add_date_job( + func=method, + run_date=job_config['run_date'], + **job_config.get('kwargs', {}) + ) + + logger.info(f"自动注册任务(组件方法): {cls.__name__}.{method_name} ({module_name})") + except Exception as e: + logger.error(f"注册任务失败 {cls.__name__}.{method_name}: {e}", exc_info=True) + + def _auto_register_worker_hooks(self, app) -> None: + """自动注册 worker 生命周期钩子(@on_worker_start / @on_worker_stop) + + 模块级函数钩子按 order 排序后追加到 app.worker_start_hooks / + app.worker_stop_hooks,由每个 worker 进程的 lifespan 触发。 + """ + hook_infos = self.discovered_components.get('worker_hooks', []) + if not hook_infos: + return + + start_hooks = [] + stop_hooks = [] + for hook_info in hook_infos: + func = hook_info.get('function') + if func is None: + continue + hook_config = getattr(func, '__myboot_worker_hook__', None) + if not hook_config: + continue + order = hook_config.get('order', 0) + if hook_config.get('event') == 'start': + start_hooks.append((order, func, hook_info['module'])) + else: + stop_hooks.append((order, func, hook_info['module'])) + + for order, func, module in sorted(start_hooks, key=lambda x: x[0]): + app.add_worker_start_hook(func) + logger.info(f"自动注册 worker 启动钩子: {module}.{func.__name__} (order={order})") + for order, func, module in sorted(stop_hooks, key=lambda x: x[0]): + app.add_worker_stop_hook(func) + logger.info(f"自动注册 worker 停止钩子: {module}.{func.__name__} (order={order})") + + +# 全局自动配置管理器实例 +_auto_configuration_manager = AutoConfigurationManager() + + +def auto_discover(package_name: str = "app") -> None: + """自动发现应用组件""" + _auto_configuration_manager.auto_discover(package_name) + + +def apply_auto_configuration(app) -> None: + """应用自动配置""" + _auto_configuration_manager.apply_auto_configuration(app) diff --git a/myboot/core/config.py b/myboot/core/config.py new file mode 100644 index 0000000..20751d6 --- /dev/null +++ b/myboot/core/config.py @@ -0,0 +1,226 @@ +""" +配置管理模块 + +使用 Dynaconf 提供强大的配置管理功能 +支持远程加载文件、配置文件优先级、环境变量覆盖等 +""" + +import os +import tempfile +from pathlib import Path +from typing import Any, Optional + +import requests +from dynaconf import Dynaconf + + +def _find_project_root() -> str: + """查找项目根目录""" + current_dir = Path(__file__).parent.absolute() + + while current_dir.parent != current_dir: + if (current_dir / 'pyproject.toml').exists(): + return str(current_dir) + current_dir = current_dir.parent + + return os.getcwd() + + +def _is_url(path: str) -> bool: + """检查是否为 URL""" + return path and path.startswith(('http://', 'https://')) + + +def _download_config(url: str, cache_dir: str) -> str: + """下载配置文件到缓存""" + import hashlib + + url_hash = hashlib.md5(url.encode()).hexdigest() + cache_path = os.path.join(cache_dir, f"{url_hash}.yaml") + + try: + print(f"正在下载配置文件: {url}") + response = requests.get(url, timeout=30) + response.raise_for_status() + + with open(cache_path, 'w', encoding='utf-8') as f: + f.write(response.text) + + print(f"配置文件已更新并缓存到: {cache_path}") + return cache_path + except Exception as e: + print(f"下载配置文件失败: {e}") + + if os.path.exists(cache_path): + print(f"网络连接失败,使用缓存的配置文件: {cache_path}") + return cache_path + else: + print(f"无可用缓存文件,下载失败: {e}") + raise + + +def _get_config_files(config_file: Optional[str] = None) -> list: + """获取配置文件列表(供 Dynaconf ``settings_files`` 使用)。 + + Dynaconf 按列表顺序依次加载,**后加载的文件会覆盖先加载的同名字段**。 + 因此列表顺序为「基底 → 越来越高优先级」,而不是把「最重要」的文件放在最前。 + + 合并结果上的优先级(后者覆盖前者): + 项目根目录 ``config.yaml`` < ``conf/config.yaml`` < 参数 ``config_file`` < 环境变量 ``CONFIG_FILE`` + """ + project_root = _find_project_root() + cache_dir = os.path.join(tempfile.gettempdir(), 'myboot_config_cache') + os.makedirs(cache_dir, exist_ok=True) + + config_files = [] + added_paths = set() # 用于去重 + + # 与 Dynaconf 加载顺序一致:靠前的先加载(作默认),靠后的覆盖同名键 + config_paths = [ + # 1. 项目根目录(最先加载) + os.path.join(project_root, 'config.yaml'), + os.path.join(project_root, 'config.yml'), + # 2. conf 目录 + os.path.join(project_root, 'conf', 'config.yaml'), + os.path.join(project_root, 'conf', 'config.yml'), + # 3. 调用方显式传入 + config_file, + # 4. CONFIG_FILE 环境变量(最后加载,覆盖上述来源中的同名字段) + os.getenv('CONFIG_FILE'), + ] + + for config_path in config_paths: + if not config_path: + continue + + # 处理 URL 配置 + if _is_url(config_path): + downloaded_path = _download_config(config_path, cache_dir) + if downloaded_path and downloaded_path not in added_paths: + config_files.append(downloaded_path) + added_paths.add(downloaded_path) + # 处理文件路径 + elif os.path.exists(config_path) and config_path not in added_paths: + config_files.append(config_path) + added_paths.add(config_path) + + return config_files + + +def create_settings(config_file: Optional[str] = None) -> Dynaconf: + """创建 Dynaconf 设置实例""" + config_files = _get_config_files(config_file) + + # 创建 Dynaconf 配置 + settings = Dynaconf( + # 配置文件列表 + settings_files=config_files, + + # 环境变量前缀(禁用前缀) + envvar_prefix=False, + + # 环境变量分隔符 + envvar_separator="__", + + # 是否自动转换环境变量类型 + env_parse_values=True, + + # 是否忽略空值 + ignore_unknown_envvars=True, + + # 是否合并环境变量 + merge_enabled=True, + + # 自动加载项目根目录 .env(不覆盖已存在的真实环境变量, + # 与容器部署习惯一致:真实 env > .env > 配置文件) + load_dotenv=True, + dotenv_path=os.path.join(_find_project_root(), ".env"), + dotenv_override=False, + + # 默认值:以大写 kwargs 传入(Dynaconf 将任意大写 kwarg 注册为默认配置项)。 + # 注意不能用 default_settings= —— Dynaconf 没有该参数, + # 它会被当成一个名为 DEFAULT_SETTINGS 的普通配置项,默认值整体失效 + APP={ + "name": "MyBoot App", + "version": "0.1.0" + }, + SERVER={ + "host": "0.0.0.0", + "port": 8000, + "reload": True, + "workers": 1, + "keep_alive_timeout": 5, + "graceful_timeout": 30, + "response_format": { + "enabled": True, + "exclude_paths": ["/docs"] + } + }, + LOGGING={ + "level": "INFO" + }, + METRICS={ + "enabled": False, + "path": "/metrics", + "multiproc_dir": None, + "http_metrics": True + }, + SCHEDULER={ + "enabled": True, + "timezone": "UTC", + "max_workers": 10 + } + ) + + return settings + + +# 全局配置实例 +_settings: Optional[Dynaconf] = None + + +def get_settings(config_file: Optional[str] = None) -> Dynaconf: + """获取 Dynaconf 设置实例""" + global _settings + + if _settings is None: + _settings = create_settings(config_file) + + return _settings + + +# 为了向后兼容,保留一些便捷函数 +def get_config(key: str, default: Any = None) -> Any: + """获取配置值的便捷函数""" + return get_settings().get(key, default) + + +def get_config_str(key: str, default: str = "") -> str: + """获取字符串配置值的便捷函数""" + value = get_config(key, default) + return str(value) if value is not None else default + + +def get_config_int(key: str, default: int = 0) -> int: + """获取整数配置值的便捷函数""" + value = get_config(key, default) + try: + return int(value) + except (ValueError, TypeError): + return default + + +def get_config_bool(key: str, default: bool = False) -> bool: + """获取布尔配置值的便捷函数""" + value = get_config(key, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in ('true', '1', 'yes', 'on') + return bool(value) + + +def reload_config() -> None: + """重新加载配置""" + global _settings + _settings = None \ No newline at end of file diff --git a/myboot/core/container.py b/myboot/core/container.py new file mode 100644 index 0000000..f0d9c3e --- /dev/null +++ b/myboot/core/container.py @@ -0,0 +1,198 @@ +""" +统一容器模块 + +提供统一的容器接口,支持从 container、components、services、clients 中获取实例 +""" + +from typing import Any, Dict, List, Type, TYPE_CHECKING + +if TYPE_CHECKING: + from .application import Application + + +class Container: + """统一容器类,支持从 container、components、services、clients 中获取实例""" + + def __init__(self, app: 'Application'): + """ + 初始化容器 + + Args: + app: 应用实例 + """ + self._app = app + # 容器自己的存储字典 + self._storage: Dict[str, Any] = {} + + def put(self, name: str, instance: Any) -> None: + """ + 将实例放入容器 + + Args: + name: 实例名称 + instance: 实例对象 + """ + self._storage[name] = instance + self._app.logger.debug(f"已放入容器: {name} ({type(instance).__name__})") + + def get(self, name: str, default: Any = None) -> Any: + """ + 从容器中获取实例(优先从 container,然后从 components、services,最后从 clients) + + Args: + name: 实例名称 + default: 如果不存在时返回的默认值 + + Returns: + 实例对象,如果不存在则返回 default + """ + # 优先从 container 中获取 + if name in self._storage: + return self._storage[name] + + # 然后从 components 中获取 + if name in self._app.components: + return self._app.components[name] + + # 然后从 services 中获取 + if name in self._app.services: + return self._app.services[name] + + # 最后从 clients 中获取 + if name in self._app.clients: + return self._app.clients[name] + + # 非单例(request/factory)的 service/client 不在上述字典中预存, + # 回退到 DI 容器按需解析 + di_container = getattr(self._app, 'di_container', None) + if di_container is not None and di_container.has_service(name): + return di_container.get_service(name) + + return default + + def get_or_raise(self, name: str) -> Any: + """ + 从容器中获取实例,如果不存在则抛出异常 + + Args: + name: 实例名称 + + Returns: + 实例对象 + + Raises: + KeyError: 如果实例不存在 + """ + instance = self.get(name) + if instance is None: + raise KeyError(f"容器中不存在实例: {name} (已检查 container、components、services、clients)") + return instance + + def has(self, name: str) -> bool: + """ + 检查容器中是否存在指定名称的实例(检查 container、components、services、clients) + + Args: + name: 实例名称 + + Returns: + 是否存在 + """ + return (name in self._storage or + name in self._app.components or + name in self._app.services or + name in self._app.clients) + + def remove(self, name: str) -> bool: + """ + 从容器中移除实例(按顺序从 container、components、services、clients 中移除) + + Args: + name: 实例名称 + + Returns: + 是否成功移除 + """ + removed = False + if name in self._storage: + del self._storage[name] + self._app.logger.debug(f"已从容器移除: {name}") + removed = True + if name in self._app.components: + del self._app.components[name] + self._app.logger.debug(f"已从组件移除: {name}") + removed = True + if name in self._app.services: + del self._app.services[name] + self._app.logger.debug(f"已从服务移除: {name}") + removed = True + if name in self._app.clients: + del self._app.clients[name] + self._app.logger.debug(f"已从客户端移除: {name}") + removed = True + return removed + + def get_by_type(self, instance_type: Type) -> List[Any]: + """ + 根据类型获取容器中所有匹配的实例(从 container、components、services、clients 中查找) + + Args: + instance_type: 实例类型 + + Returns: + 匹配的实例列表 + """ + instances = [] + # 从 container 中查找 + instances.extend([instance for instance in self._storage.values() + if isinstance(instance, instance_type)]) + # 从 components 中查找 + instances.extend([instance for instance in self._app.components.values() + if isinstance(instance, instance_type)]) + # 从 services 中查找 + instances.extend([instance for instance in self._app.services.values() + if isinstance(instance, instance_type)]) + # 从 clients 中查找 + instances.extend([instance for instance in self._app.clients.values() + if isinstance(instance, instance_type)]) + return instances + + def list_all(self) -> Dict[str, Any]: + """ + 列出容器中所有实例(包括 container、components、services、clients) + + Returns: + 所有实例的字典(名称 -> 实例) + """ + all_instances = {} + all_instances.update(self._storage) + all_instances.update(self._app.components) + all_instances.update(self._app.services) + all_instances.update(self._app.clients) + return all_instances + + def clear(self) -> None: + """清空容器(清空 container、components、services、clients)""" + self._storage.clear() + self._app.components.clear() + self._app.services.clear() + self._app.clients.clear() + self._app.logger.debug("容器已清空") + + def __getitem__(self, name: str) -> Any: + """支持使用 [] 语法获取实例""" + return self.get_or_raise(name) + + def __setitem__(self, name: str, instance: Any) -> None: + """支持使用 [] 语法设置实例""" + self.put(name, instance) + + def __contains__(self, name: str) -> bool: + """支持使用 in 语法检查实例是否存在""" + return self.has(name) + + def __delitem__(self, name: str) -> None: + """支持使用 del 语法删除实例""" + if not self.remove(name): + raise KeyError(f"容器中不存在实例: {name}") + diff --git a/myboot/core/decorators.py b/myboot/core/decorators.py new file mode 100644 index 0000000..c42d67d --- /dev/null +++ b/myboot/core/decorators.py @@ -0,0 +1,436 @@ +""" +装饰器模块 + +提供约定优于配置的装饰器,用于自动注册组件 +""" + +import re +from functools import wraps +from typing import Any, Dict, List, Optional, Union, Callable + + +from ..utils.naming import camel_to_snake as _camel_to_snake + + +def route(path: str, methods: List[str] = None, **kwargs): + """ + 路由装饰器 + + Args: + path: 路由路径 + methods: HTTP 方法列表 + **kwargs: 其他路由参数 + """ + if methods is None: + methods = ['GET'] + + def decorator(func): + func.__myboot_route__ = { + 'path': path, + 'methods': methods, + 'kwargs': kwargs + } + return func + return decorator + + +def get(path: str, **kwargs): + """GET 路由装饰器""" + return route(path, methods=['GET'], **kwargs) + + +def post(path: str, **kwargs): + """POST 路由装饰器""" + return route(path, methods=['POST'], **kwargs) + + +def put(path: str, **kwargs): + """PUT 路由装饰器""" + return route(path, methods=['PUT'], **kwargs) + + +def delete(path: str, **kwargs): + """DELETE 路由装饰器""" + return route(path, methods=['DELETE'], **kwargs) + + +def patch(path: str, **kwargs): + """PATCH 路由装饰器""" + return route(path, methods=['PATCH'], **kwargs) + + +def cron(cron_expression: str, enabled: Optional[bool] = None, all_workers: bool = False, **kwargs): + """ + Cron 任务装饰器 + + 支持在函数和类方法中使用: + - 函数:@cron("0 0 * * *") 或 @cron("0 0 * * * *") + - 类方法:在类的方法上使用 @cron("0 0 * * *") + + Args: + cron_expression: Cron 表达式 + - 标准5位格式:分 时 日 月 周(如 "0 0 * * *" 表示每小时) + - 6位格式:秒 分 时 日 月 周(如 "0 0 * * * *" 表示每小时,兼容旧格式) + enabled: 是否启用任务,如果为 None 则默认启用 + 可以通过手动获取配置来传递,例如: + from myboot.core.config import get_config + enabled = get_config('jobs.heartbeat.enabled', True) + all_workers: 多 worker 模式下是否在每个 worker 进程都注册执行 + 默认 False(仅 primary worker 执行) + **kwargs: 其他任务参数 + """ + def decorator(func): + func.__myboot_job__ = { + 'type': 'cron', + 'cron': cron_expression, + 'enabled': enabled, + 'all_workers': all_workers, + 'kwargs': kwargs + } + return func + return decorator + + +def interval(seconds: int = None, minutes: int = None, hours: int = None, enabled: Optional[bool] = None, all_workers: bool = False, **kwargs): + """ + 间隔任务装饰器 + + 支持在函数和类方法中使用: + - 函数:@interval(seconds=60) + - 类方法:在类的方法上使用 @interval(seconds=60) + + Args: + seconds: 秒数 + minutes: 分钟数 + hours: 小时数 + enabled: 是否启用任务,如果为 None 则默认启用 + 可以通过手动获取配置来传递,例如: + from myboot.core.config import get_config + enabled = get_config('jobs.heartbeat.enabled', True) + all_workers: 多 worker 模式下是否在每个 worker 进程都注册执行 + 默认 False(仅 primary worker 执行) + **kwargs: 其他任务参数 + """ + def decorator(func): + interval_value = seconds or (minutes * 60) or (hours * 3600) + func.__myboot_job__ = { + 'type': 'interval', + 'interval': interval_value, + 'enabled': enabled, + 'all_workers': all_workers, + 'kwargs': kwargs + } + return func + return decorator + + +def once(run_date: str = None, enabled: Optional[bool] = None, all_workers: bool = False, **kwargs): + """ + 一次性任务装饰器 + + 支持在函数和类方法中使用: + - 函数:@once('2025-12-31 23:59:59') + - 类方法:在类的方法上使用 @once('2025-12-31 23:59:59') + + Args: + run_date: 运行日期 + enabled: 是否启用任务,如果为 None 则默认启用 + 可以通过手动获取配置来传递,例如: + from myboot.core.config import get_config + enabled = get_config('jobs.heartbeat.enabled', True) + all_workers: 多 worker 模式下是否在每个 worker 进程都注册执行 + 默认 False(仅 primary worker 执行) + **kwargs: 其他任务参数 + """ + def decorator(func): + func.__myboot_job__ = { + 'type': 'once', + 'run_date': run_date, + 'enabled': enabled, + 'all_workers': all_workers, + 'kwargs': kwargs + } + return func + return decorator + + +# @service/@client 支持的生命周期范围 +# - singleton: 单例(默认),每个 worker 进程内一个实例 +# - request: 请求级,基于 contextvars(ContextLocalSingleton), +# 同一 HTTP 请求(asyncio 任务)内同一实例,跨请求不同实例 +# - factory: 每次解析都创建新实例 +_VALID_SCOPES = {'singleton', 'request', 'factory'} + + +def _validate_scope(scope: str, decorator_name: str) -> None: + """校验 scope 参数,装饰期即报错,避免拼写错误静默回退为单例""" + if scope not in _VALID_SCOPES: + raise ValueError( + f"@{decorator_name} 不支持的 scope: {scope!r}," + f"可选值: {sorted(_VALID_SCOPES)}" + ) + + +def service(name: str = None, scope: str = 'singleton', **kwargs): + """ + 服务装饰器 + + Args: + name: 服务名称 + scope: 生命周期范围(singleton/request/factory,默认 singleton) + - 'singleton': 每个 worker 进程内单例 + - 'request': 每个请求(asyncio 任务上下文)内单例 + - 'factory': 每次解析创建新实例 + **kwargs: 其他服务参数 + """ + _validate_scope(scope, 'service') + + def decorator(cls): + cls.__myboot_service__ = { + 'name': name or _camel_to_snake(cls.__name__), + 'scope': scope, + 'kwargs': kwargs + } + return cls + return decorator + + +def model(name: str = None, **kwargs): + """ + 模型装饰器 + + Args: + name: 模型名称 + **kwargs: 其他模型参数 + """ + def decorator(cls): + cls.__myboot_model__ = { + 'name': name or _camel_to_snake(cls.__name__), + 'kwargs': kwargs + } + return cls + return decorator + + +def client(name: str = None, scope: str = 'singleton', **kwargs): + """ + 客户端装饰器 + + Args: + name: 客户端名称 + scope: 生命周期范围(singleton/request/factory,默认 singleton) + - 'singleton': 每个 worker 进程内单例,注册到 app.clients + - 'request': 每个请求(asyncio 任务上下文)内单例, + 通过 DI 容器按需解析,不出现在 app.clients + - 'factory': 每次解析创建新实例,不出现在 app.clients + **kwargs: 其他客户端参数 + """ + _validate_scope(scope, 'client') + + def decorator(cls): + cls.__myboot_client__ = { + 'name': name or _camel_to_snake(cls.__name__), + 'scope': scope, + 'kwargs': kwargs + } + return cls + return decorator + + +def component( + name: str = None, + scope: str = 'singleton', + primary: bool = False, + lazy: bool = False, + **kwargs +): + """ + 组件装饰器 + + 用于将类注册为通用组件,支持依赖注入。 + 可用于任意需要托管的类(Job 实例、工具类、配置类等)。 + + Args: + name: 组件名称,默认使用类名的 snake_case 形式 + scope: 生命周期范围 + - 'singleton': 单例模式(默认),整个应用生命周期内只创建一个实例 + - 'prototype': 原型模式,每次获取时创建新实例 + primary: 当按类型获取有多个匹配时,是否为首选 + lazy: 是否懒加载,True 时在首次使用时才创建实例 + **kwargs: 其他组件参数 + + Examples: + # 基本用法 + @component() + class EmailHelper: + def send(self, to: str, content: str): + pass + + # 带依赖注入 + @component(name='redis_cache') + class RedisCache: + def __init__(self, redis_client: RedisClient): + self.redis = redis_client + + # 包含定时任务的组件 + @component() + class DataSyncJobs: + def __init__(self, data_service: DataService): + self.data_service = data_service + + @cron("0 2 * * *") + def sync_daily(self): + self.data_service.sync() + """ + def decorator(cls): + cls.__myboot_component__ = { + 'name': name or _camel_to_snake(cls.__name__), + 'scope': scope, + 'primary': primary, + 'lazy': lazy, + 'kwargs': kwargs + } + return cls + return decorator + + +def middleware( + name: str = None, + order: int = 0, + path_filter: Union[str, List[str]] = None, + methods: List[str] = None, + condition: Callable = None, + **kwargs +): + """ + 中间件装饰器 + + Args: + name: 中间件名称 + order: 执行顺序,数字越小越先执行(默认 0) + path_filter: 路径过滤,支持字符串、字符串列表或正则表达式模式 + 例如: '/api/*', ['/api/*', '/admin/*'] + methods: HTTP 方法过滤,如 ['GET', 'POST'](默认 None,处理所有方法) + condition: 条件函数,接收 request 对象,返回 bool 决定是否执行中间件 + **kwargs: 其他中间件参数 + + Examples: + @middleware(order=1, path_filter='/api/*') + def api_middleware(request, next_handler): + return next_handler(request) + + @middleware(order=2, methods=['POST', 'PUT']) + def post_middleware(request, next_handler): + return next_handler(request) + """ + def decorator(func): + func.__myboot_middleware__ = { + 'name': name or func.__name__, + 'order': order, + 'path_filter': path_filter, + 'methods': methods, + 'condition': condition, + 'kwargs': kwargs + } + return func + return decorator + + +def _worker_hook(event: str, order_or_func): + """on_worker_start/on_worker_stop 的公共实现 + + 同时支持两种用法: + @on_worker_start (裸装饰器) + @on_worker_start(order=1) (带参数) + """ + if callable(order_or_func): + # 裸装饰器用法:@on_worker_start + func = order_or_func + func.__myboot_worker_hook__ = {'event': event, 'order': 0} + return func + + order = order_or_func + + def decorator(func): + func.__myboot_worker_hook__ = {'event': event, 'order': order} + return func + return decorator + + +def on_worker_start(order: Union[int, Callable] = 0): + """ + Worker 启动钩子装饰器 + + 被装饰的模块级函数在每个 worker 进程的 lifespan 启动阶段触发一次 + (startup_hooks 之后、调度器启动之前)。单 worker 模式下也触发一次, + 语义一致。支持同步和异步函数。 + + 钩子函数不接收参数,可通过 myboot.core.application.app() 读取 + worker_id / is_primary_worker 等信息。 + + Args: + order: 执行顺序,数字越小越先执行(默认 0) + + Examples: + @on_worker_start + def init_pool(): + ... + + @on_worker_start(order=1) + async def warm_cache(): + ... + """ + return _worker_hook('start', order) + + +def on_worker_stop(order: Union[int, Callable] = 0): + """ + Worker 停止钩子装饰器 + + 被装饰的模块级函数在每个 worker 进程的 lifespan 关闭阶段触发一次 + (调度器停止之后、shutdown_hooks 之前)。支持同步和异步函数。 + + 注意:Windows 多 worker 模式下父进程通过 terminate()(硬终止)清理 + worker 进程,lifespan 关闭阶段可能不会执行,因此 stop 钩子在 + Windows 上不保证触发。 + + Args: + order: 执行顺序,数字越小越先执行(默认 0) + """ + return _worker_hook('stop', order) + + +def rest_controller(base_path: str, **kwargs): + """ + REST 控制器装饰器 + + 用于标记 REST 控制器类,为类中的方法提供基础路径。 + 类中的方法需要显式使用 @get、@post、@put、@delete、@patch 等装饰器才会生成路由。 + + 路径合并规则: + - 方法路径以 // 开头:作为绝对路径使用(去掉一个 /) + - 方法路径以 / 开头:去掉开头的 / 后追加到基础路径 + - 方法路径不以 / 开头:直接追加到基础路径 + + 示例: + @rest_controller('/api/reports') + class ReportController: + @post('/generate') # 最终路径: POST /api/reports/generate + def create_report(self, report_type: str): + return {"message": "报告生成任务已创建"} + + @get('/status/{job_id}') # 最终路径: GET /api/reports/status/{job_id} + def get_status(self, job_id: str): + return {"status": "completed"} + + Args: + base_path: 基础路径 + **kwargs: 其他路由参数 + """ + def decorator(cls): + cls.__myboot_rest_controller__ = { + 'base_path': base_path.rstrip('/'), + 'kwargs': kwargs + } + return cls + return decorator diff --git a/myboot/core/di/__init__.py b/myboot/core/di/__init__.py new file mode 100644 index 0000000..7b43c94 --- /dev/null +++ b/myboot/core/di/__init__.py @@ -0,0 +1,17 @@ +""" +依赖注入模块 + +提供基于 dependency_injector 的自动依赖注入功能 +""" + +from .container import DependencyContainer +from .registry import ServiceRegistry +from .decorators import inject, Provide + +__all__ = [ + 'DependencyContainer', + 'ServiceRegistry', + 'inject', + 'Provide', +] + diff --git a/myboot/core/di/container.py b/myboot/core/di/container.py new file mode 100644 index 0000000..db80a56 --- /dev/null +++ b/myboot/core/di/container.py @@ -0,0 +1,232 @@ +""" +依赖注入容器 + +管理 dependency_injector Container 和服务的生命周期 +""" + +from typing import Dict, Type, Any, Optional +from dependency_injector import containers, providers +from loguru import logger + +from .registry import ServiceRegistry +from .providers import ServiceProvider + + +class DependencyContainer: + """依赖注入容器管理器""" + + def __init__(self): + """初始化依赖注入容器""" + self.container = containers.DynamicContainer() + self.registry = ServiceRegistry() + self.service_providers: Dict[str, ServiceProvider] = {} + self.service_instances: Dict[str, Any] = {} + self._wired = False + + def register_service( + self, + service_class: Type, + service_name: str, + scope: str = ServiceProvider.SINGLETON, + config: dict = None + ) -> None: + """ + 注册服务到容器 + + Args: + service_class: 服务类 + service_name: 服务名称 + scope: 生命周期范围 (singleton/factory) + config: 服务配置 + """ + # 注册到注册表 + self.registry.register_service(service_class, service_name, config) + + # 创建服务提供者 + # 注意:装饰器元数据(config)中可能含顶层 'scope' 键, + # 与显式 scope 参数冲突,这里剔除(以显式参数为准) + config_kwargs = {k: v for k, v in (config or {}).items() if k != 'scope'} + provider = ServiceProvider(service_class, service_name, scope, **config_kwargs) + self.service_providers[service_name] = provider + + def register_instance(self, name: str, instance: Any) -> None: + """ + 注册已创建的实例到容器(用于 Client 等外部创建的实例) + + Args: + name: 实例名称 + instance: 实例对象 + """ + # 直接缓存实例 + self.service_instances[name] = instance + + # 通知注册表(避免依赖检查时产生警告) + self.registry.known_instances.add(name) + + # 创建一个返回已有实例的提供者 + di_provider = providers.Object(instance) + setattr(self.container, name, di_provider) + + # 创建占位的 ServiceProvider(用于 has_service 检查) + placeholder = ServiceProvider(type(instance), name, ServiceProvider.SINGLETON) + placeholder._provider = di_provider + self.service_providers[name] = placeholder + + def build_container(self) -> None: + """ + 构建依赖注入容器 + + 按照依赖顺序注册所有服务到 Container + """ + # 构建依赖图 + self.registry.build_dependency_graph() + + # 获取初始化顺序 + try: + init_order = self.registry.get_initialization_order() + except ValueError as e: + logger.error(f"无法构建依赖注入容器: {e}") + raise + + # 获取服务的参数信息,用于正确映射依赖 + service_params = {} + for service_name in self.service_providers.keys(): + service_class = self.registry.get_service_class(service_name) + if service_class and hasattr(service_class, '__init__'): + from .decorators import get_injectable_params + params = get_injectable_params(service_class.__init__) + service_params[service_name] = params + + # 按顺序注册服务 + for service_name in init_order: + if service_name not in self.service_providers: + continue + + provider = self.service_providers[service_name] + deps = self.registry.get_dependencies(service_name) + + # 构建依赖字典(使用参数名作为键) + dependencies = {} + params = service_params.get(service_name, {}) + + for param_name, param_info in params.items(): + dep_service_name = param_info.get('service_name') + if dep_service_name and dep_service_name in self.service_providers: + # 从容器中获取依赖的提供者 + dep_provider = self.service_providers[dep_service_name] + if not dep_provider.get_provider(): + # 如果依赖的提供者还未创建,先创建它 + dep_deps = self.registry.get_dependencies(dep_service_name) + dep_dependencies = {} + dep_params = service_params.get(dep_service_name, {}) + for dep_param_name, dep_param_info in dep_params.items(): + nested_dep_name = dep_param_info.get('service_name') + if nested_dep_name and nested_dep_name in self.service_providers: + nested_provider = self.service_providers[nested_dep_name] + if nested_provider.get_provider(): + dep_dependencies[dep_param_name] = nested_provider.get_provider() + dep_provider.create_provider(dep_dependencies if dep_dependencies else None) + + # 使用参数名作为键(dependency_injector 需要参数名匹配) + dependencies[param_name] = dep_provider.get_provider() + + # 创建提供者 + di_provider = provider.create_provider(dependencies if dependencies else None) + + # 注册到容器 + setattr(self.container, service_name, di_provider) + + logger.debug(f"已注册服务提供者: {service_name} (依赖: {deps})") + + def wire_modules(self, *modules) -> None: + """ + 连接模块以启用依赖注入 + + Args: + *modules: 要连接的模块列表 + """ + if not self._wired: + self.container.wire(modules=modules) + self._wired = True + logger.debug(f"已连接模块: {modules}") + + def unwire_modules(self) -> None: + """断开模块连接""" + if self._wired: + self.container.unwire() + self._wired = False + logger.debug("已断开模块连接") + + def get_service(self, service_name: str) -> Any: + """ + 获取服务实例 + + Args: + service_name: 服务名称 + + Returns: + 服务实例 + + Raises: + KeyError: 如果服务不存在 + """ + # 先检查是否有已缓存的实例(包括通过 register_instance 注册的) + if service_name in self.service_instances: + return self.service_instances[service_name] + + if service_name not in self.service_providers: + raise KeyError(f"服务 '{service_name}' 未注册") + + # 如果是单例,缓存实例 + provider = self.service_providers[service_name] + if provider.scope == ServiceProvider.SINGLETON: + di_provider = getattr(self.container, service_name, None) + if di_provider: + self.service_instances[service_name] = di_provider() + else: + raise RuntimeError(f"服务 '{service_name}' 的提供者未正确配置") + return self.service_instances[service_name] + else: + # 工厂模式,每次创建新实例 + di_provider = getattr(self.container, service_name, None) + if di_provider: + return di_provider() + else: + raise RuntimeError(f"服务 '{service_name}' 的提供者未正确配置") + + def has_service(self, service_name: str) -> bool: + """ + 检查服务是否已注册 + + Args: + service_name: 服务名称 + + Returns: + 是否存在 + """ + return service_name in self.service_providers or service_name in self.service_instances + + def get_all_services(self) -> Dict[str, Any]: + """ + 获取所有服务实例(仅单例) + + Returns: + 服务名称到实例的字典 + """ + result = {} + for service_name in self.service_providers.keys(): + try: + result[service_name] = self.get_service(service_name) + except Exception as e: + logger.warning(f"无法获取服务 '{service_name}': {e}") + return result + + def clear(self) -> None: + """清空容器""" + self.unwire_modules() + self.container = containers.DynamicContainer() + self.registry.clear() + self.service_providers.clear() + self.service_instances.clear() + self._wired = False + diff --git a/myboot/core/di/decorators.py b/myboot/core/di/decorators.py new file mode 100644 index 0000000..665a440 --- /dev/null +++ b/myboot/core/di/decorators.py @@ -0,0 +1,147 @@ +""" +依赖注入装饰器 + +提供 @inject 装饰器和 Provide 类型提示 +""" + +from typing import Any, TypeVar, Generic +from functools import wraps +import inspect + +T = TypeVar('T') + + +class Provide(Generic[T]): + """ + 依赖提供者类型提示 + + 用于在类型注解中显式指定需要注入的服务名称 + + Example: + @service() + class OrderService: + @inject + def __init__( + self, + user_service: Provide['user_service'], + email_service: Provide['email_service'] + ): + self.user_service = user_service + self.email_service = email_service + """ + def __class_getitem__(cls, item: str) -> str: + """支持 Provide['service_name'] 语法""" + return item + + +def inject(func): + """ + 依赖注入装饰器 + + 用于标记需要自动注入依赖的方法(通常是 __init__) + + Example: + @service() + class OrderService: + @inject + def __init__(self, user_service: UserService): + self.user_service = user_service + + Note: + 如果使用类型注解,通常不需要显式使用 @inject 装饰器 + 框架会自动检测并注入依赖 + """ + if not hasattr(func, '__myboot_inject__'): + func.__myboot_inject__ = True + + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +def get_injectable_params(func) -> dict: + """ + 获取可注入的参数信息 + + Args: + func: 函数或方法 + + Returns: + 参数字典,格式: {param_name: {type, service_name, is_optional, default}} + """ + signature = inspect.signature(func) + params = {} + + for param_name, param in signature.parameters.items(): + if param_name == 'self': + continue + + param_type = param.annotation + is_optional = False + service_name = None + + # 处理类型注解 + if param_type != inspect.Parameter.empty: + # 处理 Provide['service_name'] 类型(字符串形式) + if isinstance(param_type, str): + if param_type.startswith("Provide['") and param_type.endswith("']"): + service_name = param_type[9:-2] # 提取 'service_name' + param_type = None + # 处理类型对象 + elif hasattr(param_type, '__origin__'): + origin = param_type.__origin__ + args = getattr(param_type, '__args__', ()) + + # 处理 Optional[Type] 或 Union[Type, None] + if origin is type(None) or (hasattr(origin, '__name__') and origin.__name__ == 'Union'): + # 取第一个非 None 的类型 + for arg in args: + if arg is not type(None): + param_type = arg + is_optional = True + break + + # 处理 Provide[Type] 泛型 + if hasattr(origin, '__name__'): + origin_name = origin.__name__ + if 'Provide' in origin_name or str(origin).startswith('typing.Union') and any('Provide' in str(arg) for arg in args): + # 查找 Provide 类型参数 + for arg in args: + if isinstance(arg, str): + service_name = arg + param_type = None + break + elif hasattr(arg, '__origin__') and 'Provide' in str(arg): + # 处理嵌套的 Provide + nested_args = getattr(arg, '__args__', ()) + if nested_args and isinstance(nested_args[0], str): + service_name = nested_args[0] + param_type = None + break + + # 如果没有显式指定服务名,尝试从类型推断 + if service_name is None and param_type != inspect.Parameter.empty: + if hasattr(param_type, '__name__'): + # 将类名转换为服务名(驼峰转下划线) + from myboot.core.decorators import _camel_to_snake + service_name = _camel_to_snake(param_type.__name__) + elif isinstance(param_type, type): + # 处理直接的类型对象 + from myboot.core.decorators import _camel_to_snake + service_name = _camel_to_snake(param_type.__name__) + + # 检查默认值(表示可选) + if param.default != inspect.Parameter.empty: + is_optional = True + + params[param_name] = { + 'type': param_type, + 'service_name': service_name, + 'is_optional': is_optional, + 'default': param.default if param.default != inspect.Parameter.empty else None + } + + return params + diff --git a/myboot/core/di/providers.py b/myboot/core/di/providers.py new file mode 100644 index 0000000..69a031a --- /dev/null +++ b/myboot/core/di/providers.py @@ -0,0 +1,73 @@ +""" +服务提供者配置 + +定义服务的提供者配置和生命周期管理 +""" + +from typing import Any, Type, Optional +from dependency_injector import providers + + +class ServiceProvider: + """服务提供者配置""" + + SINGLETON = 'singleton' + REQUEST = 'request' + FACTORY = 'factory' + + def __init__( + self, + service_class: Type, + service_name: str, + scope: str = SINGLETON, + **kwargs + ): + """ + 初始化服务提供者 + + Args: + service_class: 服务类 + service_name: 服务名称 + scope: 生命周期范围 (singleton/request/factory) + **kwargs: 其他配置参数 + """ + self.service_class = service_class + self.service_name = service_name + self.scope = scope + self.kwargs = kwargs + self._provider: Optional[Any] = None + + def create_provider(self, dependencies: dict = None) -> Any: + """ + 创建 dependency_injector Provider + + scope 到 Provider 的映射: + - singleton -> providers.Singleton(默认) + - request -> providers.ContextLocalSingleton(基于 contextvars, + 每个 asyncio 任务/HTTP 请求内单例) + - 其他 -> providers.Factory(每次创建新实例) + + Args: + dependencies: 依赖的服务提供者字典 + + Returns: + dependency_injector Provider 实例 + """ + if self.scope == self.SINGLETON: + provider_class = providers.Singleton + elif self.scope == self.REQUEST: + provider_class = providers.ContextLocalSingleton + else: + provider_class = providers.Factory + + if dependencies: + self._provider = provider_class(self.service_class, **dependencies) + else: + self._provider = provider_class(self.service_class) + + return self._provider + + def get_provider(self) -> Any: + """获取提供者实例""" + return self._provider + diff --git a/myboot/core/di/registry.py b/myboot/core/di/registry.py new file mode 100644 index 0000000..b0e87c5 --- /dev/null +++ b/myboot/core/di/registry.py @@ -0,0 +1,290 @@ +""" +服务注册表 + +负责扫描服务、分析依赖关系、构建依赖图 +""" + +import inspect +from typing import Dict, List, Type, Set, Optional, Tuple +from collections import defaultdict, deque +from loguru import logger + +from .decorators import get_injectable_params + + +class ServiceRegistry: + """服务注册表""" + + def __init__(self): + """初始化服务注册表""" + self.services: Dict[str, Type] = {} # service_name -> service_class + self.service_configs: Dict[str, dict] = {} # service_name -> config + self.dependencies: Dict[str, Set[str]] = {} # service_name -> set of dependency names + self.dependents: Dict[str, Set[str]] = {} # service_name -> set of dependent names + self._dependency_graph: Optional[Dict[str, Set[str]]] = None + self.known_instances: Set[str] = set() # 已知的外部实例名称(如 Client) + + def register_service(self, service_class: Type, service_name: str, config: dict = None) -> None: + """ + 注册服务类 + + Args: + service_class: 服务类 + service_name: 服务名称 + config: 服务配置 + """ + self.services[service_name] = service_class + self.service_configs[service_name] = config or {} + self.dependencies[service_name] = set() + # 不覆盖已有 dependents:可能已有「先注册的依赖方」写入的条目,覆盖会导致拓扑序错误 + self.dependents.setdefault(service_name, set[str]()) + + # 分析依赖关系 + self._analyze_dependencies(service_name, service_class) + + # 注册时即检测循环依赖:只告警不报错(报错时机仍在容器构建时), + # 让问题在注册阶段就可见,便于定位 + for cycle in self._find_cycles_involving(service_name): + logger.warning( + f"检测到循环依赖: {' -> '.join(cycle)}。" + f"容器构建时将会失败,请重构代码以消除循环依赖。" + ) + + def _find_cycles_involving(self, service_name: str) -> List[List[str]]: + """查找包含指定服务的循环依赖链(基于当前已注册的依赖关系,不使用缓存图)""" + cycles: List[List[str]] = [] + path: List[str] = [] + rec_stack: Set[str] = set() + + def dfs(node: str) -> None: + if node in rec_stack: + cycle_start = path.index(node) + cycles.append(path[cycle_start:] + [node]) + return + rec_stack.add(node) + path.append(node) + for neighbor in self.dependencies.get(node, set()): + if neighbor in self.services and not cycles: + dfs(neighbor) + rec_stack.discard(node) + path.pop() + + dfs(service_name) + return cycles + + def _analyze_dependencies(self, service_name: str, service_class: Type) -> None: + """ + 分析服务的依赖关系 + + Args: + service_name: 服务名称 + service_class: 服务类 + """ + if not hasattr(service_class, '__init__'): + return + + init_method = service_class.__init__ + params = get_injectable_params(init_method) + + for param_name, param_info in params.items(): + dep_service_name = param_info['service_name'] + + if dep_service_name: + # 检查依赖的服务是否存在(可能还未注册) + # 先记录依赖关系,稍后在构建依赖图时验证 + self.dependencies[service_name].add(dep_service_name) + if dep_service_name not in self.dependents: + self.dependents[dep_service_name] = set() + self.dependents[dep_service_name].add(service_name) + + def build_dependency_graph(self) -> Dict[str, Set[str]]: + """ + 构建依赖关系图 + + Returns: + 依赖关系图字典 + """ + if self._dependency_graph is not None: + return self._dependency_graph + + self._dependency_graph = {} + + # 验证所有依赖的服务都已注册(排除已知的外部实例如 Client) + known_names = set(self.services.keys()) | self.known_instances + for service_name, deps in self.dependencies.items(): + missing_deps = deps - known_names + if missing_deps: + logger.warning( + f"服务 '{service_name}' 的依赖 '{missing_deps}' 未找到," + f"将在运行时检查" + ) + + # 构建依赖图 + for service_name in self.services.keys(): + self._dependency_graph[service_name] = self.dependencies.get(service_name, set()) + + return self._dependency_graph + + def detect_circular_dependencies(self) -> List[List[str]]: + """ + 检测循环依赖 + + Returns: + 循环依赖列表,每个元素是一个循环依赖链 + """ + graph = self.build_dependency_graph() + cycles = [] + visited = set() + rec_stack = set() + path = [] + + def dfs(node: str) -> None: + if node in rec_stack: + # 找到循环 + cycle_start = path.index(node) + cycle = path[cycle_start:] + [node] + cycles.append(cycle) + return + + if node in visited: + return + + visited.add(node) + rec_stack.add(node) + path.append(node) + + for neighbor in graph.get(node, set()): + if neighbor in self.services: # 只检查已注册的服务 + dfs(neighbor) + + rec_stack.remove(node) + path.pop() + + for service_name in self.services.keys(): + if service_name not in visited: + dfs(service_name) + + return cycles + + def get_initialization_order(self) -> List[str]: + """ + 获取服务初始化顺序(拓扑排序) + + Returns: + 服务名称列表,按初始化顺序排列 + + Raises: + ValueError: 如果存在循环依赖 + """ + cycles = self.detect_circular_dependencies() + if cycles: + cycle_str = ' -> '.join(cycles[0]) + raise ValueError( + f"检测到循环依赖: {cycle_str}。" + f"请重构代码以消除循环依赖。" + ) + + graph = self.build_dependency_graph() + in_degree = defaultdict(int) + + # 计算入度 + for service_name in self.services.keys(): + in_degree[service_name] = 0 + + for service_name, deps in graph.items(): + for dep in deps: + if dep in self.services: # 只考虑已注册的服务 + in_degree[service_name] += 1 + + # 拓扑排序 + queue = deque([name for name, degree in in_degree.items() if degree == 0]) + result = [] + + while queue: + service_name = queue.popleft() + result.append(service_name) + + # 更新依赖此服务的其他服务的入度 + for dependent in self.dependents.get(service_name, set()): + if dependent in in_degree: + in_degree[dependent] -= 1 + if in_degree[dependent] == 0: + queue.append(dependent) + + # 检查是否所有服务都已处理 + if len(result) != len(self.services): + remaining = set(self.services.keys()) - set(result) + logger.warning(f"以下服务无法确定初始化顺序: {remaining}") + # 将剩余的服务添加到末尾 + result.extend(remaining) + + return result + + def get_dependencies(self, service_name: str) -> Set[str]: + """ + 获取服务的依赖列表 + + Args: + service_name: 服务名称 + + Returns: + 依赖的服务名称集合 + """ + return self.dependencies.get(service_name, set()) + + def get_dependents(self, service_name: str) -> Set[str]: + """ + 获取依赖此服务的服务列表 + + Args: + service_name: 服务名称 + + Returns: + 依赖此服务的服务名称集合 + """ + return self.dependents.get(service_name, set()) + + def get_service_class(self, service_name: str) -> Optional[Type]: + """ + 获取服务类 + + Args: + service_name: 服务名称 + + Returns: + 服务类,如果不存在则返回 None + """ + return self.services.get(service_name) + + def get_service_config(self, service_name: str) -> dict: + """ + 获取服务配置 + + Args: + service_name: 服务名称 + + Returns: + 服务配置字典 + """ + return self.service_configs.get(service_name, {}) + + def has_service(self, service_name: str) -> bool: + """ + 检查服务是否已注册 + + Args: + service_name: 服务名称 + + Returns: + 是否存在 + """ + return service_name in self.services + + def clear(self) -> None: + """清空注册表""" + self.services.clear() + self.service_configs.clear() + self.dependencies.clear() + self.dependents.clear() + self._dependency_graph = None + diff --git a/myboot/core/logger.py b/myboot/core/logger.py new file mode 100644 index 0000000..f685474 --- /dev/null +++ b/myboot/core/logger.py @@ -0,0 +1,416 @@ +""" +日志管理模块 + +基于 loguru 的日志管理,提供初始化配置功能 +所有代码可以直接使用: from loguru import logger +""" + +import logging +import os +import re +import sys +from typing import Optional, Union + +from loguru import logger as loguru_logger +from dynaconf import Dynaconf + +from .config import get_settings + +logger = loguru_logger + + +def _get_worker_info() -> str: + """ + 获取当前 worker 信息 + + Returns: + worker 标识字符串,格式为 "Worker-1/4" 或 "Main" (主进程) + """ + worker_id = os.environ.get("MYBOOT_WORKER_ID") + worker_count = os.environ.get("MYBOOT_WORKER_COUNT") + + if worker_id and worker_count: + return f"Worker-{worker_id}/{worker_count}" + return "Main" # Main process + + +def _get_log_format_with_worker() -> str: + """ + 获取带 worker 标识的日志格式 + + Returns: + 日志格式字符串 + """ + return ( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{extra[worker]} | " + "{level: <8} | " + "{name}:{function}:{line} - " + "{message}" + ) + + +def _get_log_format_simple() -> str: + """ + 获取简单日志格式(无 worker 标识) + + Returns: + 日志格式字符串 + """ + return ( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <8} | " + "{name}:{function}:{line} - " + "{message}" + ) + + +def _parse_json_config(value) -> bool: + """解析 JSON 配置值为布尔值""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in ("true", "1", "yes", "on") + return bool(value) + + +def _convert_logging_format_to_loguru(user_format: str) -> str: + """ + 将标准 logging 格式转换为 loguru 格式 + + Args: + user_format: 用户提供的日志格式字符串 + + Returns: + 转换后的 loguru 格式字符串 + """ + format_mapping = { + "%(asctime)s": "{time:YYYY-MM-DD HH:mm:ss}", + "%(name)s": "{name}", + "%(levelname)s": "{level: <8}", + "%(message)s": "{message}", + "%(filename)s": "{file.name}", + "%(funcName)s": "{function}", + "%(lineno)d": "{line}", + } + + result = user_format + for old, new in format_mapping.items(): + result = result.replace(old, new) + return result + + +def _build_json_handler_kwargs(log_level: str) -> tuple[dict, dict]: + """ + 构建 JSON 格式的 handler 参数 + + Returns: + (console_kwargs, file_kwargs) 元组 + """ + console_kwargs = { + "sink": sys.stdout, + "serialize": True, + "level": log_level, + "backtrace": True, + "diagnose": True, + } + file_kwargs = { + "serialize": True, + "level": log_level, + "rotation": "10 MB", + "retention": "7 days", + "compression": "zip", + "backtrace": True, + "diagnose": True, + } + return console_kwargs, file_kwargs + + +def _build_text_handler_kwargs(log_format: str, log_level: str) -> tuple[dict, dict]: + """ + 构建文本格式的 handler 参数 + + Returns: + (console_kwargs, file_kwargs) 元组 + """ + console_kwargs = { + "sink": sys.stdout, + "format": log_format, + "level": log_level, + "colorize": True, + "backtrace": True, + "diagnose": True, + } + file_kwargs = { + "format": log_format, + "level": log_level, + "rotation": "10 MB", + "retention": "7 days", + "compression": "zip", + "backtrace": True, + "diagnose": True, + } + return console_kwargs, file_kwargs + + +def _get_third_party_config(config_obj) -> dict: + """获取第三方库日志配置""" + try: + return config_obj.logging.third_party + except (AttributeError, KeyError): + pass + + try: + return config_obj.get("logging.third_party", {}) + except (AttributeError, KeyError): + return {} + + +def _configure_third_party_loggers(third_party_config: dict) -> None: + """配置第三方库的日志级别""" + if not isinstance(third_party_config, dict): + return + + for logger_name, level_name in third_party_config.items(): + if isinstance(level_name, str): + std_logger = logging.getLogger(logger_name) + level = getattr(logging, level_name.upper(), logging.INFO) + std_logger.setLevel(level) + + +def _add_file_handler(log_file: str, file_kwargs: dict) -> None: + """添加文件日志 handler""" + log_dir = os.path.dirname(log_file) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + loguru_logger.add(log_file, **file_kwargs) + + +def setup_logging(config: Optional[Union[str, Dynaconf]] = None, enable_worker_info: bool = True) -> None: + """ + 根据配置文件或配置对象初始化 loguru 日志系统 + + Args: + config: 配置文件路径或配置对象(Dynaconf),如果为 None 则使用默认配置 + enable_worker_info: 是否启用 worker 信息显示(多 worker 模式下自动启用) + """ + # 获取配置对象 + config_obj = config if isinstance(config, Dynaconf) else get_settings(config) + + # 移除默认的 handler 并配置 worker 上下文 + loguru_logger.remove() + loguru_logger.configure(extra={"worker": _get_worker_info()}) + + # 获取日志级别 + log_level = config_obj.get("logging.level", "INFO").upper() + + # 构建 handler 参数 + use_json = _parse_json_config(config_obj.get("logging.json", False)) + if use_json: + console_kwargs, file_kwargs = _build_json_handler_kwargs(log_level) + else: + # 确定日志格式 + user_format = config_obj.get("logging.format", None) + if user_format: + log_format = _convert_logging_format_to_loguru(user_format) + else: + worker_count = os.environ.get("MYBOOT_WORKER_COUNT") + is_multi_worker = worker_count and int(worker_count) > 1 + log_format = _get_log_format_with_worker() if (is_multi_worker and enable_worker_info) else _get_log_format_simple() + + console_kwargs, file_kwargs = _build_text_handler_kwargs(log_format, log_level) + + # 添加 handlers + loguru_logger.add(**console_kwargs) + + log_file = config_obj.get("logging.file") + if log_file: + _add_file_handler(log_file, file_kwargs) + + # 配置第三方库日志 + _configure_third_party_loggers(_get_third_party_config(config_obj)) + + +def configure_worker_logger(worker_id: int, total_workers: int) -> None: + """ + 配置 worker 进程的日志上下文 + + 在多 worker 模式下,每个 worker 进程启动时调用此函数, + 以便在日志中显示 worker 标识 + + Args: + worker_id: Worker 进程 ID (从 1 开始) + total_workers: 总 worker 数量 + """ + worker_info = f"Worker-{worker_id}/{total_workers}" + loguru_logger.configure(extra={"worker": worker_info}) + + +def setup_worker_logging(worker_id: int, total_workers: int, config: Optional[Union[str, Dynaconf]] = None) -> None: + """ + 为 worker 进程重新初始化日志系统 + + 在多 worker 模式下,每个 worker 进程启动后调用此函数, + 重新配置日志格式以显示 worker 标识 + + Args: + worker_id: Worker 进程 ID (从 1 开始) + total_workers: 总 worker 数量 + config: 配置文件路径或配置对象(Dynaconf),如果为 None 则使用默认配置 + """ + # 获取配置对象 + config_obj = config if isinstance(config, Dynaconf) else get_settings(config) + + # 获取日志级别 + log_level = config_obj.get("logging.level", "INFO").upper() + + # 移除现有的 handler + loguru_logger.remove() + + # 设置 worker 上下文 + worker_info = f"Worker-{worker_id}/{total_workers}" + loguru_logger.configure(extra={"worker": worker_info}) + + # 使用带 worker 标识的日志格式 + log_format = _get_log_format_with_worker() + + # 添加控制台输出 handler + loguru_logger.add( + sys.stdout, + format=log_format, + level=log_level, + colorize=True, + backtrace=True, + diagnose=True, + ) + + +# 为了向后兼容,提供 get_logger 函数 +# 但实际上直接使用 loguru 的 logger 即可 +def get_logger(name: str = "app"): + """ + 获取日志器实例(向后兼容) + + 注意:建议直接使用 `from loguru import logger` + + Args: + name: 日志器名称(loguru 中用于标识,可通过 bind 方法绑定) + + Returns: + loguru Logger 实例 + """ + return loguru_logger.bind(name=name) + + +# issue #12: % 风格占位符检测(%s/%d/%r/%f/%x 等;%% 为转义,单独匹配以便排除) +_PERCENT_PLACEHOLDER_PATTERN = re.compile( + r"%(?:%|[-+ #0]*(?:\d+|\*)?(?:\.(?:\d+|\*))?[hlL]?[diouxXeEfFgGcrsa])" +) + +# issue #12: {} 风格占位符检测(完整的 {...} 对;{{ }} 转义在检测前剔除) +_BRACE_PLACEHOLDER_PATTERN = re.compile(r"\{[^{}]*\}") + + +# 为了向后兼容,提供 Logger 类 +class Logger: + """ + 日志器类(向后兼容) + + 注意:建议直接使用 `from loguru import logger` + + issue #12: 同时支持 logging 风格的 % 占位符("hello %s")和 + loguru 原生的 {} 占位符("hello {}")。日志调用绝不向业务代码 + 抛出格式化异常,格式化失败时降级输出消息本体。 + """ + + def __init__(self, name: str = "app"): + """ + 初始化日志器 + + Args: + name: 日志器名称 + """ + self.name = name + self._logger = loguru_logger.bind(name=name) + + @staticmethod + def _contains_percent_placeholder(message: str) -> bool: + """消息是否含真正的 % 占位符(%% 转义不算)""" + return any( + match.group() != "%%" + for match in _PERCENT_PLACEHOLDER_PATTERN.finditer(message) + ) + + @staticmethod + def _contains_brace_placeholder(message: str) -> bool: + """消息是否含 {} 风格占位符({{ }} 转义不算)""" + unescaped = message.replace("{{", "").replace("}}", "") + return _BRACE_PLACEHOLDER_PATTERN.search(unescaped) is not None + + def _format_message(self, message, args): + """ + issue #12: 统一占位符预处理 + + 规则: + 1. 含 % 占位符且有位置参数且不含 {} 占位符 → 先做 % 预格式化, + args 置空后交给 loguru + 2. 含 {} 占位符 → 原样交给 loguru({} 优先于 %,保持 loguru 原生语义) + 3. 预格式化异常(参数不匹配等)→ 回退为原样传递,不抛错 + + Returns: + (message, args) 元组,供 loguru 调用使用 + """ + if not args or not isinstance(message, str): + return message, args + if self._contains_brace_placeholder(message): + # {} 占位符优先,交给 loguru 内部的 str.format + return message, args + if self._contains_percent_placeholder(message): + try: + return message % args, () + except Exception: + # 参数不匹配等预格式化失败:回退为原样传递 + return message, args + return message, args + + def _safe_log(self, log_method, message, args, kwargs) -> None: + """ + issue #12: 安全调用 loguru 日志方法 + + loguru 内部的 str.format 路径若抛错(如消息含孤立 {), + 捕获后降级为无参输出消息本体,绝不向业务代码抛格式化异常。 + kwargs(命名 {name} 占位符 / extra)保持透传。 + """ + formatted, remaining_args = self._format_message(message, args) + try: + log_method(formatted, *remaining_args, **kwargs) + except Exception: + try: + log_method("{}", formatted) + except Exception: + # 日志降级输出也失败时静默放弃,保证业务调用不受影响 + pass + + def debug(self, message: str, *args, **kwargs) -> None: + """记录调试日志""" + self._safe_log(self._logger.debug, message, args, kwargs) + + def info(self, message: str, *args, **kwargs) -> None: + """记录信息日志""" + self._safe_log(self._logger.info, message, args, kwargs) + + def warning(self, message: str, *args, **kwargs) -> None: + """记录警告日志""" + self._safe_log(self._logger.warning, message, args, kwargs) + + def error(self, message: str, *args, **kwargs) -> None: + """记录错误日志""" + self._safe_log(self._logger.error, message, args, kwargs) + + def critical(self, message: str, *args, **kwargs) -> None: + """记录严重错误日志""" + self._safe_log(self._logger.critical, message, args, kwargs) + + def exception(self, message: str, *args, **kwargs) -> None: + """记录异常日志""" + self._safe_log(self._logger.exception, message, args, kwargs) diff --git a/myboot/core/scheduler.py b/myboot/core/scheduler.py new file mode 100644 index 0000000..45c05e9 --- /dev/null +++ b/myboot/core/scheduler.py @@ -0,0 +1,627 @@ +""" +任务调度器模块 + +提供定时任务调度功能,基于 APScheduler 实现 +""" + +import uuid +from datetime import datetime +from typing import Optional, Callable, Any, TYPE_CHECKING, Union + +from loguru import logger +from dynaconf import Dynaconf +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +from apscheduler.triggers.date import DateTrigger +from apscheduler.executors.pool import ThreadPoolExecutor +from apscheduler.jobstores.memory import MemoryJobStore + +from .config import get_config, get_settings +from ..exceptions import SchedulerError + +if TYPE_CHECKING: + from ..jobs.scheduled_job import ScheduledJob + + +class Scheduler: + """应用任务调度器(基于 APScheduler)""" + + def __init__(self, config: Optional[Union[str, Dynaconf]] = None, enabled: Optional[bool] = None): + """ + 初始化调度器 + + Args: + config: 配置文件路径或配置对象(Dynaconf),如果为 None 则使用默认配置 + enabled: 是否启用调度器,如果为 None 则从配置文件读取 + 多 workers 模式下,默认只在 primary worker 启用 + """ + self._logger = logger.bind(name="scheduler") + self._scheduled_jobs = {} # 存储 ScheduledJob 对象 + + # 获取配置对象 + if isinstance(config, Dynaconf): + # 如果传入的是配置对象,直接使用 + self._config = config + else: + # 如果传入的是配置文件路径或 None,则获取配置对象 + self._config = get_settings(config) + + # 从配置对象读取调度器配置,enabled 参数可覆盖配置 + config_enabled = self._config.get('scheduler.enabled', True) + self._enabled = enabled if enabled is not None else config_enabled + timezone_str = self._config.get('scheduler.timezone', 'UTC') + self._timezone = self._parse_timezone(timezone_str) + max_workers = self._config.get('scheduler.max_workers', 10) + + # 配置 APScheduler + jobstores = { + 'default': MemoryJobStore() + } + executors = { + 'default': ThreadPoolExecutor(max_workers=max_workers) + } + job_defaults = { + 'coalesce': False, # 不合并错过的任务 + 'max_instances': 3, # 最大并发实例数 + 'misfire_grace_time': 30 # 错过执行的宽限时间(秒) + } + + # 创建 APScheduler 实例 + self._scheduler = BackgroundScheduler( + jobstores=jobstores, + executors=executors, + job_defaults=job_defaults, + timezone=self._timezone + ) + + self._logger.debug(f"调度器初始化完成 - enabled: {self._enabled}, timezone: {timezone_str}, max_workers: {max_workers}") + + def _parse_timezone(self, timezone_str: str): + """ + 解析时区字符串 + + APScheduler 会自动使用此时区处理所有任务,无需手动转换 + """ + try: + import pytz + return pytz.timezone(timezone_str) + except ImportError: + self._logger.warning("未安装 pytz,使用系统时区") + return None + except Exception as e: + self._logger.warning(f"解析时区失败: {e},使用系统时区") + return None + + def _resolve_job_id(self, job_id: Optional[str], prefix: str, func: Callable) -> str: + """解析并校验任务 ID + + 默认 ID 为 ``{prefix}_{模块名}.{限定名}``(限定名含类名), + 不同类中的同名方法不会冲突(issue #14)。 + + 注册前显式查重——APScheduler 未启动时不校验 pending 队列中的 + ID 冲突,问题会延迟到 ``start()`` 落库时才爆发,这里提前拦截: + - 显式传入的 ID 已存在:抛 ``SchedulerError`` + - 自动生成的 ID 已存在(同一函数注册多次):追加短 uid 消歧并告警 + """ + existing_ids = set(self.list_jobs()) + + if job_id is not None: + if job_id in existing_ids: + raise SchedulerError( + f"任务 ID 已存在: {job_id},请使用唯一的 job_id", + scheduler="scheduler", + ) + return job_id + + module = getattr(func, '__module__', 'unknown') + qualname = getattr(func, '__qualname__', getattr(func, '__name__', str(func))) + auto_id = f"{prefix}_{module}.{qualname}" + if auto_id in existing_ids: + suffix = uuid.uuid4().hex[:8] + self._logger.warning( + f"自动生成的任务 ID 冲突: {auto_id}(同一函数注册多次)," + f"追加后缀消歧: {auto_id}_{suffix}" + ) + auto_id = f"{auto_id}_{suffix}" + return auto_id + + def add_cron_job( + self, + func: Callable, + cron: str, + job_id: Optional[str] = None, + **kwargs + ) -> str: + """ + 添加 Cron 任务 + + Args: + func: 要执行的函数 + cron: Cron 表达式(标准5位格式:分 时 日 月 周,或使用 CronTrigger 支持的格式) + job_id: 任务ID,如果为 None 则自动生成 + **kwargs: 其他任务参数 + + Returns: + str: 任务ID + """ + job_id = self._resolve_job_id(job_id, "cron", func) + + try: + # 解析 cron 表达式 + trigger = self._parse_cron(cron) + + # 添加到 APScheduler + self._scheduler.add_job( + func=func, + trigger=trigger, + id=job_id, + **kwargs + ) + + self._logger.info(f"已添加 Cron 任务: {job_id} - {cron}") + return job_id + + except Exception as e: + self._logger.error(f"添加 Cron 任务失败: {e}", exc_info=True) + raise + + def _parse_cron(self, cron_expr: str) -> CronTrigger: + """ + 解析 Cron 表达式 + + Args: + cron_expr: Cron 表达式字符串 + + Returns: + CronTrigger 对象(使用调度器配置的时区) + """ + # 尝试使用 CronTrigger.from_crontab 解析标准格式 + try: + return CronTrigger.from_crontab(cron_expr, timezone=self._timezone) + except (ValueError, TypeError): + # 如果不是标准格式,尝试手动解析 + parts = cron_expr.split() + if len(parts) == 5: + # 标准5位格式:分 时 日 月 周 + minute, hour, day, month, day_of_week = parts + return CronTrigger( + minute=minute, + hour=hour, + day=day, + month=month, + day_of_week=day_of_week, + timezone=self._timezone + ) + elif len(parts) == 6: + # 6位格式:秒 分 时 日 月 周(兼容旧格式) + second, minute, hour, day, month, day_of_week = parts + return CronTrigger( + second=second, + minute=minute, + hour=hour, + day=day, + month=month, + day_of_week=day_of_week, + timezone=self._timezone + ) + else: + raise ValueError(f"无效的 Cron 表达式格式: {cron_expr},应为5位或6位") + + def add_interval_job( + self, + func: Callable, + interval: int, + job_id: Optional[str] = None, + **kwargs + ) -> str: + """ + 添加间隔任务 + + Args: + func: 要执行的函数 + interval: 间隔时间(秒) + job_id: 任务ID,如果为 None 则自动生成 + **kwargs: 其他任务参数 + + Returns: + str: 任务ID + """ + job_id = self._resolve_job_id(job_id, "interval", func) + + try: + # 创建间隔触发器(使用调度器配置的时区) + trigger = IntervalTrigger(seconds=interval, timezone=self._timezone) + + # 添加到 APScheduler + self._scheduler.add_job( + func=func, + trigger=trigger, + id=job_id, + **kwargs + ) + + self._logger.info(f"已添加间隔任务: {job_id} - {interval}秒") + return job_id + + except Exception as e: + self._logger.error(f"添加间隔任务失败: {e}", exc_info=True) + raise + + def add_date_job( + self, + func: Callable, + run_date: str, + job_id: Optional[str] = None, + **kwargs + ) -> str: + """ + 添加一次性任务 + + Args: + func: 要执行的函数 + run_date: 运行日期时间字符串,格式: 'YYYY-MM-DD HH:MM:SS' 或 'YYYY-MM-DD HH:MM' + job_id: 任务ID,如果为 None 则自动生成 + **kwargs: 其他任务参数 + + Returns: + str: 任务ID + """ + job_id = self._resolve_job_id(job_id, "date", func) + + try: + # 解析日期时间(返回 naive datetime) + run_datetime = self._parse_run_date(run_date) + + # 创建日期触发器(使用调度器配置的时区) + trigger = DateTrigger(run_date=run_datetime, timezone=self._timezone) + + # 添加到 APScheduler + self._scheduler.add_job( + func=func, + trigger=trigger, + id=job_id, + **kwargs + ) + + self._logger.info(f"已添加一次性任务: {job_id} - {run_date}") + return job_id + + except Exception as e: + self._logger.error(f"添加一次性任务失败: {e}", exc_info=True) + raise + + def _parse_run_date(self, run_date: str) -> datetime: + """ + 解析运行日期时间字符串 + + Args: + run_date: 日期时间字符串 + + Returns: + naive datetime 对象(APScheduler 会自动应用调度器的全局时区) + """ + # 尝试解析不同的日期格式 + formats = [ + '%Y-%m-%d %H:%M:%S', + '%Y-%m-%d %H:%M', + '%Y-%m-%d', + ] + + for fmt in formats: + try: + # 直接返回 naive datetime,APScheduler 会自动应用全局时区 + return datetime.strptime(run_date, fmt) + except ValueError: + continue + + raise ValueError(f"无效的日期格式: {run_date},支持的格式: 'YYYY-MM-DD HH:MM:SS', 'YYYY-MM-DD HH:MM', 'YYYY-MM-DD'") + + def remove_job(self, job_id: str) -> bool: + """ + 移除任务 + + Args: + job_id: 任务ID + + Returns: + bool: 是否成功移除 + """ + try: + if self._scheduler.get_job(job_id): + self._scheduler.remove_job(job_id) + self._logger.info(f"已移除任务: {job_id}") + return True + return False + except Exception as e: + self._logger.error(f"移除任务失败: {e}", exc_info=True) + return False + + def get_job(self, job_id: str) -> Optional[Any]: + """ + 获取任务 + + Args: + job_id: 任务ID + + Returns: + APScheduler Job 对象,如果不存在则返回 None + """ + return self._scheduler.get_job(job_id) + + def list_jobs(self) -> list: + """ + 列出所有任务ID + + Returns: + list: 任务ID列表 + """ + return [job.id for job in self._scheduler.get_jobs()] + + def start(self) -> None: + """启动调度器""" + # 检查是否启用 + if not self._enabled: + self._logger.info("调度器已禁用,不启动") + return + + if not self._scheduler.running: + try: + self._scheduler.start() + self._logger.info(f"任务调度器已启动 (时区: {self._timezone}, 任务数: {len(self._scheduler.get_jobs())})") + except Exception as e: + self._logger.error(f"启动调度器失败: {e}", exc_info=True) + raise + + def stop(self) -> None: + """停止调度器""" + if self._scheduler.running: + try: + self._scheduler.shutdown(wait=True) + self._logger.info("任务调度器已停止") + except Exception as e: + self._logger.error(f"停止调度器失败: {e}", exc_info=True) + + def is_running(self) -> bool: + """检查调度器是否运行中""" + return self._scheduler.running + + def has_jobs(self) -> bool: + """检查是否有任务""" + return len(self._scheduler.get_jobs()) > 0 + + def get_job_info(self, job_id: str) -> Optional[dict]: + """ + 获取任务信息 + + Args: + job_id: 任务ID + + Returns: + dict: 任务信息字典 + """ + job = self._scheduler.get_job(job_id) + if not job: + return None + + # 未启动调度器时 pending Job 对象没有 next_run_time 属性,用 getattr 防御 + next_run_time = getattr(job, 'next_run_time', None) + info = { + 'job_id': job_id, + 'func_name': job.func.__name__ if hasattr(job.func, '__name__') else str(job.func), + 'next_run_time': next_run_time.isoformat() if next_run_time else None, + } + + # 根据触发器类型添加特定信息 + trigger = job.trigger + if isinstance(trigger, CronTrigger): + info['type'] = 'cron' + info['cron'] = str(trigger) + elif isinstance(trigger, IntervalTrigger): + info['type'] = 'interval' + info['interval'] = trigger.interval.total_seconds() + elif isinstance(trigger, DateTrigger): + info['type'] = 'date' + info['run_date'] = trigger.run_date.isoformat() if trigger.run_date else None + + return info + + def list_all_jobs(self) -> list: + """ + 列出所有任务的详细信息 + + Returns: + list: 任务信息列表 + """ + return [self.get_job_info(job.id) for job in self._scheduler.get_jobs()] + + def enable(self) -> None: + """启用调度器 + + 任务级 all_workers 支持:非 primary worker 默认调度器禁用, + 当注册门控放行了 all_workers=True 的任务时调用本方法启用调度器, + 使其能在 lifespan 中真正启动。 + """ + if not self._enabled: + self._enabled = True + self._logger.debug("调度器已启用(all_workers 任务触发)") + + def is_enabled(self) -> bool: + """检查调度器是否启用""" + return self._enabled + + def get_config(self) -> dict: + """获取调度器配置""" + return { + 'enabled': self._enabled, + 'timezone': str(self._timezone) if self._timezone else 'system', + 'running': self._scheduler.running, + 'job_count': len(self._scheduler.get_jobs()), + 'scheduled_job_count': len(self._scheduled_jobs) + } + + def add_scheduled_job( + self, + job: 'ScheduledJob', + job_id: Optional[str] = None + ) -> str: + """ + 添加 ScheduledJob 对象到调度器 + + Args: + job: ScheduledJob 对象 + job_id: 任务ID,如果为 None 则自动生成 + + Returns: + str: 任务ID + """ + from ..jobs.scheduled_job import ScheduledJob as ScheduledJobClass + + if not isinstance(job, ScheduledJobClass): + raise TypeError(f"job 必须是 ScheduledJob 的实例,当前类型: {type(job)}") + + if job.trigger is None: + raise ValueError("ScheduledJob 必须设置 trigger 属性") + + # 生成任务ID + if job_id is None: + job_id = f"scheduled_{job.name}_{id(job)}" + + # 保存 ScheduledJob 对象 + self._scheduled_jobs[job_id] = job + job.job_id = job_id + + # 转换触发器格式并添加到调度器 + trigger = job.trigger + + if isinstance(trigger, str): + # 字符串视为 cron 表达式 + self.add_cron_job( + func=job.execute, + cron=trigger, + job_id=job_id, + name=job.name + ) + elif isinstance(trigger, dict): + trigger_type = trigger.get('type') + if trigger_type == 'cron': + self.add_cron_job( + func=job.execute, + cron=trigger['cron'], + job_id=job_id, + name=job.name + ) + elif trigger_type == 'interval': + interval = trigger.get('seconds', 0) or 0 + interval += (trigger.get('minutes', 0) or 0) * 60 + interval += (trigger.get('hours', 0) or 0) * 3600 + interval += (trigger.get('days', 0) or 0) * 86400 + if interval <= 0: + raise ValueError("间隔时间必须大于 0") + self.add_interval_job( + func=job.execute, + interval=interval, + job_id=job_id, + name=job.name + ) + elif trigger_type == 'date': + self.add_date_job( + func=job.execute, + run_date=trigger['run_date'], + job_id=job_id, + name=job.name + ) + else: + raise ValueError(f"不支持的触发器类型: {trigger_type}") + else: + raise ValueError(f"不支持的触发器类型: {type(trigger)}") + + self._logger.info(f"已添加 ScheduledJob: {job.name} (ID: {job_id})") + return job_id + + def get_scheduled_job(self, job_id: str) -> Optional['ScheduledJob']: + """ + 获取 ScheduledJob 对象 + + Args: + job_id: 任务ID + + Returns: + ScheduledJob 对象,如果不存在则返回 None + """ + return self._scheduled_jobs.get(job_id) + + def get_all_scheduled_jobs(self) -> list: + """ + 获取所有 ScheduledJob 对象 + + Returns: + ScheduledJob 对象列表 + """ + return list(self._scheduled_jobs.values()) + + def add_job_object( + self, + job: 'ScheduledJob', + job_id: Optional[str] = None + ) -> str: + """ + 添加 ScheduledJob 对象到调度器(不设置触发器,用于非定时任务) + + 此方法用于添加不需要定时执行的任务,仅用于状态跟踪和管理。 + 如果需要定时执行,请使用 add_scheduled_job() 并设置 trigger。 + + Args: + job: ScheduledJob 对象 + job_id: 任务ID,如果为 None 则自动生成 + + Returns: + str: 任务ID + """ + from ..jobs.scheduled_job import ScheduledJob as ScheduledJobClass + + if not isinstance(job, ScheduledJobClass): + raise TypeError(f"job 必须是 ScheduledJob 的实例,当前类型: {type(job)}") + + # 生成任务ID + if job_id is None: + job_id = f"job_{job.name}_{id(job)}" + + # 保存 ScheduledJob 对象 + self._scheduled_jobs[job_id] = job + job.job_id = job_id + + self._logger.info(f"已添加任务对象: {job.name} (ID: {job_id})") + return job_id + + def remove_scheduled_job(self, job_id: str) -> bool: + """ + 移除 ScheduledJob + + Args: + job_id: 任务ID + + Returns: + 是否成功移除 + """ + if job_id in self._scheduled_jobs: + # 同时从调度器中移除(如果存在) + self.remove_job(job_id) + del self._scheduled_jobs[job_id] + self._logger.info(f"已移除 ScheduledJob: {job_id}") + return True + return False + + +# 全局调度器实例 +_scheduler: Optional[Scheduler] = None + + +def get_scheduler() -> Scheduler: + """获取调度器实例""" + global _scheduler + + if _scheduler is None: + _scheduler = Scheduler() + + return _scheduler diff --git a/myboot/core/server.py b/myboot/core/server.py new file mode 100644 index 0000000..c775c17 --- /dev/null +++ b/myboot/core/server.py @@ -0,0 +1,451 @@ +""" +服务器管理器 + +默认使用 Hypercorn 作为 ASGI 服务器,支持多 workers 模式 +""" + +import asyncio +import multiprocessing +import os +import signal +import socket +import sys +from typing import Any, Dict, List, Optional + +from loguru import logger + +from ..utils import get_local_ip + +# 别名映射:调用方传入的名称 → Hypercorn Config 属性名 +# application.py 白名单已处理大部分别名,此处仅保留通用兜底映射 +# 参考:https://hypercorn.readthedocs.io/en/latest/how_to_guides/configuring.html +HYPERCORN_CONFIG_ALIASES: Dict[str, str] = { + "reload": "use_reloader", + "max_incomplete_request_size": "h11_max_incomplete_size", +} + +# 序列化 Config 到子进程时跳过:只读或由其他字段推导,子进程 asyncio.serve() 不需/不应重设 +_HYPERCORN_SERIALIZE_SKIP = frozenset({ + "ssl_enabled", # Hypercorn:只读,由 certfile/keyfile 推导 + "workers", # asyncio.serve() 不用此字段;多进程由外层 fork +}) + +# myboot 内部消耗、不传给 Hypercorn 的键 +_MYBOOT_INTERNAL = {"host", "port", "app_path"} + + +def _create_socket_with_reuseport(host: str, port: int, backlog: int = 100) -> socket.socket: + """ + 创建启用 SO_REUSEPORT 的 socket(仅 Linux/macOS) + + Args: + host: 绑定的主机地址 + port: 绑定的端口号 + + Returns: + 已绑定并监听的 socket 对象 + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # Linux/macOS 支持 SO_REUSEPORT + if hasattr(socket, 'SO_REUSEPORT'): + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + + sock.setblocking(False) + sock.bind((host, port)) + sock.listen(backlog) + return sock + + +def _resolve_app_from_path(app_path: str): + """ + 从模块路径解析 ASGI 应用 + + 支持的格式: + - "module.path:app" -> 获取 module.path 模块的 app 属性 + - "module.path:app.get_fastapi_app()" -> 获取 app 对象后调用 get_fastapi_app() + - "module.path:create_app()" -> 调用 module.path 模块的 create_app() 函数 + + Args: + app_path: 应用模块路径 + + Returns: + ASGI 应用实例 + """ + import importlib + import re + + # 解析模块路径和应用名 + if ":" in app_path: + module_path, app_expr = app_path.rsplit(":", 1) + else: + module_path, app_expr = app_path, "app" + + # 导入模块 + # 如果请求的是 main 模块且 __main__ 已存在,直接使用 __main__ + if module_path == "main" and "__main__" in sys.modules: + module = sys.modules["__main__"] + else: + module = importlib.import_module(module_path) + + # 解析表达式,支持链式调用如 "app.get_fastapi_app()" + # 使用简单的解析器处理 attr 和 method() 调用 + parts = re.split(r'\.(?![^(]*\))', app_expr) # 按 . 分割,但不分割括号内的点 + + result = module + for part in parts: + part = part.strip() + if not part: + continue + + # 检查是否是方法调用 (以 () 结尾) + if part.endswith("()"): + method_name = part[:-2] + result = getattr(result, method_name)() + else: + result = getattr(result, part) + + return result + + +def _worker_serve( + app_path: str, + config_dict: dict, + worker_id: int, + total_workers: int, + socket_fd: Optional[int] = None +): + """ + Worker 进程入口函数 + + Args: + app_path: 应用模块路径,格式为 "module.path:app_name" + config_dict: Hypercorn 配置字典 + worker_id: Worker 进程 ID (从 1 开始) + total_workers: 总 worker 数量 + socket_fd: 预创建的 socket 文件描述符(Linux/macOS 多 worker 模式) + """ + # 设置环境变量,供 Application 读取 + os.environ["MYBOOT_WORKER_ID"] = str(worker_id) + os.environ["MYBOOT_WORKER_COUNT"] = str(total_workers) + os.environ["MYBOOT_IS_PRIMARY_WORKER"] = "1" if worker_id == 1 else "0" + + # 重新初始化 worker 日志(设置环境变量后才能正确检测多 worker 模式) + from .logger import setup_worker_logging + setup_worker_logging(worker_id, total_workers) + + try: + import hypercorn.asyncio + from hypercorn.config import Config + except ImportError: + raise ImportError("Hypercorn 未安装,请运行: pip install hypercorn") + + # 从模块路径加载 app + # 注意:必须先解析——导入用户模块才会构造 Application 并设置 _current_app + app = _resolve_app_from_path(app_path) + + # myboot 应用检测:若用户模块构造了 Application,则在 worker 进程内完成引导 + # (重读 worker 环境变量、重建调度器、实例化 client/service/controller)。 + # spawn 与 fork 两种模式在 bootstrap_worker 中收敛,修复: + # - fork 模式所有 worker 共享父进程预创建实例(issue #11) + # - spawn 模式(Windows)worker 无用户路由 + # - 所有 worker 都自认 primary 导致调度器多跑 + # 普通 ASGI 应用(非 myboot)路径不受影响。 + import myboot.core.application as _myboot_application + if _myboot_application._current_app is not None: + app = _myboot_application._current_app.bootstrap_worker() + + # 重建配置 + config = Config() + for key, value in config_dict.items(): + if hasattr(config, key): + setattr(config, key, value) + + # 如果提供了 socket_fd,使用 fd:// 绑定方式 + if socket_fd is not None: + config.bind = [f"fd://{socket_fd}"] + + logger.info(f"Worker-{worker_id}/{total_workers} 启动中... (primary={worker_id == 1})") + + # 运行服务器 + asyncio.run(hypercorn.asyncio.serve(app, config)) + + +class HypercornServer: + """Hypercorn 服务器,支持多 workers 模式""" + + def __init__(self, app, host: str = "0.0.0.0", port: int = 8000, **kwargs): + self.app = app + self.host = host + self.port = port + self.kwargs = kwargs + self._running = False + self._workers: List[multiprocessing.Process] = [] + self._import_hypercorn() + + def _import_hypercorn(self): + """动态导入 Hypercorn""" + try: + import hypercorn.asyncio + from hypercorn.config import Config + self.hypercorn = hypercorn + self._config_class = Config + except ImportError: + raise ImportError("Hypercorn 未安装,请运行: pip install hypercorn") + + def _build_config(self) -> Any: + """构建 Hypercorn Config:将 kwargs 通过别名映射写入 Config 属性。""" + config = self._config_class() + config.bind = [f"{self.host}:{self.port}"] + for key, value in self.kwargs.items(): + if key in _MYBOOT_INTERNAL: + continue + hypercorn_key = HYPERCORN_CONFIG_ALIASES.get(key, key) + if hasattr(config, hypercorn_key): + setattr(config, hypercorn_key, value) + return config + + def _config_to_dict(self, config) -> dict: + """将 Hypercorn Config 序列化为字典,供多 worker 子进程重建使用。""" + out = {} + for attr in dir(config): + if attr.startswith("_"): + continue + if attr in _HYPERCORN_SERIALIZE_SKIP: + continue + val = getattr(config, attr, None) + if callable(val): + continue + if isinstance(val, (str, int, float, bool, list, dict, type(None))): + out[attr] = val + return out + + def start(self, app_path: Optional[str] = None) -> None: + """ + 启动 Hypercorn 服务器 + + Args: + app_path: 应用模块路径(多 workers 模式必需),格式为 "module.path:app_name" + 例如: "myapp.main:app" 或 "myapp.main:create_app()" + """ + try: + config = self._build_config() + workers = self.kwargs.get('workers', 1) + + self._running = True + + if workers > 1: + # 多 workers 模式 + if not app_path: + logger.warning( + "多 workers 模式需要提供 app_path 参数(如 'myapp.main:app')," + "当前回退到单进程模式" + ) + asyncio.run(self.hypercorn.asyncio.serve(self.app, config)) + else: + self._start_multiple_workers(app_path, config, workers) + else: + # 单 worker 模式:直接运行 + asyncio.run(self.hypercorn.asyncio.serve(self.app, config)) + + except Exception as e: + logger.error(f"Hypercorn 服务器启动失败: {e}") + raise + finally: + self._running = False + self._cleanup_workers() + + def _start_multiple_workers(self, app_path: str, config, workers: int) -> None: + """ + 启动多个 worker 进程 + + Args: + app_path: 应用模块路径 + config: Hypercorn 配置 + workers: worker 数量 + """ + # Windows 需要使用 spawn + if sys.platform == 'win32': + multiprocessing.set_start_method('spawn', force=True) + + config_dict = self._config_to_dict(config) + socket_fd: Optional[int] = None + shared_socket: Optional[socket.socket] = None + + # Linux/macOS: 使用 SO_REUSEPORT 预创建 socket,允许多个进程绑定同一端口 + backlog = config_dict.get("backlog", 100) + if sys.platform != 'win32': + try: + shared_socket = _create_socket_with_reuseport(self.host, self.port, backlog=backlog) + socket_fd = shared_socket.fileno() + logger.info(f"已创建共享 socket (fd={socket_fd}), 启用 SO_REUSEPORT") + except Exception as e: + logger.warning(f"创建共享 socket 失败: {e},回退到普通模式") + shared_socket = None + socket_fd = None + + logger.info(f"启动 {workers} 个 worker 进程...") + + # 创建并启动 worker 进程 + for i in range(workers): + process = multiprocessing.Process( + target=_worker_serve, + args=(app_path, config_dict, i + 1, workers, socket_fd), + name=f"hypercorn-worker-{i + 1}" + ) + process.start() + self._workers.append(process) + logger.info(f"Worker-{i + 1}/{workers} 已启动 (PID: {process.pid})") + + # 主进程等待所有 worker + try: + for process in self._workers: + process.join() + except KeyboardInterrupt: + logger.info("收到中断信号,正在关闭所有 workers...") + self._cleanup_workers() + finally: + # 关闭主进程中的共享 socket + if shared_socket: + try: + shared_socket.close() + except Exception: + pass + + def _cleanup_workers(self) -> None: + """清理所有 worker 进程""" + for process in self._workers: + if process.is_alive(): + logger.info(f"终止 Worker (PID: {process.pid})...") + process.terminate() + process.join(timeout=5) + if process.is_alive(): + process.kill() + self._workers.clear() + + def stop(self) -> None: + """停止 Hypercorn 服务器""" + self._running = False + + @property + def is_running(self) -> bool: + """检查服务器是否运行中""" + return self._running + + def get_url(self) -> str: + """获取服务器 URL""" + # 使用真实 IP 地址显示(如果 host 是 0.0.0.0) + display_host = get_local_ip() if self.host == "0.0.0.0" else self.host + return f"http://{display_host}:{self.port}" + + +class ServerManager: + """简化的服务器管理器""" + + def __init__(self): + self._current_server: Optional[HypercornServer] = None + + def start_server( + self, + app, + host: str = "0.0.0.0", + port: int = 8000, + app_path: Optional[str] = None, + **kwargs + ) -> None: + """ + 启动 Hypercorn 服务器 + + Args: + app: ASGI 应用 + host: 主机地址 + port: 端口号 + app_path: 应用模块路径(多 workers 模式必需),格式为 "module.path:app_name" + 例如: "myapp.main:app" + **kwargs: 其他配置参数,包括: + - workers: worker 进程数量,默认 1 + - reload: 是否启用热重载 + - keep_alive_timeout: keep-alive 超时时间 + - graceful_timeout: 优雅关闭超时时间 + """ + if self._current_server and self._current_server.is_running: + logger.warning("服务器已在运行中,请先停止当前服务器") + return + + self._current_server = HypercornServer(app, host, port, **kwargs) + + # 注册信号处理器 + self._register_signal_handlers() + + try: + self._current_server.start(app_path=app_path) + except KeyboardInterrupt: + logger.info("收到中断信号,正在关闭服务器...") + finally: + self.stop_server() + + def stop_server(self) -> None: + """停止当前服务器""" + if self._current_server: + self._current_server.stop() + self._current_server = None + + def _register_signal_handlers(self) -> None: + """注册信号处理器""" + def signal_handler(signum, frame): + logger.info(f"收到信号 {signum},正在关闭服务器...") + self.stop_server() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + def get_server_info(self) -> Dict[str, Any]: + """获取服务器信息""" + if not self._current_server: + return {"status": "not_running"} + + return { + "status": "running" if self._current_server.is_running else "stopped", + "url": self._current_server.get_url(), + "host": self._current_server.host, + "port": self._current_server.port, + } + + +# 全局服务器管理器实例 +server_manager = ServerManager() + + +def start_server( + app, + host: str = "0.0.0.0", + port: int = 8000, + app_path: Optional[str] = None, + **kwargs +) -> None: + """ + 启动服务器的便捷函数 + + Args: + app: ASGI 应用 + host: 主机地址 + port: 端口号 + app_path: 应用模块路径(多 workers 模式必需),格式为 "module.path:app_name" + **kwargs: 其他配置参数 + + Example: + # 单 worker 模式 + start_server(app, host="0.0.0.0", port=8000) + + # 多 workers 模式(4个进程) + start_server( + app, + host="0.0.0.0", + port=8000, + app_path="myapp.main:app", + workers=4 + ) + """ + server_manager.start_server(app, host, port, app_path=app_path, **kwargs) \ No newline at end of file diff --git a/myboot/exceptions.py b/myboot/exceptions.py new file mode 100644 index 0000000..dd3e7dc --- /dev/null +++ b/myboot/exceptions.py @@ -0,0 +1,154 @@ +""" +MyBoot 异常模块 + +提供框架相关的异常类 +""" + +from typing import Any, Dict, Optional + + +class MyBootException(Exception): + """MyBoot 框架异常基类""" + + def __init__( + self, + message: str = "MyBoot 框架错误", + code: str = "MYBOOT_ERROR", + details: Optional[Dict[str, Any]] = None + ): + self.message = message + self.code = code + self.details = details or {} + super().__init__(self.message) + + +class ConfigurationError(MyBootException): + """配置错误异常""" + + def __init__( + self, + message: str = "配置错误", + config_key: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + self.config_key = config_key + super().__init__(message, "CONFIGURATION_ERROR", details) + + +class ValidationError(MyBootException): + """验证错误异常""" + + def __init__( + self, + message: str = "验证失败", + field: Optional[str] = None, + value: Any = None, + details: Optional[Dict[str, Any]] = None + ): + self.field = field + self.value = value + super().__init__(message, "VALIDATION_ERROR", details) + + +class InitializationError(MyBootException): + """初始化错误异常""" + + def __init__( + self, + message: str = "初始化失败", + component: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + self.component = component + super().__init__(message, "INITIALIZATION_ERROR", details) + + +class DependencyError(MyBootException): + """依赖错误异常""" + + def __init__( + self, + message: str = "依赖错误", + dependency: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + self.dependency = dependency + super().__init__(message, "DEPENDENCY_ERROR", details) + + +class ServiceError(MyBootException): + """服务错误异常""" + + def __init__( + self, + message: str = "服务错误", + service: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + self.service = service + super().__init__(message, "SERVICE_ERROR", details) + + +class JobError(MyBootException): + """任务错误异常""" + + def __init__( + self, + message: str = "任务错误", + job_name: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + self.job_name = job_name + super().__init__(message, "JOB_ERROR", details) + + +class SchedulerError(MyBootException): + """调度器错误异常""" + + def __init__( + self, + message: str = "调度器错误", + scheduler: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + self.scheduler = scheduler + super().__init__(message, "SCHEDULER_ERROR", details) + + +class MiddlewareError(MyBootException): + """中间件错误异常""" + + def __init__( + self, + message: str = "中间件错误", + middleware: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + self.middleware = middleware + super().__init__(message, "MIDDLEWARE_ERROR", details) + + +class RouteError(MyBootException): + """路由错误异常""" + + def __init__( + self, + message: str = "路由错误", + route: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + self.route = route + super().__init__(message, "ROUTE_ERROR", details) + + +class LifecycleError(MyBootException): + """生命周期错误异常""" + + def __init__( + self, + message: str = "生命周期错误", + phase: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + self.phase = phase + super().__init__(message, "LIFECYCLE_ERROR", details) diff --git a/myboot/jobs/__init__.py b/myboot/jobs/__init__.py new file mode 100644 index 0000000..437f400 --- /dev/null +++ b/myboot/jobs/__init__.py @@ -0,0 +1,11 @@ +""" +定时任务模块 + +包含定时任务和后台任务 +""" + +from .scheduled_job import ScheduledJob + +__all__ = [ + 'ScheduledJob', +] \ No newline at end of file diff --git a/myboot/jobs/scheduled_job.py b/myboot/jobs/scheduled_job.py new file mode 100644 index 0000000..ca6e472 --- /dev/null +++ b/myboot/jobs/scheduled_job.py @@ -0,0 +1,203 @@ +""" +定时任务基类模块 + +提供 ScheduledJob 基类,用户可以继承此类创建自定义的定时任务 +""" + +import asyncio +import time +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any, Dict, Optional, Union + +from loguru import logger + + +class ScheduledJob(ABC): + """ + 定时任务基类 + + 用户可以继承此类创建自定义的定时任务 + """ + + def __init__( + self, + name: Optional[str] = None, + description: Optional[str] = None, + trigger: Union[str, Dict[str, Any]] = None, + max_retries: int = 3, + retry_delay: float = 1.0, + timeout: Optional[float] = None + ): + """ + 初始化定时任务 + + Args: + name: 任务名称 + description: 任务描述 + trigger: 触发器,可以是: + - 字符串:cron 表达式(如 "0 0 * * * *") + - 字典:包含 type 和配置的字典 + - {'type': 'cron', 'cron': '0 0 * * * *'} + - {'type': 'interval', 'seconds': 60} + - {'type': 'date', 'run_date': '2024-01-01 12:00:00'} + max_retries: 最大重试次数 + retry_delay: 重试延迟(秒) + timeout: 超时时间(秒) + """ + self.name = name or self.__class__.__name__ + self.description = description or self.__doc__ or "" + self.trigger = trigger + self.max_retries = max_retries + self.retry_delay = retry_delay + self.timeout = timeout + + # 任务状态 + self.status = "pending" # pending, running, completed, failed, cancelled + self.created_at = datetime.now() + self.started_at: Optional[datetime] = None + self.completed_at: Optional[datetime] = None + self.last_run: Optional[datetime] = None + self.run_count = 0 + self.failure_count = 0 + self.last_error: Optional[Exception] = None + self.job_id: Optional[str] = None + + # 日志 + self.logger = logger.bind(name=f"scheduled_job.{self.name}") + + @abstractmethod + def run(self, *args, **kwargs) -> Any: + """ + 执行任务的具体逻辑(子类必须实现) + + Args: + *args: 位置参数 + **kwargs: 关键字参数 + + Returns: + Any: 任务执行结果 + """ + pass + + def execute(self, *args, **kwargs) -> Any: + """ + 执行任务的主入口(包含状态管理和重试逻辑) + + Args: + *args: 位置参数 + **kwargs: 关键字参数 + + Returns: + Any: 任务执行结果 + """ + self.status = "running" + self.started_at = datetime.now() + self.run_count += 1 + + self.logger.info(f"开始执行任务: {self.name}") + + try: + # 检查超时 + if self.timeout: + result = self._execute_with_timeout(*args, **kwargs) + else: + result = self._execute_task(*args, **kwargs) + + self.status = "completed" + self.completed_at = datetime.now() + self.last_run = self.started_at + + duration = (self.completed_at - self.started_at).total_seconds() + self.logger.info(f"任务执行完成: {self.name}, 耗时: {duration:.2f}秒") + + return result + + except Exception as e: + self.status = "failed" + self.failure_count += 1 + self.last_error = e + self.completed_at = datetime.now() + self.last_run = self.started_at + + self.logger.error(f"任务执行失败: {self.name}, 错误: {e}", exc_info=True) + + # 重试逻辑 + if self.failure_count <= self.max_retries: + self.logger.info(f"任务将在 {self.retry_delay} 秒后重试: {self.name}") + time.sleep(self.retry_delay) + return self.execute(*args, **kwargs) + else: + self.logger.error(f"任务重试次数已达上限: {self.name}") + raise + + def _execute_with_timeout(self, *args, **kwargs) -> Any: + """带超时的任务执行""" + if asyncio.iscoroutinefunction(self.run): + # 异步任务 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete( + asyncio.wait_for(self.run(*args, **kwargs), timeout=self.timeout) + ) + finally: + loop.close() + else: + # 同步任务 - 使用 ThreadPoolExecutor 实现跨平台超时 + from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError + + def run_task(): + return self.run(*args, **kwargs) + + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(run_task) + try: + return future.result(timeout=self.timeout) + except FutureTimeoutError: + future.cancel() + raise TimeoutError(f"任务执行超时: {self.name}") + + def _execute_task(self, *args, **kwargs) -> Any: + """执行任务""" + if asyncio.iscoroutinefunction(self.run): + # 异步任务 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(self.run(*args, **kwargs)) + finally: + loop.close() + else: + # 同步任务 + return self.run(*args, **kwargs) + + def get_info(self) -> Dict[str, Any]: + """获取任务信息""" + return { + "name": self.name, + "description": self.description, + "status": self.status, + "trigger": str(self.trigger) if self.trigger else None, + "created_at": self.created_at.isoformat(), + "started_at": self.started_at.isoformat() if self.started_at else None, + "completed_at": self.completed_at.isoformat() if self.completed_at else None, + "last_run": self.last_run.isoformat() if self.last_run else None, + "run_count": self.run_count, + "failure_count": self.failure_count, + "last_error": str(self.last_error) if self.last_error else None, + "max_retries": self.max_retries, + "retry_delay": self.retry_delay, + "timeout": self.timeout, + "job_id": self.job_id + } + + def reset(self) -> None: + """重置任务状态""" + self.status = "pending" + self.started_at = None + self.completed_at = None + self.failure_count = 0 + self.last_error = None + self.logger.info(f"任务状态已重置: {self.name}") + diff --git a/myboot/metrics.py b/myboot/metrics.py new file mode 100644 index 0000000..3887d48 --- /dev/null +++ b/myboot/metrics.py @@ -0,0 +1,344 @@ +""" +内置 Prometheus 指标模块(F5) + +特性: +- 可选依赖:未安装 prometheus-client 时全部 API 退化为 no-op,不报错 +- 多 worker 模式(Linux/macOS)自动配置 PROMETHEUS_MULTIPROC_DIR 实现指标聚合 +- 懒初始化:本模块 import 时绝不 import prometheus_client, + 保证 setup_multiproc_env 的环境变量先于 prometheus_client 初始化生效 +- 内置 HTTP 指标中间件与 /metrics 端点(由 Application 在 metrics.enabled 时接入) + +配置(conf/config.yaml):: + + metrics: + enabled: true # 默认 false + path: /metrics # 指标暴露路径 + http_metrics: true # 是否启用 HTTP 请求指标中间件 + multiproc_dir: null # multiproc 目录(默认系统临时目录下自动生成) + +安装: pip install myboot[metrics] +""" + +import importlib.util +import os +import re +import sys +import tempfile +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Dict, Iterator, Optional, Sequence, Tuple + +from loguru import logger + +from .exceptions import MyBootException + +# 默认耗时直方图分桶(秒) +DEFAULT_DURATION_BUCKETS = ( + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, +) + +STAGE_HISTOGRAM_NAME = "myboot_stage_duration_seconds" + + +class MetricsNotAvailableError(MyBootException): + """prometheus-client 未安装""" + + def __init__(self, message: Optional[str] = None): + super().__init__( + message + or "prometheus-client 未安装,指标功能不可用。" + "安装方式: pip install myboot[metrics]", + "METRICS_NOT_AVAILABLE", + ) + + +def is_available() -> bool: + """prometheus-client 是否已安装(不触发实际 import)""" + return importlib.util.find_spec("prometheus_client") is not None + + +def _coerce_bool(value: Any, default: bool = False) -> bool: + """布尔/字符串宽容转换(与 get_config_bool 语义一致)""" + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ("true", "1", "yes", "on") + return bool(value) + + +def is_enabled(config) -> bool: + """读取 metrics.enabled 配置(默认 False)""" + return _coerce_bool(config.get("metrics.enabled", False)) + + +def _slugify(name: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(name)).strip("_").lower() + return slug or "app" + + +def setup_multiproc_env(config, app_name: str) -> None: + """配置 Prometheus 多进程聚合环境变量 + + 须在 prometheus_client 被 import 之前调用(Application.__init__ 早期)。 + 仅当满足以下全部条件时生效,否则 no-op: + + - metrics.enabled 为真 + - prometheus-client 已安装 + - server.workers > 1 + - 非 Windows(win32 多 worker 为 spawn 模式,multiproc 文件聚合不受支持) + + 本函数自身绝不 import prometheus_client。 + """ + if not is_enabled(config): + return + if not is_available(): + return + + try: + workers = int(config.get("server.workers", 1) or 1) + except (TypeError, ValueError): + workers = 1 + if workers <= 1: + return + if sys.platform == "win32": + logger.warning( + "Windows 多 worker 模式不支持 Prometheus multiproc 聚合," + "各 worker 将仅暴露本进程指标" + ) + return + + # 用户已自行设置 → 尊重,不覆盖、不清理 + if os.environ.get("PROMETHEUS_MULTIPROC_DIR"): + Path(os.environ["PROMETHEUS_MULTIPROC_DIR"]).mkdir(parents=True, exist_ok=True) + return + + if "prometheus_client" in sys.modules: + logger.warning( + "prometheus_client 已在 PROMETHEUS_MULTIPROC_DIR 设置之前被 import," + "多进程指标聚合可能失效(请避免在应用创建前 import prometheus_client)" + ) + + multiproc_dir = config.get("metrics.multiproc_dir", None) + if multiproc_dir: + target = Path(str(multiproc_dir)) + else: + target = Path(tempfile.gettempdir()) / f"myboot_prometheus_{_slugify(app_name)}" + target.mkdir(parents=True, exist_ok=True) + + # 仅父进程(MYBOOT_WORKER_ID 尚未设置时)清理陈旧 db 文件 + if "MYBOOT_WORKER_ID" not in os.environ: + for stale in target.glob("*.db"): + try: + stale.unlink() + except OSError: + pass + + os.environ["PROMETHEUS_MULTIPROC_DIR"] = str(target) + logger.debug(f"Prometheus multiproc 目录: {target}") + + +def _use_multiproc() -> bool: + return bool(os.environ.get("PROMETHEUS_MULTIPROC_DIR")) + + +def make_metrics_asgi_app(): + """构建 /metrics ASGI 应用(懒初始化包装器) + + 首次请求时才 import prometheus_client 并构建真实 app: + - multiproc 模式(环境变量已设置)→ MultiProcessCollector 聚合所有 worker + - 否则使用默认全局 REGISTRY + """ + state: Dict[str, Any] = {"app": None} + + async def metrics_app(scope, receive, send): + if state["app"] is None: + if not is_available(): + raise MetricsNotAvailableError() + from prometheus_client import CollectorRegistry, REGISTRY, make_asgi_app + + if _use_multiproc(): + from prometheus_client import multiprocess + + registry = CollectorRegistry() + multiprocess.MultiProcessCollector(registry) + else: + registry = REGISTRY + state["app"] = make_asgi_app(registry=registry) + await state["app"](scope, receive, send) + + return metrics_app + + +def mark_current_process_dead() -> None: + """multiproc 模式下标记本进程退出(清理 gauge 残留文件),其余情况 no-op""" + try: + if not _use_multiproc() or not is_available(): + return + from prometheus_client import multiprocess + + multiprocess.mark_process_dead(os.getpid()) + except Exception as e: # 退出路径绝不抛错 + logger.debug(f"mark_current_process_dead 失败(已忽略): {e}") + + +# ==================== 指标工厂(带 no-op 退化) ==================== + + +class _NoopMetric: + """prometheus-client 未安装时的 no-op 桩对象,支持链式调用""" + + def labels(self, *args, **kwargs) -> "_NoopMetric": + return self + + def inc(self, *args, **kwargs) -> None: + pass + + def observe(self, *args, **kwargs) -> None: + pass + + def set(self, *args, **kwargs) -> None: + pass + + +_NOOP_METRIC = _NoopMetric() + +# 进程内指标缓存,防止同名重复注册 +_metrics_cache: Dict[str, Any] = {} + + +def get_counter(name: str, documentation: str, labelnames: Sequence[str] = ()): + """获取(或创建)Counter;未安装 prometheus-client 时返回 no-op 桩""" + if name in _metrics_cache: + return _metrics_cache[name] + if not is_available(): + return _NOOP_METRIC + from prometheus_client import Counter + + metric = Counter(name, documentation, list(labelnames)) + _metrics_cache[name] = metric + return metric + + +def get_histogram( + name: str, + documentation: str, + labelnames: Sequence[str] = (), + buckets: Optional[Tuple[float, ...]] = None, +): + """获取(或创建)Histogram;未安装 prometheus-client 时返回 no-op 桩""" + if name in _metrics_cache: + return _metrics_cache[name] + if not is_available(): + return _NOOP_METRIC + from prometheus_client import Histogram + + metric = Histogram( + name, + documentation, + list(labelnames), + buckets=buckets or DEFAULT_DURATION_BUCKETS, + ) + _metrics_cache[name] = metric + return metric + + +def observe_stage(stage: str, seconds: float, **labels) -> None: + """记录某个处理阶段耗时到内置 myboot_stage_duration_seconds 直方图 + + Args: + stage: 阶段名(如 "recall"、"rank") + seconds: 耗时(秒),负值忽略 + **labels: 预留扩展,当前版本忽略额外标签 + """ + if seconds < 0: + return + histogram = get_histogram( + STAGE_HISTOGRAM_NAME, + "MyBoot 处理阶段耗时(秒)", + labelnames=("stage",), + ) + histogram.labels(stage=stage).observe(seconds) + + +@contextmanager +def time_stage(stage: str) -> Iterator[None]: + """上下文管理器:自动计时 with 体并调用 observe_stage""" + t0 = time.perf_counter() + try: + yield + finally: + observe_stage(stage, time.perf_counter() - t0) + + +# ==================== HTTP 指标中间件 ==================== + +try: # starlette 是 fastapi 的必备依赖,正常总是可用 + from starlette.middleware.base import BaseHTTPMiddleware +except ImportError: # pragma: no cover + BaseHTTPMiddleware = object # type: ignore + + +class HttpMetricsMiddleware(BaseHTTPMiddleware): + """HTTP 请求指标中间件 + + - myboot_http_requests_total{method, path, status} 请求计数 + - myboot_http_request_duration_seconds{method, path} 请求耗时直方图 + + path 标签使用路由模板(如 /items/{id})避免高基数;未匹配路由归入 + "unmatched";metrics 自身路径不统计。指标对象懒创建——middleware + import / 构造时不触碰 prometheus_client。 + """ + + def __init__(self, app, metrics_path: str = "/metrics"): + super().__init__(app) + self.metrics_path = metrics_path + self._requests_total = None + self._request_duration = None + + def _ensure_metrics(self) -> None: + if self._requests_total is None: + self._requests_total = get_counter( + "myboot_http_requests_total", + "HTTP 请求总数", + labelnames=("method", "path", "status"), + ) + self._request_duration = get_histogram( + "myboot_http_request_duration_seconds", + "HTTP 请求耗时(秒)", + labelnames=("method", "path"), + ) + + @staticmethod + def _route_path(scope) -> str: + route = scope.get("route") + if route is None: + return "unmatched" + # FastAPI APIRoute → path_format(模板,如 /items/{id});Mount → path + path = getattr(route, "path_format", None) or getattr(route, "path", None) + return path or "unmatched" + + async def dispatch(self, request, call_next): + raw_path = request.url.path + if raw_path == self.metrics_path or raw_path.startswith(self.metrics_path + "/"): + return await call_next(request) + + t0 = time.perf_counter() + response = await call_next(request) + elapsed = time.perf_counter() - t0 + + try: + path = self._route_path(request.scope) + self._ensure_metrics() + self._requests_total.labels( + method=request.method, path=path, status=str(response.status_code) + ).inc() + self._request_duration.labels( + method=request.method, path=path + ).observe(elapsed) + except Exception as e: # 指标采集绝不影响业务请求 + logger.debug(f"HTTP 指标采集失败(已忽略): {e}") + + return response diff --git a/myboot/utils/__init__.py b/myboot/utils/__init__.py new file mode 100644 index 0000000..e5fc48c --- /dev/null +++ b/myboot/utils/__init__.py @@ -0,0 +1,30 @@ +""" +工具函数模块 + +包含工具函数和公共模块 +""" + +from .common import ( + generate_id, + format_datetime, + validate_email, + hash_password, + verify_password, + generate_token, + parse_datetime, + get_local_ip +) +from .worker_sync import run_primary_first, clear_markers + +__all__ = [ + "generate_id", + "format_datetime", + "validate_email", + "hash_password", + "verify_password", + "generate_token", + "parse_datetime", + "get_local_ip", + "run_primary_first", + "clear_markers", +] \ No newline at end of file diff --git a/myboot/utils/async_utils.py b/myboot/utils/async_utils.py new file mode 100644 index 0000000..e8e9458 --- /dev/null +++ b/myboot/utils/async_utils.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 +""" +异步执行工具模块 +提供可复用的异步执行方法 +""" + +import asyncio +import functools +from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor +from typing import Callable + +from loguru import logger as loguru_logger + +logger = loguru_logger.bind(name='async_utils') + + +class AsyncExecutor: + """异步执行器,提供可复用的异步执行方法""" + + def __init__(self, max_workers: int = 4, executor_type: str = 'thread'): + """ + 初始化异步执行器 + + Args: + max_workers: 最大工作线程/进程数 + executor_type: 执行器类型,'thread' 或 'process' + """ + self.max_workers = max_workers + self.executor_type = executor_type + self._executor = None + self._loop = None + + @property + def executor(self): + """获取执行器实例""" + if self._executor is None: + if self.executor_type == 'thread': + self._executor = ThreadPoolExecutor(max_workers=self.max_workers) + elif self.executor_type == 'process': + self._executor = ProcessPoolExecutor(max_workers=self.max_workers) + else: + raise ValueError(f"不支持的执行器类型: {self.executor_type}") + return self._executor + + @property + def loop(self): + """获取事件循环""" + if self._loop is None: + try: + self._loop = asyncio.get_event_loop() + except RuntimeError: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + return self._loop + + async def run_in_background(self, func: Callable, *args, **kwargs) -> asyncio.Task: + """ + 在后台异步执行函数 + + Args: + func: 要执行的函数 + *args: 函数参数 + **kwargs: 函数关键字参数 + + Returns: + asyncio.Task: 异步任务对象 + """ + task = asyncio.create_task(self._execute_func(func, *args, **kwargs)) + return task + + async def _execute_func(self, func: Callable, *args, task_name: str = None, **kwargs): + """执行函数的内部方法""" + try: + # 使用提供的任务名称或函数名 + display_name = task_name if task_name else func.__name__ + # logger.info(f"开始执行后台任务: {display_name}") + result = await self.loop.run_in_executor( + self.executor, + functools.partial(func, *args, **kwargs) + ) + # logger.info(f"后台任务执行完成: {display_name}") + return result + except Exception as e: + display_name = task_name if task_name else func.__name__ + # logger.error(f"后台任务执行失败: {display_name}, 错误: {e}", exc_info=True) + raise + + def close(self): + """关闭执行器,释放资源""" + if self._executor: + self._executor.shutdown(wait=True) + self._executor = None + # logger.info("异步执行器已关闭") + + +# 全局异步执行器实例 +_global_executor = None + + +def get_async_executor(max_workers: int = 4, executor_type: str = 'thread') -> AsyncExecutor: + """ + 获取全局异步执行器实例 + + Args: + max_workers: 最大工作线程/进程数 + executor_type: 执行器类型 + + Returns: + AsyncExecutor: 异步执行器实例 + """ + global _global_executor + if _global_executor is None: + _global_executor = AsyncExecutor(max_workers, executor_type) + return _global_executor + + +def async_run(func: Callable, *args, **kwargs) -> asyncio.Task: + """ + 快速启动后台任务的便捷函数 + + Args: + func: 要执行的函数 + *args: 函数参数 + **kwargs: 函数关键字参数,支持 task_name 用于日志显示 + + Returns: + asyncio.Task: 异步任务对象 + """ + executor = get_async_executor() + # 从kwargs中提取task_name,如果没有则使用函数名 + task_name = kwargs.pop('task_name', None) + display_name = task_name if task_name else func.__name__ + + # 创建异步任务,确保在后台运行 + task = asyncio.create_task(executor._execute_func(func, *args, task_name=display_name, **kwargs)) + return task + + +# 装饰器:将同步函数转换为异步函数 +def async_task(func: Callable) -> Callable: + """ + 装饰器:将同步函数包装为异步函数 + + Usage: + @async_task + def my_sync_function(): + # 同步代码 + pass + + # 现在可以异步调用 + await my_sync_function() + """ + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + executor = get_async_executor() + return await executor._execute_func(func, *args, **kwargs) + + return wrapper + + +# 上下文管理器:自动管理执行器生命周期 +class AsyncExecutorContext: + """异步执行器上下文管理器""" + + def __init__(self, max_workers: int = 4, executor_type: str = 'thread'): + self.max_workers = max_workers + self.executor_type = executor_type + self.executor = None + + async def __aenter__(self): + self.executor = AsyncExecutor(self.max_workers, self.executor_type) + return self.executor + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.executor: + self.executor.close() + + +# 清理函数 +def cleanup_async_executor(): + """清理全局异步执行器""" + global _global_executor + if _global_executor: + _global_executor.close() + _global_executor = None diff --git a/myboot/utils/common.py b/myboot/utils/common.py new file mode 100644 index 0000000..2250e07 --- /dev/null +++ b/myboot/utils/common.py @@ -0,0 +1,340 @@ +""" +公共工具函数 + +包含常用的工具函数和辅助方法 +""" + +import hashlib +import secrets +import socket +import re +import uuid +from datetime import datetime, timezone +from typing import Optional, Union + +from loguru import logger as loguru_logger + +logger = loguru_logger.bind(name="utils") + + +def generate_id() -> str: + """生成唯一ID""" + return str(uuid.uuid4()) + + +def get_local_ip() -> str: + """获取本机真实 IP 地址""" + try: + # 连接到一个外部地址来获取本地 IP + # 不实际发送数据,只是用于获取本地 IP + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + # 连接到公共 DNS(不需要实际连接成功) + s.connect(('8.8.8.8', 80)) + ip = s.getsockname()[0] + finally: + s.close() + return ip + except Exception: + # 如果失败,尝试使用主机名 + try: + hostname = socket.gethostname() + ip = socket.gethostbyname(hostname) + # 如果不是有效的 IP(可能是 127.0.0.1),返回 localhost + if ip.startswith('127.'): + return 'localhost' + return ip + except Exception: + return 'localhost' + + +def generate_short_id(length: int = 8) -> str: + """生成短ID""" + return secrets.token_urlsafe(length) + + +def format_datetime(dt: datetime, format_str: str = "%Y-%m-%d %H:%M:%S") -> str: + """ + 格式化日期时间 + + Args: + dt: 日期时间对象 + format_str: 格式字符串 + + Returns: + str: 格式化后的字符串 + """ + if dt is None: + return "" + + return dt.strftime(format_str) + + +def parse_datetime(date_str: str, format_str: str = "%Y-%m-%d %H:%M:%S") -> Optional[datetime]: + """ + 解析日期时间字符串 + + Args: + date_str: 日期时间字符串 + format_str: 格式字符串 + + Returns: + Optional[datetime]: 解析后的日期时间对象 + """ + try: + return datetime.strptime(date_str, format_str) + except ValueError: + logger.warning(f"无法解析日期时间: {date_str}") + return None + + +def validate_email(email: str) -> bool: + """ + 验证邮箱格式 + + Args: + email: 邮箱地址 + + Returns: + bool: 是否有效 + """ + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return bool(re.match(pattern, email)) + + +def validate_phone(phone: str) -> bool: + """ + 验证手机号格式 + + Args: + phone: 手机号 + + Returns: + bool: 是否有效 + """ + pattern = r'^1[3-9]\d{9}$' + return bool(re.match(pattern, phone)) + + +def hash_password(password: str) -> str: + """ + 哈希密码 + + Args: + password: 原始密码 + + Returns: + str: 哈希后的密码 + """ + salt = secrets.token_hex(16) + password_hash = hashlib.pbkdf2_hmac( + 'sha256', + password.encode('utf-8'), + salt.encode('utf-8'), + 100000 + ) + return f"{salt}:{password_hash.hex()}" + + +def verify_password(password: str, password_hash: str) -> bool: + """ + 验证密码 + + Args: + password: 原始密码 + password_hash: 哈希后的密码 + + Returns: + bool: 是否匹配 + """ + try: + salt, hash_value = password_hash.split(':') + password_hash_check = hashlib.pbkdf2_hmac( + 'sha256', + password.encode('utf-8'), + salt.encode('utf-8'), + 100000 + ) + return password_hash_check.hex() == hash_value + except ValueError: + return False + + +def generate_token(length: int = 32) -> str: + """ + 生成随机令牌 + + Args: + length: 令牌长度 + + Returns: + str: 随机令牌 + """ + return secrets.token_urlsafe(length) + + +def generate_api_key() -> str: + """生成API密钥""" + return f"pk_{secrets.token_urlsafe(32)}" + + +def mask_sensitive_data(data: str, mask_char: str = "*", visible_chars: int = 4) -> str: + """ + 遮蔽敏感数据 + + Args: + data: 原始数据 + mask_char: 遮蔽字符 + visible_chars: 可见字符数 + + Returns: + str: 遮蔽后的数据 + """ + if len(data) <= visible_chars: + return mask_char * len(data) + + return data[:visible_chars] + mask_char * (len(data) - visible_chars) + + +def safe_get(data: dict, key: str, default: any = None) -> any: + """ + 安全获取字典值 + + Args: + data: 字典数据 + key: 键,支持点号分隔的嵌套键 + default: 默认值 + + Returns: + any: 值或默认值 + """ + keys = key.split('.') + value = data + + try: + for k in keys: + value = value[k] + return value + except (KeyError, TypeError): + return default + + +def chunk_list(lst: list, chunk_size: int) -> list: + """ + 将列表分块 + + Args: + lst: 原始列表 + chunk_size: 块大小 + + Returns: + list: 分块后的列表 + """ + return [lst[i:i + chunk_size] for i in range(0, len(lst), chunk_size)] + + +def remove_none_values(data: dict) -> dict: + """ + 移除字典中的None值 + + Args: + data: 原始字典 + + Returns: + dict: 清理后的字典 + """ + return {k: v for k, v in data.items() if v is not None} + + +def deep_merge_dict(dict1: dict, dict2: dict) -> dict: + """ + 深度合并字典 + + Args: + dict1: 第一个字典 + dict2: 第二个字典 + + Returns: + dict: 合并后的字典 + """ + result = dict1.copy() + + for key, value in dict2.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge_dict(result[key], value) + else: + result[key] = value + + return result + + +def get_current_timestamp() -> int: + """获取当前时间戳(毫秒)""" + return int(datetime.now(timezone.utc).timestamp() * 1000) + + +def format_file_size(size_bytes: int) -> str: + """ + 格式化文件大小 + + Args: + size_bytes: 字节数 + + Returns: + str: 格式化后的大小 + """ + if size_bytes == 0: + return "0 B" + + size_names = ["B", "KB", "MB", "GB", "TB"] + i = 0 + while size_bytes >= 1024 and i < len(size_names) - 1: + size_bytes /= 1024.0 + i += 1 + + return f"{size_bytes:.1f} {size_names[i]}" + + +def is_valid_url(url: str) -> bool: + """ + 验证URL格式 + + Args: + url: URL字符串 + + Returns: + bool: 是否有效 + """ + pattern = r'^https?://(?:[-\w.])+(?:\:[0-9]+)?(?:/(?:[\w/_.])*(?:\?(?:[\w&=%.])*)?(?:\#(?:[\w.])*)?)?$' + return bool(re.match(pattern, url)) + + +class RetryHelper: + """重试辅助类""" + + def __init__(self, max_retries: int = 3, delay: float = 1.0, backoff_factor: float = 2.0): + self.max_retries = max_retries + self.delay = delay + self.backoff_factor = backoff_factor + + def execute(self, func, *args, **kwargs): + """执行带重试的函数""" + import time + + last_exception = None + current_delay = self.delay + + for attempt in range(self.max_retries + 1): + try: + return func(*args, **kwargs) + except Exception as e: + last_exception = e + + if attempt < self.max_retries: + logger.warning(f"执行失败,{current_delay}秒后重试 (尝试 {attempt + 1}/{self.max_retries + 1}): {e}") + time.sleep(current_delay) + current_delay *= self.backoff_factor + else: + logger.error(f"执行失败,已达到最大重试次数: {e}") + + raise last_exception diff --git a/myboot/utils/naming.py b/myboot/utils/naming.py new file mode 100644 index 0000000..eafab0a --- /dev/null +++ b/myboot/utils/naming.py @@ -0,0 +1,29 @@ +""" +命名转换工具 +""" + +import re + + +def camel_to_snake(name: str) -> str: + """ + 将驼峰命名转换为下划线分隔的小写形式 + + Args: + name: 类名(驼峰命名) + + Returns: + 下划线分隔的小写字符串 + + Examples: + UserService -> user_service + EmailService -> email_service + DatabaseClient -> database_client + HTTPClient -> http_client + """ + # 在大写字母前插入下划线(除了第一个字符) + s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + # 处理连续大写字母的情况(如 HTTPClient) + s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1) + # 转换为小写 + return s2.lower() diff --git a/myboot/utils/path_utils.py b/myboot/utils/path_utils.py new file mode 100644 index 0000000..31b5141 --- /dev/null +++ b/myboot/utils/path_utils.py @@ -0,0 +1,88 @@ +""" +路径工具模块 + +提供项目根目录检测和路径解析功能。 +""" + +import os +from pathlib import Path + + +def get_project_root() -> str: + """ + 获取项目根目录 + + 从当前文件所在目录开始向上查找,直到找到包含pyproject.toml的目录。 + 如果没找到,则使用当前工作目录。 + + Returns: + str: 项目根目录的绝对路径 + """ + current_dir = Path(__file__).parent.absolute() + + while current_dir.parent != current_dir: + if (current_dir / 'pyproject.toml').exists(): + return str(current_dir) + current_dir = current_dir.parent + + return os.getcwd() + + +def resolve_path(path: str) -> str: + """ + 解析路径,如果是相对路径则基于项目根目录 + + Args: + path (str): 要解析的路径,可以是相对路径或绝对路径 + + Returns: + str: 解析后的绝对路径 + + Examples: + >>> resolve_path('./data/file.csv') + '/path/to/project/data/file.csv' + + >>> resolve_path('/absolute/path/file.csv') + '/absolute/path/file.csv' + """ + if os.path.isabs(path): + return path + + # 清理路径,移除多余的./前缀 + clean_path = path.lstrip('./') + return os.path.join(get_project_root(), clean_path) + + +def get_data_dir() -> str: + """ + 获取数据目录的绝对路径 + + Returns: + str: 数据目录的绝对路径 + """ + return resolve_path('data') + + +def get_conf_dir() -> str: + """ + 获取配置目录的绝对路径 + + Returns: + str: 配置目录的绝对路径 + """ + return resolve_path('conf') + + +def ensure_dir_exists(path: str) -> str: + """ + 确保目录存在,如果不存在则创建 + + Args: + path (str): 目录路径 + + Returns: + str: 目录的绝对路径 + """ + abs_path = resolve_path(path) + os.makedirs(abs_path, exist_ok=True) + return abs_path diff --git a/myboot/utils/worker_sync.py b/myboot/utils/worker_sync.py new file mode 100644 index 0000000..9b32267 --- /dev/null +++ b/myboot/utils/worker_sync.py @@ -0,0 +1,187 @@ +""" +Worker 同步工具:primary-first 初始化协调 + +多 worker 部署下常见需求:primary worker 负责重型初始化(如下载/加载模型), +其余 worker 等待 primary 完成后再做轻量加载。本模块通过文件标记实现跨进程协调, +仅依赖标准库,兼容 Windows(不使用 fcntl),spawn / fork 两种启动模式均可用。 + +典型用法:: + + from myboot.utils import run_primary_first + + model = run_primary_first( + "sas_rec_model", + primary_fn=service.initialize_model, # primary: 下载并初始化 + secondary_fn=service.load_model_from_disk # 其他 worker: 轻量加载 + ) +""" + +import datetime +import hashlib +import os +import tempfile +import time +from pathlib import Path +from typing import Callable, Optional, TypeVar + +from loguru import logger + +T = TypeVar("T") + +_logger = logger.bind(name="worker_sync") + +# 模块加载时间,用于陈旧标记判定(见 run_primary_first docstring)。 +# spawn 模式下每个 worker 进程独立导入本模块,该时间即 worker 启动时间附近; +# fork 模式下可能继承父进程导入时刻,比 worker 启动更早——仍然早于本轮 +# primary 写入标记的时刻,因此不会误拒本轮标记。 +_MODULE_LOAD_TIME = time.time() + +# 陈旧标记判定容差(秒):mtime 早于 (_MODULE_LOAD_TIME - 容差) 的 .done 视为陈旧 +_STALE_TOLERANCE = 2.0 + + +def _slug() -> str: + """标记目录 slug:优先 MYBOOT_APP_NAME,否则取当前工作目录路径的 hash。 + + 两者在 spawn / fork 模式下父子进程均一致:环境变量被子进程继承(spawn 下 + multiprocessing 会复制父进程环境),工作目录同样被继承。 + """ + app_name = os.environ.get("MYBOOT_APP_NAME") + if app_name: + safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in app_name) + return safe[:64] + return hashlib.md5(os.getcwd().encode("utf-8")).hexdigest()[:16] + + +def _marker_dir() -> Path: + d = Path(tempfile.gettempdir()) / f"myboot_worker_sync_{_slug()}" + d.mkdir(parents=True, exist_ok=True) + return d + + +def clear_markers(name: Optional[str] = None) -> None: + """删除标记文件。 + + Args: + name: 指定时只删除该 name 的 .done / .failed;为 None 时清空整个标记目录。 + """ + d = _marker_dir() + if name is not None: + patterns = [f"{name}.done", f"{name}.failed"] + files = [d / p for p in patterns] + else: + files = list(d.glob("*.done")) + list(d.glob("*.failed")) + for f in files: + try: + f.unlink() + except FileNotFoundError: + pass + except OSError as e: # pragma: no cover - 极端权限/占用情况 + _logger.warning(f"无法删除标记文件 {f}: {e!r}") + + +def _is_fresh(path: Path) -> bool: + """判断标记文件是否属于本轮启动(基于 mtime)。""" + try: + return path.stat().st_mtime >= _MODULE_LOAD_TIME - _STALE_TOLERANCE + except OSError: + return False + + +def run_primary_first( + name: str, + primary_fn: Callable[[], T], + secondary_fn: Optional[Callable[[], T]] = None, + *, + timeout: float = 300.0, + poll_interval: float = 0.5, +) -> T: + """primary-first 初始化协调:primary worker 执行重型初始化,其余 worker 等待。 + + 判定规则:``os.environ.get("MYBOOT_IS_PRIMARY_WORKER", "1") == "1"`` 视为 + primary(单进程模式无此变量 → 默认 primary,工具退化为直接调用 primary_fn)。 + + - primary:先清理本 name 的旧标记,执行 primary_fn();成功写 + ``<标记目录>/.done``(内容为 ISO 时间戳)并返回结果; + 异常时写 ``.failed``(内容为异常 repr)后重新抛出。 + - 非 primary:以 poll_interval 间隔轮询。发现新鲜的 .done → 执行 + secondary_fn()(为 None 时执行 primary_fn)并返回其结果;发现新鲜的 + .failed → 抛 RuntimeError(含 primary 的异常信息);超过 timeout → + 抛 TimeoutError。 + + 陈旧标记的保证与限制(双重防护): + + 1. primary 在执行 primary_fn 之前删除本 name 的旧标记(clear_markers)。 + 2. secondary 仅认可 mtime 不早于「本模块加载时间 - 2 秒容差」的标记, + 上一轮运行遗留的 .done/.failed 因 mtime 过旧会被忽略并继续等待。 + + 保证:只要新一轮启动距上一轮结束超过容差(2 秒),secondary 不会被上一轮 + 遗留的标记误导;即使 secondary 比 primary 先启动并先于 primary 清理动作 + 看到旧标记,mtime 检查也会将其判为陈旧。 + + 限制: + - fork 模式下模块加载时间可能取自父进程导入时刻(早于 worker fork), + 若上一轮标记写入晚于父进程导入(如 2 秒内重启),理论上可能误认旧标记; + 实际部署中重启间隔远大于容差,且 primary 启动后会立即删除旧标记, + 该窗口极小。 + - 依赖文件系统 mtime 精度与各 worker 共享同一台机器的 temp 目录; + 不适用于跨机器协调。 + + Args: + name: 协调任务名,同一应用内唯一(用作标记文件名)。 + primary_fn: primary worker 执行的初始化函数。 + secondary_fn: 非 primary worker 在 primary 完成后执行的函数; + 为 None 时执行 primary_fn。 + timeout: 非 primary 等待 .done 的最长秒数。 + poll_interval: 轮询间隔秒数。 + + Returns: + primary_fn 或 secondary_fn 的返回值。 + + Raises: + TimeoutError: 非 primary 等待超时。 + RuntimeError: 非 primary 检测到 primary 初始化失败。 + Exception: primary 侧 primary_fn 抛出的原始异常。 + """ + is_primary = os.environ.get("MYBOOT_IS_PRIMARY_WORKER", "1") == "1" + d = _marker_dir() + done_file = d / f"{name}.done" + failed_file = d / f"{name}.failed" + + if is_primary: + clear_markers(name) + _logger.info(f"[{name}] primary worker 开始初始化") + try: + result = primary_fn() + except BaseException as e: + failed_file.write_text(repr(e), encoding="utf-8") + _logger.error(f"[{name}] primary 初始化失败: {e!r}") + raise + done_file.write_text( + datetime.datetime.now().isoformat(), encoding="utf-8" + ) + _logger.info(f"[{name}] primary 初始化完成,已写入标记 {done_file}") + return result + + # 非 primary:轮询等待 + _logger.info(f"[{name}] secondary worker 等待 primary 完成 (timeout={timeout}s)") + deadline = time.monotonic() + timeout + while True: + if done_file.exists() and _is_fresh(done_file): + _logger.info(f"[{name}] 检测到 primary 完成标记,开始 secondary 加载") + fn = secondary_fn if secondary_fn is not None else primary_fn + return fn() + if failed_file.exists() and _is_fresh(failed_file): + detail = "" + try: + detail = failed_file.read_text(encoding="utf-8") + except OSError: + pass + raise RuntimeError( + f"primary worker 初始化 '{name}' 失败: {detail}" + ) + if time.monotonic() >= deadline: + raise TimeoutError( + f"等待 primary worker 完成 '{name}' 超时 ({timeout}s)" + ) + time.sleep(poll_interval) diff --git a/myboot/web/__init__.py b/myboot/web/__init__.py new file mode 100644 index 0000000..71d1ee4 --- /dev/null +++ b/myboot/web/__init__.py @@ -0,0 +1,17 @@ +""" +Web 模块 + +提供 Web 相关的功能,包括中间件、响应格式等 +""" + +from .response import ResponseWrapper, ApiResponse, response +from .middleware import Middleware, FunctionMiddleware, ResponseFormatterMiddleware + +__all__ = [ + "ResponseWrapper", + "ApiResponse", + "response", + "Middleware", + "FunctionMiddleware", + "ResponseFormatterMiddleware" +] diff --git a/myboot/web/decorators.py b/myboot/web/decorators.py new file mode 100644 index 0000000..bec79ec --- /dev/null +++ b/myboot/web/decorators.py @@ -0,0 +1,286 @@ +""" +Web 路由装饰器 + +提供类似 Spring Boot 的注解式路由功能 +""" + +from functools import wraps +from typing import Any, Callable, List, Optional + +from pydantic import BaseModel + + +def route( + path: str, + methods: Optional[List[str]] = None, + response_model: Optional[Any] = None, + status_code: int = 200, + tags: Optional[List[str]] = None, + summary: Optional[str] = None, + description: Optional[str] = None, + **kwargs +) -> Callable: + """ + 通用路由装饰器 + + Args: + path: 路由路径 + methods: HTTP 方法列表 + response_model: 响应模型 + status_code: 状态码 + tags: 标签 + summary: 摘要 + description: 描述 + **kwargs: 其他 FastAPI 参数 + """ + if methods is None: + methods = ["GET"] + + def decorator(func: Callable) -> Callable: + # 添加路由元数据 + func._route_metadata = { + 'path': path, + 'methods': methods, + 'response_model': response_model, + 'status_code': status_code, + 'tags': tags or [], + 'summary': summary or func.__doc__ or "", + 'description': description or func.__doc__ or "", + **kwargs + } + + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def get( + path: str, + response_model: Optional[Any] = None, + status_code: int = 200, + tags: Optional[List[str]] = None, + summary: Optional[str] = None, + description: Optional[str] = None, + **kwargs +) -> Callable: + """GET 路由装饰器""" + return route( + path=path, + methods=["GET"], + response_model=response_model, + status_code=status_code, + tags=tags, + summary=summary, + description=description, + **kwargs + ) + + +def post( + path: str, + response_model: Optional[Any] = None, + status_code: int = 201, + tags: Optional[List[str]] = None, + summary: Optional[str] = None, + description: Optional[str] = None, + **kwargs +) -> Callable: + """POST 路由装饰器""" + return route( + path=path, + methods=["POST"], + response_model=response_model, + status_code=status_code, + tags=tags, + summary=summary, + description=description, + **kwargs + ) + + +def put( + path: str, + response_model: Optional[Any] = None, + status_code: int = 200, + tags: Optional[List[str]] = None, + summary: Optional[str] = None, + description: Optional[str] = None, + **kwargs +) -> Callable: + """PUT 路由装饰器""" + return route( + path=path, + methods=["PUT"], + response_model=response_model, + status_code=status_code, + tags=tags, + summary=summary, + description=description, + **kwargs + ) + + +def delete( + path: str, + response_model: Optional[Any] = None, + status_code: int = 204, + tags: Optional[List[str]] = None, + summary: Optional[str] = None, + description: Optional[str] = None, + **kwargs +) -> Callable: + """DELETE 路由装饰器""" + return route( + path=path, + methods=["DELETE"], + response_model=response_model, + status_code=status_code, + tags=tags, + summary=summary, + description=description, + **kwargs + ) + + +def patch( + path: str, + response_model: Optional[Any] = None, + status_code: int = 200, + tags: Optional[List[str]] = None, + summary: Optional[str] = None, + description: Optional[str] = None, + **kwargs +) -> Callable: + """PATCH 路由装饰器""" + return route( + path=path, + methods=["PATCH"], + response_model=response_model, + status_code=status_code, + tags=tags, + summary=summary, + description=description, + **kwargs + ) + + +def validate_request(model: BaseModel) -> Callable: + """ + 请求验证装饰器 + + Args: + model: Pydantic 模型类 + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + # 这里可以添加请求验证逻辑 + # 在实际应用中,FastAPI 会自动处理 Pydantic 模型验证 + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def require_auth(roles: Optional[List[str]] = None) -> Callable: + """ + 身份验证装饰器 + + Args: + roles: 需要的角色列表 + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + # 这里可以添加身份验证逻辑 + # 在实际应用中,可以集成 JWT、OAuth 等认证方式 + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def rate_limit(requests: int = 100, window: int = 60) -> Callable: + """ + 限流装饰器 + + Args: + requests: 时间窗口内允许的请求数 + window: 时间窗口(秒) + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + # 这里可以添加限流逻辑 + # 在实际应用中,可以集成 Redis 等存储来实现分布式限流 + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def cache(ttl: int = 300) -> Callable: + """ + 缓存装饰器 + + Args: + ttl: 缓存生存时间(秒) + """ + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + # 这里可以添加缓存逻辑 + # 在实际应用中,可以集成 Redis、Memcached 等缓存系统 + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def async_route( + path: str, + methods: Optional[List[str]] = None, + response_model: Optional[Any] = None, + status_code: int = 200, + tags: Optional[List[str]] = None, + summary: Optional[str] = None, + description: Optional[str] = None, + **kwargs +) -> Callable: + """ + 异步路由装饰器 + + 用于标记异步处理函数 + """ + if methods is None: + methods = ["GET"] + + def decorator(func: Callable) -> Callable: + # 添加路由元数据 + func._route_metadata = { + 'path': path, + 'methods': methods, + 'response_model': response_model, + 'status_code': status_code, + 'tags': tags or [], + 'summary': summary or func.__doc__ or "", + 'description': description or func.__doc__ or "", + 'async': True, + **kwargs + } + + @wraps(func) + async def wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/myboot/web/exceptions.py b/myboot/web/exceptions.py new file mode 100644 index 0000000..46b1a0a --- /dev/null +++ b/myboot/web/exceptions.py @@ -0,0 +1,231 @@ +""" +Web 异常模块 + +提供 Web 相关的异常类 + +ValidationError / ConfigurationError 自 0.2.0 起与 myboot.exceptions +中的定义收敛为同一个类(此前两处各自定义、互不兼容),从本模块 import +仍然有效。 +""" + +from typing import Any, Dict, Optional + +from ..exceptions import ConfigurationError, ValidationError # noqa: F401 + + +class HTTPException(Exception): + """HTTP 异常基类""" + + def __init__( + self, + status_code: int, + message: str = "HTTP Error", + details: Optional[Dict[str, Any]] = None + ): + self.status_code = status_code + self.message = message + self.details = details or {} + super().__init__(self.message) + + +class BadRequestError(HTTPException): + """400 错误请求异常""" + + def __init__(self, message: str = "错误请求", details: Optional[Dict[str, Any]] = None): + super().__init__(400, message, details) + + +class UnauthorizedError(HTTPException): + """401 未授权异常""" + + def __init__(self, message: str = "未授权", details: Optional[Dict[str, Any]] = None): + super().__init__(401, message, details) + + +class ForbiddenError(HTTPException): + """403 禁止访问异常""" + + def __init__(self, message: str = "禁止访问", details: Optional[Dict[str, Any]] = None): + super().__init__(403, message, details) + + +class NotFoundError(HTTPException): + """404 未找到异常""" + + def __init__(self, message: str = "未找到", details: Optional[Dict[str, Any]] = None): + super().__init__(404, message, details) + + +class MethodNotAllowedError(HTTPException): + """405 方法不允许异常""" + + def __init__(self, message: str = "方法不允许", details: Optional[Dict[str, Any]] = None): + super().__init__(405, message, details) + + +class ConflictError(HTTPException): + """409 冲突异常""" + + def __init__(self, message: str = "资源冲突", details: Optional[Dict[str, Any]] = None): + super().__init__(409, message, details) + + +class UnprocessableEntityError(HTTPException): + """422 无法处理的实体异常""" + + def __init__(self, message: str = "无法处理的实体", details: Optional[Dict[str, Any]] = None): + super().__init__(422, message, details) + + +class TooManyRequestsError(HTTPException): + """429 请求过多异常""" + + def __init__(self, message: str = "请求过多", details: Optional[Dict[str, Any]] = None): + super().__init__(429, message, details) + + +class InternalServerError(HTTPException): + """500 内部服务器错误异常""" + + def __init__(self, message: str = "内部服务器错误", details: Optional[Dict[str, Any]] = None): + super().__init__(500, message, details) + + +class ServiceUnavailableError(HTTPException): + """503 服务不可用异常""" + + def __init__(self, message: str = "服务不可用", details: Optional[Dict[str, Any]] = None): + super().__init__(503, message, details) + + +class AuthenticationError(Exception): + """认证错误异常""" + + def __init__(self, message: str = "认证失败"): + self.message = message + super().__init__(self.message) + + +class AuthorizationError(Exception): + """授权错误异常""" + + def __init__(self, message: str = "授权失败"): + self.message = message + super().__init__(self.message) + + +class RateLimitError(Exception): + """限流错误异常""" + + def __init__(self, message: str = "请求频率过高", retry_after: Optional[int] = None): + self.message = message + self.retry_after = retry_after + super().__init__(self.message) + + +class BusinessError(Exception): + """业务错误异常""" + + def __init__( + self, + message: str = "业务处理失败", + code: str = "BUSINESS_ERROR", + details: Optional[Dict[str, Any]] = None + ): + self.message = message + self.code = code + self.details = details or {} + super().__init__(self.message) + + +class ExternalServiceError(Exception): + """外部服务错误异常""" + + def __init__( + self, + service_name: str, + message: str = "外部服务调用失败", + status_code: Optional[int] = None, + details: Optional[Dict[str, Any]] = None + ): + self.service_name = service_name + self.message = message + self.status_code = status_code + self.details = details or {} + super().__init__(self.message) + + +class DatabaseError(Exception): + """数据库错误异常""" + + def __init__( + self, + message: str = "数据库操作失败", + operation: Optional[str] = None, + table: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + self.message = message + self.operation = operation + self.table = table + self.details = details or {} + super().__init__(self.message) + + +class CacheError(Exception): + """缓存错误异常""" + + def __init__( + self, + message: str = "缓存操作失败", + operation: Optional[str] = None, + key: Optional[str] = None, + details: Optional[Dict[str, Any]] = None + ): + self.message = message + self.operation = operation + self.key = key + self.details = details or {} + super().__init__(self.message) + + +class TimeoutError(Exception): + """超时错误异常""" + + def __init__( + self, + message: str = "操作超时", + timeout: Optional[float] = None, + operation: Optional[str] = None + ): + self.message = message + self.timeout = timeout + self.operation = operation + super().__init__(self.message) + + +# 异常映射 +HTTP_STATUS_EXCEPTIONS = { + 400: BadRequestError, + 401: UnauthorizedError, + 403: ForbiddenError, + 404: NotFoundError, + 405: MethodNotAllowedError, + 409: ConflictError, + 422: UnprocessableEntityError, + 429: TooManyRequestsError, + 500: InternalServerError, + 503: ServiceUnavailableError, +} + + +def create_http_exception(status_code: int, message: str, details: Optional[Dict[str, Any]] = None) -> HTTPException: + """根据状态码创建 HTTP 异常 + + 已知状态码映射到对应子类(子类构造签名为 ``(message, details)``, + status_code 已内置);未知状态码回退到基类 ``HTTPException``。 + """ + exception_class = HTTP_STATUS_EXCEPTIONS.get(status_code) + if exception_class is not None: + return exception_class(message, details) + return HTTPException(status_code, message, details) diff --git a/myboot/web/middleware.py b/myboot/web/middleware.py new file mode 100644 index 0000000..63892d8 --- /dev/null +++ b/myboot/web/middleware.py @@ -0,0 +1,327 @@ +""" +Web 中间件模块 + +提供类似 Spring Boot 的中间件功能 +""" + +import asyncio +import json +import time +from typing import Callable, List, Optional, Union, Pattern +import re + +from fastapi import Request, Response +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import StreamingResponse + + +class Middleware: + """中间件基类""" + + def __init__(self, middleware_class: type, **kwargs): + """ + 初始化中间件 + + Args: + middleware_class: 中间件类 + **kwargs: 中间件参数 + """ + self.middleware_class = middleware_class + self.kwargs = kwargs + + +class FunctionMiddleware(BaseHTTPMiddleware): + """函数中间件包装器 + + 将函数式中间件转换为 FastAPI 的 BaseHTTPMiddleware 类 + """ + + def __init__( + self, + app, + middleware_func: Callable, + path_filter: Optional[Union[str, Pattern, List[str]]] = None, + methods: Optional[List[str]] = None, + condition: Optional[Callable] = None, + order: int = 0, + **kwargs + ): + """ + 初始化函数中间件 + + Args: + app: FastAPI 应用实例 + middleware_func: 中间件函数,接收 (request, next_handler) 参数 + path_filter: 路径过滤,支持字符串、正则表达式或字符串列表 + methods: HTTP 方法过滤,如 ['GET', 'POST'] + condition: 条件函数,接收 request,返回 bool 决定是否执行中间件 + order: 执行顺序,数字越小越先执行 + **kwargs: 其他参数 + """ + super().__init__(app) + self.middleware_func = middleware_func + self.path_filter = path_filter + self.methods = [m.upper() for m in methods] if methods else None + self.condition = condition + self.order = order + self.kwargs = kwargs + + # 编译路径过滤正则表达式 + if path_filter: + if isinstance(path_filter, str): + # 将通配符转换为正则表达式 + pattern = path_filter.replace('*', '.*').replace('?', '.') + self.path_pattern = re.compile(f'^{pattern}$') + elif isinstance(path_filter, list): + patterns = [p.replace('*', '.*').replace('?', '.') for p in path_filter] + self.path_pattern = re.compile(f"^({'|'.join(patterns)})$") + elif isinstance(path_filter, Pattern): + self.path_pattern = path_filter + else: + self.path_pattern = None + else: + self.path_pattern = None + + def _should_process(self, request: Request) -> bool: + """判断是否应该处理该请求""" + # 检查路径过滤 + if self.path_pattern: + if not self.path_pattern.match(request.url.path): + return False + + # 检查 HTTP 方法过滤 + if self.methods: + if request.method not in self.methods: + return False + + # 检查条件函数 + if self.condition: + try: + if not self.condition(request): + return False + except Exception: + # 条件函数执行失败时跳过该中间件 + return False + + return True + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """执行中间件逻辑""" + # 如果不符合过滤条件,直接跳过 + if not self._should_process(request): + return await call_next(request) + + # 创建异步 next_handler 包装器 + async def async_next_handler(req: Request) -> Response: + return await call_next(req) + + # 检查中间件函数是否是异步函数 + if asyncio.iscoroutinefunction(self.middleware_func): + # 异步中间件(推荐方式) + response = await self.middleware_func(request, async_next_handler) + else: + # 同步中间件支持(为了向后兼容) + # 注意:同步中间件接收的 next_handler 会返回协程,需要手动处理 + # 建议使用异步中间件以获得更好的性能 + def sync_wrapper(req: Request): + """同步包装器:返回协程对象,需要调用者手动 await""" + return call_next(req) + + # 调用同步中间件函数 + result = self.middleware_func(request, sync_wrapper) + + # 处理返回值 + if asyncio.iscoroutine(result): + # 如果返回协程,等待它 + response = await result + elif isinstance(result, (Response, StreamingResponse)): + # 如果已经是 Response,直接使用 + response = result + else: + # 其他情况,尝试转换 + from fastapi.responses import JSONResponse + response = JSONResponse(content=result) + + # 确保返回 Response 对象 + if not isinstance(response, (Response, StreamingResponse)): + # 如果返回的不是 Response,尝试转换为 Response + from fastapi.responses import JSONResponse + response = JSONResponse(content=response) + + return response + + +class ResponseFormatterMiddleware(BaseHTTPMiddleware): + """响应格式化中间件 + + 自动将路由返回的数据包装为统一的 REST API 格式: + { + "success": true, + "code": 200, + "message": "操作成功", + "data": {...} + } + """ + + def __init__( + self, + app, + exclude_paths: Optional[List[str]] = None, + auto_wrap: bool = True + ): + """ + 初始化响应格式化中间件 + + Args: + app: FastAPI 应用实例 + exclude_paths: 排除的路径列表(这些路径不进行格式化) + auto_wrap: 是否自动包装响应 + """ + super().__init__(app) + self.exclude_paths = exclude_paths or [] + self.auto_wrap = auto_wrap + + # 默认排除的路径(系统路径和文档路径) + default_excludes = [ + "/docs", + "/openapi.json", + "/redoc", + "/health", + "/health/ready", + "/health/live" + ] + + # 合并排除路径 + self.exclude_paths = list(set(self.exclude_paths + default_excludes)) + + def _should_format(self, request: Request) -> bool: + """判断是否应该格式化响应""" + if not self.auto_wrap: + return False + + path = request.url.path + + # 检查是否在排除列表中 + for exclude_path in self.exclude_paths: + if path == exclude_path or path.startswith(exclude_path + "/"): + return False + + return True + + def _is_formatted(self, content: dict) -> bool: + """检查响应是否已经是统一格式""" + if not isinstance(content, dict): + return False + + # 检查是否包含统一格式的必须字段(message 和 data 可选) + required_fields = {"success", "code"} + return all(field in content for field in required_fields) + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """处理请求并格式化响应""" + response = await call_next(request) + + # 如果不需要格式化,直接返回 + if not self._should_format(request): + return response + + # 检查响应类型 + # FastAPI 可能返回 JSONResponse、StreamingResponse 或 _StreamingResponse + # 我们需要处理所有可能包含 JSON 内容的响应 + + # 检查是否是 JSONResponse + is_json_response = isinstance(response, JSONResponse) + + # 如果不是 JSONResponse,检查是否可能是 JSON 响应 + if not is_json_response: + # 检查 media_type 是否为 JSON + if hasattr(response, 'media_type') and response.media_type: + media_type_lower = response.media_type.lower() + if 'json' in media_type_lower: + is_json_response = True + else: + return response + elif isinstance(response, (StreamingResponse, Response)): + # StreamingResponse 或 Response 如果没有设置 media_type,尝试读取并判断 + # 对于未知类型的响应,我们尝试读取内容并判断是否为 JSON + # 允许继续处理,尝试读取响应体 + pass + else: + # 如果既不是 JSONResponse 也没有 media_type,跳过 + return response + + # 检查响应是否有 body_iterator(所有响应都应该有) + if not hasattr(response, 'body_iterator'): + return response + + # 获取响应内容 + try: + # 读取响应体 - 使用正确的方式处理流式响应 + body = b"" + async for chunk in response.body_iterator: + body += chunk + + # 如果没有响应体,直接返回空响应 + if not body: + return response + + # 解析 JSON + try: + content = json.loads(body.decode('utf-8')) + except json.JSONDecodeError: + # 如果不是有效的 JSON,返回原始响应 + return response + + # 如果已经是统一格式,重新构建响应并返回 + if self._is_formatted(content): + # 移除 Content-Length,让服务器重新计算 + headers = dict(response.headers) + headers.pop('content-length', None) + return JSONResponse( + content=content, + status_code=response.status_code, + headers=headers + ) + + # 根据状态码判断成功或失败 + status_code = response.status_code + is_success = 200 <= status_code < 400 + + # 自动包装响应 + from myboot.web.response import ResponseWrapper + + if is_success: + # 成功响应 + formatted_content = ResponseWrapper.success( + data=content, + message="操作成功", + code=status_code + ) + else: + # 错误响应(通常由异常处理器处理,但以防万一) + message = content.get("message", "操作失败") if isinstance(content, dict) else "操作失败" + formatted_content = ResponseWrapper.error( + message=message, + code=status_code, + data=content if isinstance(content, dict) else {"error": str(content)} + ) + + # 移除 Content-Length,让服务器重新计算新的响应长度 + headers = dict(response.headers) + headers.pop('content-length', None) + + return JSONResponse( + content=formatted_content, + status_code=status_code, + headers=headers + ) + + except (AttributeError, UnicodeDecodeError, StopAsyncIteration) as e: + # 如果无法解析,返回原始响应 + # 这里可以记录日志,但为了不影响正常响应,静默处理 + return response + except Exception as e: + # 捕获所有其他异常,避免中间件崩溃 + # 在生产环境中可以记录日志 + return response diff --git a/myboot/web/models.py b/myboot/web/models.py new file mode 100644 index 0000000..201d45b --- /dev/null +++ b/myboot/web/models.py @@ -0,0 +1,204 @@ +""" +Web 数据模型 + +提供请求和响应的数据模型 +""" + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field, field_validator + + +class BaseResponse(BaseModel): + """基础响应模型""" + + success: bool = Field(default=True, description="是否成功") + message: str = Field(default="操作成功", description="响应消息") + data: Optional[Any] = Field(default=None, description="响应数据") + timestamp: datetime = Field(default_factory=datetime.now, description="时间戳") + +class ErrorResponse(BaseResponse): + """错误响应模型""" + + success: bool = Field(default=False, description="是否成功") + message: str = Field(default="操作失败", description="错误消息") + error_code: Optional[str] = Field(default=None, description="错误代码") + details: Optional[Dict[str, Any]] = Field(default=None, description="错误详情") + + +class PaginationRequest(BaseModel): + """分页请求模型""" + + page: int = Field(default=1, ge=1, description="页码") + size: int = Field(default=10, ge=1, le=100, description="每页大小") + sort: Optional[str] = Field(default=None, description="排序字段") + order: str = Field(default="asc", pattern="^(asc|desc)$", description="排序方向") + + @field_validator('page') + def validate_page(cls, v): + if v < 1: + raise ValueError('页码必须大于 0') + return v + + @field_validator('size') + def validate_size(cls, v): + if v < 1 or v > 100: + raise ValueError('每页大小必须在 1-100 之间') + return v + + +class PaginationResponse(BaseResponse): + """分页响应模型""" + + data: List[Any] = Field(default=[], description="数据列表") + pagination: Dict[str, Any] = Field(description="分页信息") + + @classmethod + def create( + cls, + data: List[Any], + total: int, + page: int, + size: int, + message: str = "查询成功" + ) -> "PaginationResponse": + """创建分页响应""" + total_pages = (total + size - 1) // size + + pagination = { + "total": total, + "page": page, + "size": size, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1 + } + + return cls( + success=True, + message=message, + data=data, + pagination=pagination + ) + + +class HealthCheckResponse(BaseModel): + """健康检查响应模型""" + + status: str = Field(description="服务状态") + app: str = Field(description="应用名称") + version: str = Field(description="应用版本") + uptime: str = Field(description="运行时间") + timestamp: datetime = Field(default_factory=datetime.now, description="检查时间") + +class RequestInfo(BaseModel): + """请求信息模型""" + + method: str = Field(description="HTTP 方法") + url: str = Field(description="请求 URL") + headers: Dict[str, str] = Field(description="请求头") + query_params: Dict[str, Any] = Field(default={}, description="查询参数") + path_params: Dict[str, Any] = Field(default={}, description="路径参数") + body: Optional[Any] = Field(default=None, description="请求体") + client_ip: Optional[str] = Field(default=None, description="客户端 IP") + user_agent: Optional[str] = Field(default=None, description="用户代理") + timestamp: datetime = Field(default_factory=datetime.now, description="请求时间") + +class ResponseInfo(BaseModel): + """响应信息模型""" + + status_code: int = Field(description="状态码") + headers: Dict[str, str] = Field(description="响应头") + body: Optional[Any] = Field(default=None, description="响应体") + process_time: float = Field(description="处理时间(秒)") + timestamp: datetime = Field(default_factory=datetime.now, description="响应时间") + +class ValidationErrorDetail(BaseModel): + """验证错误详情模型""" + + field: str = Field(description="字段名") + message: str = Field(description="错误消息") + value: Any = Field(description="字段值") + type: str = Field(description="错误类型") + + +class ValidationErrorResponse(ErrorResponse): + """验证错误响应模型""" + + message: str = Field(default="请求参数验证失败", description="错误消息") + error_code: str = Field(default="VALIDATION_ERROR", description="错误代码") + details: List[ValidationErrorDetail] = Field(description="验证错误详情") + + +class APIError(BaseModel): + """API 错误模型""" + + code: str = Field(description="错误代码") + message: str = Field(description="错误消息") + details: Optional[Dict[str, Any]] = Field(default=None, description="错误详情") + timestamp: datetime = Field(default_factory=datetime.now, description="错误时间") + +class SuccessResponse(BaseResponse): + """成功响应模型""" + + success: bool = Field(default=True, description="是否成功") + message: str = Field(default="操作成功", description="成功消息") + data: Optional[Any] = Field(default=None, description="响应数据") + + +class CreatedResponse(SuccessResponse): + """创建成功响应模型""" + + message: str = Field(default="创建成功", description="成功消息") + status_code: int = Field(default=201, description="状态码") + + +class UpdatedResponse(SuccessResponse): + """更新成功响应模型""" + + message: str = Field(default="更新成功", description="成功消息") + status_code: int = Field(default=200, description="状态码") + + +class DeletedResponse(SuccessResponse): + """删除成功响应模型""" + + message: str = Field(default="删除成功", description="成功消息") + status_code: int = Field(default=204, description="状态码") + data: Optional[Any] = Field(default=None, description="响应数据") + + +# 便捷函数 +def success_response(data: Any = None, message: str = "操作成功") -> SuccessResponse: + """创建成功响应""" + return SuccessResponse(data=data, message=message) + + +def error_response( + message: str = "操作失败", + error_code: str = "UNKNOWN_ERROR", + details: Optional[Dict[str, Any]] = None +) -> ErrorResponse: + """创建错误响应""" + return ErrorResponse( + message=message, + error_code=error_code, + details=details + ) + + +def validation_error_response(errors: List[ValidationErrorDetail]) -> ValidationErrorResponse: + """创建验证错误响应""" + return ValidationErrorResponse(details=errors) + + +def pagination_response( + data: List[Any], + total: int, + page: int, + size: int, + message: str = "查询成功" +) -> PaginationResponse: + """创建分页响应""" + return PaginationResponse.create(data, total, page, size, message) diff --git a/myboot/web/response.py b/myboot/web/response.py new file mode 100644 index 0000000..a49fae6 --- /dev/null +++ b/myboot/web/response.py @@ -0,0 +1,173 @@ +""" +REST API 响应格式封装 + +提供统一的 REST API 响应格式,确保所有 API 返回一致的格式: +{ + "success": true/false, + "code": 200, + "message": "操作成功", + "data": {...} +} +""" + +from typing import Any, Dict, Optional, Union +from pydantic import BaseModel, ConfigDict, Field + + +class ApiResponse(BaseModel): + """统一的 REST API 响应格式""" + + model_config = ConfigDict(exclude_none=True) + + success: bool = Field(description="是否成功") + code: int = Field(description="HTTP 状态码") + message: Optional[str] = Field(default=None, description="响应消息") + data: Optional[Any] = Field(default=None, description="响应数据") + + +class ResponseWrapper: + """响应包装器 + + 提供便捷方法创建统一格式的响应 + """ + + @staticmethod + def success( + data: Any = None, + message: Optional[str] = None, + code: int = 200 + ) -> Dict[str, Any]: + """ + 创建成功响应 + + Args: + data: 响应数据 + message: 响应消息 + code: HTTP 状态码 + + Returns: + 统一格式的响应字典 + """ + result = { + "success": True, + "code": code, + } + if message is not None: + result["message"] = message + if data is not None: + result["data"] = data + return result + + @staticmethod + def error( + message: Optional[str] = None, + code: int = 500, + data: Optional[Any] = None + ) -> Dict[str, Any]: + """ + 创建错误响应 + + Args: + message: 错误消息 + code: HTTP 状态码 + data: 错误详情数据 + + Returns: + 统一格式的响应字典 + """ + result = { + "success": False, + "code": code, + } + if message is not None: + result["message"] = message + if data is not None: + result["data"] = data + return result + + @staticmethod + def created( + data: Any = None, + message: Optional[str] = None + ) -> Dict[str, Any]: + """创建成功响应(201)""" + return ResponseWrapper.success(data=data, message=message, code=201) + + @staticmethod + def updated( + data: Any = None, + message: Optional[str] = None + ) -> Dict[str, Any]: + """更新成功响应(200)""" + return ResponseWrapper.success(data=data, message=message, code=200) + + @staticmethod + def deleted( + message: Optional[str] = None + ) -> Dict[str, Any]: + """删除成功响应(200)""" + return ResponseWrapper.success(data=None, message=message, code=200) + + @staticmethod + def no_content() -> Dict[str, Any]: + """无内容响应(204)""" + return ResponseWrapper.success(data=None, message=None, code=204) + + @staticmethod + def pagination( + data: list, + total: int, + page: int, + size: int, + message: Optional[str] = None + ) -> Dict[str, Any]: + """ + 创建分页响应 + + Args: + data: 数据列表 + total: 总记录数 + page: 当前页码 + size: 每页大小 + message: 响应消息 + + Returns: + 统一格式的分页响应 + """ + total_pages = (total + size - 1) // size if size > 0 else 0 + pagination_data = { + "list": data, + "pagination": { + "total": total, + "page": page, + "size": size, + "totalPages": total_pages, + "hasNext": page < total_pages, + "hasPrev": page > 1 + } + } + return ResponseWrapper.success(data=pagination_data, message=message, code=200) + + @staticmethod + def wrap(data: Any, message: Optional[str] = None, code: int = 200) -> Dict[str, Any]: + """ + 包装任意数据为统一格式 + + Args: + data: 要包装的数据 + message: 响应消息(如果为 None,返回中不包含该字段) + code: HTTP 状态码 + + Returns: + 统一格式的响应字典 + """ + # 如果已经是统一格式,直接返回 + if isinstance(data, dict) and all(key in data for key in ["success", "code"]): + return data + + return ResponseWrapper.success(data=data, message=message, code=code) + + +# 全局响应包装器实例 +response = ResponseWrapper() + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fb5d41c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,170 @@ +[project] +name = "myboot" +version = "0.2.0" +description = "类似 Spring Boot 的 Python 快速开发框架" +authors = [ + {name = "TrumanDu", email = "truman.p.du@qq.com"} +] +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.9" +keywords = ["framework", "web", "api", "scheduler", "logging", "config"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Internet :: WWW/HTTP :: HTTP Servers", +] + +dependencies = [ + "fastapi>=0.104.0", + "hypercorn>=0.14.0", + "pydantic>=2.0.0", + "pyyaml>=6.0", + "APScheduler>=3.10.0", + "python-multipart>=0.0.6", + "jinja2>=3.1.0", + "python-dotenv>=1.0.0", + "loguru>=0.7.0", + "colorama>=0.4.6", + "click>=8.0.0", + "dynaconf>=3.2.0", + "requests>=2.28.0", + "dependency-injector>=4.41.0", + "pytz>=2024.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "pytest-asyncio>=0.21.0", + "black>=23.0", + "isort>=5.12", + "flake8>=6.0", + "mypy>=1.0", + "pre-commit>=3.0", +] + +test = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "pytest-asyncio>=0.21.0", + "httpx>=0.24.0", + "pytest-mock>=3.10", + "prometheus-client>=0.19.0", +] + +metrics = [ + "prometheus-client>=0.19.0", +] + +docs = [ + "sphinx>=7.0", + "sphinx-rtd-theme>=1.3", + "myst-parser>=2.0", +] + +[project.scripts] +myboot = "myboot.cli:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["myboot"] + +[tool.hatch.build.targets.sdist] +include = [ + "/myboot", + "/tests", + "/pyproject.toml", +] + +[tool.black] +line-length = 88 +target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["myboot"] + +[tool.mypy] +python_version = "3.9" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "--verbose", + "--tb=short", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", +] + +[tool.coverage.run] +source = ["myboot"] +omit = [ + "*/tests/*", + "*/test_*", + "*/__pycache__/*", + "*/venv/*", + "*/.venv/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2c62db7 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +测试模块 +""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a24dd5a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,41 @@ +""" +pytest 公共 fixture + +提供临时配置文件、干净环境变量等测试基础设施。 +""" + +import os +import sys +import textwrap + +import pytest + +# 确保从源码导入 myboot(而非已安装版本) +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + + +@pytest.fixture +def tmp_config_file(tmp_path): + """创建临时 YAML 配置文件,返回写入函数 + + 用法: + path = tmp_config_file("app:\\n name: demo") + """ + + def _write(content: str, filename: str = "config.yaml") -> str: + config_path = tmp_path / filename + config_path.write_text(textwrap.dedent(content), encoding="utf-8") + return str(config_path) + + return _write + + +@pytest.fixture +def clean_myboot_env(monkeypatch): + """清除 myboot 相关环境变量,避免测试间相互污染""" + for key in list(os.environ.keys()): + if key.startswith("MYBOOT_") or key == "CONFIG_FILE": + monkeypatch.delenv(key, raising=False) + return monkeypatch diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/fixtures/multiworker/main.py b/tests/integration/fixtures/multiworker/main.py new file mode 100644 index 0000000..f9e52fd --- /dev/null +++ b/tests/integration/fixtures/multiworker/main.py @@ -0,0 +1,57 @@ +"""多 worker 集成测试 fixture 应用入口 + +通过 subprocess 启动:python main.py +环境变量: + MW_PORT 监听端口(必需) + MW_WORKERS worker 数量(默认 2) + MW_HOOK_DIR worker 钩子记录目录 + MW_JOB_DIR 调度任务记录目录 + +注意:本模块在 Windows spawn 模式下会被 multiprocessing 以 __mp_main__ +重新导入,模块级代码(路径注入、配置覆盖)会在每个 worker 中重新执行, +这是预期行为;app.run() 受 __main__ 守卫保护只在父进程执行。 +""" + +import os +import sys +from pathlib import Path + +FIXTURE_DIR = Path(__file__).parent.absolute() +REPO_ROOT = FIXTURE_DIR.parent.parent.parent.parent + +# 确保能导入 mwapp 包与源码版 myboot +sys.path.insert(0, str(FIXTURE_DIR)) +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from myboot.core import auto_configuration # noqa: E402 +from myboot.core.application import Application # noqa: E402 + +app = Application( + name="multiworker-fixture", + auto_configuration=True, + auto_discover_package="mwapp", +) + +# 自动配置管理器默认把 myboot 源码仓库根当作 app_root, +# 这里指向 fixture 目录,使 "mwapp" 包能被发现; +# 禁用缓存避免在仓库中残留 .myboot_cache_*.json +auto_configuration._auto_configuration_manager.app_root = str(FIXTURE_DIR) +auto_configuration._auto_configuration_manager.use_cache = False + +PORT = int(os.environ.get("MW_PORT", "8765")) +WORKERS = int(os.environ.get("MW_WORKERS", "2")) + +# 显式覆盖配置——仓库 conf/config.yaml(server.workers=1 等)的优先级 +# 高于 run() 入参,必须在配置层覆盖 +app.config.set("server.host", "127.0.0.1") +app.config.set("server.port", PORT) +app.config.set("server.workers", WORKERS) + +if __name__ == "__main__": + app.run( + host="127.0.0.1", + port=PORT, + workers=WORKERS, + app_path="main:app.get_fastapi_app()", + ) diff --git a/tests/integration/fixtures/multiworker/mwapp/__init__.py b/tests/integration/fixtures/multiworker/mwapp/__init__.py new file mode 100644 index 0000000..27d4934 --- /dev/null +++ b/tests/integration/fixtures/multiworker/mwapp/__init__.py @@ -0,0 +1 @@ +"""多 worker 集成测试 fixture 应用包""" diff --git a/tests/integration/fixtures/multiworker/mwapp/api.py b/tests/integration/fixtures/multiworker/mwapp/api.py new file mode 100644 index 0000000..d8289bd --- /dev/null +++ b/tests/integration/fixtures/multiworker/mwapp/api.py @@ -0,0 +1,27 @@ +"""GET /api/whoami:返回当前 worker 与注入的 client 实例信息""" + +import os + +from myboot.core.application import app as current_app +from myboot.core.decorators import rest_controller, get + +from mwapp.clients import InstanceClient + + +@rest_controller('/api') +class WhoamiController: + + def __init__(self, instance_client: InstanceClient): + self.instance_client = instance_client + + @get('/whoami') + def whoami(self): + a = current_app() + return { + "worker_id": a.worker_id, + "worker_count": a.worker_count, + "is_primary": a.is_primary_worker, + "pid": os.getpid(), + "client_instance_id": self.instance_client.instance_id, + "client_created_pid": self.instance_client.created_pid, + } diff --git a/tests/integration/fixtures/multiworker/mwapp/clients.py b/tests/integration/fixtures/multiworker/mwapp/clients.py new file mode 100644 index 0000000..07fb7af --- /dev/null +++ b/tests/integration/fixtures/multiworker/mwapp/clients.py @@ -0,0 +1,15 @@ +"""测试客户端:记录实例 id 与创建进程 pid,用于断言每个 worker 独立实例化""" + +import os +import uuid + +from myboot.core.decorators import client + + +@client() +class InstanceClient: + """自动注册为 'instance_client'""" + + def __init__(self): + self.instance_id = uuid.uuid4().hex + self.created_pid = os.getpid() diff --git a/tests/integration/fixtures/multiworker/mwapp/hooks.py b/tests/integration/fixtures/multiworker/mwapp/hooks.py new file mode 100644 index 0000000..e803024 --- /dev/null +++ b/tests/integration/fixtures/multiworker/mwapp/hooks.py @@ -0,0 +1,33 @@ +"""worker 生命周期钩子:每个 worker 各写一个文件,供测试断言触发次数/进程""" + +import os + +from myboot.core.application import app as current_app +from myboot.core.decorators import on_worker_start, on_worker_stop + + +@on_worker_start +def record_worker_start(): + hook_dir = os.environ.get("MW_HOOK_DIR") + if not hook_dir: + return + a = current_app() + instance_client = a.get_client("instance_client") + instance_id = instance_client.instance_id if instance_client else "" + path = os.path.join(hook_dir, f"start_{os.getpid()}.txt") + # 追加写:若同一 worker 误触发两次,文件会出现两行,测试可检测到 + with open(path, "a", encoding="utf-8") as f: + f.write(f"{os.getpid()},{a.worker_id},{instance_id}\n") + + +@on_worker_stop +def record_worker_stop(): + # 注意:Windows 多 worker 模式下父进程 terminate() 硬终止 worker, + # 此钩子可能不触发(测试在 Windows 上跳过 stop 断言) + hook_dir = os.environ.get("MW_HOOK_DIR") + if not hook_dir: + return + a = current_app() + path = os.path.join(hook_dir, f"stop_{os.getpid()}.txt") + with open(path, "a", encoding="utf-8") as f: + f.write(f"{os.getpid()},{a.worker_id}\n") diff --git a/tests/integration/fixtures/multiworker/mwapp/jobs.py b/tests/integration/fixtures/multiworker/mwapp/jobs.py new file mode 100644 index 0000000..8fd8b57 --- /dev/null +++ b/tests/integration/fixtures/multiworker/mwapp/jobs.py @@ -0,0 +1,30 @@ +"""定时任务组件:每秒记录执行进程 pid + +- record_pid: 默认任务,用于断言只在 primary worker 运行 +- record_pid_all_workers: all_workers=True 任务,用于断言在每个 worker 都运行 +""" + +import os + +from myboot.core.decorators import component, interval + + +def _write_tick(prefix: str) -> None: + job_dir = os.environ.get("MW_JOB_DIR") + if not job_dir: + return + path = os.path.join(job_dir, f"{prefix}_{os.getpid()}.txt") + with open(path, "a", encoding="utf-8") as f: + f.write("tick\n") + + +@component() +class MwJobs: + + @interval(seconds=1) + def record_pid(self): + _write_tick("job") + + @interval(seconds=1, all_workers=True) + def record_pid_all_workers(self): + _write_tick("awjob") diff --git a/tests/integration/test_metrics_multiworker.py b/tests/integration/test_metrics_multiworker.py new file mode 100644 index 0000000..325db04 --- /dev/null +++ b/tests/integration/test_metrics_multiworker.py @@ -0,0 +1,93 @@ +""" +Prometheus multiproc 多进程聚合集成测试(F5) + +父进程设置 PROMETHEUS_MULTIPROC_DIR 后 spawn 两个子进程,各自 +get_counter().inc() 一次后退出;父进程通过 make_metrics_asgi_app() +(MultiProcessCollector)scrape,断言聚合值为 2 且目录下存在多个 +pid 的 db 文件。 + +注意:Windows 多 worker 不支持 multiproc 聚合,本文件在 win32 跳过, +由 CI(Linux)执行。 + +运行方式: uv run pytest tests/integration -m integration +""" + +import asyncio +import multiprocessing +import os +import sys + +import pytest + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + sys.platform == "win32", + reason="Windows 不支持 Prometheus multiproc 文件聚合", + ), +] + +COUNTER_NAME = "myboot_multiproc_itest_total" + + +def _child_inc(multiproc_dir: str) -> None: + """子进程入口:multiproc 模式下计数一次 + + 必须为模块级函数以兼容 spawn 启动方式。env 在 import + prometheus_client 之前设置,确保选用 mmap 文件后端。 + """ + os.environ["PROMETHEUS_MULTIPROC_DIR"] = multiproc_dir + + from myboot import metrics + + counter = metrics.get_counter(COUNTER_NAME, "multiproc integration test") + counter.inc() + metrics.mark_current_process_dead() + + +def test_multiproc_counter_aggregation(tmp_path, monkeypatch): + multiproc_dir = str(tmp_path) + monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", multiproc_dir) + + # 用 spawn 确保子进程在设置 env 后全新 import prometheus_client + # (fork 会继承父进程可能已按非 multiproc 模式初始化的模块状态) + ctx = multiprocessing.get_context("spawn") + procs = [ + ctx.Process(target=_child_inc, args=(multiproc_dir,)) for _ in range(2) + ] + for p in procs: + p.start() + for p in procs: + p.join(timeout=60) + assert p.exitcode == 0 + + # 目录下应有来自多个 pid 的 counter db 文件 + db_files = list(tmp_path.glob("counter_*.db")) + assert len(db_files) >= 2, f"期望至少 2 个 counter db 文件,实际: {db_files}" + + # 父进程 scrape:MultiProcessCollector 聚合两个子进程的计数 + import httpx + + from myboot.metrics import make_metrics_asgi_app + + asgi_app = make_metrics_asgi_app() + + async def _scrape() -> str: + transport = httpx.ASGITransport(app=asgi_app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + resp = await client.get("/") + assert resp.status_code == 200 + return resp.text + + text = asyncio.run(_scrape()) + + lines = [ + line + for line in text.splitlines() + if line.startswith(COUNTER_NAME) and not line.startswith("#") + ] + assert lines, f"scrape 输出中未找到 {COUNTER_NAME}: {text[:500]}" + value = float(lines[0].rsplit(" ", 1)[-1]) + assert value == 2.0 diff --git a/tests/integration/test_multiworker_integration.py b/tests/integration/test_multiworker_integration.py new file mode 100644 index 0000000..ebb849d --- /dev/null +++ b/tests/integration/test_multiworker_integration.py @@ -0,0 +1,240 @@ +""" +多 worker 真实进程集成测试(issue #11) + +启动 tests/integration/fixtures/multiworker 下的 fixture 应用(workers=2), +通过 HTTP 与临时文件断言: + +1. 每个 worker 进程独立实例化 client(实例 id 与创建 pid 互不相同) +2. @on_worker_start 钩子在每个 worker 各触发一次 +3. 调度任务只在 primary worker 执行 +4. (POSIX)@on_worker_stop 钩子在优雅关闭时每个 worker 各触发一次; + Windows 下父进程 terminate() 硬终止 worker,跳过 stop 断言 + +运行方式: uv run pytest tests/integration -m integration +""" + +import json +import os +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "multiworker" +WORKERS = 2 +STARTUP_TIMEOUT = 120 # spawn 模式启动较慢,宽松等待(轮询,就绪即继续) + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _http_get_json(url: str, timeout: float = 5.0): + with urllib.request.urlopen(url, timeout=timeout) as resp: + payload = json.loads(resp.read().decode("utf-8")) + # ResponseFormatterMiddleware 会包一层 {success, code, message, data} + if isinstance(payload, dict) and "data" in payload and "success" in payload: + return payload["data"] + return payload + + +def _kill_process_tree(proc: subprocess.Popen) -> None: + """终止 fixture 父进程及其全部 worker 子进程""" + if proc.poll() is not None: + return + try: + if sys.platform == "win32": + subprocess.run( + ["taskkill", "/PID", str(proc.pid), "/T", "/F"], + capture_output=True, + timeout=30, + ) + else: + import signal + + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except Exception: + proc.kill() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + pass + + +def _tail(path: Path, lines: int = 60) -> str: + try: + content = path.read_text(encoding="utf-8", errors="replace").splitlines() + return "\n".join(content[-lines:]) + except OSError: + return "" + + +@pytest.fixture +def fixture_app(tmp_path): + """启动 workers=2 的 fixture 应用,返回 (port, hook_dir, job_dir, proc, log)""" + port = _free_port() + hook_dir = tmp_path / "hooks" + job_dir = tmp_path / "jobs" + hook_dir.mkdir() + job_dir.mkdir() + log_path = tmp_path / "fixture_app.log" + + env = { + **os.environ, + "MW_PORT": str(port), + "MW_WORKERS": str(WORKERS), + "MW_HOOK_DIR": str(hook_dir), + "MW_JOB_DIR": str(job_dir), + } + popen_kwargs = {} + if sys.platform != "win32": + popen_kwargs["start_new_session"] = True + + log_file = open(log_path, "w", encoding="utf-8") + proc = subprocess.Popen( + [sys.executable, "main.py"], + cwd=str(FIXTURE_DIR), + env=env, + stdout=log_file, + stderr=subprocess.STDOUT, + **popen_kwargs, + ) + try: + yield port, hook_dir, job_dir, proc, log_path + finally: + _kill_process_tree(proc) + log_file.close() + + +def _wait_for_startup(port, hook_dir, proc, log_path) -> None: + """轮询等待:两个 worker 的 start 钩子文件出现且 /health 可访问""" + deadline = time.time() + STARTUP_TIMEOUT + health_ok = False + while time.time() < deadline: + if proc.poll() is not None: + pytest.fail( + f"fixture 应用提前退出 (returncode={proc.returncode})," + f"日志尾部:\n{_tail(log_path)}" + ) + start_files = list(hook_dir.glob("start_*.txt")) + if not health_ok: + try: + _http_get_json(f"http://127.0.0.1:{port}/health", timeout=2) + health_ok = True + except (urllib.error.URLError, OSError, ValueError): + pass + if health_ok and len(start_files) >= WORKERS: + return + time.sleep(0.5) + pytest.fail( + f"启动超时: health_ok={health_ok}, " + f"start_hooks={len(list(hook_dir.glob('start_*.txt')))}/{WORKERS}," + f"日志尾部:\n{_tail(log_path)}" + ) + + +def _read_start_records(hook_dir: Path): + """解析 start 钩子记录: {pid: (worker_id, client_instance_id)}""" + records = {} + duplicate_lines = [] + for path in hook_dir.glob("start_*.txt"): + lines = [ + line for line in + path.read_text(encoding="utf-8").splitlines() if line.strip() + ] + if len(lines) != 1: + duplicate_lines.append((path.name, lines)) + pid_str, worker_id_str, instance_id = lines[0].split(",") + records[int(pid_str)] = (int(worker_id_str), instance_id) + assert not duplicate_lines, f"start 钩子在同一 worker 内触发多次: {duplicate_lines}" + return records + + +@pytest.mark.integration +def test_multiworker_per_worker_instances_hooks_and_scheduler(fixture_app): + port, hook_dir, job_dir, proc, log_path = fixture_app + _wait_for_startup(port, hook_dir, proc, log_path) + + # ---- 1. start 钩子:每个 worker 各触发一次,worker_id 覆盖 1..N ---- + start_records = _read_start_records(hook_dir) + assert len(start_records) == WORKERS, f"期望 {WORKERS} 个 worker 触发 start 钩子: {start_records}" + worker_ids = sorted(wid for wid, _ in start_records.values()) + assert worker_ids == list(range(1, WORKERS + 1)), start_records + + # ---- 2. client 实例独立:不同 worker 的实例 id 互不相同 ---- + instance_ids = [iid for _, iid in start_records.values()] + assert all(instance_ids), f"worker 内未解析到 client 实例: {start_records}" + assert len(set(instance_ids)) == WORKERS, ( + f"不同 worker 共享了同一 client 实例(issue #11 回归): {start_records}" + ) + + # ---- 3. HTTP 轮询 /whoami:pid -> 实例 id 是单射,且实例创建于本进程 ---- + observed = {} # pid -> set(instance_id) + for _ in range(50): + data = _http_get_json(f"http://127.0.0.1:{port}/api/whoami") + pid = data["pid"] + observed.setdefault(pid, set()).add(data["client_instance_id"]) + # client 实例必须创建于响应请求的同一进程(而非 fork 前的父进程) + assert data["client_created_pid"] == pid, data + # 响应进程必须是已知的 worker 进程,且实例 id 与钩子记录一致 + assert pid in start_records, (pid, start_records) + assert data["client_instance_id"] == start_records[pid][1], data + assert data["worker_id"] == start_records[pid][0], data + for pid, ids in observed.items(): + assert len(ids) == 1, f"同一 worker 返回了多个 client 实例 id: {pid} -> {ids}" + # 注:Windows 下多 socket 绑定同端口(SO_REUSEADDR)不保证连接均匀分发, + # HTTP 观测到的 pid 数量不做强制断言;跨 worker 独立性已由钩子记录覆盖 + + # ---- 4. 调度任务仅 primary worker 执行 ---- + deadline = time.time() + 30 + while time.time() < deadline and not list(job_dir.glob("job_*.txt")): + time.sleep(0.5) + job_files = list(job_dir.glob("job_*.txt")) + assert job_files, f"调度任务未执行,日志尾部:\n{_tail(log_path)}" + # 再观察几秒,确认没有其他 worker 也在跑任务 + time.sleep(3) + job_pids = sorted( + int(path.stem.split("_")[1]) for path in job_dir.glob("job_*.txt") + ) + primary_pid = next( + pid for pid, (wid, _) in start_records.items() if wid == 1 + ) + assert job_pids == [primary_pid], ( + f"调度任务应只在 primary worker (pid={primary_pid}) 执行," + f"实际执行进程: {job_pids}" + ) + + # ---- 4b. all_workers=True 任务在每个 worker 都执行 ---- + deadline = time.time() + 30 + while time.time() < deadline and len(list(job_dir.glob("awjob_*.txt"))) < WORKERS: + time.sleep(0.5) + awjob_pids = sorted( + int(path.stem.split("_")[1]) for path in job_dir.glob("awjob_*.txt") + ) + assert awjob_pids == sorted(start_records.keys()), ( + f"all_workers 任务应在全部 worker 执行 (pids={sorted(start_records.keys())})," + f"实际执行进程: {awjob_pids},日志尾部:\n{_tail(log_path)}" + ) + + # ---- 5. (POSIX)优雅关闭后 stop 钩子每个 worker 各触发一次 ---- + if sys.platform != "win32": + proc.terminate() # SIGTERM -> 优雅关闭 -> lifespan 关闭阶段 + try: + proc.wait(timeout=30) + except subprocess.TimeoutExpired: + pass + deadline = time.time() + 15 + while time.time() < deadline and len(list(hook_dir.glob("stop_*.txt"))) < WORKERS: + time.sleep(0.5) + stop_files = list(hook_dir.glob("stop_*.txt")) + assert len(stop_files) == WORKERS, ( + f"期望 {WORKERS} 个 worker 触发 stop 钩子,实际 {len(stop_files)}" + ) + # Windows: 父进程 terminate() 硬终止 worker,stop 钩子不保证触发,跳过断言 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_all_workers_jobs.py b/tests/unit/test_all_workers_jobs.py new file mode 100644 index 0000000..fe5c209 --- /dev/null +++ b/tests/unit/test_all_workers_jobs.py @@ -0,0 +1,173 @@ +""" +定时任务任务级 all_workers 支持(F4)单元测试 + +覆盖: +1. @cron/@interval/@once 的 all_workers 元数据写入(顶层键,默认 False) +2. 注册门控:非 primary worker 只注册 all_workers=True 的任务 +3. scheduler.on_all_workers=true 全局配置:非 primary 注册全部任务 +4. scheduler.enabled=false:任何 worker 都不注册任何任务 +""" + +import os + +import pytest + +from myboot.core.application import Application +from myboot.core.auto_configuration import AutoConfigurationManager +from myboot.core.decorators import cron, interval, once + + +# --------------------------------------------------------------------------- +# 公共 fixture(与 test_multiworker.py 保持一致) +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _clean_worker_env(): + """每个测试前后清理 MYBOOT_* 环境变量""" + saved = {k: v for k, v in os.environ.items() if k.startswith("MYBOOT_")} + for key in saved: + del os.environ[key] + yield + for key in [k for k in os.environ if k.startswith("MYBOOT_")]: + del os.environ[key] + os.environ.update(saved) + + +@pytest.fixture +def set_config(): + """临时修改全局配置,测试结束后恢复原值""" + from myboot.core.config import get_settings + + settings = get_settings() + changed = {} + + def _set(key, value): + if key not in changed: + changed[key] = settings.get(key) + settings.set(key, value) + + yield _set + + for key, old in changed.items(): + settings.set(key, old) + + +# --------------------------------------------------------------------------- +# 测试用组件:一个 all_workers 任务 + 一个默认任务 +# --------------------------------------------------------------------------- + +class JobsComponent: + @interval(seconds=60, all_workers=True) + def refresh_local_cache(self): + pass + + @interval(seconds=60) + def primary_only_job(self): + pass + + +def _register(app): + manager = AutoConfigurationManager() + instance = JobsComponent() + manager._register_component_jobs(app, instance, JobsComponent, "tests.jobs") + return app.scheduler.list_jobs() + + +# --------------------------------------------------------------------------- +# 1. 装饰器元数据 +# --------------------------------------------------------------------------- + +class TestAllWorkersMetadata: + def test_cron_all_workers_top_level(self): + @cron("0 0 * * *", all_workers=True) + def job(): + pass + + meta = job.__myboot_job__ + assert meta["all_workers"] is True + assert "all_workers" not in meta["kwargs"] + + def test_interval_all_workers_top_level(self): + @interval(seconds=30, all_workers=True) + def job(): + pass + + meta = job.__myboot_job__ + assert meta["all_workers"] is True + assert "all_workers" not in meta["kwargs"] + + def test_once_all_workers_top_level(self): + @once("2099-01-01 00:00:00", all_workers=True) + def job(): + pass + + meta = job.__myboot_job__ + assert meta["all_workers"] is True + assert "all_workers" not in meta["kwargs"] + + def test_all_workers_defaults_to_false(self): + @cron("0 0 * * *") + def cron_job(): + pass + + @interval(seconds=1) + def interval_job(): + pass + + @once("2099-01-01 00:00:00") + def once_job(): + pass + + assert cron_job.__myboot_job__["all_workers"] is False + assert interval_job.__myboot_job__["all_workers"] is False + assert once_job.__myboot_job__["all_workers"] is False + + +# --------------------------------------------------------------------------- +# 2. 注册门控 +# --------------------------------------------------------------------------- + +class TestRegistrationGate: + def test_non_primary_registers_only_all_workers_jobs(self): + os.environ["MYBOOT_IS_PRIMARY_WORKER"] = "0" + app = Application(name="aw-nonprimary", auto_configuration=False) + assert app.is_primary_worker is False + + job_ids = _register(app) + assert len(job_ids) == 1 + assert "refresh_local_cache" in job_ids[0] + # 非 primary worker 因 all_workers 任务而启用调度器实例 + assert app.scheduler.is_enabled() is True + + def test_primary_registers_all_jobs(self): + os.environ["MYBOOT_IS_PRIMARY_WORKER"] = "1" + app = Application(name="aw-primary", auto_configuration=False) + assert app.is_primary_worker is True + + job_ids = _register(app) + assert len(job_ids) == 2 + assert any("refresh_local_cache" in j for j in job_ids) + assert any("primary_only_job" in j for j in job_ids) + + def test_on_all_workers_config_registers_all_on_non_primary(self, set_config): + set_config("scheduler.on_all_workers", True) + os.environ["MYBOOT_IS_PRIMARY_WORKER"] = "0" + app = Application(name="aw-globalcfg", auto_configuration=False) + + job_ids = _register(app) + assert len(job_ids) == 2 + assert app.scheduler.is_enabled() is True + + def test_scheduler_disabled_registers_nothing(self, set_config): + set_config("scheduler.enabled", False) + + # primary worker 也不注册 + app = Application(name="aw-disabled-primary", auto_configuration=False) + assert _register(app) == [] + assert app.scheduler.is_enabled() is False + + # 非 primary worker(含 all_workers 任务)同样不注册 + os.environ["MYBOOT_IS_PRIMARY_WORKER"] = "0" + app2 = Application(name="aw-disabled-nonprimary", auto_configuration=False) + assert _register(app2) == [] + assert app2.scheduler.is_enabled() is False diff --git a/tests/unit/test_client_auto_close.py b/tests/unit/test_client_auto_close.py new file mode 100644 index 0000000..6cc2f6d --- /dev/null +++ b/tests/unit/test_client_auto_close.py @@ -0,0 +1,110 @@ +""" +0.2.x F2:lifespan 关闭阶段自动调用 client.close() + +约定:app.clients 中具有可调用 close() 方法的实例,在 worker_stop_hooks +之后、shutdown_hooks 之前由框架兜底关闭;close 异常降为 warning 不阻断。 +""" + +import pytest +from fastapi.testclient import TestClient + +from myboot.core.application import Application + + +@pytest.fixture +def make_app(tmp_path, monkeypatch): + """构造禁用自动配置的最小 Application""" + import myboot.core.config as config_module + + monkeypatch.setattr(config_module, "_find_project_root", lambda: str(tmp_path)) + config_module.reload_config() + + def _make(): + return Application(name="auto-close-test", auto_configuration=False) + + yield _make + config_module.reload_config() + + +class _ClosableClient: + def __init__(self): + self.closed = 0 + + def close(self): + self.closed += 1 + + +class _NoCloseClient: + pass + + +class _FailingClient: + def close(self): + raise RuntimeError("boom") + + +def _run_lifespan(app: Application) -> None: + """走一遍完整 lifespan(TestClient 上下文进入/退出即 startup/shutdown)""" + with TestClient(app.get_fastapi_app()): + pass + + +def test_client_with_close_is_auto_closed(make_app): + app = make_app() + client = _ClosableClient() + app.clients["closable"] = client + + _run_lifespan(app) + + assert client.closed == 1 + + +def test_client_without_close_is_skipped(make_app): + app = make_app() + app.clients["no_close"] = _NoCloseClient() + + _run_lifespan(app) # 不抛错即通过 + + +def test_failing_close_does_not_block_others(make_app): + app = make_app() + good = _ClosableClient() + # dict 保序:失败的排在前面,验证不阻断后续 + app.clients["bad"] = _FailingClient() + app.clients["good"] = good + + _run_lifespan(app) + + assert good.closed == 1 + + +def test_close_runs_after_worker_stop_hooks_before_shutdown_hooks(make_app): + app = make_app() + order = [] + + class _OrderClient: + def close(self): + order.append("client_close") + + app.clients["order"] = _OrderClient() + app.add_worker_stop_hook(lambda: order.append("worker_stop")) + app.add_shutdown_hook(lambda: order.append("shutdown")) + + _run_lifespan(app) + + assert order == ["worker_stop", "client_close", "shutdown"] + + +def test_async_close_supported(make_app): + app = make_app() + closed = [] + + class _AsyncClient: + async def close(self): + closed.append(True) + + app.clients["async"] = _AsyncClient() + + _run_lifespan(app) + + assert closed == [True] diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..eced8e1 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,598 @@ +""" +配置系统特征测试(characterization tests) + +固化 myboot.core.config 的「当前实际行为」,作为后续重构的兼容性闸门。 +所有断言以代码现状为准(已用探针脚本逐项验证),而非文档或「理想行为」。 + +已知可疑现状(详见各测试注释,重构时务必先确认再改): + +1. ``create_settings()`` 把 ``default_settings={...}`` 作为普通 kwarg 传给 + Dynaconf。Dynaconf 没有这个选项,于是它变成了一个名为 + ``DEFAULT_SETTINGS`` 的普通配置项——内置默认值并 **不会** 出现在 + ``app.name`` / ``server.port`` 等路径上(与 docs/configuration.md 2.3 节相悖)。 +2. 由 1 推论:在没有任何 YAML 声明对应键时,``SERVER__PORT`` 等环境变量 + 会被 ``ignore_unknown_envvars=True`` 静默忽略(文档 5.6 声称内置默认键 + 可直接用 .env 覆盖,实际不行)。 +3. 同目录同时存在 ``config.yaml`` 与 ``config.yml`` 时,``.yml`` 后加载, + **覆盖** ``.yaml`` 的同名键。 +4. ``merge_enabled=True`` 下,多文件中同路径的 list 会拼接(可能重复), + 嵌套 dict 深合并;``dynaconf_merge: false`` 则整块替换(issue #13)。 +5. ``env_parse_values=True`` 下,``"1"`` 解析为 int 1 而非 bool True。 +6. 环境变量提供的 JSON 列表会 **整体替换** YAML 中的列表(不拼接)。 + +注意:``_find_project_root()`` 以 config.py 所在包向上找 pyproject.toml, +与进程工作目录无关(仓库内运行时恒为仓库根)。因此仅靠 +``monkeypatch.chdir(tmp_path)`` 无法隔离根 config.yaml 查找,必须同时 +monkeypatch ``_find_project_root``。 +""" + +import hashlib +import os +import tempfile +import textwrap +import uuid + +import pytest + +import myboot.core.config as config_module +from myboot.core.config import ( + _get_config_files, + _is_url, + create_settings, + get_config, + get_config_bool, + get_config_int, + get_config_str, + get_settings, + reload_config, +) + + +@pytest.fixture(autouse=True) +def isolate_config(tmp_path, monkeypatch): + """每个测试独立的「项目根」+ 干净的全局单例 / CONFIG_FILE。 + + - monkeypatch _find_project_root:见模块 docstring,仅 chdir 不够。 + - chdir 仍然保留,防止任何依赖 cwd 的退路逻辑读到真实仓库。 + - 前后都清空模块级单例 _settings,避免测试间污染。 + """ + monkeypatch.setattr(config_module, "_find_project_root", lambda: str(tmp_path)) + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("CONFIG_FILE", raising=False) + reload_config() + yield + reload_config() + + +def write_yaml(path, content): + """写入 YAML 文件(自动 dedent / 建父目录),返回 str 路径""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(content), encoding="utf-8") + return str(path) + + +# --------------------------------------------------------------------------- +# 1. 多源配置文件优先级 +# --------------------------------------------------------------------------- + + +class TestConfigFilePriority: + def test_root_config_yaml_loaded(self, tmp_path): + write_yaml(tmp_path / "config.yaml", """\ + app: + name: root-app + """) + settings = create_settings() + assert settings.get("app.name") == "root-app" + + def test_conf_dir_overrides_root(self, tmp_path): + write_yaml(tmp_path / "config.yaml", """\ + app: + name: root-app + server: + port: 1111 + only_root: 1 + """) + write_yaml(tmp_path / "conf" / "config.yaml", """\ + app: + name: conf-app + server: + port: 2222 + """) + settings = create_settings() + # conf/config.yaml 后加载,覆盖根目录同名标量 + assert settings.get("app.name") == "conf-app" + assert settings.get("server.port") == 2222 + # 仅根目录出现的键仍然保留(merge_enabled=True) + assert settings.get("only_root") == 1 + + def test_param_config_file_overrides_conf(self, tmp_path, tmp_config_file): + write_yaml(tmp_path / "conf" / "config.yaml", """\ + app: + name: conf-app + only_conf: 2 + """) + param = tmp_config_file( + """\ + app: + name: param-app + """, + filename="param.yaml", + ) + settings = create_settings(param) + assert settings.get("app.name") == "param-app" + assert settings.get("only_conf") == 2 + + def test_config_file_env_var_overrides_param( + self, tmp_path, tmp_config_file, monkeypatch + ): + param = tmp_config_file( + """\ + app: + name: param-app + only_param: 3 + """, + filename="param.yaml", + ) + env_file = tmp_config_file( + """\ + app: + name: envfile-app + """, + filename="envfile.yaml", + ) + monkeypatch.setenv("CONFIG_FILE", env_file) + settings = create_settings(param) + # CONFIG_FILE 指定的文件最后加载,优先级最高(文件来源中) + assert settings.get("app.name") == "envfile-app" + assert settings.get("only_param") == 3 + + def test_get_config_files_order(self, tmp_path, monkeypatch): + """固化 _get_config_files 返回的加载顺序:根 → conf → 参数 → CONFIG_FILE""" + root = write_yaml(tmp_path / "config.yaml", "a: 1\n") + conf = write_yaml(tmp_path / "conf" / "config.yaml", "a: 2\n") + param = write_yaml(tmp_path / "param.yaml", "a: 3\n") + envf = write_yaml(tmp_path / "envfile.yaml", "a: 4\n") + monkeypatch.setenv("CONFIG_FILE", envf) + assert _get_config_files(param) == [root, conf, param, envf] + + def test_nonexistent_param_file_silently_dropped(self, tmp_path): + write_yaml(tmp_path / "config.yaml", "app:\n name: root-app\n") + # 不存在的文件路径不报错,静默跳过 + settings = create_settings(str(tmp_path / "no-such-file.yaml")) + assert settings.get("app.name") == "root-app" + + def test_yml_overrides_yaml_in_same_dir(self, tmp_path): + """可疑现状 #3:config.yml 排在 config.yaml 之后加载,.yml 覆盖 .yaml。 + + 直觉上 .yaml 是「主」扩展名,但实际 .yml 同名键胜出。 + """ + write_yaml(tmp_path / "config.yaml", "app:\n name: from-yaml\n") + write_yaml(tmp_path / "config.yml", "app:\n name: from-yml\n") + settings = create_settings() + assert settings.get("app.name") == "from-yml" + + +# --------------------------------------------------------------------------- +# 2. 内置默认值(0.2.0 修复:默认值以大写 kwargs 传入 Dynaconf) +# --------------------------------------------------------------------------- + + +class TestDefaultSettings: + def test_builtin_defaults_exposed_at_documented_paths(self): + """0.2.0 修复:无任何 YAML 时内置默认值在文档路径上可取到。 + + 旧版把 default_settings={...} 当 kwarg 传给 Dynaconf(无此参数), + 默认值整体失效;现以大写 kwargs(APP=/SERVER=...)注册为默认配置项。 + """ + settings = create_settings() + assert settings.get("app.name") == "MyBoot App" + assert settings.get("server.port") == 8000 + assert settings.get("logging.level") == "INFO" + assert settings.get("scheduler.enabled") is True + assert settings.get("scheduler.max_workers") == 10 + + def test_no_stray_default_settings_key(self): + """修复后不再产生名为 DEFAULT_SETTINGS 的杂散配置项""" + settings = create_settings() + assert "DEFAULT_SETTINGS" not in settings + + def test_yaml_partial_override_deep_merges_with_defaults(self, tmp_path): + """YAML 只覆盖 server.port 时,server 其余默认键保留(深合并)""" + write_yaml(tmp_path / "config.yaml", "server:\n port: 9000\n") + settings = create_settings() + assert settings.get("server.port") == 9000 + assert settings.get("server.host") == "0.0.0.0" + assert settings.get("server.workers") == 1 + + +# --------------------------------------------------------------------------- +# 3. 环境变量 `__` 分隔符覆盖 +# --------------------------------------------------------------------------- + + +class TestEnvVarOverride: + def test_double_underscore_overrides_declared_key(self, tmp_path, monkeypatch): + # envvar_prefix=False:变量名即配置路径大写形式,无前缀 + write_yaml(tmp_path / "config.yaml", """\ + app: + name: yaml-app + """) + monkeypatch.setenv("APP__NAME", "env-app") + settings = create_settings() + assert settings.get("app.name") == "env-app" + + def test_env_var_beats_config_file_env_source( + self, tmp_path, tmp_config_file, monkeypatch + ): + env_file = tmp_config_file( + """\ + app: + name: from-config-file + """, + filename="cf.yaml", + ) + monkeypatch.setenv("CONFIG_FILE", env_file) + monkeypatch.setenv("APP__NAME", "from-envvar") + settings = create_settings() + # 环境变量键 > CONFIG_FILE 文件 + assert settings.get("app.name") == "from-envvar" + + def test_undeclared_env_key_silently_ignored(self, tmp_path, monkeypatch): + # ignore_unknown_envvars=True:YAML 中没有 database 段时被静默忽略 + write_yaml(tmp_path / "config.yaml", "app:\n name: yaml-app\n") + monkeypatch.setenv("DATABASE__URL", "postgresql://ignored") + settings = create_settings() + assert settings.get("database.url") is None + + def test_undeclared_leaf_under_declared_parent_also_ignored( + self, tmp_path, monkeypatch + ): + # server 段存在但 server.custom_flag 既未在 YAML 也未在内置默认值中 + # 声明 → SERVER__CUSTOM_FLAG 被 ignore_unknown_envvars 忽略 + # (注意 server.reload 已是内置默认键,不能再用作"未声明"示例) + write_yaml(tmp_path / "config.yaml", """\ + server: + port: 8000 + """) + monkeypatch.setenv("SERVER__CUSTOM_FLAG", "true") + settings = create_settings() + assert settings.get("server.custom_flag") is None + + def test_env_var_overrides_builtin_defaults_without_yaml(self, monkeypatch): + """0.2.0 修复:内置默认键生效后,无 YAML 时也可用环境变量覆盖 + (与 docs/configuration.md 5.6 节一致)""" + monkeypatch.setenv("SERVER__PORT", "7777") + monkeypatch.setenv("APP__NAME", "env-only") + settings = create_settings() + assert settings.get("server.port") == 7777 + assert settings.get("app.name") == "env-only" + + +# --------------------------------------------------------------------------- +# 4. 类型自动转换(env_parse_values=True) +# --------------------------------------------------------------------------- + + +class TestEnvVarTypeConversion: + @pytest.fixture(autouse=True) + def base_yaml(self, tmp_path): + write_yaml(tmp_path / "config.yaml", """\ + app: + debug: false + server: + port: 8000 + reload: false + cors: + allow_origins: ["*"] + """) + + def test_numeric_string_to_int(self, monkeypatch): + monkeypatch.setenv("SERVER__PORT", "9090") + settings = create_settings() + value = settings.get("server.port") + assert value == 9090 + assert isinstance(value, int) + + def test_true_false_strings_to_bool(self, monkeypatch): + monkeypatch.setenv("SERVER__RELOAD", "true") + settings = create_settings() + assert settings.get("server.reload") is True + + monkeypatch.setenv("SERVER__RELOAD", "false") + settings = create_settings() + assert settings.get("server.reload") is False + + def test_string_one_parses_as_int_not_bool(self, monkeypatch): + """可疑现状 #5:"1" 被解析为 int 1,不是 bool True。 + + 依赖 `settings.get("app.debug") is True` 的调用方会受影响; + get_config_bool 仍能正确转换(见 TestHelperFunctions)。 + """ + monkeypatch.setenv("APP__DEBUG", "1") + settings = create_settings() + value = settings.get("app.debug") + assert value == 1 + assert not isinstance(value, bool) + assert isinstance(value, int) + + def test_json_list_string_parsed_and_replaces_yaml_list(self, monkeypatch): + """可疑现状 #6:env 提供的 JSON 列表整体替换 YAML 列表, + 不与原列表 ["*"] 拼接(与多文件加载时的列表拼接行为不同)。 + """ + monkeypatch.setenv( + "SERVER__CORS__ALLOW_ORIGINS", '["http://a", "http://b"]' + ) + settings = create_settings() + assert list(settings.get("server.cors.allow_origins")) == [ + "http://a", + "http://b", + ] + + +# --------------------------------------------------------------------------- +# 5. 合并行为与 dynaconf_merge: false(issue #13 回归) +# --------------------------------------------------------------------------- + + +class TestMergeBehavior: + def _write_base_cors(self, tmp_path): + write_yaml(tmp_path / "config.yaml", """\ + server: + cors: + allow_origins: ["*"] + allow_methods: ["*"] + allow_headers: ["*"] + """) + + def test_nested_dict_deep_merge_keeps_sibling_keys(self, tmp_path): + # merge_enabled=True:后加载文件只写部分子键时,旧子键保留 + self._write_base_cors(tmp_path) + write_yaml(tmp_path / "conf" / "config.yaml", """\ + server: + cors: + allow_origins: ["http://x"] + """) + settings = create_settings() + cors = settings.get("server.cors") + assert list(cors["allow_methods"]) == ["*"] + assert list(cors["allow_headers"]) == ["*"] + + def test_list_values_concatenate_across_files(self, tmp_path): + """可疑现状 #4:同路径列表跨文件「拼接」而非替换,可能产生意外元素。 + + 基底 ["*"] + 覆盖文件 ["http://x"] → ["*", "http://x"], + 通配符 "*" 仍然留在 CORS origins 里。 + """ + self._write_base_cors(tmp_path) + write_yaml(tmp_path / "conf" / "config.yaml", """\ + server: + cors: + allow_origins: ["http://x"] + """) + settings = create_settings() + assert list(settings.get("server.cors.allow_origins")) == ["*", "http://x"] + + def test_dynaconf_merge_false_replaces_whole_dict(self, tmp_path): + """issue #13 回归:dynaconf_merge: false 时嵌套 dict 整块替换, + 先前的 allow_methods / allow_headers 不再保留。 + """ + self._write_base_cors(tmp_path) + write_yaml(tmp_path / "conf" / "config.yaml", """\ + server: + cors: + allow_origins: ["http://x"] + dynaconf_merge: false + """) + settings = create_settings() + cors = settings.get("server.cors") + assert cors.to_dict() == {"allow_origins": ["http://x"]} + + def test_dynaconf_merge_marker_not_exposed_as_config_key(self, tmp_path): + self._write_base_cors(tmp_path) + write_yaml(tmp_path / "conf" / "config.yaml", """\ + server: + cors: + allow_origins: ["http://x"] + dynaconf_merge: false + """) + settings = create_settings() + # 合并控制元数据不会作为业务键出现 + assert settings.get("server.cors.dynaconf_merge") is None + + +# --------------------------------------------------------------------------- +# 6. 缺失键默认值 / 便捷函数 / 大小写 +# --------------------------------------------------------------------------- + + +class TestHelperFunctions: + @pytest.fixture(autouse=True) + def base_yaml(self, tmp_path): + write_yaml(tmp_path / "config.yaml", """\ + app: + name: helper-app + server: + port: 8000 + flags: + str_yes: "yes" + str_other: "definitely" + items: + - a + - b + """) + reload_config() # 便捷函数走全局单例,确保读到本测试的 YAML + + def test_get_missing_key_returns_default(self): + assert get_config("no.such.key", "fallback") == "fallback" + + def test_get_missing_key_default_none(self): + assert get_config("no.such.key") is None + + def test_get_config_str_converts(self): + assert get_config_str("server.port") == "8000" + assert get_config_str("no.such.key", "dft") == "dft" + + def test_get_config_int_converts_and_falls_back(self): + assert get_config_int("server.port") == 8000 + # 列表无法转 int → 返回 default(当前实现吞掉 TypeError) + assert get_config_int("items", 42) == 42 + assert get_config_int("no.such.key", 7) == 7 + + def test_get_config_bool_string_semantics(self): + # 字符串仅 'true'/'1'/'yes'/'on'(不区分大小写)为真 + assert get_config_bool("flags.str_yes") is True + assert get_config_bool("flags.str_other") is False + assert get_config_bool("no.such.key", True) is True + + def test_get_is_case_insensitive(self): + settings = get_settings() + assert settings.get("APP.NAME") == "helper-app" + assert settings.get("app.name") == "helper-app" + + +class TestSingletonBehavior: + def test_get_settings_ignores_config_file_after_first_call( + self, tmp_path, tmp_config_file + ): + write_yaml(tmp_path / "config.yaml", "app:\n name: first\n") + other = tmp_config_file("app:\n name: second\n", filename="other.yaml") + + first = get_settings() + assert first.get("app.name") == "first" + # 单例已固定,后续传入不同 config_file 不生效 + second = get_settings(other) + assert second is first + assert second.get("app.name") == "first" + + def test_reload_config_resets_singleton(self, tmp_path, tmp_config_file): + write_yaml(tmp_path / "config.yaml", "app:\n name: first\n") + other = tmp_config_file("app:\n name: second\n", filename="other.yaml") + + first = get_settings() + reload_config() + second = get_settings(other) + assert second is not first + assert second.get("app.name") == "second" + + +# --------------------------------------------------------------------------- +# 7. 远程配置(HTTP URL,mock requests) +# --------------------------------------------------------------------------- + + +def _cache_path_for(url): + cache_dir = os.path.join(tempfile.gettempdir(), "myboot_config_cache") + return os.path.join(cache_dir, hashlib.md5(url.encode()).hexdigest() + ".yaml") + + +@pytest.fixture +def remote_url(): + """每个测试唯一的 URL,避免共享缓存目录(系统临时目录)相互污染""" + url = f"https://config.example.invalid/{uuid.uuid4().hex}.yaml" + yield url + cache = _cache_path_for(url) + if os.path.exists(cache): + os.remove(cache) + + +class TestDotenvAutoLoad: + """0.2.x F1:项目根 .env 自动加载(Dynaconf load_dotenv)""" + + @pytest.fixture(autouse=True) + def isolate_environ(self): + # load_dotenv 会写 os.environ;快照-恢复整个 environ 避免污染后续测试 + # (不能用 monkeypatch.setattr 替换 os.environ —— dotenv/dynaconf + # 内部持有原始 _Environ 对象引用,替换属性不生效) + saved = dict(os.environ) + yield + os.environ.clear() + os.environ.update(saved) + + def test_dotenv_loaded_from_project_root(self, tmp_path): + (tmp_path / ".env").write_text("APP__NAME=dotenv-app\n", encoding="utf-8") + settings = create_settings() + assert settings.get("app.name") == "dotenv-app" + + def test_real_env_var_beats_dotenv(self, tmp_path, monkeypatch): + # dotenv_override=False:真实环境变量优先于 .env + (tmp_path / ".env").write_text("APP__NAME=dotenv-app\n", encoding="utf-8") + monkeypatch.setenv("APP__NAME", "real-env") + settings = create_settings() + assert settings.get("app.name") == "real-env" + + def test_no_dotenv_file_is_fine(self, tmp_path): + settings = create_settings() + assert settings.get("app.name") == "MyBoot App" + + +class TestRemoteConfig: + def test_is_url_detection(self): + assert _is_url("http://example.com/c.yaml") + assert _is_url("https://example.com/c.yaml") + assert not _is_url("/etc/myboot/config.yaml") + assert not _is_url("ftp://example.com/c.yaml") + # 空字符串走 `path and ...` 短路,返回原值(falsy),不是 False + assert not _is_url("") + + def test_remote_config_downloaded_cached_and_loaded( + self, monkeypatch, mocker, remote_url + ): + response = mocker.Mock() + response.text = "app:\n name: remote-app\nremote_only: 42\n" + response.raise_for_status = mocker.Mock() + mock_get = mocker.patch.object( + config_module.requests, "get", return_value=response + ) + + monkeypatch.setenv("CONFIG_FILE", remote_url) + settings = create_settings() + + mock_get.assert_called_once_with(remote_url, timeout=30) + # 下载内容写入系统临时目录缓存(md5(url).yaml) + cache = _cache_path_for(remote_url) + assert os.path.exists(cache) + assert "remote-app" in open(cache, encoding="utf-8").read() + # 远程配置作为 CONFIG_FILE 来源参与合并 + assert settings.get("app.name") == "remote-app" + assert settings.get("remote_only") == 42 + + def test_download_failure_falls_back_to_existing_cache( + self, monkeypatch, mocker, remote_url + ): + cache = _cache_path_for(remote_url) + os.makedirs(os.path.dirname(cache), exist_ok=True) + with open(cache, "w", encoding="utf-8") as f: + f.write("app:\n name: cached-app\n") + + mocker.patch.object( + config_module.requests, + "get", + side_effect=ConnectionError("network down"), + ) + monkeypatch.setenv("CONFIG_FILE", remote_url) + settings = create_settings() + assert settings.get("app.name") == "cached-app" + + def test_download_failure_without_cache_raises( + self, monkeypatch, mocker, remote_url + ): + # 无缓存且下载失败 → 原始异常向上抛出(create_settings 直接失败) + mocker.patch.object( + config_module.requests, + "get", + side_effect=ValueError("network down"), + ) + monkeypatch.setenv("CONFIG_FILE", remote_url) + with pytest.raises(ValueError, match="network down"): + create_settings() + + def test_remote_param_config_file_also_supported(self, mocker, remote_url): + # config_file 参数同样支持 URL(不只 CONFIG_FILE 环境变量) + response = mocker.Mock() + response.text = "app:\n name: remote-param\n" + response.raise_for_status = mocker.Mock() + mocker.patch.object(config_module.requests, "get", return_value=response) + + settings = create_settings(remote_url) + assert settings.get("app.name") == "remote-param" diff --git a/tests/unit/test_di.py b/tests/unit/test_di.py new file mode 100644 index 0000000..f13c0ef --- /dev/null +++ b/tests/unit/test_di.py @@ -0,0 +1,498 @@ +# -*- coding: utf-8 -*- +""" +依赖注入子系统特征测试(characterization tests) + +固化 myboot.core.di(container/registry/providers/decorators)与 +myboot.core.decorators 中 @service/@client 的「当前实际行为」, +作为后续重构的兼容性闸门。 + +绝对规则:本文件只断言现状,不修改任何源码。 +可疑现状行为均在对应用例中以注释标注(搜索 "可疑现状")。 +""" + +import inspect +from typing import Optional + +import pytest + +from myboot.core.decorators import _camel_to_snake, client, service +from myboot.core.di.container import DependencyContainer +from myboot.core.di.decorators import Provide, get_injectable_params, inject +from myboot.core.di.providers import ServiceProvider +from myboot.core.di.registry import ServiceRegistry + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# 辅助类(前缀避开 Test,防止被 pytest 收集) +# --------------------------------------------------------------------------- + +class AlphaService: + """无依赖的服务""" + + def __init__(self): + self.tag = "alpha" + + +class BetaService: + """通过类型注解依赖 AlphaService""" + + def __init__(self, alpha_service: AlphaService): + self.alpha_service = alpha_service + + +class GammaService: + """通过字符串形式的 Provide 注解依赖 alpha_service""" + + def __init__(self, dep: "Provide['alpha_service']"): + self.dep = dep + + +# --------------------------------------------------------------------------- +# fixtures +# --------------------------------------------------------------------------- +# 说明:DI 子系统没有模块级单例——DependencyContainer / ServiceRegistry 都是 +# 普通类,每个测试构造新实例即可天然隔离。container fixture 在 teardown 时 +# 仍调用 clear() 以释放 dependency_injector 的 DynamicContainer 状态。 + + +@pytest.fixture +def registry(): + return ServiceRegistry() + + +@pytest.fixture +def container(): + c = DependencyContainer() + yield c + c.clear() + + +# --------------------------------------------------------------------------- +# 1. 服务注册:registry 中 dependencies / dependents 数据结构 +# --------------------------------------------------------------------------- + +class TestServiceRegistration: + def test_register_service_records_class_and_config(self, registry): + registry.register_service(AlphaService, "alpha_service", {"k": "v"}) + + assert registry.services == {"alpha_service": AlphaService} + assert registry.service_configs == {"alpha_service": {"k": "v"}} + assert registry.has_service("alpha_service") is True + assert registry.get_service_class("alpha_service") is AlphaService + assert registry.get_service_config("alpha_service") == {"k": "v"} + + def test_register_service_default_config_is_empty_dict(self, registry): + registry.register_service(AlphaService, "alpha_service") + assert registry.get_service_config("alpha_service") == {} + + def test_no_dependency_service_has_empty_sets(self, registry): + registry.register_service(AlphaService, "alpha_service") + + assert registry.dependencies == {"alpha_service": set()} + # register_service 通过 setdefault 为自身建立空 dependents 条目 + assert registry.dependents == {"alpha_service": set()} + + def test_dependency_recorded_in_both_directions(self, registry): + registry.register_service(AlphaService, "alpha_service") + registry.register_service(BetaService, "beta_service") + + assert registry.dependencies["beta_service"] == {"alpha_service"} + assert registry.dependencies["alpha_service"] == set() + assert registry.dependents["alpha_service"] == {"beta_service"} + assert registry.dependents["beta_service"] == set() + + assert registry.get_dependencies("beta_service") == {"alpha_service"} + assert registry.get_dependents("alpha_service") == {"beta_service"} + + def test_dependent_registered_first_creates_dependents_entry_for_unregistered_dep( + self, registry + ): + """先注册依赖方时,被依赖方(尚未注册)的 dependents 条目已被预先写入""" + registry.register_service(BetaService, "beta_service") + + assert "alpha_service" not in registry.services + assert registry.dependents["alpha_service"] == {"beta_service"} + + def test_get_dependencies_of_unknown_service_returns_empty_set(self, registry): + assert registry.get_dependencies("nonexistent") == set() + assert registry.get_dependents("nonexistent") == set() + + def test_reregister_resets_then_reanalyzes_dependencies(self, registry): + """可疑现状:register_service 无条件重置 dependencies[service_name], + 随后重新分析;对 dependents 中旧条目不做清理(只增不减)。""" + registry.register_service(BetaService, "beta_service") + registry.register_service(BetaService, "beta_service") + + assert registry.dependencies["beta_service"] == {"alpha_service"} + assert registry.dependents["alpha_service"] == {"beta_service"} + + +# --------------------------------------------------------------------------- +# 2. issue #9 回归测试:注册不得清空已记录的反向依赖(dependents) +# --------------------------------------------------------------------------- + +class TestIssue9DependentsNotOverwritten: + """issue #9:register_service 曾无条件 `self.dependents[name] = set()`, + 后注册的服务会清掉先前分析出的 dependents。 + 当前代码(registry.py:40)已改为 setdefault —— bug 已修复,以下断言修复行为。 + """ + + def test_registering_dependency_after_dependent_preserves_dependents( + self, registry + ): + # 先注册 B(依赖 A):此时分析出 A 的 dependents 含 B + registry.register_service(BetaService, "beta_service") + assert registry.dependents["alpha_service"] == {"beta_service"} + + # 再注册 A:A 的 dependents 中 B 必须仍然存在(修复后行为) + registry.register_service(AlphaService, "alpha_service") + assert "beta_service" in registry.dependents["alpha_service"], ( + "issue #9 回归:注册 alpha_service 清空了已记录的 dependents" + ) + assert registry.dependents["alpha_service"] == {"beta_service"} + + def test_initialization_order_correct_when_dependent_registered_first( + self, registry + ): + """dependents 保留后,拓扑排序能把 A 排在 B 之前""" + registry.register_service(BetaService, "beta_service") + registry.register_service(AlphaService, "alpha_service") + + order = registry.get_initialization_order() + assert order == ["alpha_service", "beta_service"] + + def test_injection_works_when_dependent_registered_first(self, container): + """端到端:先注册 B 再注册 A,构建容器后 B 仍能注入 A""" + container.register_service(BetaService, "beta_service") + container.register_service(AlphaService, "alpha_service") + container.build_container() + + beta = container.get_service("beta_service") + alpha = container.get_service("alpha_service") + assert isinstance(beta.alpha_service, AlphaService) + assert beta.alpha_service is alpha + + +# --------------------------------------------------------------------------- +# 3. 循环依赖检测 +# --------------------------------------------------------------------------- + +class CycleServiceA: + def __init__(self, dep: "Provide['cycle_b']"): + self.dep = dep + + +class CycleServiceB: + def __init__(self, dep: "Provide['cycle_a']"): + self.dep = dep + + +class TestCircularDependencyDetection: + def _register_cycle(self, registry): + registry.register_service(CycleServiceA, "cycle_a") + registry.register_service(CycleServiceB, "cycle_b") + + def test_detect_circular_dependencies_returns_cycle_chain(self, registry): + self._register_cycle(registry) + cycles = registry.detect_circular_dependencies() + + # DFS 按注册顺序从 cycle_a 出发,环以起点收尾 + assert cycles == [["cycle_a", "cycle_b", "cycle_a"]] + + def test_detect_returns_empty_list_when_no_cycle(self, registry): + registry.register_service(AlphaService, "alpha_service") + registry.register_service(BetaService, "beta_service") + assert registry.detect_circular_dependencies() == [] + + def test_register_alone_does_not_raise_on_cycle(self, registry): + """注册阶段不检测循环——只有取初始化顺序/构建容器时才报""" + self._register_cycle(registry) # 不抛异常即通过 + + def test_get_initialization_order_raises_value_error_with_cycle_chain( + self, registry + ): + self._register_cycle(registry) + with pytest.raises(ValueError) as exc_info: + registry.get_initialization_order() + + msg = str(exc_info.value) + assert "检测到循环依赖" in msg + assert "cycle_a -> cycle_b -> cycle_a" in msg + + def test_build_container_propagates_cycle_error(self): + container = DependencyContainer() + try: + container.register_service(CycleServiceA, "cycle_a") + container.register_service(CycleServiceB, "cycle_b") + with pytest.raises(ValueError, match="检测到循环依赖"): + container.build_container() + finally: + container.clear() + + def test_dependency_graph_is_cached_and_goes_stale(self, registry): + """可疑现状:build_dependency_graph 结果被缓存,且之后的 + register_service 不会使缓存失效——后注册的服务不会出现在图中, + 只有 clear() 才重置缓存。""" + registry.register_service(AlphaService, "alpha_service") + graph1 = registry.build_dependency_graph() + assert graph1 == {"alpha_service": set()} + + registry.register_service(BetaService, "beta_service") + graph2 = registry.build_dependency_graph() + assert "beta_service" not in graph2 # 缓存陈旧(现状如此) + assert graph2 is graph1 + + +# --------------------------------------------------------------------------- +# 4. 单例语义 +# --------------------------------------------------------------------------- + +class TestSingletonSemantics: + def test_singleton_get_twice_returns_same_instance(self, container): + container.register_service(AlphaService, "alpha_service") + container.build_container() + + first = container.get_service("alpha_service") + second = container.get_service("alpha_service") + assert first is second + # 单例实例被缓存在 service_instances + assert container.service_instances["alpha_service"] is first + + def test_default_scope_is_singleton(self, container): + container.register_service(AlphaService, "alpha_service") + assert ( + container.service_providers["alpha_service"].scope + == ServiceProvider.SINGLETON + ) + + def test_factory_scope_returns_new_instance_each_time(self, container): + container.register_service( + AlphaService, "alpha_service", scope=ServiceProvider.FACTORY + ) + container.build_container() + + first = container.get_service("alpha_service") + second = container.get_service("alpha_service") + assert first is not second + # 工厂模式不写入 service_instances 缓存 + assert "alpha_service" not in container.service_instances + + def test_singleton_dependency_shared_across_consumers(self, container): + container.register_service(AlphaService, "alpha_service") + container.register_service(BetaService, "beta_service") + container.register_service(GammaService, "gamma_service") + container.build_container() + + beta = container.get_service("beta_service") + gamma = container.get_service("gamma_service") + alpha = container.get_service("alpha_service") + assert beta.alpha_service is alpha + assert gamma.dep is alpha + + def test_get_unregistered_service_raises_key_error(self, container): + with pytest.raises(KeyError, match="未注册"): + container.get_service("nope") + + def test_get_registered_but_unbuilt_singleton_raises_runtime_error( + self, container + ): + """可疑现状:注册后未 build_container 就 get,报 RuntimeError + (提供者未配置),而不是更友好的提示。""" + container.register_service(AlphaService, "alpha_service") + with pytest.raises(RuntimeError, match="提供者未正确配置"): + container.get_service("alpha_service") + + def test_register_instance_returns_exact_object(self, container): + sentinel = AlphaService() + container.register_instance("external_alpha", sentinel) + + assert container.get_service("external_alpha") is sentinel + assert container.has_service("external_alpha") is True + assert "external_alpha" in container.registry.known_instances + + +# --------------------------------------------------------------------------- +# 5. @service / @client 装饰器元数据 +# --------------------------------------------------------------------------- + +class TestServiceClientDecorators: + # 注意:issue #11 多 worker 改造为 @service/@client 新增了顶层 scope 键 + # (默认 'singleton'),以下精确断言随之更新 + def test_service_decorator_sets_metadata_with_snake_case_name(self): + @service() + class OrderService: + pass + + assert OrderService.__myboot_service__ == { + "name": "order_service", + "scope": "singleton", + "kwargs": {}, + } + + def test_service_decorator_explicit_name_and_kwargs(self): + @service("custom_name", lazy=True) + class OrderService: + pass + + assert OrderService.__myboot_service__ == { + "name": "custom_name", + "scope": "singleton", + "kwargs": {"lazy": True}, + } + + def test_service_decorator_returns_same_class(self): + class PlainService: + pass + + decorated = service()(PlainService) + assert decorated is PlainService + + def test_client_decorator_sets_metadata(self): + @client() + class RedisClient: + pass + + assert RedisClient.__myboot_client__ == { + "name": "redis_client", + "scope": "singleton", + "kwargs": {}, + } + + def test_client_decorator_explicit_name(self): + @client("db", pool_size=10) + class DatabaseClient: + pass + + assert DatabaseClient.__myboot_client__ == { + "name": "db", + "scope": "singleton", + "kwargs": {"pool_size": 10}, + } + + def test_camel_to_snake_conversion(self): + assert _camel_to_snake("UserService") == "user_service" + assert _camel_to_snake("EmailService") == "email_service" + assert _camel_to_snake("HTTPClient") == "http_client" + assert _camel_to_snake("DatabaseClient") == "database_client" + + def test_inject_decorator_marks_function(self): + @inject + def init(self, dep): + return dep + + # @wraps 把原函数上的 __myboot_inject__ 拷贝到 wrapper + assert init.__myboot_inject__ is True + assert init(None, "x") == "x" + + def test_provide_class_getitem_returns_plain_string(self): + """Provide['name'] 求值结果就是普通字符串 'name'""" + assert Provide["user_service"] == "user_service" + assert isinstance(Provide["user_service"], str) + + +# --------------------------------------------------------------------------- +# 6. 依赖分析:get_injectable_params 对构造参数/注解的解析 +# --------------------------------------------------------------------------- + +class TestDependencyAnalysis: + def test_self_is_skipped(self): + params = get_injectable_params(AlphaService.__init__) + assert "self" not in params + assert params == {} + + def test_class_type_annotation_maps_to_snake_case_service_name(self): + params = get_injectable_params(BetaService.__init__) + info = params["alpha_service"] + assert info["type"] is AlphaService + assert info["service_name"] == "alpha_service" + assert info["is_optional"] is False + assert info["default"] is None + + def test_optional_annotation_unwraps_type_and_marks_optional(self): + class Consumer: + def __init__(self, dep: Optional[AlphaService] = None): + self.dep = dep + + info = get_injectable_params(Consumer.__init__)["dep"] + assert info["type"] is AlphaService + assert info["service_name"] == "alpha_service" + assert info["is_optional"] is True + assert info["default"] is None + + def test_string_provide_annotation_extracts_service_name(self): + """注解写成字符串 "Provide['x']" 时按字面解析出服务名""" + info = get_injectable_params(GammaService.__init__)["dep"] + assert info["service_name"] == "alpha_service" + assert info["type"] is None + assert info["is_optional"] is False + + def test_actual_provide_subscript_does_not_resolve_service_name(self): + """可疑现状:直接写 Provide['alpha_service'](不加引号,如 + di/decorators.py 文档示例所写)时,注解在类定义时即被求值为 + 普通字符串 'alpha_service',不匹配 "Provide['..." 前缀, + 最终 service_name 为 None(即文档示例写法实际不生效)。""" + + class Consumer: + def __init__(self, dep: Provide["alpha_service"]): + self.dep = dep + + info = get_injectable_params(Consumer.__init__)["dep"] + assert info["service_name"] is None + assert info["type"] == "alpha_service" # 残留为字符串 + + def test_forward_reference_string_annotation_not_resolved(self): + """可疑现状:普通字符串前向引用注解('AlphaService')不会被解析 + 为服务名——字符串既不匹配 Provide 前缀也没有 __name__。""" + + class Consumer: + def __init__(self, dep: "AlphaService"): + self.dep = dep + + info = get_injectable_params(Consumer.__init__)["dep"] + assert info["service_name"] is None + assert info["type"] == "AlphaService" + + def test_unannotated_param_yields_no_service_name(self): + class Consumer: + def __init__(self, plain): + self.plain = plain + + info = get_injectable_params(Consumer.__init__)["plain"] + assert info["service_name"] is None + assert info["type"] is inspect.Parameter.empty + assert info["is_optional"] is False + assert info["default"] is None + + def test_default_value_marks_optional_and_keeps_default(self): + class Consumer: + def __init__(self, count=5): + self.count = count + + info = get_injectable_params(Consumer.__init__)["count"] + assert info["is_optional"] is True + assert info["default"] == 5 + + def test_registry_analysis_uses_type_annotations(self, registry): + """registry 的依赖分析与 get_injectable_params 一致: + 类型注解 -> snake_case 服务名""" + + class ReportService: + def __init__(self, alpha_service: AlphaService, untyped=None): + pass + + registry.register_service(ReportService, "report_service") + # 只有可解析出服务名的参数才进入依赖集合 + assert registry.dependencies["report_service"] == {"alpha_service"} + + def test_registry_analysis_with_string_provide(self, registry): + registry.register_service(GammaService, "gamma_service") + assert registry.dependencies["gamma_service"] == {"alpha_service"} + + def test_unregistered_dependency_only_warns_not_raises(self, registry): + """依赖的服务从未注册时,build/排序只告警不报错,缺失服务被 + 当作图外节点(现状行为)""" + registry.register_service(BetaService, "beta_service") + order = registry.get_initialization_order() + assert order == ["beta_service"] diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py new file mode 100644 index 0000000..9f726b9 --- /dev/null +++ b/tests/unit/test_logger.py @@ -0,0 +1,649 @@ +""" +日志系统特征测试(characterization tests) + +固化 myboot/core/logger.py 的「当前实际行为」,作为后续重构的兼容性闸门。 +失败时优先修改测试以匹配源码行为(除非源码行为本身是回归)。 + +注意: +- loguru 是全局单例,每个测试添加的 sink 必须移除(见 _loguru_isolation fixture)。 +- setup_logging / setup_worker_logging 会调用 loguru_logger.remove() 清掉所有 + handler(包括测试 sink),因此捕获 sink 必须在 setup 调用之后添加, + 或者通过 capsys 捕获 stdout。 +- 始终显式传入 Dynaconf 配置对象,避免 get_settings() 全局单例读取 + 项目根目录的 conf/config.yaml 造成测试间/环境间耦合。 +""" + +import inspect +import json +import logging + +import pytest +from dynaconf import Dynaconf +from loguru import logger as loguru_logger + +from myboot.core.logger import ( + Logger, + _configure_third_party_loggers, + _convert_logging_format_to_loguru, + _get_worker_info, + _parse_json_config, + configure_worker_logger, + get_logger, + setup_logging, + setup_worker_logging, +) + + +# --------------------------------------------------------------------------- +# fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _loguru_isolation(): + """保证测试顺序无关性。 + + loguru 是全局单例;setup_logging/setup_worker_logging 会 remove() 所有 + handler 并 configure() 全局 extra。每个测试结束后清掉所有 handler 并 + 重置 extra,使下一个测试从确定状态开始(无 handler、空 extra)。 + """ + yield + loguru_logger.remove() + loguru_logger.configure(extra={}) + + +@pytest.fixture +def capture_records(): + """添加内存 sink 捕获 loguru record,返回 (records, add) 工具。 + + add() 在调用时刻挂 sink(便于在 setup_* 调用之后再挂,避免被 + loguru_logger.remove() 清掉),finally 由 _loguru_isolation 兜底移除。 + """ + records = [] + handler_ids = [] + + def _add(level="DEBUG"): + hid = loguru_logger.add( + lambda message: records.append(message), + format="{message}", + level=level, + ) + handler_ids.append(hid) + return hid + + try: + yield records, _add + finally: + for hid in handler_ids: + try: + loguru_logger.remove(hid) + except ValueError: + # 已被 setup_* 的 remove() 清掉 + pass + + +def _make_config(tmp_path, yaml_content: str) -> Dynaconf: + """从 YAML 字符串创建独立的 Dynaconf 配置对象(不经过全局 get_settings)""" + config_path = tmp_path / "logger_test_config.yaml" + config_path.write_text(yaml_content, encoding="utf-8") + return Dynaconf(settings_files=[str(config_path)]) + + +def _messages(records): + """提取捕获到的纯文本消息列表(去掉末尾换行)""" + return [str(m).rstrip("\n") for m in records] + + +# --------------------------------------------------------------------------- +# 1. issue #5 回归:setup_worker_logging 应遵循配置的日志级别(已修复) +# --------------------------------------------------------------------------- + + +class TestSetupWorkerLoggingLevel: + def test_signature_accepts_config(self): + """修复后的签名:setup_worker_logging(worker_id, total_workers, config=None)""" + params = inspect.signature(setup_worker_logging).parameters + assert list(params) == ["worker_id", "total_workers", "config"] + assert params["config"].default is None + + def test_respects_configured_warning_level(self, tmp_path, capsys): + """配置 WARNING 级别时,DEBUG/INFO 被抑制(不再硬编码 DEBUG)""" + cfg = _make_config(tmp_path, "logging:\n level: WARNING\n") + setup_worker_logging(worker_id=1, total_workers=2, config=cfg) + + loguru_logger.debug("debug-should-not-appear-xyz") + loguru_logger.info("info-should-not-appear-xyz") + loguru_logger.warning("warning-should-appear-xyz") + + out = capsys.readouterr().out + assert "debug-should-not-appear-xyz" not in out + assert "info-should-not-appear-xyz" not in out + assert "warning-should-appear-xyz" in out + + def test_respects_configured_debug_level(self, tmp_path, capsys): + """配置 DEBUG 级别时,DEBUG 消息正常输出""" + cfg = _make_config(tmp_path, "logging:\n level: DEBUG\n") + setup_worker_logging(worker_id=1, total_workers=2, config=cfg) + + loguru_logger.debug("debug-should-appear-xyz") + out = capsys.readouterr().out + assert "debug-should-appear-xyz" in out + + def test_default_level_is_info_when_config_missing_key(self, tmp_path, capsys): + """配置中无 logging.level 时,当前默认级别为 INFO""" + cfg = _make_config(tmp_path, "app:\n name: demo\n") + setup_worker_logging(worker_id=1, total_workers=2, config=cfg) + + loguru_logger.debug("debug-hidden-by-default-xyz") + loguru_logger.info("info-shown-by-default-xyz") + out = capsys.readouterr().out + assert "debug-hidden-by-default-xyz" not in out + assert "info-shown-by-default-xyz" in out + + def test_output_contains_worker_tag(self, tmp_path, capsys): + """worker 日志格式包含 Worker-{id}/{total} 标识""" + cfg = _make_config(tmp_path, "logging:\n level: INFO\n") + setup_worker_logging(worker_id=3, total_workers=8, config=cfg) + + loguru_logger.info("hello-from-worker-xyz") + out = capsys.readouterr().out + assert "Worker-3/8" in out + assert "hello-from-worker-xyz" in out + + +# --------------------------------------------------------------------------- +# 2. Logger 兼容类 +# --------------------------------------------------------------------------- + + +class TestLoggerCompatClass: + def test_instantiation_stores_name(self): + log = Logger("svc") + assert log.name == "svc" + + def test_default_name_is_app(self): + assert Logger().name == "app" + + def test_all_level_methods_callable_without_error(self, capture_records): + records, add = capture_records + add(level="DEBUG") + log = Logger("compat") + log.debug("d-msg") + log.info("i-msg") + log.warning("w-msg") + log.error("e-msg") + log.critical("c-msg") + # exception() 在无活动异常时也不抛错(loguru 行为) + log.exception("x-msg") + msgs = _messages(records) + assert msgs[:5] == ["d-msg", "i-msg", "w-msg", "e-msg", "c-msg"] + # 可疑现状:无活动异常时 exception() 仍输出异常段, + # 格式化文本末尾附加 "NoneType: None" + assert msgs[5] == "x-msg\nNoneType: None" + + def test_bind_name_in_extra(self): + """Logger 内部 _logger 是 bind(name=...) 后的 loguru logger, + name 进入 record["extra"]["name"](不影响 record["name"] 即模块名)""" + records = [] + hid = loguru_logger.add( + lambda m: records.append(m.record), format="{message}", level="DEBUG" + ) + try: + Logger("my-component").info("bound message") + finally: + loguru_logger.remove(hid) + + assert len(records) == 1 + assert records[0]["extra"]["name"] == "my-component" + # 可疑现状:record["name"](loguru 内置,模块路径)不受 bind 影响, + # 仍为调用方模块名,而非传入的 name —— 文本格式 {name} 显示的是模块名 + assert records[0]["name"] != "my-component" + + +# --------------------------------------------------------------------------- +# 3. get_logger +# --------------------------------------------------------------------------- + + +class TestGetLogger: + def test_returns_logger_bound_with_name(self): + records = [] + hid = loguru_logger.add( + lambda m: records.append(m.record), format="{message}", level="DEBUG" + ) + try: + log = get_logger("api") + log.info("from get_logger") + finally: + loguru_logger.remove(hid) + + assert len(records) == 1 + assert records[0]["extra"]["name"] == "api" + + def test_default_name_is_app(self): + records = [] + hid = loguru_logger.add( + lambda m: records.append(m.record), format="{message}", level="DEBUG" + ) + try: + get_logger().info("default name") + finally: + loguru_logger.remove(hid) + + assert records[0]["extra"]["name"] == "app" + + +# --------------------------------------------------------------------------- +# 4. loguru {} 占位符格式化 +# --------------------------------------------------------------------------- + + +class TestBracePlaceholderFormatting: + def test_positional_brace_formatting(self, capture_records): + records, add = capture_records + add() + loguru_logger.info("hello {}", "world") + assert _messages(records) == ["hello world"] + + def test_named_brace_formatting_via_kwargs(self, capture_records): + records, add = capture_records + add() + loguru_logger.info("hello {who}", who="myboot") + assert _messages(records) == ["hello myboot"] + + def test_logger_compat_class_supports_brace_args(self, capture_records): + """Logger 兼容类透传 *args,因此 {} 占位符同样可用""" + records, add = capture_records + add() + Logger("x").info("count={}", 42) + assert _messages(records) == ["count=42"] + + +# --------------------------------------------------------------------------- +# 5. %s 风格调用(issue #12:Logger 兼容类同时支持 % 与 {} 占位符) +# --------------------------------------------------------------------------- + + +class TestPercentStyleSupport: + def test_percent_s_is_formatted(self, capture_records): + """issue #12:原行为是 %s 字面量输出、参数静默丢弃; + 修复后 Logger 兼容类对 % 占位符做预格式化""" + records, add = capture_records + add() + Logger("x").info("hello %s", "world") + assert _messages(records) == ["hello world"] + + def test_percent_multiple_args_and_types(self, capture_records): + """issue #12:%s/%d 多参数预格式化""" + records, add = capture_records + add() + Logger("x").info("user=%s count=%d", "alice", 3) + assert _messages(records) == ["user=alice count=3"] + + def test_percent_works_on_all_level_methods(self, capture_records): + """issue #12:六个级别方法统一支持 % 占位符""" + records, add = capture_records + add(level="DEBUG") + log = Logger("x") + log.debug("d=%s", 1) + log.info("i=%s", 2) + log.warning("w=%s", 3) + log.error("e=%s", 4) + log.critical("c=%s", 5) + log.exception("x=%s", 6) + msgs = _messages(records) + assert msgs[:5] == ["d=1", "i=2", "w=3", "e=4", "c=5"] + # exception() 无活动异常时附加 "NoneType: None"(既有 loguru 行为) + assert msgs[5] == "x=6\nNoneType: None" + + def test_double_percent_escape(self, capture_records): + """issue #12:%% 是转义不是占位符,% 预格式化后输出单个 %""" + records, add = capture_records + add() + Logger("x").info("progress 100%% by %s", "worker") + assert _messages(records) == ["progress 100% by worker"] + + def test_double_percent_only_without_real_placeholder(self, capture_records): + """issue #12:消息只含 %%(无真正 % 占位符)且带参数时, + 不做 % 预格式化,参数交给 loguru(无 {} 则被忽略),%% 原样保留""" + records, add = capture_records + add() + Logger("x").info("progress 100%%", "ignored") + assert _messages(records) == ["progress 100%%"] + + def test_mixed_percent_and_brace_prefers_brace(self, capture_records): + """issue #12 设计决策:消息同时含 % 和 {} 占位符时,{} 优先, + 原样交给 loguru 的 str.format(保持 loguru 原生语义),% 保留字面量""" + records, add = capture_records + add() + Logger("x").info("pct=%s val={}", 42) + assert _messages(records) == ["pct=%s val=42"] + + def test_percent_args_mismatch_falls_back_without_raising(self, capture_records): + """issue #12:% 预格式化参数不匹配时回退为原样输出,不向调用方抛错""" + records, add = capture_records + add() + Logger("x").info("a=%s b=%s", "only-one") + assert _messages(records) == ["a=%s b=%s"] + + def test_lone_brace_with_args_does_not_raise(self, capture_records): + """issue #12:原行为是孤立 { 带参数时 loguru 的 str.format 异常 + 直接传播;修复后走 % 预格式化路径(无完整 {} 占位符),不抛错""" + records, add = capture_records + add() + Logger("x").info("ratio %s {bad", "x") + assert _messages(records) == ["ratio x {bad"] + + def test_lone_brace_without_percent_degrades_safely(self, capture_records): + """issue #12:孤立 { 且无 % 占位符但带参数时,loguru 内部 + str.format 抛错被捕获,降级为无参输出消息本体,不抛错""" + records, add = capture_records + add() + Logger("x").info("oops {bad", "x") + assert _messages(records) == ["oops {bad"] + + def test_brace_format_failure_degrades_safely(self, capture_records): + """issue #12:{} 路径下 str.format 仍可能失败(如孤立 { 与 {} 共存), + 捕获后降级输出消息本体,不抛错""" + records, add = capture_records + add() + Logger("x").info("{bad and {}", "x") + assert _messages(records) == ["{bad and {}"] + + def test_percent_s_on_raw_loguru_logger_unchanged(self, capture_records): + """裸 loguru logger 不受 issue #12 影响:仍原样输出 %s,参数被丢弃""" + records, add = capture_records + add() + loguru_logger.info("value=%s", 123) + assert _messages(records) == ["value=%s"] + + def test_raw_loguru_brace_chars_still_raise(self, capture_records): + """裸 loguru logger 不受 issue #12 影响:含未转义 { } 且带参数时 + str.format() 异常仍直接传播""" + records, add = capture_records + add() + with pytest.raises((ValueError, KeyError, IndexError)): + loguru_logger.info("ratio %s {bad", "x") + + def test_message_without_args_not_formatted(self, capture_records): + """无参数时不做任何格式化,含 % 或 {} 的消息原样输出(Logger 类与裸 loguru 一致)""" + records, add = capture_records + add() + loguru_logger.info("progress 100% {not-a-placeholder}") + Logger("x").info("progress 100% {not-a-placeholder}") + assert _messages(records) == [ + "progress 100% {not-a-placeholder}", + "progress 100% {not-a-placeholder}", + ] + + +class TestBracePathNoRegression: + """issue #12 回归保护:{} 路径与命名 kwargs 用法保持不变""" + + def test_logger_brace_positional_not_regressed(self, capture_records): + records, add = capture_records + add() + Logger("x").info("count={} name={}", 42, "svc") + assert _messages(records) == ["count=42 name=svc"] + + def test_logger_named_kwargs_not_regressed(self, capture_records): + """命名 {name} 占位符通过 kwargs 透传给 loguru""" + records, add = capture_records + add() + Logger("x").info("hello {who}", who="myboot") + assert _messages(records) == ["hello myboot"] + + def test_logger_named_kwargs_with_positional_args(self, capture_records): + """位置 {} 与命名 {name} 混用时 args/kwargs 均透传""" + records, add = capture_records + add() + Logger("x").info("{} meets {who}", "alice", who="bob") + assert _messages(records) == ["alice meets bob"] + + +# --------------------------------------------------------------------------- +# 6. JSON 日志格式 +# --------------------------------------------------------------------------- + + +class TestJsonLogging: + def test_setup_logging_json_outputs_valid_json(self, tmp_path, capsys, clean_myboot_env): + cfg = _make_config(tmp_path, "logging:\n level: INFO\n json: true\n") + setup_logging(config=cfg) + + loguru_logger.info("json hello") + out = capsys.readouterr().out.strip() + + parsed = json.loads(out) + assert parsed["record"]["message"] == "json hello" + assert parsed["record"]["level"]["name"] == "INFO" + # setup_logging 配置了全局 extra worker(单进程为 "Main") + assert parsed["record"]["extra"]["worker"] == "Main" + # serialize=True 时同时包含人类可读的 text 字段 + assert "json hello" in parsed["text"] + + def test_json_config_accepts_string_true(self, tmp_path, capsys, clean_myboot_env): + """logging.json 为字符串 "true" 时同样启用 JSON(_parse_json_config)""" + cfg = _make_config(tmp_path, 'logging:\n level: INFO\n json: "true"\n') + setup_logging(config=cfg) + + loguru_logger.info("string-true json") + out = capsys.readouterr().out.strip() + assert json.loads(out)["record"]["message"] == "string-true json" + + def test_parse_json_config_truth_table(self): + """固化 _parse_json_config 的当前真值表""" + assert _parse_json_config(True) is True + assert _parse_json_config(False) is False + assert _parse_json_config("true") is True + assert _parse_json_config("TRUE") is True + assert _parse_json_config("1") is True + assert _parse_json_config("yes") is True + assert _parse_json_config("on") is True + assert _parse_json_config("false") is False + assert _parse_json_config("off") is False + # 可疑现状:任意其他字符串(如 "enabled")一律为 False + assert _parse_json_config("enabled") is False + # 可疑现状:非 bool/str 走 bool() 转换,整数 1 为 True + assert _parse_json_config(1) is True + assert _parse_json_config(0) is False + assert _parse_json_config(None) is False + + +# --------------------------------------------------------------------------- +# 7. 第三方库日志级别控制 +# --------------------------------------------------------------------------- + + +class TestThirdPartyLoggerControl: + def test_configure_third_party_loggers_sets_std_logging_level(self): + name = "myboot_test_3rdparty_a" + try: + _configure_third_party_loggers({name: "WARNING"}) + assert logging.getLogger(name).level == logging.WARNING + + _configure_third_party_loggers({name: "error"}) # 大小写不敏感 + assert logging.getLogger(name).level == logging.ERROR + finally: + logging.getLogger(name).setLevel(logging.NOTSET) + + def test_unknown_level_falls_back_to_info(self): + """可疑现状:无效级别名静默回退为 INFO(getattr 默认值),不报错""" + name = "myboot_test_3rdparty_b" + try: + _configure_third_party_loggers({name: "NOT_A_LEVEL"}) + assert logging.getLogger(name).level == logging.INFO + finally: + logging.getLogger(name).setLevel(logging.NOTSET) + + def test_non_dict_and_non_str_values_ignored(self): + name = "myboot_test_3rdparty_c" + try: + # 非 dict 输入:直接 return,不抛错 + _configure_third_party_loggers("not-a-dict") + _configure_third_party_loggers(None) + # 可疑现状:级别值非字符串(如 int 30)被静默忽略 + _configure_third_party_loggers({name: 30}) + assert logging.getLogger(name).level == logging.NOTSET + finally: + logging.getLogger(name).setLevel(logging.NOTSET) + + def test_setup_logging_applies_third_party_config(self, tmp_path, capsys, clean_myboot_env): + name = "myboot_test_3rdparty_d" + cfg = _make_config( + tmp_path, + f"logging:\n level: INFO\n third_party:\n {name}: ERROR\n", + ) + try: + setup_logging(config=cfg) + assert logging.getLogger(name).level == logging.ERROR + finally: + logging.getLogger(name).setLevel(logging.NOTSET) + + +# --------------------------------------------------------------------------- +# setup_logging 整体行为 +# --------------------------------------------------------------------------- + + +class TestSetupLogging: + def test_respects_configured_level(self, tmp_path, capsys, clean_myboot_env): + cfg = _make_config(tmp_path, "logging:\n level: ERROR\n") + setup_logging(config=cfg) + + loguru_logger.info("info-suppressed-xyz") + loguru_logger.error("error-shown-xyz") + out = capsys.readouterr().out + assert "info-suppressed-xyz" not in out + assert "error-shown-xyz" in out + + def test_removes_preexisting_handlers(self, tmp_path, capsys, clean_myboot_env): + """setup_logging 调用 loguru_logger.remove() 清掉之前的所有 sink""" + before = [] + loguru_logger.add(lambda m: before.append(m), format="{message}") + + cfg = _make_config(tmp_path, "logging:\n level: INFO\n") + setup_logging(config=cfg) + + loguru_logger.info("after-setup-xyz") + assert before == [] # 之前的 sink 已被移除,收不到任何消息 + assert "after-setup-xyz" in capsys.readouterr().out + + def test_single_worker_uses_simple_format_without_worker_tag( + self, tmp_path, capsys, clean_myboot_env + ): + """无 MYBOOT_WORKER_COUNT 环境变量时使用简单格式,不含 worker 标识""" + cfg = _make_config(tmp_path, "logging:\n level: INFO\n") + setup_logging(config=cfg) + + loguru_logger.info("simple-format-xyz") + out = capsys.readouterr().out + assert "simple-format-xyz" in out + assert "Main" not in out + assert "Worker-" not in out + + def test_multi_worker_env_enables_worker_tag(self, tmp_path, capsys, clean_myboot_env): + clean_myboot_env.setenv("MYBOOT_WORKER_COUNT", "4") + clean_myboot_env.setenv("MYBOOT_WORKER_ID", "2") + cfg = _make_config(tmp_path, "logging:\n level: INFO\n") + setup_logging(config=cfg) + + loguru_logger.info("tagged-xyz") + out = capsys.readouterr().out + assert "Worker-2/4" in out + assert "tagged-xyz" in out + + def test_enable_worker_info_false_suppresses_worker_tag( + self, tmp_path, capsys, clean_myboot_env + ): + clean_myboot_env.setenv("MYBOOT_WORKER_COUNT", "4") + clean_myboot_env.setenv("MYBOOT_WORKER_ID", "2") + cfg = _make_config(tmp_path, "logging:\n level: INFO\n") + setup_logging(config=cfg, enable_worker_info=False) + + loguru_logger.info("untagged-xyz") + out = capsys.readouterr().out + assert "Worker-2/4" not in out + assert "untagged-xyz" in out + + def test_custom_logging_format_converted_to_loguru(self, tmp_path, capsys, clean_myboot_env): + """logging.format 支持标准 logging 风格 %(...)s 占位符并被转换""" + cfg = _make_config( + tmp_path, + 'logging:\n level: INFO\n format: "%(levelname)s | %(message)s"\n', + ) + setup_logging(config=cfg) + + loguru_logger.info("converted-format-xyz") + out = capsys.readouterr().out + assert "INFO" in out + assert "converted-format-xyz" in out + + def test_file_handler_writes_to_configured_path(self, tmp_path, capsys, clean_myboot_env): + log_file = tmp_path / "logs" / "app.log" + cfg = _make_config( + tmp_path, + f"logging:\n level: INFO\n file: {json.dumps(str(log_file))}\n", + ) + setup_logging(config=cfg) + + loguru_logger.info("written-to-file-xyz") + # 移除 handler 以确保文件被 flush/关闭后再读取(Windows) + loguru_logger.remove() + + assert log_file.exists() # 父目录由 _add_file_handler 自动创建 + content = log_file.read_text(encoding="utf-8") + assert "written-to-file-xyz" in content + + +# --------------------------------------------------------------------------- +# configure_worker_logger 与辅助函数 +# --------------------------------------------------------------------------- + + +class TestWorkerContextHelpers: + def test_configure_worker_logger_sets_extra_worker(self): + configure_worker_logger(worker_id=3, total_workers=8) + + records = [] + hid = loguru_logger.add( + lambda m: records.append(m.record), format="{message}", level="DEBUG" + ) + try: + loguru_logger.info("ctx check") + finally: + loguru_logger.remove(hid) + + assert records[0]["extra"]["worker"] == "Worker-3/8" + + def test_get_worker_info_main_without_env(self, clean_myboot_env): + assert _get_worker_info() == "Main" + + def test_get_worker_info_with_env(self, clean_myboot_env): + clean_myboot_env.setenv("MYBOOT_WORKER_ID", "2") + clean_myboot_env.setenv("MYBOOT_WORKER_COUNT", "4") + assert _get_worker_info() == "Worker-2/4" + + def test_get_worker_info_requires_both_env_vars(self, clean_myboot_env): + """可疑现状:只设置 WORKER_ID 而无 WORKER_COUNT 时返回 "Main" """ + clean_myboot_env.setenv("MYBOOT_WORKER_ID", "2") + assert _get_worker_info() == "Main" + + +class TestFormatConversion: + def test_standard_logging_placeholders_converted(self): + converted = _convert_logging_format_to_loguru( + "%(asctime)s %(name)s %(levelname)s %(message)s " + "%(filename)s %(funcName)s %(lineno)d" + ) + assert converted == ( + "{time:YYYY-MM-DD HH:mm:ss} {name} {level: <8} {message} " + "{file.name} {function} {line}" + ) + + def test_unknown_placeholders_left_untouched(self): + """可疑现状:映射表之外的占位符(如 %(thread)d)原样保留, + 进入 loguru format 后会原样输出在每条日志中""" + assert _convert_logging_format_to_loguru("%(thread)d %(message)s") == ( + "%(thread)d {message}" + ) diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py new file mode 100644 index 0000000..0d11601 --- /dev/null +++ b/tests/unit/test_metrics.py @@ -0,0 +1,285 @@ +""" +F5 内置 Prometheus 指标模块单元测试 + +覆盖: +- prometheus-client 未安装时的优雅退化(no-op 桩、Application 只告警不挂) +- setup_multiproc_env 的各种生效/不生效分支 +- get_counter / get_histogram 幂等缓存 +- HttpMetricsMiddleware 路由模板标签、unmatched 归类与 metrics 自身路径排除 +""" + +import importlib.util +import os +import sys +import uuid + +import pytest +from fastapi.testclient import TestClient + +import myboot.metrics as metrics +from myboot.core.application import Application +from myboot.core.config import get_settings + + +def _uniq(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex}" + + +class FakeConfig: + """支持点号 key 的最小配置桩""" + + def __init__(self, data=None): + self.data = data or {} + + def get(self, key, default=None): + return self.data.get(key, default) + + +# ==================== 可用性与 no-op 退化 ==================== + + +class TestAvailability: + def test_is_available_true_when_installed(self): + # prometheus-client 已随 test extra 安装 + assert metrics.is_available() is True + + def test_is_available_false_when_not_installed(self, monkeypatch): + monkeypatch.setattr(importlib.util, "find_spec", lambda name: None) + assert metrics.is_available() is False + + def test_get_counter_noop_when_unavailable(self, monkeypatch): + monkeypatch.setattr(metrics, "is_available", lambda: False) + counter = metrics.get_counter(_uniq("noop_counter"), "doc", ("a", "b")) + # 链式调用不报错 + counter.labels(a="1", b="2").inc() + counter.inc(5) + assert isinstance(counter, metrics._NoopMetric) + + def test_get_histogram_noop_when_unavailable(self, monkeypatch): + monkeypatch.setattr(metrics, "is_available", lambda: False) + hist = metrics.get_histogram(_uniq("noop_hist"), "doc", ("stage",)) + hist.labels(stage="x").observe(0.1) + assert isinstance(hist, metrics._NoopMetric) + + def test_observe_stage_and_time_stage_noop_when_unavailable(self, monkeypatch): + monkeypatch.setattr(metrics, "is_available", lambda: False) + metrics.observe_stage("recall", 0.05) + with metrics.time_stage("rank"): + pass + + def test_application_only_warns_when_enabled_but_not_installed(self, monkeypatch): + monkeypatch.setattr(metrics, "is_available", lambda: False) + settings = get_settings() + settings.set("metrics.enabled", True) + try: + app = Application(name="metrics-warn-test", auto_configuration=False) + fastapi_app = app.get_fastapi_app() + # 未安装 → 不挂载 /metrics、不加中间件,应用可正常服务 + client = TestClient(fastapi_app) + resp = client.get("/health") + assert resp.status_code == 200 + resp = client.get("/metrics") + assert resp.status_code == 404 + finally: + settings.set("metrics.enabled", False) + + +# ==================== is_enabled ==================== + + +class TestIsEnabled: + @pytest.mark.parametrize( + "value,expected", + [ + (True, True), + (False, False), + ("true", True), + ("True", True), + ("1", True), + ("yes", True), + ("on", True), + ("false", False), + ("0", False), + (None, False), + ], + ) + def test_bool_and_string_values(self, value, expected): + cfg = FakeConfig({"metrics.enabled": value}) + assert metrics.is_enabled(cfg) is expected + + def test_default_false(self): + assert metrics.is_enabled(FakeConfig()) is False + + +# ==================== setup_multiproc_env ==================== + + +@pytest.fixture +def clean_prom_env(monkeypatch): + monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False) + monkeypatch.delenv("MYBOOT_WORKER_ID", raising=False) + return monkeypatch + + +class TestSetupMultiprocEnv: + def _cfg(self, workers=4, enabled=True, multiproc_dir=None): + return FakeConfig( + { + "metrics.enabled": enabled, + "server.workers": workers, + "metrics.multiproc_dir": multiproc_dir, + } + ) + + def test_single_worker_noop(self, clean_prom_env): + clean_prom_env.setattr(sys, "platform", "linux") + metrics.setup_multiproc_env(self._cfg(workers=1), "demo") + assert "PROMETHEUS_MULTIPROC_DIR" not in os.environ + + def test_disabled_noop(self, clean_prom_env): + clean_prom_env.setattr(sys, "platform", "linux") + metrics.setup_multiproc_env(self._cfg(workers=4, enabled=False), "demo") + assert "PROMETHEUS_MULTIPROC_DIR" not in os.environ + + def test_win32_noop(self, clean_prom_env): + clean_prom_env.setattr(sys, "platform", "win32") + metrics.setup_multiproc_env(self._cfg(workers=4), "demo") + assert "PROMETHEUS_MULTIPROC_DIR" not in os.environ + + def test_multiworker_sets_env_and_cleans_stale_db(self, clean_prom_env, tmp_path): + clean_prom_env.setattr(sys, "platform", "linux") + target = tmp_path / "prom_dir" + target.mkdir() + stale = target / "counter_12345.db" + stale.write_bytes(b"stale") + keep = target / "note.txt" + keep.write_text("keep") + + metrics.setup_multiproc_env( + self._cfg(workers=4, multiproc_dir=str(target)), "demo" + ) + + assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == str(target) + assert target.is_dir() + assert not stale.exists() # 陈旧 .db 被父进程清理 + assert keep.exists() # 非 .db 文件保留 + + def test_worker_process_does_not_clean(self, clean_prom_env, tmp_path): + clean_prom_env.setattr(sys, "platform", "linux") + clean_prom_env.setenv("MYBOOT_WORKER_ID", "2") + target = tmp_path / "prom_dir" + target.mkdir() + stale = target / "counter_12345.db" + stale.write_bytes(b"stale") + + metrics.setup_multiproc_env( + self._cfg(workers=4, multiproc_dir=str(target)), "demo" + ) + assert stale.exists() # 非父进程不清理 + + def test_user_preset_env_respected(self, clean_prom_env, tmp_path): + clean_prom_env.setattr(sys, "platform", "linux") + user_dir = tmp_path / "user_dir" + clean_prom_env.setenv("PROMETHEUS_MULTIPROC_DIR", str(user_dir)) + metrics.setup_multiproc_env( + self._cfg(workers=4, multiproc_dir=str(tmp_path / "other")), "demo" + ) + assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == str(user_dir) + assert user_dir.is_dir() + + def test_default_dir_under_tempdir_with_app_slug(self, clean_prom_env): + import tempfile + + clean_prom_env.setattr(sys, "platform", "linux") + metrics.setup_multiproc_env(self._cfg(workers=2), "My Demo App!") + value = os.environ["PROMETHEUS_MULTIPROC_DIR"] + assert value.startswith(tempfile.gettempdir()) + assert "myboot_prometheus_my_demo_app" in value + + +# ==================== 指标工厂幂等性 ==================== + + +class TestMetricFactories: + def test_get_counter_idempotent(self): + name = _uniq("idem_counter") + c1 = metrics.get_counter(name, "doc", ("x",)) + c2 = metrics.get_counter(name, "doc", ("x",)) + assert c1 is c2 + c1.labels(x="a").inc() + + def test_get_histogram_idempotent(self): + name = _uniq("idem_hist") + h1 = metrics.get_histogram(name, "doc", ("x",)) + h2 = metrics.get_histogram(name, "doc", ("x",)) + assert h1 is h2 + h1.labels(x="a").observe(0.5) + + def test_observe_stage_negative_ignored(self): + metrics.observe_stage("noop", -1.0) # 不报错 + + def test_time_stage_records(self): + with metrics.time_stage(_uniq("stage")): + pass + + +# ==================== HttpMetricsMiddleware 端到端 ==================== + + +class TestHttpMetricsMiddleware: + @pytest.fixture + def metrics_app(self): + settings = get_settings() + settings.set("metrics.enabled", True) + try: + app = Application(name="metrics-mw-test", auto_configuration=False) + + async def get_item(item_id: str): + return {"id": item_id} + + app.add_route("/items/{item_id}", get_item, ["GET"]) + yield app + finally: + settings.set("metrics.enabled", False) + + def test_http_metrics_collected(self, metrics_app): + client = TestClient(metrics_app.get_fastapi_app()) + + assert client.get("/items/1").status_code == 200 + assert client.get("/items/2").status_code == 200 + assert client.get("/no/such/route").status_code == 404 + + scrape = client.get("/metrics") + assert scrape.status_code == 200 + text = scrape.text + + # path 标签是路由模板,两个不同 id 归并到同一序列 + template_lines = [ + line + for line in text.splitlines() + if line.startswith("myboot_http_requests_total") + and 'path="/items/{item_id}"' in line + ] + assert len(template_lines) == 1 + assert 'method="GET"' in template_lines[0] + assert 'status="200"' in template_lines[0] + assert template_lines[0].rstrip().endswith("2.0") + # 具体 id 不出现为独立标签(避免高基数) + assert 'path="/items/1"' not in text + assert 'path="/items/2"' not in text + + # 404 归入 unmatched + assert 'path="unmatched"' in text + + # metrics 自身路径不被统计 + assert 'path="/metrics"' not in text + + # 耗时直方图存在 + assert "myboot_http_request_duration_seconds" in text + + def test_metrics_endpoint_not_wrapped_by_response_formatter(self, metrics_app): + client = TestClient(metrics_app.get_fastapi_app()) + resp = client.get("/metrics") + assert resp.status_code == 200 + # Prometheus 文本格式而非统一 JSON 包装 + assert "text/plain" in resp.headers.get("content-type", "") diff --git a/tests/unit/test_multiworker.py b/tests/unit/test_multiworker.py new file mode 100644 index 0000000..6a5e687 --- /dev/null +++ b/tests/unit/test_multiworker.py @@ -0,0 +1,631 @@ +""" +多 worker 服务独立实例化(issue #11)单元测试 + +覆盖: +1. @service/@client 的 scope 元数据透传(顶层键,装饰期校验) +2. ServiceProvider 的 scope -> Provider 映射(request -> ContextLocalSingleton) +3. Application.run() 多 worker 模式下延迟引导(父进程跳过 apply_auto_configuration) +4. Application.bootstrap_worker() 环境变量重读 / 调度器门控 / fork 残留重置 +5. server._worker_serve 与 bootstrap_worker 的桥接 +6. lifespan 中 worker 启动/停止钩子的触发顺序 +7. auto_discover 幂等守卫与 @on_worker_start/@on_worker_stop 的 AST 发现 +""" + +import asyncio +import os +import textwrap + +import pytest +from fastapi.testclient import TestClient +from dependency_injector import providers as di_providers + +import myboot.core.application as application_module +import myboot.core.server as server_module +from myboot.core.application import Application +from myboot.core.auto_configuration import AutoConfigurationManager +from myboot.core.decorators import ( + service, + client, + on_worker_start, + on_worker_stop, +) +from myboot.core.di import DependencyContainer +from myboot.core.di.providers import ServiceProvider + + +# --------------------------------------------------------------------------- +# 公共 fixture +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _clean_worker_env(): + """每个测试前后清理 MYBOOT_* 环境变量 + + _worker_serve / 测试本身会写入 MYBOOT_WORKER_ID 等变量, + 若不清理会污染后续 Application 构造。 + """ + saved = {k: v for k, v in os.environ.items() if k.startswith("MYBOOT_")} + for key in saved: + del os.environ[key] + yield + for key in [k for k in os.environ if k.startswith("MYBOOT_")]: + del os.environ[key] + os.environ.update(saved) + + +@pytest.fixture +def set_config(): + """临时修改全局配置,测试结束后恢复原值""" + from myboot.core.config import get_settings + + settings = get_settings() + changed = {} + + def _set(key, value): + if key not in changed: + changed[key] = settings.get(key) + settings.set(key, value) + + yield _set + + for key, old in changed.items(): + settings.set(key, old) + + +# --------------------------------------------------------------------------- +# 1. scope 元数据透传 +# --------------------------------------------------------------------------- + +class TestScopeDecorators: + def test_service_scope_defaults_to_singleton(self): + @service() + class AlphaService: + pass + + assert AlphaService.__myboot_service__["scope"] == "singleton" + + def test_service_scope_stored_as_top_level_key(self): + """scope 必须是顶层键(而非落入 kwargs),注册时才能读到""" + + @service(scope="request") + class BetaService: + pass + + meta = BetaService.__myboot_service__ + assert meta["scope"] == "request" + assert "scope" not in meta["kwargs"] + + def test_service_invalid_scope_raises_at_decoration(self): + with pytest.raises(ValueError, match="scope"): + @service(scope="sigleton") # 故意拼错 + class TypoService: + pass + + def test_client_scope_defaults_to_singleton(self): + @client() + class AlphaClient: + pass + + assert AlphaClient.__myboot_client__["scope"] == "singleton" + + def test_client_scope_stored_as_top_level_key(self): + @client(scope="factory") + class BetaClient: + pass + + meta = BetaClient.__myboot_client__ + assert meta["scope"] == "factory" + assert "scope" not in meta["kwargs"] + + def test_client_invalid_scope_raises_at_decoration(self): + with pytest.raises(ValueError, match="scope"): + @client(scope="prototype") + class TypoClient: + pass + + +# --------------------------------------------------------------------------- +# 2. Provider 映射 +# --------------------------------------------------------------------------- + +class TestProviderScopes: + class _Dummy: + pass + + def test_singleton_scope_creates_singleton_provider(self): + sp = ServiceProvider(self._Dummy, "dummy", scope="singleton") + assert isinstance(sp.create_provider(), di_providers.Singleton) + + def test_request_scope_creates_context_local_singleton(self): + sp = ServiceProvider(self._Dummy, "dummy", scope="request") + assert isinstance(sp.create_provider(), di_providers.ContextLocalSingleton) + + def test_factory_scope_creates_factory_provider(self): + sp = ServiceProvider(self._Dummy, "dummy", scope="factory") + provider = sp.create_provider() + assert isinstance(provider, di_providers.Factory) + assert not isinstance(provider, di_providers.Singleton) + + def test_request_scope_instances_per_asyncio_task(self): + """request 作用域:跨 asyncio 任务不同实例,同一任务内同一实例""" + + class ReqService: + pass + + container = DependencyContainer() + container.register_service(ReqService, "req_service", scope="request") + container.build_container() + + async def resolve_twice(): + first = container.get_service("req_service") + second = container.get_service("req_service") + return first, second + + async def main(): + (a1, a2), (b1, b2) = await asyncio.gather( + resolve_twice(), resolve_twice() + ) + return a1, a2, b1, b2 + + a1, a2, b1, b2 = asyncio.run(main()) + assert a1 is a2 # 同一任务(请求)内单例 + assert b1 is b2 + assert a1 is not b1 # 跨任务(请求)不同实例 + # request 作用域不进入单例缓存 + assert "req_service" not in container.service_instances + + def test_register_service_accepts_decorator_metadata_with_scope(self): + """回归:装饰器元数据(含顶层 scope 键)作为 config 传入时 + 不得与 register_service 的显式 scope 参数冲突""" + + @service(scope="request") + class MetaService: + pass + + meta = MetaService.__myboot_service__ + container = DependencyContainer() + container.register_service( + MetaService, meta["name"], scope=meta["scope"], config=meta + ) + container.build_container() + + provider = container.service_providers[meta["name"]] + assert provider.scope == "request" + + def test_factory_scope_not_cached_by_container(self): + class FactService: + pass + + container = DependencyContainer() + container.register_service(FactService, "fact_service", scope="factory") + container.build_container() + + first = container.get_service("fact_service") + second = container.get_service("fact_service") + assert first is not second + assert "fact_service" not in container.service_instances + + +# --------------------------------------------------------------------------- +# 3. run() 延迟引导 +# --------------------------------------------------------------------------- + +class TestRunDefersBootstrap: + def _run(self, monkeypatch, set_config, workers, app_path): + calls = [] + monkeypatch.setattr( + application_module, "auto_discover", + lambda pkg: calls.append(("discover", pkg)), + ) + monkeypatch.setattr( + application_module, "apply_auto_configuration", + lambda app: calls.append(("apply", app)), + ) + + app = Application(name="run-defer-test") + monkeypatch.setattr( + app.server_manager, "start_server", + lambda **kwargs: calls.append(("serve", kwargs)), + ) + # conf/config.yaml 中 server.workers=1 会覆盖 run() 入参,显式设置 + set_config("server.workers", workers) + app.run(workers=workers, app_path=app_path) + return app, calls + + def test_multi_worker_with_app_path_skips_apply(self, monkeypatch, set_config): + app, calls = self._run(monkeypatch, set_config, workers=2, app_path="main:app") + events = [name for name, _ in calls] + assert "discover" in events # 父进程仍做 AST 发现(预热缓存) + assert "apply" not in events # 实例化延迟到 worker 内 + assert "serve" in events + serve_kwargs = dict(calls)["serve"] + assert serve_kwargs["workers"] == 2 + assert serve_kwargs["app_path"] == "main:app" + + def test_single_worker_applies_in_parent(self, monkeypatch, set_config): + app, calls = self._run(monkeypatch, set_config, workers=1, app_path=None) + events = [name for name, _ in calls] + assert "discover" in events + assert "apply" in events + + def test_multi_worker_without_app_path_falls_back_to_parent_apply( + self, monkeypatch, set_config + ): + """无 app_path 的回退路径(父进程自己 serve)保持原有引导行为""" + app, calls = self._run(monkeypatch, set_config, workers=2, app_path=None) + events = [name for name, _ in calls] + assert "apply" in events + + +# --------------------------------------------------------------------------- +# 4. bootstrap_worker() +# --------------------------------------------------------------------------- + +class TestBootstrapWorker: + def test_reads_worker_env_and_disables_scheduler_for_non_primary(self): + app = Application(name="bw-env-test", auto_configuration=False) + # 构造时无环境变量:默认自认 primary(这正是被修复的 bug 场景) + assert app.is_primary_worker is True + + os.environ["MYBOOT_WORKER_ID"] = "2" + os.environ["MYBOOT_WORKER_COUNT"] = "3" + os.environ["MYBOOT_IS_PRIMARY_WORKER"] = "0" + + old_scheduler = app.scheduler + result = app.bootstrap_worker() + + assert app.worker_id == 2 + assert app.worker_count == 3 + assert app.is_primary_worker is False + # 调度器被重建且按 primary 门控禁用 + assert app.scheduler is not old_scheduler + assert app.scheduler.is_enabled() is False + assert result is app.get_fastapi_app() + + def test_primary_worker_keeps_scheduler_enabled(self): + app = Application(name="bw-primary-test", auto_configuration=False) + os.environ["MYBOOT_WORKER_ID"] = "1" + os.environ["MYBOOT_WORKER_COUNT"] = "3" + os.environ["MYBOOT_IS_PRIMARY_WORKER"] = "1" + + app.bootstrap_worker() + assert app.is_primary_worker is True + assert app.scheduler.is_enabled() is True + + def test_scheduler_on_all_workers_overrides_primary_gate(self, set_config): + set_config("scheduler.on_all_workers", True) + app = Application(name="bw-allworkers-test", auto_configuration=False) + os.environ["MYBOOT_WORKER_ID"] = "2" + os.environ["MYBOOT_WORKER_COUNT"] = "2" + os.environ["MYBOOT_IS_PRIMARY_WORKER"] = "0" + + app.bootstrap_worker() + assert app.is_primary_worker is False + assert app.scheduler.is_enabled() is True + + def test_defensive_reset_on_fork_inherited_state(self): + """fork 子进程继承父进程已引导状态时:清空注册表并重建 FastAPI 应用""" + app = Application(name="bw-reset-test", auto_configuration=False) + + class InheritedService: + pass + + app.di_container = DependencyContainer() + app.di_container.register_service(InheritedService, "inherited_service") + app.services["inherited_service"] = InheritedService() + app.clients["inherited_client"] = object() + app.components["inherited_component"] = object() + app._client_type_map = {InheritedService: "inherited_client"} + app._component_type_map = {InheritedService: "inherited_component"} + app.worker_start_hooks.append(lambda: None) + app.worker_stop_hooks.append(lambda: None) + old_fastapi_app = app._fastapi_app + + result = app.bootstrap_worker() + + assert app.services == {} + assert app.clients == {} + assert app.components == {} + assert app._client_type_map == {} + assert app._component_type_map == {} + assert app.worker_start_hooks == [] + assert app.worker_stop_hooks == [] + assert not app.di_container.service_providers + # FastAPI 应用被重建(丢弃绑定到 fork 前实例的路由) + assert app._fastapi_app is not old_fastapi_app + assert result is app._fastapi_app + + def test_clean_state_keeps_pristine_fastapi_app(self): + """正常延迟引导路径:注册表为空时不触发重置,沿用原 FastAPI 实例""" + app = Application(name="bw-clean-test", auto_configuration=False) + old_fastapi_app = app._fastapi_app + + result = app.bootstrap_worker() + assert app._fastapi_app is old_fastapi_app + assert result is old_fastapi_app + + def test_runs_auto_configuration_inside_worker(self, monkeypatch): + calls = [] + monkeypatch.setattr( + application_module, "auto_discover", + lambda pkg: calls.append(("discover", pkg)), + ) + monkeypatch.setattr( + application_module, "apply_auto_configuration", + lambda app: calls.append(("apply", app)), + ) + app = Application(name="bw-autoconf-test") + app.bootstrap_worker() + + events = [name for name, _ in calls] + assert "discover" in events + assert "apply" in events + + def test_auto_configuration_disabled_skips_bootstrap_apply(self, monkeypatch): + calls = [] + monkeypatch.setattr( + application_module, "apply_auto_configuration", + lambda app: calls.append("apply"), + ) + app = Application(name="bw-noautoconf-test", auto_configuration=False) + app.bootstrap_worker() + assert calls == [] + + +# --------------------------------------------------------------------------- +# 5. _worker_serve 桥接 +# --------------------------------------------------------------------------- + +class TestWorkerServe: + @pytest.fixture + def serve_recorder(self, monkeypatch): + """mock hypercorn.asyncio.serve 与 setup_worker_logging""" + import importlib + + import hypercorn.asyncio + + # 注意:myboot.core 包的 `from .logger import logger` 会用 loguru + # 实例遮蔽包属性 myboot.core.logger,必须经 importlib 拿到真实模块 + logger_module = importlib.import_module("myboot.core.logger") + + served = {} + + async def fake_serve(app, config): + served["app"] = app + served["config"] = config + + monkeypatch.setattr(hypercorn.asyncio, "serve", fake_serve) + monkeypatch.setattr( + logger_module, "setup_worker_logging", lambda *a, **k: None + ) + return served + + def test_myboot_app_served_via_bootstrap_worker( + self, monkeypatch, serve_recorder + ): + resolved = object() + monkeypatch.setattr( + server_module, "_resolve_app_from_path", lambda path: resolved + ) + + bootstrapped_app = object() + bootstrap_calls = [] + + class FakeMybootApp: + def bootstrap_worker(self): + bootstrap_calls.append(True) + return bootstrapped_app + + monkeypatch.setattr(application_module, "_current_app", FakeMybootApp()) + + server_module._worker_serve("main:app", {}, 2, 3) + + # 桥接:serve 的是 bootstrap_worker() 的返回值,而非解析出的原对象 + assert bootstrap_calls == [True] + assert serve_recorder["app"] is bootstrapped_app + # 环境变量在引导前已设置 + assert os.environ["MYBOOT_WORKER_ID"] == "2" + assert os.environ["MYBOOT_WORKER_COUNT"] == "3" + assert os.environ["MYBOOT_IS_PRIMARY_WORKER"] == "0" + + def test_primary_worker_env_flag(self, monkeypatch, serve_recorder): + monkeypatch.setattr( + server_module, "_resolve_app_from_path", lambda path: object() + ) + monkeypatch.setattr(application_module, "_current_app", None) + + server_module._worker_serve("main:app", {}, 1, 2) + assert os.environ["MYBOOT_IS_PRIMARY_WORKER"] == "1" + + def test_plain_asgi_app_path_untouched(self, monkeypatch, serve_recorder): + """非 myboot 应用(_current_app 为 None):直接 serve 解析出的对象""" + resolved = object() + monkeypatch.setattr( + server_module, "_resolve_app_from_path", lambda path: resolved + ) + monkeypatch.setattr(application_module, "_current_app", None) + + server_module._worker_serve("asgi_module:app", {}, 2, 2) + assert serve_recorder["app"] is resolved + + +# --------------------------------------------------------------------------- +# 6. worker 生命周期钩子 +# --------------------------------------------------------------------------- + +class TestWorkerHookDecorators: + def test_bare_decorator_marks_function(self): + @on_worker_start + def hook(): + pass + + assert hook.__myboot_worker_hook__ == {"event": "start", "order": 0} + + def test_decorator_with_order(self): + @on_worker_stop(order=5) + def hook(): + pass + + assert hook.__myboot_worker_hook__ == {"event": "stop", "order": 5} + + def test_auto_register_worker_hooks_sorted_by_order(self): + class FakeApp: + def __init__(self): + self.worker_start_hooks = [] + self.worker_stop_hooks = [] + + def add_worker_start_hook(self, hook): + self.worker_start_hooks.append(hook) + + def add_worker_stop_hook(self, hook): + self.worker_stop_hooks.append(hook) + + @on_worker_start(order=2) + def start_late(): + pass + + @on_worker_start(order=1) + def start_early(): + pass + + @on_worker_stop + def stop_hook(): + pass + + manager = AutoConfigurationManager(use_cache=False) + manager.discovered_components["worker_hooks"] = [ + {"function": start_late, "module": "m", "type": "function_on_worker_start"}, + {"function": start_early, "module": "m", "type": "function_on_worker_start"}, + {"function": stop_hook, "module": "m", "type": "function_on_worker_stop"}, + ] + + fake_app = FakeApp() + manager._auto_register_worker_hooks(fake_app) + + assert fake_app.worker_start_hooks == [start_early, start_late] + assert fake_app.worker_stop_hooks == [stop_hook] + + +class TestWorkerHooksInLifespan: + def test_hook_firing_order_in_lifespan(self): + """startup -> worker_start(按 order) -> scheduler.start ... + scheduler.stop -> worker_stop -> shutdown""" + events = [] + + class StubScheduler: + def has_jobs(self): + return True + + def start(self): + events.append("scheduler_start") + + def is_running(self): + return True + + def stop(self): + events.append("scheduler_stop") + + app = Application(name="hooks-lifespan-test", auto_configuration=False) + app.scheduler = StubScheduler() + + def startup_hook(): + events.append("startup") + + def worker_start_sync(): + events.append("worker_start_sync") + + async def worker_start_async(): + events.append("worker_start_async") + + async def worker_stop_async(): + events.append("worker_stop_async") + + def shutdown_hook(): + events.append("shutdown") + + app.add_startup_hook(startup_hook) + app.add_worker_start_hook(worker_start_sync) + app.add_worker_start_hook(worker_start_async) + app.add_worker_stop_hook(worker_stop_async) + app.add_shutdown_hook(shutdown_hook) + + with TestClient(app.get_fastapi_app()): + assert events == [ + "startup", + "worker_start_sync", + "worker_start_async", + "scheduler_start", + ] + + assert events == [ + "startup", + "worker_start_sync", + "worker_start_async", + "scheduler_start", + "scheduler_stop", + "worker_stop_async", + "shutdown", + ] + + +# --------------------------------------------------------------------------- +# 7. auto_discover 幂等 + worker 钩子 AST 发现 +# --------------------------------------------------------------------------- + +class TestAutoDiscover: + def _make_package(self, tmp_path): + pkg = tmp_path / "demo_pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text("", encoding="utf-8") + (pkg / "stuff.py").write_text( + textwrap.dedent( + """ + from myboot.core.decorators import ( + service, on_worker_start, on_worker_stop + ) + + + @service() + class DemoService: + pass + + + @on_worker_start + def start_hook(): + pass + + + @on_worker_stop(order=1) + def stop_hook(): + pass + """ + ), + encoding="utf-8", + ) + return pkg + + def test_auto_discover_is_idempotent(self, tmp_path): + self._make_package(tmp_path) + manager = AutoConfigurationManager(app_root=str(tmp_path), use_cache=False) + + manager.auto_discover("demo_pkg") + assert len(manager._component_metadata["services"]) == 1 + assert len(manager._component_metadata["worker_hooks"]) == 2 + + # 重复调用(fork 子进程场景):不追加重复条目 + manager.auto_discover("demo_pkg") + assert len(manager._component_metadata["services"]) == 1 + assert len(manager._component_metadata["worker_hooks"]) == 2 + + def test_worker_hooks_discovered_via_ast(self, tmp_path): + self._make_package(tmp_path) + manager = AutoConfigurationManager(app_root=str(tmp_path), use_cache=False) + manager.auto_discover("demo_pkg") + + hook_types = sorted( + item["type"] for item in manager._component_metadata["worker_hooks"] + ) + assert hook_types == [ + "function_on_worker_start", + "function_on_worker_stop", + ] diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py new file mode 100644 index 0000000..b3e191b --- /dev/null +++ b/tests/unit/test_scheduler.py @@ -0,0 +1,336 @@ +""" +调度器特征测试(characterization tests) + +固化 myboot.core.scheduler.Scheduler 的「当前实际行为」,作为后续重构的兼容性闸门。 +这些测试只断言现状,不代表理想行为;行为有意变更时应同步更新本文件。 + +注意: +- 全程不调用 scheduler.start(),只测注册层(BackgroundScheduler 未启动时 + 任务进入 pending 队列,get_job/get_jobs/remove_job 均可操作 pending 任务)。 +- 通过直接构造 Dynaconf 对象传入 Scheduler,避免污染全局配置单例。 + +环境基线:APScheduler 3.11.x + pytz。 +""" + +import pytest +from dynaconf import Dynaconf +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger +from apscheduler.triggers.interval import IntervalTrigger + +from myboot.core.scheduler import Scheduler +from myboot.exceptions import SchedulerError + + +def make_scheduler(scheduler_config: dict = None, enabled=None) -> Scheduler: + """构造一个使用独立 Dynaconf 配置的 Scheduler(不依赖全局配置单例)""" + if scheduler_config is None: + config = Dynaconf() + else: + config = Dynaconf(scheduler=scheduler_config) + return Scheduler(config=config, enabled=enabled) + + +@pytest.fixture +def scheduler(): + return make_scheduler() + + +def sample_task(): + """用于注册的示例任务函数""" + pass + + +# issue #14:默认 job_id 为 {prefix}_{模块名}.{限定名} +SAMPLE_TASK_QUALNAME = f"{sample_task.__module__}.{sample_task.__qualname__}" + + +# --------------------------------------------------------------------------- +# 1. 构造与配置读取 +# --------------------------------------------------------------------------- + + +class TestSchedulerConstruction: + def test_default_enabled_is_true(self): + s = make_scheduler() + assert s.is_enabled() is True + + def test_enabled_read_from_config(self): + s = make_scheduler({"enabled": False}) + assert s.is_enabled() is False + + def test_enabled_param_overrides_config(self): + # application.py 即以 Scheduler(config=..., enabled=...) 方式覆盖 + s = make_scheduler({"enabled": False}, enabled=True) + assert s.is_enabled() is True + + def test_default_timezone_is_utc(self): + s = make_scheduler() + assert str(s._timezone) == "UTC" + + def test_not_running_before_start(self, scheduler): + assert scheduler.is_running() is False + + def test_get_config_shape(self): + s = make_scheduler({"timezone": "Asia/Shanghai", "enabled": False}) + cfg = s.get_config() + assert cfg == { + "enabled": False, + "timezone": "Asia/Shanghai", + "running": False, + "job_count": 0, + "scheduled_job_count": 0, + } + + +# --------------------------------------------------------------------------- +# 2. 时区解析 _parse_timezone +# --------------------------------------------------------------------------- + + +class TestParseTimezone: + def test_valid_timezone_string(self, scheduler): + tz = scheduler._parse_timezone("Asia/Shanghai") + assert str(tz) == "Asia/Shanghai" + + def test_invalid_timezone_falls_back_to_none(self, scheduler): + # 当前行为:解析失败仅记 warning,返回 None(即使用系统时区) + assert scheduler._parse_timezone("Not/AZone") is None + + def test_invalid_timezone_in_config_reports_system(self): + s = make_scheduler({"timezone": "Not/AZone"}) + assert s._timezone is None + assert s.get_config()["timezone"] == "system" + + def test_valid_timezone_in_config_applied(self): + s = make_scheduler({"timezone": "Asia/Shanghai"}) + assert str(s._timezone) == "Asia/Shanghai" + + +# --------------------------------------------------------------------------- +# 3. Cron 表达式解析 _parse_cron +# --------------------------------------------------------------------------- + + +class TestParseCron: + def test_standard_5_field(self, scheduler): + trigger = scheduler._parse_cron("0 2 * * *") + assert isinstance(trigger, CronTrigger) + text = str(trigger) + assert "hour='2'" in text + assert "minute='0'" in text + # 5 位格式不含秒字段 + assert "second" not in text + + def test_5_field_with_step(self, scheduler): + trigger = scheduler._parse_cron("*/15 * * * *") + assert isinstance(trigger, CronTrigger) + assert "minute='*/15'" in str(trigger) + + def test_6_field_with_seconds(self, scheduler): + # 6 位格式:秒 分 时 日 月 周(兼容旧格式,走手动解析分支) + trigger = scheduler._parse_cron("30 0 2 * * *") + assert isinstance(trigger, CronTrigger) + text = str(trigger) + assert "second='30'" in text + assert "minute='0'" in text + assert "hour='2'" in text + + def test_trigger_uses_scheduler_timezone(self): + s = make_scheduler({"timezone": "Asia/Shanghai"}) + trigger = s._parse_cron("0 2 * * *") + assert str(trigger.timezone) == "Asia/Shanghai" + + def test_too_few_fields_raises_value_error(self, scheduler): + with pytest.raises(ValueError): + scheduler._parse_cron("* * *") + + def test_too_many_fields_raises_value_error(self, scheduler): + with pytest.raises(ValueError): + scheduler._parse_cron("* * * * * * *") + + def test_out_of_range_field_value_raises_value_error(self, scheduler): + # from_crontab 与手动构造均拒绝越界值 + with pytest.raises(ValueError): + scheduler._parse_cron("99 * * * *") + + +# --------------------------------------------------------------------------- +# 4. add_cron_job / add_interval_job / add_date_job +# --------------------------------------------------------------------------- + + +class TestAddCronJob: + def test_default_job_id_format(self, scheduler): + job_id = scheduler.add_cron_job(sample_task, "0 2 * * *") + # issue #14:0.2.0 起默认 job_id 为 cron_{模块名}.{限定名}(含类名) + assert job_id == f"cron_{SAMPLE_TASK_QUALNAME}" + + def test_explicit_job_id_used_verbatim(self, scheduler): + job_id = scheduler.add_cron_job(sample_task, "0 2 * * *", job_id="my_job") + assert job_id == "my_job" + assert scheduler.get_job("my_job") is not None + # 默认 ID 没有被注册 + assert scheduler.get_job(f"cron_{SAMPLE_TASK_QUALNAME}") is None + + def test_registered_job_retrievable(self, scheduler): + job_id = scheduler.add_cron_job(sample_task, "0 2 * * *") + job = scheduler.get_job(job_id) + assert job is not None + assert job.id == job_id + assert isinstance(job.trigger, CronTrigger) + + def test_invalid_cron_raises_value_error(self, scheduler): + with pytest.raises(ValueError): + scheduler.add_cron_job(sample_task, "not a cron") + # 失败的注册不会留下任务 + assert scheduler.list_jobs() == [] + + +class TestAddIntervalJob: + def test_default_job_id_format(self, scheduler): + job_id = scheduler.add_interval_job(sample_task, 60) + # issue #14:0.2.0 起默认 job_id 为 interval_{模块名}.{限定名} + assert job_id == f"interval_{SAMPLE_TASK_QUALNAME}" + + def test_explicit_job_id_used_verbatim(self, scheduler): + job_id = scheduler.add_interval_job(sample_task, 60, job_id="tick") + assert job_id == "tick" + + def test_interval_seconds_applied(self, scheduler): + job_id = scheduler.add_interval_job(sample_task, 60) + job = scheduler.get_job(job_id) + assert isinstance(job.trigger, IntervalTrigger) + assert job.trigger.interval.total_seconds() == 60.0 + + +class TestAddDateJob: + def test_default_job_id_format(self, scheduler): + job_id = scheduler.add_date_job(sample_task, "2099-12-31 23:59:59") + # issue #14:0.2.0 起默认 job_id 为 date_{模块名}.{限定名} + assert job_id == f"date_{SAMPLE_TASK_QUALNAME}" + + def test_explicit_job_id_used_verbatim(self, scheduler): + job_id = scheduler.add_date_job( + sample_task, "2099-12-31 23:59:59", job_id="once" + ) + assert job_id == "once" + + @pytest.mark.parametrize( + "run_date", + ["2099-12-31 23:59:59", "2099-12-31 23:59", "2099-12-31"], + ) + def test_supported_date_formats(self, scheduler, run_date): + job_id = scheduler.add_date_job(sample_task, run_date) + job = scheduler.get_job(job_id) + assert isinstance(job.trigger, DateTrigger) + + def test_invalid_date_raises_value_error(self, scheduler): + with pytest.raises(ValueError): + scheduler.add_date_job(sample_task, "2099-13-01") + with pytest.raises(ValueError): + scheduler.add_date_job(sample_task, "31/12/2099") + + +# --------------------------------------------------------------------------- +# 5. 同名函数重复注册(默认 ID 冲突)的当前行为 +# --------------------------------------------------------------------------- + + +class TestDuplicateRegistration: + def test_duplicate_auto_id_disambiguated_with_uid_suffix(self, scheduler): + """0.2.0(issue #14 配套):注册时显式查重。同一函数注册两次时, + 第二个自动生成的 ID 追加 8 位 uid 后缀消歧(并打 warning), + 不再出现重复 ID 进入 pending 队列、延迟到 start() 才爆发的问题。 + """ + id1 = scheduler.add_cron_job(sample_task, "0 2 * * *") + id2 = scheduler.add_cron_job(sample_task, "0 3 * * *") + assert id1 == f"cron_{SAMPLE_TASK_QUALNAME}" + assert id2.startswith(f"cron_{SAMPLE_TASK_QUALNAME}_") + assert len(id2) == len(f"cron_{SAMPLE_TASK_QUALNAME}_") + 8 + assert sorted(scheduler.list_jobs()) == sorted([id1, id2]) + + def test_duplicate_explicit_id_raises_scheduler_error(self, scheduler): + """0.2.0(issue #14 配套):显式传入的 job_id 已存在时抛 SchedulerError""" + scheduler.add_cron_job(sample_task, "0 2 * * *", job_id="my_job") + with pytest.raises(SchedulerError, match="my_job"): + scheduler.add_cron_job(sample_task, "0 3 * * *", job_id="my_job") + # 失败的注册不留残留 + assert scheduler.list_jobs() == ["my_job"] + + def test_same_method_name_in_different_classes_no_conflict(self, scheduler): + """issue #14 核心场景:两个类中的同名方法注册互不冲突""" + + class AlphaJobs: + def job(self): + pass + + class BetaJobs: + def job(self): + pass + + id1 = scheduler.add_cron_job(AlphaJobs().job, "0 2 * * *") + id2 = scheduler.add_cron_job(BetaJobs().job, "0 3 * * *") + assert id1 != id2 + assert "AlphaJobs.job" in id1 + assert "BetaJobs.job" in id2 + + def test_remove_job_after_disambiguation(self, scheduler): + """消歧后两个任务可分别独立移除""" + id1 = scheduler.add_cron_job(sample_task, "0 2 * * *") + id2 = scheduler.add_cron_job(sample_task, "0 3 * * *") + assert scheduler.remove_job(id1) is True + assert scheduler.list_jobs() == [id2] + + +# --------------------------------------------------------------------------- +# 6. remove_job / get_job / list_jobs / has_jobs / get_job_info +# --------------------------------------------------------------------------- + + +class TestJobManagement: + def test_remove_existing_job_returns_true(self, scheduler): + job_id = scheduler.add_interval_job(sample_task, 60) + assert scheduler.remove_job(job_id) is True + assert scheduler.get_job(job_id) is None + assert scheduler.list_jobs() == [] + + def test_remove_missing_job_returns_false(self, scheduler): + assert scheduler.remove_job("does_not_exist") is False + + def test_get_missing_job_returns_none(self, scheduler): + assert scheduler.get_job("does_not_exist") is None + + def test_list_jobs_and_has_jobs(self, scheduler): + assert scheduler.has_jobs() is False + scheduler.add_cron_job(sample_task, "0 2 * * *") + scheduler.add_interval_job(sample_task, 60) + assert scheduler.has_jobs() is True + assert sorted(scheduler.list_jobs()) == sorted([ + f"cron_{SAMPLE_TASK_QUALNAME}", + f"interval_{SAMPLE_TASK_QUALNAME}", + ]) + + def test_get_job_info_on_pending_job_returns_dict(self, scheduler): + """0.2.0 修复:调度器未启动时 pending Job 没有 next_run_time 属性, + get_job_info 现在返回信息字典(next_run_time 为 None)而不是抛 + AttributeError。 + """ + job_id = scheduler.add_cron_job(sample_task, "0 2 * * *") + info = scheduler.get_job_info(job_id) + assert info is not None + assert info["job_id"] == job_id + assert info["func_name"] == "sample_task" + assert info["next_run_time"] is None + assert info["type"] == "cron" + + def test_list_all_jobs_on_pending_jobs(self, scheduler): + """list_all_jobs 同样不再因 pending 任务崩溃""" + scheduler.add_cron_job(sample_task, "0 2 * * *") + scheduler.add_interval_job(sample_task, 60) + infos = scheduler.list_all_jobs() + assert len(infos) == 2 + assert all(i is not None for i in infos) + + def test_get_job_info_missing_job_returns_none(self, scheduler): + assert scheduler.get_job_info("does_not_exist") is None diff --git a/tests/unit/test_web.py b/tests/unit/test_web.py new file mode 100644 index 0000000..bc8103b --- /dev/null +++ b/tests/unit/test_web.py @@ -0,0 +1,698 @@ +""" +Web 层特征测试(characterization tests) + +固化 myboot web 层的「当前实际行为」,作为后续重构的兼容性闸门。 +这些测试不评判行为是否正确——可疑之处照实断言并以注释标记, +重构时若行为有意变更,应同步更新对应测试。 + +覆盖范围: +- myboot/web/decorators.py 路由装饰器元数据 +- myboot/core/decorators.py 路由装饰器与 @rest_controller(auto_configuration 实际消费的那套) +- myboot/core/auto_configuration.py 的 AST 扫描 route_type 前缀(issue #8 回归)与路由注册分支 +- myboot/web/models.py BaseResponse 系列模型 +- myboot/web/response.py ResponseWrapper / ApiResponse +- myboot/web/exceptions.py HTTP 异常体系 +- myboot/exceptions.py 与 myboot/web/exceptions.py 的同名异常类二义性 +""" + +import asyncio +import textwrap +from datetime import datetime +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from myboot.core.auto_configuration import ( + AutoConfigurationError, + AutoConfigurationManager, +) +from myboot.core.decorators import delete as core_delete +from myboot.core.decorators import get as core_get +from myboot.core.decorators import patch as core_patch +from myboot.core.decorators import post as core_post +from myboot.core.decorators import put as core_put +from myboot.core.decorators import rest_controller +from myboot.core.decorators import route as core_route +from myboot.web.decorators import ( + async_route, + delete, + get, + patch, + post, + put, + route, +) +from myboot.web.exceptions import ( + HTTP_STATUS_EXCEPTIONS, + BadRequestError, + ConflictError, + ForbiddenError, + HTTPException, + InternalServerError, + MethodNotAllowedError, + NotFoundError, + ServiceUnavailableError, + TooManyRequestsError, + UnauthorizedError, + UnprocessableEntityError, + create_http_exception, +) +from myboot.web.models import ( + BaseResponse, + CreatedResponse, + DeletedResponse, + ErrorResponse, + SuccessResponse, + UpdatedResponse, + error_response, + success_response, +) +from myboot.web.response import ApiResponse, ResponseWrapper + + +def _make_manager() -> AutoConfigurationManager: + """每个测试创建独立的 manager,避免共享 discovered_components 状态。 + + AutoConfigurationManager 的组件元数据是实例级状态(非模块级全局 + registry),use_cache=False 确保不读写磁盘缓存。 + """ + return AutoConfigurationManager(app_root=".", use_cache=False) + + +# --------------------------------------------------------------------------- +# 1. myboot/web/decorators.py 路由装饰器元数据 +# --------------------------------------------------------------------------- + + +class TestWebRouteDecorators: + """myboot.web.decorators 装饰器把元数据写入 func._route_metadata""" + + def test_get_decorator_metadata(self): + @get("/users", tags=["user"]) + def handler(): + """获取用户列表""" + return [] + + meta = handler._route_metadata + assert meta["path"] == "/users" + assert meta["methods"] == ["GET"] + assert meta["status_code"] == 200 + assert meta["tags"] == ["user"] + # summary/description 未显式提供时回退到 __doc__ + assert meta["summary"] == "获取用户列表" + assert meta["description"] == "获取用户列表" + assert meta["response_model"] is None + + def test_default_status_codes_per_method(self): + # 当前行为:post 默认 201,delete 默认 204,get/put/patch 默认 200 + cases = [ + (get, ["GET"], 200), + (post, ["POST"], 201), + (put, ["PUT"], 200), + (delete, ["DELETE"], 204), + (patch, ["PATCH"], 200), + ] + for decorator, methods, status_code in cases: + @decorator("/x") + def handler(): + return None + + meta = handler._route_metadata + assert meta["methods"] == methods + assert meta["status_code"] == status_code + + def test_route_decorator_defaults_and_extra_kwargs(self): + @route("/multi", methods=["GET", "POST"], deprecated=True) + def handler(): + return None + + meta = handler._route_metadata + assert meta["methods"] == ["GET", "POST"] + # 额外 kwargs 被直接展开合并进元数据字典 + assert meta["deprecated"] is True + + @route("/default") + def handler2(): + return None + + # methods 缺省为 ["GET"] + assert handler2._route_metadata["methods"] == ["GET"] + + def test_summary_falls_back_to_empty_string_without_docstring(self): + @get("/nodoc") + def handler(): + return None + + meta = handler._route_metadata + assert meta["summary"] == "" + assert meta["description"] == "" + assert meta["tags"] == [] + + def test_decorator_returns_wrapper_that_still_works(self): + # 当前行为:装饰器返回 @wraps 包装函数(非原函数), + # functools.wraps 复制 __dict__,因此包装函数上也带 _route_metadata + @get("/calc") + def add(a, b): + return a + b + + assert add(1, 2) == 3 + assert add.__name__ == "add" + assert add._route_metadata["path"] == "/calc" + + @pytest.mark.asyncio + async def test_async_route_metadata_and_await(self): + @async_route("/async", methods=["POST"]) + async def handler(value): + return value * 2 + + meta = handler._route_metadata + assert meta["path"] == "/async" + assert meta["methods"] == ["POST"] + # async_route 额外写入 'async': True 标记 + assert meta["async"] is True + assert await handler(21) == 42 + assert asyncio.iscoroutinefunction(handler) + + +# --------------------------------------------------------------------------- +# 1b. myboot/core/decorators.py 路由装饰器(auto_configuration 消费的元数据) +# --------------------------------------------------------------------------- + + +class TestCoreRouteDecorators: + """myboot.core.decorators 装饰器把元数据写入 func.__myboot_route__""" + + def test_core_get_sets_myboot_route_and_returns_original(self): + def raw(a): + return a + + decorated = core_get("/ping", tags=["t"])(raw) + # 当前行为:core 装饰器不包装,直接返回原函数对象 + assert decorated is raw + assert raw.__myboot_route__ == { + "path": "/ping", + "methods": ["GET"], + "kwargs": {"tags": ["t"]}, + } + + def test_core_http_method_decorators(self): + cases = [ + (core_get, ["GET"]), + (core_post, ["POST"]), + (core_put, ["PUT"]), + (core_delete, ["DELETE"]), + (core_patch, ["PATCH"]), + ] + for decorator, methods in cases: + @decorator("/x") + def handler(): + return None + + assert handler.__myboot_route__["methods"] == methods + + def test_core_route_default_methods(self): + @core_route("/r") + def handler(): + return None + + assert handler.__myboot_route__["methods"] == ["GET"] + assert handler.__myboot_route__["kwargs"] == {} + + def test_rest_controller_sets_class_attribute(self): + @rest_controller("/api/items/", version=1) + class ItemController: + pass + + config = ItemController.__myboot_rest_controller__ + # base_path 尾部 / 被 rstrip 去掉 + assert config["base_path"] == "/api/items" + assert config["kwargs"] == {"version": 1} + + +# --------------------------------------------------------------------------- +# 2. route_type 前缀格式(issue #8 回归)与路由注册分支 +# --------------------------------------------------------------------------- + + +class TestRouteTypeDiscovery: + """AST 扫描产生的 type 字段格式:f'function_{dec}' / f'class_{dec}'""" + + def test_ast_scan_route_type_prefixes(self, tmp_path): + # issue #8 回归:发现阶段的 type 必须以 function_ / class_ 开头 + source = textwrap.dedent( + """ + from myboot.core.decorators import rest_controller, get, route + + @rest_controller('/api/x') + class XController: + pass + + @get('/ping') + def ping(): + return 'pong' + + @route('/r') + def plain_route(): + return 'r' + """ + ) + mod_file = tmp_path / "scanned_mod.py" + mod_file.write_text(source, encoding="utf-8") + + manager = _make_manager() + manager._scan_file_ast(mod_file, "scanned_mod") + + controllers = manager._component_metadata["rest_controllers"] + assert controllers == [ + { + "module": "scanned_mod", + "class_name": "XController", + "type": "class_rest_controller", + } + ] + + routes = manager._component_metadata["routes"] + types = {item["func_name"]: item["type"] for item in routes} + assert types == { + "ping": "function_get", + "plain_route": "function_route", + } + for item in routes: + assert item["type"].startswith("function_") or item["type"].startswith( + "class_" + ) + + def test_function_route_type_is_registered(self): + @core_route("/fr", methods=["GET"]) + def fr(): + return "x" + + calls = [] + + class FakeApp: + def add_route(self, **kwargs): + calls.append(kwargs) + + manager = _make_manager() + manager.discovered_components["routes"] = [ + {"type": "function_route", "function": fr, "module": "m"} + ] + manager._auto_register_routes(FakeApp()) + + assert len(calls) == 1 + assert calls[0]["path"] == "/fr" + assert calls[0]["handler"] is fr + assert calls[0]["methods"] == ["GET"] + + def test_function_get_route_type_registers(self): + # 0.2.0 修复(issue #8 残留):_auto_register_routes 现以前缀匹配 + # route_type,AST 扫描产生的 'function_get'/'function_post' 等 + # 模块级函数路由不再被静默跳过。 + @core_get("/fn") + def fn(): + return "x" + + calls = [] + + class FakeApp: + def add_route(self, **kwargs): + calls.append(kwargs) + + manager = _make_manager() + manager.discovered_components["routes"] = [ + {"type": "function_get", "function": fn, "module": "m"} + ] + manager._auto_register_routes(FakeApp()) + + assert len(calls) == 1 + assert calls[0]["path"] == "/fn" + assert calls[0]["methods"] == ["GET"] + + def test_class_route_type_registers_methods_without_error(self): + # 0.2.0 修复:class 类型条目没有 'function' 键,旧版收尾 debug 日志 + # 访问 route_info['function'] 抛 KeyError 导致注册必然失败; + # 现在方法路由注册成功且不再抛 AutoConfigurationError。 + @core_route("/cls") + class ClassController: + @core_get("/m") + def m(self): + return 1 + + calls = [] + + class FakeApp: + def add_route(self, **kwargs): + calls.append(kwargs) + + manager = _make_manager() + manager.discovered_components["routes"] = [ + {"type": "class_route", "class": ClassController, "module": "m"} + ] + manager._auto_register_routes(FakeApp()) + + assert len(calls) == 1 + assert calls[0]["path"] == "/m" + + +class TestRestControllerEndToEnd: + """用最小 FastAPI app + TestClient 验证 @rest_controller 注册后端点可访问 + + myboot 的注册逻辑只依赖 app.add_route 和 app.di_container, + 用桩对象把 add_route 转接到 FastAPI 即可隔离全局 Application 状态。 + """ + + def _build_client(self): + @rest_controller("/api/items") + class ItemController: + def __init__(self): + pass + + @core_get("/list") + def list_items(self): + return {"items": [1, 2]} + + @core_get("//absolute") + def absolute(self): + return {"abs": True} + + @core_get("relative") + def rel(self): + return {"rel": True} + + fastapi_app = FastAPI() + + class FakeApp: + def __init__(self): + self.di_container = SimpleNamespace(has_service=lambda name: False) + self.components = {} + self.clients = {} + + def add_route(self, path, handler, methods, **kwargs): + fastapi_app.add_api_route(path, handler, methods=methods) + + manager = _make_manager() + manager.discovered_components["rest_controllers"] = [ + { + "class": ItemController, + "module": "tests.unit.test_web", + "type": "class_rest_controller", + } + ] + manager._auto_register_rest_controllers(FakeApp()) + return TestClient(fastapi_app) + + def test_method_path_appended_to_base_path(self): + client = self._build_client() + resp = client.get("/api/items/list") + assert resp.status_code == 200 + assert resp.json() == {"items": [1, 2]} + + def test_double_slash_prefix_means_absolute_path(self): + # 路径合并规则:方法路径以 // 开头时作为绝对路径(去掉一个 /), + # 不拼接 base_path + client = self._build_client() + resp = client.get("/absolute") + assert resp.status_code == 200 + assert resp.json() == {"abs": True} + # base_path 下不存在该路由 + assert client.get("/api/items/absolute").status_code == 404 + + def test_relative_path_appended_with_slash(self): + client = self._build_client() + resp = client.get("/api/items/relative") + assert resp.status_code == 200 + assert resp.json() == {"rel": True} + + +# --------------------------------------------------------------------------- +# 3. BaseResponse 响应结构 +# --------------------------------------------------------------------------- + + +class TestBaseResponse: + def test_defaults(self): + r = BaseResponse() + assert r.success is True + assert r.message == "操作成功" + assert r.data is None + assert isinstance(r.timestamp, datetime) + + def test_model_dump_field_names(self): + dumped = BaseResponse().model_dump() + # 字段名固定为这 4 个 + assert sorted(dumped.keys()) == ["data", "message", "success", "timestamp"] + assert dumped["success"] is True + assert dumped["message"] == "操作成功" + # 当前行为:data=None 也会出现在 model_dump 结果中 + assert dumped["data"] is None + assert isinstance(dumped["timestamp"], datetime) + + def test_error_response_defaults(self): + r = ErrorResponse() + assert r.success is False + assert r.message == "操作失败" + assert r.error_code is None + assert r.details is None + dumped = r.model_dump() + assert sorted(dumped.keys()) == [ + "data", + "details", + "error_code", + "message", + "success", + "timestamp", + ] + + def test_crud_response_defaults(self): + assert SuccessResponse().message == "操作成功" + + created = CreatedResponse() + assert created.message == "创建成功" + # 当前行为:status_code 是模型字段,会出现在序列化结果里 + assert created.status_code == 201 + assert created.model_dump()["status_code"] == 201 + + assert UpdatedResponse().status_code == 200 + assert UpdatedResponse().message == "更新成功" + + deleted = DeletedResponse() + assert deleted.status_code == 204 + assert deleted.message == "删除成功" + + def test_helper_functions(self): + ok = success_response(data={"id": 1}, message="done") + assert isinstance(ok, SuccessResponse) + assert ok.data == {"id": 1} + assert ok.message == "done" + + err = error_response(message="bad", error_code="E1", details={"k": "v"}) + assert isinstance(err, ErrorResponse) + assert err.success is False + assert err.error_code == "E1" + assert err.details == {"k": "v"} + + +# --------------------------------------------------------------------------- +# 4. ResponseWrapper 包装行为 +# --------------------------------------------------------------------------- + + +class TestResponseWrapper: + def test_success_minimal_omits_none_fields(self): + # message/data 为 None 时根本不出现在返回字典中 + assert ResponseWrapper.success() == {"success": True, "code": 200} + + def test_success_with_data_and_message(self): + result = ResponseWrapper.success(data=[1], message="ok", code=200) + assert result == {"success": True, "code": 200, "message": "ok", "data": [1]} + + def test_error_defaults_to_500(self): + assert ResponseWrapper.error() == {"success": False, "code": 500} + result = ResponseWrapper.error(message="boom", code=400, data={"f": 1}) + assert result == { + "success": False, + "code": 400, + "message": "boom", + "data": {"f": 1}, + } + + def test_created_updated_deleted_no_content_codes(self): + assert ResponseWrapper.created(data={"id": 1}) == { + "success": True, + "code": 201, + "data": {"id": 1}, + } + assert ResponseWrapper.updated() == {"success": True, "code": 200} + # 可疑现状:deleted() 的 code 是 200(文档注释也写明 200), + # 而 DeletedResponse 模型的 status_code 默认 204,两处不一致 + assert ResponseWrapper.deleted() == {"success": True, "code": 200} + # no_content() 标记 204 但仍返回非空响应体字典 + assert ResponseWrapper.no_content() == {"success": True, "code": 204} + + def test_pagination_structure(self): + result = ResponseWrapper.pagination(data=[1, 2], total=5, page=1, size=2) + assert result["success"] is True + assert result["code"] == 200 + # 分页字段为 camelCase(totalPages/hasNext/hasPrev), + # 与 PaginationResponse 模型的 snake_case(total_pages 等)不一致 + assert result["data"] == { + "list": [1, 2], + "pagination": { + "total": 5, + "page": 1, + "size": 2, + "totalPages": 3, + "hasNext": True, + "hasPrev": False, + }, + } + + def test_pagination_size_zero_total_pages_zero(self): + result = ResponseWrapper.pagination(data=[], total=0, page=1, size=0) + assert result["data"]["pagination"]["totalPages"] == 0 + + def test_wrap_plain_data(self): + assert ResponseWrapper.wrap({"id": 1}) == { + "success": True, + "code": 200, + "data": {"id": 1}, + } + assert ResponseWrapper.wrap("text", message="m", code=201) == { + "success": True, + "code": 201, + "message": "m", + "data": "text", + } + + def test_wrap_passthrough_when_keys_present(self): + # 当前行为:只要 dict 同时含 "success" 和 "code" 键就原样透传, + # 即使是手工构造、值类型任意的字典也不会被再包装 + already = {"success": False, "code": 500, "message": "x"} + assert ResponseWrapper.wrap(already) is already + fake = {"success": "yes", "code": "200", "extra": 1} + assert ResponseWrapper.wrap(fake) is fake + + def test_api_response_model_dump_includes_none(self): + # ConfigDict(exclude_none=True) 在 FastAPI JSON 响应中生效; + # model_dump() 默认仍包含 None 字段,需显式 exclude_none=True 才会排除 + r = ApiResponse(success=True, code=200) + assert r.model_dump() == { + "success": True, + "code": 200, + "message": None, + "data": None, + } + assert r.model_dump(exclude_none=True) == {"success": True, "code": 200} + + +# --------------------------------------------------------------------------- +# 5. HTTP 异常体系 +# --------------------------------------------------------------------------- + + +class TestHTTPExceptions: + def test_http_status_exceptions_mapping_complete(self): + # 10 个状态码齐全,映射到对应异常类 + assert HTTP_STATUS_EXCEPTIONS == { + 400: BadRequestError, + 401: UnauthorizedError, + 403: ForbiddenError, + 404: NotFoundError, + 405: MethodNotAllowedError, + 409: ConflictError, + 422: UnprocessableEntityError, + 429: TooManyRequestsError, + 500: InternalServerError, + 503: ServiceUnavailableError, + } + assert len(HTTP_STATUS_EXCEPTIONS) == 10 + + def test_each_exception_default_status_code_and_message(self): + expected = { + BadRequestError: (400, "错误请求"), + UnauthorizedError: (401, "未授权"), + ForbiddenError: (403, "禁止访问"), + NotFoundError: (404, "未找到"), + MethodNotAllowedError: (405, "方法不允许"), + ConflictError: (409, "资源冲突"), + UnprocessableEntityError: (422, "无法处理的实体"), + TooManyRequestsError: (429, "请求过多"), + InternalServerError: (500, "内部服务器错误"), + ServiceUnavailableError: (503, "服务不可用"), + } + for exc_class, (status_code, message) in expected.items(): + exc = exc_class() + assert exc.status_code == status_code + assert exc.message == message + # details 缺省为 {}(非 None) + assert exc.details == {} + assert isinstance(exc, HTTPException) + assert str(exc) == message + + def test_exception_with_custom_message_and_details(self): + exc = NotFoundError(message="用户不存在", details={"user_id": 1}) + assert exc.status_code == 404 + assert exc.message == "用户不存在" + assert exc.details == {"user_id": 1} + + def test_create_http_exception_unknown_status_returns_base(self): + exc = create_http_exception(418, "teapot", {"hint": "rfc2324"}) + assert type(exc) is HTTPException + assert exc.status_code == 418 + assert exc.message == "teapot" + assert exc.details == {"hint": "rfc2324"} + + def test_create_http_exception_known_status_returns_mapped_subclass(self): + # 0.2.0 修复:旧版对已知状态码用三个位置参数调用 (message, details) + # 签名的子类,对全部 10 个已知状态码都抛 TypeError; + # 现在已知状态码返回对应子类实例 + for status_code, exc_class in HTTP_STATUS_EXCEPTIONS.items(): + exc = create_http_exception(status_code, "msg", {"k": "v"}) + assert isinstance(exc, exc_class) + assert exc.status_code == status_code + assert exc.message == "msg" + assert exc.details == {"k": "v"} + + +# --------------------------------------------------------------------------- +# 6. myboot/exceptions.py 与 myboot/web/exceptions.py 同名类二义性 +# --------------------------------------------------------------------------- + + +class TestExceptionClassUnification: + """0.2.0 起两套同名异常收敛为同一个类(以 myboot.exceptions 为准), + web 路径保留 re-export,import 兼容但身份唯一""" + + def test_validation_error_is_same_class(self): + from myboot.exceptions import MyBootException + from myboot.exceptions import ValidationError as CoreValidationError + from myboot.web.exceptions import ValidationError as WebValidationError + + # 0.2.0:同一个类对象,except 任一路径都能捕获 + assert CoreValidationError is WebValidationError + + exc = WebValidationError(field="name", value="x") + assert isinstance(exc, MyBootException) + assert exc.code == "VALIDATION_ERROR" + assert exc.message == "验证失败" + assert exc.field == "name" + assert exc.value == "x" + # 旧 web 版独有的 error_type 参数已移除(以核心版签名为准) + assert not hasattr(exc, "error_type") + + def test_configuration_error_is_same_class(self): + from myboot.exceptions import ConfigurationError as CoreConfigurationError + from myboot.exceptions import MyBootException + from myboot.web.exceptions import ( + ConfigurationError as WebConfigurationError, + ) + + assert CoreConfigurationError is WebConfigurationError + + exc = WebConfigurationError(config_key="app.name") + assert isinstance(exc, MyBootException) + assert exc.code == "CONFIGURATION_ERROR" + assert exc.config_key == "app.name" + assert exc.details == {} diff --git a/tests/unit/test_worker_sync.py b/tests/unit/test_worker_sync.py new file mode 100644 index 0000000..e956747 --- /dev/null +++ b/tests/unit/test_worker_sync.py @@ -0,0 +1,135 @@ +"""run_primary_first / clear_markers 单元测试""" + +import os +import threading +import time + +import pytest + +from myboot.utils import worker_sync +from myboot.utils.worker_sync import run_primary_first, clear_markers + + +@pytest.fixture(autouse=True) +def isolated_marker_dir(tmp_path, monkeypatch): + """每个测试使用独立的标记目录,避免相互污染。""" + import tempfile + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + monkeypatch.setenv("MYBOOT_APP_NAME", "testapp") + monkeypatch.delenv("MYBOOT_IS_PRIMARY_WORKER", raising=False) + yield tmp_path + + +def _marker_dir(tmp_path): + return tmp_path / "myboot_worker_sync_testapp" + + +def test_single_process_passthrough(): + """无环境变量时默认 primary,直通执行并返回值。""" + assert run_primary_first("m1", lambda: 42) == 42 + + +def test_primary_writes_done(monkeypatch, isolated_marker_dir): + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "1") + assert run_primary_first("model", lambda: "ok") == "ok" + done = _marker_dir(isolated_marker_dir) / "model.done" + assert done.exists() + assert done.read_text(encoding="utf-8") # ISO 时间戳非空 + + +def test_secondary_waits_then_runs_secondary_fn(monkeypatch, isolated_marker_dir): + """secondary 在另一线程先等待,primary 后完成 → secondary 执行 secondary_fn。""" + results = {} + + def secondary_thread(): + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "0") + results["secondary"] = run_primary_first( + "model", + primary_fn=lambda: "heavy", + secondary_fn=lambda: "light", + timeout=5.0, + poll_interval=0.05, + ) + + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "0") + t = threading.Thread(target=secondary_thread) + t.start() + time.sleep(0.2) + # primary 完成 + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "1") + assert run_primary_first("model", lambda: "heavy") == "heavy" + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "0") + t.join(timeout=5) + assert not t.is_alive() + assert results["secondary"] == "light" + + +def test_secondary_defaults_to_primary_fn(monkeypatch, isolated_marker_dir): + """secondary_fn 为 None 时 secondary 执行 primary_fn。""" + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "1") + run_primary_first("m2", lambda: 1) + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "0") + assert run_primary_first("m2", lambda: 1, timeout=1.0, poll_interval=0.05) == 1 + + +def test_primary_failure_writes_failed_and_raises(monkeypatch, isolated_marker_dir): + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "1") + + def boom(): + raise ValueError("download failed") + + with pytest.raises(ValueError, match="download failed"): + run_primary_first("model", boom) + + failed = _marker_dir(isolated_marker_dir) / "model.failed" + assert failed.exists() + assert "download failed" in failed.read_text(encoding="utf-8") + + # secondary 检测到 .failed → RuntimeError 含异常信息 + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "0") + with pytest.raises(RuntimeError, match="download failed"): + run_primary_first("model", lambda: 1, timeout=2.0, poll_interval=0.05) + + +def test_secondary_timeout(monkeypatch): + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "0") + with pytest.raises(TimeoutError): + run_primary_first("never", lambda: 1, timeout=0.3, poll_interval=0.05) + + +def test_stale_done_marker_ignored(monkeypatch, isolated_marker_dir): + """上一轮遗留的旧 .done(mtime 过旧)不会让 secondary 跳过等待。""" + d = _marker_dir(isolated_marker_dir) + d.mkdir(parents=True, exist_ok=True) + stale = d / "model.done" + stale.write_text("old-run", encoding="utf-8") + old = worker_sync._MODULE_LOAD_TIME - 100 + os.utime(stale, (old, old)) + + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "0") + with pytest.raises(TimeoutError): + run_primary_first("model", lambda: 1, timeout=0.3, poll_interval=0.05) + + +def test_primary_clears_stale_markers(monkeypatch, isolated_marker_dir): + """primary 执行前清理旧标记,成功后 .failed 不残留。""" + d = _marker_dir(isolated_marker_dir) + d.mkdir(parents=True, exist_ok=True) + (d / "model.failed").write_text("old failure", encoding="utf-8") + + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "1") + assert run_primary_first("model", lambda: "ok") == "ok" + assert not (d / "model.failed").exists() + assert (d / "model.done").exists() + + +def test_clear_markers(monkeypatch, isolated_marker_dir): + monkeypatch.setenv("MYBOOT_IS_PRIMARY_WORKER", "1") + run_primary_first("a", lambda: 1) + run_primary_first("b", lambda: 1) + d = _marker_dir(isolated_marker_dir) + clear_markers("a") + assert not (d / "a.done").exists() + assert (d / "b.done").exists() + clear_markers() + assert not (d / "b.done").exists() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8e5b8ff --- /dev/null +++ b/uv.lock @@ -0,0 +1,2039 @@ +version = 1 +revision = 2 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload-time = "2025-10-24T14:57:10.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload-time = "2025-10-24T14:57:09.462Z" }, +] + +[[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.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "black" +version = "25.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/40/dbe31fc56b218a858c8fc6f5d8d3ba61c1fa7e989d43d4a4574b8b992840/black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7", size = 1715605, upload-time = "2025-09-19T00:36:13.483Z" }, + { url = "https://files.pythonhosted.org/packages/92/b2/f46800621200eab6479b1f4c0e3ede5b4c06b768e79ee228bc80270bcc74/black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92", size = 1571829, upload-time = "2025-09-19T00:32:42.13Z" }, + { url = "https://files.pythonhosted.org/packages/4e/64/5c7f66bd65af5c19b4ea86062bb585adc28d51d37babf70969e804dbd5c2/black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713", size = 1631888, upload-time = "2025-09-19T00:30:54.212Z" }, + { url = "https://files.pythonhosted.org/packages/3b/64/0b9e5bfcf67db25a6eef6d9be6726499a8a72ebab3888c2de135190853d3/black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1", size = 1327056, upload-time = "2025-09-19T00:31:08.877Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f4/7531d4a336d2d4ac6cc101662184c8e7d068b548d35d874415ed9f4116ef/black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa", size = 1698727, upload-time = "2025-09-19T00:31:14.264Z" }, + { url = "https://files.pythonhosted.org/packages/28/f9/66f26bfbbf84b949cc77a41a43e138d83b109502cd9c52dfc94070ca51f2/black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d", size = 1555679, upload-time = "2025-09-19T00:31:29.265Z" }, + { url = "https://files.pythonhosted.org/packages/bf/59/61475115906052f415f518a648a9ac679d7afbc8da1c16f8fdf68a8cebed/black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608", size = 1617453, upload-time = "2025-09-19T00:30:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5b/20fd5c884d14550c911e4fb1b0dae00d4abb60a4f3876b449c4d3a9141d5/black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f", size = 1333655, upload-time = "2025-09-19T00:30:56.715Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8e/319cfe6c82f7e2d5bfb4d3353c6cc85b523d677ff59edc61fdb9ee275234/black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0", size = 1742012, upload-time = "2025-09-19T00:33:08.678Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/f562fe5d0a40cd2a4e6ae3f685e4c36e365b1f7e494af99c26ff7f28117f/black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4", size = 1581421, upload-time = "2025-09-19T00:35:25.937Z" }, + { url = "https://files.pythonhosted.org/packages/84/67/6db6dff1ebc8965fd7661498aea0da5d7301074b85bba8606a28f47ede4d/black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e", size = 1655619, upload-time = "2025-09-19T00:30:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/10/10/3faef9aa2a730306cf469d76f7f155a8cc1f66e74781298df0ba31f8b4c8/black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a", size = 1342481, upload-time = "2025-09-19T00:31:29.625Z" }, + { url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165, upload-time = "2025-09-19T00:34:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259, upload-time = "2025-09-19T00:32:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583, upload-time = "2025-09-19T00:30:44.505Z" }, + { url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428, upload-time = "2025-09-19T00:32:13.839Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/0f724eb152bc9fc03029a9c903ddd77a288285042222a381050d27e64ac1/black-25.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef69351df3c84485a8beb6f7b8f9721e2009e20ef80a8d619e2d1788b7816d47", size = 1715243, upload-time = "2025-09-19T00:34:14.216Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/cb986ea2f0fabd0ee58668367724ba16c3a042842e9ebe009c139f8221c9/black-25.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e3c1f4cd5e93842774d9ee4ef6cd8d17790e65f44f7cdbaab5f2cf8ccf22a823", size = 1571246, upload-time = "2025-09-19T00:31:39.624Z" }, + { url = "https://files.pythonhosted.org/packages/82/ce/74cf4d66963fca33ab710e4c5817ceeff843c45649f61f41d88694c2e5db/black-25.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:154b06d618233fe468236ba1f0e40823d4eb08b26f5e9261526fde34916b9140", size = 1631265, upload-time = "2025-09-19T00:31:05.341Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f3/9b11e001e84b4d1721f75e20b3c058854a748407e6fc1abe6da0aa22014f/black-25.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:e593466de7b998374ea2585a471ba90553283fb9beefcfa430d84a2651ed5933", size = 1326615, upload-time = "2025-09-19T00:31:25.347Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/46/7c/0c4760bccf082737ca7ab84a4c2034fcc06b1f21cf3032ea98bd6feb1725/charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", size = 209609, upload-time = "2025-10-14T04:42:10.922Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a4/69719daef2f3d7f1819de60c9a6be981b8eeead7542d5ec4440f3c80e111/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", size = 149029, upload-time = "2025-10-14T04:42:12.38Z" }, + { url = "https://files.pythonhosted.org/packages/e6/21/8d4e1d6c1e6070d3672908b8e4533a71b5b53e71d16828cc24d0efec564c/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608", size = 144580, upload-time = "2025-10-14T04:42:13.549Z" }, + { url = "https://files.pythonhosted.org/packages/a7/0a/a616d001b3f25647a9068e0b9199f697ce507ec898cacb06a0d5a1617c99/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", size = 162340, upload-time = "2025-10-14T04:42:14.892Z" }, + { url = "https://files.pythonhosted.org/packages/85/93/060b52deb249a5450460e0585c88a904a83aec474ab8e7aba787f45e79f2/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", size = 159619, upload-time = "2025-10-14T04:42:16.676Z" }, + { url = "https://files.pythonhosted.org/packages/dd/21/0274deb1cc0632cd587a9a0ec6b4674d9108e461cb4cd40d457adaeb0564/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", size = 153980, upload-time = "2025-10-14T04:42:17.917Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/e3d7d982858dccc11b31906976323d790dded2017a0572f093ff982d692f/charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", size = 152174, upload-time = "2025-10-14T04:42:19.018Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ff/4a269f8e35f1e58b2df52c131a1fa019acb7ef3f8697b7d464b07e9b492d/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", size = 151666, upload-time = "2025-10-14T04:42:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/da/c9/ec39870f0b330d58486001dd8e532c6b9a905f5765f58a6f8204926b4a93/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", size = 145550, upload-time = "2025-10-14T04:42:21.324Z" }, + { url = "https://files.pythonhosted.org/packages/75/8f/d186ab99e40e0ed9f82f033d6e49001701c81244d01905dd4a6924191a30/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", size = 163721, upload-time = "2025-10-14T04:42:22.46Z" }, + { url = "https://files.pythonhosted.org/packages/96/b1/6047663b9744df26a7e479ac1e77af7134b1fcf9026243bb48ee2d18810f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", size = 152127, upload-time = "2025-10-14T04:42:23.712Z" }, + { url = "https://files.pythonhosted.org/packages/59/78/e5a6eac9179f24f704d1be67d08704c3c6ab9f00963963524be27c18ed87/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", size = 161175, upload-time = "2025-10-14T04:42:24.87Z" }, + { url = "https://files.pythonhosted.org/packages/e5/43/0e626e42d54dd2f8dd6fc5e1c5ff00f05fbca17cb699bedead2cae69c62f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", size = 155375, upload-time = "2025-10-14T04:42:27.246Z" }, + { url = "https://files.pythonhosted.org/packages/e9/91/d9615bf2e06f35e4997616ff31248c3657ed649c5ab9d35ea12fce54e380/charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", size = 99692, upload-time = "2025-10-14T04:42:28.425Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/6c040053909d9d1ef4fcab45fddec083aedc9052c10078339b47c8573ea8/charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", size = 107192, upload-time = "2025-10-14T04:42:29.482Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c6/4fa536b2c0cd3edfb7ccf8469fa0f363ea67b7213a842b90909ca33dd851/charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", size = 100220, upload-time = "2025-10-14T04:42:30.632Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[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.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "coverage" +version = "7.11.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/89/12/3e2d2ec71796e0913178478e693a06af6a3bc9f7f9cb899bf85a426d8370/coverage-7.11.1.tar.gz", hash = "sha256:b4b3a072559578129a9e863082a2972a2abd8975bc0e2ec57da96afcd6580a8a", size = 814037, upload-time = "2025-11-07T10:52:41.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/15/6d7162366ed0508686dd68a716260bb3e2686fbce9e1acb6a42fa07cbc19/coverage-7.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:057c0aedcade895c0d25c06daff00fb381dea8089434ec916e59b051e5dead68", size = 216603, upload-time = "2025-11-07T10:49:45.154Z" }, + { url = "https://files.pythonhosted.org/packages/74/87/37ad9c35a3e5376f437c20a0fb01e20c4841afbf75328eb37d66dd87242d/coverage-7.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ea73d4b5a489ea60ebce592ea516089d2bee8b299fb465fdd295264da98b2480", size = 217120, upload-time = "2025-11-07T10:49:47.95Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/4a1f7f679018c189c97a48f215275fe9e31e6a4db0135aac755c08224310/coverage-7.11.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:63f837e043f7f0788c2ce8fc6bbbcc3579f123af9cb284e1334099969222ceab", size = 243865, upload-time = "2025-11-07T10:49:49.716Z" }, + { url = "https://files.pythonhosted.org/packages/38/3f/5678792f90d4c8467531a4db9b66a8929cee0c9f28a8f5fed0e94d7e1d3e/coverage-7.11.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:086764f9fa6f4fa57035ed1c2387501c57092f2159bf1be0f090f85f9042ccf2", size = 245693, upload-time = "2025-11-07T10:49:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0e6d9d3ec92307b67eb735b451cbead5d0307dc43f6ef1faf3f280abd68b/coverage-7.11.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a30a6ba3b668227d5a6f9f6ac2d875117af20f260ddc01619487174036a5583", size = 247552, upload-time = "2025-11-07T10:49:53.826Z" }, + { url = "https://files.pythonhosted.org/packages/98/d4/5600ae43bfeb9cea2b7ea2cd6a3c5a064533cdb53696a35b7bd8e288396b/coverage-7.11.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2663b19df42932a2cd66e62783f4bbbca047853ede893d48f3271c5e12c89246", size = 244515, upload-time = "2025-11-07T10:49:55.632Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b3/73a5033b46d8193b775ed6768f05c63dc4f9402834c56d6f456cc92175bb/coverage-7.11.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8c6570122b2eafaa5f4b54700b6f17ee10e23c5cf4292fa9b5a00e9dc279a74", size = 245596, upload-time = "2025-11-07T10:49:58.138Z" }, + { url = "https://files.pythonhosted.org/packages/72/57/40abaeacf2a78c22983183e0d44145ef64256ab12d35635d89fe08337475/coverage-7.11.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2cf57b5be59d36d133c06103f50c72bfdba7c7624d68b443b16a2d2d4eb40424", size = 243605, upload-time = "2025-11-07T10:49:59.73Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a5/796f3a21bdde952568e0cadf825269c74c33ae82966e46283075e3babb80/coverage-7.11.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f3f3eb204cbe221ef9209e34341b3d0bc32f4cf3c7c4f150db571e20b9963ecd", size = 243867, upload-time = "2025-11-07T10:50:01.164Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/2071cb65945737f5d82eebcdfb7b869c56c0f521e1af4af6f6b0a80cfe62/coverage-7.11.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:57d36cb40ad55fe443bb2390c759c61b9fa3afc68d5446a2aaed1ad18fc92752", size = 244485, upload-time = "2025-11-07T10:50:03.086Z" }, + { url = "https://files.pythonhosted.org/packages/45/c5/599efe919c50d4069029fa59696f7ec106a70eb0e92b8a2f7a5f8afd0980/coverage-7.11.1-cp310-cp310-win32.whl", hash = "sha256:999a82a2dec9e31df7cb49a17e6b564b76fab3f9cd76788280997b5a694b8025", size = 219176, upload-time = "2025-11-07T10:50:04.432Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8c/022c91f0f0e08918991bff99bdc961a60b0585397f78e9885414c9e20f0f/coverage-7.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:d47ad0fdc96d5772fcded1a57f042a72dba893a226d3efa5802d0bfa88e3a9a1", size = 220112, upload-time = "2025-11-07T10:50:06.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/09/7d035b041643d4d99c8ea374b7f0363ebb5edf02121ea4bfddaf7f738e08/coverage-7.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9f8be6327cb57e73f1933a111b31ca3e8db68eba70921244296cd9541f8405cf", size = 216729, upload-time = "2025-11-07T10:50:07.543Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d0/3b31528bb14c2dc498c09804ee4bfe3e17ca28b1de6c2e3e850c99ed2b39/coverage-7.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3386b3d974eea5b8fbc31388c2847d5b3ce783aa001048c7c13ad0e0f9f97284", size = 217232, upload-time = "2025-11-07T10:50:09.064Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1c/713bd524fec4d3d1d2813de0fad233d4ff9e3bbd9bf8f8052bb0359e0f3f/coverage-7.11.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd5a0e53989aa0d2b94871ac9a990f7b6247c3afe49af77f8750d7bcf1e66efa", size = 247628, upload-time = "2025-11-07T10:50:10.609Z" }, + { url = "https://files.pythonhosted.org/packages/b2/05/2887d76a5e160eb1b62dc99b1f177052799c37134d38e8b208e01bd4d712/coverage-7.11.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e17d99e4a9989ccc52d672543ed9d8741d90730ba331d452793be5733b4fee58", size = 249545, upload-time = "2025-11-07T10:50:12.187Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/bb95b8396a7c8deb0426a1261d62851b28a380a849546f730a8ee36471f7/coverage-7.11.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2ece0ace8d8fc20cc29e2108d4031517c03d9e08883f10c1df16bef84d469110", size = 251658, upload-time = "2025-11-07T10:50:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/1397eaee431b43dbe2ec683401c8341d65993434d69f3a36087c5c280fb1/coverage-7.11.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54bf4a13bfcf6f07c4b7d83970074dc2fa8b5782e8dee962f5eb4dfbc3a275ef", size = 247742, upload-time = "2025-11-07T10:50:16.001Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ea/b71c504fe7fd58495ccabe1cd4afd7e5685d563e2019ae4865cb0b44f652/coverage-7.11.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b48e85160795648323fc3a9d8efe11be65a033b564e1db28b53866810da6cf35", size = 249351, upload-time = "2025-11-07T10:50:17.852Z" }, + { url = "https://files.pythonhosted.org/packages/10/35/e44cb3d633cdeec7c6def511f552494a16bfa4e6cb5e916d9a0d4c98a933/coverage-7.11.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4b77e7bb5765988a7a80463b999085cd66c6515113fc88b46910217f19ee99fe", size = 247423, upload-time = "2025-11-07T10:50:19.439Z" }, + { url = "https://files.pythonhosted.org/packages/af/88/c344ab065706a9df03b558fe4bcb9d367f92d5983f6a672c03eeb0905d39/coverage-7.11.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ce345819ddedcbe797d8ba824deeb0d55710037dfd47efd95709ab9e1b841e0c", size = 247150, upload-time = "2025-11-07T10:50:20.919Z" }, + { url = "https://files.pythonhosted.org/packages/34/5b/b0b6c986e41c6072d0c57761e648c120a34b1004f7de5b90bda5cb7542eb/coverage-7.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:abde2bd52560527124d9e6515daa1f1e3c7e820a37af63d063723867775220aa", size = 248047, upload-time = "2025-11-07T10:50:22.599Z" }, + { url = "https://files.pythonhosted.org/packages/06/2b/aa232a409b63422910e180ccd5f7083e6e41d942608f3513e617006c0253/coverage-7.11.1-cp311-cp311-win32.whl", hash = "sha256:049883a469ec823b1c9556050380e61f580d52f8abfc8be2071f3512a2bc3859", size = 219201, upload-time = "2025-11-07T10:50:24.513Z" }, + { url = "https://files.pythonhosted.org/packages/41/d4/ec0155c883ddc43b2ff08e3b88fc846a4642a117306f8891188f217bd823/coverage-7.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:784a9fe33335296857db05b97dcb16df811418515a2355fc4811b0c2b029b4be", size = 220134, upload-time = "2025-11-07T10:50:26.035Z" }, + { url = "https://files.pythonhosted.org/packages/71/59/96dc2070a2f124e27c9b8d6e45e35d44f01b056b6eaf6793bfff40e84c4a/coverage-7.11.1-cp311-cp311-win_arm64.whl", hash = "sha256:2bcfeb983a53f0d3ee3ebc004827723d8accb619f64bf90aff73b7703dfe14bd", size = 218807, upload-time = "2025-11-07T10:50:27.685Z" }, + { url = "https://files.pythonhosted.org/packages/0f/31/04af7e42fdb3681e4d73d37bf3f375f0488aa38d1001ee746c7dbfe09643/coverage-7.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:421e2d237dcecdefa9b77cae1aa0dfff5c495f29e053e776172457e289976311", size = 216896, upload-time = "2025-11-07T10:50:31.429Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e9/1c3628a1225bdea66295a117cd2bb1d324d9c433c40078b24d50f55448a7/coverage-7.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:08ef89c812072ecd52a862b46e131f75596475d23cc7f5a75410394341d4332f", size = 217261, upload-time = "2025-11-07T10:50:33.008Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/4d4f943da23c432b2bba8664f4eada9b19911081852e8cc89776c61d0b94/coverage-7.11.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bc6e0b2d6ed317810b4e435ffabc31b2d517d6ceb4183dfd6af4748c52d170eb", size = 248742, upload-time = "2025-11-07T10:50:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e1/c4b42f02fbb6ce08e05d7a2b26bcf5df11d3e67a3806e40415f7ab9511e7/coverage-7.11.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b59736704df8b1f8b1dafb36b16f2ef8a952e4410465634442459426bd2319ae", size = 251503, upload-time = "2025-11-07T10:50:36.501Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3df60e88f1dabccae4994c6df4a2f23d4cd0eee27fc3ae8f0bb2e78cb538/coverage-7.11.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:843816452d8bfc4c2be72546b3b382850cb91150feaa963ec7d2b665ec9d4768", size = 252590, upload-time = "2025-11-07T10:50:38.059Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/2b9fae11361b0348c2d3612a8179d2cc8b6b245e8b14d5479c75b9f18613/coverage-7.11.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19363046125d4a423c25d3d7c90bab3a0230932c16014198f87a6b3960c1b187", size = 249133, upload-time = "2025-11-07T10:50:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2b/e33712a8eede02762a536bdc2f89e736e0ad87bd13b35d724306585aeb54/coverage-7.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e37486aed7045c280ebdc207026bdef9267730177d929a5e25250e1f33cc125", size = 250524, upload-time = "2025-11-07T10:50:41.59Z" }, + { url = "https://files.pythonhosted.org/packages/84/c9/6181877977a0f6e46b9c93a8382b8c671769fb12df8a15be8d6091541b77/coverage-7.11.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7c68180e67b4843674bfb1d3ec928ffcfc94081b5da959e616405eca51c23356", size = 248673, upload-time = "2025-11-07T10:50:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d6/ff26c2eb57d4dcd46c6ed136d6b04aceb7f58f48dcc500c77f7194711a6f/coverage-7.11.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cf825b60f94d1706c22d4887310db26cc3117d545ac6ad4229b4a0d718afcf9a", size = 248251, upload-time = "2025-11-07T10:50:45.069Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ff/411803f1fcb9efe00afbc96442564cc691f537541a8bde377cf1ac04e695/coverage-7.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:437149272ff0440df66044bd6ee87cbc252463754ca43cafa496cfb2f57f56dd", size = 250111, upload-time = "2025-11-07T10:50:46.701Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9f/781c045e1e5f8930f8266f224318040413b60837749d2ed11883b7478c81/coverage-7.11.1-cp312-cp312-win32.whl", hash = "sha256:98ea0b8d1addfc333494c2248af367e8ecb27724a99804a18376b801f876da58", size = 219407, upload-time = "2025-11-07T10:50:48.862Z" }, + { url = "https://files.pythonhosted.org/packages/26/59/813d8eedc96a781e8a6f9c37f6ecb4326ebbffdafe2e1154ed2def468b76/coverage-7.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d49a473799e55a465bcadd19525977ab80031b8b86baaa622241808df4585cd", size = 220220, upload-time = "2025-11-07T10:50:51.576Z" }, + { url = "https://files.pythonhosted.org/packages/63/5f/c0905d9159d38194943a21d7d013f1c2f0c43e7d63f680ed56269728418a/coverage-7.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:0c77e5951ab176a6ccb70c6f688fca2a7ac834753ba82ee4eb741be655f30b43", size = 218856, upload-time = "2025-11-07T10:50:53.591Z" }, + { url = "https://files.pythonhosted.org/packages/f4/01/0c50c318f5e8f1a482da05d788d0ff06137803ed8fface4a1ba51e04b3ad/coverage-7.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:da9930594ca99d66eb6f613d7beba850db2f8dfa86810ee35ae24e4d5f2bb97d", size = 216920, upload-time = "2025-11-07T10:50:55.992Z" }, + { url = "https://files.pythonhosted.org/packages/20/11/9f038e6c2baea968c377ab355b0d1d0a46b5f38985691bf51164e1b78c1f/coverage-7.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc47a280dc014220b0fc6e5f55082a3f51854faf08fd9635b8a4f341c46c77d3", size = 217301, upload-time = "2025-11-07T10:50:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/68/cd/9dcf93d81d0cddaa0bba90c3b4580e6f1ddf833918b816930d250cc553a4/coverage-7.11.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:74003324321bbf130939146886eddf92e48e616b5910215e79dea6edeb8ee7c8", size = 248277, upload-time = "2025-11-07T10:50:59.442Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/b2c7c494046c9c783d3cac4c812fc24d6104dd36a7a598e7dd6fea3e7927/coverage-7.11.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:211f7996265daab60a8249af4ca6641b3080769cbedcffc42cc4841118f3a305", size = 250871, upload-time = "2025-11-07T10:51:01.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5a/b359649566954498aa17d7c98093182576d9e435ceb4ea917b3b48d56f86/coverage-7.11.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70619d194d8fea0cb028cb6bb9c85b519c7509c1d1feef1eea635183bc8ecd27", size = 252115, upload-time = "2025-11-07T10:51:03.087Z" }, + { url = "https://files.pythonhosted.org/packages/f3/17/3cef1ede3739622950f0737605353b797ec564e70c9d254521b10f4b03ba/coverage-7.11.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0208bb59d441cfa3321569040f8e455f9261256e0df776c5462a1e5a9b31e13", size = 248442, upload-time = "2025-11-07T10:51:04.888Z" }, + { url = "https://files.pythonhosted.org/packages/5f/63/d5854c47ae42d9d18855329db6bc528f5b7f4f874257edb00cf8b483f9f8/coverage-7.11.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:545714d8765bda1c51f8b1c96e0b497886a054471c68211e76ef49dd1468587d", size = 250253, upload-time = "2025-11-07T10:51:06.515Z" }, + { url = "https://files.pythonhosted.org/packages/48/e8/c7706f8a5358a59c18b489e7e19e83d6161b7c8bc60771f95920570c94a8/coverage-7.11.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d0a2b02c1e20158dd405054bcca87f91fd5b7605626aee87150819ea616edd67", size = 248217, upload-time = "2025-11-07T10:51:08.405Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c9/a2136dfb168eb09e2f6d9d6b6c986243fdc0b3866a9376adb263d3c3378b/coverage-7.11.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0f4aa986a4308a458e0fb572faa3eb3db2ea7ce294604064b25ab32b435a468", size = 248040, upload-time = "2025-11-07T10:51:10.626Z" }, + { url = "https://files.pythonhosted.org/packages/18/9a/a63991c0608ddc6adf65e6f43124951aaf36bd79f41937b028120b8268ea/coverage-7.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d51cc6687e8bbfd1e041f52baed0f979cd592242cf50bf18399a7e03afc82d88", size = 249801, upload-time = "2025-11-07T10:51:12.63Z" }, + { url = "https://files.pythonhosted.org/packages/84/19/947acf7c0c6e90e4ec3abf474133ed36d94407d07e36eafdfd3acb59fee9/coverage-7.11.1-cp313-cp313-win32.whl", hash = "sha256:1b3067db3afe6deeca2b2c9f0ec23820d5f1bd152827acfadf24de145dfc5f66", size = 219430, upload-time = "2025-11-07T10:51:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/35/54/36fef7afb3884450c7b6d494fcabe2fab7c669d547c800ca30f41c1dc212/coverage-7.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:39a4c44b0cd40e3c9d89b2b7303ebd6ab9ae8a63f9e9a8c4d65a181a0b33aebe", size = 220239, upload-time = "2025-11-07T10:51:16.418Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/7d38bb99e8e69200b7dd5de15507226bd90eac102dfc7cc891b9934cdc76/coverage-7.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:a2e3560bf82fa8169a577e054cbbc29888699526063fee26ea59ea2627fd6e73", size = 218868, upload-time = "2025-11-07T10:51:18.186Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/d1ff54fbd6bcad42dbcfd13b417e636ef84aae194353b1ef3361700f2525/coverage-7.11.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47a4f362a10285897ab3aa7a4b37d28213a4f2626823923613d6d7a3584dd79a", size = 217615, upload-time = "2025-11-07T10:51:21.065Z" }, + { url = "https://files.pythonhosted.org/packages/73/f9/6ed59e7cf1488d6f975e5b14ef836f5e537913523e92175135f8518a83ce/coverage-7.11.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0df35fa7419ef571db9dacd50b0517bc54dbfe37eb94043b5fc3540bff276acd", size = 217960, upload-time = "2025-11-07T10:51:22.797Z" }, + { url = "https://files.pythonhosted.org/packages/c4/74/2dab1dc2ebe16f074f80ae483b0f45faf278d102be703ac01b32cd85b6c3/coverage-7.11.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e1a2c621d341c9d56f7917e56fbb56be4f73fe0d0e8dae28352fb095060fd467", size = 259262, upload-time = "2025-11-07T10:51:24.467Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/eccfe039663e29a50a54b0c2c8d076acd174d7ac50d018ef8a5b1c37c8dc/coverage-7.11.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c354b111be9b2234d9573d75dd30ca4e414b7659c730e477e89be4f620b3fb5", size = 261326, upload-time = "2025-11-07T10:51:26.232Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bb/2b829aa23fd5ee8318e33cc02a606eb09900921291497963adc3f06af8bb/coverage-7.11.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4589bd44698728f600233fb2881014c9b8ec86637ef454c00939e779661dbe7e", size = 263758, upload-time = "2025-11-07T10:51:27.912Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/d44c3d70e5da275caf2cad2071da6b425412fbcb1d1d5a81f1f89b45e3f1/coverage-7.11.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c6956fc8754f2309131230272a7213a483a32ecbe29e2b9316d808a28f2f8ea1", size = 258444, upload-time = "2025-11-07T10:51:30.107Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c1/cf61d9f46ae088774c65dd3387a15dfbc72de90c1f6e105025e9eda19b42/coverage-7.11.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63926a97ed89dc6a087369b92dcb8b9a94cead46c08b33a7f1f4818cd8b6a3c3", size = 261335, upload-time = "2025-11-07T10:51:31.814Z" }, + { url = "https://files.pythonhosted.org/packages/95/9a/b3299bb14f11f2364d78a2b9704491b15395e757af6116694731ce4e5834/coverage-7.11.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f5311ba00c53a7fb2b293fdc1f478b7286fe2a845a7ba9cda053f6e98178f0b4", size = 258951, upload-time = "2025-11-07T10:51:33.925Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a3/73cb2763e59f14ba6d8d6444b1f640a9be2242bfb59b7e50581c695db7ff/coverage-7.11.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:31bf5ffad84c974f9e72ac53493350f36b6fa396109159ec704210698f12860b", size = 257840, upload-time = "2025-11-07T10:51:36.092Z" }, + { url = "https://files.pythonhosted.org/packages/85/db/482e72589a952027e238ffa3a15f192c552e0685fd0c5220ad05b5f17d56/coverage-7.11.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:227ee59fbc4a8c57a7383a1d7af6ca94a78ae3beee4045f38684548a8479a65b", size = 260040, upload-time = "2025-11-07T10:51:38.277Z" }, + { url = "https://files.pythonhosted.org/packages/18/a1/b931d3ee099c2dca8e9ea56c07ae84c0f91562f7bbbcccab8c91b3474ef1/coverage-7.11.1-cp313-cp313t-win32.whl", hash = "sha256:a447d97b3ce680bb1da2e6bd822ebb71be6a1fb77ce2c2ad2fe4bd8aacec3058", size = 220102, upload-time = "2025-11-07T10:51:40.017Z" }, + { url = "https://files.pythonhosted.org/packages/9a/53/b553b7bfa6207def4918f0cb72884c844fa4c3f1566e58fbb4f34e54cdc5/coverage-7.11.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d6d11180437c67bde2248563a42b8e5bbf85c8df78fae13bf818ad17bfb15f02", size = 221166, upload-time = "2025-11-07T10:51:41.921Z" }, + { url = "https://files.pythonhosted.org/packages/6b/45/1c1d58b3ed585598764bd2fe41fcf60ccafe15973ad621c322ba52e22d32/coverage-7.11.1-cp313-cp313t-win_arm64.whl", hash = "sha256:1e19a4c43d612760c6f7190411fb157e2d8a6dde00c91b941d43203bd3b17f6f", size = 219439, upload-time = "2025-11-07T10:51:43.753Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c2/ac2c3417eaa4de1361036ebbc7da664242b274b2e00c4b4a1cfc7b29920b/coverage-7.11.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0305463c45c5f21f0396cd5028de92b1f1387e2e0756a85dd3147daa49f7a674", size = 216967, upload-time = "2025-11-07T10:51:45.55Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a3/afef455d03c468ee303f9df9a6f407e8bea64cd576fca914ff888faf52ca/coverage-7.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fa4d468d5efa1eb6e3062be8bd5f45cbf28257a37b71b969a8c1da2652dfec77", size = 217298, upload-time = "2025-11-07T10:51:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/59/6e2fb3fb58637001132dc32228b4fb5b332d75d12f1353cb00fe084ee0ba/coverage-7.11.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d2b2f5fc8fe383cbf2d5c77d6c4b2632ede553bc0afd0cdc910fa5390046c290", size = 248337, upload-time = "2025-11-07T10:51:49.48Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5e/ce442bab963e3388658da8bde6ddbd0a15beda230afafaa25e3c487dc391/coverage-7.11.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde6488c1ad509f4fb1a4f9960fd003d5a94adef61e226246f9699befbab3276", size = 250853, upload-time = "2025-11-07T10:51:51.215Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2f/43f94557924ca9b64e09f1c3876da4eec44a05a41e27b8a639d899716c0e/coverage-7.11.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a69e0d6fa0b920fe6706a898c52955ec5bcfa7e45868215159f45fd87ea6da7c", size = 252190, upload-time = "2025-11-07T10:51:53.262Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fa/a04e769b92bc5628d4bd909dcc3c8219efe5e49f462e29adc43e198ecfde/coverage-7.11.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:976e51e4a549b80e4639eda3a53e95013a14ff6ad69bb58ed604d34deb0e774c", size = 248335, upload-time = "2025-11-07T10:51:55.388Z" }, + { url = "https://files.pythonhosted.org/packages/99/d0/b98ab5d2abe425c71117a7c690ead697a0b32b83256bf0f566c726b7f77b/coverage-7.11.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d61fcc4d384c82971a3d9cf00d0872881f9ded19404c714d6079b7a4547e2955", size = 250209, upload-time = "2025-11-07T10:51:57.263Z" }, + { url = "https://files.pythonhosted.org/packages/9c/3f/b9c4fbd2e6d1b64098f99fb68df7f7c1b3e0a0968d24025adb24f359cdec/coverage-7.11.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:284c5df762b533fae3ebd764e3b81c20c1c9648d93ef34469759cb4e3dfe13d0", size = 248163, upload-time = "2025-11-07T10:51:59.014Z" }, + { url = "https://files.pythonhosted.org/packages/08/fc/3e4d54fb6368b0628019eefd897fc271badbd025410fd5421a65fb58758f/coverage-7.11.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:bab32cb1d4ad2ac6dcc4e17eee5fa136c2a1d14ae914e4bce6c8b78273aece3c", size = 247983, upload-time = "2025-11-07T10:52:01.027Z" }, + { url = "https://files.pythonhosted.org/packages/b9/4a/a5700764a12e932b35afdddb2f59adbca289c1689455d06437f609f3ef35/coverage-7.11.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:36f2fed9ce392ca450fb4e283900d0b41f05c8c5db674d200f471498be3ce747", size = 249646, upload-time = "2025-11-07T10:52:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2c/45ed33d9e80a1cc9b44b4bd535d44c154d3204671c65abd90ec1e99522a2/coverage-7.11.1-cp314-cp314-win32.whl", hash = "sha256:853136cecb92a5ba1cc8f61ec6ffa62ca3c88b4b386a6c835f8b833924f9a8c5", size = 219700, upload-time = "2025-11-07T10:52:05.05Z" }, + { url = "https://files.pythonhosted.org/packages/90/d7/5845597360f6434af1290118ebe114642865f45ce47e7e822d9c07b371be/coverage-7.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:77443d39143e20927259a61da0c95d55ffc31cf43086b8f0f11a92da5260d592", size = 220516, upload-time = "2025-11-07T10:52:07.259Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d0/d311a06f9cf7a48a98ffcfd0c57db0dcab6da46e75c439286a50dc648161/coverage-7.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:829acb88fa47591a64bf5197e96a931ce9d4b3634c7f81a224ba3319623cdf6c", size = 219091, upload-time = "2025-11-07T10:52:09.216Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3d/c6a84da4fa9b840933045b19dd19d17b892f3f2dd1612903260291416dba/coverage-7.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2ad1fe321d9522ea14399de83e75a11fb6a8887930c3679feb383301c28070d9", size = 217700, upload-time = "2025-11-07T10:52:11.348Z" }, + { url = "https://files.pythonhosted.org/packages/94/10/a4fc5022017dd7ac682dc423849c241dfbdad31734b8f96060d84e70b587/coverage-7.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f69c332f0c3d1357c74decc9b1843fcd428cf9221bf196a20ad22aa1db3e1b6c", size = 217968, upload-time = "2025-11-07T10:52:13.203Z" }, + { url = "https://files.pythonhosted.org/packages/59/2d/a554cd98924d296de5816413280ac3b09e42a05fb248d66f8d474d321938/coverage-7.11.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:576baeea4eebde684bf6c91c01e97171c8015765c8b2cfd4022a42b899897811", size = 259334, upload-time = "2025-11-07T10:52:15.079Z" }, + { url = "https://files.pythonhosted.org/packages/05/98/d484cb659ec33958ca96b6f03438f56edc23b239d1ad0417b7a97fc1848a/coverage-7.11.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28ad84c694fa86084cfd3c1eab4149844b8cb95bd8e5cbfc4a647f3ee2cce2b3", size = 261445, upload-time = "2025-11-07T10:52:17.134Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/920cba122cc28f4557c0507f8bd7c6e527ebcc537d0309186f66464a8fd9/coverage-7.11.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1043ff958f09fc3f552c014d599f3c6b7088ba97d7bc1bd1cce8603cd75b520", size = 263858, upload-time = "2025-11-07T10:52:19.836Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a0/036397bdbee0f3bd46c2e26fdfbb1a61b2140bf9059240c37b61149047fa/coverage-7.11.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c6681add5060c2742dafcf29826dff1ff8eef889a3b03390daeed84361c428bd", size = 258381, upload-time = "2025-11-07T10:52:21.687Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/2533926eb8990f182eb287f4873216c8ca530cc47241144aabf46fe80abe/coverage-7.11.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:773419b225ec9a75caa1e941dd0c83a91b92c2b525269e44e6ee3e4c630607db", size = 261321, upload-time = "2025-11-07T10:52:23.612Z" }, + { url = "https://files.pythonhosted.org/packages/32/6e/618f7e203a998e4f6b8a0fa395744a416ad2adbcdc3735bc19466456718a/coverage-7.11.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a9cb272a0e0157dbb9b2fd0b201b759bd378a1a6138a16536c025c2ce4f7643b", size = 258933, upload-time = "2025-11-07T10:52:25.514Z" }, + { url = "https://files.pythonhosted.org/packages/22/40/6b1c27f772cb08a14a338647ead1254a57ee9dabbb4cacbc15df7f278741/coverage-7.11.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e09adb2a7811dc75998eef68f47599cf699e2b62eed09c9fefaeb290b3920f34", size = 257756, upload-time = "2025-11-07T10:52:27.845Z" }, + { url = "https://files.pythonhosted.org/packages/73/07/f9cd12f71307a785ea15b009c8d8cc2543e4a867bd04b8673843970b6b43/coverage-7.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1335fa8c2a2fea49924d97e1e3500cfe8d7c849f5369f26bb7559ad4259ccfab", size = 260086, upload-time = "2025-11-07T10:52:29.776Z" }, + { url = "https://files.pythonhosted.org/packages/34/02/31c5394f6f5d72a466966bcfdb61ce5a19862d452816d6ffcbb44add16ee/coverage-7.11.1-cp314-cp314t-win32.whl", hash = "sha256:4782d71d2a4fa7cef95e853b7097c8bbead4dbd0e6f9c7152a6b11a194b794db", size = 220483, upload-time = "2025-11-07T10:52:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/7f/96/81e1ef5fbfd5090113a96e823dbe055e4c58d96ca73b1fb0ad9d26f9ec36/coverage-7.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:939f45e66eceb63c75e8eb8fc58bb7077c00f1a41b0e15c6ef02334a933cfe93", size = 221592, upload-time = "2025-11-07T10:52:33.724Z" }, + { url = "https://files.pythonhosted.org/packages/38/7a/a5d050de44951ac453a2046a0f3fb5471a4a557f0c914d00db27d543d94c/coverage-7.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:01c575bdbef35e3f023b50a146e9a75c53816e4f2569109458155cd2315f87d9", size = 219627, upload-time = "2025-11-07T10:52:36.285Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/bd9f48c28e23b2f08946f8e83983617b00619f5538dbd7e1045fa7e88c00/coverage-7.11.1-py3-none-any.whl", hash = "sha256:0fa848acb5f1da24765cee840e1afe9232ac98a8f9431c6112c15b34e880b9e8", size = 208689, upload-time = "2025-11-07T10:52:38.646Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + +[[package]] +name = "dependency-injector" +version = "4.48.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/a4/619df82de38ce6451cc1acb549237cbd9306c4bfbcec6e8e1fdbceb8c5f3/dependency_injector-4.48.2.tar.gz", hash = "sha256:9ce6089d75a5dd0b6191a243f41d2c2746802bb39550ad431242c15136fefd60", size = 1103335, upload-time = "2025-09-19T10:19:43.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/56/dce91cc7638a4be4d83e18d20edd3f9b295440b1897d972f7a8ce3ea240f/dependency_injector-4.48.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:54d0178be10f17b768afb5c0ed1c5c565abaa2d097b2bc5a529a31c580613df2", size = 1755919, upload-time = "2025-09-19T10:18:53.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/80/c29f5cb5fd794ea453b240e6d6682a07cdc519a4bd76589c4b75a1bb7a91/dependency_injector-4.48.2-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12a15979fd534b728b3061c8aa52fd55adb77574758817daae9df8a1c2eb830b", size = 1855277, upload-time = "2025-09-19T10:18:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/79/fa/20f14684dfb822f4b72623d4c1250149ba2fcc95a831ae334605eff31b33/dependency_injector-4.48.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85cdf4b423884d4a24a18b970abe73352fb210761302cd6b5ebc6e9a20dbe53f", size = 1760596, upload-time = "2025-09-19T10:18:57.636Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c0/06dddb1b21f64bd1e948244aed1743c8013ea7800fc6e3e470b0019dd93e/dependency_injector-4.48.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a9b457b95a400b7a2de0978a55768cdd104bd265953bf0ed06e7f25d18f35ed2", size = 1742442, upload-time = "2025-09-19T10:18:59.041Z" }, + { url = "https://files.pythonhosted.org/packages/63/23/32575e230f5baf8082eb776847756024441207eabbb03ada679c29061070/dependency_injector-4.48.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:417809f565c39800adb744d666dfe4d94eae510b73ec33f932d592415d7c46d0", size = 1842421, upload-time = "2025-09-19T10:19:01.446Z" }, + { url = "https://files.pythonhosted.org/packages/3c/99/6595e8235d8e120129098d7a56b0491be313bab5415fc9d107ad1ae2a967/dependency_injector-4.48.2-cp310-abi3-win32.whl", hash = "sha256:f014aa7bab427932802d59967d9fe0863a0001db66446177dcc62e47f3a6b234", size = 1512262, upload-time = "2025-09-19T10:19:03.029Z" }, + { url = "https://files.pythonhosted.org/packages/96/04/cf1d482d163bf8c7cfd886cb4cf8eed950b366c2723dea2b21874ef2201c/dependency_injector-4.48.2-cp310-abi3-win_amd64.whl", hash = "sha256:e3fcdeb8189f3e1f87fde9276061f8a6cc596c2fa139bc4b4d1f571035ebd645", size = 1640200, upload-time = "2025-09-19T10:19:04.549Z" }, + { url = "https://files.pythonhosted.org/packages/16/e2/afa8c3bb94b724249636e8920b27982c415ee247fb921a60af2ea8915d68/dependency_injector-4.48.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:02618868c87e11b4a09ce49981faf340813726589e54d8f623094e144856d31c", size = 1871944, upload-time = "2025-09-19T10:19:17.267Z" }, + { url = "https://files.pythonhosted.org/packages/12/77/a0d9a537459b2f0a2a62f018aa48e3e07c86ac32527384b0115c9d792ddf/dependency_injector-4.48.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec7166e70b8f0fcfd1259f22a6e85f58305736e760701fd48c767d398d154cc8", size = 1856264, upload-time = "2025-09-19T10:19:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/ef/54/611d438351da7e9231c7b43c41db47ea24be164627294a0dd1b9d94e263e/dependency_injector-4.48.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:65492bda0217c7fad304ce57be19711252f7440f8f72b77f01fc396148192360", size = 1971827, upload-time = "2025-09-19T10:19:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c5/c3f267b647efc95881fa69271a7b0182f43fccdab9b33c8adaf6121389cf/dependency_injector-4.48.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9c22aba674451f45993b026de83e8a4854d7927589158fe85d063dcda7f49141", size = 1853675, upload-time = "2025-09-19T10:19:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/a4/39/5a45099494fe4b608f716f6abeaa8109aa8bc300bcc78cbe126a36a72ecb/dependency_injector-4.48.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d860c71bc3245046214b77da38451edbfa52fd91399cd07fe325227d5cf98eec", size = 1981054, upload-time = "2025-09-19T10:19:23.562Z" }, + { url = "https://files.pythonhosted.org/packages/f8/96/4f749884af67173336a28e03dfd1bd77e05a7a9bad27bfc5e2aea10e2da7/dependency_injector-4.48.2-cp39-cp39-win32.whl", hash = "sha256:8691d46941f222d625c19eaa455d06d1b7d8c05f145e4f35a66b0ccead2719f3", size = 1566898, upload-time = "2025-09-19T10:19:25.123Z" }, + { url = "https://files.pythonhosted.org/packages/dd/dd/3a99e9e4ceb2d6d2cca9052da2b939220726e47cef64363c2f14b720cd45/dependency_injector-4.48.2-cp39-cp39-win_amd64.whl", hash = "sha256:82c733ce660f061a39e951e9035a016bef22540e907e69ccd4d253a70c714d0b", size = 1733284, upload-time = "2025-09-19T10:19:26.609Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/c0d77e46b4dc3fb69590b25a8b322c823a56eab8f8b91ccffde6b7aac277/dependency_injector-4.48.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f2e3df258cb3ca59f40fffb9c88677cc9dde3f3cdd0f8fc706cf88dad2ac2736", size = 1734990, upload-time = "2025-09-19T10:19:28.173Z" }, + { url = "https://files.pythonhosted.org/packages/11/8b/6ae9eeba0238c871a0705e10b026e6d23075dbb0e12e3933717b849a53da/dependency_injector-4.48.2-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65d990ca81c8ec213e0b4b10c8e7c46caa69887502e5fc3ced7bcb04ad75ebdf", size = 1825018, upload-time = "2025-09-19T10:19:29.67Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2b/32554fa591cdf5434b590e8764765a9c7c329d400c5197c5af00c4711744/dependency_injector-4.48.2-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f026957ba99997a9566cb846804ec712c67428a8041d93bf2e2966ed4a4d7dfc", size = 1733507, upload-time = "2025-09-19T10:19:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2e/6d/b5972d29961a30cf65a011f7887591b90891023b5b265be4fd69ee1001b7/dependency_injector-4.48.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5e8773d3d2298c4ad446a57168e108dc9e46bdb62a5bc369ca553a14d9d239a1", size = 1621410, upload-time = "2025-09-19T10:19:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/2a27d65b34419818a89024413b428f1effd6125e185c4902486796281ff2/dependency_injector-4.48.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:174b117a7a5a28d45c004a3242b28184db12f9818ff4ea8d0cebc230bb5f7b65", size = 1736171, upload-time = "2025-09-19T10:19:35.431Z" }, + { url = "https://files.pythonhosted.org/packages/7b/9c/4f55dc60c6dc3f59defd461213b58d90d977c0928e20a98d3d870413db86/dependency_injector-4.48.2-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8eed93fa25368be43bc3486bddedcc5ad0a62e0a9a441d060eb80ae7980416b0", size = 1828380, upload-time = "2025-09-19T10:19:37.36Z" }, + { url = "https://files.pythonhosted.org/packages/21/e6/16a09fdf0eb368e1c2395979ec9a4bdf7829a57ae3524be491bd5e7f05b4/dependency_injector-4.48.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:85368f039e7fbef1d5f38f8ba42eca28b950288dc72aca13534ed4b3e96ee8cf", size = 1736520, upload-time = "2025-09-19T10:19:38.986Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/41598584075fef9e2bc33c102ba2e0b91ffb207d914b19402d3abf566de8/dependency_injector-4.48.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:05ff29398e23a08e840c9a89a0b516d988b337a38534d33791857bd1defd2d23", size = 1623553, upload-time = "2025-09-19T10:19:40.947Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "dynaconf" +version = "3.2.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/7a6f84b68268fe1d12e709faec7d293e0c37c9c03bacaf363de41e7e7568/dynaconf-3.2.12.tar.gz", hash = "sha256:29cea583b007d890e6031fa89c0ac489b631c73dbee83bcd5e6f97602c26354e", size = 313801, upload-time = "2025-10-10T19:54:06.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/68/51adede38ab2ee9ecfddb8b60a80a42b618a72f1018fcf60974e5d852831/dynaconf-3.2.12-py2.py3-none-any.whl", hash = "sha256:eb2a11865917dff8810c6098cd736b8f4d2f4e39ad914500e2dfbe064b82c499", size = 237788, upload-time = "2025-10-10T19:54:03.731Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "fastapi" +version = "0.121.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/77a2df0946703973b9905fd0cde6172c15e0781984320123b4f5079e7113/fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa", size = 342412, upload-time = "2025-11-03T10:25:54.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload-time = "2025-11-03T10:25:53.27Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[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 = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[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 = "hypercorn" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "taskgroup", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/3a/df6c27642e0dcb7aff688ca4be982f0fb5d89f2afd3096dc75347c16140f/hypercorn-0.17.3.tar.gz", hash = "sha256:1b37802ee3ac52d2d85270700d565787ab16cf19e1462ccfa9f089ca17574165", size = 44409, upload-time = "2024-05-28T20:55:53.06Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/3b/dfa13a8d96aa24e40ea74a975a9906cfdc2ab2f4e3b498862a57052f04eb/hypercorn-0.17.3-py3-none-any.whl", hash = "sha256:059215dec34537f9d40a69258d323f56344805efb462959e727152b0aa504547", size = 61742, upload-time = "2024-05-28T20:55:48.829Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[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 = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +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 = "isort" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/82/fa43935523efdfcce6abbae9da7f372b627b27142c3419fcf13bf5b0c397/isort-6.1.0.tar.gz", hash = "sha256:9b8f96a14cfee0677e78e941ff62f03769a06d412aabb9e2a90487b3b7e8d481", size = 824325, upload-time = "2025-10-01T16:26:45.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/cc/9b681a170efab4868a032631dea1e8446d8ec718a7f657b94d49d1a12643/isort-6.1.0-py3-none-any.whl", hash = "sha256:58d8927ecce74e5087aef019f778d4081a3b6c98f15a80ba35782ca8a2097784", size = 94329, upload-time = "2025-10-01T16:26:43.291Z" }, +] + +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, + { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, + { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "markdown-it-py", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "markdown-it-py", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "myboot" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "apscheduler" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "colorama" }, + { name = "dependency-injector" }, + { name = "dynaconf" }, + { name = "fastapi" }, + { name = "hypercorn" }, + { name = "jinja2" }, + { name = "loguru" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "requests" }, +] + +[package.optional-dependencies] +dev = [ + { name = "black" }, + { name = "flake8" }, + { name = "isort", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "isort", version = "7.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] +docs = [ + { name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-rtd-theme" }, +] +metrics = [ + { name = "prometheus-client" }, +] +test = [ + { name = "httpx" }, + { name = "prometheus-client" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "apscheduler", specifier = ">=3.10.0" }, + { name = "black", marker = "extra == 'dev'", specifier = ">=23.0" }, + { name = "click", specifier = ">=8.0.0" }, + { name = "colorama", specifier = ">=0.4.6" }, + { name = "dependency-injector", specifier = ">=4.41.0" }, + { name = "dynaconf", specifier = ">=3.2.0" }, + { name = "fastapi", specifier = ">=0.104.0" }, + { name = "flake8", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "httpx", marker = "extra == 'test'", specifier = ">=0.24.0" }, + { name = "hypercorn", specifier = ">=0.14.0" }, + { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12" }, + { name = "jinja2", specifier = ">=3.1.0" }, + { name = "loguru", specifier = ">=0.7.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" }, + { name = "myst-parser", marker = "extra == 'docs'", specifier = ">=2.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0" }, + { name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.19.0" }, + { name = "prometheus-client", marker = "extra == 'test'", specifier = ">=0.19.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.21.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.0" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.10" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-multipart", specifier = ">=0.0.6" }, + { name = "pytz", specifier = ">=2024.1" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "requests", specifier = ">=2.28.0" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.3" }, +] +provides-extras = ["dev", "test", "metrics", "docs"] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a6/490ff491d8ecddf8ab91762d4f67635040202f76a44171420bcbe38ceee5/mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b", size = 12807230, upload-time = "2025-09-19T00:09:49.471Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/60076fc829645d167ece9e80db9e8375648d210dab44cc98beb5b322a826/mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133", size = 11895666, upload-time = "2025-09-19T00:10:53.678Z" }, + { url = "https://files.pythonhosted.org/packages/97/4a/1e2880a2a5dda4dc8d9ecd1a7e7606bc0b0e14813637eeda40c38624e037/mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6", size = 12499608, upload-time = "2025-09-19T00:09:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/00/81/a117f1b73a3015b076b20246b1f341c34a578ebd9662848c6b80ad5c4138/mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac", size = 13244551, upload-time = "2025-09-19T00:10:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/9b/61/b9f48e1714ce87c7bf0358eb93f60663740ebb08f9ea886ffc670cea7933/mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b", size = 13491552, upload-time = "2025-09-19T00:10:13.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/b2c0af3b684fa80d1b27501a8bdd3d2daa467ea3992a8aa612f5ca17c2db/mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0", size = 9765635, upload-time = "2025-09-19T00:10:30.993Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "myst-parser" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "docutils", marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", marker = "python_full_version < '3.10'" }, + { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyyaml", marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/e2f13dac02f599980798c01156393b781aec983b52a6e4057ee58f07c43a/myst_parser-3.0.1.tar.gz", hash = "sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87", size = 92392, upload-time = "2024-04-28T20:22:42.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/de/21aa8394f16add8f7427f0a1326ccd2b3a2a8a3245c9252bc5ac034c6155/myst_parser-3.0.1-py3-none-any.whl", hash = "sha256:6457aaa33a5d474aca678b8ead9b3dc298e89c68e67012e73146ea6fd54babf1", size = 83163, upload-time = "2024-04-28T20:22:39.985Z" }, +] + +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "docutils", marker = "python_full_version >= '3.10'" }, + { name = "jinja2", marker = "python_full_version >= '3.10'" }, + { name = "markdown-it-py", marker = "python_full_version >= '3.10'" }, + { name = "mdit-py-plugins", version = "0.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyyaml", marker = "python_full_version >= '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[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 = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.4" +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/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, +] + +[[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/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { 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/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" }, + { url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" }, + { url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" }, + { 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/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { 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 = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[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 = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +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-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.11.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pytokens" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "roman-numerals-py" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "7.4.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "babel", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.10'" }, + { name = "imagesize", marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "requests", marker = "python_full_version < '3.10'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "babel", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version == '3.10.*'" }, + { name = "imagesize", marker = "python_full_version == '3.10.*'" }, + { name = "jinja2", marker = "python_full_version == '3.10.*'" }, + { name = "packaging", marker = "python_full_version == '3.10.*'" }, + { name = "pygments", marker = "python_full_version == '3.10.*'" }, + { name = "requests", marker = "python_full_version == '3.10.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "8.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +dependencies = [ + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "babel", marker = "python_full_version >= '3.11'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "imagesize", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/44/c97faec644d29a5ceddd3020ae2edffa69e7d00054a8c7a6021e82f20335/sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85", size = 7620463, upload-time = "2024-11-13T11:06:04.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/77/46e3bac77b82b4df5bb5b61f2de98637724f246b4966cfc34bc5895d852a/sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13", size = 7655561, upload-time = "2024-11-13T11:06:02.094Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "starlette" +version = "0.49.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, +] + +[[package]] +name = "taskgroup" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/b1/74babcc824a57904e919f3af16d86c08b524c0691504baf038ef2d7f655c/taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb", size = 14237, upload-time = "2025-01-03T09:24:11.41Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[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" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock", version = "3.19.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "filelock", version = "3.20.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + +[[package]] +name = "wsproto" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload-time = "2022-08-23T19:58:21.447Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226, upload-time = "2022-08-23T19:58:19.96Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]