17 Commits
Author SHA1 Message Date
MH.Dmitrii 56e6476484 Merge pull request 'feature/rabbitmq' (#15) from feature/rabbitmq into dev
Reviewed-on: #15
2026-09-15 14:07:15 +00:00
MH.Dmitrii 3fb0f8c76d tests, reset_password first steps 2026-09-15 17:04:24 +03:00
MH.Dmitrii 2ac6f898a7 update claude.md, imports sort 2026-09-14 18:40:00 +03:00
MH.Dmitrii e124d897eb add logging and retry to connect to the rabbitmq server 2026-09-14 18:34:15 +03:00
MH.Dmitrii a93c6d5fca refactor logging module, add welcome and reset daemons, changed structure of the rabbitmq queues 2026-09-13 18:40:14 +03:00
MH.Dmitrii b4e88a6ff4 docker container for daemons 2026-09-09 18:01:48 +03:00
MH.Dmitrii 1aa3085468 topic exchange rabbitmq 2026-09-09 14:07:08 +03:00
MH.Dmitrii 10ce1755bd producers, consumers, rabbit_client 2026-09-06 18:12:59 +03:00
MH.Dmitrii d707c8b329 rabbitmq docker instance 2026-09-06 15:40:08 +03:00
MH.Dmitrii a63bfa16c6 fix redis container 2026-09-05 23:09:20 +03:00
MH.Dmitrii 5c1eab88aa Merge pull request 'feature/redis' (#14) from feature/redis into dev
Reviewed-on: #14
2026-09-05 20:06:33 +00:00
MH.Dmitrii ba5e90c516 fix rate_limit tests and add prod_mode to env 2026-09-05 23:02:42 +03:00
MH.Dmitrii 5cdcd342dc add tests for the redis 2026-09-05 22:31:54 +03:00
MH.Dmitrii 9fd44c0ad9 fix rate-limit valid login bug and fix sessions of redis and psql in tests 2026-09-05 21:55:31 +03:00
MH.Dmitrii c4a6a88d05 revoke access_tokens 2026-09-05 20:26:42 +03:00
MH.Dmitrii da8284ae64 implemented rate limit and set up redis container 2026-09-05 13:06:04 +03:00
MH.Dmitrii 99ab6ae3af Merge pull request 'docker multistage build' (#13) from feature/docker into dev
Reviewed-on: #13
2026-09-03 13:15:33 +00:00
48 changed files with 1926 additions and 345 deletions
+7
View File
@@ -0,0 +1,7 @@
---
name: code-reviewer
description: Expert code review specialist. Proactively reviews code for quality, security, and maintainability. Use immediately after writing or modifying code.
tools: Read, Grep, Glob, Bash
model: sonnet
---
You are a senior code reviewer. Read the recent git diff or modified files, then report only what matters: correctness bugs, security vulnerabilities, and maintainability traps. Lead with the highest-severity finding.
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"sandbox": {
"filesystem": {
"allowRead": ["."],
"denyRead": ["**/.env", "./DB", "./RTMQ", "./.vscode", "./.pytest_cache", "**/__pycache__"]
}
}
}
+8 -3
View File
@@ -17,12 +17,17 @@ __pycache__/
.DS_Store
Thumbs.db
#env
# env files
*.env
#db
# DB
*.db
DB/
#logs
# rabbitmq
RTMQ/
# logs
logs/
#Примеры документов
+206
View File
@@ -0,0 +1,206 @@
# The DisExcel Project — Context for Claude Code
Backend: FastAPI + SQLAlchemy (async) + Pydantic v2 + PostgreSQL. JWT auth
(access + refresh tokens), RBAC permissions, Redis cache, RabbitMQ background
workers. Python >=3.13,<4.0, Poetry for dependency management.
## Auth & Permissions
- Users have `direct_permissions` (list of `Permissions`) and `group`
(list of `PermissionsGroups`, each with its own `permissions`) —
many-to-many both ways.
- Effective permissions = `direct_permissions (union of all groups' permissions)`.
- `require_permissions(*permissions)` in `src/web/protected_routes` is a
FastAPI dependency factory — wraps `CurrentUserService.get_current_user`.
Call with no args (`require_permissions()`) for "just authenticated, no
specific permission needed".
- Access tokens carry a `jti` claim. Logout writes `revoked_access_token:{jti}`
to Redis with TTL = remaining token lifetime — `get_current_user` checks
this key before anything else.
- `secure` flag on refresh_token cookie is driven by `env_settings.PROD_MODE`
(bool) — `False` locally/tests so cookies work over plain HTTP, `True` in
prod.
## Redis (`src/cache/`)
- `RedisClient(redis.Redis)` — module-level shared singleton, subclasses
`redis.Redis` directly (inherits all commands, no manual wrapping needed).
- Three uses: permissions is-cache was considered and rejected (no real DB
savings — `get_user_by_id` already eager-loads everything via `selectin`
in one call); rate limiting on login (`RateLimit.rate_limit(ip)`
`INCR` + `EXPIRE` on first attempt, blocks >5/60s); access-token revoke
blacklist (see above).
- Rate limit is only triggered inside `except HTTPException` on `/protected/token`
— i.e. only on failed logins, not successful ones (otherwise legitimate
repeated logins would trip it).
## RabbitMQ (`src/messaging/`)
- `RabbitMQClient` — shared class, lazy `connect()` (can't be async `__init__`),
holds one `connection` + one `channel`, `get_channel()` ensures setup.
`connect()` retries `connect_robust()` up to 5 times with exponential
backoff (`asyncio.sleep(2**attempt)`) before raising — needed because
when running the app locally (outside Docker Compose) right after
`docker compose up rabbitmq`, the broker's AMQP listener isn't
immediately ready and resets the connection mid-handshake
(`ConnectionResetError`). Compose's own `depends_on: condition:
service_healthy` only helps when the app itself also runs as a Compose
service — it does nothing when the app is started on the host.
- **Topology is centralized, not declared ad-hoc.** `src/models/rabbitmq_models/email.py`
defines the whole topology as pydantic data (`Topology`/`ExchangeSpec`/`QueueSpec`,
exported as `email_topology`) — topic exchange `"email"`, one durable
quorum queue per message type (`queue_welcome_email`/`email.welcome`,
`queue_reset_email`/`email.reset`), each with a matching dead-letter queue.
`src/messaging/topology_setup.py::apply_topology(channel, topology)` is
the only place that actually calls `declare_exchange`/`declare_queue`/`bind`
against RabbitMQ. It's called once at startup — in `main.py`'s `lifespan`
(web) and in `daemon_run.py`'s `main()` (daemon entrypoint) — **before**
any producer/consumer touches the exchange/queues. `EmailProducer`/
`WelcomeEmailConsumer`/`ResetEmailConsumer` no longer declare or bind
anything themselves — they only `get_exchange("email")` /
`get_queue("queue_...")`, assuming `apply_topology` already ran.
Changing a queue's arguments in `email_topology` requires manually
deleting the existing queue in RabbitMQ first — `declare_queue` on an
already-existing queue with different arguments fails with
`PRECONDITION_FAILED`, it doesn't update in place.
- **Dead-lettering**: each original queue's `x-dead-letter-exchange` points
at `email.dlx`, a **`direct`** exchange (not `fanout` — fanout would
broadcast every dead-lettered message to *all* DLQs, mixing up
welcome/reset failures). Each DLQ is bound to `email.dlx` with the same
routing key as the original queue (`email.welcome`/`email.reset`);
RabbitMQ preserves a message's original routing key when dead-lettering,
so no explicit `x-dead-letter-routing-key` override is needed — the
`direct` exchange alone routes each dead letter back to the correct DLQ.
- `message.process(ignore_processed=True)` + manual `await message.nack(...)`
in `process_message`, not the plain auto-ack/nack that `message.process()`
does by default — needed because business-logic exceptions are caught
*inside* the block (to log them and decide retry vs. dead-letter) rather
than left to propagate, so `process()` itself must be told not to also
try to ack/nack on exit. Classification: transient errors (SMTP/network:
`SMTPServerDisconnected`, `SMTPConnectError`, `TimeoutError`,
`ConnectionRefusedError`) → `nack(requeue=True)`; everything else
(bad message body, `SMTPAuthenticationError`, bugs) → `nack(requeue=False)`
→ DLQ. Note `SMTPAuthenticationError` is a known rough edge here — it's
a systemic config problem (bad credentials), not a per-message one, but
is currently DLQ'd per-message like any other permanent error rather
than halting the daemon with an alert.
- Email templates: Jinja2 (`src/service/email/jinja_env.py` — one shared
`Environment`/`FileSystemLoader` singleton, templates in
`src/service/email/templates/`), inline CSS (email clients don't support
`<style>` reliably), `EmailMessage` with `set_content()` (plain-text
fallback) + `add_alternative(html, subtype="html")`.
- **Known gap**: the reset-password flow is a stub. `ResetEmailConsumer.process_message`
only prints and acks — it never calls a sender — and
`src/service/email/email_reset.py` is empty. `templates/reset.html` still
has a hardcoded placeholder password. Don't assume reset emails actually
send until this is wired up like `WelcomeEmailConsumer`/`DaemonEmailSender`.
## Logging (`src/logging/`)
- All log output (HTTP endpoints, SQL, daemons) funnels through one
module-level `asyncio.Queue` (`log_queue` in `src/logging/logger.py`) and
a single `LogWriter.log_writer()` background task that drains it and
appends to `./logs/{type}_log_{month}_{year}.txt` — this avoids the
original bug of firing an unsupervised `asyncio.create_task` per log
line (unordered writes, lost logs if the process died before the task
ran). **`log_writer()` must be started explicitly wherever the process
entrypoint is** — it's `asyncio.create_task(writer.log_writer())` in
`main.py`'s `lifespan` for the web process, and the same call in
`daemon_run.py`'s `main()` for the daemon process. `log_queue` is
process-local (plain in-memory `asyncio.Queue`, not shared across
processes), so **every new entrypoint needs its own writer task** or its
logs silently queue forever and are never written (unbounded memory
growth, not a crash).
- `logging.Handler` subclasses (`LoggerDB` for `sqlalchemy.engine`,
`LoggerDaemon` for the `"daemon"` logger) just push `(type, formatted_msg)`
onto `log_queue` from `emit()` — do **not** give them a custom `__init__`
that doesn't call `super().__init__()`; skipping it means `self.level`/
`self.filters`/etc. never get set and any log call raises `AttributeError:
'LoggerX' object has no attribute 'level'`.
- Two `ContextVar`s tag log lines with a correlation id without threading
it through every function call: `request_id_ctx` (set once per HTTP
request in `LoggingMiddleware.dispatch`) and `message_id_ctx` (meant to
be set once per RabbitMQ message in `process_message`). Only visible
within the same async call chain — a `ContextVar` set in an HTTP request
is `"-"` (the default) inside daemon code, and vice versa; they don't
cross the process boundary either.
- **`src/logging/logger.py` vs `src/logging/http_logger.py` split matters
for Docker.** `logger.py` has zero `fastapi`/`starlette` imports —
intentional, because the `daemon` Poetry group (and therefore the daemon
Docker image) never installs `fastapi`. `http_logger.py` holds
`LoggingMiddleware`/`ProcessingTimeMiddleware` (the only things that
actually need FastAPI/Starlette) and is imported only from `main.py`.
`src/logging/__init__.py` imports only from `logger.py`. **Never import
`src.logging.http_logger` from anything that runs in the daemon
process** (`consumers.py`, `email_welcome.py`, `daemon_run.py`, etc.) —
it would drag in `fastapi`, which raises `ModuleNotFoundError` in the
daemon container.
- Plain `logger.exception(...)` must only be called from inside an
`except` block — it pulls the active exception via `sys.exc_info()` to
attach a traceback. Called outside `except` (e.g. for a routine "message
received" log line), it still runs but appends a literal `NoneType: None`
instead of a traceback, since there's no active exception to format.
## Daemons / workers (`src/daemons/`)
- `BaseDaemon` ABC (`name` + async `run()`), one subclass per consumer
(`WelcomeEmailDaemon`, `ResetEmailDaemon`, more to come — e.g. reports).
- `DAEMONS` registry dict maps string name → daemon class.
- `daemon_run.py` (project root) is the single entrypoint: `python
daemon_run.py <name>` runs one daemon, `python daemon_run.py --all` reads
`configs/daemons.json` (`{"daemons": [...]}`) and runs all enabled ones
concurrently via `asyncio.gather`. `main()` also applies the RabbitMQ
topology (`apply_topology`) and starts the log writer task before
dispatching to either mode, inside a `try`/`finally` so both the
single-daemon and `--all` paths cancel the writer task on the way out.
- Each daemon runs as its own Docker service/container (`command: ["python",
"daemon_run.py", "<name>"]`), same pattern as the `migration` service.
- `daemons.json` is read with a plain Pydantic `BaseModel` + manual
`json.load`, NOT `pydantic-settings` `json_file` — that requires wiring
`settings_customise_sources` manually in this pydantic-settings version
and isn't worth the complexity here.
## Docker / Poetry groups
- `pyproject.toml` uses PEP 621 `[project.dependencies]` for shared deps
(sqlalchemy, redis, aio-pika, pydantic, bcrypt, jose, aiofiles, asyncpg,
psycopg2-binary, alembic, greenlet).
- `web` group: fastapi, uvicorn, gunicorn, python-multipart — only needed
by the API server.
- `daemon` group: worker-only deps — `aiosmtpd`, `jinja2` (email sending
and templating). Must **not** gain a `fastapi`/`starlette` dependency;
see the `src/logging/logger.py` vs `http_logger.py` split above for why
that boundary is enforced deliberately.
- `dev` group: pytest stack, allure, httpie, requests-async.
- Dockerfile has parallel builder→final stage pairs: `builder`→`prod`
(installs `main,web`) and `worker-builder`→`worker` (installs
`main,daemon`). Same base pattern: venv builder stage copies
`/opt/venv` into a clean final stage, poetry itself is uninstalled
after install to keep the final image lean.
- Alembic runs against a **separate sync engine** (`asyncpg` swapped out,
psycopg2 used instead) — async SQLAlchemy engine can't drive Alembic
directly without the `run_sync` bridge, and a dedicated sync engine is
simpler than that bridge.
- `DB_HOST` differs between contexts: `psql` (Docker service name) for
containers talking to each other, `localhost` for anything run on the
host (e.g. local `alembic revision --autogenerate`). Compose services
override `DB_HOST` via `environment:`; the `.env` file's own default is
for host-side runs.
## Testing (`tests/unit`, `tests/integrated`, `tests/e2e`)
- **Recurring root cause of "different event loop" / `MissingGreenlet`-style
errors**: prod code uses module-level singletons (`engine`, `redis_client`)
created once at import time and reused for the app's whole lifetime — this
is correct for prod (one event loop, whole uptime) but breaks under
pytest-asyncio's default `function`-scoped event loop (a new loop per
test, but the singleton's connections stay bound to the *first* loop).
Fix: test fixtures create a **fresh** `engine`/`RedisClient` per test and
monkeypatch or inject them in place of the global singleton, then dispose
on teardown — not a global `session`-scoped event loop (that would mask
real isolation bugs).
- e2e `MySession` must subclass `httpx.AsyncClient` (not `requests_async.
AsyncSession` — that library silently drops cookies between requests,
which broke refresh-token-cookie-dependent tests like logout).
- `test_user_fixture` is `indirect=True` parametrized with
`(direct_permissions, group)` tuples.
+22 -3
View File
@@ -1,10 +1,29 @@
SECRET_KEY = "change_me"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS= 45
ACCESS_TOKEN_EXPIRE_MINUTES = 15 #int
REFRESH_TOKEN_EXPIRE_DAYS= 45 #int
DB_USER="change_me"
DB_PASSWORD="change_me"
DB_POSTGRESS="change_me"
DB_HOST="change_me"
DB_PORT="change_me"
DB_PORT="change_me"
REDIS_PASSWORD="change_me"
REDIS_PORT=change_me #int
REDIS_HOST="change_me"
RABBITMQ_PASSWORD="change_me"
RABBITMQ_LOGIN="change_me"
RABBITMQ_HOST="change_me"
RABBITMQ_PORT=change_me #int
RABBITMQ_PORT_UI=chane_me #int
EMAIL_PORT=change_me #int
SMTP_SERVER="change_me"
EMAIL_LOGIN="change_me"
EMAIL_PASSWORD="change_me"
PROD_MODE=bool
+7
View File
@@ -0,0 +1,7 @@
{
"daemons":
[
"welcome_email",
"reset_email"
]
}
+67
View File
@@ -0,0 +1,67 @@
import asyncio
import os
import sys
from src.daemons.registry import DAEMONS
from src.logging.logger import LogWriter
from src.messaging.rabbitmq_client import rabbitmq_client
from src.messaging.topology_setup import apply_topology
from src.models.configs_read.daemons_json import daemons_config
from src.models.rabbitmq_models.email import email_topology
writer = LogWriter()
async def run_one(daemon_name: str) -> None:
daemon_cls = DAEMONS.get(daemon_name)
if daemon_cls is None:
print(f"Unknown daemon: {daemon_name}. Available: {list(DAEMONS.keys())}")
sys.exit(1)
daemon = daemon_cls()
await daemon.run()
async def run_enabled_from_config() -> None:
daemons = []
for name in daemons_config.daemons:
name = name.strip()
if name not in DAEMONS:
print(f"Warning: unknown daemon '{name}' in config, skipping")
continue
daemons.append(DAEMONS[name]())
if not daemons:
print("No enabled daemons found in config")
sys.exit(1)
await asyncio.gather(*(d.run() for d in daemons))
async def main() -> None:
channel = await rabbitmq_client.get_channel()
await apply_topology(channel, email_topology)
writer_task = asyncio.create_task(writer.log_writer())
try:
if len(sys.argv) < 2:
print("Usage: python run_daemon.py <daemon_name> | --all")
sys.exit(1)
arg = sys.argv[1]
if arg == "--all":
await run_enabled_from_config()
else:
await run_one(arg)
finally:
writer_task.cancel()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print('Interrupted')
try:
sys.exit(0)
except SystemExit:
os._exit(0)
+90 -5
View File
@@ -1,6 +1,7 @@
name: excel-project
name: disexcel
services:
backend-dev:
profiles: ["dev"]
image: "${DOCKER_REGISTRY:-local}/excel-dev:${IMAGE_TAG:-local}"
@@ -12,7 +13,9 @@ services:
init: true #Manage processes and reap zombies
ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications
environment:
- DB_HOST=psql
- DB_HOST=psql #rewrite DB_HOST var to communicate inside the docker network
- RABBITMQ_HOST=rabbitmq
- REDIS_HOST=redis
volumes:
- type: bind
source: ../src
@@ -32,11 +35,18 @@ services:
- type: bind
source: ../logs
target: /home/excel-project/logs
- type: bind
source: ../daemon_run.py
target: /home/excel-project/daemon_run.py
networks:
- backend
depends_on:
psql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
ports:
- "80:8000"
entrypoint: ["./entrypoint.sh", "--dev"]
@@ -53,6 +63,8 @@ services:
ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications
environment:
- DB_HOST=psql
- RABBITMQ_HOST=rabbitmq
- REDIS_HOST=redis
volumes:
- type: bind
source: ../configs
@@ -72,11 +84,41 @@ services:
depends_on:
psql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
ports:
- "80:8000"
daemons:
profiles: ["prod", "dev"]
image: "${DOCKER_REGISTRY:-local}/excel-daemons:${IMAGE_TAG:-local}"
container_name: daemons
environment:
- RABBITMQ_HOST=rabbitmq
build:
dockerfile: ./docker/dockerfile
context: ../
target: daemon
init: true
ipc: private
volumes:
- type: bind
source: ../configs
target: /home/excel-project/configs
- type: bind
source: ../logs
target: /home/excel-project/logs
depends_on:
rabbitmq:
condition: service_healthy
networks:
- backend
restart: unless-stopped
psql:
profiles: ["prod", "dev", "db"]
profiles: ["prod", "dev", "local"]
image: postgres:16-alpine
container_name: psql
init: true
@@ -98,8 +140,51 @@ services:
timeout: 5s
retries: 5
ports:
- "5432:5432"
- "${DB_PORT}:5432"
redis:
image: redis:latest
profiles: ["prod", "dev", "local"]
container_name: redis
init: true
ipc: private
ports:
- '${REDIS_PORT}:6379'
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes"]
healthcheck:
test: ["CMD-SHELL", "redis-cli -a ${REDIS_PASSWORD} ping | grep PONG"]
interval: 1s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
- backend
rabbitmq:
image: rabbitmq:3-management-alpine
profiles: ["prod", "dev", "local"]
container_name: rabbitmq_broker
restart: always
init: true
ipc: private
environment:
- RABBITMQ_DEFAULT_USER=${RABBITMQ_LOGIN}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
ports:
- "${RABBITMQ_PORT}:5672"
- "${RABBITMQ_PORT_UI}:15672"
volumes:
- type: bind
source: ../RTMQ
target: /var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
networks:
backend:
name: "${BACKEND_NETWORK:-backend_network}"
+48 -2
View File
@@ -34,7 +34,7 @@ COPY pyproject.toml poetry.lock ./
RUN pip install --no-cache-dir poetry \
&& poetry config virtualenvs.create false \
&& poetry install --no-root --no-interaction --only main \
&& poetry install --no-root --no-interaction --only main,web \
&& pip uninstall -y poetry poetry-core poetry-plugin-export
# --- Stage 2: Python Backend prod ---
@@ -62,4 +62,50 @@ RUN groupadd --gid 1000 appuser \
USER appuser
ENTRYPOINT ["./entrypoint.sh", "--prod"]
ENTRYPOINT ["./entrypoint.sh", "--prod"]
# --- Stage 1: Python daemon builder ---
FROM python:3.14-slim AS worker-builder
LABEL org.opencontainers.image.title="The-DisExcel-project-dev"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_DisExcel_project"
WORKDIR /home/excel-project
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN python -m venv "$VIRTUAL_ENV"
COPY pyproject.toml poetry.lock ./
RUN pip install --no-cache-dir poetry \
&& poetry config virtualenvs.create false \
&& poetry install --no-root --no-interaction --only main,daemon \
&& pip uninstall -y poetry poetry-core poetry-plugin-export
# --- Stage 2: Python daemons ---
FROM python:3.14-slim AS daemon
LABEL org.opencontainers.image.title="The-DisExcel-project-prod"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_DisExcel_project"
WORKDIR /home/excel-project
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
COPY --from=worker-builder /opt/venv /opt/venv
COPY pyproject.toml poetry.lock alembic.ini daemon_run.py ./
COPY src/ ./src/
RUN groupadd --gid 1000 appuser \
&& useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser \
&& chown -R appuser:appuser /home/excel-project
USER appuser
ENTRYPOINT ["python","-u", "daemon_run.py", "--all"]
+14 -3
View File
@@ -1,23 +1,34 @@
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
# import uvicorn
from fastapi import FastAPI
from src.cache.redis_client import redis_client
from src.database.users.crud import Seed
from src.logging.logger import LoggingMiddleware, ProcessingTimeMiddleware
from src.logging.http_logger import LoggingMiddleware, ProcessingTimeMiddleware
from src.logging.logger import LogWriter
from src.messaging.rabbitmq_client import rabbitmq_client
from src.messaging.topology_setup import apply_topology
from src.models.rabbitmq_models.email import email_topology
from src.web.protected_routes.auth_routes import router as protected_router
from src.web.protected_routes.protected_user_action_routes import (
router as protected_user_action_routes,
)
writer=LogWriter()
@asynccontextmanager
async def lifespan(app: FastAPI):
create_dirs()
channel = await rabbitmq_client.get_channel()
await apply_topology(channel, email_topology)
await create_first_user()
writer_task=asyncio.create_task(writer.log_writer())
yield
writer_task.cancel()
await redis_client.close()
await rabbitmq_client.close()
app=FastAPI(root_path="/", lifespan=lifespan)
app.add_middleware(LoggingMiddleware)
+2 -2
View File
@@ -17,11 +17,11 @@ help:
##
.PHONY: run
run: ## Run dev local application
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db up -d && ${VENV} uvicorn main:app --host 0.0.0.0 --port 8000 --reload
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile local up -d && ${VENV} uvicorn main:app --host 0.0.0.0 --port 8000 --reload
.PHONY: down
down: ## Down dev local db
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile db down
docker compose -f docker/compose-dev.yaml --env-file configs/.env --profile local down
.PHONY: run-dev
run-dev: ## Run dev application
Generated
+377 -204
View File
@@ -1,5 +1,21 @@
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
[[package]]
name = "aio-pika"
version = "10.0.1"
description = "Wrapper around the aiormq for asyncio and humans"
optional = false
python-versions = "<4,>=3.11"
groups = ["main"]
files = [
{file = "aio_pika-10.0.1-py3-none-any.whl", hash = "sha256:12120a3cf8022d2a8bc5dc89e716512a38bf742c24c5562f54764af27eec7edd"},
{file = "aio_pika-10.0.1.tar.gz", hash = "sha256:96ec3ef748ca7a25a9d2fa6e511c16c3ffcfa6b1f40ade79b8a5baabba682efd"},
]
[package.dependencies]
aiormq = ">=7,<8"
yarl = "*"
[[package]]
name = "aiofiles"
version = "25.1.0"
@@ -12,6 +28,38 @@ files = [
{file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"},
]
[[package]]
name = "aiormq"
version = "7.0.0"
description = "Pure python AMQP asynchronous client library"
optional = false
python-versions = "<4,>=3.11"
groups = ["main"]
files = [
{file = "aiormq-7.0.0-py3-none-any.whl", hash = "sha256:df49bb2282e5374a28507c4c43948e8c8e5321590f2998781c2d90a34e100789"},
{file = "aiormq-7.0.0.tar.gz", hash = "sha256:f524121f1afbb875f50235b2748f81331e3be47542ee600e83c321c4e97ea168"},
]
[package.dependencies]
pamqp = ">=4,<5"
yarl = "*"
[[package]]
name = "aiosmtpd"
version = "1.4.6"
description = "aiosmtpd - asyncio based SMTP server"
optional = false
python-versions = ">=3.8"
groups = ["daemon"]
files = [
{file = "aiosmtpd-1.4.6-py3-none-any.whl", hash = "sha256:72c99179ba5aa9ae0abbda6994668239b64a5ce054471955fe75f581d2592475"},
{file = "aiosmtpd-1.4.6.tar.gz", hash = "sha256:5a811826e1a5a06c25ebc3e6c4a704613eb9a1bcf6b78428fbe865f4f6c9a4b8"},
]
[package.dependencies]
atpublic = "*"
attrs = "*"
[[package]]
name = "alembic"
version = "1.18.5"
@@ -70,7 +118,7 @@ version = "0.0.4"
description = "Document parameters, class attributes, return types, and variables inline, with Annotated."
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["web"]
files = [
{file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"},
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
@@ -82,7 +130,7 @@ version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["main", "web"]
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
@@ -94,7 +142,7 @@ version = "4.14.2"
description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
groups = ["dev", "web"]
files = [
{file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"},
{file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"},
@@ -192,13 +240,25 @@ files = [
[package.extras]
gssauth = ["gssapi ; platform_system != \"Windows\"", "sspilib ; platform_system == \"Windows\""]
[[package]]
name = "atpublic"
version = "7.0.0"
description = "Keep all y'all's __all__'s in sync"
optional = false
python-versions = ">=3.10"
groups = ["daemon"]
files = [
{file = "atpublic-7.0.0-py3-none-any.whl", hash = "sha256:6702bd9e7245eb4e8220a3e222afcef7f87412154732271ee7deee4433b72b4b"},
{file = "atpublic-7.0.0.tar.gz", hash = "sha256:466ef10d0c8bbd14fd02a5fbd5a8b6af6a846373d91106d3a07c16d72d96b63e"},
]
[[package]]
name = "attrs"
version = "26.1.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
groups = ["daemon", "dev"]
files = [
{file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"},
{file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"},
@@ -402,7 +462,7 @@ version = "8.4.2"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["main"]
groups = ["web"]
files = [
{file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"},
{file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"},
@@ -417,12 +477,12 @@ version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev"]
groups = ["dev", "web"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
markers = {dev = "sys_platform == \"win32\"", web = "platform_system == \"Windows\""}
[[package]]
name = "coverage"
@@ -629,7 +689,7 @@ version = "0.139.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.10"
groups = ["main"]
groups = ["web"]
files = [
{file = "fastapi-0.139.1-py3-none-any.whl", hash = "sha256:17faa81907751a8a85cd44c46f37fb576bde0078cb37de40bf1cd55de7104d87"},
{file = "fastapi-0.139.1.tar.gz", hash = "sha256:99461bde7ac3fc34c78443da1f4dad3ca8f3182580029a2827692db216a8d7ae"},
@@ -746,7 +806,7 @@ version = "26.0.0"
description = "WSGI HTTP Server for UNIX"
optional = false
python-versions = ">=3.10"
groups = ["main"]
groups = ["web"]
files = [
{file = "gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc"},
{file = "gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf"},
@@ -769,7 +829,7 @@ version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
groups = ["dev", "web"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
@@ -857,7 +917,7 @@ version = "3.18"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
groups = ["main", "dev", "web"]
files = [
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
@@ -945,6 +1005,24 @@ parso = ">=0.8.6,<0.9.0"
dev = ["Django", "attrs", "colorama", "docopt", "flake8 (==7.1.2)", "pytest (<9.0.0)", "types-setuptools (==80.9.0.20250529)", "typing-extensions", "zuban (==0.7.0)"]
docs = ["Jinja2 (==3.1.6)", "MarkupSafe (==3.0.3)", "Pygments (==2.20.0)", "Sphinx (==9.1.0)", "alabaster (==1.0.0)", "babel (==2.18.0)", "certifi (==2026.4.22)", "charset-normalizer (==3.4.7)", "docutils (==0.22.4)", "idna (==3.13)", "imagesize (==2.0.0)", "iniconfig (==2.3.0)", "packaging (==26.2)", "pluggy (==1.6.0)", "pytest (==9.0.3)", "requests (==2.33.1)", "roman-numerals (==4.1.0)", "snowballstemmer (==3.0.1)", "sphinx-rtd-theme (==3.1.0)", "sphinxcontrib-applehelp (==2.0.0)", "sphinxcontrib-devhelp (==2.0.0)", "sphinxcontrib-htmlhelp (==2.1.0)", "sphinxcontrib-jquery (==4.1)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-qthelp (==2.0.0)", "sphinxcontrib-serializinghtml (==2.0.0)", "urllib3 (==2.6.3)"]
[[package]]
name = "jinja2"
version = "3.1.6"
description = "A very fast and expressive template engine."
optional = false
python-versions = ">=3.7"
groups = ["daemon"]
files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
]
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "mako"
version = "1.3.12"
@@ -995,7 +1073,7 @@ version = "3.0.3"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "daemon"]
files = [
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
@@ -1124,7 +1202,7 @@ version = "6.7.1"
description = "multidict implementation"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
groups = ["main", "dev"]
files = [
{file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"},
{file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"},
@@ -1274,163 +1352,32 @@ files = [
{file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"},
]
[[package]]
name = "numpy"
version = "2.5.1"
description = "Fundamental package for array computing in Python"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277"},
{file = "numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1"},
{file = "numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0"},
{file = "numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e"},
{file = "numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75"},
{file = "numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca"},
{file = "numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3"},
{file = "numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9"},
{file = "numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2"},
{file = "numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2"},
{file = "numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b"},
{file = "numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1"},
{file = "numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6"},
{file = "numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d"},
{file = "numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1"},
{file = "numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd"},
{file = "numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a"},
{file = "numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7"},
{file = "numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6"},
{file = "numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9"},
{file = "numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74"},
{file = "numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107"},
{file = "numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8"},
{file = "numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75"},
{file = "numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2"},
{file = "numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b"},
{file = "numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95"},
{file = "numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21"},
{file = "numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373"},
{file = "numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438"},
{file = "numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace"},
{file = "numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a"},
{file = "numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0"},
{file = "numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22"},
{file = "numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7"},
{file = "numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d"},
{file = "numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09"},
{file = "numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4"},
{file = "numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1"},
{file = "numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077"},
{file = "numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf"},
{file = "numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af"},
{file = "numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb"},
{file = "numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3"},
]
[[package]]
name = "packaging"
version = "26.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
groups = ["dev", "web"]
files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
]
[[package]]
name = "pandas"
version = "3.0.3"
description = "Powerful data structures for data analysis, time series, and statistics"
name = "pamqp"
version = "4.0.1"
description = "RabbitMQ Focused AMQP low-level library"
optional = false
python-versions = ">=3.11"
groups = ["main"]
files = [
{file = "pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98"},
{file = "pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639"},
{file = "pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2"},
{file = "pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27"},
{file = "pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824"},
{file = "pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938"},
{file = "pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea"},
{file = "pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a"},
{file = "pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09"},
{file = "pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4"},
{file = "pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c"},
{file = "pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9"},
{file = "pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf"},
{file = "pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c"},
{file = "pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc"},
{file = "pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49"},
{file = "pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa"},
{file = "pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7"},
{file = "pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8"},
{file = "pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a"},
{file = "pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb"},
{file = "pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2"},
{file = "pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44"},
{file = "pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e"},
{file = "pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d"},
{file = "pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066"},
{file = "pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd"},
{file = "pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085"},
{file = "pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870"},
{file = "pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f"},
{file = "pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13"},
{file = "pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac"},
{file = "pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f"},
{file = "pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb"},
{file = "pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a"},
{file = "pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360"},
{file = "pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76"},
{file = "pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5"},
{file = "pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977"},
{file = "pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04"},
{file = "pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6"},
{file = "pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c"},
{file = "pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028"},
{file = "pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d"},
{file = "pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a"},
{file = "pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1"},
{file = "pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1"},
{file = "pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc"},
{file = "pamqp-4.0.1-py3-none-any.whl", hash = "sha256:a547f45128b06e42ce8d7a739b0cfcc40f2c724770622eaaff4a3f587b1cf7d0"},
{file = "pamqp-4.0.1.tar.gz", hash = "sha256:9dd13b828e346622793981f14a5df817fce5de998c746209d6c0154eb8403970"},
]
[package.dependencies]
numpy = [
{version = ">=1.26.0", markers = "python_version < \"3.14\""},
{version = ">=2.3.3", markers = "python_version >= \"3.14\""},
]
python-dateutil = ">=2.8.2"
tzdata = {version = "*", markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\""}
[package.extras]
all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)", "beautifulsoup4 (>=4.12.3)", "bottleneck (>=1.4.2)", "fastparquet (>=2024.11.0)", "fsspec (>=2024.10.0)", "gcsfs (>=2024.10.0)", "html5lib (>=1.1)", "hypothesis (>=6.116.0)", "jinja2 (>=3.1.5)", "lxml (>=5.3.0)", "matplotlib (>=3.9.3)", "numba (>=0.60.0)", "numexpr (>=2.10.2)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "psycopg2 (>=2.9.10)", "pyarrow (>=13.0.0)", "pyiceberg (>=0.8.1)", "pymysql (>=1.1.1)", "pyreadstat (>=1.2.8)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)", "python-calamine (>=0.3.0)", "pytz (>=2020.1)", "pyxlsb (>=1.0.10)", "qtpy (>=2.4.2)", "s3fs (>=2024.10.0)", "scipy (>=1.14.1)", "tables (>=3.10.1)", "tabulate (>=0.9.0)", "xarray (>=2024.10.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)", "zstandard (>=0.23.0)"]
aws = ["s3fs (>=2024.10.0)"]
clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.4.2)"]
compression = ["zstandard (>=0.23.0)"]
computation = ["scipy (>=1.14.1)", "xarray (>=2024.10.0)"]
excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.5)", "python-calamine (>=0.3.0)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.2.0)"]
feather = ["pyarrow (>=13.0.0)"]
fss = ["fsspec (>=2024.10.0)"]
gcp = ["gcsfs (>=2024.10.0)"]
hdf5 = ["tables (>=3.10.1)"]
html = ["beautifulsoup4 (>=4.12.3)", "html5lib (>=1.1)", "lxml (>=5.3.0)"]
iceberg = ["pyiceberg (>=0.8.1)"]
mysql = ["SQLAlchemy (>=2.0.36)", "pymysql (>=1.1.1)"]
output-formatting = ["jinja2 (>=3.1.5)", "tabulate (>=0.9.0)"]
parquet = ["pyarrow (>=13.0.0)"]
performance = ["bottleneck (>=1.4.2)", "numba (>=0.60.0)", "numexpr (>=2.10.2)"]
plot = ["matplotlib (>=3.9.3)"]
postgresql = ["SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "psycopg2 (>=2.9.10)"]
pyarrow = ["pyarrow (>=13.0.0)"]
spss = ["pyreadstat (>=1.2.8)"]
sql-other = ["SQLAlchemy (>=2.0.36)", "adbc-driver-postgresql (>=1.2.0)", "adbc-driver-sqlite (>=1.2.0)"]
test = ["hypothesis (>=6.116.0)", "pytest (>=8.3.4)", "pytest-xdist (>=3.6.1)"]
timezone = ["pytz (>=2020.1)"]
xml = ["lxml (>=5.3.0)"]
codegen = ["lxml", "requests", "yapf"]
[[package]]
name = "parso"
@@ -1507,6 +1454,137 @@ files = [
[package.dependencies]
wcwidth = "*"
[[package]]
name = "propcache"
version = "0.5.2"
description = "Accelerated property cache"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b"},
{file = "propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c"},
{file = "propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb"},
{file = "propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e"},
{file = "propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e"},
{file = "propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b"},
{file = "propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d"},
{file = "propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e"},
{file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274"},
{file = "propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe"},
{file = "propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d"},
{file = "propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5"},
{file = "propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78"},
{file = "propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959"},
{file = "propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7"},
{file = "propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511"},
{file = "propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660"},
{file = "propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66"},
{file = "propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b"},
{file = "propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27"},
{file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f"},
{file = "propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0"},
{file = "propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82"},
{file = "propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab"},
{file = "propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba"},
{file = "propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a"},
{file = "propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf"},
{file = "propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144"},
{file = "propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9"},
{file = "propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42"},
{file = "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476"},
{file = "propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33"},
{file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a"},
{file = "propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031"},
{file = "propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42"},
{file = "propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84"},
{file = "propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a"},
{file = "propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117"},
{file = "propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098"},
{file = "propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4"},
{file = "propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e"},
{file = "propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7"},
{file = "propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d"},
{file = "propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704"},
{file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4"},
{file = "propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d"},
{file = "propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757"},
{file = "propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f"},
{file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d"},
{file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa"},
{file = "propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94"},
{file = "propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164"},
{file = "propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f"},
{file = "propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c"},
{file = "propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc"},
{file = "propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55"},
{file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568"},
{file = "propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191"},
{file = "propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7"},
{file = "propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96"},
{file = "propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999"},
{file = "propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e"},
{file = "propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539"},
{file = "propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e"},
{file = "propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979"},
{file = "propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80"},
{file = "propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825"},
{file = "propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5"},
{file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4"},
{file = "propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0"},
{file = "propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c"},
{file = "propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0"},
{file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb"},
{file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078"},
{file = "propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa"},
{file = "propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917"},
{file = "propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe"},
{file = "propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03"},
{file = "propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335"},
{file = "propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d"},
{file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2"},
{file = "propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821"},
{file = "propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370"},
{file = "propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6"},
{file = "propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe"},
{file = "propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427"},
]
[[package]]
name = "psutil"
version = "7.2.2"
@@ -1666,7 +1744,7 @@ version = "2.13.4"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "web"]
files = [
{file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"},
{file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"},
@@ -1689,7 +1767,7 @@ version = "2.46.4"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "web"]
files = [
{file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"},
{file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"},
@@ -1947,21 +2025,6 @@ pytest = ">=6.2.5"
[package.extras]
dev = ["pre-commit", "pytest-asyncio", "tox"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["main"]
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
six = ">=1.5"
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -2006,12 +2069,32 @@ version = "0.0.32"
description = "A streaming multipart parser for Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
groups = ["web"]
files = [
{file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"},
{file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"},
]
[[package]]
name = "redis"
version = "8.1.0"
description = "Python client for Redis database and key-value store"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb"},
{file = "redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25"},
]
[package.extras]
circuit-breaker = ["pybreaker (>=1.4.0)"]
hiredis = ["hiredis (>=3.2.0)"]
jwt = ["pyjwt (>=2.13.0)"]
ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"]
otel = ["opentelemetry-api (>=1.39.1)", "opentelemetry-exporter-otlp-proto-http (>=1.39.1)", "opentelemetry-sdk (>=1.39.1)"]
xxhash = ["xxhash (>=3.6.0,<3.7.0)"]
[[package]]
name = "requests"
version = "2.34.2"
@@ -2087,21 +2170,6 @@ pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "rsa"
version = "4.2"
description = "Pure-Python RSA implementation"
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version >= \"3.14\""
files = [
{file = "rsa-4.2.tar.gz", hash = "sha256:aaefa4b84752e3e99bd8333a2e1e3e7a7da64614042bd66f775573424370108a"},
]
[package.dependencies]
pyasn1 = ">=0.1.3"
[[package]]
name = "rsa"
version = "4.9.1"
@@ -2109,7 +2177,6 @@ description = "Pure-Python RSA implementation"
optional = false
python-versions = "<4,>=3.6"
groups = ["main"]
markers = "python_version == \"3.13\""
files = [
{file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"},
{file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"},
@@ -2280,7 +2347,7 @@ version = "1.3.1"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.10"
groups = ["main"]
groups = ["web"]
files = [
{file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"},
{file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"},
@@ -2314,7 +2381,7 @@ version = "4.16.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "web"]
files = [
{file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
{file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
@@ -2326,7 +2393,7 @@ version = "0.4.2"
description = "Runtime typing introspection tools"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "web"]
files = [
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
@@ -2335,19 +2402,6 @@ files = [
[package.dependencies]
typing-extensions = ">=4.12.0"
[[package]]
name = "tzdata"
version = "2026.3"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
groups = ["main"]
markers = "sys_platform == \"win32\" or sys_platform == \"emscripten\""
files = [
{file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"},
{file = "tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415"},
]
[[package]]
name = "urllib3"
version = "2.7.0"
@@ -2372,7 +2426,7 @@ version = "0.51.0"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.10"
groups = ["main"]
groups = ["web"]
files = [
{file = "uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b"},
{file = "uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0"},
@@ -2397,7 +2451,126 @@ files = [
{file = "wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda"},
]
[[package]]
name = "yarl"
version = "1.24.5"
description = "Yet another URL library"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "yarl-1.24.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88f50c94e21a0a7f14042c015b0eba1881af78562e7bf007e0033e624da59750"},
{file = "yarl-1.24.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6efbccc3d7f75d5b03105172a8dc86d82ba4da86817952529dd93185f4a88be2"},
{file = "yarl-1.24.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0ebfaffe1a16cb72141c8e09f18cc76856dbe58639f393a4f2b26e474b96b871"},
{file = "yarl-1.24.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ac73abdc7ab75610f95a8fd994c6457e87752b02a63987e188f937a1fc180f0"},
{file = "yarl-1.24.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d97a951a81039050e45f04e96689b58b8243fa5e62aa14fe67cb6075300885e"},
{file = "yarl-1.24.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe7b7bb170daccbba19ad33012d2b15f1e7942296fd4d45fc1b79013da8cc0f2"},
{file = "yarl-1.24.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89a1bbb58e0e3f7a283653d854b1e95d65e5cfd4af224dac5f02629ec1a3e621"},
{file = "yarl-1.24.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fa5e51397466ea7e98de493fa2ff1b8193cfef8a7b0f9b4842f92d342df0dba"},
{file = "yarl-1.24.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4103b77b8a8225e413107d2349b65eb3c1c52627b5cc5c3c4c1c6a798b218950"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9f3e9c8a9ecffa57bef8fb4fa19e5fa4d2d8307cf6bac5b1fca5e5860f4ba00"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0ebc836c47a6477e182169c6a476fc691d12b518894bf7dd2572f0d59f1c7ed"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:96d30286dd02679e32a39aa8f0b7498fc847fcda46cfc09df5513e82ce252440"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:fd8c81f346b58f45818d09ea11db69a8d5fd34a224b79871f6d44f12cd7977b1"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c55256dee8f4b27bfbf636c8363383c7c8db7890c7cba5217d7bd5f5f21dab6"},
{file = "yarl-1.24.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9f4d8cf085a4c6a40fb97ea0f46938a8df43c85d31f9d45e2a8867ea9293790d"},
{file = "yarl-1.24.5-cp310-cp310-win_amd64.whl", hash = "sha256:240cbec09667c1fed4c6cd0060b9ec57332427d7441289a2ed8875dc9fb2b224"},
{file = "yarl-1.24.5-cp310-cp310-win_arm64.whl", hash = "sha256:8a6987eaad834cb32dd57d9d582225f0054a5d1af706ccfbbdba735af4927e13"},
{file = "yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3"},
{file = "yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a"},
{file = "yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840"},
{file = "yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966"},
{file = "yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723"},
{file = "yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb"},
{file = "yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780"},
{file = "yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e"},
{file = "yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077"},
{file = "yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd"},
{file = "yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25"},
{file = "yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a"},
{file = "yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d"},
{file = "yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec"},
{file = "yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c"},
{file = "yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54"},
{file = "yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12"},
{file = "yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d"},
{file = "yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1"},
{file = "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9"},
{file = "yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce"},
{file = "yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba"},
{file = "yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b"},
{file = "yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c"},
{file = "yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2"},
{file = "yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb"},
{file = "yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075"},
{file = "yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff"},
{file = "yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448"},
{file = "yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f"},
{file = "yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd"},
{file = "yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16"},
{file = "yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144"},
{file = "yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4"},
{file = "yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740"},
{file = "yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1"},
{file = "yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76"},
{file = "yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d"},
{file = "yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75"},
{file = "yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9"},
{file = "yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede"},
{file = "yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca"},
{file = "yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027"},
{file = "yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9"},
{file = "yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5"},
{file = "yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4"},
{file = "yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad"},
{file = "yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f"},
{file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88"},
{file = "yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba"},
{file = "yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a"},
{file = "yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6"},
{file = "yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047"},
{file = "yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104"},
{file = "yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688"},
{file = "yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7"},
{file = "yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f"},
]
[package.dependencies]
idna = ">=2.0"
multidict = ">=4.0"
propcache = ">=0.2.1"
[metadata]
lock-version = "2.1"
python-versions = ">=3.13"
content-hash = "f507694d0ef7cad070da5cc13f689e93240a272af43b3dd64b1a9ec2b3bbbea4"
python-versions = ">=3.13,<4.0"
content-hash = "2ac8d07b41e04495f16ac9da7093f96ec08d094402ebafe48a421a40a98c7d05"
+25 -8
View File
@@ -7,26 +7,30 @@ authors = [
]
license = "MH.Dmitrii's project"
readme = "README.md"
requires-python = ">=3.13"
requires-python = ">=3.13,<4.0"
dependencies = [
"alembic (>=1.18.5,<2.0.0)",
"uvicorn (>=0.51.0,<0.52.0)",
"gunicorn (>=26.0.0,<27.0.0)",
"fastapi (>=0.139.1,<0.140.0)",
"pydantic[email] (>=2.13.4,<3.0.0)",
"pydantic-settings (>=2.14.2,<3.0.0)",
"sqlalchemy[asyncio] (>=2.0.52,<3.0.0)",
"pandas (>=3.0.3,<4.0.0)",
"bcrypt (>=5.0.0,<6.0.0)",
"python-jose (>=3.5.0,<4.0.0)",
"python-multipart (>=0.0.32,<0.0.33)",
"greenlet (>=3.5.4,<4.0.0)",
"aiofiles (>=25.1.0,<26.0.0)",
"asyncpg (>=0.31.0,<0.32.0)",
"psycopg2-binary (>=2.9.12,<3.0.0)",
"redis (>=8.1.0,<9.0.0)",
"aio-pika (>=10.0.1,<11.0.0)",
]
[tool.poetry.group.web.dependencies]
uvicorn = ">=0.51.0,<0.52.0"
gunicorn = ">=26.0.0,<27.0.0"
fastapi = ">=0.139.1,<0.140.0"
python-multipart = ">=0.0.32,<0.0.33"
[tool.poetry.group.dev.dependencies]
pytest = ">=9.1.1,<10.0.0"
pytest-cov = ">=7.1.0,<8.0.0"
@@ -37,6 +41,10 @@ httpie = ">=3.2.4,<4.0.0"
pytest-asyncio = ">=1.4.0,<2.0.0"
requests-async = ">=0.2.4,<0.3.0"
[tool.poetry.group.daemon.dependencies]
aiosmtpd = ">=1.4.6,<2.0.0"
jinja2 = ">=3.1.6,<4.0.0"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
@@ -48,10 +56,19 @@ omit = [
"*/database/*",
"*/errors/*",
"__init__.py",
"*/docker/*"
"*/docker/*",
"*/rate_limit.py",
"*/logger.py",
"*/daemons/*",
"*/topology_setup.py",
"*/logging/*",
"*/email_reset.py",
"*/email_welcome.py"
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
]
]
[tool.ruff.lint]
ignore=["B008"]
+1
View File
@@ -0,0 +1 @@
#redis dir
+24
View File
@@ -0,0 +1,24 @@
from src.cache.redis_client import redis_client
from src.errors.http_errors.errors import Errors
class RateLimit:
def __init__(self) -> None:
self.errors=Errors()
async def rate_limit(self, ip:str)->None:
key=f"action attempt {ip}"
attempts = await redis_client.incrby(key)
if attempts == 1:
await redis_client.expire(key, 60)
if attempts>5:
raise self.errors.rate_limit_error(detail="too many attempts", retry_after=60)
async def check_rate_limit(self, client_ip:str) -> None:
await self.rate_limit(client_ip)
rate_limiter=RateLimit()
+23
View File
@@ -0,0 +1,23 @@
import redis.asyncio as redis
from src.models.configs_read.env import env_settings
class RedisClient:
def __init__(self) -> None:
self.client = redis.Redis(
host=env_settings.REDIS_HOST,
port=env_settings.REDIS_PORT,
password=env_settings.REDIS_PASSWORD,
decode_responses=True,
max_connections=10,
)
def __getattr__(self, name):
return getattr(self.client, name)
async def close(self) -> None:
await self.client.aclose()
redis_client = RedisClient()
View File
+9
View File
@@ -0,0 +1,9 @@
from abc import ABC, abstractmethod
class BaseDaemon(ABC):
name: str
@abstractmethod
async def run(self) -> None:
...
+19
View File
@@ -0,0 +1,19 @@
from src.daemons.base import BaseDaemon
from src.messaging.consumers.consumers import (
reset_email_consumer,
welcome_email_consumer,
)
class WelcomeEmailDaemon(BaseDaemon):
name = "welcome_email"
async def run(self) -> None:
await welcome_email_consumer.start_consuming()
class ResetEmailDaemon(BaseDaemon):
name = "reset_email"
async def run(self) -> None:
await reset_email_consumer.start_consuming()
+6
View File
@@ -0,0 +1,6 @@
from src.daemons.email_daemons import ResetEmailDaemon, WelcomeEmailDaemon
DAEMONS = {
"welcome_email": WelcomeEmailDaemon,
"reset_email": ResetEmailDaemon,
}
-1
View File
@@ -1 +0,0 @@
'''fake data for tests'''
+8 -2
View File
@@ -2,8 +2,14 @@
import logging
from .logger import LoggerDB
from .logger import LoggerDaemon, LoggerDB
sql_logger = logging.getLogger("sqlalchemy.engine")
sql_logger.setLevel(logging.INFO)
sql_logger.addHandler(LoggerDB())
sql_logger.addHandler(LoggerDB())
sql_logger.addHandler(logging.StreamHandler())
daemon_logger=logging.getLogger("daemon")
daemon_logger.setLevel(logging.INFO)
daemon_logger.addHandler(LoggerDaemon())
daemon_logger.addHandler(logging.StreamHandler())
+63
View File
@@ -0,0 +1,63 @@
import json
from time import perf_counter
from typing import cast
from uuid import uuid4
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.concurrency import iterate_in_threadpool
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response, StreamingResponse
from src.logging.logger import log_queue, request_id_ctx
class ProcessingTimeMiddleware(BaseHTTPMiddleware, ):
async def dispatch(self, request: Request, call_next)->Response:
start_time = perf_counter()
response = await call_next(request)
process_time = perf_counter() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
class LoggingMiddleware(BaseHTTPMiddleware):
async def build_log_line(self, request_id, method, path, status_code, detail, client_ip) -> str:
return f"[{request_id}] [{method}] [{path}] [{status_code}] [{detail}] [{client_ip}]"
async def dispatch(self, request: Request, call_next) -> Response:
request_id=str(uuid4())
request_id_ctx.set(request_id)
client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown')
method = request.method
path=request.url.path
try:
response = await call_next(request)
except Exception as exc: # noqa: BLE001
line=await self.build_log_line(request_id=request_id, method=method, path=path, status_code=500, detail=repr(exc), client_ip=client_ip)
log_queue.put_nowait(("endpoints", line))
return JSONResponse(
status_code=500,
content={"detail": "Internal Server Error", "request_id": request_id}
)
streaming_response = cast(StreamingResponse, response)
chunks = []
async for chunk in streaming_response.body_iterator:
chunks.append(chunk.encode() if isinstance(chunk, str) else bytes(chunk))
body_bytes = b"".join(chunks)
streaming_response.body_iterator = iterate_in_threadpool(iter([body_bytes]))
try:
parsed = json.loads(body_bytes)
body = parsed.get("detail", None) if not isinstance(parsed, bool) else None
except (json.JSONDecodeError, TypeError):
body = None
line=await self.build_log_line(request_id=request_id, method=method, path=path, status_code=response.status_code, detail=body, client_ip=client_ip)
log_queue.put_nowait(("endpoints", line))
return response
+29 -57
View File
@@ -1,71 +1,43 @@
import asyncio
import json
import logging
from time import gmtime, perf_counter, strftime
from typing import cast
from contextvars import ContextVar
from time import gmtime, strftime
import aiofiles
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.concurrency import iterate_in_threadpool
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response, StreamingResponse
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
message_id_ctx: ContextVar[str] = ContextVar("message_id", default="-")
class ProcessingTimeMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next)->Response:
start_time = perf_counter()
response = await call_next(request)
process_time = perf_counter() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
log_queue=asyncio.Queue()
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
current_time = strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime())
class LogWriter:
def __init__(self) -> None:
pass
async def log_writer(self)->None:
while True:
log_type, msg=await log_queue.get()
await self._write(msg, log_type)
async def _write(self, msg:str, type:str)->None:
file_time = strftime("%b_%Y", gmtime())
client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown')
try:
response = await call_next(request)
except Exception as exc: # noqa: BLE001
body = str(exc)
async with aiofiles.open(f"./logs/endpoints_log_{file_time}.txt", "a") as file:
await file.write(f"[{current_time}] [500] [{body}] [{client_ip}]\n")
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
streaming_response = cast(StreamingResponse, response)
chunks = []
async for chunk in streaming_response.body_iterator:
chunks.append(chunk.encode() if isinstance(chunk, str) else bytes(chunk))
body_bytes = b"".join(chunks)
streaming_response.body_iterator = iterate_in_threadpool(iter([body_bytes]))
try:
parsed = json.loads(body_bytes)
body = parsed.get("detail", None) if not isinstance(parsed, bool) else None
except (json.JSONDecodeError, TypeError):
body = None
async with aiofiles.open(f"./logs/endpoints_log_{file_time}.txt", "a") as file:
await file.write(f"[{current_time}] [{response.status_code}] [{body}] [{client_ip}]\n")
return response
current_time = strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime())
async with aiofiles.open(f"./logs/{type}_log_{file_time}.txt", "a") as f:
await f.write(f"[{current_time}] {msg}\n")
class LoggerDB(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
msg = self.format(record)
asyncio.create_task(self._write(msg))
rid=request_id_ctx.get()
log_queue.put_nowait(("sql",f"[{rid}] {msg}"))
async def _write(self, msg: str) -> None:
file_time = strftime("%b_%Y", gmtime())
current_time = strftime("%a, %d %b %Y %H:%M:%S +0000", gmtime())
async with aiofiles.open(f"./logs/sql_log_{file_time}.txt", "a") as f:
await f.write(f"[{current_time}] {msg}\n")
class LoggerDaemon(logging.Handler):
def emit(self, record: logging.LogRecord)->None:
msg= self.format(record)
mid=message_id_ctx.get()
log_queue.put_nowait(("daemon", f"[{mid}], {msg}"))
+1
View File
@@ -0,0 +1 @@
# rabbitmq code
+105
View File
@@ -0,0 +1,105 @@
import json
import smtplib
from uuid import uuid4
from src.logging import daemon_logger
from src.logging.logger import message_id_ctx
from src.messaging.rabbitmq_client import rabbitmq_client
from src.service.email.email_reset import ResetEmailSender
from src.service.email.email_welcome import DaemonEmailSender
class WelcomeEmailConsumer:
def __init__(self) -> None:
self.channel = None
self.queue = None
self.daemon=DaemonEmailSender()
async def setup(self) -> None:
self.channel = await rabbitmq_client.get_channel()
await self.channel.set_qos(prefetch_count=10)
self.queue = await self.channel.get_queue("queue_welcome_email")
async def process_message(self, message) -> None:
message_id_ctx.set(str(uuid4()))
async with message.process(ignore_processed=True):
data = json.loads(message.body)
daemon_logger.info(f"Обрабатываю: {data}, метка: {message.routing_key}")
try:
await self.daemon.send_email(data.get("email"))
except (
smtplib.SMTPServerDisconnected,
smtplib.SMTPConnectError,
TimeoutError,
ConnectionRefusedError,
) as exc:
daemon_logger.exception(f"transient error, retrying: {exc!r}")
await message.nack(requeue=True)
except Exception as exc: # noqa: BLE001
daemon_logger.exception(f"permanent error, sending to DLQ: {exc!r}")
await message.nack(requeue=False)
async def start_consuming(self)->None:
if self.queue is None:
await self.setup()
queue = self.queue
if queue is None:
raise RuntimeError("Failed to set up RabbitMQ queue")
async with queue.iterator() as queue_iter:
async for message in queue_iter:
await self.process_message(message)
class ResetEmailConsumer:
def __init__(self) -> None:
self.channel = None
self.queue = None
self.daemon = ResetEmailSender()
async def setup(self) -> None:
self.channel = await rabbitmq_client.get_channel()
await self.channel.set_qos(prefetch_count=10)
self.queue = await self.channel.get_queue("queue_reset_email")
async def process_message(self, message) -> None:
message_id_ctx.set(str(uuid4()))
async with message.process(ignore_processed=True):
data = json.loads(message.body)
daemon_logger.info(f"Обрабатываю: {data}, метка: {message.routing_key}")
try:
await self.daemon.send_email(data.get("email"), data.get("temp_password"))
except (
smtplib.SMTPServerDisconnected,
smtplib.SMTPConnectError,
TimeoutError,
ConnectionRefusedError,
) as exc:
daemon_logger.exception(f"transient error, retrying: {exc!r}")
await message.nack(requeue=True)
except Exception as exc: # noqa: BLE001
daemon_logger.exception(f"permanent error, sending to DLQ: {exc!r}")
await message.nack(requeue=False)
async def start_consuming(self)->None:
if self.queue is None:
await self.setup()
queue = self.queue
if queue is None:
raise RuntimeError("Failed to set up RabbitMQ queue")
async with queue.iterator() as queue_iter:
async for message in queue_iter:
await self.process_message(message)
welcome_email_consumer=WelcomeEmailConsumer()
reset_email_consumer=ResetEmailConsumer()
+41
View File
@@ -0,0 +1,41 @@
import json
import aio_pika
from src.messaging.rabbitmq_client import rabbitmq_client
class EmailProducer:
def __init__(self) -> None:
self.channel = None
self.exchange = None
async def setup(self) -> None:
self.channel = await rabbitmq_client.get_channel()
self.exchange = await self.channel.get_exchange("email")
async def send_welcome_email(self, email:str)->None:
await self._publish({"email":email}, routing_key="email.welcome")
async def send_reset_email(self, email: str, temp_password: str) -> None:
await self._publish({"email": email, "temp_password": temp_password}, routing_key="email.reset")
async def _publish(self,data:dict, routing_key:str)->None:
if self.exchange is None:
await self.setup()
if self.exchange is None:
raise RuntimeError("Failed to set up RabbitMQ exchange")
message = aio_pika.Message(
body=json.dumps(data).encode(),
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
)
await self.exchange.publish(message=message, routing_key=routing_key)
email_producer=EmailProducer()
+47
View File
@@ -0,0 +1,47 @@
import asyncio
import aio_pika
from aio_pika.abc import AbstractChannel, AbstractRobustConnection
from src.models.configs_read.env import env_settings
class RabbitMQClient:
def __init__(self) -> None:
self.connection: AbstractRobustConnection | None = None
self.channel: AbstractChannel | None = None
async def connect(self) -> None:
for attempt in range(1,6):
try:
if self.connection is None or self.connection.is_closed:
self.connection = await aio_pika.connect_robust(
host=env_settings.RABBITMQ_HOST,
port=env_settings.RABBITMQ_PORT,
login=env_settings.RABBITMQ_LOGIN,
password=env_settings.RABBITMQ_PASSWORD,
)
break
except Exception as exc:
if attempt==5:
raise
print(f"RabbitMQ not ready yet (attempt {attempt}/5): {exc!r}, retrying...")
await asyncio.sleep(2**attempt)
if self.connection is None:
raise RuntimeError("Failed to esablish RabbitMQ connection. Check the server!")
if self.channel is None or self.channel.is_closed:
self.channel = await self.connection.channel()
async def get_channel(self) -> AbstractChannel:
await self.connect()
if self.channel is None:
raise RuntimeError("Failed to establish RabbitMQ channel")
return self.channel
async def close(self) -> None:
if self.channel and not self.channel.is_closed:
await self.channel.close()
if self.connection and not self.connection.is_closed:
await self.connection.close()
rabbitmq_client = RabbitMQClient()
+22
View File
@@ -0,0 +1,22 @@
from src.models.rabbitmq_models.email import Topology
async def apply_topology(channel, topology: Topology) -> None:
dlx = await channel.declare_exchange("email.dlx", "direct")
for exch_spec in topology.exchanges:
exchange = await channel.declare_exchange(exch_spec.name, exch_spec.type)
for q in exch_spec.queues:
queue = await channel.declare_queue(
q.name,
durable=True,
arguments={
"x-queue-type": "quorum",
"x-dead-letter-exchange": "email.dlx",
},
)
await queue.bind(exchange, routing_key=q.routing_key)
if q.dlx_queue:
dlq = await channel.declare_queue(q.dlx_queue, durable=True)
await dlq.bind(dlx, routing_key=q.routing_key)
+28
View File
@@ -0,0 +1,28 @@
from pydantic_settings import (
BaseSettings,
JsonConfigSettingsSource,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
from src.models.configs_read.env import Base
class DaemonsConfig(Base):
daemons: list[str]
model_config = SettingsConfigDict(json_file="configs/daemons.json")
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
return (JsonConfigSettingsSource(settings_cls),)
daemons_config = DaemonsConfig() # type: ignore[call-arg]
+17
View File
@@ -16,6 +16,23 @@ class Env(Base):
DB_HOST:str
DB_PORT:str
REDIS_PASSWORD:str
REDIS_PORT:int
REDIS_HOST:str
RABBITMQ_PASSWORD:str
RABBITMQ_LOGIN:str
RABBITMQ_HOST:str
RABBITMQ_PORT:int
RABBITMQ_PORT_UI:int
EMAIL_PORT:int
SMTP_SERVER:str
EMAIL_LOGIN:str
EMAIL_PASSWORD:str
PROD_MODE:bool
model_config=SettingsConfigDict(env_file="configs/.env", extra=None)
env_settings=Env() # type: ignore[call-arg]
+6 -1
View File
@@ -17,7 +17,12 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from src.models.configs_read.env import env_settings
engine = create_async_engine(f"postgresql+asyncpg://{env_settings.DB_USER}:{env_settings.DB_PASSWORD}@{env_settings.DB_HOST}:{env_settings.DB_PORT}/{env_settings.DB_POSTGRESS}")
engine = create_async_engine(f"postgresql+asyncpg://{env_settings.DB_USER}:{env_settings.DB_PASSWORD}@{env_settings.DB_HOST}:{env_settings.DB_PORT}/{env_settings.DB_POSTGRESS}",
pool_size=20, # сколько соединений держать открытыми постоянно
max_overflow=10, # сколько доп. соединений можно создать при пиковой нагрузке
pool_timeout=30, # сколько ждать свободное соединение, прежде чем упасть с ошибкой
pool_pre_ping=True, # проверять соединение перед использованием (ловит "протухшие" соединения)
)
'''remember as a boilerplate, or just cp/pst'''
class Model(DeclarativeBase):
+24
View File
@@ -0,0 +1,24 @@
from pydantic import BaseModel
class QueueSpec(BaseModel):
name:str
routing_key:str
dlx_queue:str|None = None
class ExchangeSpec(BaseModel):
name:str
type:str = "topic"
queues: list[QueueSpec]
class Topology(BaseModel):
exchanges:list[ExchangeSpec]
email_topology=Topology(exchanges=[
ExchangeSpec(name="email", queues=[
QueueSpec(name="queue_welcome_email", routing_key="email.welcome", dlx_queue="queue_welcome_email.dlq"),
QueueSpec(name="queue_reset_email", routing_key="email.reset", dlx_queue="queue_reset_email.dlq"),
]),
])
+26 -7
View File
@@ -4,6 +4,7 @@ from uuid import UUID
from fastapi import Request
from src.cache.redis_client import redis_client
from src.database.auth.refresh_tokens import JwtCrudActions
from src.database.users.crud import UsersCrudActions
from src.errors.http_errors.errors import Errors
@@ -52,8 +53,13 @@ class CurrentUserService:
async def get_current_user(self, token:str, *permissions: str)->UserOut:
payload= await self.jwt_service.jwt_decode(token)
sub=payload.get("sub")
jti_access=payload.get("jti")
if jti_access and await redis_client.get(f"revoked_access_token:{jti_access}"):
raise self.error.credentials_error(detail="Token has been revoked")
try:
sub=UUID(sub)
except (ValueError, TypeError) as e:
@@ -167,21 +173,34 @@ class CurrentUserService:
async def logout(self, refresh_token:str)->bool:
async def logout(self, refresh_token:str, access_token:str)->bool:
'''decode current refresh token'''
payload=await self.jwt_service.jwt_decode(refresh_token)
payload_refresh=await self.jwt_service.jwt_decode(refresh_token)
if (jti:=payload.get("jti")) is None:
raise self.error.credentials_error(detail="Jwt token is incorrect")
'''decode current access token'''
payload_access=await self.jwt_service.jwt_decode(access_token)
if (jti_refresh:=payload_refresh.get("jti")) is None:
raise self.error.credentials_error(detail="Jwt refresh token is incorrect")
if (jti_access:=payload_access.get("jti")) is None or (exp_access:=payload_access.get("exp")) is None:
raise self.error.credentials_error(detail="Jwt access token is incorrect")
try:
jti=UUID(jti)
jti_refresh=UUID(jti_refresh)
jti_access=UUID(jti_access)
except (ValueError, TypeError, AttributeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
if jti_access and exp_access:
exp_datetime = datetime.fromtimestamp(exp_access, tz=UTC)
remaining_ttl = max(int((exp_datetime - datetime.now(UTC)).total_seconds()), 1)
await redis_client.set(f"revoked_access_token:{jti_access}", "1", ex=remaining_ttl) #revoke tokens and set them to redis until their exp ends
'''logout by assigning revoked flag'''
if await self.jwt_db_actions.logout(jti):
if await self.jwt_db_actions.logout(jti_refresh):
return True
else:
raise self.error.not_found_error(detail="Refresh Token Not Found")
+4 -1
View File
@@ -35,14 +35,17 @@ class JwtService:
if not (data.get("sub")) or data.get("sub") == "":
raise self.error.credentials_error(detail="Jwt token is incorrect")
async def create_access_token(self, data:dict)->str:
user_info=data.copy()
jti=str(uuid4())
await self._validate_sub(user_info)
user_info.update({"exp": datetime.now(UTC)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES),
"token_type":"access"})
"token_type":"access",
"jti":jti})
return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM)
+37
View File
@@ -0,0 +1,37 @@
import smtplib
import ssl
from email.message import EmailMessage
from src.models.configs_read.env import env_settings
from src.service.email.jinja_env import jinja_env
class ResetEmailSender:
async def send_email(self, target_email: str, temp_password: str) -> None:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(
env_settings.SMTP_SERVER, env_settings.EMAIL_PORT, context=context
) as server:
server.login(env_settings.EMAIL_LOGIN, env_settings.EMAIL_PASSWORD)
msg = await self.build_email_message(target_email, temp_password)
server.send_message(msg)
async def build_email_message(self, target_email: str, temp_password: str) -> EmailMessage:
template = jinja_env.get_template("reset.html")
html_body = template.render(temp_password=temp_password)
text_body = (
"Пароль от вашей учётной записи в «The DisExcel» был сброшен.\n\n"
f"Временный пароль: {temp_password}\n\n"
"Рекомендуем сменить его на свой сразу после входа в аккаунт. "
"Если вы не запрашивали сброс пароля, срочно свяжитесь с поддержкой."
)
msg = EmailMessage()
msg["to"] = target_email
msg["from"] = env_settings.EMAIL_LOGIN
msg["subject"] = "Пароль сброшен"
msg.set_content(text_body)
msg.add_alternative(html_body, subtype="html")
return msg
+40
View File
@@ -0,0 +1,40 @@
import smtplib
import ssl
from email.message import EmailMessage
from src.models.configs_read.env import env_settings
from src.service.email.jinja_env import jinja_env
class DaemonEmailSender:
def __init__(self) -> None:
pass
async def send_email(self, target_email:str)->None:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(
env_settings.SMTP_SERVER, env_settings.EMAIL_PORT, context=context
) as server:
server.login(env_settings.EMAIL_LOGIN, env_settings.EMAIL_PASSWORD)
msg=await self.build_email_message(target_email)
server.send_message(msg)
async def build_email_message(self, target_email: str) -> EmailMessage:
template = jinja_env.get_template("welcome.html")
html_body = template.render(name=target_email)
text_body = (
f"Добро пожаловать, {target_email}!\n\n"
"Спасибо за регистрацию на проекте «The DisExcel». "
"Мы рады, что вы с нами — учётная запись уже готова к работе.\n\n"
"Если вы не регистрировались на The DisExcel, просто проигнорируйте это письмо."
)
msg = EmailMessage()
msg["to"] = target_email
msg["from"] = env_settings.EMAIL_LOGIN
msg["subject"] = "Добро пожаловать"
msg.set_content(text_body)
msg.add_alternative(html_body, subtype="html")
return msg
+6
View File
@@ -0,0 +1,6 @@
from jinja2 import Environment, FileSystemLoader
jinja_env = Environment(
loader=FileSystemLoader("src/service/email/templates"),
autoescape=True,
)
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Пароль сброшен</title>
</head>
<body style="margin:0; padding:0; background-color:#f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7; padding:40px 0;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background-color:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 2px 8px rgba(0,0,0,0.06);">
<tr>
<td style="background-color:#dc2626; padding:32px 40px; text-align:center;">
<h1 style="margin:0; color:#ffffff; font-size:22px; font-weight:600;">The DisExcel</h1>
</td>
</tr>
<tr>
<td style="padding:40px;">
<h2 style="margin:0 0 16px 0; color:#111827; font-size:20px;">Пароль был сброшен</h2>
<p style="margin:0 0 24px 0; color:#4b5563; font-size:15px; line-height:1.6;">
Пароль от вашей учётной записи в «The DisExcel» был сброшен. Ниже — временный пароль для входа. Рекомендуем сменить его на свой сразу после входа в аккаунт.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb; border:1px solid #e5e7eb; border-radius:8px;">
<tr>
<td style="padding:20px; text-align:center;">
<span style="display:block; margin:0 0 4px 0; color:#9ca3af; font-size:12px; text-transform:uppercase; letter-spacing:0.05em;">Новый пароль</span>
<span style="display:inline-block; font-family: 'Courier New', monospace; font-size:20px; font-weight:700; color:#111827; letter-spacing:0.05em;">{{ temp_password }}</span>
</td>
</tr>
</table>
<p style="margin:24px 0 0 0; color:#9ca3af; font-size:13px; line-height:1.5;">
Если вы не запрашивали сброс пароля, срочно свяжитесь с поддержкой — возможно, кто-то получил доступ к вашей учётной записи.
</p>
</td>
</tr>
<tr>
<td style="padding:24px 40px; background-color:#f9fafb; border-top:1px solid #e5e7eb;">
<p style="margin:0; color:#9ca3af; font-size:12px; line-height:1.5;">
Это автоматическое уведомление от The DisExcel. Не пересылайте это письмо никому.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Добро пожаловать</title>
</head>
<body style="margin:0; padding:0; background-color:#f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7; padding:40px 0;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background-color:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 2px 8px rgba(0,0,0,0.06);">
<tr>
<td style="background-color:#4f46e5; padding:32px 40px; text-align:center;">
<h1 style="margin:0; color:#ffffff; font-size:22px; font-weight:600;">The DisExcel</h1>
</td>
</tr>
<tr>
<td style="padding:40px;">
<h2 style="margin:0 0 16px 0; color:#111827; font-size:20px;">Добро пожаловать, {{ name }}</h2>
<p style="margin:0 0 24px 0; color:#4b5563; font-size:15px; line-height:1.6;">
Спасибо за регистрацию на проекте «The DisExcel». Мы рады, что вы с нами — учётная запись уже готова к работе.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" align="center" style="margin:0 auto;">
<tr>
<td style="border-radius:8px; background-color:#4f46e5;">
<a href="#" style="display:inline-block; padding:12px 28px; color:#ffffff; font-size:14px; font-weight:600; text-decoration:none;">
Перейти в аккаунт
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:24px 40px; background-color:#f9fafb; border-top:1px solid #e5e7eb;">
<p style="margin:0; color:#9ca3af; font-size:12px; line-height:1.5;">
Если вы не регистрировались на The DisExcel, просто проигнорируйте это письмо.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+30 -10
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter, Cookie, Depends, Request, Response
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from src.cache.rate_limit import rate_limiter
from src.models.configs_read.env import env_settings
from src.models.pydantic_models.model import UserOut
from src.service.auth.auth import CurrentUserService, auth_service
@@ -12,22 +13,32 @@ oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/pro
def require_permissions(*permissions: str): #permissions check dependency
async def checker(
token: str = Depends(oauth2_schema),
auth: CurrentUserService = Depends(auth_service), #noqa: B008
auth: CurrentUserService = Depends(auth_service),
) -> UserOut:
return UserOut.model_validate(await auth.get_current_user(token, *permissions))
return checker
@router.post("/token")
async def get_access_token(request: Request,response:Response,auth:CurrentUserService=Depends(auth_service), form_data:OAuth2PasswordRequestForm=Depends())->dict: # noqa: B008
async def get_access_token(request: Request,
response:Response,
auth:CurrentUserService=Depends(auth_service),
form_data:OAuth2PasswordRequestForm=Depends(),
)->dict:
access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request)
client_ip = request.headers.get('x-forwarded-for', '').split(',')[0].strip() or (request.client.host if request.client else 'unknown')
try:
access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request)
except HTTPException:
await rate_limiter.rate_limit(client_ip)
raise
response.set_cookie(
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
secure=env_settings.PROD_MODE,
samesite="strict",
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
)
@@ -35,7 +46,10 @@ async def get_access_token(request: Request,response:Response,auth:CurrentUserSe
@router.post("/refresh")
async def get_refresh_token(request:Request,response:Response, refresh_token: str = Cookie(), auth:CurrentUserService=Depends(auth_service))->dict: # noqa: B008
async def get_refresh_token(request:Request,
response:Response,
refresh_token: str = Cookie(),
auth:CurrentUserService=Depends(auth_service))->dict:
access_token, refresh_token= await auth.refresh_token(refresh_token=refresh_token,request=request)
@@ -43,7 +57,7 @@ async def get_refresh_token(request:Request,response:Response, refresh_token: st
key="refresh_token",
value=refresh_token,
httponly=True,
secure=True,
secure=env_settings.PROD_MODE,
samesite="strict",
max_age=env_settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
)
@@ -51,10 +65,16 @@ async def get_refresh_token(request:Request,response:Response, refresh_token: st
return {"access_token":access_token, "token_type": "bearer"}
@router.get("/logout")
async def logout(response:Response,refresh_token: str = Cookie(),auth:CurrentUserService=Depends(auth_service),current_user:UserOut=Depends(require_permissions()))->bool: # noqa: B008
async def logout(response:Response,
refresh_token: str = Cookie(),
access_token: str = Depends(oauth2_schema),
auth:CurrentUserService=Depends(auth_service),
current_user:UserOut=Depends(require_permissions()))->bool:
response.delete_cookie("refresh_token")
return await auth.logout(refresh_token)
return await auth.logout(refresh_token, access_token)
@router.get("")
async def protected(current_user:UserOut=Depends(require_permissions()))->dict: # noqa: B008
async def protected(current_user:UserOut=Depends(require_permissions()))->dict:
return {"protected router": "Hello, this is a protected router"}
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends
from src.messaging.producers.producers import email_producer
from src.models.pydantic_models.model import UserCreate, UserOut, UserUpdate
from src.service.users_crud.users_crud import CrudService, crud_service
from src.web.protected_routes.auth_routes import require_permissions
@@ -7,22 +8,23 @@ from src.web.protected_routes.auth_routes import require_permissions
router=APIRouter(prefix="/user")
@router.get("/get_by_email")
async def get_current_user_by_email(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: # noqa: B008
async def get_current_user_by_email(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut:
return await crud.get_user_by_email(email)
@router.post("/create_user")
async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: #noqa: B008
async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut:
await email_producer.send_welcome_email(current_user.email)
return await crud.create_user(data)
@router.post("/delete_user_soft")
async def delete_user_soft(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: #noqa: B008
async def delete_user_soft(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool:
return await crud.delete_user_soft(email)
@router.post("/delete_user_hard")
async def delete_user_hard(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool: #noqa: B008
async def delete_user_hard(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->bool:
return await crud.delete_user_hard(email, current_user)
@router.patch("/patch_user")
async def patch_user(email:str, data:UserUpdate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut: #noqa: B008
async def patch_user(email:str, data:UserUpdate, crud:CrudService=Depends(crud_service), current_user=Depends(require_permissions("admin")))->UserOut:
return await crud.update_user(email, data)
+17 -1
View File
@@ -1,6 +1,9 @@
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from src.models.configs_read.env import env_settings
from src.service.auth.jwt import HashService, JwtService
from src.service.users_crud.users_crud import CrudService
@pytest_asyncio.fixture
@@ -11,4 +14,17 @@ async def jwt_service()->JwtService:
@pytest_asyncio.fixture
async def hash_service()->HashService:
hash_service=HashService()
return hash_service
return hash_service
@pytest_asyncio.fixture
async def crud_service():
test_engine = create_async_engine(f"postgresql+asyncpg://{env_settings.DB_USER}:{env_settings.DB_PASSWORD}@{env_settings.DB_HOST}:{env_settings.DB_PORT}/{env_settings.DB_POSTGRESS}",
pool_size=20,
max_overflow=10,
pool_timeout=30,
pool_pre_ping=True
)
crud_service = CrudService()
crud_service.crud_db_actions.Session = async_sessionmaker(bind=test_engine)
yield crud_service
await test_engine.dispose()
+47 -7
View File
@@ -4,7 +4,7 @@ import requests_async
from httpx import HTTPStatusError
class TestAuth:
class TestPermissions:
async def test_get_access_token_positive(self, target_url:str)->None:
@@ -12,6 +12,7 @@ class TestAuth:
response = await requests_async.post(f"{target_url}/protected/token")
response.raise_for_status()
assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code != 401
@@ -24,7 +25,7 @@ class TestAuth:
assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code != 401
async def test_get_root_positive(self, target_url:str)->None:
async def test_get_root_unauthorized(self, target_url:str)->None:
with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info:
@@ -33,14 +34,53 @@ class TestAuth:
assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code == 401
@pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
async def test_get_logout_positive(self,test_user_fixture, target_url:str)->None:
async def test_get_logout_positive(self, test_user_fixture, target_url: str) -> None:
session=test_user_fixture[0]
with allure.step("get_root"), pytest.raises(HTTPStatusError) as exc_info:
session = test_user_fixture[0]
with allure.step("get_root"):
response = await session.get(f"{target_url}/protected/logout")
response.raise_for_status()
assert exc_info.value.response.status_code != 403
assert exc_info.value.response.status_code != 401
assert response.status_code == 200
class TestRedis:
@pytest.mark.parametrize("wrong_user_data, expected_status",[
pytest.param({"username":"Wrong_user", "password":"Wrong_password"},429,id="Wrong_user_creds")
])
async def test_rate_limit_positive(self, wrong_user_data:dict, target_url:str, expected_status:int):
with allure.step("logging with invalid creds"):
for i in range(5):
with pytest.raises(HTTPStatusError) as exc_info:
response = await requests_async.post(target_url + "/protected/token", data=wrong_user_data)
response.raise_for_status()
assert exc_info.value.response.status_code == 401, f"Attempt {i+1} should be 401"
with allure.step("verify rate_limit works"),pytest.raises(HTTPStatusError) as exc_info:
response = await requests_async.post(target_url + "/protected/token", data=wrong_user_data)
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
@pytest.mark.parametrize("test_user_fixture", [([], [])], indirect=True)
async def test_logout_revokes_access_token(self,test_user_fixture, target_url:str)->None:
session=test_user_fixture[0]
with allure.step("logout"):
response = await session.get(f"{target_url}/protected/logout")
response.raise_for_status()
with allure.step("verify token is revoked"), pytest.raises(HTTPStatusError) as exc_info:
response = await session.get(f"{target_url}/protected")
response.raise_for_status()
assert exc_info.value.response.status_code==401
+10 -9
View File
@@ -1,22 +1,23 @@
import pytest_asyncio
from fastapi import Request
from src.cache.redis_client import RedisClient
from src.service.auth.auth import CurrentUserService
from src.service.users_crud.users_crud import CrudService
@pytest_asyncio.fixture
async def current_user_service()->CurrentUserService:
current_user_service=CurrentUserService()
return current_user_service
async def current_user_service(monkeypatch):
test_redis = RedisClient()
monkeypatch.setattr("src.service.auth.auth.redis_client", test_redis)
service = CurrentUserService()
yield service
await test_redis.aclose()
@pytest_asyncio.fixture
async def requests(mocker):
fake_request = mocker.MagicMock(spec=Request)
fake_request.headers = {"user-agent": "pytest-agent", "x-forwarded-for":"127.0.0.1"}
return fake_request
@pytest_asyncio.fixture
async def crud_service()->CrudService:
crud_service=CrudService()
return crud_service
+6 -5
View File
@@ -118,7 +118,8 @@ class TestAuth:
with allure.step("create fake refresh token"):
token=await jwt_service.create_refresh_token({"sub":str(uuid4())})
refresh_token=await jwt_service.create_refresh_token({"sub":str(uuid4())})
access_token=await jwt_service.create_access_token({"sub":str(uuid4)})
with allure.step("patching db call functions"):
@@ -126,7 +127,7 @@ class TestAuth:
with allure.step("test logout with fake data"):
status=await current_user_service.logout(token[0])
status=await current_user_service.logout(refresh_token[0], access_token)
assert status is True
@pytest.mark.parametrize("jti,db_result, expected_exception, expected_status",[
@@ -148,11 +149,11 @@ class TestAuth:
with allure.step("create fake refresh token"):
token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)})
refresh_token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)})
access_token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(minutes=30)})
with allure.step("test logout with fake data"), pytest.raises(expected_exception) as exc_info:
await current_user_service.logout(token)
await current_user_service.logout(refresh_token, access_token)
if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status
-9
View File
@@ -1,9 +0,0 @@
import pytest_asyncio
from src.service.users_crud.users_crud import CrudService
@pytest_asyncio.fixture
async def crud_service()->CrudService:
crud_service=CrudService()
return crud_service
+234
View File
@@ -0,0 +1,234 @@
import asyncio
import json
import smtplib
from unittest.mock import AsyncMock, MagicMock
import allure
import pytest
import src.messaging.rabbitmq_client as rabbitmq_client_module
from src.messaging.consumers.consumers import ResetEmailConsumer, WelcomeEmailConsumer
from src.messaging.rabbitmq_client import RabbitMQClient
@pytest.mark.unit
class TestRabbitMQClient:
@pytest.mark.parametrize("",[
pytest.param(id="Connect_retries_then_succeeds")
])
async def test_connect_retries_then_succeeds(self, monkeypatch):
with allure.step("Mocking connection to the rabbitmq"):
fake_connection = MagicMock()
fake_connection.is_closed = False
fake_connection.channel = AsyncMock(return_value=MagicMock())
mock_connect = AsyncMock(
side_effect=[ConnectionError(), ConnectionError(), fake_connection]
)
monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect)
monkeypatch.setattr(asyncio, "sleep", AsyncMock()) # чтобы не ждать реально
with allure.step("Test client connection"):
client = RabbitMQClient()
await client.connect()
assert mock_connect.await_count == 3
assert client.connection is fake_connection
@pytest.mark.parametrize("",[
pytest.param(id="test_connect_gives_up_after_5_attempts")
])
async def test_connect_gives_up_after_5_attempts(self, monkeypatch):
with allure.step("Mocking connection to the rabbitmq"):
mock_connect = AsyncMock(side_effect=ConnectionError("still down"))
monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect)
monkeypatch.setattr(asyncio, "sleep", AsyncMock())
with allure.step("Test client connection"):
client = RabbitMQClient()
with pytest.raises(ConnectionError):
await client.connect()
assert mock_connect.await_count == 5
@pytest.mark.parametrize("",[
pytest.param(id="test_get_channel_negative")
])
async def test_get_channel_negative(self, monkeypatch)->None:
with allure.step("Mocking connection to the rabbitmq"):
fake_connection = MagicMock()
fake_connection.is_closed = False
fake_connection.channel = AsyncMock(return_value=None)
mock_connect = AsyncMock(return_value=fake_connection)
monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect)
with allure.step("Test client connection"):
client = RabbitMQClient()
with pytest.raises(RuntimeError):
await client.get_channel()
@pytest.mark.parametrize("",[
pytest.param(id="test_get_channel_positive")
])
async def test_get_channel_positive(self, monkeypatch)->None:
with allure.step("Mocking connection to the rabbitmq"):
fake_connection = MagicMock()
fake_connection.is_closed = False
fake_connection.channel = AsyncMock(return_value=MagicMock())
mock_connect = AsyncMock(return_value=fake_connection)
monkeypatch.setattr(rabbitmq_client_module.aio_pika, "connect_robust", mock_connect)
with allure.step("Test client connection"):
client = RabbitMQClient()
await client.get_channel()
assert client.channel is fake_connection.channel.return_value
def make_fake_message(body: dict, routing_key: str = "email.welcome") -> MagicMock:
"""Строим 'фальшивое' RabbitMQ-сообщение вручную, без реального брокера."""
message = MagicMock()
message.body = json.dumps(body).encode()
message.routing_key = routing_key
message.nack = AsyncMock() # запоминает, вызвали ли nack и с какими аргументами
# message.process(...) в реальном aio_pika — async context manager,
# поэтому нужен объект с асинхронными __aenter__/__aexit__.
process_cm = MagicMock()
process_cm.__aenter__ = AsyncMock(return_value=None)
process_cm.__aexit__ = AsyncMock(return_value=False)
message.process = MagicMock(return_value=process_cm)
return message
@pytest.mark.unit
class TestEmailConsumers:
@pytest.mark.parametrize("",[
pytest.param(id="test_start_consuming_negative")
])
async def test_start_consuming_negative(self, monkeypatch)->None:
with allure.step("Mocking connection to the rabbitmq"):
consumer = WelcomeEmailConsumer()
consumer.setup=AsyncMock()
with allure.step("Test client connection"), pytest.raises(RuntimeError):
await consumer.start_consuming()
@pytest.mark.parametrize("error", [
pytest.param(smtplib.SMTPServerDisconnected(), id="smtplib.SMTPServerDisconnected"),
pytest.param(smtplib.SMTPConnectError(421, "Service not available"), id="smtplib.SMTPConnectError"),
pytest.param(TimeoutError(), id="TimeoutError"),
pytest.param(ConnectionRefusedError(), id="ConnectionRefusedError"),
])
async def test_process_message_transient_errors_requeue(self, error) -> None:
consumer = WelcomeEmailConsumer()
consumer.daemon.send_email = AsyncMock(side_effect=error)
message = make_fake_message({"email": "user@example.com"})
await consumer.process_message(message)
message.nack.assert_awaited_once_with(requeue=True)
@pytest.mark.parametrize("",[
pytest.param(id="test_process_message_positive")
])
async def test_process_message_positive(self) -> None:
with allure.step("Mocking dependencies"):
consumer = WelcomeEmailConsumer()
consumer.daemon.send_email = AsyncMock()
with allure.step("Test process_message"):
message = make_fake_message({"email": "user@example.com"})
await consumer.process_message(message)
consumer.daemon.send_email.assert_awaited_once_with("user@example.com")
message.nack.assert_not_called()
@pytest.mark.unit
class TestResetEmailConsumer:
@pytest.mark.parametrize("",[
pytest.param(id="test_reset_start_consuming_negative")
])
async def test_start_consuming_negative(self, monkeypatch) -> None:
with allure.step("Mocking connection to the rabbitmq"):
consumer = ResetEmailConsumer()
consumer.setup = AsyncMock()
with allure.step("Test client connection"), pytest.raises(RuntimeError):
await consumer.start_consuming()
@pytest.mark.parametrize("error", [
pytest.param(smtplib.SMTPServerDisconnected(), id="smtplib.SMTPServerDisconnected"),
pytest.param(smtplib.SMTPConnectError(421, "Service not available"), id="smtplib.SMTPConnectError"),
pytest.param(TimeoutError(), id="TimeoutError"),
pytest.param(ConnectionRefusedError(), id="ConnectionRefusedError"),
])
async def test_process_message_transient_errors_requeue(self, error) -> None:
consumer = ResetEmailConsumer()
consumer.daemon.send_email = AsyncMock(side_effect=error)
message = make_fake_message(
{"email": "user@example.com", "temp_password": "Temp1234!"},
routing_key="email.reset",
)
await consumer.process_message(message)
message.nack.assert_awaited_once_with(requeue=True)
@pytest.mark.parametrize("",[
pytest.param(id="test_reset_process_message_permanent_error_goes_to_dlq")
])
async def test_process_message_permanent_error_goes_to_dlq(self) -> None:
consumer = ResetEmailConsumer()
consumer.daemon.send_email = AsyncMock(side_effect=ValueError("bad payload"))
message = make_fake_message(
{"email": "user@example.com", "temp_password": "Temp1234!"},
routing_key="email.reset",
)
await consumer.process_message(message)
message.nack.assert_awaited_once_with(requeue=False)
@pytest.mark.parametrize("",[
pytest.param(id="test_reset_process_message_positive")
])
async def test_process_message_positive(self) -> None:
with allure.step("Mocking dependencies"):
consumer = ResetEmailConsumer()
consumer.daemon.send_email = AsyncMock()
with allure.step("Test process_message"):
message = make_fake_message(
{"email": "user@example.com", "temp_password": "Temp1234!"},
routing_key="email.reset",
)
await consumer.process_message(message)
consumer.daemon.send_email.assert_awaited_once_with("user@example.com", "Temp1234!")
message.nack.assert_not_called()