Compare commits
11 Commits
f79f4ddde5
...
mongodb
| Author | SHA1 | Date | |
|---|---|---|---|
| c84af64ce8 | |||
| 981bee5a46 | |||
|
|
5e00aa6095 | ||
|
|
52707b6917 | ||
|
|
8fbaf55763 | ||
|
|
aeac01f285 | ||
|
|
3f96300886 | ||
|
|
ec94e9393b | ||
|
|
da5b691ad1 | ||
|
|
2609be3f6f | ||
|
|
c65b826c10 |
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# ===== MongoDB =====
|
||||||
|
MONGO_DSN=mongodb://127.0.0.1:27017
|
||||||
|
MONGO_DB=app
|
||||||
|
|
||||||
|
# ===== 应用 =====
|
||||||
|
APP_NAME=fastapi-template
|
||||||
|
APP_ENV=dev
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=8000
|
||||||
|
|
||||||
|
# 跨域,逗号分隔;* 仅调试用
|
||||||
|
CORS_ORIGINS=*
|
||||||
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
148
AGENTS.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# AGENTS.md — fastapi-template 开发规范(mongodb 分支)
|
||||||
|
|
||||||
|
> 本分支为 **MongoDB(Beanie)单后端**:无 alembic、无 SQLAlchemy。
|
||||||
|
> Document 模型写 `src/{domain}/mongo.py`,新增 Document 必须在 `src/mongo.py::_collect_documents()` 登记。
|
||||||
|
|
||||||
|
|
||||||
|
本文件是 AI Agent / 开发者在本仓库工作的**必读规范**。先读这个,再动手。
|
||||||
|
通用 FastAPI 最佳实践参考 [docs/AGENTS_GENERIC.md](./docs/AGENTS_GENERIC.md) 与 [docs/BEST_PRACTICES_ZH.md](./docs/BEST_PRACTICES_ZH.md)。
|
||||||
|
|
||||||
|
## 0. 这个模板是什么
|
||||||
|
|
||||||
|
FastAPI 生产级项目骨架。**核心特性:`DB_BACKEND` 一个环境变量切换 MySQL / MongoDB,业务代码零改动**。
|
||||||
|
任何修改都不得破坏这个特性——router/schemas 永远不允许 import 具体 DB 实现。
|
||||||
|
|
||||||
|
## 1. 技术栈基线
|
||||||
|
|
||||||
|
| 项 | 版本 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| Python | ≥3.11 | 用 `X \| Y`、`StrEnum` |
|
||||||
|
| FastAPI | ≥0.115 | 依赖注入一律 `Annotated[T, Depends(...)]` |
|
||||||
|
| Pydantic | v2 | 禁用 v1 API(`.dict()` / `json_encoders`) |
|
||||||
|
| SQLAlchemy | 2.0 async | `AsyncSession` + `async_sessionmaker`,禁 sync session |
|
||||||
|
| Beanie | ≥1.27 | MongoDB ODM |
|
||||||
|
| 包管理 | **uv** | 禁 pip/poetry/conda;加依赖 `uv add xxx` |
|
||||||
|
| Lint | ruff | 提交前 `uv run ruff check --fix .` 必须零告警 |
|
||||||
|
| 测试 | pytest + pytest-asyncio + httpx | `asyncio_mode=auto` |
|
||||||
|
|
||||||
|
## 2. 目录结构铁律
|
||||||
|
|
||||||
|
按**域(domain)**组织,不按文件类型。一个域一个包,照抄 `src/items/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/{domain}/
|
||||||
|
├── router.py # 路由:只做参数校验和调用 repo,禁写 SQL/查询
|
||||||
|
├── schemas.py # Pydantic 契约:与 DB 无关,前后端共用语义
|
||||||
|
├── dependencies.py # 定义 Repo Protocol + 按 DB_BACKEND 选实现
|
||||||
|
├── mysql.py # MySQL ORM 模型 + MySQLXxxRepo
|
||||||
|
├── mongo.py # Beanie Document + MongoXxxRepo
|
||||||
|
├── service.py # (可选)跨 repo 的复杂业务逻辑
|
||||||
|
├── constants.py # (可选)常量、错误码
|
||||||
|
└── exceptions.py # (可选)域内异常,继承 src.exceptions.AppError
|
||||||
|
```
|
||||||
|
|
||||||
|
- 新增域后必做三件事:① `src/mongo.py::_collect_documents()` 登记 Document;
|
||||||
|
② `alembic/env.py` import mysql 模型;③ `src/main.py` include_router。
|
||||||
|
- 跨域 import 必须显式模块名:`from src.items import constants as item_constants`,禁 `import *`。
|
||||||
|
- 全局共享的东西才放 `src/` 根(config / exceptions / database / mongo)。
|
||||||
|
|
||||||
|
## 3. 双数据库后端规范(本模板灵魂)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ router 只依赖 Protocol
|
||||||
|
async def get_item(item_id: str, repo: ItemRepoDep): ...
|
||||||
|
|
||||||
|
# ✅ dependencies.py 模块加载时选定实现
|
||||||
|
get_item_repo = _get_mysql_repo if settings.DB_BACKEND == "mysql" else _get_mongo_repo
|
||||||
|
|
||||||
|
# ❌ 禁止在 router/schemas/service 里出现
|
||||||
|
from src.items.mysql import Item # 不许!
|
||||||
|
from src.items.mongo import ItemDoc # 不许!
|
||||||
|
```
|
||||||
|
|
||||||
|
- 两个 repo 实现**同一 Protocol**,方法签名逐字一致。
|
||||||
|
- schemas 的 `id` 统一用 `str`(MySQL int 自增 / Mongo ObjectId 都转 str),
|
||||||
|
转换在各 repo 的 `to_out()` 里完成。
|
||||||
|
- MySQL 改表结构 → 必须 `alembic revision --autogenerate` 生成迁移,禁手改生产库。
|
||||||
|
- Mongo 新增 Document → 只登记 `_collect_documents()`,beanie 自动建索引。
|
||||||
|
- `src/database.py`、`src/mongo.py` 只在 lifespan / dependencies 里被引用;
|
||||||
|
非对应后端模式下它们可以 import 但**不得产生连接**(engine 惰性,别主动 connect)。
|
||||||
|
|
||||||
|
## 4. 异步纪律
|
||||||
|
|
||||||
|
| 场景 | 写法 |
|
||||||
|
|---|---|
|
||||||
|
| 可 await 的 I/O | `async def` |
|
||||||
|
| 只有同步 SDK | `def`(FastAPI 自动进线程池)或 `run_in_threadpool` |
|
||||||
|
| CPU 密集 >50ms | 扔任务队列,别放请求路径 |
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ❌ async 路由里调用阻塞库 = 冻结整个事件循环
|
||||||
|
@router.get("/bad")
|
||||||
|
async def bad():
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# ✅
|
||||||
|
@router.get("/ok")
|
||||||
|
def ok():
|
||||||
|
time.sleep(5)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 配置与异常
|
||||||
|
|
||||||
|
- 配置只走 `src/config.py` 的 `settings`(pydantic-settings);域级配置放 `{domain}/config.py`,`env_prefix="XXX_"`。
|
||||||
|
- 禁在代码里硬编码 DSN / 密钥;`.env` 不入库,`.env.example` 必须同步更新。
|
||||||
|
- 业务异常继承 `AppError`,404 用 `NotFoundError`;统一由 `register_exception_handlers` 输出 `{"detail": ...}`。
|
||||||
|
- 禁裸 `except:`,禁 `except Exception: pass`。
|
||||||
|
|
||||||
|
## 6. Pydantic 规范
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✅ 约束写清楚;required 和 optional 二选一,别自相矛盾
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
age: int | None = Field(default=None, ge=0) # 可选
|
||||||
|
age: int = Field(ge=18) # 必填
|
||||||
|
|
||||||
|
# ❌ 矛盾写法
|
||||||
|
age: int = Field(ge=18, default=None)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 序列化定制用 `@field_serializer`,不用 `json_encoders`。
|
||||||
|
- 入参模型(XxxCreate/XxxUpdate)与出参模型(XxxOut)分开;`XxxUpdate` 全字段可选 + `model_dump(exclude_unset=True)`。
|
||||||
|
|
||||||
|
## 7. 测试规范
|
||||||
|
|
||||||
|
- 测试从第一天就是异步:`httpx.AsyncClient` + `ASGITransport`,见 `tests/conftest.py`。
|
||||||
|
- 测试库与开发库隔离:Mongo 用 `app_test`,MySQL 用 `app_test`。
|
||||||
|
- 每个域至少覆盖:create / get / list / update / delete + 404 路径。
|
||||||
|
- 跑 MySQL 后端测试:`DB_BACKEND=mysql uv run pytest`。
|
||||||
|
|
||||||
|
## 8. Git 规范
|
||||||
|
|
||||||
|
- 提交信息:Conventional Commits —— `feat: xxx` / `fix: xxx` / `refactor:` / `docs:` / `test:` / `chore:`。
|
||||||
|
- 主分支 `main`;功能开发开 `feature/xxx` 分支,提 PR 合并。
|
||||||
|
- 提交前自检三连:`uv run ruff check --fix . && uv run pytest`,全绿才推。
|
||||||
|
|
||||||
|
## 9. 常用命令速查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync # 装依赖
|
||||||
|
uv add fastapi # 加依赖(dev 依赖:uv add --dev xxx)
|
||||||
|
uv run uvicorn src.main:app --reload # 开发起服务
|
||||||
|
uv run pytest # 测试
|
||||||
|
uv run ruff check --fix . # lint
|
||||||
|
uv run alembic revision --autogenerate -m "msg" # MySQL 迁移
|
||||||
|
docker compose up -d --build # 容器化整套
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 反模式清单(Review 时逐条核对)
|
||||||
|
|
||||||
|
- [ ] router 里出现 SQL / `ItemDoc.find()` 等具体 DB 调用
|
||||||
|
- [ ] async 路由里调用 requests / time.sleep 等阻塞库
|
||||||
|
- [ ] `print` 调试残留(用 `logging`)
|
||||||
|
- [ ] 硬编码连接串 / 密钥
|
||||||
|
- [ ] `from x import *`、裸 except
|
||||||
|
- [ ] 改了 MySQL 模型没生成 alembic 迁移
|
||||||
|
- [ ] 新增 Mongo Document 没登记 `_collect_documents()`
|
||||||
|
- [ ] 新域只在单一后端实现(两个 repo 必须成对)
|
||||||
|
- [ ] 提交信息 freestyle(非 Conventional Commits)
|
||||||
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock* ./
|
||||||
|
RUN uv sync --no-dev --no-cache
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uv", "run", "--no-sync", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
877
README.md
@@ -1,828 +1,91 @@
|
|||||||
## FastAPI Best Practices <!-- omit from toc -->
|
# fastapi-template(mongodb 分支)
|
||||||
Opinionated list of best practices and conventions I use in startups.
|
|
||||||
|
|
||||||
For the last several years in production,
|
> 本分支为 **MongoDB(Beanie 2.x + pymongo async)单后端**精简版。
|
||||||
we have been making good and bad decisions that impacted our developer experience dramatically.
|
> 双后端可切换版见 `main` 分支,MySQL 版见 `mysql` 分支。
|
||||||
Some of them are worth sharing.
|
|
||||||
|
|
||||||
## Contents <!-- omit from toc -->
|
|
||||||
- [Project Structure](#project-structure)
|
|
||||||
- [Async Routes](#async-routes)
|
|
||||||
- [I/O Intensive Tasks](#io-intensive-tasks)
|
|
||||||
- [CPU Intensive Tasks](#cpu-intensive-tasks)
|
|
||||||
- [Pydantic](#pydantic)
|
|
||||||
- [Excessively use Pydantic](#excessively-use-pydantic)
|
|
||||||
- [Custom Base Model](#custom-base-model)
|
|
||||||
- [Decouple Pydantic BaseSettings](#decouple-pydantic-basesettings)
|
|
||||||
- [Dependencies](#dependencies)
|
|
||||||
- [Beyond Dependency Injection](#beyond-dependency-injection)
|
|
||||||
- [Chain Dependencies](#chain-dependencies)
|
|
||||||
- [Decouple \& Reuse dependencies. Dependency calls are cached](#decouple--reuse-dependencies-dependency-calls-are-cached)
|
|
||||||
- [Prefer `async` dependencies](#prefer-async-dependencies)
|
|
||||||
- [Miscellaneous](#miscellaneous)
|
|
||||||
- [Follow the REST](#follow-the-rest)
|
|
||||||
- [FastAPI response serialization](#fastapi-response-serialization)
|
|
||||||
- [If you must use sync SDK, then run it in a thread pool.](#if-you-must-use-sync-sdk-then-run-it-in-a-thread-pool)
|
|
||||||
- [ValueErrors might become Pydantic ValidationError](#valueerrors-might-become-pydantic-validationerror)
|
|
||||||
- [Docs](#docs)
|
|
||||||
- [Set DB keys naming conventions](#set-db-keys-naming-conventions)
|
|
||||||
- [Migrations. Alembic](#migrations-alembic)
|
|
||||||
- [Set DB naming conventions](#set-db-naming-conventions)
|
|
||||||
- [SQL-first. Pydantic-second](#sql-first-pydantic-second)
|
|
||||||
- [Set tests client async from day 0](#set-tests-client-async-from-day-0)
|
|
||||||
- [Use ruff](#use-ruff)
|
|
||||||
- [Bonus Section](#bonus-section)
|
|
||||||
|
|
||||||
## Project Structure
|
开箱即用的 FastAPI 项目模板。**一条环境变量切换 MySQL / MongoDB**,业务代码零改动。
|
||||||
There are many ways to structure a project, but the best structure is one that is consistent, straightforward, and free of surprises.
|
|
||||||
|
|
||||||
Many example projects and tutorials divide the project by file type (e.g., crud, routers, models), which works well for microservices or projects with fewer scopes. However, this approach didn't fit our monolith with many domains and modules.
|
- MySQL:SQLAlchemy 2.0 async + aiomysql + Alembic 迁移
|
||||||
|
- MongoDB:Beanie ODM + Motor
|
||||||
|
- 工程化:uv 管理依赖、ruff lint、pytest 异步测试、Docker / compose 一键起
|
||||||
|
|
||||||
The structure I found more scalable and evolvable for these cases is inspired by Netflix's [Dispatch](https://github.com/Netflix/dispatch), with some minor modifications.
|
> 写给 AI Agent 的开发规范见 [AGENTS.md](./AGENTS.md)。
|
||||||
```
|
> FastAPI 通用最佳实践(原版文档):[docs/BEST_PRACTICES_ZH.md](./docs/BEST_PRACTICES_ZH.md)
|
||||||
fastapi-project
|
|
||||||
├── alembic/
|
## 快速开始
|
||||||
├── src
|
|
||||||
│ ├── auth
|
```bash
|
||||||
│ │ ├── router.py
|
# 1. 克隆后改个名
|
||||||
│ │ ├── schemas.py # pydantic models
|
git clone https://git.code-lab.cn/Quentin/fastapi-template.git my-project && cd my-project
|
||||||
│ │ ├── models.py # db models
|
|
||||||
│ │ ├── dependencies.py
|
# 2. 配置:选数据库后端
|
||||||
│ │ ├── config.py # local configs
|
cp .env.example .env
|
||||||
│ │ ├── constants.py
|
# 编辑 .env:DB_BACKEND=mongodb 或 mysql,填对应 DSN
|
||||||
│ │ ├── exceptions.py
|
|
||||||
│ │ ├── service.py
|
# 3. 装依赖(uv)
|
||||||
│ │ └── utils.py
|
uv sync
|
||||||
│ ├── aws
|
|
||||||
│ │ ├── client.py # client model for external service communication
|
# 4. 起数据库(或直接用现成的)
|
||||||
│ │ ├── schemas.py
|
docker compose up -d mongo # MongoDB
|
||||||
│ │ ├── config.py
|
docker compose up -d mysql # MySQL
|
||||||
│ │ ├── constants.py
|
|
||||||
│ │ ├── exceptions.py
|
# 5. 跑!
|
||||||
│ │ └── utils.py
|
uv run uvicorn src.main:app --reload
|
||||||
│ └── posts
|
|
||||||
│ │ ├── router.py
|
|
||||||
│ │ ├── schemas.py
|
|
||||||
│ │ ├── models.py
|
|
||||||
│ │ ├── dependencies.py
|
|
||||||
│ │ ├── constants.py
|
|
||||||
│ │ ├── exceptions.py
|
|
||||||
│ │ ├── service.py
|
|
||||||
│ │ └── utils.py
|
|
||||||
│ ├── config.py # global configs
|
|
||||||
│ ├── models.py # global models
|
|
||||||
│ ├── exceptions.py # global exceptions
|
|
||||||
│ ├── pagination.py # global module e.g. pagination
|
|
||||||
│ ├── database.py # db connection related stuff
|
|
||||||
│ └── main.py
|
|
||||||
├── tests/
|
|
||||||
│ ├── auth
|
|
||||||
│ ├── aws
|
|
||||||
│ └── posts
|
|
||||||
├── templates/
|
|
||||||
│ └── index.html
|
|
||||||
├── requirements
|
|
||||||
│ ├── base.txt
|
|
||||||
│ ├── dev.txt
|
|
||||||
│ └── prod.txt
|
|
||||||
├── .env
|
|
||||||
├── .gitignore
|
|
||||||
├── logging.ini
|
|
||||||
└── alembic.ini
|
|
||||||
```
|
|
||||||
1. Store all domain directories inside `src` folder
|
|
||||||
1. `src/` - highest level of an app, contains common models, configs, and constants, etc.
|
|
||||||
2. `src/main.py` - root of the project, which inits the FastAPI app
|
|
||||||
2. Each package has its own router, schemas, models, etc.
|
|
||||||
1. `router.py` - is a core of each module with all the endpoints
|
|
||||||
2. `schemas.py` - for pydantic models
|
|
||||||
3. `models.py` - for db models
|
|
||||||
4. `service.py` - module specific business logic
|
|
||||||
5. `dependencies.py` - router dependencies
|
|
||||||
6. `constants.py` - module specific constants and error codes
|
|
||||||
7. `config.py` - e.g. env vars
|
|
||||||
8. `utils.py` - non-business logic functions, e.g. response normalization, data enrichment, etc.
|
|
||||||
9. `exceptions.py` - module specific exceptions, e.g. `PostNotFound`, `InvalidUserData`
|
|
||||||
3. When package requires services or dependencies or constants from other packages - import them with an explicit module name
|
|
||||||
```python
|
|
||||||
from src.auth import constants as auth_constants
|
|
||||||
from src.notifications import service as notification_service
|
|
||||||
from src.posts.constants import ErrorCode as PostsErrorCode # in case we have Standard ErrorCode in constants module of each package
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Async Routes
|
打开 http://127.0.0.1:8000/docs 看交互式 API 文档,
|
||||||
FastAPI is an async framework, in the first place. It is designed to work with async I/O operations and that is the reason it is so fast.
|
`GET /health` 会返回当前生效的 `db_backend`。
|
||||||
|
|
||||||
However, FastAPI doesn't restrict you to use only `async` routes, and the developer can use `sync` routes as well. This might confuse beginner developers into believing that they are the same, but they are not.
|
## 切库说明
|
||||||
|
|
||||||
### I/O Intensive Tasks
|
| | MySQL | MongoDB |
|
||||||
Under the hood, FastAPI can [effectively handle](https://fastapi.tiangolo.com/async/#path-operation-functions) both async and sync I/O operations.
|
|---|---|---|
|
||||||
- FastAPI runs `sync` routes in the [threadpool](https://en.wikipedia.org/wiki/Thread_pool)
|
| 开关 | `DB_BACKEND=mysql` | `DB_BACKEND=mongodb` |
|
||||||
and blocking I/O operations won't stop the [event loop](https://docs.python.org/3/library/asyncio-eventloop.html)
|
| 连接 | `MYSQL_DSN=mysql+aiomysql://user:pass@host:3306/db` | `MONGO_DSN` + `MONGO_DB` |
|
||||||
from executing the tasks.
|
| 模型 | `src/{domain}/mysql.py`(ORM) | `src/{domain}/mongo.py`(Document) |
|
||||||
- If the route is defined `async` then it's called regularly via `await`
|
| 迁移 | `alembic revision --autogenerate` + `upgrade head` | 不需要(beanie 自动建索引) |
|
||||||
and FastAPI trusts you to do only non-blocking I/O operations.
|
|
||||||
|
|
||||||
The caveat is that if you violate that trust and execute blocking operations within async routes,
|
router/service 只依赖 `ItemRepo` 协议(见 `src/items/dependencies.py`),
|
||||||
the event loop will not be able to run subsequent tasks until the blocking operation completes.
|
两个后端实现同一套接口,`.env` 改一行即切换。
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
## 项目结构
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/terrible-ping")
|
|
||||||
async def terrible_ping():
|
|
||||||
time.sleep(10) # I/O blocking operation for 10 seconds, the whole process will be blocked
|
|
||||||
|
|
||||||
return {"pong": True}
|
|
||||||
|
|
||||||
@router.get("/good-ping")
|
|
||||||
def good_ping():
|
|
||||||
time.sleep(10) # I/O blocking operation for 10 seconds, but in a separate thread for the whole `good_ping` route
|
|
||||||
|
|
||||||
return {"pong": True}
|
|
||||||
|
|
||||||
@router.get("/perfect-ping")
|
|
||||||
async def perfect_ping():
|
|
||||||
await asyncio.sleep(10) # non-blocking I/O operation
|
|
||||||
|
|
||||||
return {"pong": True}
|
|
||||||
|
|
||||||
```
|
```
|
||||||
**What happens when we call:**
|
├── src/
|
||||||
1. `GET /terrible-ping`
|
│ ├── main.py # app 工厂 + lifespan(按后端初始化 DB)
|
||||||
1. FastAPI server receives a request and starts handling it
|
│ ├── config.py # pydantic-settings 全局配置
|
||||||
2. Server's event loop and all the tasks in the queue will be waiting until `time.sleep()` is finished
|
│ ├── database.py # MySQL engine/session(仅 mysql 模式使用)
|
||||||
1. Server thinks `time.sleep()` is not an I/O task, so it waits until it is finished
|
│ ├── mongo.py # beanie 初始化(仅 mongodb 模式使用)
|
||||||
2. Server won't accept any new requests while waiting
|
│ ├── exceptions.py # 全局异常 + 统一错误响应
|
||||||
3. Server returns the response.
|
│ └── items/ # 示例域(新增域照抄这个目录)
|
||||||
1. After a response, server starts accepting new requests
|
│ ├── router.py # 路由:只依赖 ItemRepo 协议
|
||||||
2. `GET /good-ping`
|
│ ├── schemas.py # Pydantic 契约(与 DB 无关)
|
||||||
1. FastAPI server receives a request and starts handling it
|
│ ├── dependencies.py# 按 DB_BACKEND 选仓储实现
|
||||||
2. FastAPI sends the whole route `good_ping` to the threadpool, where a worker thread will run the function
|
│ ├── mysql.py # MySQL 模型 + 仓储
|
||||||
3. While `good_ping` is being executed, event loop selects next tasks from the queue and works on them (e.g. accept new request, call db)
|
│ └── mongo.py # MongoDB Document + 仓储
|
||||||
- Independently of main thread (i.e. our FastAPI app),
|
├── alembic/ # MySQL 迁移
|
||||||
worker thread will be waiting for `time.sleep` to finish.
|
├── tests/ # pytest + httpx ASGITransport
|
||||||
- Sync operation blocks only the side thread, not the main one.
|
├── Dockerfile
|
||||||
4. When `good_ping` finishes its work, server returns a response to the client
|
└── docker-compose.yml # app + mysql + mongo
|
||||||
3. `GET /perfect-ping`
|
|
||||||
1. FastAPI server receives a request and starts handling it
|
|
||||||
2. FastAPI awaits `asyncio.sleep(10)`
|
|
||||||
3. Event loop selects next tasks from the queue and works on them (e.g. accept new request, call db)
|
|
||||||
4. When `asyncio.sleep(10)` is done, servers finishes the execution of the route and returns a response to the client
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> Notes on the thread pool:
|
|
||||||
> - Threads require more resources than coroutines, so they are not as cheap as async I/O operations.
|
|
||||||
> - Thread pool has a limited number of threads, i.e. you might run out of threads and your app will become slow. [Read more](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#2-be-careful-with-non-async-functions) (external link)
|
|
||||||
|
|
||||||
### CPU Intensive Tasks
|
|
||||||
The second caveat is that operations that are non-blocking awaitables or are sent to the thread pool must be I/O intensive tasks (e.g. open file, db call, external API call).
|
|
||||||
- Awaiting CPU-intensive tasks (e.g. heavy calculations, data processing, video transcoding) is worthless since the CPU has to work to finish the tasks,
|
|
||||||
while I/O operations are external and server does nothing while waiting for that operations to finish, thus it can go to the next tasks.
|
|
||||||
- Running CPU-intensive tasks in other threads also isn't effective, because of [GIL](https://realpython.com/python-gil/).
|
|
||||||
In short, GIL allows only one thread to work at a time, which makes it useless for CPU tasks.
|
|
||||||
- If you want to optimize CPU intensive tasks you should send them to workers in another process.
|
|
||||||
|
|
||||||
**Related StackOverflow questions of confused users**
|
|
||||||
1. https://stackoverflow.com/questions/62976648/architecture-flask-vs-fastapi/70309597#70309597
|
|
||||||
- Here you can also check [my answer](https://stackoverflow.com/a/70309597/6927498)
|
|
||||||
2. https://stackoverflow.com/questions/65342833/fastapi-uploadfile-is-slow-compared-to-flask
|
|
||||||
3. https://stackoverflow.com/questions/71516140/fastapi-runs-api-calls-in-serial-instead-of-parallel-fashion
|
|
||||||
|
|
||||||
## Pydantic
|
|
||||||
### Excessively use Pydantic
|
|
||||||
Pydantic has a rich set of features to validate and transform data.
|
|
||||||
|
|
||||||
In addition to regular features like required & non-required fields with default values,
|
|
||||||
Pydantic has built-in comprehensive data processing tools like regex, enums, strings manipulation, emails validation, etc.
|
|
||||||
```python
|
|
||||||
from enum import Enum
|
|
||||||
from pydantic import AnyUrl, BaseModel, EmailStr, Field
|
|
||||||
|
|
||||||
|
|
||||||
class MusicBand(str, Enum):
|
|
||||||
AEROSMITH = "AEROSMITH"
|
|
||||||
QUEEN = "QUEEN"
|
|
||||||
ACDC = "AC/DC"
|
|
||||||
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
|
||||||
first_name: str = Field(min_length=1, max_length=128)
|
|
||||||
username: str = Field(min_length=1, max_length=128, pattern="^[A-Za-z0-9-_]+$")
|
|
||||||
email: EmailStr
|
|
||||||
age: int = Field(ge=18, default=None) # must be greater or equal to 18
|
|
||||||
favorite_band: MusicBand | None = None # only "AEROSMITH", "QUEEN", "AC/DC" values are allowed to be inputted
|
|
||||||
website: AnyUrl | None = None
|
|
||||||
```
|
|
||||||
### Custom Base Model
|
|
||||||
Having a controllable global base model allows us to customize all the models within the app. For instance, we can enforce a standard datetime format or introduce a common method for all subclasses of the base model.
|
|
||||||
```python
|
|
||||||
from datetime import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from fastapi.encoders import jsonable_encoder
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
def datetime_to_gmt_str(dt: datetime) -> str:
|
|
||||||
if not dt.tzinfo:
|
|
||||||
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
|
||||||
|
|
||||||
return dt.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
||||||
|
|
||||||
|
|
||||||
class CustomModel(BaseModel):
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_encoders={datetime: datetime_to_gmt_str},
|
|
||||||
populate_by_name=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def serializable_dict(self, **kwargs):
|
|
||||||
"""Return a dict which contains only serializable fields."""
|
|
||||||
default_dict = self.model_dump()
|
|
||||||
|
|
||||||
return jsonable_encoder(default_dict)
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
In the example above, we have decided to create a global base model that:
|
|
||||||
- Serializes all datetime fields to a standard format with an explicit timezone
|
|
||||||
- Provides a method to return a dict with only serializable fields
|
|
||||||
### Decouple Pydantic BaseSettings
|
|
||||||
BaseSettings was a great innovation for reading environment variables, but having a single BaseSettings for the whole app can become messy over time. To improve maintainability and organization, we have split the BaseSettings across different modules and domains.
|
|
||||||
```python
|
|
||||||
# src.auth.config
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
|
|
||||||
|
|
||||||
class AuthConfig(BaseSettings):
|
|
||||||
JWT_ALG: str
|
|
||||||
JWT_SECRET: str
|
|
||||||
JWT_EXP: int = 5 # minutes
|
|
||||||
|
|
||||||
REFRESH_TOKEN_KEY: str
|
|
||||||
REFRESH_TOKEN_EXP: timedelta = timedelta(days=30)
|
|
||||||
|
|
||||||
SECURE_COOKIES: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
auth_settings = AuthConfig()
|
|
||||||
|
|
||||||
|
|
||||||
# src.config
|
|
||||||
from pydantic import PostgresDsn, RedisDsn, model_validator
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
|
|
||||||
from src.constants import Environment
|
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseSettings):
|
|
||||||
DATABASE_URL: PostgresDsn
|
|
||||||
REDIS_URL: RedisDsn
|
|
||||||
|
|
||||||
SITE_DOMAIN: str = "myapp.com"
|
|
||||||
|
|
||||||
ENVIRONMENT: Environment = Environment.PRODUCTION
|
|
||||||
|
|
||||||
SENTRY_DSN: str | None = None
|
|
||||||
|
|
||||||
CORS_ORIGINS: list[str]
|
|
||||||
CORS_ORIGINS_REGEX: str | None = None
|
|
||||||
CORS_HEADERS: list[str]
|
|
||||||
|
|
||||||
APP_VERSION: str = "1.0"
|
|
||||||
|
|
||||||
|
|
||||||
settings = Config()
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependencies
|
## 常用命令
|
||||||
### Beyond Dependency Injection
|
|
||||||
Pydantic is a great schema validator, but for complex validations that involve calling a database or external services, it is not sufficient.
|
|
||||||
|
|
||||||
FastAPI documentation mostly presents dependencies as DI for endpoints, but they are also excellent for request validation.
|
|
||||||
|
|
||||||
Dependencies can be used to validate data against database constraints (e.g., checking if an email already exists, ensuring a user is found, etc.).
|
|
||||||
```python
|
|
||||||
# dependencies.py
|
|
||||||
async def valid_post_id(post_id: UUID4) -> dict[str, Any]:
|
|
||||||
post = await service.get_by_id(post_id)
|
|
||||||
if not post:
|
|
||||||
raise PostNotFound()
|
|
||||||
|
|
||||||
return post
|
|
||||||
|
|
||||||
|
|
||||||
# router.py
|
|
||||||
@router.get("/posts/{post_id}", response_model=PostResponse)
|
|
||||||
async def get_post_by_id(post: dict[str, Any] = Depends(valid_post_id)):
|
|
||||||
return post
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/posts/{post_id}", response_model=PostResponse)
|
|
||||||
async def update_post(
|
|
||||||
update_data: PostUpdate,
|
|
||||||
post: dict[str, Any] = Depends(valid_post_id),
|
|
||||||
):
|
|
||||||
updated_post = await service.update(id=post["id"], data=update_data)
|
|
||||||
return updated_post
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/posts/{post_id}/reviews", response_model=list[ReviewsResponse])
|
|
||||||
async def get_post_reviews(post: dict[str, Any] = Depends(valid_post_id)):
|
|
||||||
post_reviews = await reviews_service.get_by_post_id(post["id"])
|
|
||||||
return post_reviews
|
|
||||||
```
|
|
||||||
If we didn't put data validation to dependency, we would have to validate `post_id` exists
|
|
||||||
for every endpoint and write the same tests for each of them.
|
|
||||||
|
|
||||||
### Chain Dependencies
|
|
||||||
Dependencies can use other dependencies and avoid code repetition for similar logic.
|
|
||||||
```python
|
|
||||||
# dependencies.py
|
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
|
||||||
from jose import JWTError, jwt
|
|
||||||
|
|
||||||
async def valid_post_id(post_id: UUID4) -> dict[str, Any]:
|
|
||||||
post = await service.get_by_id(post_id)
|
|
||||||
if not post:
|
|
||||||
raise PostNotFound()
|
|
||||||
|
|
||||||
return post
|
|
||||||
|
|
||||||
|
|
||||||
async def parse_jwt_data(
|
|
||||||
token: str = Depends(OAuth2PasswordBearer(tokenUrl="/auth/token"))
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
|
|
||||||
except JWTError:
|
|
||||||
raise InvalidCredentials()
|
|
||||||
|
|
||||||
return {"user_id": payload["id"]}
|
|
||||||
|
|
||||||
|
|
||||||
async def valid_owned_post(
|
|
||||||
post: dict[str, Any] = Depends(valid_post_id),
|
|
||||||
token_data: dict[str, Any] = Depends(parse_jwt_data),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if post["creator_id"] != token_data["user_id"]:
|
|
||||||
raise UserNotOwner()
|
|
||||||
|
|
||||||
return post
|
|
||||||
|
|
||||||
# router.py
|
|
||||||
@router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse)
|
|
||||||
async def get_user_post(post: dict[str, Any] = Depends(valid_owned_post)):
|
|
||||||
return post
|
|
||||||
|
|
||||||
```
|
|
||||||
### Decouple & Reuse dependencies. Dependency calls are cached
|
|
||||||
Dependencies can be reused multiple times, and they won't be recalculated - FastAPI caches dependency's result within a request's scope by default,
|
|
||||||
i.e. if `valid_post_id` gets called multiple times in one route, it will be called only once.
|
|
||||||
|
|
||||||
Knowing this, we can decouple dependencies onto multiple smaller functions that operate on a smaller domain and are easier to reuse in other routes.
|
|
||||||
For example, in the code below we are using `parse_jwt_data` three times:
|
|
||||||
1. `valid_owned_post`
|
|
||||||
2. `valid_active_creator`
|
|
||||||
3. `get_user_post`,
|
|
||||||
|
|
||||||
but `parse_jwt_data` is called only once, in the very first call.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# dependencies.py
|
|
||||||
from fastapi import BackgroundTasks
|
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
|
||||||
from jose import JWTError, jwt
|
|
||||||
|
|
||||||
async def valid_post_id(post_id: UUID4) -> Mapping:
|
|
||||||
post = await service.get_by_id(post_id)
|
|
||||||
if not post:
|
|
||||||
raise PostNotFound()
|
|
||||||
|
|
||||||
return post
|
|
||||||
|
|
||||||
|
|
||||||
async def parse_jwt_data(
|
|
||||||
token: str = Depends(OAuth2PasswordBearer(tokenUrl="/auth/token"))
|
|
||||||
) -> dict:
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
|
|
||||||
except JWTError:
|
|
||||||
raise InvalidCredentials()
|
|
||||||
|
|
||||||
return {"user_id": payload["id"]}
|
|
||||||
|
|
||||||
|
|
||||||
async def valid_owned_post(
|
|
||||||
post: Mapping = Depends(valid_post_id),
|
|
||||||
token_data: dict = Depends(parse_jwt_data),
|
|
||||||
) -> Mapping:
|
|
||||||
if post["creator_id"] != token_data["user_id"]:
|
|
||||||
raise UserNotOwner()
|
|
||||||
|
|
||||||
return post
|
|
||||||
|
|
||||||
|
|
||||||
async def valid_active_creator(
|
|
||||||
token_data: dict = Depends(parse_jwt_data),
|
|
||||||
):
|
|
||||||
user = await users_service.get_by_id(token_data["user_id"])
|
|
||||||
if not user["is_active"]:
|
|
||||||
raise UserIsBanned()
|
|
||||||
|
|
||||||
if not user["is_creator"]:
|
|
||||||
raise UserNotCreator()
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
# router.py
|
|
||||||
@router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse)
|
|
||||||
async def get_user_post(
|
|
||||||
worker: BackgroundTasks,
|
|
||||||
post: Mapping = Depends(valid_owned_post),
|
|
||||||
user: Mapping = Depends(valid_active_creator),
|
|
||||||
):
|
|
||||||
"""Get post that belong the active user."""
|
|
||||||
worker.add_task(notifications_service.send_email, user["id"])
|
|
||||||
return post
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest # 测试(默认 mongodb 后端,库名 app_test)
|
||||||
|
uv run ruff check --fix . # lint + 自动修
|
||||||
|
uv run alembic revision --autogenerate -m "msg" # 生成 MySQL 迁移
|
||||||
|
uv run alembic upgrade head # 执行迁移
|
||||||
|
docker compose up -d --build # 整套起
|
||||||
```
|
```
|
||||||
|
|
||||||
### Prefer `async` dependencies
|
## 新增一个业务域
|
||||||
FastAPI supports both `sync` and `async` dependencies, and there is a temptation to use `sync` dependencies, when you don't have to await anything, but that might not be the best choice.
|
|
||||||
|
|
||||||
Just as with routes, `sync` dependencies are run in the thread pool. And threads here also come with a price and limitations, that are redundant, if you just make a small non-I/O operation.
|
照抄 `src/items/` 为 `src/{domain}/`,然后:
|
||||||
|
|
||||||
[See more](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#9-your-dependencies-may-be-running-on-threads) (external link)
|
1. `mongo.py` 里写 Document,并在 `src/mongo.py` 的 `_collect_documents()` 登记
|
||||||
|
2. `mysql.py` 里写 ORM 模型,并在 `alembic/env.py` import 保证 metadata 可见
|
||||||
|
3. `main.py` 里 `include_router`
|
||||||
|
|
||||||
|
详细规范(结构、命名、依赖注入、异步纪律、Git 提交)都在 [AGENTS.md](./AGENTS.md)。
|
||||||
## Miscellaneous
|
|
||||||
### Follow the REST
|
|
||||||
Developing RESTful API makes it easier to reuse dependencies in routes like these:
|
|
||||||
1. `GET /courses/:course_id`
|
|
||||||
2. `GET /courses/:course_id/chapters/:chapter_id/lessons`
|
|
||||||
3. `GET /chapters/:chapter_id`
|
|
||||||
|
|
||||||
The only caveat is having to use the same variable names in the path:
|
|
||||||
- If you have two endpoints `GET /profiles/:profile_id` and `GET /creators/:creator_id`
|
|
||||||
that both validate whether the given `profile_id` exists, but `GET /creators/:creator_id`
|
|
||||||
also checks if the profile is creator, then it's better to rename `creator_id` path variable to `profile_id` and chain those two dependencies.
|
|
||||||
```python
|
|
||||||
# src.profiles.dependencies
|
|
||||||
async def valid_profile_id(profile_id: UUID4) -> Mapping:
|
|
||||||
profile = await service.get_by_id(profile_id)
|
|
||||||
if not profile:
|
|
||||||
raise ProfileNotFound()
|
|
||||||
|
|
||||||
return profile
|
|
||||||
|
|
||||||
# src.creators.dependencies
|
|
||||||
async def valid_creator_id(profile: Mapping = Depends(valid_profile_id)) -> Mapping:
|
|
||||||
if not profile["is_creator"]:
|
|
||||||
raise ProfileNotCreator()
|
|
||||||
|
|
||||||
return profile
|
|
||||||
|
|
||||||
# src.profiles.router.py
|
|
||||||
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
|
|
||||||
async def get_user_profile_by_id(profile: Mapping = Depends(valid_profile_id)):
|
|
||||||
"""Get profile by id."""
|
|
||||||
return profile
|
|
||||||
|
|
||||||
# src.creators.router.py
|
|
||||||
@router.get("/creators/{profile_id}", response_model=ProfileResponse)
|
|
||||||
async def get_user_profile_by_id(
|
|
||||||
creator_profile: Mapping = Depends(valid_creator_id)
|
|
||||||
):
|
|
||||||
"""Get creator's profile by id."""
|
|
||||||
return creator_profile
|
|
||||||
|
|
||||||
```
|
|
||||||
### FastAPI response serialization
|
|
||||||
You may think you can return Pydantic object that matches your route's `response_model` to make some optimizations,
|
|
||||||
but you'd be wrong.
|
|
||||||
|
|
||||||
FastAPI first converts that pydantic object to dict with its `jsonable_encoder`, then validates
|
|
||||||
data with your `response_model`, and only then serializes your object to JSON.
|
|
||||||
|
|
||||||
This means your Pydantic model object is created twice:
|
|
||||||
- First, when you explicitly create it to return from your route.
|
|
||||||
- Second, implicitly by FastAPI to validate the response data according to the response_model.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from pydantic import BaseModel, root_validator
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileResponse(BaseModel):
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def debug_usage(self):
|
|
||||||
print("created pydantic model")
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_model=ProfileResponse)
|
|
||||||
async def root():
|
|
||||||
return ProfileResponse()
|
|
||||||
```
|
|
||||||
**Logs Output:**
|
|
||||||
```
|
|
||||||
[INFO] [2022-08-28 12:00:00.000000] created pydantic model
|
|
||||||
[INFO] [2022-08-28 12:00:00.000020] created pydantic model
|
|
||||||
```
|
|
||||||
|
|
||||||
### If you must use sync SDK, then run it in a thread pool.
|
|
||||||
If you must use a library to interact with external services, and it's not `async`,
|
|
||||||
then make the HTTP calls in an external worker thread.
|
|
||||||
|
|
||||||
We can use the well-known `run_in_threadpool` from starlette.
|
|
||||||
```python
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.concurrency import run_in_threadpool
|
|
||||||
from my_sync_library import SyncAPIClient
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def call_my_sync_library():
|
|
||||||
my_data = await service.get_my_data()
|
|
||||||
|
|
||||||
client = SyncAPIClient()
|
|
||||||
await run_in_threadpool(client.make_request, data=my_data)
|
|
||||||
```
|
|
||||||
|
|
||||||
### ValueErrors might become Pydantic ValidationError
|
|
||||||
If you raise a `ValueError` in a Pydantic schema that is directly faced by the client, it will return a nice detailed response to users.
|
|
||||||
```python
|
|
||||||
# src.profiles.schemas
|
|
||||||
from pydantic import BaseModel, field_validator
|
|
||||||
|
|
||||||
class ProfileCreate(BaseModel):
|
|
||||||
username: str
|
|
||||||
|
|
||||||
@field_validator("password", mode="after")
|
|
||||||
@classmethod
|
|
||||||
def valid_password(cls, password: str) -> str:
|
|
||||||
if not re.match(STRONG_PASSWORD_PATTERN, password):
|
|
||||||
raise ValueError(
|
|
||||||
"Password must contain at least "
|
|
||||||
"one lower character, "
|
|
||||||
"one upper character, "
|
|
||||||
"digit or "
|
|
||||||
"special symbol"
|
|
||||||
)
|
|
||||||
|
|
||||||
return password
|
|
||||||
|
|
||||||
|
|
||||||
# src.profiles.routes
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/profiles")
|
|
||||||
async def get_creator_posts(profile_data: ProfileCreate):
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
**Response Example:**
|
|
||||||
|
|
||||||
<img src="images/value_error_response.png" width="400" height="auto">
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
1. Unless your API is public, hide docs by default. Show it explicitly on the selected envs only.
|
|
||||||
```python
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from starlette.config import Config
|
|
||||||
|
|
||||||
config = Config(".env") # parse .env file for env variables
|
|
||||||
|
|
||||||
ENVIRONMENT = config("ENVIRONMENT") # get current env name
|
|
||||||
SHOW_DOCS_ENVIRONMENT = ("local", "staging") # explicit list of allowed envs
|
|
||||||
|
|
||||||
app_configs = {"title": "My Cool API"}
|
|
||||||
if ENVIRONMENT not in SHOW_DOCS_ENVIRONMENT:
|
|
||||||
app_configs["openapi_url"] = None # set url for docs as null
|
|
||||||
|
|
||||||
app = FastAPI(**app_configs)
|
|
||||||
```
|
|
||||||
2. Help FastAPI to generate an easy-to-understand docs
|
|
||||||
1. Set `response_model`, `status_code`, `description`, etc.
|
|
||||||
2. If models and statuses vary, use `responses` route attribute to add docs for different responses
|
|
||||||
```python
|
|
||||||
from fastapi import APIRouter, status
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/endpoints",
|
|
||||||
response_model=DefaultResponseModel, # default response pydantic model
|
|
||||||
status_code=status.HTTP_201_CREATED, # default status code
|
|
||||||
description="Description of the well documented endpoint",
|
|
||||||
tags=["Endpoint Category"],
|
|
||||||
summary="Summary of the Endpoint",
|
|
||||||
responses={
|
|
||||||
status.HTTP_200_OK: {
|
|
||||||
"model": OkResponse, # custom pydantic model for 200 response
|
|
||||||
"description": "Ok Response",
|
|
||||||
},
|
|
||||||
status.HTTP_201_CREATED: {
|
|
||||||
"model": CreatedResponse, # custom pydantic model for 201 response
|
|
||||||
"description": "Creates something from user request",
|
|
||||||
},
|
|
||||||
status.HTTP_202_ACCEPTED: {
|
|
||||||
"model": AcceptedResponse, # custom pydantic model for 202 response
|
|
||||||
"description": "Accepts request and handles it later",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def documented_route():
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
Will generate docs like this:
|
|
||||||

|
|
||||||
|
|
||||||
### Set DB keys naming conventions
|
|
||||||
Explicitly setting the indexes' namings according to your database's convention is preferable over sqlalchemy's.
|
|
||||||
```python
|
|
||||||
from sqlalchemy import MetaData
|
|
||||||
|
|
||||||
POSTGRES_INDEXES_NAMING_CONVENTION = {
|
|
||||||
"ix": "%(column_0_label)s_idx",
|
|
||||||
"uq": "%(table_name)s_%(column_0_name)s_key",
|
|
||||||
"ck": "%(table_name)s_%(constraint_name)s_check",
|
|
||||||
"fk": "%(table_name)s_%(column_0_name)s_fkey",
|
|
||||||
"pk": "%(table_name)s_pkey",
|
|
||||||
}
|
|
||||||
metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)
|
|
||||||
```
|
|
||||||
### Migrations. Alembic
|
|
||||||
1. Migrations must be static and revertable.
|
|
||||||
If your migrations depend on dynamically generated data, then
|
|
||||||
make sure the only thing that is dynamic is the data itself, not its structure.
|
|
||||||
2. Generate migrations with descriptive names & slugs. Slug is required and should explain the changes.
|
|
||||||
3. Set human-readable file template for new migrations. We use `*date*_*slug*.py` pattern, e.g. `2022-08-24_post_content_idx.py`
|
|
||||||
```
|
|
||||||
# alembic.ini
|
|
||||||
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
|
|
||||||
```
|
|
||||||
### Set DB naming conventions
|
|
||||||
Being consistent with names is important. Some rules we followed:
|
|
||||||
1. lower_case_snake
|
|
||||||
2. singular form (e.g. `post`, `post_like`, `user_playlist`)
|
|
||||||
3. group similar tables with module prefix, e.g. `payment_account`, `payment_bill`, `post`, `post_like`
|
|
||||||
4. stay consistent across tables, but concrete namings are ok, e.g.
|
|
||||||
1. use `profile_id` in all tables, but if some of them need only profiles that are creators, use `creator_id`
|
|
||||||
2. use `post_id` for all abstract tables like `post_like`, `post_view`, but use concrete naming in relevant modules like `course_id` in `chapters.course_id`
|
|
||||||
5. `_at` suffix for datetime
|
|
||||||
6. `_date` suffix for date
|
|
||||||
### SQL-first. Pydantic-second
|
|
||||||
- Usually, database handles data processing much faster and cleaner than CPython will ever do.
|
|
||||||
- It's preferable to do all the complex joins and simple data manipulations with SQL.
|
|
||||||
- It's preferable to aggregate JSONs in DB for responses with nested objects.
|
|
||||||
```python
|
|
||||||
# src.posts.service
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import UUID4
|
|
||||||
from sqlalchemy import desc, func, select, text
|
|
||||||
from sqlalchemy.sql.functions import coalesce
|
|
||||||
|
|
||||||
from src.database import database, posts, profiles, post_review, products
|
|
||||||
|
|
||||||
async def get_posts(
|
|
||||||
creator_id: UUID4, *, limit: int = 10, offset: int = 0
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
select_query = (
|
|
||||||
select(
|
|
||||||
(
|
|
||||||
posts.c.id,
|
|
||||||
posts.c.slug,
|
|
||||||
posts.c.title,
|
|
||||||
func.json_build_object(
|
|
||||||
text("'id', profiles.id"),
|
|
||||||
text("'first_name', profiles.first_name"),
|
|
||||||
text("'last_name', profiles.last_name"),
|
|
||||||
text("'username', profiles.username"),
|
|
||||||
).label("creator"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.select_from(posts.join(profiles, posts.c.owner_id == profiles.c.id))
|
|
||||||
.where(posts.c.owner_id == creator_id)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset)
|
|
||||||
.group_by(
|
|
||||||
posts.c.id,
|
|
||||||
posts.c.type,
|
|
||||||
posts.c.slug,
|
|
||||||
posts.c.title,
|
|
||||||
profiles.c.id,
|
|
||||||
profiles.c.first_name,
|
|
||||||
profiles.c.last_name,
|
|
||||||
profiles.c.username,
|
|
||||||
profiles.c.avatar,
|
|
||||||
)
|
|
||||||
.order_by(
|
|
||||||
desc(coalesce(posts.c.updated_at, posts.c.published_at, posts.c.created_at))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return await database.fetch_all(select_query)
|
|
||||||
|
|
||||||
# src.posts.schemas
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel, UUID4
|
|
||||||
|
|
||||||
|
|
||||||
class Creator(BaseModel):
|
|
||||||
id: UUID4
|
|
||||||
first_name: str
|
|
||||||
last_name: str
|
|
||||||
username: str
|
|
||||||
|
|
||||||
|
|
||||||
class Post(BaseModel):
|
|
||||||
id: UUID4
|
|
||||||
slug: str
|
|
||||||
title: str
|
|
||||||
creator: Creator
|
|
||||||
|
|
||||||
|
|
||||||
# src.posts.router
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/creators/{creator_id}/posts", response_model=list[Post])
|
|
||||||
async def get_creator_posts(creator: dict[str, Any] = Depends(valid_creator_id)):
|
|
||||||
posts = await service.get_posts(creator["id"])
|
|
||||||
|
|
||||||
return posts
|
|
||||||
```
|
|
||||||
### Set tests client async from day 0
|
|
||||||
Writing integration tests with DB will most likely lead to messed up event loop errors in the future.
|
|
||||||
Set the async test client immediately, e.g. [httpx](https://github.com/encode/starlette/issues/652)
|
|
||||||
```python
|
|
||||||
import pytest
|
|
||||||
from async_asgi_testclient import TestClient
|
|
||||||
|
|
||||||
from src.main import app # inited FastAPI app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def client() -> AsyncGenerator[TestClient, None]:
|
|
||||||
host, port = "127.0.0.1", "9000"
|
|
||||||
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app, client=(host, port)), base_url="http://test") as client:
|
|
||||||
yield client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_create_post(client: TestClient):
|
|
||||||
resp = await client.post("/posts")
|
|
||||||
|
|
||||||
assert resp.status_code == 201
|
|
||||||
```
|
|
||||||
Unless you have sync db connections (excuse me?) or aren't planning to write integration tests.
|
|
||||||
|
|
||||||
### Use ruff
|
|
||||||
With linters, you can forget about formatting the code and focus on writing the business logic.
|
|
||||||
|
|
||||||
[Ruff](https://github.com/astral-sh/ruff) is "blazingly-fast" new linter that replaces black, autoflake, isort, and supports more than 600 lint rules.
|
|
||||||
|
|
||||||
It's a popular good practice to use pre-commit hooks, but just using the script was ok for us.
|
|
||||||
```shell
|
|
||||||
#!/bin/sh -e
|
|
||||||
set -x
|
|
||||||
|
|
||||||
ruff check --fix src
|
|
||||||
ruff format src
|
|
||||||
```
|
|
||||||
|
|
||||||
## Bonus Section
|
|
||||||
Some very kind people shared their own experience and best practices that are definitely worth reading.
|
|
||||||
Check them out at [issues](https://github.com/zhanymkanov/fastapi-best-practices/issues) section of the project.
|
|
||||||
|
|
||||||
For instance, [lowercase00](https://github.com/zhanymkanov/fastapi-best-practices/issues/4)
|
|
||||||
has described in details their best practices working with permissions & auth, class-based services & views,
|
|
||||||
task queues, custom response serializers, configuration with dynaconf, etc.
|
|
||||||
|
|
||||||
If you have something to share about your experience working with FastAPI, whether it's good or bad,
|
|
||||||
you are very welcome to create a new issue. It is our pleasure to read it.
|
|
||||||
|
|||||||
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
MONGO_DSN: mongodb://mongo:27017
|
||||||
|
depends_on:
|
||||||
|
- mongo
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
image: mongo:8.0
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
volumes:
|
||||||
|
- mongo_data:/data/db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo_data:
|
||||||
447
docs/AGENTS_GENERIC.md
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
# FastAPI Best Practices for AI Agents
|
||||||
|
|
||||||
|
A machine-readable companion to [README.md](./README.md) for AI coding agents
|
||||||
|
working in FastAPI projects. Same rules, restructured for fast pattern matching:
|
||||||
|
version pins, Do/Don't blocks, anti-patterns, and a quick-reference table.
|
||||||
|
|
||||||
|
## Compatibility Matrix
|
||||||
|
|
||||||
|
Pin to these versions or newer. Examples in this file assume them.
|
||||||
|
|
||||||
|
| Dependency | Minimum | Notes |
|
||||||
|
|------------------|-----------|------------------------------------------------------|
|
||||||
|
| Python | 3.11 | Required for `StrEnum` and `X \| Y` union syntax |
|
||||||
|
| FastAPI | 0.115 | `Annotated[T, Depends(...)]` is the idiomatic form |
|
||||||
|
| Pydantic | 2.7 | v1 APIs (`json_encoders`, `.dict()`) are removed |
|
||||||
|
| pydantic-settings| 2.4 | Lives in a separate package since Pydantic v2 |
|
||||||
|
| SQLAlchemy | 2.0 | Use the async API (`AsyncSession`, `async_sessionmaker`) |
|
||||||
|
| Alembic | 1.13 | Async-aware migrations |
|
||||||
|
| httpx | 0.27 | Use `ASGITransport` for in-process tests |
|
||||||
|
| PyJWT | 2.9 | Use this, not the unmaintained `python-jose` |
|
||||||
|
| ruff | 0.6 | Replaces black, isort, autoflake |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Organize by domain, not by file type. One package per bounded context.
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── {domain}/ # e.g., auth/, posts/, aws/
|
||||||
|
│ ├── router.py # API endpoints
|
||||||
|
│ ├── schemas.py # Pydantic models
|
||||||
|
│ ├── models.py # SQLAlchemy ORM models
|
||||||
|
│ ├── service.py # Business logic
|
||||||
|
│ ├── dependencies.py # Route dependencies
|
||||||
|
│ ├── config.py # Domain-scoped BaseSettings
|
||||||
|
│ ├── constants.py # Constants and error codes
|
||||||
|
│ ├── exceptions.py # Domain-specific exceptions
|
||||||
|
│ └── utils.py # Helper functions
|
||||||
|
├── config.py # Global BaseSettings
|
||||||
|
├── models.py # Shared Pydantic / ORM bases
|
||||||
|
├── exceptions.py # Global exceptions
|
||||||
|
├── database.py # Async engine + session factory
|
||||||
|
└── main.py # FastAPI app + lifespan
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cross-domain imports**: always use the explicit module name. Never `from src.auth import *`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.auth import constants as auth_constants
|
||||||
|
from src.notifications import service as notification_service
|
||||||
|
from src.posts.constants import ErrorCode as PostsErrorCode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Async Routes
|
||||||
|
|
||||||
|
### Decision rule
|
||||||
|
|
||||||
|
| Route does this | Use |
|
||||||
|
|----------------------------------------|-------------|
|
||||||
|
| `await`-able non-blocking I/O | `async def` |
|
||||||
|
| Blocking I/O (no async client exists) | `def` (sync, runs in threadpool) |
|
||||||
|
| Mix of both | `async def` + `run_in_threadpool` for the blocking part |
|
||||||
|
| CPU-bound work (>50 ms compute) | Offload to a worker process (Celery / RQ / Arq) |
|
||||||
|
|
||||||
|
### Do / Don't
|
||||||
|
|
||||||
|
```python
|
||||||
|
# DON'T — blocking call inside async route freezes the entire event loop
|
||||||
|
@router.get("/bad")
|
||||||
|
async def bad():
|
||||||
|
time.sleep(10) # blocks every request on this worker
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# DO — sync route lets FastAPI run it in a threadpool
|
||||||
|
@router.get("/sync-ok")
|
||||||
|
def sync_ok():
|
||||||
|
time.sleep(10) # blocks one threadpool worker, not the loop
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# DO — async route with awaitable sleep
|
||||||
|
@router.get("/async-ok")
|
||||||
|
async def async_ok():
|
||||||
|
await asyncio.sleep(10) # yields control, loop keeps serving requests
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# DO — async route that has to call a sync library
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
@router.get("/wrap")
|
||||||
|
async def wrap():
|
||||||
|
result = await run_in_threadpool(legacy_sync_client.fetch, "id")
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
### Threadpool caveats
|
||||||
|
- Default Starlette threadpool size is 40. Saturating it slows every sync route.
|
||||||
|
- Threads cost more than coroutines. Don't use sync routes "just because."
|
||||||
|
|
||||||
|
## Pydantic
|
||||||
|
|
||||||
|
### Use built-in validators
|
||||||
|
```python
|
||||||
|
from enum import StrEnum
|
||||||
|
from pydantic import AnyUrl, BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MusicBand(StrEnum):
|
||||||
|
AEROSMITH = "AEROSMITH"
|
||||||
|
QUEEN = "QUEEN"
|
||||||
|
ACDC = "AC/DC"
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
first_name: str = Field(min_length=1, max_length=128)
|
||||||
|
username: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_-]+$")
|
||||||
|
email: EmailStr
|
||||||
|
age: int = Field(ge=18) # required, must be >= 18
|
||||||
|
favorite_band: MusicBand | None = None
|
||||||
|
website: AnyUrl | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Don't** write `Field(ge=18, default=None)`. The constraint and the default contradict
|
||||||
|
> each other. Decide: required (`Field(ge=18)`) or optional (`int | None = Field(default=None, ge=18)`).
|
||||||
|
|
||||||
|
### Custom base model — modern serialization
|
||||||
|
|
||||||
|
`json_encoders` is deprecated in Pydantic v2. Use `@field_serializer` for per-field rules,
|
||||||
|
or annotate a custom type with `PlainSerializer`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_serializer
|
||||||
|
|
||||||
|
|
||||||
|
class CustomModel(BaseModel):
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
@field_serializer("*", when_used="json", check_fields=False)
|
||||||
|
def _serialize_datetimes(self, value):
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=ZoneInfo("UTC"))
|
||||||
|
return value.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
return value
|
||||||
|
```
|
||||||
|
|
||||||
|
### Split BaseSettings by domain
|
||||||
|
|
||||||
|
`pydantic-settings` is its own package since Pydantic v2.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/auth/config.py
|
||||||
|
from datetime import timedelta
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class AuthConfig(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_prefix="AUTH_", env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
JWT_ALG: str
|
||||||
|
JWT_SECRET: str
|
||||||
|
JWT_EXP_MINUTES: int = 5
|
||||||
|
REFRESH_TOKEN_KEY: str
|
||||||
|
REFRESH_TOKEN_EXP: timedelta = timedelta(days=30)
|
||||||
|
SECURE_COOKIES: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
auth_settings = AuthConfig()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Use Annotated, not default-arg `Depends(...)`
|
||||||
|
|
||||||
|
`Annotated[T, Depends(...)]` is the idiomatic form since FastAPI 0.95 and avoids
|
||||||
|
gotchas with default values.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# DO — modern Annotated form
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
PostDep = Annotated[dict, Depends(valid_post_id)]
|
||||||
|
|
||||||
|
@router.get("/posts/{post_id}")
|
||||||
|
async def get_post(post: PostDep):
|
||||||
|
return post
|
||||||
|
|
||||||
|
# Avoid — default-argument form (still works, but legacy)
|
||||||
|
@router.get("/posts/{post_id}")
|
||||||
|
async def get_post(post: dict = Depends(valid_post_id)):
|
||||||
|
return post
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validate inside dependencies (not just inject)
|
||||||
|
```python
|
||||||
|
async def valid_post_id(post_id: UUID4) -> dict:
|
||||||
|
post = await service.get_by_id(post_id)
|
||||||
|
if not post:
|
||||||
|
raise PostNotFound()
|
||||||
|
return post
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chain dependencies for reuse
|
||||||
|
```python
|
||||||
|
async def valid_owned_post(
|
||||||
|
post: Annotated[dict, Depends(valid_post_id)],
|
||||||
|
token_data: Annotated[dict, Depends(parse_jwt_data)],
|
||||||
|
) -> dict:
|
||||||
|
if post["creator_id"] != token_data["user_id"]:
|
||||||
|
raise UserNotOwner()
|
||||||
|
return post
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
- Dependencies are **cached per request**. Same `Depends(x)` called 5 times in one request → `x` runs once.
|
||||||
|
- Prefer `async def` dependencies. Sync deps run in the threadpool — wasted overhead for small CPU-only checks.
|
||||||
|
- Use **the same path-variable name** across endpoints when you want to share a dependency (e.g. `profile_id` in both `/profiles/{profile_id}` and `/creators/{profile_id}`).
|
||||||
|
|
||||||
|
## Authentication — JWT
|
||||||
|
|
||||||
|
Use **`PyJWT`**, not `python-jose` (unmaintained).
|
||||||
|
|
||||||
|
```python
|
||||||
|
import jwt # PyJWT
|
||||||
|
from jwt.exceptions import InvalidTokenError
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALG])
|
||||||
|
except InvalidTokenError as exc:
|
||||||
|
raise InvalidCredentials() from exc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database — SQLAlchemy 2.0 async
|
||||||
|
|
||||||
|
Prefer SQLAlchemy 2.0's async API. `encode/databases` is in maintenance mode — don't pick it for new projects.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/database.py
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
engine = create_async_engine(str(settings.DATABASE_URL), pool_pre_ping=True)
|
||||||
|
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
yield session
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming conventions
|
||||||
|
- `lower_case_snake`
|
||||||
|
- Singular tables: `post`, `user`, `post_like`
|
||||||
|
- Group with prefix: `payment_account`, `payment_bill`
|
||||||
|
- `_at` suffix for `datetime`, `_date` suffix for `date`
|
||||||
|
- Use the same FK column name everywhere it appears (`profile_id`, not `user_id` in some tables and `profile_id` in others)
|
||||||
|
|
||||||
|
### Index naming convention
|
||||||
|
```python
|
||||||
|
from sqlalchemy import MetaData
|
||||||
|
|
||||||
|
POSTGRES_INDEXES_NAMING_CONVENTION = {
|
||||||
|
"ix": "%(column_0_label)s_idx",
|
||||||
|
"uq": "%(table_name)s_%(column_0_name)s_key",
|
||||||
|
"ck": "%(table_name)s_%(constraint_name)s_check",
|
||||||
|
"fk": "%(table_name)s_%(column_0_name)s_fkey",
|
||||||
|
"pk": "%(table_name)s_pkey",
|
||||||
|
}
|
||||||
|
metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)
|
||||||
|
```
|
||||||
|
|
||||||
|
### SQL-first, Pydantic-second
|
||||||
|
- Do joins, aggregation, and JSON shaping in SQL — Postgres is faster than CPython at this.
|
||||||
|
- Hydrate the result into Pydantic only for response validation, not for transformation.
|
||||||
|
|
||||||
|
## Background work — BackgroundTasks vs Celery
|
||||||
|
|
||||||
|
| Use BackgroundTasks when… | Use Celery / Arq / RQ when… |
|
||||||
|
|------------------------------------------|--------------------------------------------|
|
||||||
|
| Task is < 1 second | Task takes seconds to minutes |
|
||||||
|
| Failure can be silently dropped | You need retries, dead-letter, or visibility|
|
||||||
|
| Task is in-process (send email, log row) | Task is CPU-heavy or needs a separate pool |
|
||||||
|
| You don't need scheduling | You need cron, ETA, or rate limiting |
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
|
||||||
|
@router.post("/signup")
|
||||||
|
async def signup(data: SignupIn, bg: BackgroundTasks):
|
||||||
|
user = await service.create_user(data)
|
||||||
|
bg.add_task(send_welcome_email, user.email) # fire-and-forget, in-process
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
> BackgroundTasks run **after the response is sent, in the same worker process**. If the
|
||||||
|
> worker dies, the task is lost. There is no retry. Don't use them for anything you'd
|
||||||
|
> page on.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Async client from day one
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
from src.main import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_post(client: AsyncClient):
|
||||||
|
resp = await client.post("/posts", json={"title": "hi"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Don't** use `async_asgi_testclient` — it's unmaintained. The example above (httpx +
|
||||||
|
> `ASGITransport`) is the supported path.
|
||||||
|
|
||||||
|
### Override dependencies in tests
|
||||||
|
Don't monkeypatch internals. Use FastAPI's built-in `dependency_overrides`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.auth.dependencies import parse_jwt_data
|
||||||
|
from src.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def fake_user():
|
||||||
|
return {"user_id": "00000000-0000-0000-0000-000000000001"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _override_auth():
|
||||||
|
app.dependency_overrides[parse_jwt_data] = fake_user
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrations (Alembic)
|
||||||
|
|
||||||
|
- Migrations must be static and reversible.
|
||||||
|
- Use the async template: `alembic init -t async migrations`
|
||||||
|
- Descriptive filenames:
|
||||||
|
```ini
|
||||||
|
# alembic.ini
|
||||||
|
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
|
||||||
|
```
|
||||||
|
→ `2026-04-14_add_post_content_idx.py`
|
||||||
|
|
||||||
|
## API documentation
|
||||||
|
|
||||||
|
### Hide docs outside selected envs
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
|
SHOW_DOCS_IN = {"local", "staging"}
|
||||||
|
app_kwargs = {"title": "My API"}
|
||||||
|
if settings.ENVIRONMENT not in SHOW_DOCS_IN:
|
||||||
|
app_kwargs["openapi_url"] = None # disables /docs and /redoc
|
||||||
|
|
||||||
|
app = FastAPI(**app_kwargs)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Document endpoints fully
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/items",
|
||||||
|
response_model=ItemResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create an item",
|
||||||
|
description="Creates an item owned by the authenticated user.",
|
||||||
|
tags=["items"],
|
||||||
|
responses={
|
||||||
|
status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse, "description": "Validation error"},
|
||||||
|
status.HTTP_409_CONFLICT: {"model": ErrorResponse, "description": "Slug already exists"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def create_item(payload: ItemCreate) -> ItemResponse: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linting
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ruff check --fix src
|
||||||
|
ruff format src
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to a pre-commit hook or run in CI. Ruff replaces black + isort + autoflake + most of flake8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-patterns — common AI-agent mistakes
|
||||||
|
|
||||||
|
If you're an agent reviewing a diff, check for these. Each is a real failure mode I've
|
||||||
|
seen agents introduce.
|
||||||
|
|
||||||
|
| Anti-pattern | Why it's wrong | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `requests.get(...)` inside `async def` | Blocks the event loop. `requests` is sync. | Use `httpx.AsyncClient` or `await run_in_threadpool(requests.get, ...)`. |
|
||||||
|
| `time.sleep` / `open()` / sync DB driver inside `async def` | Same — blocks the loop. | Use the async equivalent (`asyncio.sleep`, `aiofiles`, async driver). |
|
||||||
|
| `from jose import jwt` | `python-jose` is unmaintained. | `import jwt` (PyJWT). |
|
||||||
|
| `from async_asgi_testclient import TestClient` | Unmaintained. | `httpx.AsyncClient` + `ASGITransport`. |
|
||||||
|
| `model_config = ConfigDict(json_encoders={...})` | Deprecated in Pydantic v2. | `@field_serializer` or `Annotated[T, PlainSerializer(...)]`. |
|
||||||
|
| `Field(ge=18, default=None)` | Constraint contradicts the default. | Pick required or optional, not both. |
|
||||||
|
| `def get_user(id: int = Depends(...))` (default-arg form) | Legacy; gotchas with default values. | `user: Annotated[User, Depends(...)]`. |
|
||||||
|
| Catching `Exception` around a route's body | Hides bugs and turns 500s into silent 200s. | Catch the specific exception class; raise `HTTPException` with a meaningful status. |
|
||||||
|
| `BackgroundTasks` for anything you'd page on | No retry, dies with the worker. | Use Celery / Arq / RQ. |
|
||||||
|
| Calling a sync ORM session inside `async def` | Blocks the loop, may deadlock the pool. | Use `AsyncSession`. |
|
||||||
|
| Returning a Pydantic model and *also* setting `response_model=` to that same class | Model gets constructed twice (validate + serialize). | Either return a `dict`/ORM row and let `response_model` validate, or drop `response_model` and trust the return type. |
|
||||||
|
| Importing across domains via deep paths (`from src.auth.service.user import ...`) | Tight coupling, hard to refactor. | `from src.auth import service as auth_service`. |
|
||||||
|
| Reusing one `BaseSettings` for the whole app | Hard to reason about, every domain reads every var. | One `BaseSettings` per domain. |
|
||||||
|
| Mocking the database in integration tests | Mock/prod divergence eventually fires in prod. | Use a real DB (testcontainers, ephemeral schema) and `dependency_overrides` for auth/external services. |
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| Scenario | Solution |
|
||||||
|
|--------------------------------------|---------------------------------------------------|
|
||||||
|
| Non-blocking I/O | `async def` route with `await` |
|
||||||
|
| Blocking I/O (no async client) | `def` route (sync, runs in threadpool) |
|
||||||
|
| Sync library inside async route | `await run_in_threadpool(fn, *args)` |
|
||||||
|
| CPU-intensive work | Celery / Arq / RQ worker process |
|
||||||
|
| Request validation against DB | Dependency that loads + validates + returns |
|
||||||
|
| Reuse validation across routes | Chain dependencies |
|
||||||
|
| Inject dependency in modern style | `Annotated[T, Depends(...)]` |
|
||||||
|
| Per-request dep caching | Default behavior — same `Depends(x)` runs once |
|
||||||
|
| Per-domain config | One `BaseSettings` subclass per domain |
|
||||||
|
| Custom datetime serialization | `@field_serializer` |
|
||||||
|
| Fire-and-forget short task | `BackgroundTasks` |
|
||||||
|
| Reliable / scheduled / heavy task | Celery / Arq / RQ |
|
||||||
|
| JWT decode | `PyJWT` (`import jwt`) |
|
||||||
|
| Async DB | SQLAlchemy 2.0 async (`AsyncSession`) |
|
||||||
|
| HTTP test client | `httpx.AsyncClient` + `ASGITransport` |
|
||||||
|
| Swap dep in tests | `app.dependency_overrides[dep] = fake` |
|
||||||
|
| Lint + format | `ruff check --fix` + `ruff format` |
|
||||||
871
docs/BEST_PRACTICES.md
Normal file
@@ -0,0 +1,871 @@
|
|||||||
|
## FastAPI Best Practices <!-- omit from toc -->
|
||||||
|
Opinionated list of best practices and conventions we use at our startups.
|
||||||
|
|
||||||
|
After several years of building production systems,
|
||||||
|
we've made both good and bad decisions that significantly impacted our developer experience.
|
||||||
|
Here are some lessons worth sharing.
|
||||||
|
|
||||||
|
> **Working with an AI agent?** See [AGENTS.md](../AGENTS.md) for the same rules in a
|
||||||
|
> terse, machine-readable format with a version matrix, Do/Don't blocks, and an
|
||||||
|
> anti-patterns checklist.
|
||||||
|
|
||||||
|
*[简体中文](./BEST_PRACTICES_ZH.md)*
|
||||||
|
|
||||||
|
## Contents <!-- omit from toc -->
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Async Routes](#async-routes)
|
||||||
|
- [I/O Intensive Tasks](#io-intensive-tasks)
|
||||||
|
- [CPU Intensive Tasks](#cpu-intensive-tasks)
|
||||||
|
- [Pydantic](#pydantic)
|
||||||
|
- [Excessively use Pydantic](#excessively-use-pydantic)
|
||||||
|
- [Custom Base Model](#custom-base-model)
|
||||||
|
- [Decouple Pydantic BaseSettings](#decouple-pydantic-basesettings)
|
||||||
|
- [Dependencies](#dependencies)
|
||||||
|
- [Beyond Dependency Injection](#beyond-dependency-injection)
|
||||||
|
- [Chain Dependencies](#chain-dependencies)
|
||||||
|
- [Decouple \& Reuse dependencies. Dependency calls are cached](#decouple--reuse-dependencies-dependency-calls-are-cached)
|
||||||
|
- [Prefer `async` dependencies](#prefer-async-dependencies)
|
||||||
|
- [Miscellaneous](#miscellaneous)
|
||||||
|
- [Follow the REST](#follow-the-rest)
|
||||||
|
- [FastAPI response serialization](#fastapi-response-serialization)
|
||||||
|
- [If you must use sync SDK, then run it in a thread pool.](#if-you-must-use-sync-sdk-then-run-it-in-a-thread-pool)
|
||||||
|
- [BackgroundTasks vs a real task queue](#backgroundtasks-vs-a-real-task-queue)
|
||||||
|
- [ValueErrors might become Pydantic ValidationError](#valueerrors-might-become-pydantic-validationerror)
|
||||||
|
- [Docs](#docs)
|
||||||
|
- [Set DB keys naming conventions](#set-db-keys-naming-conventions)
|
||||||
|
- [Migrations. Alembic](#migrations-alembic)
|
||||||
|
- [Set DB naming conventions](#set-db-naming-conventions)
|
||||||
|
- [SQL-first. Pydantic-second](#sql-first-pydantic-second)
|
||||||
|
- [Set tests client async from day 0](#set-tests-client-async-from-day-0)
|
||||||
|
- [Use ruff](#use-ruff)
|
||||||
|
- [Bonus Section](#bonus-section)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
There are many ways to structure a project, but the best structure is one that is consistent, straightforward, and free of surprises.
|
||||||
|
|
||||||
|
Many example projects and tutorials organize projects by file type (e.g., crud, routers, models), which works well for microservices or smaller projects. However, this approach didn't scale well for our monolith with many domains and modules.
|
||||||
|
|
||||||
|
The structure I found more scalable and evolvable is inspired by Netflix's [Dispatch](https://github.com/Netflix/dispatch), with some minor modifications.
|
||||||
|
```
|
||||||
|
fastapi-project
|
||||||
|
├── alembic/
|
||||||
|
├── src
|
||||||
|
│ ├── auth
|
||||||
|
│ │ ├── router.py
|
||||||
|
│ │ ├── schemas.py # pydantic models
|
||||||
|
│ │ ├── models.py # db models
|
||||||
|
│ │ ├── dependencies.py
|
||||||
|
│ │ ├── config.py # local configs
|
||||||
|
│ │ ├── constants.py
|
||||||
|
│ │ ├── exceptions.py
|
||||||
|
│ │ ├── service.py
|
||||||
|
│ │ └── utils.py
|
||||||
|
│ ├── aws
|
||||||
|
│ │ ├── client.py # client model for external service communication
|
||||||
|
│ │ ├── schemas.py
|
||||||
|
│ │ ├── config.py
|
||||||
|
│ │ ├── constants.py
|
||||||
|
│ │ ├── exceptions.py
|
||||||
|
│ │ └── utils.py
|
||||||
|
│ ├── posts
|
||||||
|
│ │ ├── router.py
|
||||||
|
│ │ ├── schemas.py
|
||||||
|
│ │ ├── models.py
|
||||||
|
│ │ ├── dependencies.py
|
||||||
|
│ │ ├── constants.py
|
||||||
|
│ │ ├── exceptions.py
|
||||||
|
│ │ ├── service.py
|
||||||
|
│ │ └── utils.py
|
||||||
|
│ ├── config.py # global configs
|
||||||
|
│ ├── models.py # global models
|
||||||
|
│ ├── exceptions.py # global exceptions
|
||||||
|
│ ├── pagination.py # global module e.g. pagination
|
||||||
|
│ ├── database.py # db connection related stuff
|
||||||
|
│ └── main.py
|
||||||
|
├── tests/
|
||||||
|
│ ├── auth
|
||||||
|
│ ├── aws
|
||||||
|
│ └── posts
|
||||||
|
├── templates/
|
||||||
|
│ └── index.html
|
||||||
|
├── requirements
|
||||||
|
│ ├── base.txt
|
||||||
|
│ ├── dev.txt
|
||||||
|
│ └── prod.txt
|
||||||
|
├── .env
|
||||||
|
├── .gitignore
|
||||||
|
├── logging.ini
|
||||||
|
└── alembic.ini
|
||||||
|
```
|
||||||
|
1. Store all domain directories inside `src` folder
|
||||||
|
1. `src/` - highest level of an app, contains common models, configs, and constants, etc.
|
||||||
|
2. `src/main.py` - root of the project, which inits the FastAPI app
|
||||||
|
2. Each package has its own router, schemas, models, etc.
|
||||||
|
1. `router.py` - is a core of each module with all the endpoints
|
||||||
|
2. `schemas.py` - for pydantic models
|
||||||
|
3. `models.py` - for db models
|
||||||
|
4. `service.py` - module specific business logic
|
||||||
|
5. `dependencies.py` - router dependencies
|
||||||
|
6. `constants.py` - module specific constants and error codes
|
||||||
|
7. `config.py` - e.g. env vars
|
||||||
|
8. `utils.py` - non-business logic functions, e.g. response normalization, data enrichment, etc.
|
||||||
|
9. `exceptions.py` - module specific exceptions, e.g. `PostNotFound`, `InvalidUserData`
|
||||||
|
3. When package requires services or dependencies or constants from other packages - import them with an explicit module name
|
||||||
|
```python
|
||||||
|
from src.auth import constants as auth_constants
|
||||||
|
from src.notifications import service as notification_service
|
||||||
|
from src.posts.constants import ErrorCode as PostsErrorCode # in case we have Standard ErrorCode in constants module of each package
|
||||||
|
```
|
||||||
|
|
||||||
|
## Async Routes
|
||||||
|
FastAPI is an async-first framework—it's designed to work with async I/O operations, which is why it's so fast.
|
||||||
|
|
||||||
|
However, FastAPI doesn't restrict you to only `async` routes; you can use `sync` routes too. This might confuse beginners into thinking they're the same, but they're not.
|
||||||
|
|
||||||
|
### I/O Intensive Tasks
|
||||||
|
Under the hood, FastAPI can [effectively handle](https://fastapi.tiangolo.com/async/#path-operation-functions) both async and sync I/O operations:
|
||||||
|
- FastAPI runs `sync` routes in a [threadpool](https://en.wikipedia.org/wiki/Thread_pool), so blocking I/O operations won't stop the [event loop](https://docs.python.org/3/library/asyncio-eventloop.html) from executing other tasks.
|
||||||
|
- If the route is defined as `async`, it's called via `await` and FastAPI trusts you to only perform non-blocking I/O operations.
|
||||||
|
|
||||||
|
The caveat is that if you violate that trust and execute blocking operations within async routes, the event loop won't be able to run other tasks until the blocking operation completes.
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/terrible-ping")
|
||||||
|
async def terrible_ping():
|
||||||
|
time.sleep(10) # I/O blocking operation for 10 seconds, the whole process will be blocked
|
||||||
|
|
||||||
|
return {"pong": True}
|
||||||
|
|
||||||
|
@router.get("/good-ping")
|
||||||
|
def good_ping():
|
||||||
|
time.sleep(10) # I/O blocking operation for 10 seconds, but in a separate thread for the whole `good_ping` route
|
||||||
|
|
||||||
|
return {"pong": True}
|
||||||
|
|
||||||
|
@router.get("/perfect-ping")
|
||||||
|
async def perfect_ping():
|
||||||
|
await asyncio.sleep(10) # non-blocking I/O operation
|
||||||
|
|
||||||
|
return {"pong": True}
|
||||||
|
|
||||||
|
```
|
||||||
|
**What happens when we call:**
|
||||||
|
1. `GET /terrible-ping`
|
||||||
|
1. FastAPI server receives a request and starts handling it
|
||||||
|
2. Server's event loop and all queued tasks wait until `time.sleep()` finishes
|
||||||
|
1. Since the route is `async`, the server doesn't offload it to a threadpool—it blocks the entire event loop
|
||||||
|
2. Server won't accept any new requests while waiting
|
||||||
|
3. Server returns the response
|
||||||
|
1. Only after responding does the server resume accepting new requests
|
||||||
|
2. `GET /good-ping`
|
||||||
|
1. FastAPI server receives a request and starts handling it
|
||||||
|
2. FastAPI sends the entire `good_ping` route to the threadpool, where a worker thread runs the function
|
||||||
|
3. While `good_ping` executes, the event loop continues processing other tasks (e.g., accepting new requests, calling the database)
|
||||||
|
- The worker thread waits for `time.sleep` to finish, independently of the main thread
|
||||||
|
- The sync operation blocks only the worker thread, not the main event loop
|
||||||
|
4. When `good_ping` finishes, the server returns a response to the client
|
||||||
|
3. `GET /perfect-ping`
|
||||||
|
1. FastAPI server receives a request and starts handling it
|
||||||
|
2. FastAPI awaits `asyncio.sleep(10)`
|
||||||
|
3. Event loop continues processing other tasks from the queue (e.g., accepting new requests, calling the database)
|
||||||
|
4. When `asyncio.sleep(10)` completes, the server finishes executing the route and returns a response to the client
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Notes on the thread pool:
|
||||||
|
> - Threads require more resources than coroutines, so they are not as cheap as async I/O operations.
|
||||||
|
> - Thread pool has a limited number of threads, i.e. you might run out of threads and your app will become slow. [Read more](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#2-be-careful-with-non-async-functions) (external link)
|
||||||
|
|
||||||
|
### CPU Intensive Tasks
|
||||||
|
The second caveat is that non-blocking awaitables and threadpool offloading are only beneficial for I/O intensive tasks (e.g., file operations, database calls, external API requests).
|
||||||
|
- Awaiting CPU-intensive tasks (e.g., heavy calculations, data processing, video transcoding) provides no benefit since the CPU must actively work to complete them. In contrast, I/O operations are external—the server just waits for a response and can handle other tasks in the meantime.
|
||||||
|
- Running CPU-intensive tasks in other threads is also ineffective due to the [GIL](https://realpython.com/python-gil/). In short, the GIL allows only one thread to execute Python bytecode at a time, making threads ineffective for CPU-bound work.
|
||||||
|
- To optimize CPU-intensive tasks, you should offload them to worker processes (e.g., using `multiprocessing` or a task queue like Celery).
|
||||||
|
|
||||||
|
**Related StackOverflow questions of confused users**
|
||||||
|
1. https://stackoverflow.com/questions/62976648/architecture-flask-vs-fastapi/70309597#70309597
|
||||||
|
- Here you can also check [my answer](https://stackoverflow.com/a/70309597/6927498)
|
||||||
|
2. https://stackoverflow.com/questions/65342833/fastapi-uploadfile-is-slow-compared-to-flask
|
||||||
|
3. https://stackoverflow.com/questions/71516140/fastapi-runs-api-calls-in-serial-instead-of-parallel-fashion
|
||||||
|
|
||||||
|
## Pydantic
|
||||||
|
### Excessively use Pydantic
|
||||||
|
Pydantic has a rich set of features to validate and transform data.
|
||||||
|
|
||||||
|
In addition to standard features like required and optional fields with default values,
|
||||||
|
Pydantic has built-in data processing tools like regex validation, enums, string manipulation, email validation, and more.
|
||||||
|
```python
|
||||||
|
from enum import StrEnum
|
||||||
|
from pydantic import AnyUrl, BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MusicBand(StrEnum):
|
||||||
|
AEROSMITH = "AEROSMITH"
|
||||||
|
QUEEN = "QUEEN"
|
||||||
|
ACDC = "AC/DC"
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
first_name: str = Field(min_length=1, max_length=128)
|
||||||
|
username: str = Field(min_length=1, max_length=128, pattern="^[A-Za-z0-9-_]+$")
|
||||||
|
email: EmailStr
|
||||||
|
age: int = Field(ge=18) # required, must be greater or equal to 18
|
||||||
|
favorite_band: MusicBand | None = None # only "AEROSMITH", "QUEEN", "AC/DC" values are allowed to be inputted
|
||||||
|
website: AnyUrl | None = None
|
||||||
|
```
|
||||||
|
### Custom Base Model
|
||||||
|
Having a controllable global base model allows us to customize all the models within the app. For instance, we can enforce a standard datetime format or introduce a common method for all subclasses of the base model.
|
||||||
|
```python
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_serializer
|
||||||
|
|
||||||
|
|
||||||
|
class CustomModel(BaseModel):
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
@field_serializer("*", when_used="json", check_fields=False)
|
||||||
|
def _serialize_datetimes(self, value: Any) -> Any:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=ZoneInfo("UTC"))
|
||||||
|
return value.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def serializable_dict(self, **kwargs):
|
||||||
|
"""Return a dict which contains only serializable fields."""
|
||||||
|
default_dict = self.model_dump()
|
||||||
|
|
||||||
|
return jsonable_encoder(default_dict)
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
In the example above, we have decided to create a global base model that:
|
||||||
|
- Serializes all datetime fields to a standard format with an explicit timezone
|
||||||
|
- Provides a method to return a dict with only serializable fields
|
||||||
|
### Decouple Pydantic BaseSettings
|
||||||
|
BaseSettings is great for reading environment variables, but a single BaseSettings for the whole app gets messy. Split it across modules and domains.
|
||||||
|
```python
|
||||||
|
# src.auth.config
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class AuthConfig(BaseSettings):
|
||||||
|
JWT_ALG: str
|
||||||
|
JWT_SECRET: str
|
||||||
|
JWT_EXP: int = 5 # minutes
|
||||||
|
|
||||||
|
REFRESH_TOKEN_KEY: str
|
||||||
|
REFRESH_TOKEN_EXP: timedelta = timedelta(days=30)
|
||||||
|
|
||||||
|
SECURE_COOKIES: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
auth_settings = AuthConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# src.config
|
||||||
|
from pydantic import PostgresDsn, RedisDsn
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
from src.constants import Environment
|
||||||
|
|
||||||
|
|
||||||
|
class Config(BaseSettings):
|
||||||
|
DATABASE_URL: PostgresDsn
|
||||||
|
REDIS_URL: RedisDsn
|
||||||
|
|
||||||
|
SITE_DOMAIN: str = "myapp.com"
|
||||||
|
|
||||||
|
ENVIRONMENT: Environment = Environment.PRODUCTION
|
||||||
|
|
||||||
|
SENTRY_DSN: str | None = None
|
||||||
|
|
||||||
|
CORS_ORIGINS: list[str]
|
||||||
|
CORS_ORIGINS_REGEX: str | None = None
|
||||||
|
CORS_HEADERS: list[str]
|
||||||
|
|
||||||
|
APP_VERSION: str = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Config()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
### Beyond Dependency Injection
|
||||||
|
Pydantic is a great schema validator, but for complex validations that require database or external service calls, it's not enough.
|
||||||
|
|
||||||
|
FastAPI docs mostly present dependencies as DI for endpoints, but they're also great for request validation.
|
||||||
|
|
||||||
|
Dependencies can validate data against database constraints (e.g., checking if an email already exists, ensuring a user exists, etc.).
|
||||||
|
```python
|
||||||
|
# dependencies.py
|
||||||
|
async def valid_post_id(post_id: UUID4) -> dict[str, Any]:
|
||||||
|
post = await service.get_by_id(post_id)
|
||||||
|
if not post:
|
||||||
|
raise PostNotFound()
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
# router.py
|
||||||
|
@router.get("/posts/{post_id}", response_model=PostResponse)
|
||||||
|
async def get_post_by_id(post: dict[str, Any] = Depends(valid_post_id)):
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/posts/{post_id}", response_model=PostResponse)
|
||||||
|
async def update_post(
|
||||||
|
update_data: PostUpdate,
|
||||||
|
post: dict[str, Any] = Depends(valid_post_id),
|
||||||
|
):
|
||||||
|
updated_post = await service.update(id=post["id"], data=update_data)
|
||||||
|
return updated_post
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/posts/{post_id}/reviews", response_model=list[ReviewsResponse])
|
||||||
|
async def get_post_reviews(post: dict[str, Any] = Depends(valid_post_id)):
|
||||||
|
post_reviews = await reviews_service.get_by_post_id(post["id"])
|
||||||
|
return post_reviews
|
||||||
|
```
|
||||||
|
If we didn't put data validation in a dependency, we would have to validate that `post_id` exists
|
||||||
|
in every endpoint and write the same tests for each of them.
|
||||||
|
|
||||||
|
### Chain Dependencies
|
||||||
|
Dependencies can use other dependencies and avoid code repetition for similar logic.
|
||||||
|
```python
|
||||||
|
# dependencies.py
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
import jwt # PyJWT
|
||||||
|
from jwt.exceptions import InvalidTokenError
|
||||||
|
|
||||||
|
async def valid_post_id(post_id: UUID4) -> dict[str, Any]:
|
||||||
|
post = await service.get_by_id(post_id)
|
||||||
|
if not post:
|
||||||
|
raise PostNotFound()
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_jwt_data(
|
||||||
|
token: str = Depends(OAuth2PasswordBearer(tokenUrl="/auth/token"))
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
|
||||||
|
except InvalidTokenError:
|
||||||
|
raise InvalidCredentials()
|
||||||
|
|
||||||
|
return {"user_id": payload["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def valid_owned_post(
|
||||||
|
post: dict[str, Any] = Depends(valid_post_id),
|
||||||
|
token_data: dict[str, Any] = Depends(parse_jwt_data),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if post["creator_id"] != token_data["user_id"]:
|
||||||
|
raise UserNotOwner()
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
# router.py
|
||||||
|
@router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse)
|
||||||
|
async def get_user_post(post: dict[str, Any] = Depends(valid_owned_post)):
|
||||||
|
return post
|
||||||
|
|
||||||
|
```
|
||||||
|
### Decouple & Reuse dependencies. Dependency calls are cached
|
||||||
|
Dependencies can be reused multiple times, and they won't be recalculated - FastAPI caches dependency's result within a request's scope by default,
|
||||||
|
i.e. if `valid_post_id` gets called multiple times in one route, it will be called only once.
|
||||||
|
|
||||||
|
Knowing this, we can decouple dependencies onto multiple smaller functions that operate on a smaller domain and are easier to reuse in other routes.
|
||||||
|
For example, in the code below we are using `parse_jwt_data` three times:
|
||||||
|
1. `valid_owned_post`
|
||||||
|
2. `valid_active_creator`
|
||||||
|
3. `get_user_post`,
|
||||||
|
|
||||||
|
but `parse_jwt_data` is called only once, in the very first call.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# dependencies.py
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
import jwt # PyJWT
|
||||||
|
from jwt.exceptions import InvalidTokenError
|
||||||
|
|
||||||
|
async def valid_post_id(post_id: UUID4) -> Mapping:
|
||||||
|
post = await service.get_by_id(post_id)
|
||||||
|
if not post:
|
||||||
|
raise PostNotFound()
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_jwt_data(
|
||||||
|
token: str = Depends(OAuth2PasswordBearer(tokenUrl="/auth/token"))
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
|
||||||
|
except InvalidTokenError:
|
||||||
|
raise InvalidCredentials()
|
||||||
|
|
||||||
|
return {"user_id": payload["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def valid_owned_post(
|
||||||
|
post: Mapping = Depends(valid_post_id),
|
||||||
|
token_data: dict = Depends(parse_jwt_data),
|
||||||
|
) -> Mapping:
|
||||||
|
if post["creator_id"] != token_data["user_id"]:
|
||||||
|
raise UserNotOwner()
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
async def valid_active_creator(
|
||||||
|
token_data: dict = Depends(parse_jwt_data),
|
||||||
|
):
|
||||||
|
user = await users_service.get_by_id(token_data["user_id"])
|
||||||
|
if not user["is_active"]:
|
||||||
|
raise UserIsBanned()
|
||||||
|
|
||||||
|
if not user["is_creator"]:
|
||||||
|
raise UserNotCreator()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# router.py
|
||||||
|
@router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse)
|
||||||
|
async def get_user_post(
|
||||||
|
worker: BackgroundTasks,
|
||||||
|
post: Mapping = Depends(valid_owned_post),
|
||||||
|
user: Mapping = Depends(valid_active_creator),
|
||||||
|
):
|
||||||
|
"""Get post that belong the active user."""
|
||||||
|
worker.add_task(notifications_service.send_email, user["id"])
|
||||||
|
return post
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prefer `async` dependencies
|
||||||
|
FastAPI supports both `sync` and `async` dependencies. It's tempting to use `sync` when you don't need to await anything, but that's not the best choice.
|
||||||
|
|
||||||
|
Just like routes, `sync` dependencies run in a threadpool. Threads have overhead that's unnecessary for small non-I/O operations.
|
||||||
|
|
||||||
|
[See more](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#9-your-dependencies-may-be-running-on-threads) (external link)
|
||||||
|
|
||||||
|
|
||||||
|
## Miscellaneous
|
||||||
|
### Follow the REST
|
||||||
|
Developing RESTful API makes it easier to reuse dependencies in routes like these:
|
||||||
|
1. `GET /courses/:course_id`
|
||||||
|
2. `GET /courses/:course_id/chapters/:chapter_id/lessons`
|
||||||
|
3. `GET /chapters/:chapter_id`
|
||||||
|
|
||||||
|
The only caveat is having to use the same variable names in the path:
|
||||||
|
- If you have two endpoints `GET /profiles/:profile_id` and `GET /creators/:creator_id`
|
||||||
|
that both validate whether the given `profile_id` exists, but `GET /creators/:creator_id`
|
||||||
|
also checks if the profile is creator, then it's better to rename `creator_id` path variable to `profile_id` and chain those two dependencies.
|
||||||
|
```python
|
||||||
|
# src.profiles.dependencies
|
||||||
|
async def valid_profile_id(profile_id: UUID4) -> Mapping:
|
||||||
|
profile = await service.get_by_id(profile_id)
|
||||||
|
if not profile:
|
||||||
|
raise ProfileNotFound()
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
# src.creators.dependencies
|
||||||
|
async def valid_creator_id(profile: Mapping = Depends(valid_profile_id)) -> Mapping:
|
||||||
|
if not profile["is_creator"]:
|
||||||
|
raise ProfileNotCreator()
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
# src.profiles.router.py
|
||||||
|
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
|
||||||
|
async def get_user_profile_by_id(profile: Mapping = Depends(valid_profile_id)):
|
||||||
|
"""Get profile by id."""
|
||||||
|
return profile
|
||||||
|
|
||||||
|
# src.creators.router.py
|
||||||
|
@router.get("/creators/{profile_id}", response_model=ProfileResponse)
|
||||||
|
async def get_user_profile_by_id(
|
||||||
|
creator_profile: Mapping = Depends(valid_creator_id)
|
||||||
|
):
|
||||||
|
"""Get creator's profile by id."""
|
||||||
|
return creator_profile
|
||||||
|
|
||||||
|
```
|
||||||
|
### FastAPI response serialization
|
||||||
|
You might think you can return a Pydantic object that matches your route's `response_model` and skip some processing steps, but you'd be wrong.
|
||||||
|
|
||||||
|
FastAPI first converts the Pydantic object to a dict using `jsonable_encoder`, then validates the data against your `response_model`, and only then serializes it to JSON.
|
||||||
|
|
||||||
|
This means your Pydantic model object is created twice:
|
||||||
|
- First, when you explicitly create it to return from your route.
|
||||||
|
- Second, implicitly by FastAPI to validate the response data according to the response_model.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from pydantic import BaseModel, model_validator
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileResponse(BaseModel):
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def debug_usage(self):
|
||||||
|
print("created pydantic model")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_model=ProfileResponse)
|
||||||
|
async def root():
|
||||||
|
return ProfileResponse()
|
||||||
|
```
|
||||||
|
**Logs Output:**
|
||||||
|
```
|
||||||
|
[INFO] [2022-08-28 12:00:00.000000] created pydantic model
|
||||||
|
[INFO] [2022-08-28 12:00:00.000020] created pydantic model
|
||||||
|
```
|
||||||
|
|
||||||
|
### If you must use sync SDK, then run it in a thread pool.
|
||||||
|
If you must use a library that's not `async`, run the HTTP calls in an external worker thread.
|
||||||
|
|
||||||
|
Use `run_in_threadpool` from Starlette.
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from my_sync_library import SyncAPIClient
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def call_my_sync_library():
|
||||||
|
my_data = await service.get_my_data()
|
||||||
|
|
||||||
|
client = SyncAPIClient()
|
||||||
|
await run_in_threadpool(client.make_request, data=my_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### BackgroundTasks vs a real task queue
|
||||||
|
FastAPI's `BackgroundTasks` is convenient — and a footgun if you misuse it. Tasks run **after the response is sent, in the same worker process**. If the worker dies, the task is gone. There is no retry, no visibility, no scheduling.
|
||||||
|
|
||||||
|
| Use `BackgroundTasks` when… | Use Celery / Arq / RQ when… |
|
||||||
|
|--------------------------------------------------|-------------------------------------------------|
|
||||||
|
| Task is short (< 1 second) | Task takes seconds to minutes |
|
||||||
|
| Failure can be silently dropped | You need retries or dead-letter handling |
|
||||||
|
| It's in-process (send email, log a row) | It's CPU-heavy or needs a separate worker pool |
|
||||||
|
| You don't need scheduling or rate limiting | You need cron, ETA, or rate limiting |
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup")
|
||||||
|
async def signup(data: SignupIn, bg: BackgroundTasks):
|
||||||
|
user = await service.create_user(data)
|
||||||
|
bg.add_task(send_welcome_email, user.email) # fire-and-forget, in-process
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
Rule of thumb: if you'd page someone when the task is lost, it doesn't belong in `BackgroundTasks`.
|
||||||
|
|
||||||
|
### ValueErrors might become Pydantic ValidationError
|
||||||
|
If you raise a `ValueError` in a Pydantic schema that's used directly in a request body, FastAPI will return a detailed validation error response to users.
|
||||||
|
```python
|
||||||
|
# src.profiles.schemas
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
class ProfileCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@field_validator("password", mode="after")
|
||||||
|
@classmethod
|
||||||
|
def valid_password(cls, password: str) -> str:
|
||||||
|
if not re.match(STRONG_PASSWORD_PATTERN, password):
|
||||||
|
raise ValueError(
|
||||||
|
"Password must contain at least "
|
||||||
|
"one lower character, "
|
||||||
|
"one upper character, "
|
||||||
|
"digit or "
|
||||||
|
"special symbol"
|
||||||
|
)
|
||||||
|
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
|
# src.profiles.routes
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profiles")
|
||||||
|
async def create_profile(profile_data: ProfileCreate):
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
**Response Example:**
|
||||||
|
|
||||||
|
<img src="images/value_error_response.png" width="400" height="auto">
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
1. Unless your API is public, hide docs by default. Show it explicitly on the selected envs only.
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from starlette.config import Config
|
||||||
|
|
||||||
|
config = Config(".env") # parse .env file for env variables
|
||||||
|
|
||||||
|
ENVIRONMENT = config("ENVIRONMENT") # get current env name
|
||||||
|
SHOW_DOCS_ENVIRONMENT = ("local", "staging") # explicit list of allowed envs
|
||||||
|
|
||||||
|
app_configs = {"title": "My Cool API"}
|
||||||
|
if ENVIRONMENT not in SHOW_DOCS_ENVIRONMENT:
|
||||||
|
app_configs["openapi_url"] = None # set url for docs as null
|
||||||
|
|
||||||
|
app = FastAPI(**app_configs)
|
||||||
|
```
|
||||||
|
2. Help FastAPI to generate an easy-to-understand docs
|
||||||
|
1. Set `response_model`, `status_code`, `description`, etc.
|
||||||
|
2. If models and statuses vary, use `responses` route attribute to add docs for different responses
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/endpoints",
|
||||||
|
response_model=DefaultResponseModel, # default response pydantic model
|
||||||
|
status_code=status.HTTP_201_CREATED, # default status code
|
||||||
|
description="Description of the well documented endpoint",
|
||||||
|
tags=["Endpoint Category"],
|
||||||
|
summary="Summary of the Endpoint",
|
||||||
|
responses={
|
||||||
|
status.HTTP_200_OK: {
|
||||||
|
"model": OkResponse, # custom pydantic model for 200 response
|
||||||
|
"description": "Ok Response",
|
||||||
|
},
|
||||||
|
status.HTTP_201_CREATED: {
|
||||||
|
"model": CreatedResponse, # custom pydantic model for 201 response
|
||||||
|
"description": "Creates something from user request",
|
||||||
|
},
|
||||||
|
status.HTTP_202_ACCEPTED: {
|
||||||
|
"model": AcceptedResponse, # custom pydantic model for 202 response
|
||||||
|
"description": "Accepts request and handles it later",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def documented_route():
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
Will generate docs like this:
|
||||||
|

|
||||||
|
|
||||||
|
### Set DB keys naming conventions
|
||||||
|
Explicitly setting the indexes' namings according to your database's convention is preferable over sqlalchemy's.
|
||||||
|
```python
|
||||||
|
from sqlalchemy import MetaData
|
||||||
|
|
||||||
|
POSTGRES_INDEXES_NAMING_CONVENTION = {
|
||||||
|
"ix": "%(column_0_label)s_idx",
|
||||||
|
"uq": "%(table_name)s_%(column_0_name)s_key",
|
||||||
|
"ck": "%(table_name)s_%(constraint_name)s_check",
|
||||||
|
"fk": "%(table_name)s_%(column_0_name)s_fkey",
|
||||||
|
"pk": "%(table_name)s_pkey",
|
||||||
|
}
|
||||||
|
metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)
|
||||||
|
```
|
||||||
|
### Migrations. Alembic
|
||||||
|
1. Migrations must be static and reversible. If your migrations depend on dynamically generated data, make sure only the data itself is dynamic, not its structure.
|
||||||
|
2. Generate migrations with descriptive names and slugs. The slug is required and should explain the changes.
|
||||||
|
3. Set a human-readable file template for new migrations. We use the `*date*_*slug*.py` pattern, e.g., `2022-08-24_post_content_idx.py`
|
||||||
|
```
|
||||||
|
# alembic.ini
|
||||||
|
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
|
||||||
|
```
|
||||||
|
### Set DB naming conventions
|
||||||
|
Being consistent with names is important. Some rules we followed:
|
||||||
|
1. lower_case_snake
|
||||||
|
2. singular form (e.g. `post`, `post_like`, `user_playlist`)
|
||||||
|
3. group similar tables with module prefix, e.g. `payment_account`, `payment_bill`, `post`, `post_like`
|
||||||
|
4. stay consistent across tables, but concrete namings are ok, e.g.
|
||||||
|
1. use `profile_id` in all tables, but if some of them need only profiles that are creators, use `creator_id`
|
||||||
|
2. use `post_id` for all abstract tables like `post_like`, `post_view`, but use concrete naming in relevant modules like `course_id` in `chapters.course_id`
|
||||||
|
5. `_at` suffix for datetime
|
||||||
|
6. `_date` suffix for date
|
||||||
|
### SQL-first. Pydantic-second
|
||||||
|
- Usually, database handles data processing much faster and cleaner than CPython will ever do.
|
||||||
|
- It's preferable to do all the complex joins and simple data manipulations with SQL.
|
||||||
|
- It's preferable to aggregate JSONs in DB for responses with nested objects.
|
||||||
|
|
||||||
|
For new projects, reach for SQLAlchemy 2.0's async API (`AsyncSession`, `async_sessionmaker`). The example below uses `encode/databases` for brevity — the SQL-first principle is what matters; the client is interchangeable.
|
||||||
|
```python
|
||||||
|
# src.posts.service
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import UUID4
|
||||||
|
from sqlalchemy import desc, func, select, text
|
||||||
|
from sqlalchemy.sql.functions import coalesce
|
||||||
|
|
||||||
|
from src.database import database, posts, profiles, post_review, products
|
||||||
|
|
||||||
|
async def get_posts(
|
||||||
|
creator_id: UUID4, *, limit: int = 10, offset: int = 0
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
select_query = (
|
||||||
|
select(
|
||||||
|
(
|
||||||
|
posts.c.id,
|
||||||
|
posts.c.slug,
|
||||||
|
posts.c.title,
|
||||||
|
func.json_build_object(
|
||||||
|
text("'id', profiles.id"),
|
||||||
|
text("'first_name', profiles.first_name"),
|
||||||
|
text("'last_name', profiles.last_name"),
|
||||||
|
text("'username', profiles.username"),
|
||||||
|
).label("creator"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.select_from(posts.join(profiles, posts.c.owner_id == profiles.c.id))
|
||||||
|
.where(posts.c.owner_id == creator_id)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.group_by(
|
||||||
|
posts.c.id,
|
||||||
|
posts.c.type,
|
||||||
|
posts.c.slug,
|
||||||
|
posts.c.title,
|
||||||
|
profiles.c.id,
|
||||||
|
profiles.c.first_name,
|
||||||
|
profiles.c.last_name,
|
||||||
|
profiles.c.username,
|
||||||
|
profiles.c.avatar,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
desc(coalesce(posts.c.updated_at, posts.c.published_at, posts.c.created_at))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return await database.fetch_all(select_query)
|
||||||
|
|
||||||
|
# src.posts.schemas
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, UUID4
|
||||||
|
|
||||||
|
|
||||||
|
class Creator(BaseModel):
|
||||||
|
id: UUID4
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class Post(BaseModel):
|
||||||
|
id: UUID4
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
creator: Creator
|
||||||
|
|
||||||
|
|
||||||
|
# src.posts.router
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/creators/{creator_id}/posts", response_model=list[Post])
|
||||||
|
async def get_creator_posts(creator: dict[str, Any] = Depends(valid_creator_id)):
|
||||||
|
posts = await service.get_posts(creator["id"])
|
||||||
|
|
||||||
|
return posts
|
||||||
|
```
|
||||||
|
### Set tests client async from day 0
|
||||||
|
Writing integration tests with DB will likely lead to messed up event loop errors in the future.
|
||||||
|
Set the async test client immediately, using [httpx](https://www.python-httpx.org/) with `ASGITransport`. Don't reach for `async_asgi_testclient` — it's unmaintained.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
from src.main import app # inited FastAPI app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_post(client: AsyncClient):
|
||||||
|
resp = await client.post("/posts")
|
||||||
|
|
||||||
|
assert resp.status_code == 201
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Override dependencies in tests
|
||||||
|
Don't monkeypatch internals. FastAPI's `dependency_overrides` lets you swap any dependency for a test fake — auth, external clients, anything you don't want hitting the network.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.auth.dependencies import parse_jwt_data
|
||||||
|
from src.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def fake_user():
|
||||||
|
return {"user_id": "00000000-0000-0000-0000-000000000001"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _override_auth():
|
||||||
|
app.dependency_overrides[parse_jwt_data] = fake_user
|
||||||
|
yield
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
Unless you have synchronous database connections (excuse me?) or don't plan to write integration tests.
|
||||||
|
|
||||||
|
### Use ruff
|
||||||
|
With linters, you can forget about formatting the code and focus on writing the business logic.
|
||||||
|
|
||||||
|
[Ruff](https://github.com/astral-sh/ruff) is "blazingly-fast" new linter that replaces black, autoflake, isort, and supports more than 600 lint rules.
|
||||||
|
|
||||||
|
It's a popular good practice to use pre-commit hooks, but just using the script was ok for us.
|
||||||
|
```shell
|
||||||
|
#!/bin/sh -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
ruff check --fix src
|
||||||
|
ruff format src
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bonus Section
|
||||||
|
Some very kind people shared their own experience and best practices that are definitely worth reading.
|
||||||
|
Check them out at [issues](https://github.com/zhanymkanov/fastapi-best-practices/issues) section of the project.
|
||||||
|
|
||||||
|
For instance, [lowercase00](https://github.com/zhanymkanov/fastapi-best-practices/issues/4)
|
||||||
|
has described in details their best practices working with permissions & auth, class-based services & views,
|
||||||
|
task queues, custom response serializers, configuration with dynaconf, etc.
|
||||||
|
|
||||||
|
If you have something to share about your experience working with FastAPI, whether it's good or bad,
|
||||||
|
you are very welcome to create a new issue. It is our pleasure to read it.
|
||||||
855
docs/BEST_PRACTICES_ZH.md
Executable file
@@ -0,0 +1,855 @@
|
|||||||
|
# Fast Api最佳实践指南
|
||||||
|
|
||||||
|
这是我在初创公司使用的一系列最佳实践和约定。
|
||||||
|
|
||||||
|
在过去几年的生产实践中,我们做过一些好的和不好的决策,这些决策极大地影响了开发者体验。其中一些经验值得分享。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
- [Fast Api最佳实践指南](#fast-api最佳实践指南)
|
||||||
|
- [目录](#目录)
|
||||||
|
- [项目结构](#项目结构)
|
||||||
|
- [异步路由](#异步路由)
|
||||||
|
- [I/O密集型任务](#io密集型任务)
|
||||||
|
- [CPU密集型任务](#cpu密集型任务)
|
||||||
|
- [Pydantic](#pydantic)
|
||||||
|
- [大量使用Pydantic](#大量使用pydantic)
|
||||||
|
- [自定义基础模型](#自定义基础模型)
|
||||||
|
- [拆分Pydantic BaseSettings](#拆分pydantic-basesettings)
|
||||||
|
- [依赖项](#依赖项)
|
||||||
|
- [超越依赖注入](#超越依赖注入)
|
||||||
|
- [链式依赖](#链式依赖)
|
||||||
|
- [拆分并复用依赖项。依赖调用会被缓存](#拆分并复用依赖项依赖调用会被缓存)
|
||||||
|
- [优先使用`async`依赖项](#优先使用async依赖项)
|
||||||
|
- [其他](#其他)
|
||||||
|
- [遵循REST规范](#遵循rest规范)
|
||||||
|
- [FastAPI响应序列化](#fastapi响应序列化)
|
||||||
|
- [如果必须使用同步SDK,请在线程池中运行它。](#如果必须使用同步sdk请在线程池中运行它)
|
||||||
|
- [ValueErrors可能会变成Pydantic ValidationError](#valueerrors可能会变成pydantic-validationerror)
|
||||||
|
- [文档](#文档)
|
||||||
|
- [迁移工具Alembic](#迁移工具alembic)
|
||||||
|
- [设置数据库键命名约定](#设置数据库键命名约定)
|
||||||
|
- [SQL优先,Pydantic次之](#sql优先pydantic次之)
|
||||||
|
- [从一开始就设置异步测试客户端](#从一开始就设置异步测试客户端)
|
||||||
|
- [使用ruff](#使用ruff)
|
||||||
|
- [额外部分](#额外部分)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
项目结构有很多种,但最好的结构是一致、直观且没有意外的。
|
||||||
|
|
||||||
|
许多示例项目和教程按文件类型(如crud、routers、models)划分项目,这种方式对于微服务或范围较小的项目很有效。但是,这种方法并不适合我们这个包含许多领域和模块的单体应用。
|
||||||
|
|
||||||
|
我发现对于这类情况,更具可扩展性和可演进性的结构是受Netflix的[Dispatch](https://github.com/Netflix/dispatch)启发,并做了一些小修改。
|
||||||
|
|
||||||
|
```
|
||||||
|
fastapi-project
|
||||||
|
├── alembic/
|
||||||
|
├── src
|
||||||
|
│ ├── auth
|
||||||
|
│ │ ├── router.py
|
||||||
|
│ │ ├── schemas.py # pydantic模型
|
||||||
|
│ │ ├── models.py # 数据库模型
|
||||||
|
│ │ ├── dependencies.py
|
||||||
|
│ │ ├── config.py # 本地配置
|
||||||
|
│ │ ├── constants.py
|
||||||
|
│ │ ├── exceptions.py
|
||||||
|
│ │ ├── service.py
|
||||||
|
│ │ └── utils.py
|
||||||
|
│ ├── aws
|
||||||
|
│ │ ├── client.py # 用于外部服务通信的客户端模型
|
||||||
|
│ │ ├── schemas.py
|
||||||
|
│ │ ├── config.py
|
||||||
|
│ │ ├── constants.py
|
||||||
|
│ │ ├── exceptions.py
|
||||||
|
│ │ └── utils.py
|
||||||
|
│ ├── posts
|
||||||
|
│ │ ├── router.py
|
||||||
|
│ │ ├── schemas.py
|
||||||
|
│ │ ├── models.py
|
||||||
|
│ │ ├── dependencies.py
|
||||||
|
│ │ ├── constants.py
|
||||||
|
│ │ ├── exceptions.py
|
||||||
|
│ │ ├── service.py
|
||||||
|
│ │ └── utils.py
|
||||||
|
│ ├── config.py # 全局配置
|
||||||
|
│ ├── models.py # 全局模型
|
||||||
|
│ ├── exceptions.py # 全局异常
|
||||||
|
│ ├── pagination.py # 全局模块,如分页
|
||||||
|
│ ├── database.py # 数据库连接相关内容
|
||||||
|
│ └── main.py
|
||||||
|
├── tests/
|
||||||
|
│ ├── auth
|
||||||
|
│ ├── aws
|
||||||
|
│ └── posts
|
||||||
|
├── templates/
|
||||||
|
│ └── index.html
|
||||||
|
├── requirements
|
||||||
|
│ ├── base.txt
|
||||||
|
│ ├── dev.txt
|
||||||
|
│ └── prod.txt
|
||||||
|
├── .env
|
||||||
|
├── .gitignore
|
||||||
|
├── logging.ini
|
||||||
|
└── alembic.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
1. 将所有领域目录存储在`src`文件夹中
|
||||||
|
1. `src/` - 应用的最高级别,包含通用模型、配置和常量等。
|
||||||
|
2. `src/main.py` - 项目的根文件,用于初始化FastAPI应用
|
||||||
|
|
||||||
|
2. 每个包都有自己的路由、模式、模型等。
|
||||||
|
1. `router.py` - 每个模块的核心,包含所有端点
|
||||||
|
2. `schemas.py` - 用于pydantic模型
|
||||||
|
3. `models.py` - 用于数据库模型
|
||||||
|
4. `service.py` - 模块特定的业务逻辑
|
||||||
|
5. `dependencies.py` - 路由依赖项
|
||||||
|
6. `constants.py` - 模块特定的常量和错误代码
|
||||||
|
7. `config.py` - 例如环境变量
|
||||||
|
8. `utils.py` - 非业务逻辑函数,例如响应规范化、数据丰富等
|
||||||
|
9. `exceptions.py` - 模块特定的异常,例如`PostNotFound`、`InvalidUserData`
|
||||||
|
|
||||||
|
3. 当包需要其他包的服务、依赖项或常量时,使用显式的模块名导入
|
||||||
|
```python
|
||||||
|
from src.auth import constants as auth_constants
|
||||||
|
from src.notifications import service as notification_service
|
||||||
|
from src.posts.constants import ErrorCode as PostsErrorCode # 以防每个包的constants模块中都有标准的ErrorCode
|
||||||
|
```
|
||||||
|
|
||||||
|
## 异步路由
|
||||||
|
|
||||||
|
FastAPI首先是一个异步框架。它设计用于处理异步I/O操作,这也是它如此快速的原因。
|
||||||
|
|
||||||
|
然而,FastAPI并不限制你只能使用`async`路由,开发者也可以使用同步路由。这可能会让初学者误以为它们是一样的,但实际上并非如此。
|
||||||
|
|
||||||
|
### I/O密集型任务
|
||||||
|
|
||||||
|
在底层,FastAPI可以有效地处理异步和同步I/O操作。
|
||||||
|
|
||||||
|
- FastAPI在线程池中运行同步路由,阻塞的I/O操作不会阻止事件循环执行任务。
|
||||||
|
- 如果路由定义为`async`,那么它会通过`await`正常调用,FastAPI相信你只会执行非阻塞的I/O操作。
|
||||||
|
|
||||||
|
需要注意的是,如果你违反了这种信任,在异步路由中执行阻塞操作,事件循环将无法在阻塞操作完成之前运行后续任务。
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/terrible-ping")
|
||||||
|
async def terrible_ping():
|
||||||
|
time.sleep(10) # 10秒的I/O阻塞操作,整个进程都会被阻塞
|
||||||
|
|
||||||
|
return {"pong": True}
|
||||||
|
|
||||||
|
@router.get("/good-ping")
|
||||||
|
def good_ping():
|
||||||
|
time.sleep(10) # 10秒的I/O阻塞操作,但在单独的线程中运行整个`good_ping`路由
|
||||||
|
|
||||||
|
return {"pong": True}
|
||||||
|
|
||||||
|
@router.get("/perfect-ping")
|
||||||
|
async def perfect_ping():
|
||||||
|
await asyncio.sleep(10) # 非阻塞I/O操作
|
||||||
|
|
||||||
|
return {"pong": True}
|
||||||
|
```
|
||||||
|
|
||||||
|
**当我们调用时会发生什么:**
|
||||||
|
|
||||||
|
1. `GET /terrible-ping`
|
||||||
|
1. FastAPI服务器接收请求并开始处理
|
||||||
|
2. 服务器的事件循环和队列中的所有任务都将等待`time.sleep()`完成
|
||||||
|
1. 服务器认为`time.sleep()`不是I/O任务,所以会等待它完成
|
||||||
|
2. 等待期间,服务器不会接受任何新请求
|
||||||
|
3. 服务器返回响应。
|
||||||
|
1. 响应之后,服务器开始接受新请求
|
||||||
|
2. `GET /good-ping`
|
||||||
|
1. FastAPI服务器接收请求并开始处理
|
||||||
|
2. FastAPI将整个路由`good_ping`发送到线程池,工作线程将在那里运行该函数
|
||||||
|
3. 在`good_ping`执行期间,事件循环从队列中选择下一个任务并处理它们(例如接受新请求、调用数据库)
|
||||||
|
- 独立于主线程(即我们的FastAPI应用),工作线程将等待`time.sleep`完成。
|
||||||
|
- 同步操作只阻塞子线程,而不是主线程。
|
||||||
|
4. 当`good_ping`完成工作后,服务器向客户端返回响应
|
||||||
|
3. `GET /perfect-ping`
|
||||||
|
1. FastAPI服务器接收请求并开始处理
|
||||||
|
2. FastAPI等待`asyncio.sleep(10)`
|
||||||
|
3. 事件循环从队列中选择下一个任务并处理它们(例如接受新请求、调用数据库)
|
||||||
|
4. 当`asyncio.sleep(10)`完成后,服务器完成路由的执行并向客户端返回响应
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
关于线程池的注意事项:
|
||||||
|
>
|
||||||
|
> - 线程比协程需要更多资源,因此它们不像异步I/O操作那样轻量。
|
||||||
|
> - 线程池的线程数量是有限的,也就是说,你可能会耗尽线程,导致应用变慢。[了解更多](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#2-be-careful-with-non-async-functions)(外部链接)
|
||||||
|
|
||||||
|
### CPU密集型任务
|
||||||
|
|
||||||
|
第二个需要注意的是,非阻塞的可等待对象或发送到线程池的操作必须是I/O密集型任务(例如打开文件、数据库调用、外部API调用)。
|
||||||
|
|
||||||
|
- 等待CPU密集型任务(例如繁重的计算、数据处理、视频转码)是没有意义的,因为CPU必须工作才能完成这些任务,而I/O操作是外部的,服务器在等待这些操作完成时什么也不做,因此它可以处理下一个任务。
|
||||||
|
- 在其他线程中运行CPU密集型任务也不是有效的,因为[GIL(全局解释器锁)](https://realpython.com/python-gil/)的存在。简而言之,GIL只允许一个线程同时工作,这使得它对CPU任务毫无用处。
|
||||||
|
- 如果你想优化CPU密集型任务,你应该将它们发送到另一个进程中的工作节点。
|
||||||
|
|
||||||
|
**困惑用户的相关StackOverflow问题**
|
||||||
|
|
||||||
|
1. [https://stackoverflow.com/questions/62976648/architecture-flask-vs-fastapi/70309597#70309597](https://stackoverflow.com/questions/62976648/architecture-flask-vs-fastapi/70309597#70309597)
|
||||||
|
- 在这里你也可以查看[我的回答](https://stackoverflow.com/a/70309597/6927498)
|
||||||
|
2. [https://stackoverflow.com/questions/65342833/fastapi-uploadfile-is-slow-compared-to-flask](https://stackoverflow.com/questions/65342833/fastapi-uploadfile-is-slow-compared-to-flask)
|
||||||
|
3. [https://stackoverflow.com/questions/71516140/fastapi-runs-api-calls-in-serial-instead-of-parallel-fashion](https://stackoverflow.com/questions/71516140/fastapi-runs-api-calls-in-serial-instead-of-parallel-fashion)
|
||||||
|
|
||||||
|
## Pydantic
|
||||||
|
|
||||||
|
### 大量使用Pydantic
|
||||||
|
|
||||||
|
Pydantic有丰富的功能来验证和转换数据。
|
||||||
|
|
||||||
|
除了常规功能(如带有默认值的必填和非必填字段),Pydantic还有内置的综合数据处理工具,如正则表达式、枚举、字符串操作、电子邮件验证等。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from enum import Enum
|
||||||
|
from pydantic import AnyUrl, BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
class MusicBand(str, Enum):
|
||||||
|
AEROSMITH = "AEROSMITH"
|
||||||
|
QUEEN = "QUEEN"
|
||||||
|
ACDC = "AC/DC"
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
first_name: str = Field(min_length=1, max_length=128)
|
||||||
|
username: str = Field(min_length=1, max_length=128, pattern="^[A-Za-z0-9-_]+$")
|
||||||
|
email: EmailStr
|
||||||
|
age: int = Field(ge=18) # 必须大于或等于18
|
||||||
|
favorite_band: MusicBand | None = None # 只允许输入"AEROSMITH"、"QUEEN"、"AC/DC"值
|
||||||
|
website: AnyUrl | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义基础模型
|
||||||
|
|
||||||
|
拥有一个可控制的全局基础模型允许我们自定义应用中的所有模型。例如,我们可以强制使用标准的 datetime 格式,或者为基础模型的所有子类引入一个通用方法。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from pydantic import BaseModel, ConfigDict, field_serializer
|
||||||
|
|
||||||
|
class CustomModel(BaseModel):
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
@field_serializer("*", when_used="json", check_fields=False)
|
||||||
|
def _serialize_datetimes(self, value: Any) -> Any:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=ZoneInfo("UTC"))
|
||||||
|
return value.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def serializable_dict(self, **kwargs):
|
||||||
|
"""返回仅包含可序列化字段的字典。"""
|
||||||
|
default_dict = self.model_dump()
|
||||||
|
|
||||||
|
return jsonable_encoder(default_dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
在上面的例子中,我们决定创建一个全局基础模型,它:
|
||||||
|
|
||||||
|
- 将所有datetime字段序列化为具有显式时区的标准格式
|
||||||
|
- 提供一个方法来返回仅包含可序列化字段的字典
|
||||||
|
|
||||||
|
### 拆分Pydantic BaseSettings
|
||||||
|
|
||||||
|
BaseSettings是读取环境变量的一项伟大创新,但为整个应用使用单个BaseSettings随着时间的推移可能会变得混乱。为了提高可维护性和组织性,我们将BaseSettings拆分到不同的模块和领域中。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src.auth.config
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
class AuthConfig(BaseSettings):
|
||||||
|
JWT_ALG: str
|
||||||
|
JWT_SECRET: str
|
||||||
|
JWT_EXP: int = 5 # 分钟
|
||||||
|
|
||||||
|
REFRESH_TOKEN_KEY: str
|
||||||
|
REFRESH_TOKEN_EXP: timedelta = timedelta(days=30)
|
||||||
|
|
||||||
|
SECURE_COOKIES: bool = True
|
||||||
|
|
||||||
|
auth_settings = AuthConfig()
|
||||||
|
|
||||||
|
# src.config
|
||||||
|
from pydantic import PostgresDsn, RedisDsn
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
from src.constants import Environment
|
||||||
|
|
||||||
|
class Config(BaseSettings):
|
||||||
|
DATABASE_URL: PostgresDsn
|
||||||
|
REDIS_URL: RedisDsn
|
||||||
|
|
||||||
|
SITE_DOMAIN: str = "myapp.com"
|
||||||
|
|
||||||
|
ENVIRONMENT: Environment = Environment.PRODUCTION
|
||||||
|
|
||||||
|
SENTRY_DSN: str | None = None
|
||||||
|
|
||||||
|
CORS_ORIGINS: list[str]
|
||||||
|
CORS_ORIGINS_REGEX: str | None = None
|
||||||
|
CORS_HEADERS: list[str]
|
||||||
|
|
||||||
|
APP_VERSION: str = "1.0"
|
||||||
|
|
||||||
|
settings = Config()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖项
|
||||||
|
|
||||||
|
### 超越依赖注入
|
||||||
|
|
||||||
|
Pydantic是一个很棒的模式验证器,但对于涉及调用数据库或外部服务的复杂验证,它还不够。
|
||||||
|
|
||||||
|
FastAPI文档主要将依赖项展示为端点的依赖注入,但它们也非常适合请求验证。
|
||||||
|
|
||||||
|
依赖项可用于根据数据库约束验证数据(例如,检查电子邮件是否已存在、确保找到用户等)。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# dependencies.py
|
||||||
|
async def valid_post_id(post_id: UUID4) -> dict[str, Any]:
|
||||||
|
post = await service.get_by_id(post_id)
|
||||||
|
if not post:
|
||||||
|
raise PostNotFound()
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
# router.py
|
||||||
|
@router.get("/posts/{post_id}", response_model=PostResponse)
|
||||||
|
async def get_post_by_id(post: dict[str, Any] = Depends(valid_post_id)):
|
||||||
|
return post
|
||||||
|
|
||||||
|
@router.put("/posts/{post_id}", response_model=PostResponse)
|
||||||
|
async def update_post(
|
||||||
|
update_data: PostUpdate,
|
||||||
|
post: dict[str, Any] = Depends(valid_post_id),
|
||||||
|
):
|
||||||
|
updated_post = await service.update(id=post["id"], data=update_data)
|
||||||
|
return updated_post
|
||||||
|
|
||||||
|
@router.get("/posts/{post_id}/reviews", response_model=list[ReviewsResponse])
|
||||||
|
async def get_post_reviews(post: dict[str, Any] = Depends(valid_post_id)):
|
||||||
|
post_reviews = await reviews_service.get_by_post_id(post["id"])
|
||||||
|
return post_reviews
|
||||||
|
```
|
||||||
|
|
||||||
|
如果我们没有将数据验证放入依赖项中,我们将不得不为每个端点验证`post_id`是否存在,并为每个端点编写相同的测试。
|
||||||
|
|
||||||
|
### 链式依赖
|
||||||
|
|
||||||
|
依赖项可以使用其他依赖项,避免类似逻辑的代码重复。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# dependencies.py
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
import jwt # PyJWT
|
||||||
|
from jwt.exceptions import InvalidTokenError
|
||||||
|
|
||||||
|
async def valid_post_id(post_id: UUID4) -> dict[str, Any]:
|
||||||
|
post = await service.get_by_id(post_id)
|
||||||
|
if not post:
|
||||||
|
raise PostNotFound()
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
async def parse_jwt_data(
|
||||||
|
token: str = Depends(OAuth2PasswordBearer(tokenUrl="/auth/token"))
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
|
||||||
|
except InvalidTokenError:
|
||||||
|
raise InvalidCredentials()
|
||||||
|
|
||||||
|
return {"user_id": payload["id"]}
|
||||||
|
|
||||||
|
async def valid_owned_post(
|
||||||
|
post: dict[str, Any] = Depends(valid_post_id),
|
||||||
|
token_data: dict[str, Any] = Depends(parse_jwt_data),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if post["creator_id"] != token_data["user_id"]:
|
||||||
|
raise UserNotOwner()
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
# router.py
|
||||||
|
@router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse)
|
||||||
|
async def get_user_post(post: dict[str, Any] = Depends(valid_owned_post)):
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
### 拆分并复用依赖项。依赖调用会被缓存
|
||||||
|
|
||||||
|
依赖项可以多次复用,并且它们不会被重新计算——FastAPI默认在请求的范围内缓存依赖项的结果,也就是说,如果`valid_post_id`在一个路由中被多次调用,它只会被调用一次。
|
||||||
|
|
||||||
|
了解这一点后,我们可以将依赖项拆分为多个更小的函数,这些函数在更小的领域上运行,并且更容易在其他路由中复用。
|
||||||
|
|
||||||
|
例如,在下面的代码中,我们三次使用`parse_jwt_data`:
|
||||||
|
|
||||||
|
1. `valid_owned_post`
|
||||||
|
2. `valid_active_creator`
|
||||||
|
3. `get_user_post`
|
||||||
|
|
||||||
|
但`parse_jwt_data`只在第一次调用时被调用一次。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# dependencies.py
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
import jwt # PyJWT
|
||||||
|
from jwt.exceptions import InvalidTokenError
|
||||||
|
|
||||||
|
async def valid_post_id(post_id: UUID4) -> Mapping:
|
||||||
|
post = await service.get_by_id(post_id)
|
||||||
|
if not post:
|
||||||
|
raise PostNotFound()
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
async def parse_jwt_data(
|
||||||
|
token: str = Depends(OAuth2PasswordBearer(tokenUrl="/auth/token"))
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
|
||||||
|
except InvalidTokenError:
|
||||||
|
raise InvalidCredentials()
|
||||||
|
|
||||||
|
return {"user_id": payload["id"]}
|
||||||
|
|
||||||
|
async def valid_owned_post(
|
||||||
|
post: Mapping = Depends(valid_post_id),
|
||||||
|
token_data: dict = Depends(parse_jwt_data),
|
||||||
|
) -> Mapping:
|
||||||
|
if post["creator_id"] != token_data["user_id"]:
|
||||||
|
raise UserNotOwner()
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
async def valid_active_creator(
|
||||||
|
token_data: dict = Depends(parse_jwt_data),
|
||||||
|
):
|
||||||
|
user = await users_service.get_by_id(token_data["user_id"])
|
||||||
|
if not user["is_active"]:
|
||||||
|
raise UserIsBanned()
|
||||||
|
|
||||||
|
if not user["is_creator"]:
|
||||||
|
raise UserNotCreator()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# router.py
|
||||||
|
@router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse)
|
||||||
|
async def get_user_post(
|
||||||
|
worker: BackgroundTasks,
|
||||||
|
post: Mapping = Depends(valid_owned_post),
|
||||||
|
user: Mapping = Depends(valid_active_creator),
|
||||||
|
):
|
||||||
|
"""Get post that belong the active user."""
|
||||||
|
worker.add_task(notifications_service.send_email, user["id"])
|
||||||
|
return post
|
||||||
|
```
|
||||||
|
|
||||||
|
### 优先使用`async`依赖项
|
||||||
|
|
||||||
|
FastAPI同时支持同步和异步依赖项,当你不需要等待任何东西时,很容易会想使用同步依赖项,但这可能不是最佳选择。
|
||||||
|
|
||||||
|
与路由一样,同步依赖项在线程池中运行。这里的线程也有代价和限制,如果只是进行小的非I/O操作,这些代价和限制是多余的。
|
||||||
|
|
||||||
|
[了解更多](https://github.com/Kludex/fastapi-tips?tab=readme-ov-file#9-your-dependencies-may-be-running-on-threads)(外部链接)
|
||||||
|
|
||||||
|
## 其他
|
||||||
|
|
||||||
|
### 遵循REST规范
|
||||||
|
|
||||||
|
开发RESTful API可以更轻松地在如下路由中复用依赖项:
|
||||||
|
|
||||||
|
1. `GET /courses/:course_id`
|
||||||
|
2. `GET /courses/:course_id/chapters/:chapter_id/lessons`
|
||||||
|
3. `GET /chapters/:chapter_id`
|
||||||
|
|
||||||
|
唯一需要注意的是必须在路径中使用相同的变量名:
|
||||||
|
|
||||||
|
- 如果你有两个端点`GET /profiles/:profile_id`和`GET /creators/:creator_id`,它们都验证给定的`profile_id`是否存在,但`GET /creators/:creator_id`还检查该个人资料是否是创作者,那么最好将`creator_id`路径变量重命名为`profile_id`并链接这两个依赖项。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src.profiles.dependencies
|
||||||
|
async def valid_profile_id(profile_id: UUID4) -> Mapping:
|
||||||
|
profile = await service.get_by_id(profile_id)
|
||||||
|
if not profile:
|
||||||
|
raise ProfileNotFound()
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
# src.creators.dependencies
|
||||||
|
async def valid_creator_id(profile: Mapping = Depends(valid_profile_id)) -> Mapping:
|
||||||
|
if not profile["is_creator"]:
|
||||||
|
raise ProfileNotCreator()
|
||||||
|
|
||||||
|
return profile
|
||||||
|
|
||||||
|
# src.profiles.router.py
|
||||||
|
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
|
||||||
|
async def get_user_profile_by_id(profile: Mapping = Depends(valid_profile_id)):
|
||||||
|
"""Get profile by id."""
|
||||||
|
return profile
|
||||||
|
|
||||||
|
# src.creators.router.py
|
||||||
|
@router.get("/creators/{profile_id}", response_model=ProfileResponse)
|
||||||
|
async def get_user_profile_by_id(
|
||||||
|
creator_profile: Mapping = Depends(valid_creator_id)
|
||||||
|
):
|
||||||
|
"""Get creator's profile by id."""
|
||||||
|
return creator_profile
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### FastAPI响应序列化
|
||||||
|
|
||||||
|
你可能认为可以返回与路由的`response_model`匹配的Pydantic对象来进行一些优化,但你错了。
|
||||||
|
|
||||||
|
FastAPI首先使用其`jsonable_encoder`将该pydantic对象转换为字典,然后使用你的`response_model`验证数据,最后才将你的对象序列化为JSON。
|
||||||
|
|
||||||
|
这意味着你的Pydantic模型对象会被创建两次:
|
||||||
|
|
||||||
|
- 第一次,当你显式创建它以从路由返回时。
|
||||||
|
- 第二次,FastAPI隐式创建它以根据response_model验证响应数据。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from pydantic import BaseModel, root_validator
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
class ProfileResponse(BaseModel):
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def debug_usage(self):
|
||||||
|
print("created pydantic model")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
@app.get("/", response_model=ProfileResponse)
|
||||||
|
async def root():
|
||||||
|
return ProfileResponse()
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**日志输出:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[INFO] [2022-08-28 12:00:00.000000] created pydantic model
|
||||||
|
[INFO] [2022-08-28 12:00:00.000020] created pydantic model
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 如果必须使用同步SDK,请在线程池中运行它。
|
||||||
|
|
||||||
|
如果你必须使用一个库与外部服务交互,并且它不是异步的,那么在外部工作线程中进行HTTP调用。
|
||||||
|
|
||||||
|
我们可以使用starlette中著名的`run_in_threadpool`。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from my_sync_library import SyncAPIClient
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def call_my_sync_library():
|
||||||
|
my_data = await service.get_my_data()
|
||||||
|
|
||||||
|
client = SyncAPIClient()
|
||||||
|
await run_in_threadpool(client.make_request, data=my_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ValueErrors可能会变成Pydantic ValidationError
|
||||||
|
|
||||||
|
如果你在直接面向客户端的Pydantic模式中引发`ValueError`,它将向用户返回一个详细的响应。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src.profiles.schemas
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
class ProfileCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
|
||||||
|
@field_validator("password", mode="after")
|
||||||
|
@classmethod
|
||||||
|
def valid_password(cls, password: str) -> str:
|
||||||
|
if not re.match(STRONG_PASSWORD_PATTERN, password):
|
||||||
|
raise ValueError(
|
||||||
|
"Password must contain at least "
|
||||||
|
"one lower character, "
|
||||||
|
"one upper character, "
|
||||||
|
"digit or "
|
||||||
|
"special symbol"
|
||||||
|
)
|
||||||
|
|
||||||
|
return password
|
||||||
|
|
||||||
|
# src.profiles.routes
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/profiles")
|
||||||
|
async def get_creator_posts(profile_data: ProfileCreate):
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
<img src="images/value_error_response.png" width="400" height="auto">
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
1. 除非你的API是公共的,否则默认隐藏文档。只在选定的环境中显式显示它。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from starlette.config import Config
|
||||||
|
|
||||||
|
config = Config(".env") # parse .env file for env variables
|
||||||
|
|
||||||
|
ENVIRONMENT = config("ENVIRONMENT") # get current env name
|
||||||
|
SHOW_DOCS_ENVIRONMENT = ("local", "staging") # explicit list of allowed envs
|
||||||
|
|
||||||
|
app_configs = {"title": "My Cool API"}
|
||||||
|
if ENVIRONMENT not in SHOW_DOCS_ENVIRONMENT:
|
||||||
|
app_configs["openapi_url"] = None # set url for docs as null
|
||||||
|
|
||||||
|
app = FastAPI(**app_configs)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. 帮助FastAPI生成易于理解的文档
|
||||||
|
1. 设置`response_model`、`status_code`、`description`等。
|
||||||
|
2. 如果模型和状态不同,使用`responses`路由属性为不同的响应添加文档
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/endpoints",
|
||||||
|
response_model=DefaultResponseModel, # default response pydantic model
|
||||||
|
status_code=status.HTTP_201_CREATED, # default status code
|
||||||
|
description="Description of the well documented endpoint",
|
||||||
|
tags=["Endpoint Category"],
|
||||||
|
summary="Summary of the Endpoint",
|
||||||
|
responses={
|
||||||
|
status.HTTP_200_OK: {
|
||||||
|
"model": OkResponse, # custom pydantic model for 200 response
|
||||||
|
"description": "Ok Response",
|
||||||
|
},
|
||||||
|
status.HTTP_201_CREATED: {
|
||||||
|
"model": CreatedResponse, # custom pydantic model for 201 response
|
||||||
|
"description": "Creates something from user request",
|
||||||
|
},
|
||||||
|
status.HTTP_202_ACCEPTED: {
|
||||||
|
"model": AcceptedResponse, # custom pydantic model for 202 response
|
||||||
|
"description": "Accepts request and handles it later",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def documented_route():
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
将生成如下文档:
|
||||||
|
|
||||||
|
<img src="images/custom_responses.png" width="400" height="auto">
|
||||||
|
|
||||||
|
**设置数据库键命名约定**
|
||||||
|
|
||||||
|
根据数据库的约定显式设置索引命名比使用sqlalchemy的默认命名方式更好。
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
from sqlalchemy import MetaData
|
||||||
|
|
||||||
|
POSTGRES_INDEXES_NAMING_CONVENTION = {
|
||||||
|
"ix": "%(column_0_label)s_idx",
|
||||||
|
"uq": "%(table_name)s_%(column_0_name)s_key",
|
||||||
|
"ck": "%(table_name)s_%(constraint_name)s_check",
|
||||||
|
"fk": "%(table_name)s_%(column_0_name)s_fkey",
|
||||||
|
"pk": "%(table_name)s_pkey",
|
||||||
|
}
|
||||||
|
metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移工具Alembic
|
||||||
|
|
||||||
|
1. 迁移必须是静态的且可回滚的。如果你的迁移依赖于动态生成的数据,那么确保只有数据本身是动态的,而不是其结构。
|
||||||
|
2. 生成具有描述性名称和slug的迁移。slug是必需的,应该解释所做的更改。
|
||||||
|
3. 为新迁移设置人类可读的文件模板。我们使用`date*_*slug*.py`模式,例如`2022-08-24_post_content_idx.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
# alembic.ini
|
||||||
|
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设置数据库键命名约定
|
||||||
|
|
||||||
|
保持名称的一致性很重要。我们遵循的一些规则:
|
||||||
|
|
||||||
|
1. 小写蛇形命名(lower_case_snake)
|
||||||
|
2. 单数形式(例如`post`、`post_like`、`user_playlist`)
|
||||||
|
3. 用模块前缀对类似的表进行分组,例如`payment_account`、`payment_bill`、`post`、`post_like`
|
||||||
|
4. 在表之间保持一致,但具体命名也可以,例如
|
||||||
|
1. 在所有表中使用`profile_id`,但如果其中一些表只需要作为创作者的个人资料,则使用`creator_id`
|
||||||
|
2. 在`post_like`、`post_view`等抽象表中使用`post_id`,但在相关模块中使用具体命名,如`chapters.course_id`中的`course_id`
|
||||||
|
5. datetime类型字段使用`_at`后缀
|
||||||
|
6. date类型字段使用`_date`后缀
|
||||||
|
|
||||||
|
### SQL优先,Pydantic次之
|
||||||
|
|
||||||
|
- 通常,数据库处理数据的速度比CPython快得多,也更简洁。
|
||||||
|
- 最好使用SQL进行所有复杂的连接和简单的数据操作。
|
||||||
|
- 最好在数据库中为具有嵌套对象的响应聚合JSON。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src.posts.service
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import UUID4
|
||||||
|
from sqlalchemy import desc, func, select, text
|
||||||
|
from sqlalchemy.sql.functions import coalesce
|
||||||
|
|
||||||
|
from src.database import database, posts, profiles, post_review, products
|
||||||
|
|
||||||
|
async def get_posts(
|
||||||
|
creator_id: UUID4, *, limit: int = 10, offset: int = 0
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
select_query = (
|
||||||
|
select(
|
||||||
|
(
|
||||||
|
posts.c.id,
|
||||||
|
posts.c.slug,
|
||||||
|
posts.c.title,
|
||||||
|
func.json_build_object(
|
||||||
|
text("'id', profiles.id"),
|
||||||
|
text("'first_name', profiles.first_name"),
|
||||||
|
text("'last_name', profiles.last_name"),
|
||||||
|
text("'username', profiles.username"),
|
||||||
|
).label("creator"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.select_from(posts.join(profiles, posts.c.owner_id == profiles.c.id))
|
||||||
|
.where(posts.c.owner_id == creator_id)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
.group_by(
|
||||||
|
posts.c.id,
|
||||||
|
posts.c.type,
|
||||||
|
posts.c.slug,
|
||||||
|
posts.c.title,
|
||||||
|
profiles.c.id,
|
||||||
|
profiles.c.first_name,
|
||||||
|
profiles.c.last_name,
|
||||||
|
profiles.c.username,
|
||||||
|
profiles.c.avatar,
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
desc(coalesce(posts.c.updated_at, posts.c.published_at, posts.c.created_at))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return await database.fetch_all(select_query)
|
||||||
|
|
||||||
|
# src.posts.schemas
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, UUID4
|
||||||
|
|
||||||
|
|
||||||
|
class Creator(BaseModel):
|
||||||
|
id: UUID4
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
username: str
|
||||||
|
|
||||||
|
class Post(BaseModel):
|
||||||
|
id: UUID4
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
creator: Creator
|
||||||
|
|
||||||
|
|
||||||
|
# src.posts.router
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/creators/{creator_id}/posts", response_model=list[Post])
|
||||||
|
async def get_creator_posts(creator: dict[str, Any] = Depends(valid_creator_id)):
|
||||||
|
posts = await service.get_posts(creator["id"])
|
||||||
|
|
||||||
|
return posts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 从一开始就设置异步测试客户端
|
||||||
|
|
||||||
|
使用数据库编写集成测试很可能在将来导致混乱的事件循环错误。立即设置异步测试客户端,例如[httpx](https://github.com/encode/starlette/issues/652)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
from src.main import app # inited FastAPI app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client() -> AsyncGenerator[AsyncClient, None]:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_post(client: AsyncClient):
|
||||||
|
resp = await client.post("/posts")
|
||||||
|
|
||||||
|
assert resp.status_code == 201
|
||||||
|
```
|
||||||
|
|
||||||
|
除非你有同步数据库连接(抱歉?)或者不打算编写集成测试。
|
||||||
|
|
||||||
|
### 使用ruff
|
||||||
|
|
||||||
|
有了代码检查工具,你可以忘记代码格式化,专注于编写业务逻辑。
|
||||||
|
|
||||||
|
[Ruff](https://github.com/astral-sh/ruff)是一个“速度极快”的新代码检查工具,它替代了black、autoflake、isort,并支持600多个检查规则。
|
||||||
|
|
||||||
|
使用pre-commit钩子是一种流行的最佳实践,但对我们来说,只使用脚本就足够了。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/sh -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
ruff check --fix src
|
||||||
|
ruff format src
|
||||||
|
```
|
||||||
|
|
||||||
|
## 额外部分
|
||||||
|
|
||||||
|
一些非常善良的人分享了他们自己的经验和最佳实践,绝对值得一读。
|
||||||
|
|
||||||
|
查看项目的[issues(问题)](https://github.com/zhanymkanov/fastapi-best-practices/issues)部分。
|
||||||
|
|
||||||
|
例如,[lowercase00](https://github.com/zhanymkanov/fastapi-best-practices/issues/4)详细描述了他们在权限和认证、基于类的服务和视图、任务队列、自定义响应序列化器、使用dynaconf进行配置等方面的最佳实践。
|
||||||
|
|
||||||
|
如果你有关于使用FastAPI的经验要分享,无论是好是坏,都非常欢迎创建一个新的issue。我们很乐意阅读它。
|
||||||
|
Before Width: | Height: | Size: 684 KiB After Width: | Height: | Size: 684 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
42
pyproject.toml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
[project]
|
||||||
|
name = "fastapi-template"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "FastAPI 模板(MongoDB + Beanie 单后端分支)"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.30",
|
||||||
|
"pydantic>=2.7",
|
||||||
|
"pydantic-settings>=2.4",
|
||||||
|
"beanie>=2.0",
|
||||||
|
"pymongo>=4.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"ruff>=0.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "ASYNC", "RUF"]
|
||||||
|
ignore = [
|
||||||
|
"RUF001", "RUF002", "RUF003", # 中文文档字符串里的全角标点是有意的
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/conftest.py" = ["E402"] # 需要先设环境变量再 import app
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
0
src/__init__.py
Normal file
26
src/config.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""全局配置:pydantic-settings,环境变量 / .env 驱动。(mongodb 单后端分支)"""
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
# MongoDB
|
||||||
|
MONGO_DSN: str = "mongodb://127.0.0.1:27017"
|
||||||
|
MONGO_DB: str = "app"
|
||||||
|
|
||||||
|
# 应用
|
||||||
|
APP_NAME: str = "fastapi-template"
|
||||||
|
APP_ENV: str = "dev"
|
||||||
|
APP_DEBUG: bool = True
|
||||||
|
APP_HOST: str = "0.0.0.0"
|
||||||
|
APP_PORT: int = 8000
|
||||||
|
CORS_ORIGINS: str = "*"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
26
src/exceptions.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""全局异常 + 统一错误响应。"""
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
"""业务异常基类。子类在各域 exceptions.py 中定义。"""
|
||||||
|
|
||||||
|
status_code: int = status.HTTP_400_BAD_REQUEST
|
||||||
|
detail: str = "bad request"
|
||||||
|
|
||||||
|
def __init__(self, detail: str | None = None):
|
||||||
|
self.detail = detail or self.detail
|
||||||
|
super().__init__(self.detail)
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(AppError):
|
||||||
|
status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
detail = "not found"
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||||
0
src/items/__init__.py
Normal file
25
src/items/dependencies.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""items 域依赖:MongoDB(Beanie) 仓储。"""
|
||||||
|
|
||||||
|
from typing import Annotated, Protocol
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from src.items.mongo import MongoItemRepo
|
||||||
|
from src.items.schemas import ItemCreate, ItemOut, ItemUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class ItemRepo(Protocol):
|
||||||
|
"""仓储协议:业务层只依赖它,不关心底层实现。"""
|
||||||
|
|
||||||
|
async def create(self, data: ItemCreate) -> ItemOut: ...
|
||||||
|
async def get(self, item_id: str) -> ItemOut | None: ...
|
||||||
|
async def list(self, skip: int = 0, limit: int = 20) -> tuple[int, list[ItemOut]]: ...
|
||||||
|
async def update(self, item_id: str, data: ItemUpdate) -> ItemOut | None: ...
|
||||||
|
async def delete(self, item_id: str) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
async def get_item_repo() -> ItemRepo:
|
||||||
|
return MongoItemRepo()
|
||||||
|
|
||||||
|
|
||||||
|
ItemRepoDep = Annotated[ItemRepo, Depends(get_item_repo)]
|
||||||
57
src/items/mongo.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""items 域 MongoDB 实现:Beanie ODM。"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from beanie import Document, PydanticObjectId
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from src.items.schemas import ItemCreate, ItemOut, ItemUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class ItemDoc(Document):
|
||||||
|
name: str = Field(max_length=128)
|
||||||
|
description: str | None = None
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
name = "items"
|
||||||
|
|
||||||
|
def to_out(self) -> ItemOut:
|
||||||
|
return ItemOut(
|
||||||
|
id=str(self.id),
|
||||||
|
name=self.name,
|
||||||
|
description=self.description,
|
||||||
|
created_at=self.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MongoItemRepo:
|
||||||
|
"""与 MySQLItemRepo 同接口,router 无感知切换。"""
|
||||||
|
|
||||||
|
async def create(self, data: ItemCreate) -> ItemOut:
|
||||||
|
doc = ItemDoc(name=data.name, description=data.description)
|
||||||
|
await doc.insert()
|
||||||
|
return doc.to_out()
|
||||||
|
|
||||||
|
async def get(self, item_id: str) -> ItemOut | None:
|
||||||
|
doc = await ItemDoc.get(PydanticObjectId(item_id))
|
||||||
|
return doc.to_out() if doc else None
|
||||||
|
|
||||||
|
async def list(self, skip: int = 0, limit: int = 20) -> tuple[int, list[ItemOut]]:
|
||||||
|
total = await ItemDoc.count()
|
||||||
|
docs = await ItemDoc.find_all().skip(skip).limit(limit).to_list()
|
||||||
|
return total, [d.to_out() for d in docs]
|
||||||
|
|
||||||
|
async def update(self, item_id: str, data: ItemUpdate) -> ItemOut | None:
|
||||||
|
doc = await ItemDoc.get(PydanticObjectId(item_id))
|
||||||
|
if not doc:
|
||||||
|
return None
|
||||||
|
await doc.set(data.model_dump(exclude_unset=True))
|
||||||
|
return doc.to_out()
|
||||||
|
|
||||||
|
async def delete(self, item_id: str) -> bool:
|
||||||
|
doc = await ItemDoc.get(PydanticObjectId(item_id))
|
||||||
|
if not doc:
|
||||||
|
return False
|
||||||
|
await doc.delete()
|
||||||
|
return True
|
||||||
46
src/items/router.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""items 域路由:只依赖 ItemRepo 协议,不关心底层是 MySQL 还是 MongoDB。"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, status
|
||||||
|
|
||||||
|
from src.exceptions import NotFoundError
|
||||||
|
from src.items.dependencies import ItemRepoDep
|
||||||
|
from src.items.schemas import ItemCreate, ItemList, ItemOut, ItemUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/items", tags=["items"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_item(data: ItemCreate, repo: ItemRepoDep):
|
||||||
|
return await repo.create(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{item_id}", response_model=ItemOut)
|
||||||
|
async def get_item(item_id: str, repo: ItemRepoDep):
|
||||||
|
item = await repo.get(item_id)
|
||||||
|
if not item:
|
||||||
|
raise NotFoundError(f"item {item_id} not found")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ItemList)
|
||||||
|
async def list_items(
|
||||||
|
repo: ItemRepoDep,
|
||||||
|
skip: int = Query(default=0, ge=0),
|
||||||
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
|
):
|
||||||
|
total, items = await repo.list(skip=skip, limit=limit)
|
||||||
|
return ItemList(total=total, items=items)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{item_id}", response_model=ItemOut)
|
||||||
|
async def update_item(item_id: str, data: ItemUpdate, repo: ItemRepoDep):
|
||||||
|
item = await repo.update(item_id, data)
|
||||||
|
if not item:
|
||||||
|
raise NotFoundError(f"item {item_id} not found")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_item(item_id: str, repo: ItemRepoDep):
|
||||||
|
if not await repo.delete(item_id):
|
||||||
|
raise NotFoundError(f"item {item_id} not found")
|
||||||
27
src/items/schemas.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""items 域 API 模型(对外契约,与 DB 实现无关)。"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ItemCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
description: str | None = Field(default=None, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class ItemOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ItemList(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: list[ItemOut]
|
||||||
50
src/main.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""应用入口:FastAPI app + lifespan(mongodb 单后端分支)。"""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.exceptions import register_exception_handlers
|
||||||
|
from src.items.router import router as items_router
|
||||||
|
from src.mongo import close_mongo, init_mongo
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
await init_mongo()
|
||||||
|
yield
|
||||||
|
await close_mongo()
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
debug=settings.APP_DEBUG,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
register_exception_handlers(app)
|
||||||
|
app.include_router(items_router)
|
||||||
|
|
||||||
|
@app.get("/health", tags=["meta"])
|
||||||
|
async def health() -> dict:
|
||||||
|
return {"status": "ok", "db_backend": "mongodb"}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run("src.main:app", host=settings.APP_HOST, port=settings.APP_PORT, reload=True)
|
||||||
31
src/mongo.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""MongoDB:pymongo async client + beanie 初始化。
|
||||||
|
仅 DB_BACKEND=mongodb 时使用;mysql 模式下本模块不被引用。
|
||||||
|
(beanie 2.x 基于 pymongo 原生异步客户端,不再需要 motor)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from beanie import init_beanie
|
||||||
|
from pymongo import AsyncMongoClient
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
|
_client: AsyncMongoClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_documents() -> list[type]:
|
||||||
|
"""汇总所有 beanie Document。新增域时在此登记。"""
|
||||||
|
from src.items import mongo as item_mongo
|
||||||
|
|
||||||
|
return [item_mongo.ItemDoc]
|
||||||
|
|
||||||
|
|
||||||
|
async def init_mongo() -> None:
|
||||||
|
global _client
|
||||||
|
_client = AsyncMongoClient(settings.MONGO_DSN)
|
||||||
|
await init_beanie(database=_client[settings.MONGO_DB], document_models=_collect_documents())
|
||||||
|
|
||||||
|
|
||||||
|
async def close_mongo() -> None:
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
await _client.close()
|
||||||
|
_client = None
|
||||||
23
tests/conftest.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""测试基座:httpx ASGITransport 进程内测试,不起真实服务。
|
||||||
|
需要本地 27017 有 mongo;测试库 app_test,与开发库隔离。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
os.environ.setdefault("MONGO_DB", "app_test")
|
||||||
|
|
||||||
|
from src.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client():
|
||||||
|
app = create_app()
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app), base_url="http://test"
|
||||||
|
) as ac:
|
||||||
|
# 手动触发 lifespan(ASGITransport 不自动跑 lifespan)
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
yield ac
|
||||||
33
tests/test_items.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
async def test_health(client):
|
||||||
|
resp = await client.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_items_crud(client):
|
||||||
|
# create
|
||||||
|
resp = await client.post("/api/v1/items", json={"name": "demo", "description": "d"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
item = resp.json()
|
||||||
|
assert item["name"] == "demo"
|
||||||
|
item_id = item["id"]
|
||||||
|
|
||||||
|
# get
|
||||||
|
resp = await client.get(f"/api/v1/items/{item_id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# list
|
||||||
|
resp = await client.get("/api/v1/items")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["total"] >= 1
|
||||||
|
|
||||||
|
# update
|
||||||
|
resp = await client.patch(f"/api/v1/items/{item_id}", json={"name": "demo2"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["name"] == "demo2"
|
||||||
|
|
||||||
|
# delete
|
||||||
|
resp = await client.delete(f"/api/v1/items/{item_id}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
resp = await client.get(f"/api/v1/items/{item_id}")
|
||||||
|
assert resp.status_code == 404
|
||||||
905
uv.lock
generated
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-doc"
|
||||||
|
version = "0.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.14.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beanie"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "lazy-model" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pymongo" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6b/a1/4abc93d5437999c55465f68c3c81e4735cdfe22827072c0e9cab00f63d11/beanie-2.2.0.tar.gz", hash = "sha256:2dc116e7f4a6650f8066f95851d34d898983af6ffaf5366dcfef892993537a51", size = 70115, upload-time = "2026-08-07T17:15:24.494Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/ae/2584fb9871b58f3c5334110f8ce00bd1421fe5fbda28eeac9a8d01d10ba6/beanie-2.2.0-py3-none-any.whl", hash = "sha256:4074f893ef00c52b8538b814c9dfd55130eea4ed0579abfae72853657c9029e4", size = 93116, upload-time = "2026-08-07T17:15:22.777Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "dnspython"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi"
|
||||||
|
version = "0.141.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi-template"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "beanie" },
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "pymongo" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "beanie", specifier = ">=2.0" },
|
||||||
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
|
{ name = "pydantic", specifier = ">=2.7" },
|
||||||
|
{ name = "pydantic-settings", specifier = ">=2.4" },
|
||||||
|
{ name = "pymongo", specifier = ">=4.9" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=0.23" },
|
||||||
|
{ name = "ruff", specifier = ">=0.6" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.19"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy-model"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/85/e25dc36dee49cf0726c03a1558b5c311a17095bc9361bcbf47226cb3075a/lazy-model-0.4.0.tar.gz", hash = "sha256:a851d85d0b518b0b9c8e626bbee0feb0494c0e0cb5636550637f032dbbf9c55f", size = 8256, upload-time = "2025-08-07T20:05:34.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/54/653ea0d7c578741e9867ccf0cbf47b7eac09ff22e4238f311ac20671a911/lazy_model-0.4.0-py3-none-any.whl", hash = "sha256:95ea59551c1ac557a2c299f75803c56cc973923ef78c67ea4839a238142f7927", size = 13749, upload-time = "2025-08-07T20:05:36.303Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.13.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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.46.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-settings"
|
||||||
|
version = "2.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pymongo"
|
||||||
|
version = "4.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dnspython" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ca/64/50be6fbac9c79fe2e4c17401a467da2d8764d82833d83cec325afe5cab32/pymongo-4.17.0.tar.gz", hash = "sha256:70ffa08ba641468cc068cf46c06b34f01a8ce3489f6411309fcb5ceabe6b2fc0", size = 2523370, upload-time = "2026-04-20T16:39:53.524Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/e2/336d86f221cf1b56b2ed9330d4a3b98f9f38f0b37829ae9a9184617d5419/pymongo-4.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4141e6c6a339789b2974efa00ecd9409101672d77a0e3ee2cc3839eedf8ec4df", size = 874668, upload-time = "2026-04-20T16:37:41.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/8e/75d3c6c935d187ab59c61e9c15d9aab3f274b563eaf1706e8cae5f508dec/pymongo-4.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e68c76b84e0c132d9dbf9307f12ff8185702328187a87b9aca8c941303873433", size = 875294, upload-time = "2026-04-20T16:37:43.432Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/ec/62e855744489dbcd54fd778aae4d80fa4c4819e8fb228ca0cf6f21a03997/pymongo-4.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba2195d4f386f839a52a23ea1cfd60ffaaba78a3d7841db51b7e433001139918", size = 1496233, upload-time = "2026-04-20T16:37:45.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/e8/93e4e5e5ce8fdf8929dabeefe24aafa5ce046028eed0dfa8eeb936e72c49/pymongo-4.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446ff4bfcb6ec2a2e50998c860986a1e992136f998b7f53e7a717fb8aa5a0b9", size = 1522927, upload-time = "2026-04-20T16:37:47.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/ca/425dc1d21e0f17bdea0072fc463f662f7fa06d2852af52975c9eced3c07c/pymongo-4.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2a0d5ac205728c86e0a02192f1aa5f865b0d7d51f8df6101c01a69a7fc620d72", size = 1583468, upload-time = "2026-04-20T16:37:49.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/9d/f08b07eeffda1a43c1759f0fa625e88ae12360996eb56d42aad832fa7dff/pymongo-4.17.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:485c8a8eaa4c739f00a331fc73757898ee7c092c214a79e63866ff76aaf282ff", size = 1572787, upload-time = "2026-04-20T16:37:51.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/c2/6855a07aafa7b894929af23675b6fb9634800ce43122b76a62f6eeb8da2a/pymongo-4.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2dfcc795f5b9fedbe179a11fdf6051581479d196582a3fe819a92a00e9b9969", size = 1526184, upload-time = "2026-04-20T16:37:53.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/05/c952bac7db71c1942ea3559fcd308b49754cc5004b455935fb4000d1f37b/pymongo-4.17.0-cp311-cp311-win32.whl", hash = "sha256:c2292144505fb12156b981bd440f3dc994a883da06ac726c0c8692ccdbc1c510", size = 852621, upload-time = "2026-04-20T16:37:55.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/c0/c04da9f4c0c6252404598f4e394b862a58a9e866822a70ae261c8a018fdf/pymongo-4.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2e190827834fce70ecdf9d46796c6dbc0ce08ea87dc2ff5bc6f3f5579b605cb9", size = 867852, upload-time = "2026-04-20T16:37:57.233Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/b2/c7b4870fbeef471e947d3e014676f5910d02e0197074d692ebcf24ec049a/pymongo-4.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:a8f9c40a09bb7d4b9fc8b1da65ecf6efa79bda5cb2756f39d9b6940fac1d19ae", size = 855019, upload-time = "2026-04-20T16:37:58.983Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/90/60bcb508840135d5ee46b51b1a950f548338aa8145a8366dbe6639ae51ac/pymongo-4.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53ffa94b2340dbf6b055e09a0090618c60482c158ecfc9565642fc996bf0944", size = 930529, upload-time = "2026-04-20T16:38:00.936Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/e9/313840f1e52c6dfac47f704428cbfbce59956ebe7633bffc92b03f74f0ad/pymongo-4.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6fe0de9d0f6791abce3471230b32b4817bf89d27b1182b6a550e1ec0fa72aa9a", size = 930665, upload-time = "2026-04-20T16:38:02.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/35/9d3565ea45b1606f635c1e2cd2563c28d66caafdc50f7ad7d979fcd1b363/pymongo-4.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e537e95514dae1aaa718f481ec03151a0f0394bcd05f1322896d8fc1330cb729", size = 1762369, upload-time = "2026-04-20T16:38:05.375Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/ee/149b0d4b1a11c38bff6f14c23d5814c9b0843fd6dc38ad40596bdb1a62d2/pymongo-4.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37a8385c29881b43eab31f584100fa0eaddedd5607adf010147ba1810118be90", size = 1798044, upload-time = "2026-04-20T16:38:07.195Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/d4/4cee4a7b8d8f6f0550ef6cd2fea42455c5ed619a220cb6ba4fb40d6a5bc8/pymongo-4.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3ee3d241ed77a4fc99ce3cff3b289c3ebce37f61fdd7349d3592c23b82c8784", size = 1878567, upload-time = "2026-04-20T16:38:09.121Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/ef/7fe366c84952619ee2f69973566c214775e083dd4df465751912153e4b72/pymongo-4.17.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9eb5d63a3c518cb0804ed678f5e2b875af032d89a7cf57a57360322cf6a4d222", size = 1864881, upload-time = "2026-04-20T16:38:10.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/35/b577d82c6d1be7aee7ac7e249bc86f7847998345042e5f8360de238e177b/pymongo-4.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e97e03fa13327c87e3fdc5656acd01e71817f0c1dc3221cd8f30de136bf4ec3", size = 1800349, upload-time = "2026-04-20T16:38:13.589Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/69/dafcf04f66e130ddd91aeb92e7a692480eda46dcd04ec1dbe82c06619e10/pymongo-4.17.0-cp312-cp312-win32.whl", hash = "sha256:6877214bff5f06f6884a9fc8d9016a4a7a5f51f537f5c51ac3a576f93e7dfb32", size = 900518, upload-time = "2026-04-20T16:38:15.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/35/5c9262a459f988b4eb2605f70815240b77a0d4131136c4326d18f1822b89/pymongo-4.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9828485f72f63c7d802e0ec41f71906f633c2692621ab3af55ca990186b091b1", size = 920335, upload-time = "2026-04-20T16:38:17.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/da/e9c7265ee176faccf4e52c4797837e794d93569a1046f6b19a4acc36e5ad/pymongo-4.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:1195370a77baf003b59b10e91ecc4706297197f0dd9d29c840cc556dc08f7cee", size = 903289, upload-time = "2026-04-20T16:38:19.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/6b/c1206879708b94e82fcd8b9653440ec271f79a3674d122192df383047f5a/pymongo-4.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:809ec74de3b9148ae43fa8df9faf53470f511c8d384f13b99d6f671f2a379f15", size = 985829, upload-time = "2026-04-20T16:38:21.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/cf/bb044ed85160e5c40f568c7c4f4e8ea16f40764ff5d302e5befbe8f6f814/pymongo-4.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a431b737816bf4cddd4fa0fcef04e424ad36b7692734a64150f872fb8f3208be", size = 985899, upload-time = "2026-04-20T16:38:23.409Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/0a/f6dfd5ea3901e5d6888da8de8ba728971a1d447debab681cfc56f90d1208/pymongo-4.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4fab10f8403169ce92f3cea921609d9ee81107306caae06c08f592d4b8ad2b5", size = 2028569, upload-time = "2026-04-20T16:38:25.343Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/c5/081f59a1c02ae8c0dc73ae58e563838c44eec81aeafa7d0b93a637841c9b/pymongo-4.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20323b0b1c1d33770ad1fc68d429c757734ce9ad3594421c3d6618f10572b1b9", size = 2072916, upload-time = "2026-04-20T16:38:27.291Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/42/6e41d434297ffe8b30d9c3717916591a4a7be9075a0dcc2fafdfaaaa62ed/pymongo-4.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5a5de048e6da5c18e27cc2437e8c15b3b0cdc8385c15b41178b0caa3322a09c2", size = 2173234, upload-time = "2026-04-20T16:38:29.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/cf/1e4a7db352ef9485831c7268dfe8402f0117b32a9ad54b16e810699e3617/pymongo-4.17.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dff3de1294fbbc1db0ba6b511f77b8e540601d092538a31312e99c8a91a78b1e", size = 2156784, upload-time = "2026-04-20T16:38:32.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/10/6195be29962a61ebb5f4bd9e4c7519890b172f7968a0a0d880398c6ddb02/pymongo-4.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faf03e4c2aafd6de626dbd30ba246d369ae33f47f10629d1bbe40f72115027a6", size = 2074446, upload-time = "2026-04-20T16:38:34.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/48/33410b8819837ed370c738587306bdf060b59cef11823be212f4a07703c5/pymongo-4.17.0-cp313-cp313-win32.whl", hash = "sha256:c9786665926a09630c5d420c79762cfadbff35a9438bcbc4c81a9fb5ab9228b7", size = 948435, upload-time = "2026-04-20T16:38:35.922Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/77/c0ed522f798a286b99acaa7914ed8d9c80ab091f97f57c59ffed72906e5e/pymongo-4.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:5960519b4d7168f1ecdd3ea10c81b2aedeb9423651aca953cfbc8e76705d3b38", size = 972847, upload-time = "2026-04-20T16:38:37.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/f0/c39480a2db385fde23861d0c8acda41cdaf1d43e46579db72c5c013a2e81/pymongo-4.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:0ff6bd2f735ab5356541e3e57d5b7dbfbc3f2ee1ccb10b6b0f82d58af69d1d8e", size = 951575, upload-time = "2026-04-20T16:38:40.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/49/2b0250762a89737ed6f9cea238331baca061b89a8ddd10dd17fee52c3970/pymongo-4.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff5aa3f1c7e3f08eb0e7a016c91ba468b1850ccfd63d9b1f12f56350f4974cef", size = 1040945, upload-time = "2026-04-20T16:38:42.783Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/1c/7a9b5447a08be20e84b6e5b17330917e8d6d9507daa3cd099a9309f11ad7/pymongo-4.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e816db649ba5d7de0568cf3a9f287a9dc9aad21cf0ca667ab156a7ef47fca0b0", size = 1041187, upload-time = "2026-04-20T16:38:45.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/a1/71704f61632dfc90407a5834fe5f6132854937c4a3648f6c05c351d85a45/pymongo-4.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c4fded3a9f1d6a687e36ebd384ac6d00b9b00de1969aa74048e7051ec2a713", size = 2294806, upload-time = "2026-04-20T16:38:47.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/b9/aff42be75108b96c2469b1d9329b912c15108f3e7ef32fdc86da8423c330/pymongo-4.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db66aa8dd253a0fc1fad3b0d23d5b3993f7ebde02fbbd7727128debf2853675", size = 2348231, upload-time = "2026-04-20T16:38:50.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/30/44c115b8ba1479942c15fd9480eb29a7da0ba68acd56983423ba0deb4a94/pymongo-4.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3987e96e7c7be4083d42e8ac2cc6c0d5b78db9973c90fce42ae800b616ca6b20", size = 2467614, upload-time = "2026-04-20T16:38:52.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/84/21ee95c8bf0ca7acae7ec7eb365d740bf8fc0156c194baf2c3bdfcb85ec0/pymongo-4.17.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cee36b3c0d0354f880fa7a7fdcdaf2bb5e542c2281e25c1bfadf8cfe21eba7d2", size = 2445970, upload-time = "2026-04-20T16:38:55.175Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/89/081d7f1809d5ca09d1e47e49f2111b245f5694de3a7af32cd3a353a6f43f/pymongo-4.17.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:320b34457b20bbcc79997801f95d25ce00472915ca5241167242b42c4359e027", size = 2348605, upload-time = "2026-04-20T16:38:57.557Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/c3/0d949f9d3f2a341c1f635c398c16615e96f89f51ff424ed81e914cf1a4de/pymongo-4.17.0-cp314-cp314-win32.whl", hash = "sha256:df4a644af9ae132d4bfdb2e9516ea51a615fd881caddfbfbd071cf1354844479", size = 1004119, upload-time = "2026-04-20T16:39:00.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/55/5c3a3db1048054c695c75c5964cc8bedc2247fdb5a75ef6fab4ec8bb013e/pymongo-4.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:c797f8a80957134f6dd9690367a0f8f5906d672119af2c6aa55f0c527b656bed", size = 1032314, upload-time = "2026-04-20T16:39:02.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/19/e235f39906134cb0ffd5574c5a59c355ef5380f0499644ab94994afbb109/pymongo-4.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:68fca71e05ee5da23a8d73cee8379dfb3d26e609a377cae731d742771ed96946", size = 1007627, upload-time = "2026-04-20T16:39:04.678Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/e0/c4c1a86791415b14c684fa0908f9da96de91594a3fd1fa1b8dc689fbb800/pymongo-4.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b4384700cffc3f1dd98e088bc0072dedf6d7d68a230bb4b972665cf69c071c1e", size = 1099151, upload-time = "2026-04-20T16:39:06.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/4b/69c67f3e23fd9b23b9bedc7ebd23754881cc9d5c5d5b2a9811e96b07f475/pymongo-4.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:93641192644fa1ee0f34030e774fd31022a27ad11ba22cb1716142231524f8bd", size = 1099346, upload-time = "2026-04-20T16:39:08.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/19/a5208f62f9508a26d73acc69bd3821b8c8adae253679a3c26d2f9652f0d5/pymongo-4.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:75bc3aa5b94fdb7138d357ec6ca61cd97e0c79f4f7f0bd3efe9639b15cc50942", size = 2619034, upload-time = "2026-04-20T16:39:11.049Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/27/426cba1ec5973082a56d4150798529bfdf4151c31391ed1fbbecb23ef2ac/pymongo-4.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e8f8e23c6df7c6d6929f5e734980b227706e73ee847517c9ba5af90f7fc466", size = 2689939, upload-time = "2026-04-20T16:39:13.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/2e/f70993d1255e33f6ee59a4ec4371cc65bff7a7e3fda7d55c3386f25287e8/pymongo-4.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d3f3d732aecac1f8d481bde4029755615639bd3076f258a2147210aec8515a", size = 2824994, upload-time = "2026-04-20T16:39:16.057Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/eb/87b0e988ba889e1fcc3430c2cfc166b251872c813e92b43174298bee17ff/pymongo-4.17.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5f62862d0f87be481fa1fe8cb811994486773c94a2b61e509285e3f2890763", size = 2801745, upload-time = "2026-04-20T16:39:18.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/4c/3f83412d086f682d4d468761d66ddc49cf161e786ea74073045eb4491c60/pymongo-4.17.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64837adbbd72073301af51bb0fc80e3d7707fe5527cea1033ba0320f0b2f881b", size = 2684636, upload-time = "2026-04-20T16:39:20.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/d8/b75f6f4ab6c8beb50b0270a4f1e2530b5774f5e116563440e1677ca1820f/pymongo-4.17.0-cp314-cp314t-win32.whl", hash = "sha256:b93b22eedc62598cf5ee9d8c8007a8e9121c50fd88137012d8985500e9dc3151", size = 1056356, upload-time = "2026-04-20T16:39:22.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/5e/648c8a238eef18a25ed8a169ea6542d4a860bbec3e95b3d9badac2935c71/pymongo-4.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3689ea34f6b647c7d1e7bdc60fcfb214b2789ed1359a7fb96569c69f50e5f18f", size = 1090964, upload-time = "2026-04-20T16:39:24.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/cb/d9780b66939c4fc1f024bcc7be23a2abcfe06a9745ca8fa76dc73395482e/pymongo-4.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9543d8f84c2e5608565c08ac679774811e6730770d8a645439b073422a4276fb", size = 1058526, upload-time = "2026-04-20T16:39:27.924Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "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/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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.16.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "1.6.0"
|
||||||
|
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/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.52.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
standard = [
|
||||||
|
{ name = "httptools" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||||
|
{ name = "watchfiles" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "17.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/54/b2bce5b754b91b727b852e78af6d7193d4fe985e420dc54e6c2abe161c1c/websockets-17.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c38515cb54902f7e97d0239e81ef46c4444f9475f4807fb9bbdb789b4089abcf", size = 212573, upload-time = "2026-07-31T11:29:04.803Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/1b/eaefeb695b217d8c735fc377ab53c6b00bc9ed64a01a3f6f797096ac0ee8/websockets-17.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70d438268e49f1a4bd096b6b6f7010f3ab48b5db2574dbf7d8c864c46ce7a06a", size = 210262, upload-time = "2026-07-31T11:29:06.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/2d/68439a174c74969fb51619bbe9af9496826610883b828a2edd2f022c94fc/websockets-17.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0b52c76b8a870b141b7ca0705289452183ce7a523101954ccfe29a25986a673f", size = 210535, upload-time = "2026-07-31T11:29:07.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/ab/13a856b488dbac7fd3c5473e38098897cda0baa04e3ca3b34ec6c8a32b46/websockets-17.0.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b98860aefbd3d9bc8e3c7f0eefb83b11142b16110739c68cd33d3b4d6e84e536", size = 219602, upload-time = "2026-07-31T11:29:09.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/a1/2b100e71aa1fe283ec8fb73f8b74a8576d855486a49117903b100dd3b78d/websockets-17.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d41e9845514754a42d1d83b2fca9d27fee2ca7b3b0bee6843ba5a9bb2b6e25ac", size = 219873, upload-time = "2026-07-31T11:29:10.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/71/92e6146d3588d145136c0e0e16d106bbb855bb5047e13ec3fdee39cce770/websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9aac6081513f02eac3f8caace800dbfc5c608b69e4a7bef69e414eabfc95aa1", size = 221108, upload-time = "2026-07-31T11:29:11.708Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/8d/357afa2ffd29686536109e7e3cb2a94f2d09e535df4cf3989393dc506a40/websockets-17.0.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b85b960a4507b0714c0a1246d031be9118d908ee974dc085257297a955205f1d", size = 224401, upload-time = "2026-07-31T11:29:12.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/27/9efba1e7a8df48e405d017e67513c6b2a8f0b59c8500b820c47cd5f3dea9/websockets-17.0.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c356dbddab0a529ed7574f78f559d75a223735c321c28f6f587fbf02b11ed301", size = 221670, upload-time = "2026-07-31T11:29:14.199Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/85/ab27d62103e8a150f3657e043e5fc711ad3e018c8cd8a715093e93d7640c/websockets-17.0.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5661f868ef191d33dfc6a0cc7c5b3d495f0cc8bb3f8b30d87bda8755c61c95f5", size = 220442, upload-time = "2026-07-31T11:29:15.417Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/04/289e00b8001b622b0c397a6901fcc4aa8f34a6d2ed42be17f2704f2faae1/websockets-17.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2fa2cb465a131c347ba6717a78c887746e73edb1c131d01c982d6ef0d68b82e0", size = 217760, upload-time = "2026-07-31T11:29:16.772Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/70/0fe58cdac988dfc0066786cc07b09dfd72b48b0c01e5de667721192b2e6e/websockets-17.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:55383d8177b3c99fd873ee5db0e0193f4c1dd4a3feaccf1a4a03c1b7cf539cac", size = 220597, upload-time = "2026-07-31T11:29:18.075Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/76/d3eaf120710d1a791d1c7a4963f0b50a589df8ba675394fd2a97dfea3746/websockets-17.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:038cfad5d5417f8bb09295abe986029a26d22f34bda622ccc79b670efd4dab56", size = 219187, upload-time = "2026-07-31T11:29:19.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/00/4e9ae886bdb1647537176ca33fca66d79dddb3773d213ac98ddc6bba9ab4/websockets-17.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:15920057a6b723f84734f0641403bca163a4b176e5af809ee4f0c4a1e75e9fed", size = 219955, upload-time = "2026-07-31T11:29:20.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/67/cd7cc6849a86cf8c9979c0c570b6b6ccee75d2872854238d8d54e77be6ec/websockets-17.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5508f38c98ac29def9e747b87543b008a58b075df6da70b2cf2e0b47073d33bb", size = 221002, upload-time = "2026-07-31T11:29:21.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/5c/4d14eaf7b2f1448d1af24c1641f04eb74c1632a5802952aac4b7e068b8e7/websockets-17.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a68e604c6d1b0338e46652e2688cbce8096ad9c03548b075fda9e2ea19a9b7dd", size = 218579, upload-time = "2026-07-31T11:29:22.888Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/67/ff8bc4b8a6ec235ed8985de12fecc59ad2cd68cc8fc79b97deaa42e412ac/websockets-17.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a8af570fc29cd998a921c7131c8ac81d9434466d6d25300cb12a690fb56a8a08", size = 219612, upload-time = "2026-07-31T11:29:24.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/eb/103d81d655bab3ffd5c7d5d4b08f92c374499decd1d4be6035ce715b385b/websockets-17.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:246927ae9ae06ca0d42a483a4bdb80d4862e1ee5b4cab37c354a5e1ad8356448", size = 219846, upload-time = "2026-07-31T11:29:25.421Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/1e/3495161b1827941258545604fdf72e3e053d03f25bb61752228a784c26a1/websockets-17.0.1-cp311-cp311-win32.whl", hash = "sha256:1d4cf7e8e5b8b1fa40758ac7524843a00237b124ab217e227542cafcfeb7a946", size = 213046, upload-time = "2026-07-31T11:29:27.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/b1/ba0ce59681db38c320a6d485f95a497ddea20356d9a9e8e70615ddd867b9/websockets-17.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:02f0b037a737d0cb0c33866c97bcd1a0b73170dfbf42d69d8fb86f51002fd5ae", size = 213344, upload-time = "2026-07-31T11:29:28.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/9e/abfdde9cbd57f0b5867a70e3426ac63341e1a21557b273c39bb6d2ccf8b9/websockets-17.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:1bdd8c4be420905dd732e00dcd669852d8128cc723efa585a0c0e51adb00a28a", size = 213277, upload-time = "2026-07-31T11:29:29.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/ff/6199a52d864215750af8668d84b0274775011a90052081f5a9495807a92b/websockets-17.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f", size = 212603, upload-time = "2026-07-31T11:29:30.771Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/7e/439a962bcada88dcf586da77a1b2385f91e2d2910e9359540934c827156b/websockets-17.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d", size = 210286, upload-time = "2026-07-31T11:29:32.157Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/82/123660edc759c225626b3b91952c7625f85c77a8362acbc35a4623120f7d/websockets-17.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1", size = 210549, upload-time = "2026-07-31T11:29:33.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/2f/2940e57080cf56f28190287516400126d5a76b52b9a61dc10ba6f6400dbe/websockets-17.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21", size = 219874, upload-time = "2026-07-31T11:29:34.608Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/28/9ec976c16d63cc51c28dfec74b66854048c0b8b6579946e902f91b69e8bf/websockets-17.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a", size = 220150, upload-time = "2026-07-31T11:29:35.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/a4/850c699a16bbc451723856360c59bd997bec075e637154f3fa96e80d5760/websockets-17.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c", size = 221389, upload-time = "2026-07-31T11:29:37.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/e1/f60a891c1a4b3420d5052333a84eb7241e1fb4a71866dec1562f5fa30027/websockets-17.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761", size = 224169, upload-time = "2026-07-31T11:29:38.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/fb/e2a893be6fae4fddfe50ddc3035a331d3f381103d5467b7900026bdb3a64/websockets-17.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6", size = 222025, upload-time = "2026-07-31T11:29:39.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/72/e3144b2d79276fab9798ed7d4aea2f0847434f186800b6f56a1eddcb3114/websockets-17.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861", size = 220779, upload-time = "2026-07-31T11:29:41.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/5e/69c02174fbcf1c40c6adc45d3c316a401558392fe7bab8969ef8c46f1689/websockets-17.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4", size = 218053, upload-time = "2026-07-31T11:29:42.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/09/7574778b095b99cfa0856583462f56568df784f9b41485145169b2ec9c64/websockets-17.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f", size = 220825, upload-time = "2026-07-31T11:29:43.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/e6/f46571f38765dbc4cbc0d0b47de8db65768006dbbd4340e6f5f51bc1d895/websockets-17.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2", size = 219427, upload-time = "2026-07-31T11:29:44.731Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/31/6ff1fee057bd7e9dd5237fc064a749615378d003aa045b5bfc2d12b2f4f7/websockets-17.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e", size = 220198, upload-time = "2026-07-31T11:29:45.997Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/4f/d41847227a44b9ad87c3d5a9fddbfad8b7c4d6032878d8460d9d37c2d44f/websockets-17.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966", size = 221304, upload-time = "2026-07-31T11:29:47.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/30/21a7e326c6ad2eb526cd5b816383d59cdeb28b8805b65a543c3cfbd8e8ce/websockets-17.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f", size = 218858, upload-time = "2026-07-31T11:29:48.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/48/55b0331cd5bec9ce29748f79edc00075805450a47011d0c8e3b1c61dbf04/websockets-17.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a", size = 219840, upload-time = "2026-07-31T11:29:49.791Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/6e/2e8bc06e546f49b32a58a2bc2957902d1809ecc37552d3d7ccd6639a126e/websockets-17.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2", size = 220116, upload-time = "2026-07-31T11:29:51.026Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/ad/4bac01fa41aca54307157b9c9f68b066a6bb51fb18716ba618078a67b283/websockets-17.0.1-cp312-cp312-win32.whl", hash = "sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0", size = 213050, upload-time = "2026-07-31T11:29:52.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/d8/c3a78cccc74a554780e9e76e323d5cde891048627025f0f82623e22dc3df/websockets-17.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3", size = 213348, upload-time = "2026-07-31T11:29:53.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/25/e1b8824bd632c8a5a62d504b61e9e35e470b67e4be0206f5c28f90c7f86d/websockets-17.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79", size = 213276, upload-time = "2026-07-31T11:29:55.299Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/a8/79c577bc2f874ee22f6f5ccdab97ba9ce6b96806be3fcc3a6d8490f88a21/websockets-17.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", size = 212593, upload-time = "2026-07-31T11:29:56.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/99/e1cfaf419bb3b2fcfd6792a846f1d936293132b0b9a56530ced016c83c7b/websockets-17.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", size = 210280, upload-time = "2026-07-31T11:29:57.768Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/ef/cc994494bf7d97e41833f6ff55c24f535e4d527a10370b9631737e9c2f00/websockets-17.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", size = 210538, upload-time = "2026-07-31T11:29:59.021Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/32/fbf2d132f63ba3e67f675bccf333469786a24e0418969ce1d8e6ff9e6f02/websockets-17.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", size = 219925, upload-time = "2026-07-31T11:30:00.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/50/64eee3d25a47fe744a9490e0627cc373dca096755db740f91c28bd61cd35/websockets-17.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", size = 220206, upload-time = "2026-07-31T11:30:01.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/56/10ed4bc4dd75f204e3c62bd4898e44a8742a27773c80b188cfa7888aad2d/websockets-17.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", size = 221445, upload-time = "2026-07-31T11:30:02.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/be/bb14328614c068ab09569962fbf218fc00413ce3febc6d2684c764b6f37e/websockets-17.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", size = 222887, upload-time = "2026-07-31T11:30:03.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/1b/4cb0eec2fee310007104687493175af190019f705940c864f9c523fe9f6f/websockets-17.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", size = 222072, upload-time = "2026-07-31T11:30:05.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/9c/14e6391de777ddb39c439c450deb551406d445e25a5877d6fa25c49d4544/websockets-17.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", size = 220826, upload-time = "2026-07-31T11:30:06.53Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/57/96e94e384442247bbed5d3ab67381c7257355c2d66b62c3ad33a17f5d385/websockets-17.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", size = 218107, upload-time = "2026-07-31T11:30:07.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/8c/9c9dedd14c3919435df9b35cdee7111268c751252b87652f3a6a4f56e760/websockets-17.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", size = 220889, upload-time = "2026-07-31T11:30:09.035Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/4d/ca73c2ac82c00f50c529784bacb323e42da4816333211bc1543d90c9cf11/websockets-17.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", size = 219486, upload-time = "2026-07-31T11:30:10.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/da/fb37ac09dcd7c69dd73bac979ed393df35f78a3c232e293d1ff3bd586d24/websockets-17.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", size = 220258, upload-time = "2026-07-31T11:30:11.605Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/04/9693f191d968a93f37326a17301a101d49580889c688f466699f89ecdee1/websockets-17.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", size = 221358, upload-time = "2026-07-31T11:30:12.858Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/29/ad0d85c01db5dcf22898d51648bd2c25af0dd0a4a41c550b11acddeeeba7/websockets-17.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", size = 218921, upload-time = "2026-07-31T11:30:14.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/3b/bf8e855e495dcca63f2b8aa019cf2ada3160e1fa66d833c7417f3b1f7f38/websockets-17.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", size = 219871, upload-time = "2026-07-31T11:30:15.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/db/c7abd6639a93a40279cd1ddc57e09e1c4f8381c4cfccdb775aa5aac9770a/websockets-17.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", size = 220154, upload-time = "2026-07-31T11:30:16.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/2a/25a9f8f2e5a6ef34e911d2f55d9f756bdeb92b4c28cfb77b8430bbc73cb1/websockets-17.0.1-cp313-cp313-win32.whl", hash = "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", size = 213038, upload-time = "2026-07-31T11:30:18.434Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/2f/ea1380f72bb11b64fc5bc7ae0d42de5bbf3e6dc13b965706b2a1d4e17cdf/websockets-17.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", size = 213348, upload-time = "2026-07-31T11:30:19.693Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/c7/b956ed9151c3c74530ebc62d716fbfdbde7507a6acc6423a64f9ecfb6b8a/websockets-17.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", size = 213282, upload-time = "2026-07-31T11:30:21.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/77/63b4abd29f15107d856f010de6f35434faa7c49ef89151e051d2807a9c40/websockets-17.0.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:49266e4488309b38783257293a38298942b9a03aa106fcb45195377a77c0c1e2", size = 210194, upload-time = "2026-07-31T11:31:18.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/ab/6160542ee644f72b865af13ddfff23740c95595b237e16312262cafdb641/websockets-17.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d69fd559f9f0e8a52d2fce6f04ee143f86e70df0a189cd95164eddac599e810f", size = 210465, upload-time = "2026-07-31T11:31:19.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/1a/d4437d3cb0691eeac2c6064e21c83a9eeda04f6ef0261abfc0dde708590d/websockets-17.0.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a39ce3a7b0e6059be093213d637963101380157bcbad355916738fafb490698d", size = 211417, upload-time = "2026-07-31T11:31:20.687Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/28/2d671e23a20359a1cc142848384659813b49028d46cb0acdc28e81ac59b5/websockets-17.0.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2437d4ca208cc0f246d3a2297ae7474b4ba18261aaf5b9c79c84c031ecf348e1", size = 211309, upload-time = "2026-07-31T11:31:21.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/52/b85a676b161991e5c0d884252376435f60510789e7740e3da73489d22a6e/websockets-17.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6740be6d1bab69f08ab52cb15b08f76c143b6fe61c580ba62bd929f3ab7a1d42", size = 212203, upload-time = "2026-07-31T11:31:23.38Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/e3/91e297e41381d9131f3142a9c1a50389fd96b98125ce9b81526fa4e14b9f/websockets-17.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:afbce6e3f0fac32dc87c2a0d84869d1a706460d64f39f3889386413e6e4d3d26", size = 213434, upload-time = "2026-07-31T11:31:24.707Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" },
|
||||||
|
]
|
||||||