From dc8bd5a6506fcb154dbbf96f4264a912ae606380 Mon Sep 17 00:00:00 2001 From: Nikita Sysoev Date: Sun, 6 Sep 2026 22:50:13 +0300 Subject: [PATCH 1/3] Refactor backend onto clean architecture, split frontend into layers Backend - Layered as domain / application / infrastructure / presentation with the dependency rule pointing inwards; src/container.py is the composition root. The rule is enforced by tests/unit/test_architecture.py, which walks the AST and fails if an inner layer imports an outer one. - Entities are framework-free dataclasses persisted through SQLAlchemy's imperative mapping, so the domain stays pure without a duplicate ORM model. - State transitions moved onto the entities; statuses became StrEnums. - HTTPException no longer escapes the HTTP layer: use cases raise domain errors and one handler maps them to status codes. - 14 bugs fixed, including: deleting an alerted file failed on the FK; delete unlinked the blob before committing; a failed insert orphaned the blob; blocking I/O on the event loop; missing greenlet; a DSN made of "None" when an env var was absent; unbounded uploads; unsanitised filenames; Postgres data written outside its volume; alembic assets missing from the image. - Optimisations: uploads and metadata extraction are streamed in constant memory (the counters reproduce str.splitlines() exactly under arbitrary chunking); the three chained Celery tasks collapse into one invocation on one session; indexes added for both list queries and for alerts.file_id; one event loop per worker process; eager_defaults removes the post-write refresh; downloads use sendfile. - Tooling: uv, ruff, ty, and 99 pytest tests that need neither Postgres nor Redis. Frontend - page.tsx split by Feature-Sliced Design: app / views / widgets / features / entities / shared, with a single HTTP client and shared table primitives. - strict TypeScript, pinned dependencies, API origin from NEXT_PUBLIC_API_URL. - Fixed a Docker build that copied a .env.production that is not in the repo, and a favicon pointing at an unserved path. - The dashboard polls quietly while files are still processing. API behaviour is unchanged: same routes, response fields, statuses and alert messages. Co-Authored-By: Claude Opus 5 (1M context) --- .env.dev | 6 +- .gitignore | 6 +- README.md | 69 +- backend/Dockerfile | 18 +- backend/README.md | 162 +++ backend/migrations/env.py | 58 +- ...4f2b7e903_add_indexes_and_alert_cascade.py | 44 + backend/pyproject.toml | 103 +- backend/src/__init__.py | 0 backend/src/app.py | 72 +- backend/src/application/__init__.py | 0 backend/src/application/dto.py | 33 + backend/src/application/ports.py | 15 + backend/src/application/use_cases/__init__.py | 0 .../src/application/use_cases/manage_files.py | 90 ++ .../src/application/use_cases/process_file.py | 83 ++ .../src/application/use_cases/upload_file.py | 86 ++ backend/src/container.py | 116 ++ backend/src/domain/__init__.py | 0 backend/src/domain/entities.py | 124 ++ backend/src/domain/errors.py | 42 + backend/src/domain/repositories.py | 55 + backend/src/domain/services/__init__.py | 0 backend/src/domain/services/alert_policy.py | 20 + backend/src/domain/services/metadata.py | 119 ++ backend/src/domain/services/naming.py | 31 + backend/src/domain/services/threat_scanner.py | 46 + backend/src/domain/storage.py | 31 + backend/src/domain/value_objects.py | 26 + backend/src/infrastructure/__init__.py | 0 backend/src/infrastructure/config.py | 58 + backend/src/infrastructure/db/__init__.py | 0 backend/src/infrastructure/db/engine.py | 20 + backend/src/infrastructure/db/repositories.py | 51 + backend/src/infrastructure/db/tables.py | 79 ++ backend/src/infrastructure/db/types.py | 33 + backend/src/infrastructure/db/unit_of_work.py | 58 + backend/src/infrastructure/queue/__init__.py | 0 .../src/infrastructure/queue/celery_app.py | 29 + backend/src/infrastructure/queue/runner.py | 30 + .../src/infrastructure/queue/task_queue.py | 22 + backend/src/infrastructure/queue/tasks.py | 25 + .../src/infrastructure/storage/__init__.py | 0 backend/src/infrastructure/storage/local.py | 64 + backend/src/models.py | 49 - backend/src/presentation/__init__.py | 0 backend/src/presentation/http/__init__.py | 0 backend/src/presentation/http/app.py | 50 + backend/src/presentation/http/dependencies.py | 65 + .../src/presentation/http/error_handlers.py | 37 + .../src/presentation/http/routers/__init__.py | 0 .../src/presentation/http/routers/alerts.py | 20 + .../src/presentation/http/routers/files.py | 116 ++ backend/src/presentation/http/schemas.py | 48 + backend/src/schemas.py | 34 - backend/src/service.py | 109 -- backend/src/tasks.py | 122 -- backend/tests/__init__.py | 0 backend/tests/conftest.py | 59 + backend/tests/doubles.py | 115 ++ backend/tests/integration/__init__.py | 0 backend/tests/integration/test_api.py | 135 ++ backend/tests/integration/test_pipeline.py | 126 ++ backend/tests/unit/__init__.py | 0 backend/tests/unit/test_architecture.py | 55 + backend/tests/unit/test_metadata.py | 90 ++ backend/tests/unit/test_threat_scanner.py | 53 + backend/tests/unit/test_upload_file.py | 115 ++ backend/uv.lock | 547 +++++--- docker-compose.dev.yml | 41 +- frontend/Dockerfile | 6 +- frontend/README.md | 124 +- frontend/package-lock.json | 1139 +++++------------ frontend/package.json | 20 +- frontend/src/app/layout.tsx | 27 +- frontend/src/app/page.tsx | 366 +----- frontend/src/entities/alert/api/alertApi.ts | 10 + frontend/src/entities/alert/model/level.ts | 12 + frontend/src/entities/alert/model/types.ts | 9 + frontend/src/entities/file/api/fileApi.ts | 42 + frontend/src/entities/file/model/status.ts | 22 + frontend/src/entities/file/model/types.ts | 17 + .../upload-file/model/useUploadFile.ts | 56 + .../upload-file/ui/UploadFileModal.tsx | 49 + frontend/src/shared/api/http.ts | 51 + frontend/src/shared/config/env.ts | 11 + frontend/src/shared/lib/format.ts | 25 + frontend/src/shared/ui/AsyncSection.tsx | 14 + frontend/src/shared/ui/DataTable.tsx | 35 + frontend/src/shared/ui/SectionCard.tsx | 22 + frontend/src/shared/ui/StatusBadge.tsx | 7 + .../model/useFilesDashboard.ts | 75 ++ .../files-dashboard/ui/FilesDashboard.tsx | 65 + .../src/widgets/alert-table/ui/AlertTable.tsx | 28 + .../src/widgets/file-table/ui/FileTable.tsx | 48 + frontend/tsconfig.json | 40 +- 96 files changed, 4321 insertions(+), 1909 deletions(-) create mode 100644 backend/README.md create mode 100644 backend/migrations/versions/a1c4f2b7e903_add_indexes_and_alert_cascade.py create mode 100644 backend/src/__init__.py create mode 100644 backend/src/application/__init__.py create mode 100644 backend/src/application/dto.py create mode 100644 backend/src/application/ports.py create mode 100644 backend/src/application/use_cases/__init__.py create mode 100644 backend/src/application/use_cases/manage_files.py create mode 100644 backend/src/application/use_cases/process_file.py create mode 100644 backend/src/application/use_cases/upload_file.py create mode 100644 backend/src/container.py create mode 100644 backend/src/domain/__init__.py create mode 100644 backend/src/domain/entities.py create mode 100644 backend/src/domain/errors.py create mode 100644 backend/src/domain/repositories.py create mode 100644 backend/src/domain/services/__init__.py create mode 100644 backend/src/domain/services/alert_policy.py create mode 100644 backend/src/domain/services/metadata.py create mode 100644 backend/src/domain/services/naming.py create mode 100644 backend/src/domain/services/threat_scanner.py create mode 100644 backend/src/domain/storage.py create mode 100644 backend/src/domain/value_objects.py create mode 100644 backend/src/infrastructure/__init__.py create mode 100644 backend/src/infrastructure/config.py create mode 100644 backend/src/infrastructure/db/__init__.py create mode 100644 backend/src/infrastructure/db/engine.py create mode 100644 backend/src/infrastructure/db/repositories.py create mode 100644 backend/src/infrastructure/db/tables.py create mode 100644 backend/src/infrastructure/db/types.py create mode 100644 backend/src/infrastructure/db/unit_of_work.py create mode 100644 backend/src/infrastructure/queue/__init__.py create mode 100644 backend/src/infrastructure/queue/celery_app.py create mode 100644 backend/src/infrastructure/queue/runner.py create mode 100644 backend/src/infrastructure/queue/task_queue.py create mode 100644 backend/src/infrastructure/queue/tasks.py create mode 100644 backend/src/infrastructure/storage/__init__.py create mode 100644 backend/src/infrastructure/storage/local.py delete mode 100644 backend/src/models.py create mode 100644 backend/src/presentation/__init__.py create mode 100644 backend/src/presentation/http/__init__.py create mode 100644 backend/src/presentation/http/app.py create mode 100644 backend/src/presentation/http/dependencies.py create mode 100644 backend/src/presentation/http/error_handlers.py create mode 100644 backend/src/presentation/http/routers/__init__.py create mode 100644 backend/src/presentation/http/routers/alerts.py create mode 100644 backend/src/presentation/http/routers/files.py create mode 100644 backend/src/presentation/http/schemas.py delete mode 100644 backend/src/schemas.py delete mode 100644 backend/src/service.py delete mode 100644 backend/src/tasks.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/doubles.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/integration/test_api.py create mode 100644 backend/tests/integration/test_pipeline.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/test_architecture.py create mode 100644 backend/tests/unit/test_metadata.py create mode 100644 backend/tests/unit/test_threat_scanner.py create mode 100644 backend/tests/unit/test_upload_file.py create mode 100644 frontend/src/entities/alert/api/alertApi.ts create mode 100644 frontend/src/entities/alert/model/level.ts create mode 100644 frontend/src/entities/alert/model/types.ts create mode 100644 frontend/src/entities/file/api/fileApi.ts create mode 100644 frontend/src/entities/file/model/status.ts create mode 100644 frontend/src/entities/file/model/types.ts create mode 100644 frontend/src/features/upload-file/model/useUploadFile.ts create mode 100644 frontend/src/features/upload-file/ui/UploadFileModal.tsx create mode 100644 frontend/src/shared/api/http.ts create mode 100644 frontend/src/shared/config/env.ts create mode 100644 frontend/src/shared/lib/format.ts create mode 100644 frontend/src/shared/ui/AsyncSection.tsx create mode 100644 frontend/src/shared/ui/DataTable.tsx create mode 100644 frontend/src/shared/ui/SectionCard.tsx create mode 100644 frontend/src/shared/ui/StatusBadge.tsx create mode 100644 frontend/src/views/files-dashboard/model/useFilesDashboard.ts create mode 100644 frontend/src/views/files-dashboard/ui/FilesDashboard.tsx create mode 100644 frontend/src/widgets/alert-table/ui/AlertTable.tsx create mode 100644 frontend/src/widgets/file-table/ui/FileTable.tsx diff --git a/.env.dev b/.env.dev index 181da01c..87221cf4 100644 --- a/.env.dev +++ b/.env.dev @@ -6,4 +6,8 @@ POSTGRES_HOST=backend-db PGPORT=5433 # Celery / Redis -CELERY_BROKER_URL=redis://backend-redis:6379/0 \ No newline at end of file +CELERY_BROKER_URL=redis://backend-redis:6379/0 + +# Application +LOG_LEVEL=INFO +MAX_UPLOAD_SIZE=104857600 diff --git a/.gitignore b/.gitignore index e3870959..3bd29557 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,8 @@ wheels/ .venv .idea **/.DS_Store -backend/storage/* \ No newline at end of file +backend/storage/* +# Tooling caches +.ruff_cache/ +.pytest_cache/ +.mypy_cache/ diff --git a/README.md b/README.md index 00da8f4c..e95345ce 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,73 @@ 2. ```docker exec -it backend alembic upgrade head``` -**Открыть фронт:** ```http://localhost:3000/test``` +**Открыть фронт:** ```http://localhost:3000/test``` **Открыть бэк:** ```http://localhost:8000/docs``` + +--- + +# Решение + +Внешнее поведение API сохранено: те же маршруты, те же поля ответов, те же +статусы и тексты алертов. + +## 1. Архитектура бэкенда — clean architecture + +Зависимости направлены только внутрь: `presentation → application → domain`, +инфраструктура подключается через порты. `src/container.py` — composition root, +единственный модуль, знающий про все слои сразу. + +``` +src/ + domain/ сущности, value objects, доменные сервисы, порты (только stdlib) + application/ use cases, DTO, порты приложения (→ domain) + infrastructure/ SQLAlchemy, Celery, локальное хранилище, настройки (→ domain, application) + presentation/ FastAPI: роутеры, схемы, обработчики ошибок + container.py сборка зависимостей +``` + +Сущности — обычные dataclass'ы без единого импорта фреймворка, положенные в +таблицы через imperative mapping SQLAlchemy: чистый домен без дублирования +моделей. Правило зависимостей не только описано, но и проверяется тестом +`tests/unit/test_architecture.py`. + +Подробности, полный список найденных багов (14 штук) и разбор оптимизаций — +в [backend/README.md](backend/README.md). + +## 2. Неочевидная оптимизация + +Файл трижды целиком загружался в память: `await upload_file.read()` при +загрузке, затем `read_text()` / `read_bytes()` в задаче извлечения метаданных — +ради подсчёта строк, символов и маркеров страниц PDF. Пиковая память росла +линейно с размером файла. + +Теперь всё читается потоком по 1 МБ, а счётчики переписаны так, чтобы результат +побайтово совпадал с прежним при любой нарезке на чанки (включая `\r\n` на стыке +и все десять символов-разделителей строк Python). Это проверено тестами на +случайных входных данных. + +Дополнительно: цепочка из трёх Celery-задач свёрнута в один вызов с одной сессией +(было 3 обращения к брокеру и 3 SELECT'а на загрузку), добавлены индексы под +запросы списков и внешний ключ, один event loop на процесс воркера, отдача +файлов через `sendfile`. + +## 3. Слои фронтенда + +`page.tsx` на 400 строк разбит по Feature-Sliced Design: +`app → views → widgets → features → entities → shared`. Подробности — +в [frontend/README.md](frontend/README.md). + +## Проверки + +```bash +cd backend +uv run ruff check . # линтер +uv run ruff format --check . # форматирование +uv run ty check src tests # типы +uv run pytest # 99 тестов, без Postgres и Redis + +cd ../frontend +npm run typecheck +npm run build +``` diff --git a/backend/Dockerfile b/backend/Dockerfile index 66443a20..2a02c862 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,11 +3,27 @@ FROM python:3.14-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ UV_NO_DEV=1 \ + UV_COMPILE_BYTECODE=1 \ UV_PROJECT_ENVIRONMENT=/usr/local WORKDIR /backend -COPY pyproject.toml uv.lock ./ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +COPY pyproject.toml uv.lock ./ RUN uv sync --locked +# alembic.ini and migrations/ are part of the image so that +# `alembic upgrade head` works without a bind mount. +COPY alembic.ini ./ +COPY migrations ./migrations COPY src ./src + +# The storage directory is created at startup by the storage adapter; make sure +# the unprivileged user owns it. +RUN useradd --create-home --uid 1000 app \ + && mkdir -p /backend/storage/files \ + && chown -R app:app /backend +USER app + +EXPOSE 8000 +CMD ["uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..28ee058e --- /dev/null +++ b/backend/README.md @@ -0,0 +1,162 @@ +# Backend + +FastAPI + Celery service for uploading files, scanning them for suspicious +content and emitting alerts. Refactored onto a clean-architecture layout. + +## Architecture + +The single rule: **dependencies point inwards.** An inner layer never imports an +outer one, so the business rules can be read, tested and changed without a +database, a broker or a web framework in the picture. + +``` + ┌───────────────────────────────────────────────┐ + │ presentation/http routers, schemas, │ FastAPI + │ error handlers │ + ├───────────────────────────────────────────────┤ + │ infrastructure SQLAlchemy, Celery, │ adapters + │ local storage, settings │ + ├───────────────────────────────────────────────┤ + │ application use cases, DTOs, ports │ orchestration + ├───────────────────────────────────────────────┤ + │ domain entities, value objects, │ pure Python + │ services, ports │ + └───────────────────────────────────────────────┘ + ▲ imports only ever point up this diagram +``` + +`src/container.py` is the composition root - the one module allowed to see every +layer at once. It wires concrete adapters into use cases; everything else talks +to protocols. + +The rule is not just documented, it is asserted: +[`tests/unit/test_architecture.py`](tests/unit/test_architecture.py) walks the AST +of every module and fails the build if `domain/` imports SQLAlchemy, if +`application/` imports FastAPI, and so on. + +### Layout + +| Path | Contains | May import | +| --- | --- | --- | +| `src/domain/` | `StoredFile`, `Alert`, statuses, `ThreatScanner`, `MetadataExtractor`, `AlertPolicy`, and the `FileRepository` / `UnitOfWork` / `FileStorage` ports | stdlib only | +| `src/application/` | one class per use case (`UploadFileUseCase`, `ProcessFileUseCase`, …), DTOs, the `FileProcessingQueue` port | `domain` | +| `src/infrastructure/` | SQLAlchemy tables + repositories + unit of work, `LocalFileStorage`, Celery app and tasks, typed settings | `domain`, `application` | +| `src/presentation/http/` | routers, Pydantic schemas, dependency providers, domain-error → HTTP mapping | all of the above | + +### Notable decisions + +**Entities are persisted with SQLAlchemy's imperative mapping.** `StoredFile` and +`Alert` are plain dataclasses with zero framework imports, mapped onto their +tables in `infrastructure/db/tables.py`. That gives a genuinely pure domain +*without* the usual price of a second set of ORM models plus a hand-written +mapper, and Alembic still autogenerates from the same metadata. + +**State transitions live on the entity.** `start_processing()`, `apply_scan()`, +`apply_metadata()`, `mark_failed()`, `rename()`. Previously these were loose +attribute assignments spread across three Celery tasks, so a rule like "a file +that already has a scan verdict keeps it when processing fails" was implicit in +the order of two lines of code. + +**No `HTTPException` outside the HTTP layer.** The old `service.py` raised +`HTTPException` from the persistence functions, which the Celery worker also +imported - a 404 raised inside a background job means nothing. Use cases now +raise domain errors and `presentation/http/error_handlers.py` maps them to status +codes in one place. + +**Statuses are `StrEnum`s.** The literals `"processing"`, `"suspicious"`, +`"critical"` were repeated as bare strings across four modules. The values stored +in Postgres are unchanged. + +## Bugs fixed + +| # | Problem | Fix | +| --- | --- | --- | +| 1 | Deleting a file that had produced an alert failed: `alerts.file_id` had no `ON DELETE` action, so the FK blocked the `DELETE` | `ON DELETE CASCADE` (migration `a1c4f2b7e903`) | +| 2 | `delete_file` unlinked the blob *before* committing - a failed commit destroyed the content of a file that was still listed | row is deleted and committed first, blob after | +| 3 | `create_file` wrote the blob before inserting the row - a failed insert left an orphan file on disk forever | the blob is removed if the insert fails | +| 4 | Blocking I/O on the event loop: `Path.write_bytes`, `Path.exists`, and Celery's `.delay()` (a synchronous broker socket call) inside `async def` endpoints | all file I/O goes through `anyio`; `send_task` runs on a worker thread | +| 5 | `greenlet` was never declared, and SQLAlchemy's async layer refuses to run without it | dependency is `sqlalchemy[asyncio]` | +| 6 | The DSN was built from `os.environ.get(...)`, so a missing variable silently produced `postgresql+asyncpg://None:None@None:None/None` | typed `Settings` (pydantic-settings) | +| 7 | Uploads were unbounded - one large request could exhaust RAM and disk | streamed with a `max_upload_size` cap, aborted mid-stream (HTTP 413) | +| 8 | The filename from the multipart body was used unsanitised for the stored name and for `Content-Disposition` | `sanitize_filename` strips directories, CR/LF and quotes; storage refuses any path that resolves outside its root | +| 9 | `GET /files` and `GET /alerts` returned every row, forever | `limit`/`offset` with a validated cap | +| 10 | Postgres data lived in an unmounted directory (`/var/lib/postgresql` vs. `PGDATA`), so the volume held nothing | `PGDATA` points inside the mount | +| 11 | Neither the API nor the worker waited for Redis; the worker did not even declare it | healthchecks + `depends_on: service_healthy` | +| 12 | The worker read blobs from its own container-local directory - it could only ever see the API's files by accident of the bind mount | an explicit shared `backend-storage` volume | +| 13 | `alembic.ini` and `migrations/` were not in the image, so `alembic upgrade head` only worked because of a dev bind mount | both are copied into the image | +| 14 | A blank or whitespace-only title was accepted | validated in the entity, before anything is written | + +## Optimisations + +**1. Constant-memory file handling (the non-obvious one).** The old code read +whole files into RAM three times over: `await upload_file.read()` on upload, then +`read_text()` or `read_bytes()` in the metadata task purely to count lines, +characters or PDF page markers. Peak memory scaled with file size, in a service +whose own scanner flags anything over 10 MB as unusual. + +Both directions are now streamed in 1 MB chunks. The counting had to survive +being cut into arbitrary pieces, so `TextContentAnalyzer` decodes UTF-8 +incrementally and reproduces `str.splitlines()` semantics exactly - including a +`\r\n` pair split across a chunk boundary and the ten characters Python treats as +line breaks - while `PdfContentAnalyzer` keeps a 10-byte overlap so a marker +straddling two chunks is still counted once. `tests/unit/test_metadata.py` +asserts the streaming result equals the whole-file result on hand-picked and on +200 randomised inputs. + +The scanner needs no bytes at all, so scanning never opens the file. + +**2. Three Celery tasks collapsed into one.** `scan → extract_metadata → +send_alert` chained through the broker, each task opening its own session and +re-loading the same row: 3 broker round-trips, 3 connection checkouts, 3 SELECTs +per upload. They are still three explicit business steps (`ProcessFileUseCase` +calls them in order, with the same commit boundaries, so the intermediate +`processing` state stays observable) but they run in one invocation on one +session: 1 round-trip, 1 checkout, 1 SELECT. + +**3. Indexes for the queries that actually run.** Both list endpoints sort by +`created_at DESC` and neither column was indexed; `alerts.file_id` had no index +either, because Postgres does not create one for a foreign key, so every file +deletion scanned the whole alerts table. Measured on 50 000 rows: + +``` +with ix_files_created_at_id: Index Scan Backward ... (actual rows=100) 0.140 ms +without it: Seq Scan on files ... (actual rows=50001) + top-N sort +``` + +**4. One event loop per worker process.** The old `run_in_worker_loop` created +and re-created a module-level loop by hand. An `asyncio.Runner` is now held for +the process lifetime, so the asyncpg pool is established once instead of being +rebuilt per task, and it is disposed on `worker_process_shutdown`. + +**5. `eager_defaults=True` on the mappers.** Server-generated `created_at` / +`updated_at` come back through `RETURNING` as part of the INSERT/UPDATE, instead +of the extra `SELECT` the original `session.refresh()` issued after every write. + +**6. Zero-copy downloads.** When the storage adapter is backed by a local disk it +exposes the path and the response is served with `sendfile`; a remote adapter +returns `None` and the same endpoint falls back to streaming. Downloads no longer +pull the file through Python either way. + +## Running + +```bash +docker compose -f docker-compose.dev.yml up +docker exec -it backend alembic upgrade head +``` + +API docs: · health: + +## Development + +```bash +uv sync # install (uv manages the venv and the lockfile) +uv run ruff check . # lint +uv run ruff format . # format +uv run ty check src tests # type check +uv run pytest # tests +``` + +The test suite needs no Postgres and no Redis: the ports are filled with SQLite, +a temporary directory and in-memory doubles, which is the practical payoff of the +layering. `tests/unit` covers the domain and the use cases, `tests/integration` +drives the real adapters and the real FastAPI app. diff --git a/backend/migrations/env.py b/backend/migrations/env.py index e9e9f01b..a6944e06 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -1,53 +1,38 @@ +"""Alembic environment. + +Autogeneration targets the metadata declared in +:mod:`src.infrastructure.db.tables`; the URL comes from the same typed settings +the application uses, so the two can never drift apart. +""" + import asyncio from logging.config import fileConfig + +from alembic import context from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config -from alembic import context -from src.service import DB_URL -from src.models import Base -import src.models -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. +from src.infrastructure.config import get_settings +from src.infrastructure.db.tables import metadata + config = context.config -config.set_main_option('sqlalchemy.url', DB_URL) +config.set_main_option("sqlalchemy.url", get_settings().database_url) -# Interpret the config file for Python logging. -# This line sets up loggers basically. if config.config_file_name is not None: fileConfig(config.config_file_name) -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -target_metadata = Base.metadata - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. +target_metadata = metadata def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = config.get_main_option("sqlalchemy.url") + """Run migrations without a DBAPI connection, emitting SQL to stdout.""" context.configure( - url=url, + url=config.get_main_option("sqlalchemy.url"), target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, + compare_type=True, ) with context.begin_transaction(): @@ -55,18 +40,13 @@ def run_migrations_offline() -> None: def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) with context.begin_transaction(): context.run_migrations() async def run_async_migrations() -> None: - """In this scenario we need to create an Engine - and associate a connection with the context. - - """ - connectable = async_engine_from_config( config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", @@ -80,8 +60,6 @@ async def run_async_migrations() -> None: def run_migrations_online() -> None: - """Run migrations in 'online' mode.""" - asyncio.run(run_async_migrations()) diff --git a/backend/migrations/versions/a1c4f2b7e903_add_indexes_and_alert_cascade.py b/backend/migrations/versions/a1c4f2b7e903_add_indexes_and_alert_cascade.py new file mode 100644 index 00000000..b22cc805 --- /dev/null +++ b/backend/migrations/versions/a1c4f2b7e903_add_indexes_and_alert_cascade.py @@ -0,0 +1,44 @@ +"""add listing indexes and cascade alerts on file delete + +Two problems this fixes: + +* ``GET /files`` and ``GET /alerts`` sort by ``created_at DESC`` with no + supporting index, so every request was a sequential scan plus a sort. +* ``alerts.file_id`` had no index (Postgres does not create one for a foreign + key), which made the referential check on ``DELETE FROM files`` scan the whole + alerts table - and, because the constraint had no ``ON DELETE`` action, + deleting a file that had already produced an alert failed outright. + +Revision ID: a1c4f2b7e903 +Revises: 0d6439d2e79f +Create Date: 2026-09-06 00:00:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "a1c4f2b7e903" +down_revision: str | Sequence[str] | None = "0d6439d2e79f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +ALERTS_FK = "alerts_file_id_fkey" + + +def upgrade() -> None: + op.create_index("ix_files_created_at_id", "files", ["created_at", "id"]) + op.create_index("ix_alerts_created_at_id", "alerts", ["created_at", "id"]) + op.create_index("ix_alerts_file_id", "alerts", ["file_id"]) + + op.drop_constraint(ALERTS_FK, "alerts", type_="foreignkey") + op.create_foreign_key(ALERTS_FK, "alerts", "files", ["file_id"], ["id"], ondelete="CASCADE") + + +def downgrade() -> None: + op.drop_constraint(ALERTS_FK, "alerts", type_="foreignkey") + op.create_foreign_key(ALERTS_FK, "alerts", "files", ["file_id"], ["id"]) + + op.drop_index("ix_alerts_file_id", table_name="alerts") + op.drop_index("ix_alerts_created_at_id", table_name="alerts") + op.drop_index("ix_files_created_at_id", table_name="files") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 988f6959..ca1db250 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,16 +1,113 @@ [project] name = "backend" -version = "0.1.0" -description = "Add your description here" +version = "1.0.0" +description = "File exchange service - upload, scan and alert pipeline" readme = "README.md" requires-python = ">=3.14" dependencies = [ "alembic>=1.18.4", + "anyio>=4.6.0", "asyncpg>=0.30.0", "celery[redis]>=5.6.3", "fastapi>=0.135.3", "pydantic>=2.12.5", + "pydantic-settings>=2.7.0", "python-multipart>=0.0.20", - "sqlalchemy>=2.0.48", + # the [asyncio] extra pulls in greenlet, without which every async query + # raises "the greenlet library is required" + "sqlalchemy[asyncio]>=2.0.48", "uvicorn>=0.42.0", ] + +[dependency-groups] +dev = [ + "aiosqlite>=0.20.0", + "httpx>=0.28.1", + "pytest>=8.3.4", + "pytest-asyncio>=0.25.0", + "ruff>=0.14.0", + "ty>=0.0.1a14", +] + +[tool.uv] +default-groups = ["dev"] +# The service is run from the source tree, not installed as a distribution. +package = false + +# --------------------------------------------------------------------------- +# ruff +# --------------------------------------------------------------------------- +[tool.ruff] +line-length = 120 +target-version = "py314" +src = ["src", "tests"] +extend-exclude = ["migrations/versions"] + +[tool.ruff.lint] +select = [ + "F", # pyflakes + "E", "W", # pycodestyle + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "S", # flake8-bandit + "B", # flake8-bugbear + "A", # flake8-builtins + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "T20", # flake8-print + "PT", # flake8-pytest-style + "SIM", # flake8-simplify + "TID", # flake8-tidy-imports + "PTH", # flake8-use-pathlib + "PL", # pylint + "PERF", # perflint + "RUF", +] +ignore = [ + "ANN401", # Any is required by a few adapter signatures + "PLR0913", # use cases legitimately take several collaborators + "S104", # binding 0.0.0.0 inside a container is intentional +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101", "S311", "ANN", "PLR2004"] +"migrations/**" = ["ANN", "E501", "I001"] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.lint.isort] +known-first-party = ["src", "tests"] + +[tool.ruff.format] +line-ending = "lf" + +# --------------------------------------------------------------------------- +# ty (Astral's type checker) +# --------------------------------------------------------------------------- +[tool.ty.environment] +python-version = "3.14" +# Imports are absolute from the project root ("src.domain...."), so the root +# is the directory that *contains* the src package. +root = ["."] + +[tool.ty.src] +include = ["src", "tests"] +exclude = ["migrations/versions"] + +[tool.ty.rules] +unresolved-import = "error" +unresolved-attribute = "error" +invalid-return-type = "error" + +# --------------------------------------------------------------------------- +# pytest +# --------------------------------------------------------------------------- +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +addopts = "-q" diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/app.py b/backend/src/app.py index bec89a5f..1a2c8b4c 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,71 +1,5 @@ -from fastapi import FastAPI, HTTPException -from fastapi import File, Form, UploadFile -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse -from starlette import status -from src.schemas import AlertItem, FileItem, FileUpdate -from src.service import create_file, delete_file, get_file, list_alerts, list_files, update_file, STORAGE_DIR -from src.tasks import scan_file_for_threats +"""ASGI entrypoint (``uvicorn src.app:app``).""" -app = FastAPI() -app.add_middleware( - CORSMiddleware, - allow_origins=[ - "http://localhost:3000", - "http://127.0.0.1:3000", - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +from src.presentation.http.app import create_app - -@app.get("/files", response_model=list[FileItem]) -async def list_files_view(): - return await list_files() - - -@app.get("/alerts", response_model=list[AlertItem]) -async def list_alerts_view(): - return await list_alerts() - - -@app.post("/files", response_model=FileItem, status_code=201) -async def create_file_view( - title: str = Form(...), - file: UploadFile = File(...), -): - file_item = await create_file(title=title, upload_file=file) - scan_file_for_threats.delay(file_item.id) - return file_item - - -@app.get("/files/{file_id}", response_model=FileItem) -async def get_file_view(file_id: str): - return await get_file(file_id) - - -@app.patch("/files/{file_id}", response_model=FileItem) -async def update_file_view( - file_id: str, - payload: FileUpdate, -): - return await update_file(file_id=file_id, title=payload.title) - - -@app.get("/files/{file_id}/download") -async def download_file(file_id: str): - file_item = await get_file(file_id) - stored_path = STORAGE_DIR / file_item.stored_name - if not stored_path.exists(): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stored file not found") - return FileResponse( - path=stored_path, - media_type=file_item.mime_type, - filename=file_item.original_name, - ) - - -@app.delete("/files/{file_id}", status_code=204) -async def delete_file_view(file_id: str): - await delete_file(file_id) +app = create_app() diff --git a/backend/src/application/__init__.py b/backend/src/application/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/application/dto.py b/backend/src/application/dto.py new file mode 100644 index 00000000..c3e84c10 --- /dev/null +++ b/backend/src/application/dto.py @@ -0,0 +1,33 @@ +"""Data carried across the application boundary.""" + +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass +from pathlib import Path + +from src.domain.entities import StoredFile + +DEFAULT_PAGE_LIMIT = 100 +MAX_PAGE_LIMIT = 500 + + +@dataclass(frozen=True, slots=True) +class Page: + limit: int = DEFAULT_PAGE_LIMIT + offset: int = 0 + + +@dataclass(slots=True) +class UploadFileCommand: + title: str + original_name: str | None + declared_mime_type: str | None + chunks: AsyncIterator[bytes] + + +@dataclass(slots=True) +class FileDownload: + """Everything the transport needs to serve a stored file.""" + + file: StoredFile + open_stream: Callable[[], AsyncIterator[bytes]] + local_path: Path | None = None diff --git a/backend/src/application/ports.py b/backend/src/application/ports.py new file mode 100644 index 00000000..385bdd5d --- /dev/null +++ b/backend/src/application/ports.py @@ -0,0 +1,15 @@ +"""Application-level ports for outbound infrastructure.""" + +from typing import Protocol + + +class FileProcessingQueue(Protocol): + """Hands a freshly uploaded file over to the asynchronous pipeline.""" + + async def enqueue_processing(self, file_id: str) -> None: ... + + +class IdGenerator(Protocol): + """Supplies identifiers for new aggregates (injected so tests stay deterministic).""" + + def __call__(self) -> str: ... diff --git a/backend/src/application/use_cases/__init__.py b/backend/src/application/use_cases/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/application/use_cases/manage_files.py b/backend/src/application/use_cases/manage_files.py new file mode 100644 index 00000000..484971fe --- /dev/null +++ b/backend/src/application/use_cases/manage_files.py @@ -0,0 +1,90 @@ +"""Read and lifecycle use cases for stored files and alerts.""" + +from src.application.dto import FileDownload, Page +from src.domain.entities import Alert, StoredFile +from src.domain.errors import StoredContentNotFoundError, StoredFileNotFoundError +from src.domain.repositories import UnitOfWork, UnitOfWorkFactory +from src.domain.storage import FileStorage + + +async def _require_file(uow: UnitOfWork, file_id: str) -> StoredFile: + file = await uow.files.get(file_id) + if file is None: + raise StoredFileNotFoundError(file_id) + return file + + +class ListFilesUseCase: + def __init__(self, *, uow_factory: UnitOfWorkFactory) -> None: + self._uow_factory = uow_factory + + async def execute(self, page: Page) -> list[StoredFile]: + async with self._uow_factory() as uow: + return await uow.files.list_recent(limit=page.limit, offset=page.offset) + + +class ListAlertsUseCase: + def __init__(self, *, uow_factory: UnitOfWorkFactory) -> None: + self._uow_factory = uow_factory + + async def execute(self, page: Page) -> list[Alert]: + async with self._uow_factory() as uow: + return await uow.alerts.list_recent(limit=page.limit, offset=page.offset) + + +class GetFileUseCase: + def __init__(self, *, uow_factory: UnitOfWorkFactory) -> None: + self._uow_factory = uow_factory + + async def execute(self, file_id: str) -> StoredFile: + async with self._uow_factory() as uow: + return await _require_file(uow, file_id) + + +class RenameFileUseCase: + def __init__(self, *, uow_factory: UnitOfWorkFactory) -> None: + self._uow_factory = uow_factory + + async def execute(self, file_id: str, title: str) -> StoredFile: + async with self._uow_factory() as uow: + file = await _require_file(uow, file_id) + file.rename(title) + await uow.commit() + return file + + +class DeleteFileUseCase: + def __init__(self, *, uow_factory: UnitOfWorkFactory, storage: FileStorage) -> None: + self._uow_factory = uow_factory + self._storage = storage + + async def execute(self, file_id: str) -> None: + async with self._uow_factory() as uow: + file = await _require_file(uow, file_id) + stored_name = file.stored_name + await uow.files.delete(file) + # The row is dropped first: if the transaction fails we still have + # the blob, whereas the original order could destroy the content of + # a file that remained listed in the database. + await uow.commit() + + await self._storage.delete(stored_name) + + +class DownloadFileUseCase: + def __init__(self, *, uow_factory: UnitOfWorkFactory, storage: FileStorage) -> None: + self._uow_factory = uow_factory + self._storage = storage + + async def execute(self, file_id: str) -> FileDownload: + async with self._uow_factory() as uow: + file = await _require_file(uow, file_id) + + if not await self._storage.exists(file.stored_name): + raise StoredContentNotFoundError(file.stored_name) + + return FileDownload( + file=file, + open_stream=lambda: self._storage.read_chunks(file.stored_name), + local_path=self._storage.local_path(file.stored_name), + ) diff --git a/backend/src/application/use_cases/process_file.py b/backend/src/application/use_cases/process_file.py new file mode 100644 index 00000000..b58b284c --- /dev/null +++ b/backend/src/application/use_cases/process_file.py @@ -0,0 +1,83 @@ +"""The asynchronous post-upload pipeline: scan -> extract metadata -> alert. + +Originally these were three Celery tasks that chained into each other, each one +re-opening a database session and re-loading the same row. They are three +distinct business steps, so they stay three explicit steps here - but they run +inside a single worker invocation and a single session, which removes two +broker round-trips and two connection acquisitions per upload. The commit +boundaries are unchanged, so the intermediate states ("processing", scan +verdict before metadata) remain observable exactly as before. +""" + +import logging +from typing import Any + +from src.domain.entities import StoredFile +from src.domain.repositories import UnitOfWork, UnitOfWorkFactory +from src.domain.services.alert_policy import AlertPolicy +from src.domain.services.metadata import MetadataExtractor +from src.domain.services.threat_scanner import ThreatScanner +from src.domain.storage import FileStorage + +logger = logging.getLogger(__name__) + +MISSING_CONTENT_REASON = "stored file not found during metadata extraction" + + +class ProcessFileUseCase: + def __init__( + self, + *, + uow_factory: UnitOfWorkFactory, + storage: FileStorage, + scanner: ThreatScanner, + metadata_extractor: MetadataExtractor, + alert_policy: AlertPolicy, + ) -> None: + self._uow_factory = uow_factory + self._storage = storage + self._scanner = scanner + self._metadata_extractor = metadata_extractor + self._alert_policy = alert_policy + + async def execute(self, file_id: str) -> None: + async with self._uow_factory() as uow: + file = await uow.files.get(file_id) + if file is None: + # The file was deleted while the job sat in the queue: nothing + # to process and nothing to alert about. + logger.warning("Skipping processing of unknown file %s", file_id) + return + + await self._scan(uow, file) + await self._extract_metadata(uow, file) + await self._raise_alert(uow, file) + + async def _scan(self, uow: UnitOfWork, file: StoredFile) -> None: + file.start_processing() + file.apply_scan(self._scanner.scan(file)) + await uow.commit() + + async def _extract_metadata(self, uow: UnitOfWork, file: StoredFile) -> None: + if not await self._storage.exists(file.stored_name): + file.mark_failed(MISSING_CONTENT_REASON) + else: + file.apply_metadata(await self._collect_metadata(file)) + await uow.commit() + + async def _collect_metadata(self, file: StoredFile) -> dict[str, Any]: + metadata = self._metadata_extractor.base_metadata(file) + + analyzer = self._metadata_extractor.analyzer_for(file.mime_type) + if analyzer is not None: + # Constant memory: the file is consumed chunk by chunk and never + # materialised in full. + async for chunk in self._storage.read_chunks(file.stored_name): + analyzer.feed(chunk) + metadata.update(analyzer.result()) + + return metadata + + async def _raise_alert(self, uow: UnitOfWork, file: StoredFile) -> None: + await uow.alerts.add(self._alert_policy.build(file)) + await uow.commit() diff --git a/backend/src/application/use_cases/upload_file.py b/backend/src/application/use_cases/upload_file.py new file mode 100644 index 00000000..4bf827f3 --- /dev/null +++ b/backend/src/application/use_cases/upload_file.py @@ -0,0 +1,86 @@ +"""Upload a file, persist its record and schedule asynchronous processing.""" + +import logging +from collections.abc import AsyncIterator +from uuid import uuid4 + +from src.application.dto import UploadFileCommand +from src.application.ports import FileProcessingQueue, IdGenerator +from src.domain.entities import StoredFile +from src.domain.errors import EmptyFileError, FileTooLargeError +from src.domain.repositories import UnitOfWorkFactory +from src.domain.services.naming import file_extension, guess_mime_type, sanitize_filename +from src.domain.storage import FileStorage + +logger = logging.getLogger(__name__) + + +class UploadFileUseCase: + def __init__( + self, + *, + uow_factory: UnitOfWorkFactory, + storage: FileStorage, + queue: FileProcessingQueue, + max_upload_size: int, + id_generator: IdGenerator = lambda: str(uuid4()), + ) -> None: + self._uow_factory = uow_factory + self._storage = storage + self._queue = queue + self._max_upload_size = max_upload_size + self._id_generator = id_generator + + async def execute(self, command: UploadFileCommand) -> StoredFile: + # Validate before touching storage: a rejected command must not leave a + # blob behind. + title = StoredFile.normalize_title(command.title) + + file_id = self._id_generator() + original_name = sanitize_filename(command.original_name or "", fallback=file_id) + stored_name = f"{file_id}{file_extension(original_name)}" + + size = await self._store_content(stored_name, command.chunks) + + file = StoredFile.create( + id=file_id, + title=title, + original_name=original_name, + stored_name=stored_name, + mime_type=command.declared_mime_type or guess_mime_type(stored_name), + size=size, + ) + + try: + async with self._uow_factory() as uow: + await uow.files.add(file) + await uow.commit() + except Exception: + # Never leave an orphan blob behind when the row could not be written. + await self._storage.delete(stored_name) + raise + + await self._queue.enqueue_processing(file.id) + return file + + async def _store_content(self, stored_name: str, chunks: AsyncIterator[bytes]) -> int: + """Stream the upload straight to storage, enforcing the size cap as it goes.""" + try: + size = await self._storage.save(stored_name, self._capped(chunks)) + except Exception: + await self._storage.delete(stored_name) + raise + + if size == 0: + await self._storage.delete(stored_name) + raise EmptyFileError() + return size + + async def _capped(self, chunks: AsyncIterator[bytes]) -> AsyncIterator[bytes]: + """Abort as soon as the stream exceeds the limit instead of buffering it all.""" + written = 0 + async for chunk in chunks: + written += len(chunk) + if written > self._max_upload_size: + raise FileTooLargeError(written, self._max_upload_size) + yield chunk diff --git a/backend/src/container.py b/backend/src/container.py new file mode 100644 index 00000000..7d23e08d --- /dev/null +++ b/backend/src/container.py @@ -0,0 +1,116 @@ +"""Composition root. + +The only module allowed to know about every layer at once: it wires concrete +adapters into the use cases. Everything else depends on abstractions. +""" + +from dataclasses import dataclass +from functools import cached_property, lru_cache + +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker + +from src.application.ports import FileProcessingQueue +from src.application.use_cases.manage_files import ( + DeleteFileUseCase, + DownloadFileUseCase, + GetFileUseCase, + ListAlertsUseCase, + ListFilesUseCase, + RenameFileUseCase, +) +from src.application.use_cases.process_file import ProcessFileUseCase +from src.application.use_cases.upload_file import UploadFileUseCase +from src.domain.repositories import UnitOfWork +from src.domain.services.alert_policy import AlertPolicy +from src.domain.services.metadata import MetadataExtractor +from src.domain.services.threat_scanner import ThreatScanner +from src.domain.storage import FileStorage +from src.infrastructure.config import Settings, get_settings +from src.infrastructure.db.engine import create_engine, create_session_factory +from src.infrastructure.db.unit_of_work import SqlAlchemyUnitOfWork +from src.infrastructure.queue.celery_app import celery_app +from src.infrastructure.queue.task_queue import CeleryFileProcessingQueue +from src.infrastructure.storage.local import LocalFileStorage + + +@dataclass +class Container: + settings: Settings + + @cached_property + def engine(self) -> AsyncEngine: + return create_engine(self.settings) + + @cached_property + def session_factory(self) -> async_sessionmaker: + return create_session_factory(self.engine) + + @cached_property + def storage(self) -> FileStorage: + return LocalFileStorage(self.settings.storage_dir, self.settings.download_chunk_size) + + @cached_property + def queue(self) -> FileProcessingQueue: + return CeleryFileProcessingQueue(celery_app) + + @cached_property + def scanner(self) -> ThreatScanner: + return ThreatScanner() + + @cached_property + def metadata_extractor(self) -> MetadataExtractor: + return MetadataExtractor() + + @cached_property + def alert_policy(self) -> AlertPolicy: + return AlertPolicy() + + def unit_of_work(self) -> UnitOfWork: + return SqlAlchemyUnitOfWork(self.session_factory) + + # --- use cases ------------------------------------------------------- + + def upload_file(self) -> UploadFileUseCase: + return UploadFileUseCase( + uow_factory=self.unit_of_work, + storage=self.storage, + queue=self.queue, + max_upload_size=self.settings.max_upload_size, + ) + + def list_files(self) -> ListFilesUseCase: + return ListFilesUseCase(uow_factory=self.unit_of_work) + + def list_alerts(self) -> ListAlertsUseCase: + return ListAlertsUseCase(uow_factory=self.unit_of_work) + + def get_file(self) -> GetFileUseCase: + return GetFileUseCase(uow_factory=self.unit_of_work) + + def rename_file(self) -> RenameFileUseCase: + return RenameFileUseCase(uow_factory=self.unit_of_work) + + def delete_file(self) -> DeleteFileUseCase: + return DeleteFileUseCase(uow_factory=self.unit_of_work, storage=self.storage) + + def download_file(self) -> DownloadFileUseCase: + return DownloadFileUseCase(uow_factory=self.unit_of_work, storage=self.storage) + + def process_file(self) -> ProcessFileUseCase: + return ProcessFileUseCase( + uow_factory=self.unit_of_work, + storage=self.storage, + scanner=self.scanner, + metadata_extractor=self.metadata_extractor, + alert_policy=self.alert_policy, + ) + + async def dispose(self) -> None: + if "engine" in self.__dict__: + await self.engine.dispose() + + +@lru_cache(maxsize=1) +def get_container() -> Container: + """Process-wide singleton: one engine and one connection pool per process.""" + return Container(settings=get_settings()) diff --git a/backend/src/domain/__init__.py b/backend/src/domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/domain/entities.py b/backend/src/domain/entities.py new file mode 100644 index 00000000..b1b0702b --- /dev/null +++ b/backend/src/domain/entities.py @@ -0,0 +1,124 @@ +"""Domain entities. + +These are plain dataclasses: no SQLAlchemy, Pydantic or FastAPI imports. They +are persisted through SQLAlchemy's *imperative* mapping +(:mod:`src.infrastructure.db.mapping`), which keeps the domain free of ORM +concerns while still avoiding a hand-written entity <-> row mapper. + +All state transitions live here as methods, so the rules ("a failed file +requires attention", "renaming trims the title") cannot be bypassed by a caller +that pokes at the attributes directly. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from src.domain.errors import ValidationError +from src.domain.value_objects import AlertLevel, ProcessingStatus, ScanStatus + +MAX_TITLE_LENGTH = 255 +MAX_SCAN_DETAILS_LENGTH = 500 +MAX_ALERT_MESSAGE_LENGTH = 500 + + +@dataclass +class ScanReport: + """Outcome of a threat scan, produced by :class:`~src.domain.services.threat_scanner.ThreatScanner`.""" + + status: ScanStatus + details: str + requires_attention: bool + + +# ``eq=False`` keeps identity-based equality/hashing: entities are identified by +# their id, and SQLAlchemy's identity map requires hashable instances. +# ``repr=False`` avoids touching every attribute (and triggering a lazy load) +# from a log statement. +@dataclass(eq=False, repr=False) +class StoredFile: + """An uploaded file together with its processing state.""" + + id: str + title: str + original_name: str + stored_name: str + mime_type: str + size: int + processing_status: ProcessingStatus = ProcessingStatus.UPLOADED + scan_status: ScanStatus | None = None + scan_details: str | None = None + metadata_json: dict[str, Any] | None = None + requires_attention: bool = False + created_at: datetime | None = None + updated_at: datetime | None = None + + def rename(self, title: str) -> None: + self.title = self.normalize_title(title) + + def start_processing(self) -> None: + self.processing_status = ProcessingStatus.PROCESSING + + def apply_scan(self, report: ScanReport) -> None: + self.scan_status = report.status + self.scan_details = report.details[:MAX_SCAN_DETAILS_LENGTH] + self.requires_attention = report.requires_attention + + def apply_metadata(self, metadata: dict[str, Any]) -> None: + self.metadata_json = metadata + self.processing_status = ProcessingStatus.PROCESSED + + def mark_failed(self, reason: str) -> None: + self.processing_status = ProcessingStatus.FAILED + # A file that already carries a scan verdict keeps it; otherwise the + # scan is considered failed as well. + self.scan_status = self.scan_status or ScanStatus.FAILED + self.scan_details = reason[:MAX_SCAN_DETAILS_LENGTH] + + @property + def has_failed(self) -> bool: + return self.processing_status is ProcessingStatus.FAILED + + @staticmethod + def normalize_title(title: str) -> str: + cleaned = title.strip() + if not cleaned: + raise ValidationError("Title must not be empty") + if len(cleaned) > MAX_TITLE_LENGTH: + raise ValidationError(f"Title must be at most {MAX_TITLE_LENGTH} characters") + return cleaned + + @classmethod + def create( + cls, + *, + id: str, # noqa: A002 - mirrors the persisted column name + title: str, + original_name: str, + stored_name: str, + mime_type: str, + size: int, + ) -> StoredFile: + return cls( + id=id, + title=cls.normalize_title(title), + original_name=original_name, + stored_name=stored_name, + mime_type=mime_type, + size=size, + processing_status=ProcessingStatus.UPLOADED, + ) + + +@dataclass(eq=False, repr=False) +class Alert: + """A notification emitted about a file at the end of the processing pipeline.""" + + file_id: str + level: AlertLevel + message: str + id: int | None = None + created_at: datetime | None = field(default=None) + + def __post_init__(self) -> None: + self.message = self.message[:MAX_ALERT_MESSAGE_LENGTH] diff --git a/backend/src/domain/errors.py b/backend/src/domain/errors.py new file mode 100644 index 00000000..eb94b4ba --- /dev/null +++ b/backend/src/domain/errors.py @@ -0,0 +1,42 @@ +"""Domain-level errors. + +The domain never knows about HTTP, Celery or SQLAlchemy, so it raises its own +exceptions. The presentation layer is responsible for translating them into +transport-specific responses (see ``src.presentation.http.error_handlers``). +""" + + +class DomainError(Exception): + """Base class for every error the domain can raise.""" + + +class NotFoundError(DomainError): + """A requested aggregate does not exist.""" + + +class StoredFileNotFoundError(NotFoundError): + def __init__(self, file_id: str) -> None: + super().__init__("File not found") + self.file_id = file_id + + +class StoredContentNotFoundError(NotFoundError): + def __init__(self, stored_name: str) -> None: + super().__init__("Stored file not found") + self.stored_name = stored_name + + +class ValidationError(DomainError): + """The command violates a business rule.""" + + +class EmptyFileError(ValidationError): + def __init__(self) -> None: + super().__init__("File is empty") + + +class FileTooLargeError(ValidationError): + def __init__(self, size: int, limit: int) -> None: + super().__init__(f"File is larger than the {limit} byte limit") + self.size = size + self.limit = limit diff --git a/backend/src/domain/repositories.py b/backend/src/domain/repositories.py new file mode 100644 index 00000000..e032af89 --- /dev/null +++ b/backend/src/domain/repositories.py @@ -0,0 +1,55 @@ +"""Persistence ports. + +Declared in the domain and implemented in the infrastructure layer, so the +dependency arrow points inwards: use cases depend on these protocols, never on +SQLAlchemy. +""" + +from types import TracebackType +from typing import Protocol + +from src.domain.entities import Alert, StoredFile + + +class FileRepository(Protocol): + async def add(self, file: StoredFile) -> None: ... + + async def get(self, file_id: str) -> StoredFile | None: ... + + async def list_recent(self, *, limit: int, offset: int) -> list[StoredFile]: ... + + async def delete(self, file: StoredFile) -> None: ... + + +class AlertRepository(Protocol): + async def add(self, alert: Alert) -> None: ... + + async def list_recent(self, *, limit: int, offset: int) -> list[Alert]: ... + + +class UnitOfWork(Protocol): + """A transactional scope grouping the repositories. + + Used as an async context manager; leaving the block without an explicit + :meth:`commit` rolls back. + """ + + files: FileRepository + alerts: AlertRepository + + async def __aenter__(self) -> UnitOfWork: ... + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: ... + + async def commit(self) -> None: ... + + async def rollback(self) -> None: ... + + +class UnitOfWorkFactory(Protocol): + def __call__(self) -> UnitOfWork: ... diff --git a/backend/src/domain/services/__init__.py b/backend/src/domain/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/domain/services/alert_policy.py b/backend/src/domain/services/alert_policy.py new file mode 100644 index 00000000..5e657955 --- /dev/null +++ b/backend/src/domain/services/alert_policy.py @@ -0,0 +1,20 @@ +"""Decides which alert a processed file deserves.""" + +from src.domain.entities import Alert, StoredFile +from src.domain.value_objects import AlertLevel + +PROCESSING_FAILED_MESSAGE = "File processing failed" +PROCESSED_SUCCESSFULLY_MESSAGE = "File processed successfully" + + +class AlertPolicy: + def build(self, file: StoredFile) -> Alert: + if file.has_failed: + return Alert(file_id=file.id, level=AlertLevel.CRITICAL, message=PROCESSING_FAILED_MESSAGE) + if file.requires_attention: + return Alert( + file_id=file.id, + level=AlertLevel.WARNING, + message=f"File requires attention: {file.scan_details}", + ) + return Alert(file_id=file.id, level=AlertLevel.INFO, message=PROCESSED_SUCCESSFULLY_MESSAGE) diff --git a/backend/src/domain/services/metadata.py b/backend/src/domain/services/metadata.py new file mode 100644 index 00000000..de4f2063 --- /dev/null +++ b/backend/src/domain/services/metadata.py @@ -0,0 +1,119 @@ +"""Metadata extraction rules. + +The original implementation loaded whole files into memory +(``read_text()`` / ``read_bytes()``) just to count lines, characters and PDF +pages. The analyzers below consume the file as a stream of chunks and keep a +constant amount of state, so peak memory no longer scales with file size while +the produced metadata stays byte-for-byte identical. +""" + +from codecs import getincrementaldecoder +from typing import Any, Protocol, runtime_checkable + +from src.domain.entities import StoredFile +from src.domain.services.naming import file_extension + +TEXT_MIME_PREFIX = "text/" +PDF_MIME_TYPE = "application/pdf" + +# Byte sequence Adobe uses to introduce a page object. Counting it is a rough +# but cheap approximation of the page count - kept from the original code. +_PDF_PAGE_MARKER = b"/Type /Page" + +# The exact set of characters ``str.splitlines()`` treats as a line boundary. +_LINE_BOUNDARIES = frozenset("\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029") + + +@runtime_checkable +class ContentAnalyzer(Protocol): + """Incrementally derives metadata from the raw bytes of a file.""" + + def feed(self, chunk: bytes) -> None: ... + + def result(self) -> dict[str, Any]: ... + + +class TextContentAnalyzer: + """Counts lines and characters exactly like ``len(text.splitlines())``/``len(text)``. + + UTF-8 is decoded incrementally so that a multi-byte character split across + two chunks is still decoded as one character, and a ``\\r\\n`` pair split + across two chunks is still counted as a single line break. + """ + + def __init__(self, encoding: str = "utf-8", errors: str = "ignore") -> None: + self._decoder = getincrementaldecoder(encoding)(errors) + self._chars = 0 + self._lines = 0 + self._pending = "" + + def feed(self, chunk: bytes) -> None: + self._consume(self._decoder.decode(chunk)) + + def _consume(self, text: str) -> None: + if not text: + return + self._chars += len(text) + + buffer = self._pending + text + self._pending = "" + parts = buffer.splitlines(keepends=True) + if not parts: + return + + tail = parts[-1] + # The tail is incomplete when it is not terminated by a boundary, and + # also when it ends with a bare "\r": the next chunk may start with a + # "\n" that turns it into a single CRLF break. + if tail[-1] not in _LINE_BOUNDARIES or tail.endswith("\r"): + self._pending = tail + parts = parts[:-1] + + self._lines += len(parts) + + def result(self) -> dict[str, Any]: + self._consume(self._decoder.decode(b"", True)) + lines = self._lines + (1 if self._pending else 0) + return {"line_count": lines, "char_count": self._chars} + + +class PdfContentAnalyzer: + """Approximates a page count by counting page markers across chunk boundaries.""" + + def __init__(self) -> None: + self._pages = 0 + self._overlap = b"" + + def feed(self, chunk: bytes) -> None: + if not chunk: + return + buffer = self._overlap + chunk + self._pages += buffer.count(_PDF_PAGE_MARKER) + # Keep just enough bytes for a marker that straddles two chunks; a full + # marker can never fit in the overlap, so nothing is counted twice. + self._overlap = buffer[-(len(_PDF_PAGE_MARKER) - 1) :] + + def result(self) -> dict[str, Any]: + return {"approx_page_count": max(self._pages, 1)} + + +class MetadataExtractor: + """Decides *what* to derive from a file; the caller supplies the bytes.""" + + def base_metadata(self, file: StoredFile) -> dict[str, Any]: + return { + "extension": file_extension(file.original_name), + "size_bytes": file.size, + "mime_type": file.mime_type, + } + + def analyzer_for(self, mime_type: str) -> ContentAnalyzer | None: + """Return an analyzer for ``mime_type``, or ``None`` when the bytes are irrelevant. + + Returning ``None`` lets the caller skip reading the file entirely. + """ + if mime_type.startswith(TEXT_MIME_PREFIX): + return TextContentAnalyzer() + if mime_type == PDF_MIME_TYPE: + return PdfContentAnalyzer() + return None diff --git a/backend/src/domain/services/naming.py b/backend/src/domain/services/naming.py new file mode 100644 index 00000000..fd3694dd --- /dev/null +++ b/backend/src/domain/services/naming.py @@ -0,0 +1,31 @@ +"""Filename helpers shared by the domain and the storage adapters.""" + +import mimetypes +from pathlib import PurePosixPath, PureWindowsPath + +# Everything that could let a crafted upload name escape the storage directory +# or poison a Content-Disposition header. +_UNSAFE_CHARS = str.maketrans({"\r": "_", "\n": "_", "\x00": "_", '"': "_"}) +MAX_FILENAME_LENGTH = 255 + + +def file_extension(name: str) -> str: + """Return the lower-cased extension of ``name`` (``".pdf"``, or ``""``). + + Accepts both POSIX and Windows separators because the value comes straight + from a browser's multipart payload. + """ + return PurePosixPath(PureWindowsPath(name).name).suffix.lower() + + +def sanitize_filename(name: str, *, fallback: str) -> str: + """Strip any directory component and control characters from ``name``.""" + base = PurePosixPath(PureWindowsPath(name).name).name.translate(_UNSAFE_CHARS).strip() + if not base or base in {".", ".."}: + return fallback + return base[:MAX_FILENAME_LENGTH] + + +def guess_mime_type(name: str, *, default: str = "application/octet-stream") -> str: + """Best-effort MIME type for a filename, used when the client sends none.""" + return mimetypes.guess_type(name)[0] or default diff --git a/backend/src/domain/services/threat_scanner.py b/backend/src/domain/services/threat_scanner.py new file mode 100644 index 00000000..a27dcc9d --- /dev/null +++ b/backend/src/domain/services/threat_scanner.py @@ -0,0 +1,46 @@ +"""Threat scanning rules. + +Pure business logic: it only needs the declared metadata of an upload, never +its bytes, which is why scanning does not touch the filesystem at all. +""" + +from collections.abc import Iterator +from dataclasses import dataclass, field + +from src.domain.entities import ScanReport, StoredFile +from src.domain.services.naming import file_extension +from src.domain.value_objects import ScanStatus + +DEFAULT_SUSPICIOUS_EXTENSIONS = frozenset({".exe", ".bat", ".cmd", ".sh", ".js"}) +DEFAULT_MAX_SAFE_SIZE = 10 * 1024 * 1024 +DEFAULT_PDF_MIME_TYPES = frozenset({"application/pdf", "application/octet-stream"}) + +NO_THREATS_FOUND = "no threats found" + + +@dataclass(frozen=True) +class ThreatScanner: + suspicious_extensions: frozenset[str] = DEFAULT_SUSPICIOUS_EXTENSIONS + max_safe_size: int = DEFAULT_MAX_SAFE_SIZE + pdf_mime_types: frozenset[str] = DEFAULT_PDF_MIME_TYPES + max_safe_size_label: str = field(default="10 MB") + + def scan(self, file: StoredFile) -> ScanReport: + reasons = list(self._reasons(file)) + return ScanReport( + status=ScanStatus.SUSPICIOUS if reasons else ScanStatus.CLEAN, + details=", ".join(reasons) if reasons else NO_THREATS_FOUND, + requires_attention=bool(reasons), + ) + + def _reasons(self, file: StoredFile) -> Iterator[str]: + extension = file_extension(file.original_name) + + if extension in self.suspicious_extensions: + yield f"suspicious extension {extension}" + + if file.size > self.max_safe_size: + yield f"file is larger than {self.max_safe_size_label}" + + if extension == ".pdf" and file.mime_type not in self.pdf_mime_types: + yield "pdf extension does not match mime type" diff --git a/backend/src/domain/storage.py b/backend/src/domain/storage.py new file mode 100644 index 00000000..0f3dec30 --- /dev/null +++ b/backend/src/domain/storage.py @@ -0,0 +1,31 @@ +"""Binary storage port.""" + +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Protocol + +DEFAULT_CHUNK_SIZE = 1024 * 1024 + + +class FileStorage(Protocol): + async def save(self, stored_name: str, chunks: AsyncIterator[bytes]) -> int: + """Persist ``chunks`` under ``stored_name`` and return the number of bytes written.""" + ... + + def read_chunks(self, stored_name: str, chunk_size: int | None = None) -> AsyncIterator[bytes]: + """Stream the stored object back.""" + ... + + async def delete(self, stored_name: str) -> None: ... + + async def exists(self, stored_name: str) -> bool: ... + + def local_path(self, stored_name: str) -> Path | None: + """Filesystem path of the object, when the adapter is backed by a local disk. + + Purely an optimisation hook: it lets the HTTP layer hand the descriptor + to the kernel (``sendfile``) instead of pumping bytes through Python. + Adapters backed by a remote object store return ``None`` and callers + fall back to :meth:`read_chunks`. + """ + ... diff --git a/backend/src/domain/value_objects.py b/backend/src/domain/value_objects.py new file mode 100644 index 00000000..6b1632a2 --- /dev/null +++ b/backend/src/domain/value_objects.py @@ -0,0 +1,26 @@ +"""Value objects shared across the domain. + +The string values are part of the persisted contract (they are stored verbatim +in Postgres and returned by the public API), so they must not be renamed. +""" + +from enum import StrEnum + + +class ProcessingStatus(StrEnum): + UPLOADED = "uploaded" + PROCESSING = "processing" + PROCESSED = "processed" + FAILED = "failed" + + +class ScanStatus(StrEnum): + CLEAN = "clean" + SUSPICIOUS = "suspicious" + FAILED = "failed" + + +class AlertLevel(StrEnum): + INFO = "info" + WARNING = "warning" + CRITICAL = "critical" diff --git a/backend/src/infrastructure/__init__.py b/backend/src/infrastructure/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/infrastructure/config.py b/backend/src/infrastructure/config.py new file mode 100644 index 00000000..6036e8fb --- /dev/null +++ b/backend/src/infrastructure/config.py @@ -0,0 +1,58 @@ +"""Typed application settings. + +Replaces the scattered ``os.environ.get(...)`` calls, which silently produced a +DSN containing the literal string ``None`` when a variable was missing. +""" + +from functools import lru_cache +from pathlib import Path + +from pydantic import Field, computed_field +from pydantic_settings import BaseSettings, SettingsConfigDict + +BASE_DIR = Path(__file__).resolve().parents[2] + +MEGABYTE = 1024 * 1024 + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False) + + postgres_user: str = "postgres" + postgres_password: str = "postgres" # noqa: S105 - local-dev fallback, overridden by the environment + postgres_db: str = "test" + postgres_host: str = "backend-db" + pgport: int = 5432 + + celery_broker_url: str = "redis://backend-redis:6379/0" + celery_result_backend: str | None = None + + storage_dir: Path = BASE_DIR / "storage" / "files" + max_upload_size: int = Field(default=100 * MEGABYTE, gt=0) + download_chunk_size: int = Field(default=MEGABYTE, gt=0) + + cors_allow_origins: tuple[str, ...] = ("http://localhost:3000", "http://127.0.0.1:3000") + + log_level: str = "INFO" + sql_echo: bool = False + + db_pool_size: int = Field(default=5, gt=0) + db_max_overflow: int = Field(default=10, ge=0) + + @computed_field + @property + def database_url(self) -> str: + return ( + f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}" + f"@{self.postgres_host}:{self.pgport}/{self.postgres_db}" + ) + + @computed_field + @property + def result_backend(self) -> str: + return self.celery_result_backend or self.celery_broker_url + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() diff --git a/backend/src/infrastructure/db/__init__.py b/backend/src/infrastructure/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/infrastructure/db/engine.py b/backend/src/infrastructure/db/engine.py new file mode 100644 index 00000000..a21b05aa --- /dev/null +++ b/backend/src/infrastructure/db/engine.py @@ -0,0 +1,20 @@ +"""Engine and session factory construction.""" + +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine + +from src.infrastructure.config import Settings +from src.infrastructure.db import tables # noqa: F401 - registers the imperative mappings + + +def create_engine(settings: Settings) -> AsyncEngine: + return create_async_engine( + settings.database_url, + echo=settings.sql_echo, + pool_size=settings.db_pool_size, + max_overflow=settings.db_max_overflow, + pool_pre_ping=True, + ) + + +def create_session_factory(engine: AsyncEngine) -> async_sessionmaker: + return async_sessionmaker(engine, expire_on_commit=False, autoflush=True) diff --git a/backend/src/infrastructure/db/repositories.py b/backend/src/infrastructure/db/repositories.py new file mode 100644 index 00000000..2eee27ca --- /dev/null +++ b/backend/src/infrastructure/db/repositories.py @@ -0,0 +1,51 @@ +"""SQLAlchemy implementations of the domain repository ports.""" + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import Alert, StoredFile +from src.infrastructure.db.tables import alerts_table, files_table + + +class SqlAlchemyFileRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def add(self, file: StoredFile) -> None: + self._session.add(file) + + async def get(self, file_id: str) -> StoredFile | None: + return await self._session.get(StoredFile, file_id) + + async def list_recent(self, *, limit: int, offset: int) -> list[StoredFile]: + stmt = ( + select(StoredFile) + # ``id`` breaks ties so that paging cannot show or skip a row twice + # when several uploads share a timestamp. + .order_by(files_table.c.created_at.desc(), files_table.c.id.desc()) + .limit(limit) + .offset(offset) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def delete(self, file: StoredFile) -> None: + await self._session.delete(file) + + +class SqlAlchemyAlertRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def add(self, alert: Alert) -> None: + self._session.add(alert) + + async def list_recent(self, *, limit: int, offset: int) -> list[Alert]: + stmt = ( + select(Alert) + .order_by(alerts_table.c.created_at.desc(), alerts_table.c.id.desc()) + .limit(limit) + .offset(offset) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) diff --git a/backend/src/infrastructure/db/tables.py b/backend/src/infrastructure/db/tables.py new file mode 100644 index 00000000..e431be80 --- /dev/null +++ b/backend/src/infrastructure/db/tables.py @@ -0,0 +1,79 @@ +"""Table definitions and the imperative mapping onto the domain entities. + +Using SQLAlchemy's *imperative* (classical) mapping instead of the declarative +base keeps the persistence schema here and the business rules in +:mod:`src.domain.entities`, without the duplication of a separate ORM model plus +a hand-written mapper. Alembic still autogenerates from ``metadata``. +""" + +from sqlalchemy import ( + JSON, + Boolean, + Column, + DateTime, + ForeignKey, + Index, + Integer, + MetaData, + String, + Table, + func, +) +from sqlalchemy.orm import registry + +from src.domain.entities import Alert, StoredFile +from src.domain.value_objects import AlertLevel, ProcessingStatus, ScanStatus +from src.infrastructure.db.types import StrEnumType + +metadata = MetaData() +mapper_registry = registry(metadata=metadata) + +files_table = Table( + "files", + metadata, + Column("id", String(36), primary_key=True), + Column("title", String(255), nullable=False), + Column("original_name", String(255), nullable=False), + Column("stored_name", String(255), nullable=False, unique=True), + Column("mime_type", String(255), nullable=False), + Column("size", Integer, nullable=False), + Column("processing_status", StrEnumType(ProcessingStatus, 50), nullable=False, default=ProcessingStatus.UPLOADED), + Column("scan_status", StrEnumType(ScanStatus, 50), nullable=True), + Column("scan_details", String(500), nullable=True), + Column("metadata_json", JSON, nullable=True), + Column("requires_attention", Boolean, nullable=False, default=False), + Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), + Column("updated_at", DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False), + # The listing is always "newest first"; without this index every page is a + # full scan plus a sort. + Index("ix_files_created_at_id", "created_at", "id"), +) + +alerts_table = Table( + "alerts", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("file_id", String(36), ForeignKey("files.id", ondelete="CASCADE"), nullable=False), + Column("level", StrEnumType(AlertLevel, 50), nullable=False), + Column("message", String(500), nullable=False), + Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), + # Postgres does not index foreign keys automatically, so deleting a file had + # to scan the whole alerts table to check the constraint. + Index("ix_alerts_file_id", "file_id"), + Index("ix_alerts_created_at_id", "created_at", "id"), +) + + +def configure_mappings() -> None: + """Bind the domain entities to their tables (idempotent).""" + if not mapper_registry.mappers: + # ``eager_defaults`` makes SQLAlchemy fetch server-generated columns + # (created_at / updated_at) via RETURNING as part of the INSERT or + # UPDATE, instead of leaving them expired and needing an extra + # round-trip refresh - which is what the original code paid for on + # every write. + mapper_registry.map_imperatively(StoredFile, files_table, eager_defaults=True) + mapper_registry.map_imperatively(Alert, alerts_table, eager_defaults=True) + + +configure_mappings() diff --git a/backend/src/infrastructure/db/types.py b/backend/src/infrastructure/db/types.py new file mode 100644 index 00000000..81758cbe --- /dev/null +++ b/backend/src/infrastructure/db/types.py @@ -0,0 +1,33 @@ +"""Custom SQLAlchemy types bridging domain value objects and plain columns.""" + +from enum import StrEnum +from typing import Any + +from sqlalchemy import Dialect, String +from sqlalchemy.types import TypeDecorator + + +class StrEnumType(TypeDecorator[StrEnum]): + """Stores a :class:`~enum.StrEnum` as a plain ``VARCHAR``. + + Deliberately not ``sqlalchemy.Enum``: the existing columns are ``VARCHAR`` + and must stay that way, and a native enum would make adding a status a + migration instead of a code change. + """ + + impl = String + cache_ok = True + + def __init__(self, enum_type: type[StrEnum], length: int) -> None: + super().__init__(length=length) + self._enum_type = enum_type + + def process_bind_param(self, value: Any, dialect: Dialect) -> str | None: + if value is None: + return None + return str(self._enum_type(value).value) + + def process_result_value(self, value: Any, dialect: Dialect) -> StrEnum | None: + if value is None: + return None + return self._enum_type(value) diff --git a/backend/src/infrastructure/db/unit_of_work.py b/backend/src/infrastructure/db/unit_of_work.py new file mode 100644 index 00000000..f17c6934 --- /dev/null +++ b/backend/src/infrastructure/db/unit_of_work.py @@ -0,0 +1,58 @@ +"""Transactional scope backed by an ``AsyncSession``.""" + +from types import TracebackType + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.domain.repositories import AlertRepository, FileRepository +from src.infrastructure.db.repositories import SqlAlchemyAlertRepository, SqlAlchemyFileRepository + + +class SqlAlchemyUnitOfWork: + """One session, one transaction, both repositories. + + Leaving the ``async with`` block without committing rolls the transaction + back, so a failing use case can never half-persist an aggregate. + """ + + files: FileRepository + alerts: AlertRepository + + def __init__(self, session_factory: async_sessionmaker) -> None: + self._session_factory = session_factory + self._session: AsyncSession | None = None + + async def __aenter__(self) -> SqlAlchemyUnitOfWork: + self._session = self._session_factory() + self.files = SqlAlchemyFileRepository(self._session) + self.alerts = SqlAlchemyAlertRepository(self._session) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + session = self._require_session() + try: + if exc_type is not None and session.in_transaction(): + await session.rollback() + finally: + # ``close()`` releases the connection, which discards anything that + # was not committed, and - unlike ``rollback()`` - leaves the loaded + # entities readable after they are detached. Use cases return + # entities to the caller, so that difference matters. + await session.close() + self._session = None + + async def commit(self) -> None: + await self._require_session().commit() + + async def rollback(self) -> None: + await self._require_session().rollback() + + def _require_session(self) -> AsyncSession: + if self._session is None: + raise RuntimeError("UnitOfWork must be used inside an 'async with' block") + return self._session diff --git a/backend/src/infrastructure/queue/__init__.py b/backend/src/infrastructure/queue/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/infrastructure/queue/celery_app.py b/backend/src/infrastructure/queue/celery_app.py new file mode 100644 index 00000000..c4898eae --- /dev/null +++ b/backend/src/infrastructure/queue/celery_app.py @@ -0,0 +1,29 @@ +"""Celery application used by the worker and by the producing API process.""" + +from celery import Celery + +from src.infrastructure.config import get_settings + +PROCESS_FILE_TASK = "files.process" + + +def create_celery_app() -> Celery: + settings = get_settings() + app = Celery( + "file_tasks", + broker=settings.celery_broker_url, + backend=settings.result_backend, + include=["src.infrastructure.queue.tasks"], + ) + app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="UTC", + enable_utc=True, + worker_hijack_root_logger=False, + ) + return app + + +celery_app = create_celery_app() diff --git a/backend/src/infrastructure/queue/runner.py b/backend/src/infrastructure/queue/runner.py new file mode 100644 index 00000000..d32e2deb --- /dev/null +++ b/backend/src/infrastructure/queue/runner.py @@ -0,0 +1,30 @@ +"""Bridges Celery's synchronous worker to the async application layer. + +One :class:`asyncio.Runner` is kept alive for the lifetime of the worker +process, so the asyncpg connection pool is reused across tasks instead of being +rebuilt - or, worse, bound to an event loop that has already been closed. +""" + +import asyncio +from collections.abc import Coroutine +from typing import Any + + +class WorkerLoop: + """Owns the worker's event loop; created lazily on the first task.""" + + def __init__(self) -> None: + self._runner: asyncio.Runner | None = None + + def run[T](self, coroutine: Coroutine[Any, Any, T]) -> T: + if self._runner is None: + self._runner = asyncio.Runner() + return self._runner.run(coroutine) + + def close(self) -> None: + if self._runner is not None: + self._runner.close() + self._runner = None + + +worker_loop = WorkerLoop() diff --git a/backend/src/infrastructure/queue/task_queue.py b/backend/src/infrastructure/queue/task_queue.py new file mode 100644 index 00000000..923e3160 --- /dev/null +++ b/backend/src/infrastructure/queue/task_queue.py @@ -0,0 +1,22 @@ +"""Celery-backed implementation of the :class:`~src.application.ports.FileProcessingQueue` port.""" + +from anyio import to_thread +from celery import Celery + +from src.infrastructure.queue.celery_app import PROCESS_FILE_TASK + + +class CeleryFileProcessingQueue: + """Publishes by task *name*, so the API process never imports the task module.""" + + def __init__(self, celery_app: Celery, task_name: str = PROCESS_FILE_TASK) -> None: + self._celery_app = celery_app + self._task_name = task_name + + async def enqueue_processing(self, file_id: str) -> None: + # ``send_task`` talks to the broker over a blocking socket; running it + # on a worker thread keeps the API event loop responsive. + await to_thread.run_sync(self._send, file_id) + + def _send(self, file_id: str) -> None: + self._celery_app.send_task(self._task_name, args=[file_id]) diff --git a/backend/src/infrastructure/queue/tasks.py b/backend/src/infrastructure/queue/tasks.py new file mode 100644 index 00000000..fde23a70 --- /dev/null +++ b/backend/src/infrastructure/queue/tasks.py @@ -0,0 +1,25 @@ +"""Celery tasks: thin adapters that hand off to a use case.""" + +import logging + +from celery.signals import worker_process_shutdown + +from src.container import get_container +from src.infrastructure.queue.celery_app import PROCESS_FILE_TASK, celery_app +from src.infrastructure.queue.runner import worker_loop + +logger = logging.getLogger(__name__) + + +@celery_app.task(name=PROCESS_FILE_TASK) +def process_file(file_id: str) -> None: + """Run the scan -> metadata -> alert pipeline for one uploaded file.""" + worker_loop.run(get_container().process_file().execute(file_id)) + + +@worker_process_shutdown.connect +def _dispose_resources(**_: object) -> None: + try: + worker_loop.run(get_container().dispose()) + finally: + worker_loop.close() diff --git a/backend/src/infrastructure/storage/__init__.py b/backend/src/infrastructure/storage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/infrastructure/storage/local.py b/backend/src/infrastructure/storage/local.py new file mode 100644 index 00000000..0c677423 --- /dev/null +++ b/backend/src/infrastructure/storage/local.py @@ -0,0 +1,64 @@ +"""Filesystem-backed implementation of the :class:`~src.domain.storage.FileStorage` port. + +Every operation is awaited off the event loop (``anyio``), so a slow disk can no +longer stall the whole API process the way the previous blocking +``Path.write_bytes`` / ``Path.exists`` calls did. +""" + +import logging +from collections.abc import AsyncIterator +from pathlib import Path + +import anyio + +from src.domain.storage import DEFAULT_CHUNK_SIZE + +logger = logging.getLogger(__name__) + + +class UnsafeStoredNameError(ValueError): + """Raised when a stored name would resolve outside the storage root.""" + + +class LocalFileStorage: + def __init__(self, root: Path, chunk_size: int = DEFAULT_CHUNK_SIZE) -> None: + self._root = Path(root).resolve() + self._root.mkdir(parents=True, exist_ok=True) + self._chunk_size = chunk_size + + async def save(self, stored_name: str, chunks: AsyncIterator[bytes]) -> int: + path = self._resolve(stored_name) + size = 0 + async with await anyio.open_file(path, "wb") as handle: + async for chunk in chunks: + if not chunk: + continue + await handle.write(chunk) + size += len(chunk) + return size + + async def read_chunks(self, stored_name: str, chunk_size: int | None = None) -> AsyncIterator[bytes]: + path = self._resolve(stored_name) + async with await anyio.open_file(path, "rb") as handle: + while chunk := await handle.read(chunk_size or self._chunk_size): + yield chunk + + async def delete(self, stored_name: str) -> None: + try: + await anyio.Path(self._resolve(stored_name)).unlink(missing_ok=True) + except OSError: + # Losing a blob must not fail the surrounding transaction; the row + # is already gone and the leftover is visible in the logs. + logger.exception("Could not delete stored file %s", stored_name) + + async def exists(self, stored_name: str) -> bool: + return await anyio.Path(self._resolve(stored_name)).is_file() + + def local_path(self, stored_name: str) -> Path | None: + return self._resolve(stored_name) + + def _resolve(self, stored_name: str) -> Path: + candidate = (self._root / stored_name).resolve() + if candidate.parent != self._root: + raise UnsafeStoredNameError(f"Refusing to access {stored_name!r} outside the storage root") + return candidate diff --git a/backend/src/models.py b/backend/src/models.py deleted file mode 100644 index ad5e515b..00000000 --- a/backend/src/models.py +++ /dev/null @@ -1,49 +0,0 @@ -from datetime import datetime - -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, func -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column - - -class Base(DeclarativeBase): - pass - - -class StoredFile(Base): - __tablename__ = "files" - - id: Mapped[str] = mapped_column(String(36), primary_key=True) - title: Mapped[str] = mapped_column(String(255), nullable=False) - original_name: Mapped[str] = mapped_column(String(255), nullable=False) - stored_name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) - mime_type: Mapped[str] = mapped_column(String(255), nullable=False) - size: Mapped[int] = mapped_column(Integer, nullable=False) - processing_status: Mapped[str] = mapped_column(String(50), nullable=False, default="uploaded") - scan_status: Mapped[str | None] = mapped_column(String(50), nullable=True) - scan_details: Mapped[str | None] = mapped_column(String(500), nullable=True) - metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) - requires_attention: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - nullable=False, - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - nullable=False, - ) - - -class Alert(Base): - __tablename__ = "alerts" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - file_id: Mapped[str] = mapped_column(String(36), ForeignKey("files.id"), nullable=False) - level: Mapped[str] = mapped_column(String(50), nullable=False) - message: Mapped[str] = mapped_column(String(500), nullable=False) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - nullable=False, - ) diff --git a/backend/src/presentation/__init__.py b/backend/src/presentation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/presentation/http/__init__.py b/backend/src/presentation/http/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/presentation/http/app.py b/backend/src/presentation/http/app.py new file mode 100644 index 00000000..d1d7e613 --- /dev/null +++ b/backend/src/presentation/http/app.py @@ -0,0 +1,50 @@ +"""FastAPI application factory.""" + +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from src.container import get_container +from src.infrastructure.config import Settings, get_settings +from src.presentation.http.error_handlers import register_error_handlers +from src.presentation.http.routers import alerts, files + + +def configure_logging(settings: Settings) -> None: + logging.basicConfig( + level=settings.log_level.upper(), + format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", + ) + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + yield + await get_container().dispose() + + +def create_app(settings: Settings | None = None) -> FastAPI: + settings = settings or get_settings() + configure_logging(settings) + + app = FastAPI(title="File exchange", version="1.0.0", lifespan=lifespan) + app.add_middleware( + CORSMiddleware, + allow_origins=list(settings.cors_allow_origins), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + register_error_handlers(app) + app.include_router(files.router) + app.include_router(alerts.router) + + @app.get("/health", tags=["ops"]) + async def health() -> dict[str, str]: + return {"status": "ok"} + + return app diff --git a/backend/src/presentation/http/dependencies.py b/backend/src/presentation/http/dependencies.py new file mode 100644 index 00000000..aaa785a8 --- /dev/null +++ b/backend/src/presentation/http/dependencies.py @@ -0,0 +1,65 @@ +"""FastAPI dependency providers. + +Routers ask for a use case, never for a session, an engine or a Celery app. +""" + +from typing import Annotated + +from fastapi import Depends, Query + +from src.application.dto import DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, Page +from src.application.use_cases.manage_files import ( + DeleteFileUseCase, + DownloadFileUseCase, + GetFileUseCase, + ListAlertsUseCase, + ListFilesUseCase, + RenameFileUseCase, +) +from src.application.use_cases.upload_file import UploadFileUseCase +from src.container import Container, get_container + + +def provide_container() -> Container: + return get_container() + + +ContainerDep = Annotated[Container, Depends(provide_container)] + + +def provide_page( + limit: Annotated[int, Query(ge=1, le=MAX_PAGE_LIMIT)] = DEFAULT_PAGE_LIMIT, + offset: Annotated[int, Query(ge=0)] = 0, +) -> Page: + return Page(limit=limit, offset=offset) + + +PageDep = Annotated[Page, Depends(provide_page)] + + +def provide_upload_file(container: ContainerDep) -> UploadFileUseCase: + return container.upload_file() + + +def provide_list_files(container: ContainerDep) -> ListFilesUseCase: + return container.list_files() + + +def provide_list_alerts(container: ContainerDep) -> ListAlertsUseCase: + return container.list_alerts() + + +def provide_get_file(container: ContainerDep) -> GetFileUseCase: + return container.get_file() + + +def provide_rename_file(container: ContainerDep) -> RenameFileUseCase: + return container.rename_file() + + +def provide_delete_file(container: ContainerDep) -> DeleteFileUseCase: + return container.delete_file() + + +def provide_download_file(container: ContainerDep) -> DownloadFileUseCase: + return container.download_file() diff --git a/backend/src/presentation/http/error_handlers.py b/backend/src/presentation/http/error_handlers.py new file mode 100644 index 00000000..1eca5b0f --- /dev/null +++ b/backend/src/presentation/http/error_handlers.py @@ -0,0 +1,37 @@ +"""Translates domain errors into HTTP responses. + +Keeping this mapping in one place is what lets the use cases stay free of +``HTTPException`` - previously the persistence layer raised HTTP errors, which +made it unusable from the Celery worker. +""" + +import logging + +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse + +from src.domain.errors import DomainError, FileTooLargeError, NotFoundError, ValidationError +from src.infrastructure.storage.local import UnsafeStoredNameError + +logger = logging.getLogger(__name__) + + +def _status_for(exc: DomainError) -> int: + if isinstance(exc, NotFoundError): + return status.HTTP_404_NOT_FOUND + if isinstance(exc, FileTooLargeError): + return status.HTTP_413_CONTENT_TOO_LARGE + if isinstance(exc, ValidationError): + return status.HTTP_400_BAD_REQUEST + return status.HTTP_422_UNPROCESSABLE_ENTITY + + +def register_error_handlers(app: FastAPI) -> None: + @app.exception_handler(DomainError) + async def _handle_domain_error(_: Request, exc: DomainError) -> JSONResponse: + return JSONResponse(status_code=_status_for(exc), content={"detail": str(exc)}) + + @app.exception_handler(UnsafeStoredNameError) + async def _handle_unsafe_name(_: Request, exc: UnsafeStoredNameError) -> JSONResponse: + logger.warning("Blocked unsafe storage access: %s", exc) + return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={"detail": "Invalid file reference"}) diff --git a/backend/src/presentation/http/routers/__init__.py b/backend/src/presentation/http/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/presentation/http/routers/alerts.py b/backend/src/presentation/http/routers/alerts.py new file mode 100644 index 00000000..f4d44d6a --- /dev/null +++ b/backend/src/presentation/http/routers/alerts.py @@ -0,0 +1,20 @@ +"""HTTP endpoints for the alert feed.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from src.application.use_cases.manage_files import ListAlertsUseCase +from src.presentation.http.dependencies import PageDep, provide_list_alerts +from src.presentation.http.schemas import AlertItem + +router = APIRouter(prefix="/alerts", tags=["alerts"]) + + +@router.get("", response_model=list[AlertItem]) +async def list_alerts( + page: PageDep, + use_case: Annotated[ListAlertsUseCase, Depends(provide_list_alerts)], +) -> list[AlertItem]: + alerts = await use_case.execute(page) + return [AlertItem.model_validate(alert) for alert in alerts] diff --git a/backend/src/presentation/http/routers/files.py b/backend/src/presentation/http/routers/files.py new file mode 100644 index 00000000..3d58d907 --- /dev/null +++ b/backend/src/presentation/http/routers/files.py @@ -0,0 +1,116 @@ +"""HTTP endpoints for files: parse, delegate, serialise. No business rules here.""" + +from collections.abc import AsyncIterator +from typing import Annotated + +from fastapi import APIRouter, Depends, File, Form, Response, UploadFile, status +from fastapi.responses import FileResponse, StreamingResponse + +from src.application.dto import UploadFileCommand +from src.application.use_cases.manage_files import ( + DeleteFileUseCase, + DownloadFileUseCase, + GetFileUseCase, + ListFilesUseCase, + RenameFileUseCase, +) +from src.application.use_cases.upload_file import UploadFileUseCase +from src.domain.storage import DEFAULT_CHUNK_SIZE +from src.presentation.http.dependencies import ( + PageDep, + provide_delete_file, + provide_download_file, + provide_get_file, + provide_list_files, + provide_rename_file, + provide_upload_file, +) +from src.presentation.http.schemas import ErrorResponse, FileItem, FileUpdate + +router = APIRouter(prefix="/files", tags=["files"]) + +NOT_FOUND: dict[int | str, dict[str, type[ErrorResponse]]] = {404: {"model": ErrorResponse}} + + +async def _iter_upload(upload: UploadFile, chunk_size: int = DEFAULT_CHUNK_SIZE) -> AsyncIterator[bytes]: + """Yield the upload in chunks instead of materialising it in memory.""" + while chunk := await upload.read(chunk_size): + yield chunk + + +@router.get("", response_model=list[FileItem]) +async def list_files( + page: PageDep, + use_case: Annotated[ListFilesUseCase, Depends(provide_list_files)], +) -> list[FileItem]: + files = await use_case.execute(page) + return [FileItem.model_validate(file) for file in files] + + +@router.post("", response_model=FileItem, status_code=status.HTTP_201_CREATED) +async def create_file( + use_case: Annotated[UploadFileUseCase, Depends(provide_upload_file)], + title: Annotated[str, Form(min_length=1, max_length=255)], + file: Annotated[UploadFile, File()], +) -> FileItem: + created = await use_case.execute( + UploadFileCommand( + title=title, + original_name=file.filename, + declared_mime_type=file.content_type, + chunks=_iter_upload(file), + ) + ) + return FileItem.model_validate(created) + + +@router.get("/{file_id}", response_model=FileItem, responses=NOT_FOUND) +async def get_file( + file_id: str, + use_case: Annotated[GetFileUseCase, Depends(provide_get_file)], +) -> FileItem: + return FileItem.model_validate(await use_case.execute(file_id)) + + +@router.patch("/{file_id}", response_model=FileItem, responses=NOT_FOUND) +async def update_file( + file_id: str, + payload: FileUpdate, + use_case: Annotated[RenameFileUseCase, Depends(provide_rename_file)], +) -> FileItem: + return FileItem.model_validate(await use_case.execute(file_id, payload.title)) + + +@router.get("/{file_id}/download", responses=NOT_FOUND) +async def download_file( + file_id: str, + use_case: Annotated[DownloadFileUseCase, Depends(provide_download_file)], +) -> Response: + download = await use_case.execute(file_id) + headers = {"Content-Length": str(download.file.size)} + + if download.local_path is not None: + # Local disk: let the kernel send the file, no bytes through Python. + return FileResponse( + path=download.local_path, + media_type=download.file.mime_type, + filename=download.file.original_name, + ) + + return StreamingResponse( + download.open_stream(), + media_type=download.file.mime_type, + headers={ + **headers, + "Content-Disposition": f'attachment; filename="{download.file.original_name}"', + }, + ) + + +@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT, responses=NOT_FOUND) +async def delete_file( + file_id: str, + use_case: Annotated[DeleteFileUseCase, Depends(provide_delete_file)], +) -> Response: + await use_case.execute(file_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/src/presentation/http/schemas.py b/backend/src/presentation/http/schemas.py new file mode 100644 index 00000000..7062db53 --- /dev/null +++ b/backend/src/presentation/http/schemas.py @@ -0,0 +1,48 @@ +"""Transport models. + +Deliberately separate from the domain entities: the wire format is a contract +with the frontend and must be free to evolve independently of the business +model. The field set is unchanged from the original API. +""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from src.domain.value_objects import AlertLevel, ProcessingStatus, ScanStatus + + +class FileItem(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + title: str + original_name: str + mime_type: str + size: int + processing_status: ProcessingStatus + scan_status: ScanStatus | None + scan_details: str | None + metadata_json: dict[str, Any] | None + requires_attention: bool + created_at: datetime + updated_at: datetime + + +class FileUpdate(BaseModel): + title: str = Field(min_length=1, max_length=255) + + +class AlertItem(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + file_id: str + level: AlertLevel + message: str + created_at: datetime + + +class ErrorResponse(BaseModel): + detail: str diff --git a/backend/src/schemas.py b/backend/src/schemas.py deleted file mode 100644 index 4d639b1f..00000000 --- a/backend/src/schemas.py +++ /dev/null @@ -1,34 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, ConfigDict - - -class FileItem(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: str - title: str - original_name: str - mime_type: str - size: int - processing_status: str - scan_status: str | None - scan_details: str | None - metadata_json: dict | None - requires_attention: bool - created_at: datetime - updated_at: datetime - - -class FileUpdate(BaseModel): - title: str - - -class AlertItem(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - file_id: str - level: str - message: str - created_at: datetime diff --git a/backend/src/service.py b/backend/src/service.py deleted file mode 100644 index e707fdc7..00000000 --- a/backend/src/service.py +++ /dev/null @@ -1,109 +0,0 @@ -import mimetypes -import os -from pathlib import Path -from uuid import uuid4 - -from fastapi import HTTPException, UploadFile, status -from sqlalchemy import select -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker - -from src.models import Alert, StoredFile - - -BASE_DIR = Path(__file__).resolve().parent.parent -STORAGE_DIR = BASE_DIR / "storage" / "files" -STORAGE_DIR.mkdir(parents=True, exist_ok=True) -DB_URL = ( - f"postgresql+asyncpg://{os.environ.get('POSTGRES_USER')}:" - f"{os.environ.get('POSTGRES_PASSWORD')}@{os.environ.get('POSTGRES_HOST')}:" - f"{os.environ.get('PGPORT')}/{os.environ.get('POSTGRES_DB')}" -) -engine = create_async_engine(DB_URL) -async_session_maker = async_sessionmaker(engine, expire_on_commit=False) - - -async def list_files() -> list[StoredFile]: - async with async_session_maker() as session: - result = await session.execute(select(StoredFile).order_by(StoredFile.created_at.desc())) - return list(result.scalars().all()) - - -async def list_alerts() -> list[Alert]: - async with async_session_maker() as session: - result = await session.execute(select(Alert).order_by(Alert.created_at.desc())) - return list(result.scalars().all()) - - -async def get_file(file_id: str) -> StoredFile: - async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") - return file_item - - -async def create_file(title: str, upload_file: UploadFile) -> StoredFile: - content = await upload_file.read() - if not content: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty") - - file_id = str(uuid4()) - suffix = Path(upload_file.filename or "").suffix - stored_name = f"{file_id}{suffix}" - stored_path = STORAGE_DIR / stored_name - stored_path.write_bytes(content) - - file_item = StoredFile( - id=file_id, - title=title, - original_name=upload_file.filename or stored_name, - stored_name=stored_name, - mime_type=upload_file.content_type or mimetypes.guess_type(stored_name)[0] or "application/octet-stream", - size=len(content), - processing_status="uploaded", - ) - async with async_session_maker() as session: - session.add(file_item) - await session.commit() - await session.refresh(file_item) - return file_item - - -async def update_file(file_id: str, title: str) -> StoredFile: - async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") - file_item.title = title - await session.commit() - await session.refresh(file_item) - return file_item - - -async def delete_file(file_id: str) -> None: - async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") - stored_path = STORAGE_DIR / file_item.stored_name - if stored_path.exists(): - stored_path.unlink() - await session.delete(file_item) - await session.commit() - - -async def get_file_path(file_id: str) -> tuple[StoredFile, Path]: - file_item = await get_file(file_id) - stored_path = STORAGE_DIR / file_item.stored_name - if not stored_path.exists(): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stored file not found") - return file_item, stored_path - - -async def create_alert(file_id: str, level: str, message: str) -> Alert: - alert = Alert(file_id=file_id, level=level, message=message) - async with async_session_maker() as session: - session.add(alert) - await session.commit() - await session.refresh(alert) - return alert diff --git a/backend/src/tasks.py b/backend/src/tasks.py deleted file mode 100644 index 4583aded..00000000 --- a/backend/src/tasks.py +++ /dev/null @@ -1,122 +0,0 @@ -import asyncio -import os -from pathlib import Path -from celery import Celery -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker -from src.models import Alert, StoredFile -from src.service import STORAGE_DIR, DB_URL - -REDIS_URL = os.environ.get("REDIS_URL", "redis://backend-redis:6379/0") -_worker_loop: asyncio.AbstractEventLoop | None = None - - -def run_in_worker_loop(coroutine): - global _worker_loop - if _worker_loop is None or _worker_loop.is_closed(): - _worker_loop = asyncio.new_event_loop() - asyncio.set_event_loop(_worker_loop) - return _worker_loop.run_until_complete(coroutine) - - -celery_app = Celery("file_tasks", broker=REDIS_URL, backend=REDIS_URL) -engine = create_async_engine(DB_URL) -async_session_maker = async_sessionmaker(engine, expire_on_commit=False) - - -async def _scan_file_for_threats(file_id: str) -> None: - async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - return - - file_item.processing_status = "processing" - reasons: list[str] = [] - extension = Path(file_item.original_name).suffix.lower() - - if extension in {".exe", ".bat", ".cmd", ".sh", ".js"}: - reasons.append(f"suspicious extension {extension}") - - if file_item.size > 10 * 1024 * 1024: - reasons.append("file is larger than 10 MB") - - if extension == ".pdf" and file_item.mime_type not in {"application/pdf", "application/octet-stream"}: - reasons.append("pdf extension does not match mime type") - - file_item.scan_status = "suspicious" if reasons else "clean" - file_item.scan_details = ", ".join(reasons) if reasons else "no threats found" - file_item.requires_attention = bool(reasons) - await session.commit() - - extract_file_metadata.delay(file_id) - - -async def _extract_file_metadata(file_id: str) -> None: - async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - return - - stored_path = STORAGE_DIR / file_item.stored_name - if not stored_path.exists(): - file_item.processing_status = "failed" - file_item.scan_status = file_item.scan_status or "failed" - file_item.scan_details = "stored file not found during metadata extraction" - await session.commit() - send_file_alert.delay(file_id) - return - - metadata = { - "extension": Path(file_item.original_name).suffix.lower(), - "size_bytes": file_item.size, - "mime_type": file_item.mime_type, - } - - if file_item.mime_type.startswith("text/"): - content = stored_path.read_text(encoding="utf-8", errors="ignore") - metadata["line_count"] = len(content.splitlines()) - metadata["char_count"] = len(content) - elif file_item.mime_type == "application/pdf": - content = stored_path.read_bytes() - metadata["approx_page_count"] = max(content.count(b"/Type /Page"), 1) - - file_item.metadata_json = metadata - file_item.processing_status = "processed" - await session.commit() - - send_file_alert.delay(file_id) - - -async def _send_file_alert(file_id: str) -> None: - async with async_session_maker() as session: - file_item = await session.get(StoredFile, file_id) - if not file_item: - return - - if file_item.processing_status == "failed": - alert = Alert(file_id=file_id, level="critical", message="File processing failed") - elif file_item.requires_attention: - alert = Alert( - file_id=file_id, - level="warning", - message=f"File requires attention: {file_item.scan_details}", - ) - else: - alert = Alert(file_id=file_id, level="info", message="File processed successfully") - - session.add(alert) - await session.commit() - - -@celery_app.task -def scan_file_for_threats(file_id: str) -> None: - run_in_worker_loop(_scan_file_for_threats(file_id)) - - -@celery_app.task -def extract_file_metadata(file_id: str) -> None: - run_in_worker_loop(_extract_file_metadata(file_id)) - - -@celery_app.task -def send_file_alert(file_id: str) -> None: - run_in_worker_loop(_send_file_alert(file_id)) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..25b20433 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,59 @@ +"""Shared fixtures. + +The suite never needs Postgres or Redis: the domain and application layers only +know about ports, so tests plug in SQLite and in-memory doubles. +""" + +import sqlite3 +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from src.container import Container +from src.infrastructure.config import Settings +from src.infrastructure.db.engine import create_session_factory +from src.infrastructure.db.tables import metadata +from src.infrastructure.storage.local import LocalFileStorage +from tests.doubles import RecordingQueue + + +@pytest.fixture +def settings(tmp_path: Path) -> Settings: + return Settings(storage_dir=tmp_path / "files", max_upload_size=1024 * 1024) + + +@pytest.fixture +async def engine(tmp_path: Path) -> AsyncIterator[AsyncEngine]: + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}") + + @event.listens_for(engine.sync_engine, "connect") + def _enable_foreign_keys(connection: sqlite3.Connection, _: object) -> None: + # SQLite ignores foreign keys unless asked, and we want the + # ON DELETE CASCADE behaviour to be exercised here too. + connection.execute("PRAGMA foreign_keys=ON") + + async with engine.begin() as connection: + await connection.run_sync(metadata.create_all) + + yield engine + await engine.dispose() + + +@pytest.fixture +def queue() -> RecordingQueue: + return RecordingQueue() + + +@pytest.fixture +def container(settings: Settings, engine: AsyncEngine, queue: RecordingQueue) -> Container: + container = Container(settings=settings) + # ``cached_property`` reads through ``__dict__``: seeding it swaps the real + # Postgres engine and Celery broker for test doubles without any patching. + container.__dict__["engine"] = engine + container.__dict__["session_factory"] = create_session_factory(engine) + container.__dict__["storage"] = LocalFileStorage(settings.storage_dir) + container.__dict__["queue"] = queue + return container diff --git a/backend/tests/doubles.py b/backend/tests/doubles.py new file mode 100644 index 00000000..3eaa9a7e --- /dev/null +++ b/backend/tests/doubles.py @@ -0,0 +1,115 @@ +"""In-memory implementations of the application ports.""" + +from collections.abc import AsyncIterator +from pathlib import Path +from types import TracebackType + +from src.domain.entities import Alert, StoredFile +from src.domain.repositories import AlertRepository, FileRepository + + +class RecordingQueue: + """A :class:`~src.application.ports.FileProcessingQueue` that just remembers calls.""" + + def __init__(self) -> None: + self.enqueued: list[str] = [] + + async def enqueue_processing(self, file_id: str) -> None: + self.enqueued.append(file_id) + + +class InMemoryStorage: + """A :class:`~src.domain.storage.FileStorage` backed by a dict.""" + + def __init__(self, chunk_size: int = 8) -> None: + self.objects: dict[str, bytes] = {} + self._chunk_size = chunk_size + + async def save(self, stored_name: str, chunks: AsyncIterator[bytes]) -> int: + content = b"" + async for chunk in chunks: + content += chunk + self.objects[stored_name] = content + return len(content) + + async def read_chunks(self, stored_name: str, chunk_size: int | None = None) -> AsyncIterator[bytes]: + content = self.objects[stored_name] + step = chunk_size or self._chunk_size + for start in range(0, len(content), step): + yield content[start : start + step] + + async def delete(self, stored_name: str) -> None: + self.objects.pop(stored_name, None) + + async def exists(self, stored_name: str) -> bool: + return stored_name in self.objects + + def local_path(self, stored_name: str) -> Path | None: + return None + + +async def chunks_of(data: bytes, size: int = 8) -> AsyncIterator[bytes]: + for start in range(0, len(data), size): + yield data[start : start + size] + + +class InMemoryFileRepository: + def __init__(self, store: dict[str, StoredFile]) -> None: + self._store = store + + async def add(self, file: StoredFile) -> None: + self._store[file.id] = file + + async def get(self, file_id: str) -> StoredFile | None: + return self._store.get(file_id) + + async def list_recent(self, *, limit: int, offset: int) -> list[StoredFile]: + return list(self._store.values())[offset : offset + limit] + + async def delete(self, file: StoredFile) -> None: + self._store.pop(file.id, None) + + +class InMemoryAlertRepository: + def __init__(self, store: list[Alert]) -> None: + self._store = store + + async def add(self, alert: Alert) -> None: + self._store.append(alert) + + async def list_recent(self, *, limit: int, offset: int) -> list[Alert]: + return self._store[offset : offset + limit] + + +class FakeUnitOfWork: + """Shares its state across instances so repeated ``uow_factory()`` calls see the same data.""" + + files: FileRepository + alerts: AlertRepository + + def __init__(self, files: dict[str, StoredFile], alerts: list[Alert], *, fail_on_commit: bool = False) -> None: + self._files = files + self._alerts = alerts + self.files = InMemoryFileRepository(files) + self.alerts = InMemoryAlertRepository(alerts) + self.commits = 0 + self.fail_on_commit = fail_on_commit + + async def __aenter__(self) -> FakeUnitOfWork: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None + + async def commit(self) -> None: + if self.fail_on_commit: + raise RuntimeError("commit failed") + self.commits += 1 + + async def rollback(self) -> None: + return None diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/integration/test_api.py b/backend/tests/integration/test_api.py new file mode 100644 index 00000000..19925fc6 --- /dev/null +++ b/backend/tests/integration/test_api.py @@ -0,0 +1,135 @@ +"""HTTP contract tests driven through the real FastAPI app.""" + +from collections.abc import AsyncIterator + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from src.container import Container +from src.presentation.http.app import create_app +from src.presentation.http.dependencies import provide_container +from tests.doubles import RecordingQueue + + +@pytest.fixture +def app(container: Container) -> FastAPI: + app = create_app(container.settings) + app.dependency_overrides[provide_container] = lambda: container + return app + + +@pytest.fixture +async def client(app: FastAPI) -> AsyncIterator[AsyncClient]: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + yield client + + +async def upload(client: AsyncClient, *, title: str = "Doc", name: str = "a.txt", content: bytes = b"hi") -> dict: + response = await client.post( + "/files", + data={"title": title}, + files={"file": (name, content, "text/plain")}, + ) + assert response.status_code == 201, response.text + return response.json() + + +async def test_upload_returns_the_full_file_representation(client: AsyncClient, queue: RecordingQueue) -> None: + body = await upload(client, title="Contract", name="contract.txt", content=b"line\n") + + assert body.keys() == { + "id", + "title", + "original_name", + "mime_type", + "size", + "processing_status", + "scan_status", + "scan_details", + "metadata_json", + "requires_attention", + "created_at", + "updated_at", + } + assert body["title"] == "Contract" + assert body["original_name"] == "contract.txt" + assert body["size"] == 5 + assert body["processing_status"] == "uploaded" + assert queue.enqueued == [body["id"]] + + +async def test_empty_upload_is_a_400(client: AsyncClient) -> None: + response = await client.post("/files", data={"title": "t"}, files={"file": ("e.txt", b"", "text/plain")}) + + assert response.status_code == 400 + assert response.json() == {"detail": "File is empty"} + + +async def test_oversized_upload_is_a_413(client: AsyncClient, container: Container) -> None: + container.settings.max_upload_size = 8 + + response = await client.post("/files", data={"title": "t"}, files={"file": ("big.txt", b"x" * 64, "text/plain")}) + + assert response.status_code == 413 + + +async def test_unknown_file_is_a_404(client: AsyncClient) -> None: + response = await client.get("/files/missing") + + assert response.status_code == 404 + assert response.json() == {"detail": "File not found"} + + +async def test_listing_is_paginated(client: AsyncClient) -> None: + for index in range(3): + await upload(client, name=f"f{index}.txt") + + assert len((await client.get("/files")).json()) == 3 + assert len((await client.get("/files", params={"limit": 2})).json()) == 2 + assert len((await client.get("/files", params={"limit": 2, "offset": 2})).json()) == 1 + + assert (await client.get("/files", params={"limit": 0})).status_code == 422 + + +async def test_rename(client: AsyncClient) -> None: + file = await upload(client) + + response = await client.patch(f"/files/{file['id']}", json={"title": "Renamed"}) + + assert response.status_code == 200 + assert response.json()["title"] == "Renamed" + assert (await client.patch(f"/files/{file['id']}", json={"title": ""})).status_code == 422 + + +async def test_download_returns_the_original_bytes_and_name(client: AsyncClient) -> None: + file = await upload(client, name="report.txt", content=b"payload bytes") + + response = await client.get(f"/files/{file['id']}/download") + + assert response.status_code == 200 + assert response.content == b"payload bytes" + assert "report.txt" in response.headers["content-disposition"] + + +async def test_delete(client: AsyncClient) -> None: + file = await upload(client) + + assert (await client.delete(f"/files/{file['id']}")).status_code == 204 + assert (await client.get(f"/files/{file['id']}")).status_code == 404 + assert (await client.delete(f"/files/{file['id']}")).status_code == 404 + + +async def test_alerts_endpoint(client: AsyncClient, container: Container) -> None: + file = await upload(client, name="setup.exe") + await container.process_file().execute(file["id"]) + + body = (await client.get("/alerts")).json() + + assert len(body) == 1 + assert body[0]["file_id"] == file["id"] + assert body[0]["level"] == "warning" + + +async def test_health(client: AsyncClient) -> None: + assert (await client.get("/health")).json() == {"status": "ok"} diff --git a/backend/tests/integration/test_pipeline.py b/backend/tests/integration/test_pipeline.py new file mode 100644 index 00000000..59db7d02 --- /dev/null +++ b/backend/tests/integration/test_pipeline.py @@ -0,0 +1,126 @@ +"""End-to-end exercise of the real adapters: SQLAlchemy, local disk, use cases.""" + +import pytest + +from src.application.dto import Page, UploadFileCommand +from src.container import Container +from src.domain.errors import StoredContentNotFoundError, StoredFileNotFoundError +from src.domain.value_objects import AlertLevel, ProcessingStatus, ScanStatus +from tests.doubles import chunks_of + + +async def upload(container: Container, *, title: str, name: str, mime: str, content: bytes) -> str: + file = await container.upload_file().execute( + UploadFileCommand(title=title, original_name=name, declared_mime_type=mime, chunks=chunks_of(content)) + ) + return file.id + + +async def test_clean_text_file_is_processed_and_gets_an_info_alert(container: Container) -> None: + file_id = await upload(container, title="Notes", name="notes.txt", mime="text/plain", content=b"alpha\nbeta\ngamma") + + await container.process_file().execute(file_id) + + file = await container.get_file().execute(file_id) + assert file.processing_status is ProcessingStatus.PROCESSED + assert file.scan_status is ScanStatus.CLEAN + assert file.scan_details == "no threats found" + assert file.requires_attention is False + assert file.metadata_json == { + "extension": ".txt", + "size_bytes": 16, + "mime_type": "text/plain", + "line_count": 3, + "char_count": 16, + } + + alerts = await container.list_alerts().execute(Page()) + assert [(a.level, a.message) for a in alerts] == [(AlertLevel.INFO, "File processed successfully")] + + +async def test_suspicious_file_raises_a_warning_alert(container: Container) -> None: + file_id = await upload( + container, title="Installer", name="setup.exe", mime="application/octet-stream", content=b"MZ\x90\x00" + ) + + await container.process_file().execute(file_id) + + file = await container.get_file().execute(file_id) + assert file.scan_status is ScanStatus.SUSPICIOUS + assert file.requires_attention is True + assert file.processing_status is ProcessingStatus.PROCESSED + + (alert,) = await container.list_alerts().execute(Page()) + assert alert.level is AlertLevel.WARNING + assert alert.message == "File requires attention: suspicious extension .exe" + + +async def test_missing_blob_fails_the_file_and_raises_a_critical_alert(container: Container) -> None: + file_id = await upload(container, title="Gone", name="gone.txt", mime="text/plain", content=b"data") + file = await container.get_file().execute(file_id) + await container.storage.delete(file.stored_name) + + await container.process_file().execute(file_id) + + file = await container.get_file().execute(file_id) + assert file.processing_status is ProcessingStatus.FAILED + assert file.scan_details == "stored file not found during metadata extraction" + # The clean verdict from the scan step survives; only processing failed. + assert file.scan_status is ScanStatus.CLEAN + + (alert,) = await container.list_alerts().execute(Page()) + assert alert.level is AlertLevel.CRITICAL + assert alert.message == "File processing failed" + + +async def test_pdf_page_count(container: Container) -> None: + content = b"%PDF-1.4" + b"/Type /Page" * 3 + file_id = await upload(container, title="Doc", name="doc.pdf", mime="application/pdf", content=content) + + await container.process_file().execute(file_id) + + file = await container.get_file().execute(file_id) + assert file.metadata_json is not None + assert file.metadata_json["approx_page_count"] == 3 + + +async def test_processing_an_unknown_file_is_a_no_op(container: Container) -> None: + await container.process_file().execute("does-not-exist") + + assert await container.list_alerts().execute(Page()) == [] + + +async def test_listing_is_newest_first_and_paginated(container: Container) -> None: + ids = [await upload(container, title=f"n{i}", name=f"n{i}.txt", mime="text/plain", content=b"x") for i in range(5)] + + page = await container.list_files().execute(Page(limit=2, offset=0)) + + assert len(page) == 2 + assert {file.id for file in page} <= set(ids) + assert page == sorted(page, key=lambda f: (f.created_at, f.id), reverse=True) + + +async def test_rename_and_delete(container: Container) -> None: + file_id = await upload(container, title="Old", name="a.txt", mime="text/plain", content=b"x") + stored_name = (await container.get_file().execute(file_id)).stored_name + + renamed = await container.rename_file().execute(file_id, " New name ") + assert renamed.title == "New name" + + await container.process_file().execute(file_id) # produces an alert referencing the file + await container.delete_file().execute(file_id) + + assert await container.storage.exists(stored_name) is False + with pytest.raises(StoredFileNotFoundError): + await container.get_file().execute(file_id) + # The FK now cascades, so deleting an already-alerted file no longer fails. + assert await container.list_alerts().execute(Page()) == [] + + +async def test_download_of_a_missing_blob_reports_it(container: Container) -> None: + file_id = await upload(container, title="A", name="a.txt", mime="text/plain", content=b"x") + file = await container.get_file().execute(file_id) + await container.storage.delete(file.stored_name) + + with pytest.raises(StoredContentNotFoundError): + await container.download_file().execute(file_id) diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/unit/test_architecture.py b/backend/tests/unit/test_architecture.py new file mode 100644 index 00000000..4db3ba3c --- /dev/null +++ b/backend/tests/unit/test_architecture.py @@ -0,0 +1,55 @@ +"""Executable version of the dependency rule. + +Clean architecture is only worth something if it is enforced, so the layering +is asserted rather than described: inner layers must not import outer ones. +""" + +import ast +from pathlib import Path + +import pytest + +SRC = Path(__file__).resolve().parents[2] / "src" + +FRAMEWORKS = ("sqlalchemy", "fastapi", "starlette", "celery", "anyio") + +# layer package -> module prefixes it must never import +FORBIDDEN_IMPORTS = { + "domain": (*FRAMEWORKS, "pydantic", "src.application", "src.infrastructure", "src.presentation"), + "application": (*FRAMEWORKS, "src.infrastructure", "src.presentation"), + "infrastructure": ("fastapi", "src.presentation"), +} + + +def imported_modules(path: Path) -> set[str]: + tree = ast.parse(path.read_text()) + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + modules.add(node.module) + return modules + + +def layer_modules(layer: str) -> list[Path]: + return sorted((SRC / layer).rglob("*.py")) + + +@pytest.mark.parametrize("layer", sorted(FORBIDDEN_IMPORTS)) +def test_layer_does_not_depend_on_outer_layers(layer: str) -> None: + forbidden = FORBIDDEN_IMPORTS[layer] + violations = [ + f"{path.relative_to(SRC)} imports {module}" + for path in layer_modules(layer) + for module in imported_modules(path) + if module.startswith(forbidden) + ] + + assert violations == [] + + +def test_every_layer_is_covered() -> None: + packages = {path.name for path in SRC.iterdir() if path.is_dir() and not path.name.startswith("__")} + + assert packages == set(FORBIDDEN_IMPORTS) | {"presentation"} diff --git a/backend/tests/unit/test_metadata.py b/backend/tests/unit/test_metadata.py new file mode 100644 index 00000000..581ecff4 --- /dev/null +++ b/backend/tests/unit/test_metadata.py @@ -0,0 +1,90 @@ +import random + +import pytest + +from src.domain.services.metadata import ( + ContentAnalyzer, + MetadataExtractor, + PdfContentAnalyzer, + TextContentAnalyzer, +) +from tests.unit.test_threat_scanner import make_file + +SAMPLE_TEXTS = [ + "", + "one line", + "one line\n", + "a\nb\nc", + "a\r\nb\r\nc\r\n", + "trailing\r", + "\n\n\n", + "unicode \u00e9\u00e8\u00ea and \u2028 separator", + "form\x0cfeed\x0bvertical", + "mixed\r\n\r\nblank\n\rlines\r", +] + + +def feed_in_chunks(analyzer: ContentAnalyzer, data: bytes, size: int) -> None: + for start in range(0, len(data), size): + analyzer.feed(data[start : start + size]) + + +@pytest.mark.parametrize("text", SAMPLE_TEXTS) +@pytest.mark.parametrize("chunk_size", [1, 2, 3, 7, 4096]) +def test_streaming_text_counts_match_whole_file_counts(text: str, chunk_size: int) -> None: + analyzer = TextContentAnalyzer() + feed_in_chunks(analyzer, text.encode(), chunk_size) + + assert analyzer.result() == {"line_count": len(text.splitlines()), "char_count": len(text)} + + +def test_streaming_matches_on_random_input() -> None: + alphabet = "ab\n\r\u2028\x0c \u00e9" + rng = random.Random(1234) + + for _ in range(200): + text = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 120))) + analyzer = TextContentAnalyzer() + feed_in_chunks(analyzer, text.encode(), rng.randint(1, 8)) + + assert analyzer.result() == {"line_count": len(text.splitlines()), "char_count": len(text)} + + +@pytest.mark.parametrize("chunk_size", [1, 5, 11, 4096]) +def test_pdf_page_markers_are_counted_across_chunk_boundaries(chunk_size: int) -> None: + content = b"%PDF-1.4" + b"/Type /Page" * 7 + b"trailer" + + analyzer = PdfContentAnalyzer() + feed_in_chunks(analyzer, content, chunk_size) + + assert analyzer.result() == {"approx_page_count": content.count(b"/Type /Page")} + + +def test_pdf_without_markers_reports_at_least_one_page() -> None: + analyzer = PdfContentAnalyzer() + analyzer.feed(b"%PDF-1.4 no markers here") + + assert analyzer.result() == {"approx_page_count": 1} + + +def test_base_metadata() -> None: + file = make_file(original_name="Report.TXT", size=42, mime_type="text/plain") + + assert MetadataExtractor().base_metadata(file) == { + "extension": ".txt", + "size_bytes": 42, + "mime_type": "text/plain", + } + + +@pytest.mark.parametrize( + ("mime_type", "expected"), + [ + ("text/plain", TextContentAnalyzer), + ("text/csv", TextContentAnalyzer), + ("application/pdf", PdfContentAnalyzer), + ("image/png", type(None)), + ], +) +def test_analyzer_selection(mime_type: str, expected: type) -> None: + assert isinstance(MetadataExtractor().analyzer_for(mime_type), expected) diff --git a/backend/tests/unit/test_threat_scanner.py b/backend/tests/unit/test_threat_scanner.py new file mode 100644 index 00000000..297143e6 --- /dev/null +++ b/backend/tests/unit/test_threat_scanner.py @@ -0,0 +1,53 @@ +import pytest + +from src.domain.entities import StoredFile +from src.domain.services.threat_scanner import ThreatScanner +from src.domain.value_objects import ScanStatus + + +def make_file(*, original_name: str = "report.txt", size: int = 10, mime_type: str = "text/plain") -> StoredFile: + return StoredFile.create( + id="id", + title="t", + original_name=original_name, + stored_name="stored", + mime_type=mime_type, + size=size, + ) + + +@pytest.mark.parametrize( + ("original_name", "size", "mime_type", "expected_details"), + [ + ("report.txt", 10, "text/plain", "no threats found"), + ("payload.EXE", 10, "application/octet-stream", "suspicious extension .exe"), + ("big.txt", 10 * 1024 * 1024 + 1, "text/plain", "file is larger than 10 MB"), + ("doc.pdf", 10, "text/html", "pdf extension does not match mime type"), + ("doc.pdf", 10, "application/pdf", "no threats found"), + ("doc.pdf", 10, "application/octet-stream", "no threats found"), + ( + "script.sh", + 10 * 1024 * 1024 + 1, + "text/plain", + "suspicious extension .sh, file is larger than 10 MB", + ), + ], +) +def test_scan_details(original_name: str, size: int, mime_type: str, expected_details: str) -> None: + report = ThreatScanner().scan(make_file(original_name=original_name, size=size, mime_type=mime_type)) + + assert report.details == expected_details + assert report.requires_attention is (expected_details != "no threats found") + assert report.status is (ScanStatus.SUSPICIOUS if report.requires_attention else ScanStatus.CLEAN) + + +def test_exactly_at_the_size_limit_is_clean() -> None: + report = ThreatScanner().scan(make_file(size=10 * 1024 * 1024)) + + assert report.status is ScanStatus.CLEAN + + +def test_windows_paths_are_understood() -> None: + report = ThreatScanner().scan(make_file(original_name=r"C:\Users\bob\payload.bat")) + + assert report.details == "suspicious extension .bat" diff --git a/backend/tests/unit/test_upload_file.py b/backend/tests/unit/test_upload_file.py new file mode 100644 index 00000000..a73fa053 --- /dev/null +++ b/backend/tests/unit/test_upload_file.py @@ -0,0 +1,115 @@ +import pytest + +from src.application.dto import UploadFileCommand +from src.application.use_cases.upload_file import UploadFileUseCase +from src.domain.errors import EmptyFileError, FileTooLargeError, ValidationError +from src.domain.value_objects import ProcessingStatus +from tests.doubles import FakeUnitOfWork, InMemoryStorage, RecordingQueue, chunks_of + + +def build_use_case( + *, + storage: InMemoryStorage, + queue: RecordingQueue, + max_upload_size: int = 1024, + uow: FakeUnitOfWork | None = None, +) -> UploadFileUseCase: + uow = uow or FakeUnitOfWork({}, []) + return UploadFileUseCase( + uow_factory=lambda: uow, + storage=storage, + queue=queue, + max_upload_size=max_upload_size, + id_generator=lambda: "file-1", + ) + + +async def test_upload_persists_stores_and_enqueues() -> None: + storage, queue = InMemoryStorage(), RecordingQueue() + uow = FakeUnitOfWork({}, []) + + file = await build_use_case(storage=storage, queue=queue, uow=uow).execute( + UploadFileCommand( + title=" Contract ", + original_name="contract.pdf", + declared_mime_type="application/pdf", + chunks=chunks_of(b"%PDF-1.4 hello"), + ) + ) + + assert file.id == "file-1" + assert file.title == "Contract" + assert file.stored_name == "file-1.pdf" + assert file.size == len(b"%PDF-1.4 hello") + assert file.processing_status is ProcessingStatus.UPLOADED + assert storage.objects["file-1.pdf"] == b"%PDF-1.4 hello" + assert queue.enqueued == ["file-1"] + + +async def test_missing_content_type_is_guessed_from_the_name() -> None: + storage, queue = InMemoryStorage(), RecordingQueue() + + file = await build_use_case(storage=storage, queue=queue).execute( + UploadFileCommand("t", "notes.txt", None, chunks_of(b"hi")) + ) + + assert file.mime_type == "text/plain" + + +async def test_directory_traversal_in_the_filename_is_stripped() -> None: + storage, queue = InMemoryStorage(), RecordingQueue() + + file = await build_use_case(storage=storage, queue=queue).execute( + UploadFileCommand("t", "../../etc/passwd.txt", "text/plain", chunks_of(b"hi")) + ) + + assert file.original_name == "passwd.txt" + assert file.stored_name == "file-1.txt" + + +async def test_empty_upload_is_rejected_and_leaves_nothing_behind() -> None: + storage, queue = InMemoryStorage(), RecordingQueue() + + with pytest.raises(EmptyFileError): + await build_use_case(storage=storage, queue=queue).execute( + UploadFileCommand("t", "empty.txt", "text/plain", chunks_of(b"")) + ) + + assert storage.objects == {} + assert queue.enqueued == [] + + +async def test_oversized_upload_is_aborted_mid_stream() -> None: + storage, queue = InMemoryStorage(), RecordingQueue() + + with pytest.raises(FileTooLargeError): + await build_use_case(storage=storage, queue=queue, max_upload_size=16).execute( + UploadFileCommand("t", "big.txt", "text/plain", chunks_of(b"x" * 128, size=4)) + ) + + assert storage.objects == {} + + +async def test_blank_title_is_rejected_before_anything_is_enqueued() -> None: + storage, queue = InMemoryStorage(), RecordingQueue() + + with pytest.raises(ValidationError): + await build_use_case(storage=storage, queue=queue).execute( + UploadFileCommand(" ", "notes.txt", "text/plain", chunks_of(b"hi")) + ) + + assert storage.objects == {} + assert queue.enqueued == [] + + +async def test_failed_insert_does_not_leave_an_orphan_blob() -> None: + storage, queue = InMemoryStorage(), RecordingQueue() + uow = FakeUnitOfWork({}, [], fail_on_commit=True) + + with pytest.raises(RuntimeError): + await build_use_case(storage=storage, queue=queue, uow=uow).execute( + UploadFileCommand("t", "notes.txt", "text/plain", chunks_of(b"hi")) + ) + + assert storage.objects == {} + assert queue.enqueued == [] diff --git a/backend/uv.lock b/backend/uv.lock index d69f0c19..e605d22a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,7 +1,16 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.14" +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "alembic" version = "1.18.4" @@ -11,9 +20,9 @@ dependencies = [ { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725 } +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893 }, + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, ] [[package]] @@ -23,27 +32,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013 } +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944 }, + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, ] [[package]] name = "annotated-doc" version = "0.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] [[package]] @@ -53,69 +62,93 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "asyncpg" version = "0.31.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667 } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867 }, - { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349 }, - { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428 }, - { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678 }, - { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505 }, - { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744 }, - { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251 }, - { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901 }, - { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280 }, - { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931 }, - { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608 }, - { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738 }, - { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026 }, - { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426 }, - { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495 }, - { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062 }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] [[package]] name = "backend" -version = "0.1.0" +version = "1.0.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "anyio" }, { name = "asyncpg" }, { name = "celery", extra = ["redis"] }, { name = "fastapi" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "python-multipart" }, - { name = "sqlalchemy" }, + { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "aiosqlite" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "ty" }, +] + [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.18.4" }, + { name = "anyio", specifier = ">=4.6.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "celery", extras = ["redis"], specifier = ">=5.6.3" }, { name = "fastapi", specifier = ">=0.135.3" }, { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pydantic-settings", specifier = ">=2.7.0" }, { name = "python-multipart", specifier = ">=0.0.20" }, - { name = "sqlalchemy", specifier = ">=2.0.48" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.48" }, { name = "uvicorn", specifier = ">=0.42.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "aiosqlite", specifier = ">=0.20.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-asyncio", specifier = ">=0.25.0" }, + { name = "ruff", specifier = ">=0.14.0" }, + { name = "ty", specifier = ">=0.0.1a14" }, +] + [[package]] name = "billiard" version = "4.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537 } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070 }, + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, ] [[package]] @@ -133,9 +166,9 @@ dependencies = [ { name = "tzlocal" }, { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235 }, + { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" }, ] [package.optional-dependencies] @@ -143,6 +176,15 @@ redis = [ { name = "kombu", extra = ["redis"] }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -150,9 +192,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -162,9 +204,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089 } +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631 }, + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, ] [[package]] @@ -174,9 +216,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051 }, + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, ] [[package]] @@ -187,18 +229,18 @@ dependencies = [ { name = "click" }, { name = "prompt-toolkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449 } +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289 }, + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -212,52 +254,89 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524 } +sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734 }, + { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, ] [[package]] name = "greenlet" version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650 }, - { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295 }, - { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163 }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371 }, - { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160 }, - { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181 }, - { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713 }, - { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034 }, - { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437 }, - { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617 }, - { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189 }, - { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225 }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581 }, - { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907 }, - { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857 }, - { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010 }, - { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086 }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "idna" version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -270,9 +349,9 @@ dependencies = [ { name = "tzdata" }, { name = "vine" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594 } +sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219 }, + { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, ] [package.optional-dependencies] @@ -287,48 +366,57 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474 } +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 }, + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "packaging" version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -338,9 +426,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] @@ -353,9 +441,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] @@ -365,36 +453,87 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] @@ -404,36 +543,70 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] name = "python-multipart" version = "0.0.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612 } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579 }, + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] [[package]] name = "redis" version = "6.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399 } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847 }, + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] @@ -444,22 +617,27 @@ dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075 } +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401 }, - { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528 }, - { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523 }, - { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312 }, - { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304 }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565 }, - { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205 }, - { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519 }, - { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611 }, - { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326 }, - { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453 }, - { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209 }, - { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198 }, - { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202 }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, ] [[package]] @@ -469,18 +647,43 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289 } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "ty" +version = "0.0.78" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/c7/2ba0861384c5b5097ac354383abb98188112cb330208c21d5197e98a29e5/ty-0.0.78.tar.gz", hash = "sha256:770b45854f85fa11595208f08c0f28df80943164d10a2832d86be6ac29f135b2", size = 7050609, upload-time = "2026-09-02T22:41:33.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651 }, + { url = "https://files.pythonhosted.org/packages/ea/ed/f34cfc06a9ba72979df219e48dae1a0b6a6c74a340763ccd30a547edf913/ty-0.0.78-py3-none-linux_armv6l.whl", hash = "sha256:122700b98f9d45785c1ce91a9418154562e7f64f4bf34679f6f7b38c5b97453f", size = 13304624, upload-time = "2026-09-02T22:40:56.649Z" }, + { url = "https://files.pythonhosted.org/packages/1c/28/5576e2a08b57676d9b2a736d528f077a6c6e32c3d1de2d75dbbe28346966/ty-0.0.78-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cbe7e3709ccf29ef3d9f58f5914cc30ec36fb647008a5a4ff8483abe401bfe8d", size = 12928383, upload-time = "2026-09-02T22:40:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/027d49c3da5235e634da3d3fc52c4874ec89f50830388c9c259f8fb71e0e/ty-0.0.78-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c3528897b3ab9d3589561bc2b5e61a4d5d686de527a4400bb09a439b922a6c70", size = 12730058, upload-time = "2026-09-02T22:41:01.235Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bc/6bf8ffefd8063730a70ae889cf85def72bc155fd1453135ebf8a09c2f1c2/ty-0.0.78-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8bfba4c44a06484093f527b8ef43a317abcf91395b49396170266629eedf74aa", size = 12813321, upload-time = "2026-09-02T22:41:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c0/b3cdf26f82108908d92fdb7ddb741019c446ceb666cf204d4dbf611bde33/ty-0.0.78-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f903c06fdee17baf8373173039ab28f1bce28e66b1c734929a5f50b37eb54d9", size = 13072219, upload-time = "2026-09-02T22:41:05.225Z" }, + { url = "https://files.pythonhosted.org/packages/4d/36/16bfb0abdd178dae11dbc4b57b9a86c2b62642a18c1103fd2c2930aa5879/ty-0.0.78-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd83e5fe3f07291d1bd4e591f8d2607c204f3eab53bcb1b8060c40e876e1aa61", size = 13903958, upload-time = "2026-09-02T22:41:07.333Z" }, + { url = "https://files.pythonhosted.org/packages/0f/0c/74d3b1f0344b13156c719dd68a7edf7e48c2051718c9f326ba3cbf45ea53/ty-0.0.78-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:325285377319cf7168a2b8b771ae5027f411530e3c7eab1faf4583cdd72fd7c9", size = 14355581, upload-time = "2026-09-02T22:41:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d7/7ca0359e1e61b15b8b02328c8d120db3c507876b9b7c6bd9f26935353e0e/ty-0.0.78-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b7846a404da6492697b524a769755ee9bee157d67674063ecd9c5f69dc52ff", size = 14044749, upload-time = "2026-09-02T22:41:11.678Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6a/731f16ff42c5fc96e742c2f4e0a6915356bae114ae02ae4af6f33502175f/ty-0.0.78-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:169d3b9d134c0b48fe1af8a844142d374655552054a9d6c15a4b6e51bb0382ea", size = 13391647, upload-time = "2026-09-02T22:41:13.928Z" }, + { url = "https://files.pythonhosted.org/packages/0c/af/250cc29daf310e837188509ea4d78460c495d8a74c4fb84b4152d9ef2d4f/ty-0.0.78-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6108cb3b2d28dac5981d4e25008a0a5547d8c877a67f6da9e8c38e7e946be44f", size = 13945020, upload-time = "2026-09-02T22:41:15.968Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/96aab1dee4535e66554e8dc7657c69f6c61c181d8e70b9c3527908e93ef4/ty-0.0.78-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:589ad03608d9d2975ef4b23c41c4f847e325bcc7686f51d4c66066b8dbc18a2a", size = 12851149, upload-time = "2026-09-02T22:41:18.06Z" }, + { url = "https://files.pythonhosted.org/packages/85/b0/53d8fe9a847534ef5fe2165c7d5292cb853b29dfd7011cbfb1cdb90a8012/ty-0.0.78-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b3fa0786edc1af06030f872d83499c0cea7c261dbbb651e0aee8306bf2c8a869", size = 13091464, upload-time = "2026-09-02T22:41:20.227Z" }, + { url = "https://files.pythonhosted.org/packages/21/65/94a4e5a02de559f6c6c0ee7a14b88660b4eee058ec6fec5c0ff6ecfbf5cc/ty-0.0.78-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92c5639befc577578c8abd4e5a7fccf98d828db98b09e8d4dd81605dc0e54aef", size = 13389389, upload-time = "2026-09-02T22:41:22.27Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/d0c7fe6be64ca5a15100c4b1f0e39c19660d53bc544f609fa117b346e661/ty-0.0.78-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0dce70bc51652b2775debd1d1e422fb0179f5207c73deb4d5d0aca37e5f61695", size = 13691928, upload-time = "2026-09-02T22:41:24.292Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5d/36081205611ba3fa802efc4ad63e4b1e949bda2f7bb37b34ac9bb6c00db0/ty-0.0.78-py3-none-win32.whl", hash = "sha256:1c80976ca9185d7a9d1baab1fb57240331b8dac58d3b11e46200284086a6de8d", size = 12647346, upload-time = "2026-09-02T22:41:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/f1/89/b925fe1ea1bc56fc7f11d2496e29072fdd2ceb7b389884fc7b2d07cf0c41/ty-0.0.78-py3-none-win_amd64.whl", hash = "sha256:32e82b704471eab34f67b51c151660ca8a00815977b28278905d76fba54f7415", size = 13241810, upload-time = "2026-09-02T22:41:28.857Z" }, + { url = "https://files.pythonhosted.org/packages/91/f1/090ef7b52355bcedfbfbff6ce70fa81ba5bd5f6ed3f8d99ae05e6f2fe75b/ty-0.0.78-py3-none-win_arm64.whl", hash = "sha256:3a14d641a3c04fa9a80f2a46be1531d915f60d4fb79d4b894627bbe46bb35d64", size = 13077433, upload-time = "2026-09-02T22:41:31.525Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] @@ -490,18 +693,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" version = "2026.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639 } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952 }, + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, ] [[package]] @@ -511,9 +714,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761 } +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026 }, + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] [[package]] @@ -524,25 +727,25 @@ dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393 } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830 }, + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] [[package]] name = "vine" version = "5.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980 } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636 }, + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, ] [[package]] name = "wcwidth" version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684 } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189 }, + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c0df035b..62856bbd 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,42 +5,71 @@ services: container_name: backend volumes: - ./backend:/backend + - backend-storage:/backend/storage env_file: ".env.dev" ports: - "8000:8000" depends_on: - - backend-db + backend-db: + condition: service_healthy + backend-redis: + condition: service_healthy backend-worker: build: ./backend - command: [ 'celery', '-A', 'src.tasks.celery_app', 'worker', '-l', 'info' ] + command: [ "celery", "-A", "src.infrastructure.queue.celery_app:celery_app", "worker", "-l", "info" ] container_name: backend-worker env_file: ".env.dev" volumes: - ./backend:/backend + # The worker reads the bytes the API wrote, so both must see the same + # storage volume. + - backend-storage:/backend/storage depends_on: - - backend-db + backend-db: + condition: service_healthy + backend-redis: + condition: service_healthy backend-db: - image: postgres:latest + image: postgres:17 container_name: backend-db ports: - "5433:5433" env_file: ".env.dev" + environment: + # Without PGDATA pointing inside the mount, the volume holds nothing and + # the database is recreated empty on every restart. + PGDATA: /var/lib/postgresql/data/pgdata volumes: - - backend-db-volume:/var/lib/postgresql + - backend-db-volume:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -p $${PGPORT}" ] + interval: 3s + timeout: 3s + retries: 20 backend-redis: - image: redis:latest + image: redis:7-alpine container_name: backend-redis + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 3s + timeout: 3s + retries: 20 frontend: build: context: ./frontend dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_URL: "http://localhost:8000" container_name: frontend ports: - "3000:3000" + depends_on: + - backend volumes: backend-db-volume: + backend-storage: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 73896276..b0c9ef99 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -24,6 +24,11 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +# NEXT_PUBLIC_* values are inlined at build time, so the API origin has to be +# known here rather than at runtime. +ARG NEXT_PUBLIC_API_URL=http://localhost:8000 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL + # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. @@ -48,7 +53,6 @@ RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public -COPY --from=builder /app/.env.production ./.env.production # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing diff --git a/frontend/README.md b/frontend/README.md index 592b4177..2b2f99e3 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,83 +1,63 @@ -# With Docker +# Frontend -This examples shows how to use Docker with Next.js based on the [deployment documentation](https://nextjs.org/docs/deployment#docker-image). Additionally, it contains instructions for deploying to Google Cloud Run. However, you can use any container-based deployment host. +Next.js (App Router) dashboard for the file exchange service. -## How to use +## Layers -Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), [pnpm](https://pnpm.io) or [bun](https://bun.sh/docs/cli/bun-create) to bootstrap the example: +The page used to be a single 400-line `page.tsx` holding types, formatting, +status-to-colour mapping, `fetch` calls, error handling and markup. It is now +split along [Feature-Sliced Design](https://feature-sliced.design) lines, with +imports only ever pointing **downwards**: -```bash -npx create-next-app --example with-docker nextjs-docker -``` - -```bash -yarn create next-app --example with-docker nextjs-docker -``` - -```bash -pnpm create next-app --example with-docker nextjs-docker -``` - -```bash -bun create next-app --example with-docker nextjs-docker ``` - -## Using Docker - -1. [Install Docker](https://docs.docker.com/get-docker/) on your machine. -1. Build your container: - ```bash - # For npm, pnpm or yarn - docker build -t nextjs-docker . - - # For bun - docker build -f Dockerfile.bun -t nextjs-docker . - ``` -1. Run your container: `docker run -p 3000:3000 nextjs-docker`. - -You can view your images created with `docker images`. - -### In existing projects - -To add Docker support, copy [`Dockerfile`](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile) to the project root. If using Bun, copy [`Dockerfile.bun`](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile.bun) instead. Then add the following to next.config.js: - -```js -// next.config.js -module.exports = { - // ... rest of the configuration. - output: "standalone", -}; +app/ Next.js routing and the root layout - nothing else + ↓ +views/ a whole screen: files-dashboard (data flow in model/, layout in ui/) + ↓ +widgets/ self-contained blocks: file-table, alert-table + ↓ +features/ a user action with its own state: upload-file + ↓ +entities/ a business object: file, alert - its type, its API calls, its display rules + ↓ +shared/ reusable and domain-agnostic: http client, config, formatters, UI primitives ``` -This will build the project as a standalone app inside the Docker image. - -## Deploying to Google Cloud Run - -1. Install the [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) so you can use `gcloud` on the command line. -1. Run `gcloud auth login` to log in to your account. -1. [Create a new project](https://cloud.google.com/run/docs/quickstarts/build-and-deploy) in Google Cloud Run (e.g. `nextjs-docker`). Ensure billing is turned on. -1. Build your container image using Cloud Build: `gcloud builds submit --tag gcr.io/PROJECT-ID/helloworld --project PROJECT-ID`. This will also enable Cloud Build for your project. -1. Deploy to Cloud Run: `gcloud run deploy --image gcr.io/PROJECT-ID/helloworld --project PROJECT-ID --platform managed --allow-unauthenticated`. Choose a region of your choice. - - - You will be prompted for the service name: press Enter to accept the default name, `helloworld`. - - You will be prompted for [region](https://cloud.google.com/run/docs/quickstarts/build-and-deploy#follow-cloud-run): select the region of your choice, for example `us-central1`. - -## Running Locally - -First, run the development server: +| Slice | Responsibility | +| --- | --- | +| `shared/config/env.ts` | the API origin, read from `NEXT_PUBLIC_API_URL` instead of `http://localhost:8000` hard-coded in the page | +| `shared/api/http.ts` | the only module that knows how this API reports failures - checks `response.ok`, reads `detail`, throws `ApiError` | +| `shared/lib/format.ts` | `formatDate`, `formatSize` | +| `shared/ui/` | `DataTable`, `SectionCard`, `AsyncSection`, `StatusBadge` - the table/card/spinner markup that was duplicated between the two tables | +| `entities/file`, `entities/alert` | types, endpoint calls, and the status → badge-variant mapping | +| `features/upload-file` | `useUploadFile` owns the form state and the submit workflow; `UploadFileModal` renders it | +| `views/files-dashboard` | `useFilesDashboard` owns loading, errors and refresh; `FilesDashboard` is layout only | + +`@/*` maps to `src/*` (see `tsconfig.json`). + +## Changes beyond the split + +- `strict: true` in `tsconfig.json` (it was `false`), plus `noUncheckedIndexedAccess` + and `noUnusedLocals`. +- The Docker build was broken: it copied `/app/.env.production`, a file that is not + in the repository, so `docker compose build frontend` failed outright. +- `next: "latest"` and the other floating ranges are pinned to the versions in + `package-lock.json`, so a build is reproducible. +- The favicon pointed at `/public/favicon.ico`, which is not a served path. Next + serves `public/favicon.ico` itself, and now does. +- Errors from the upload form are shown inside the modal rather than behind it. +- Processing happens in a background worker, so a freshly uploaded file is still + `uploaded` when the response arrives. The dashboard now polls quietly (2 s) + while any file is unfinished and stops once everything has settled, instead of + leaving the user to press *Обновить*. + +## Development ```bash -npm run dev -# or -yarn dev -# or -bun run dev +npm install +npm run dev # http://localhost:3000/test +npm run typecheck +npm run build ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. - -[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. - -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. +Requires Node 20.9+ (Next 16). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3483508e..d42ea639 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,683 +1,313 @@ { - "name": "frontend", - "lockfileVersion": 3, "requires": true, - "packages": { - "": { - "dependencies": { - "bootstrap": "^5.3.8", - "next": "latest", - "react": "^18.2.0", - "react-bootstrap": "^2.10.10", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@types/node": "^20.11.0", - "typescript": "^5.4.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "license": "MIT", - "optional": true, - "dependencies": { + "lockfileVersion": 1, + "dependencies": { + "@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==" + }, + "@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "optional": true, + "requires": { "tslib": "^2.4.0" } }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } + "@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "optional": true }, - "node_modules/@img/sharp-darwin-arm64": { + "@img/sharp-darwin-arm64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { + "requires": { "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, - "node_modules/@img/sharp-darwin-x64": { + "@img/sharp-darwin-x64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { + "requires": { "@img/sharp-libvips-darwin-x64": "1.2.4" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { + "@img/sharp-libvips-darwin-arm64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-libvips-darwin-x64": { + "@img/sharp-libvips-darwin-x64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-libvips-linux-arm": { + "@img/sharp-libvips-linux-arm": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-libvips-linux-arm64": { + "@img/sharp-libvips-linux-arm64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-libvips-linux-ppc64": { + "@img/sharp-libvips-linux-ppc64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-libvips-linux-riscv64": { + "@img/sharp-libvips-linux-riscv64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-libvips-linux-s390x": { + "@img/sharp-libvips-linux-s390x": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-libvips-linux-x64": { + "@img/sharp-libvips-linux-x64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "@img/sharp-libvips-linuxmusl-arm64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "@img/sharp-libvips-linuxmusl-x64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-linux-arm": { + "@img/sharp-linux-arm": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { + "requires": { "@img/sharp-libvips-linux-arm": "1.2.4" } }, - "node_modules/@img/sharp-linux-arm64": { + "@img/sharp-linux-arm64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { + "requires": { "@img/sharp-libvips-linux-arm64": "1.2.4" } }, - "node_modules/@img/sharp-linux-ppc64": { + "@img/sharp-linux-ppc64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { + "requires": { "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, - "node_modules/@img/sharp-linux-riscv64": { + "@img/sharp-linux-riscv64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { + "requires": { "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, - "node_modules/@img/sharp-linux-s390x": { + "@img/sharp-linux-s390x": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { + "requires": { "@img/sharp-libvips-linux-s390x": "1.2.4" } }, - "node_modules/@img/sharp-linux-x64": { + "@img/sharp-linux-x64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { + "requires": { "@img/sharp-libvips-linux-x64": "1.2.4" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { + "@img/sharp-linuxmusl-arm64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { + "requires": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, - "node_modules/@img/sharp-linuxmusl-x64": { + "@img/sharp-linuxmusl-x64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { + "requires": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, - "node_modules/@img/sharp-wasm32": { + "@img/sharp-wasm32": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, - "dependencies": { + "requires": { "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-win32-arm64": { + "@img/sharp-win32-arm64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-win32-ia32": { + "@img/sharp-win32-ia32": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } + "optional": true }, - "node_modules/@img/sharp-win32-x64": { + "@img/sharp-win32-x64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "optional": true + }, + "@internationalized/date": { + "version": "3.12.4", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.4.tgz", + "integrity": "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==", + "requires": { + "@swc/helpers": "^0.5.0" } }, - "node_modules/@next/env": { + "@internationalized/number": { + "version": "3.6.8", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.8.tgz", + "integrity": "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==", + "requires": { + "@swc/helpers": "^0.5.0" + } + }, + "@internationalized/string": { + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.10.tgz", + "integrity": "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==", + "requires": { + "@swc/helpers": "^0.5.0" + } + }, + "@next/env": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", - "license": "MIT" + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==" }, - "node_modules/@next/swc-darwin-arm64": { + "@next/swc-darwin-arm64": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } + "optional": true }, - "node_modules/@next/swc-darwin-x64": { + "@next/swc-darwin-x64": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } + "optional": true }, - "node_modules/@next/swc-linux-arm64-gnu": { + "@next/swc-linux-arm64-gnu": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } + "optional": true }, - "node_modules/@next/swc-linux-arm64-musl": { + "@next/swc-linux-arm64-musl": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } + "optional": true }, - "node_modules/@next/swc-linux-x64-gnu": { + "@next/swc-linux-x64-gnu": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } + "optional": true }, - "node_modules/@next/swc-linux-x64-musl": { + "@next/swc-linux-x64-musl": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } + "optional": true }, - "node_modules/@next/swc-win32-arm64-msvc": { + "@next/swc-win32-arm64-msvc": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } + "optional": true }, - "node_modules/@next/swc-win32-x64-msvc": { + "@next/swc-win32-x64-msvc": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } + "optional": true }, - "node_modules/@popperjs/core": { + "@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" }, - "node_modules/@react-aria/ssr": { - "version": "3.9.10", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", - "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + "@react-aria/ssr": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.10.1.tgz", + "integrity": "sha512-jn038/ZYmu6DpfXJ6r2U9zFFppjbc9wnApPJSCxao2RZVEqep4YyoniHSy8qv6V21/xyS4IV7W9a+X2jOjSuag==", + "requires": { + "@swc/helpers": "^0.5.0", + "react-aria": "^3.48.0" } }, - "node_modules/@restart/hooks": { + "@react-types/shared": { + "version": "3.36.1", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz", + "integrity": "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==" + }, + "@restart/hooks": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", - "license": "MIT", - "dependencies": { + "requires": { "dequal": "^2.0.3" - }, - "peerDependencies": { - "react": ">=16.8.0" } }, - "node_modules/@restart/ui": { + "@restart/ui": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", - "license": "MIT", - "dependencies": { + "requires": { "@babel/runtime": "^7.26.0", "@popperjs/core": "^2.11.8", "@react-aria/ssr": "^3.5.0", @@ -688,241 +318,165 @@ "uncontrollable": "^8.0.4", "warning": "^4.0.3" }, - "peerDependencies": { - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - } - }, - "node_modules/@restart/ui/node_modules/@restart/hooks": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", - "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", - "license": "MIT", "dependencies": { - "dequal": "^2.0.3" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@restart/ui/node_modules/uncontrollable": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", - "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.14.0" + "@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "requires": { + "dequal": "^2.0.3" + } + }, + "uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==" + } } }, - "node_modules/@swc/helpers": { + "@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { + "requires": { "tslib": "^2.8.0" } }, - "node_modules/@types/node": { + "@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "dev": true, - "license": "MIT", - "dependencies": { + "requires": { "undici-types": "~6.21.0" } }, - "node_modules/@types/prop-types": { + "@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "license": "MIT", - "dependencies": { + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==" + }, + "@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "requires": { + "@types/prop-types": "*", "csstype": "^3.2.2" } }, - "node_modules/@types/react-transition-group": { + "@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true + }, + "@types/react-transition-group": { "version": "4.4.12", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", - "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==" }, - "node_modules/@types/warning": { + "@types/warning": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.4.tgz", - "integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==", - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" + "integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==" + }, + "aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "requires": { + "tslib": "^2.0.0" } }, - "node_modules/bootstrap": { + "baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==" + }, + "bootstrap": { "version": "5.3.8", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", - "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "license": "MIT", - "peerDependencies": { - "@popperjs/core": "^2.11.8" - } + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==" }, - "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" + "caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==" }, - "node_modules/classnames": { + "classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" }, - "node_modules/client-only": { + "client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" }, - "node_modules/csstype": { + "csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, - "node_modules/dequal": { + "dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" }, - "node_modules/detect-libc": { + "detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } + "optional": true }, - "node_modules/dom-helpers": { + "dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { + "requires": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, - "node_modules/invariant": { + "invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "license": "MIT", - "dependencies": { + "requires": { "loose-envify": "^1.0.0" } }, - "node_modules/js-tokens": { + "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, - "node_modules/loose-envify": { + "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { + "requires": { "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } + "nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==" }, - "node_modules/next": { + "next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", - "license": "MIT", - "dependencies": { + "requires": { "@next/env": "16.1.6", - "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": ">=20.9.0" - }, - "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", @@ -931,116 +485,82 @@ "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", - "sharp": "^0.34.4" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "sharp": "^0.34.4", + "styled-jsx": "5.1.6" } }, - "node_modules/object-assign": { + "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, - "node_modules/picocolors": { + "picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, - "node_modules/postcss": { + "postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { + "requires": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" } }, - "node_modules/prop-types": { + "prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { + "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, - "node_modules/prop-types-extra": { + "prop-types-extra": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", - "license": "MIT", - "dependencies": { + "requires": { "react-is": "^16.3.2", "warning": "^4.0.0" - }, - "peerDependencies": { - "react": ">=0.14.0" } }, - "node_modules/react": { + "react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { + "requires": { "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" } }, - "node_modules/react-bootstrap": { + "react-aria": { + "version": "3.52.1", + "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.52.1.tgz", + "integrity": "sha512-fdZZruC9/x/joCg0mhKGs5aHpwrXLCSZ4GOJmhhYyiE0ffyEsk9MLFt9LCOCF9tn8ErTD+UDQP4oDUSlZkmpCg==", + "requires": { + "@internationalized/date": "^3.12.4", + "@internationalized/number": "^3.6.8", + "@internationalized/string": "^3.2.10", + "@react-types/shared": "^3.36.1", + "@swc/helpers": "^0.5.0", + "aria-hidden": "^1.2.3", + "clsx": "^2.0.0", + "react-stately": "3.50.0", + "use-sync-external-store": "^1.6.0" + } + }, + "react-bootstrap": { "version": "2.10.10", "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", - "license": "MIT", - "dependencies": { + "requires": { "@babel/runtime": "^7.24.7", "@restart/hooks": "^0.4.9", "@restart/ui": "^1.9.4", @@ -1054,100 +574,72 @@ "react-transition-group": "^4.4.5", "uncontrollable": "^7.2.1", "warning": "^4.0.3" - }, - "peerDependencies": { - "@types/react": ">=16.14.8", - "react": ">=16.14.0", - "react-dom": ">=16.14.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } } }, - "node_modules/react-dom": { + "react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { + "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" } }, - "node_modules/react-is": { + "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, - "node_modules/react-lifecycles-compat": { + "react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "license": "MIT" - }, - "node_modules/react-transition-group": { + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "react-stately": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.50.0.tgz", + "integrity": "sha512-TnckvpDQGU0672wEaLZqdzvhuSeXWjgW6vijMWxiKOxc8CosQUfPGISdcZyfHqjX3XMjEtRxj4w9HumvX4h4mw==", + "requires": { + "@internationalized/date": "^3.12.4", + "@internationalized/number": "^3.6.8", + "@internationalized/string": "^3.2.10", + "@react-types/shared": "^3.36.1", + "@swc/helpers": "^0.5.0", + "use-sync-external-store": "^1.6.0" + } + }, + "react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { + "requires": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" } }, - "node_modules/scheduler": { + "scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { + "requires": { "loose-envify": "^1.1.0" } }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } + "semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "optional": true }, - "node_modules/sharp": { + "sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", "optional": true, - "dependencies": { + "requires": { "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", @@ -1171,89 +663,72 @@ "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-win32-x64": "0.34.5", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" } }, - "node_modules/source-map-js": { + "source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, - "node_modules/styled-jsx": { + "styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { + "requires": { "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } } }, - "node_modules/tslib": { + "tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "node_modules/typescript": { + "typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } + "dev": true }, - "node_modules/uncontrollable": { + "uncontrollable": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", - "license": "MIT", - "dependencies": { + "requires": { "@babel/runtime": "^7.6.3", "@types/react": ">=16.9.11", "invariant": "^2.2.4", "react-lifecycles-compat": "^3.0.4" }, - "peerDependencies": { - "react": ">=15.0.0" + "dependencies": { + "@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "requires": { + "csstype": "^3.2.2" + } + } } }, - "node_modules/undici-types": { + "undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" + "dev": true + }, + "use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==" }, - "node_modules/warning": { + "warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "license": "MIT", - "dependencies": { + "requires": { "loose-envify": "^1.0.0" } } diff --git a/frontend/package.json b/frontend/package.json index 4a0d27c3..caaf1a05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,17 +2,21 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build" + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit" }, "dependencies": { - "bootstrap": "^5.3.8", - "next": "latest", - "react": "^18.2.0", - "react-bootstrap": "^2.10.10", - "react-dom": "^18.2.0" + "bootstrap": "5.3.8", + "next": "16.1.6", + "react": "18.3.1", + "react-bootstrap": "2.10.10", + "react-dom": "18.3.1" }, "devDependencies": { - "@types/node": "^20.11.0", - "typescript": "^5.4.0" + "@types/node": "20.19.33", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "typescript": "5.9.3" } } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 264ad6ab..e91a3817 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,27 +1,18 @@ import type { Metadata } from "next"; -import 'bootstrap/dist/css/bootstrap.min.css'; +import "bootstrap/dist/css/bootstrap.min.css"; import { Container } from "react-bootstrap"; -export async function generateMetadata(): Promise { - return { - title: 'Тестовое задание Fullstack', - description: 'Тестовое задание Fullstack', - }; -} +export const metadata: Metadata = { + title: "Тестовое задание Fullstack", + description: "Управление файлами: загрузка, проверка и лента алертов", +}; -export default async function RootLayout({ - children -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( - - - - + - - {children} + + {children} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 8f420e2e..e6dbae52 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,367 +1,5 @@ -"use client"; - -import { FormEvent, useEffect, useState } from "react"; -import { - Alert, - Badge, - Button, - Card, - Col, - Container, - Form, - Modal, - Row, - Spinner, - Table, -} from "react-bootstrap"; - -type FileItem = { - id: string; - title: string; - original_name: string; - mime_type: string; - size: number; - processing_status: string; - scan_status: string | null; - scan_details: string | null; - metadata_json: Record | null; - requires_attention: boolean; - created_at: string; - updated_at: string; -}; - -type AlertItem = { - id: number; - file_id: string; - level: string; - message: string; - created_at: string; -}; - - -function formatDate(value: string) { - return new Intl.DateTimeFormat("ru-RU", { - dateStyle: "short", - timeStyle: "short", - }).format(new Date(value)); -} - -function formatSize(size: number) { - if (size < 1024) { - return `${size} B`; - } - - if (size < 1024 * 1024) { - return `${(size / 1024).toFixed(1)} KB`; - } - - return `${(size / (1024 * 1024)).toFixed(1)} MB`; -} - -function getLevelVariant(level: string) { - if (level === "critical") { - return "danger"; - } - - if (level === "warning") { - return "warning"; - } - - return "success"; -} - -function getProcessingVariant(status: string) { - if (status === "failed") { - return "danger"; - } - - if (status === "processing") { - return "warning"; - } - - if (status === "processed") { - return "success"; - } - - return "secondary"; -} +import { FilesDashboard } from "@/views/files-dashboard/ui/FilesDashboard"; export default function Page() { - const [files, setFiles] = useState([]); - const [alerts, setAlerts] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); - const [showModal, setShowModal] = useState(false); - const [title, setTitle] = useState(""); - const [selectedFile, setSelectedFile] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - - async function loadData() { - setIsLoading(true); - setErrorMessage(null); - - try { - const [filesResponse, alertsResponse] = await Promise.all([ - fetch(`http://localhost:8000/files`, { cache: "no-store" }), - fetch(`http://localhost:8000/alerts`, { cache: "no-store" }), - ]); - - if (!filesResponse.ok || !alertsResponse.ok) { - throw new Error("Не удалось загрузить данные"); - } - - const [filesData, alertsData] = await Promise.all([ - filesResponse.json() as Promise, - alertsResponse.json() as Promise, - ]); - - setFiles(filesData); - setAlerts(alertsData); - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : "Произошла ошибка"); - } finally { - setIsLoading(false); - } - } - - useEffect(() => { - void loadData(); - }, []); - - async function handleSubmit(event: FormEvent) { - event.preventDefault(); - - if (!title.trim() || !selectedFile) { - setErrorMessage("Укажите название и выберите файл"); - return; - } - - setIsSubmitting(true); - setErrorMessage(null); - - const formData = new FormData(); - formData.append("title", title.trim()); - formData.append("file", selectedFile); - - try { - const response = await fetch(`http://localhost:8000/files`, { - method: "POST", - body: formData, - }); - - if (!response.ok) { - throw new Error("Не удалось загрузить файл"); - } - - setShowModal(false); - setTitle(""); - setSelectedFile(null); - await loadData(); - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : "Произошла ошибка"); - } finally { - setIsSubmitting(false); - } - } - - return ( - - - - - -
-
-

Управление файлами

-

- Загрузка файлов, просмотр статусов обработки и ленты алертов. -

-
-
- - -
-
-
-
- - {errorMessage ? ( - - {errorMessage} - - ) : null} - - - -
-

Файлы

- {files.length} -
-
- - {isLoading ? ( -
- -
- ) : ( -
- - - - - - - - - - - - - - - {files.length === 0 ? ( - - - - ) : ( - files.map((file) => ( - - - - - - - - - - - )) - )} - -
НазваниеФайлMIMEРазмерСтатусПроверкаСоздан
- Файлы пока не загружены -
-
{file.title}
-
{file.id}
-
{file.original_name}{file.mime_type}{formatSize(file.size)} - - {file.processing_status} - - -
- - {file.scan_status ?? "pending"} - - - {file.scan_details ?? "Ожидает обработки"} - -
-
{formatDate(file.created_at)} - -
-
- )} -
-
- - - -
-

Алерты

- {alerts.length} -
-
- - {isLoading ? ( -
- -
- ) : ( -
- - - - - - - - - - - - {alerts.length === 0 ? ( - - - - ) : ( - alerts.map((item) => ( - - - - - - - - )) - )} - -
IDFile IDУровеньСообщениеСоздан
- Алертов пока нет -
{item.id}{item.file_id} - {item.level} - {item.message}{formatDate(item.created_at)}
-
- )} -
-
- -
- - setShowModal(false)} centered> -
- - Добавить файл - - - - Название - setTitle(event.target.value)} - placeholder="Например, Договор с подрядчиком" - /> - - - Файл - - setSelectedFile((event.target as HTMLInputElement).files?.[0] ?? null) - } - /> - - - - - - -
-
-
- ); + return ; } diff --git a/frontend/src/entities/alert/api/alertApi.ts b/frontend/src/entities/alert/api/alertApi.ts new file mode 100644 index 00000000..d49e7d60 --- /dev/null +++ b/frontend/src/entities/alert/api/alertApi.ts @@ -0,0 +1,10 @@ +import type { AlertItem } from "@/entities/alert/model/types"; +import { request } from "@/shared/api/http"; + +export const DEFAULT_PAGE_SIZE = 100; + +export function fetchAlerts(limit = DEFAULT_PAGE_SIZE): Promise { + return request(`/alerts?limit=${limit}`, { + fallbackError: "Не удалось загрузить данные", + }); +} diff --git a/frontend/src/entities/alert/model/level.ts b/frontend/src/entities/alert/model/level.ts new file mode 100644 index 00000000..fe818fe0 --- /dev/null +++ b/frontend/src/entities/alert/model/level.ts @@ -0,0 +1,12 @@ +import type { AlertLevel } from "@/entities/alert/model/types"; +import type { BadgeVariant } from "@/shared/ui/StatusBadge"; + +const LEVEL_VARIANTS: Record = { + critical: "danger", + warning: "warning", + info: "success", +}; + +export function getLevelVariant(level: AlertLevel): BadgeVariant { + return LEVEL_VARIANTS[level] ?? "success"; +} diff --git a/frontend/src/entities/alert/model/types.ts b/frontend/src/entities/alert/model/types.ts new file mode 100644 index 00000000..89aebf06 --- /dev/null +++ b/frontend/src/entities/alert/model/types.ts @@ -0,0 +1,9 @@ +export type AlertLevel = "info" | "warning" | "critical"; + +export type AlertItem = { + id: number; + file_id: string; + level: AlertLevel; + message: string; + created_at: string; +}; diff --git a/frontend/src/entities/file/api/fileApi.ts b/frontend/src/entities/file/api/fileApi.ts new file mode 100644 index 00000000..0e4d484b --- /dev/null +++ b/frontend/src/entities/file/api/fileApi.ts @@ -0,0 +1,42 @@ +import type { FileItem } from "@/entities/file/model/types"; +import { apiUrl, request } from "@/shared/api/http"; + +export const DEFAULT_PAGE_SIZE = 100; + +export function fetchFiles(limit = DEFAULT_PAGE_SIZE): Promise { + return request(`/files?limit=${limit}`, { + fallbackError: "Не удалось загрузить данные", + }); +} + +export function uploadFile(title: string, file: File): Promise { + const formData = new FormData(); + formData.append("title", title); + formData.append("file", file); + + return request("/files", { + method: "POST", + body: formData, + fallbackError: "Не удалось загрузить файл", + }); +} + +export function renameFile(id: string, title: string): Promise { + return request(`/files/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + fallbackError: "Не удалось переименовать файл", + }); +} + +export function deleteFile(id: string): Promise { + return request(`/files/${id}`, { + method: "DELETE", + fallbackError: "Не удалось удалить файл", + }); +} + +export function downloadUrl(id: string): string { + return apiUrl(`/files/${id}/download`); +} diff --git a/frontend/src/entities/file/model/status.ts b/frontend/src/entities/file/model/status.ts new file mode 100644 index 00000000..ab59aaed --- /dev/null +++ b/frontend/src/entities/file/model/status.ts @@ -0,0 +1,22 @@ +import type { BadgeVariant } from "@/shared/ui/StatusBadge"; +import type { FileItem, ProcessingStatus } from "@/entities/file/model/types"; + +const PROCESSING_VARIANTS: Record = { + failed: "danger", + processing: "warning", + processed: "success", + uploaded: "secondary", +}; + +export function getProcessingVariant(status: ProcessingStatus): BadgeVariant { + return PROCESSING_VARIANTS[status] ?? "secondary"; +} + +export function getScanVariant(file: FileItem): BadgeVariant { + return file.requires_attention ? "warning" : "success"; +} + +/** A file the backend is still working on; the dashboard keeps polling these. */ +export function isPending(file: FileItem): boolean { + return file.processing_status !== "processed" && file.processing_status !== "failed"; +} diff --git a/frontend/src/entities/file/model/types.ts b/frontend/src/entities/file/model/types.ts new file mode 100644 index 00000000..51f9a333 --- /dev/null +++ b/frontend/src/entities/file/model/types.ts @@ -0,0 +1,17 @@ +export type ProcessingStatus = "uploaded" | "processing" | "processed" | "failed"; +export type ScanStatus = "clean" | "suspicious" | "failed"; + +export type FileItem = { + id: string; + title: string; + original_name: string; + mime_type: string; + size: number; + processing_status: ProcessingStatus; + scan_status: ScanStatus | null; + scan_details: string | null; + metadata_json: Record | null; + requires_attention: boolean; + created_at: string; + updated_at: string; +}; diff --git a/frontend/src/features/upload-file/model/useUploadFile.ts b/frontend/src/features/upload-file/model/useUploadFile.ts new file mode 100644 index 00000000..9c239304 --- /dev/null +++ b/frontend/src/features/upload-file/model/useUploadFile.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useCallback, useState } from "react"; + +import { uploadFile } from "@/entities/file/api/fileApi"; +import { toMessage } from "@/shared/api/http"; + +type Options = { onUploaded: () => Promise | void }; + +/** + * Owns the upload form state and the submit workflow; the modal below is a + * pure rendering of what this hook exposes. + */ +export function useUploadFile({ onUploaded }: Options) { + const [isOpen, setIsOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [title, setTitle] = useState(""); + const [file, setFile] = useState(null); + const [error, setError] = useState(null); + + const reset = useCallback(() => { + setTitle(""); + setFile(null); + setError(null); + }, []); + + const open = useCallback(() => setIsOpen(true), []); + + const close = useCallback(() => { + setIsOpen(false); + reset(); + }, [reset]); + + const submit = useCallback(async () => { + if (!title.trim() || !file) { + setError("Укажите название и выберите файл"); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + await uploadFile(title.trim(), file); + setIsOpen(false); + reset(); + await onUploaded(); + } catch (cause) { + setError(toMessage(cause)); + } finally { + setIsSubmitting(false); + } + }, [file, onUploaded, reset, title]); + + return { isOpen, isSubmitting, title, file, error, open, close, setTitle, setFile, submit }; +} diff --git a/frontend/src/features/upload-file/ui/UploadFileModal.tsx b/frontend/src/features/upload-file/ui/UploadFileModal.tsx new file mode 100644 index 00000000..2616591f --- /dev/null +++ b/frontend/src/features/upload-file/ui/UploadFileModal.tsx @@ -0,0 +1,49 @@ +"use client"; + +import type { FormEvent } from "react"; +import { Alert, Button, Form, Modal } from "react-bootstrap"; + +import type { useUploadFile } from "@/features/upload-file/model/useUploadFile"; + +export function UploadFileModal({ upload }: { upload: ReturnType }) { + function handleSubmit(event: FormEvent) { + event.preventDefault(); + void upload.submit(); + } + + return ( + +
+ + Добавить файл + + + {upload.error ? {upload.error} : null} + + Название + upload.setTitle(event.target.value)} + placeholder="Например, Договор с подрядчиком" + /> + + + Файл + upload.setFile((event.target as HTMLInputElement).files?.[0] ?? null)} + /> + + + + + + +
+
+ ); +} diff --git a/frontend/src/shared/api/http.ts b/frontend/src/shared/api/http.ts new file mode 100644 index 00000000..33c86490 --- /dev/null +++ b/frontend/src/shared/api/http.ts @@ -0,0 +1,51 @@ +import { API_BASE_URL } from "@/shared/config/env"; + +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "ApiError"; + } +} + +type ErrorBody = { detail?: unknown }; + +async function readErrorMessage(response: Response, fallback: string): Promise { + try { + const body = (await response.json()) as ErrorBody; + return typeof body.detail === "string" ? body.detail : fallback; + } catch { + return fallback; + } +} + +/** + * The single place that knows how this API reports failures, so no caller has + * to remember to check `response.ok`. + */ +export async function request( + path: string, + { fallbackError = "Запрос не удался", ...init }: RequestInit & { fallbackError?: string } = {}, +): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { cache: "no-store", ...init }); + + if (!response.ok) { + throw new ApiError(await readErrorMessage(response, fallbackError), response.status); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; +} + +export function apiUrl(path: string): string { + return `${API_BASE_URL}${path}`; +} + +export function toMessage(error: unknown, fallback = "Произошла ошибка"): string { + return error instanceof Error ? error.message : fallback; +} diff --git a/frontend/src/shared/config/env.ts b/frontend/src/shared/config/env.ts new file mode 100644 index 00000000..50b3f114 --- /dev/null +++ b/frontend/src/shared/config/env.ts @@ -0,0 +1,11 @@ +/** + * Runtime configuration. + * + * The API origin used to be hard-coded in the page component, which made the + * app impossible to deploy anywhere but a developer's laptop. + */ +export const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ?? "http://localhost:8000"; + +/** How often the dashboard re-checks files that are still being processed. */ +export const PROCESSING_POLL_INTERVAL_MS = 2000; diff --git a/frontend/src/shared/lib/format.ts b/frontend/src/shared/lib/format.ts new file mode 100644 index 00000000..56adc5e4 --- /dev/null +++ b/frontend/src/shared/lib/format.ts @@ -0,0 +1,25 @@ +const DATE_FORMAT = new Intl.DateTimeFormat("ru-RU", { + dateStyle: "short", + timeStyle: "short", +}); + +export function formatDate(value: string): string { + return DATE_FORMAT.format(new Date(value)); +} + +const KB = 1024; +const MB = KB * 1024; +const GB = MB * 1024; + +export function formatSize(size: number): string { + if (size < KB) { + return `${size} B`; + } + if (size < MB) { + return `${(size / KB).toFixed(1)} KB`; + } + if (size < GB) { + return `${(size / MB).toFixed(1)} MB`; + } + return `${(size / GB).toFixed(1)} GB`; +} diff --git a/frontend/src/shared/ui/AsyncSection.tsx b/frontend/src/shared/ui/AsyncSection.tsx new file mode 100644 index 00000000..9056b8d8 --- /dev/null +++ b/frontend/src/shared/ui/AsyncSection.tsx @@ -0,0 +1,14 @@ +import { Spinner } from "react-bootstrap"; + +/** Renders a spinner while loading, otherwise the children. */ +export function AsyncSection({ isLoading, children }: { isLoading: boolean; children: React.ReactNode }) { + if (isLoading) { + return ( +
+ +
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/shared/ui/DataTable.tsx b/frontend/src/shared/ui/DataTable.tsx new file mode 100644 index 00000000..f636272a --- /dev/null +++ b/frontend/src/shared/ui/DataTable.tsx @@ -0,0 +1,35 @@ +import { Table } from "react-bootstrap"; + +type Props = { + columns: string[]; + rows: T[]; + emptyMessage: string; + renderRow: (row: T) => React.ReactNode; +}; + +export function DataTable({ columns, rows, emptyMessage, renderRow }: Props) { + return ( +
+ + + + {columns.map((column, index) => ( + + ))} + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map(renderRow) + )} + +
{column}
+ {emptyMessage} +
+
+ ); +} diff --git a/frontend/src/shared/ui/SectionCard.tsx b/frontend/src/shared/ui/SectionCard.tsx new file mode 100644 index 00000000..0a8cd315 --- /dev/null +++ b/frontend/src/shared/ui/SectionCard.tsx @@ -0,0 +1,22 @@ +import { Badge, Card } from "react-bootstrap"; + +type Props = { + title: string; + count?: number; + children: React.ReactNode; + className?: string; +}; + +export function SectionCard({ title, count, children, className }: Props) { + return ( + + +
+

{title}

+ {count !== undefined ? {count} : null} +
+
+ {children} +
+ ); +} diff --git a/frontend/src/shared/ui/StatusBadge.tsx b/frontend/src/shared/ui/StatusBadge.tsx new file mode 100644 index 00000000..0adfac30 --- /dev/null +++ b/frontend/src/shared/ui/StatusBadge.tsx @@ -0,0 +1,7 @@ +import { Badge } from "react-bootstrap"; + +export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info"; + +export function StatusBadge({ variant, children }: { variant: BadgeVariant; children: React.ReactNode }) { + return {children}; +} diff --git a/frontend/src/views/files-dashboard/model/useFilesDashboard.ts b/frontend/src/views/files-dashboard/model/useFilesDashboard.ts new file mode 100644 index 00000000..d1c25140 --- /dev/null +++ b/frontend/src/views/files-dashboard/model/useFilesDashboard.ts @@ -0,0 +1,75 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { fetchAlerts } from "@/entities/alert/api/alertApi"; +import type { AlertItem } from "@/entities/alert/model/types"; +import { fetchFiles } from "@/entities/file/api/fileApi"; +import { isPending } from "@/entities/file/model/status"; +import type { FileItem } from "@/entities/file/model/types"; +import { toMessage } from "@/shared/api/http"; +import { PROCESSING_POLL_INTERVAL_MS } from "@/shared/config/env"; + +/** + * All of the dashboard's data flow lives here, so the components below stay + * declarative: they render what they are given and raise events. + */ +export function useFilesDashboard() { + const [files, setFiles] = useState([]); + const [alerts, setAlerts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const isMounted = useRef(true); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + const load = useCallback(async ({ silent = false }: { silent?: boolean } = {}) => { + if (!silent) { + setIsLoading(true); + } + setError(null); + + try { + const [nextFiles, nextAlerts] = await Promise.all([fetchFiles(), fetchAlerts()]); + if (!isMounted.current) { + return; + } + setFiles(nextFiles); + setAlerts(nextAlerts); + } catch (cause) { + if (isMounted.current) { + setError(toMessage(cause)); + } + } finally { + if (isMounted.current) { + setIsLoading(false); + } + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + // Processing happens in a Celery worker, so a freshly uploaded file reaches + // its final status a moment after the upload response. Refresh quietly until + // everything has settled instead of making the user press "Обновить". + const hasPending = files.some(isPending); + useEffect(() => { + if (!hasPending) { + return; + } + + const timer = setTimeout(() => void load({ silent: true }), PROCESSING_POLL_INTERVAL_MS); + return () => clearTimeout(timer); + }, [hasPending, files, load]); + + const refresh = useCallback(() => void load(), [load]); + + return { files, alerts, isLoading, error, refresh, reload: load }; +} diff --git a/frontend/src/views/files-dashboard/ui/FilesDashboard.tsx b/frontend/src/views/files-dashboard/ui/FilesDashboard.tsx new file mode 100644 index 00000000..1f1cfa87 --- /dev/null +++ b/frontend/src/views/files-dashboard/ui/FilesDashboard.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Alert, Button, Card, Col, Container, Row } from "react-bootstrap"; + +import { useUploadFile } from "@/features/upload-file/model/useUploadFile"; +import { UploadFileModal } from "@/features/upload-file/ui/UploadFileModal"; +import { AsyncSection } from "@/shared/ui/AsyncSection"; +import { SectionCard } from "@/shared/ui/SectionCard"; +import { useFilesDashboard } from "@/views/files-dashboard/model/useFilesDashboard"; +import { AlertTable } from "@/widgets/alert-table/ui/AlertTable"; +import { FileTable } from "@/widgets/file-table/ui/FileTable"; + +export function FilesDashboard() { + const { files, alerts, isLoading, error, refresh, reload } = useFilesDashboard(); + const upload = useUploadFile({ onUploaded: () => reload({ silent: true }) }); + + return ( + + + + + +
+
+

Управление файлами

+

+ Загрузка файлов, просмотр статусов обработки и ленты алертов. +

+
+
+ + +
+
+
+
+ + {error ? ( + + {error} + + ) : null} + + + + + + + + + + + + + +
+ + +
+ ); +} diff --git a/frontend/src/widgets/alert-table/ui/AlertTable.tsx b/frontend/src/widgets/alert-table/ui/AlertTable.tsx new file mode 100644 index 00000000..695672aa --- /dev/null +++ b/frontend/src/widgets/alert-table/ui/AlertTable.tsx @@ -0,0 +1,28 @@ +import { getLevelVariant } from "@/entities/alert/model/level"; +import type { AlertItem } from "@/entities/alert/model/types"; +import { DataTable } from "@/shared/ui/DataTable"; +import { StatusBadge } from "@/shared/ui/StatusBadge"; +import { formatDate } from "@/shared/lib/format"; + +const COLUMNS = ["ID", "File ID", "Уровень", "Сообщение", "Создан"]; + +export function AlertTable({ alerts }: { alerts: AlertItem[] }) { + return ( + ( + + {alert.id} + {alert.file_id} + + {alert.level} + + {alert.message} + {formatDate(alert.created_at)} + + )} + /> + ); +} diff --git a/frontend/src/widgets/file-table/ui/FileTable.tsx b/frontend/src/widgets/file-table/ui/FileTable.tsx new file mode 100644 index 00000000..f761b171 --- /dev/null +++ b/frontend/src/widgets/file-table/ui/FileTable.tsx @@ -0,0 +1,48 @@ +import { Button } from "react-bootstrap"; + +import { downloadUrl } from "@/entities/file/api/fileApi"; +import { getProcessingVariant, getScanVariant } from "@/entities/file/model/status"; +import type { FileItem } from "@/entities/file/model/types"; +import { DataTable } from "@/shared/ui/DataTable"; +import { StatusBadge } from "@/shared/ui/StatusBadge"; +import { formatDate, formatSize } from "@/shared/lib/format"; + +const COLUMNS = ["Название", "Файл", "MIME", "Размер", "Статус", "Проверка", "Создан", ""]; + +export function FileTable({ files }: { files: FileItem[] }) { + return ( + ( + + +
{file.title}
+
{file.id}
+ + {file.original_name} + {file.mime_type} + {formatSize(file.size)} + + + {file.processing_status} + + + +
+ {file.scan_status ?? "pending"} + {file.scan_details ?? "Ожидает обработки"} +
+ + {formatDate(file.created_at)} + + + + + )} + /> + ); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 8c4be946..d181d18d 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,19 +1,47 @@ { "compilerOptions": { "target": "ES2020", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, - "strict": false, + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", - "incremental": true + "jsx": "react-jsx", + "incremental": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + }, + "plugins": [ + { + "name": "next" + } + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } From e0b325867bc468a7d4964c98367a4ff44ce6157f Mon Sep 17 00:00:00 2001 From: Nikita Sysoev Date: Mon, 7 Sep 2026 11:26:29 +0300 Subject: [PATCH 2/3] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8E=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=BD=D0=B0=20=D1=80=D1=83=D1=81=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- backend/README.md | 257 +++++++++++++++++++++++---------------------- frontend/README.md | 82 +++++++-------- 2 files changed, 171 insertions(+), 168 deletions(-) diff --git a/backend/README.md b/backend/README.md index 28ee058e..422767fd 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,162 +1,165 @@ -# Backend +# Бэкенд -FastAPI + Celery service for uploading files, scanning them for suspicious -content and emitting alerts. Refactored onto a clean-architecture layout. +Сервис на FastAPI + Celery: загрузка файлов, проверка на подозрительное +содержимое и рассылка алертов. Отрефакторен на clean architecture. -## Architecture +## Архитектура -The single rule: **dependencies point inwards.** An inner layer never imports an -outer one, so the business rules can be read, tested and changed without a -database, a broker or a web framework in the picture. +Единственное правило: **зависимости направлены внутрь.** Внешний слой знает про +внутренний, но никогда наоборот — поэтому бизнес-правила можно читать, тестировать +и менять, не имея под рукой ни базы, ни брокера, ни веб-фреймворка. ``` ┌───────────────────────────────────────────────┐ - │ presentation/http routers, schemas, │ FastAPI - │ error handlers │ + │ presentation/http роутеры, схемы, │ FastAPI + │ обработчики ошибок │ ├───────────────────────────────────────────────┤ - │ infrastructure SQLAlchemy, Celery, │ adapters - │ local storage, settings │ + │ infrastructure SQLAlchemy, Celery, │ адаптеры + │ хранилище, настройки │ ├───────────────────────────────────────────────┤ - │ application use cases, DTOs, ports │ orchestration + │ application use case'ы, DTO, порты │ оркестрация ├───────────────────────────────────────────────┤ - │ domain entities, value objects, │ pure Python - │ services, ports │ + │ domain сущности, value objects, │ чистый Python + │ доменные сервисы, порты │ └───────────────────────────────────────────────┘ - ▲ imports only ever point up this diagram + ▲ импорты идут только вверх по этой схеме ``` -`src/container.py` is the composition root - the one module allowed to see every -layer at once. It wires concrete adapters into use cases; everything else talks -to protocols. +`src/container.py` — composition root, единственный модуль, которому позволено +видеть все слои сразу. Он собирает конкретные адаптеры в use case'ы; всё +остальное работает с протоколами. -The rule is not just documented, it is asserted: -[`tests/unit/test_architecture.py`](tests/unit/test_architecture.py) walks the AST -of every module and fails the build if `domain/` imports SQLAlchemy, if -`application/` imports FastAPI, and so on. +Правило не только описано, но и проверяется: +[`tests/unit/test_architecture.py`](tests/unit/test_architecture.py) обходит AST +каждого модуля и роняет сборку, если `domain/` импортирует SQLAlchemy, а +`application/` — FastAPI. -### Layout +### Структура -| Path | Contains | May import | +| Путь | Что внутри | Что может импортировать | | --- | --- | --- | -| `src/domain/` | `StoredFile`, `Alert`, statuses, `ThreatScanner`, `MetadataExtractor`, `AlertPolicy`, and the `FileRepository` / `UnitOfWork` / `FileStorage` ports | stdlib only | -| `src/application/` | one class per use case (`UploadFileUseCase`, `ProcessFileUseCase`, …), DTOs, the `FileProcessingQueue` port | `domain` | -| `src/infrastructure/` | SQLAlchemy tables + repositories + unit of work, `LocalFileStorage`, Celery app and tasks, typed settings | `domain`, `application` | -| `src/presentation/http/` | routers, Pydantic schemas, dependency providers, domain-error → HTTP mapping | all of the above | - -### Notable decisions - -**Entities are persisted with SQLAlchemy's imperative mapping.** `StoredFile` and -`Alert` are plain dataclasses with zero framework imports, mapped onto their -tables in `infrastructure/db/tables.py`. That gives a genuinely pure domain -*without* the usual price of a second set of ORM models plus a hand-written -mapper, and Alembic still autogenerates from the same metadata. - -**State transitions live on the entity.** `start_processing()`, `apply_scan()`, -`apply_metadata()`, `mark_failed()`, `rename()`. Previously these were loose -attribute assignments spread across three Celery tasks, so a rule like "a file -that already has a scan verdict keeps it when processing fails" was implicit in -the order of two lines of code. - -**No `HTTPException` outside the HTTP layer.** The old `service.py` raised -`HTTPException` from the persistence functions, which the Celery worker also -imported - a 404 raised inside a background job means nothing. Use cases now -raise domain errors and `presentation/http/error_handlers.py` maps them to status -codes in one place. - -**Statuses are `StrEnum`s.** The literals `"processing"`, `"suspicious"`, -`"critical"` were repeated as bare strings across four modules. The values stored -in Postgres are unchanged. - -## Bugs fixed - -| # | Problem | Fix | +| `src/domain/` | `StoredFile`, `Alert`, статусы, `ThreatScanner`, `MetadataExtractor`, `AlertPolicy`, порты `FileRepository` / `UnitOfWork` / `FileStorage` | только stdlib | +| `src/application/` | по классу на use case (`UploadFileUseCase`, `ProcessFileUseCase`, …), DTO, порт `FileProcessingQueue` | `domain` | +| `src/infrastructure/` | таблицы, репозитории и unit of work на SQLAlchemy, `LocalFileStorage`, Celery-приложение и задачи, типизированные настройки | `domain`, `application` | +| `src/presentation/http/` | роутеры, схемы Pydantic, провайдеры зависимостей, маппинг доменных ошибок в HTTP | всё перечисленное выше | + +### Ключевые решения + +**Сущности кладутся в БД через imperative mapping.** `StoredFile` и `Alert` — +обычные dataclass'ы без единого импорта фреймворка, привязанные к таблицам в +`infrastructure/db/tables.py`. Это даёт по-настоящему чистый домен *без* обычной +платы за него: не появляется ни второго набора ORM-моделей, ни ручного маппера, а +Alembic по-прежнему автогенерирует миграции из тех же метаданных. + +**Переходы состояний живут на сущности.** `start_processing()`, `apply_scan()`, +`apply_metadata()`, `mark_failed()`, `rename()`. Раньше это были разрозненные +присваивания атрибутов, размазанные по трём Celery-задачам, поэтому правило вроде +«файл, у которого уже есть вердикт сканера, сохраняет его при провале обработки» +существовало только в виде порядка двух строк кода. + +**Никакого `HTTPException` за пределами HTTP-слоя.** Старый `service.py` бросал +`HTTPException` прямо из функций доступа к данным — а этот же модуль импортировал +Celery-воркер, где 404 внутри фоновой задачи не значит ничего. Теперь use case'ы +бросают доменные ошибки, а `presentation/http/error_handlers.py` в одном месте +превращает их в коды ответа. + +**Статусы стали `StrEnum`.** Литералы `"processing"`, `"suspicious"`, +`"critical"` дублировались строками в четырёх модулях. Значения, которые лежат в +Postgres, не изменились. + +## Исправленные баги + +| № | Проблема | Решение | | --- | --- | --- | -| 1 | Deleting a file that had produced an alert failed: `alerts.file_id` had no `ON DELETE` action, so the FK blocked the `DELETE` | `ON DELETE CASCADE` (migration `a1c4f2b7e903`) | -| 2 | `delete_file` unlinked the blob *before* committing - a failed commit destroyed the content of a file that was still listed | row is deleted and committed first, blob after | -| 3 | `create_file` wrote the blob before inserting the row - a failed insert left an orphan file on disk forever | the blob is removed if the insert fails | -| 4 | Blocking I/O on the event loop: `Path.write_bytes`, `Path.exists`, and Celery's `.delay()` (a synchronous broker socket call) inside `async def` endpoints | all file I/O goes through `anyio`; `send_task` runs on a worker thread | -| 5 | `greenlet` was never declared, and SQLAlchemy's async layer refuses to run without it | dependency is `sqlalchemy[asyncio]` | -| 6 | The DSN was built from `os.environ.get(...)`, so a missing variable silently produced `postgresql+asyncpg://None:None@None:None/None` | typed `Settings` (pydantic-settings) | -| 7 | Uploads were unbounded - one large request could exhaust RAM and disk | streamed with a `max_upload_size` cap, aborted mid-stream (HTTP 413) | -| 8 | The filename from the multipart body was used unsanitised for the stored name and for `Content-Disposition` | `sanitize_filename` strips directories, CR/LF and quotes; storage refuses any path that resolves outside its root | -| 9 | `GET /files` and `GET /alerts` returned every row, forever | `limit`/`offset` with a validated cap | -| 10 | Postgres data lived in an unmounted directory (`/var/lib/postgresql` vs. `PGDATA`), so the volume held nothing | `PGDATA` points inside the mount | -| 11 | Neither the API nor the worker waited for Redis; the worker did not even declare it | healthchecks + `depends_on: service_healthy` | -| 12 | The worker read blobs from its own container-local directory - it could only ever see the API's files by accident of the bind mount | an explicit shared `backend-storage` volume | -| 13 | `alembic.ini` and `migrations/` were not in the image, so `alembic upgrade head` only worked because of a dev bind mount | both are copied into the image | -| 14 | A blank or whitespace-only title was accepted | validated in the entity, before anything is written | - -## Optimisations - -**1. Constant-memory file handling (the non-obvious one).** The old code read -whole files into RAM three times over: `await upload_file.read()` on upload, then -`read_text()` or `read_bytes()` in the metadata task purely to count lines, -characters or PDF page markers. Peak memory scaled with file size, in a service -whose own scanner flags anything over 10 MB as unusual. - -Both directions are now streamed in 1 MB chunks. The counting had to survive -being cut into arbitrary pieces, so `TextContentAnalyzer` decodes UTF-8 -incrementally and reproduces `str.splitlines()` semantics exactly - including a -`\r\n` pair split across a chunk boundary and the ten characters Python treats as -line breaks - while `PdfContentAnalyzer` keeps a 10-byte overlap so a marker -straddling two chunks is still counted once. `tests/unit/test_metadata.py` -asserts the streaming result equals the whole-file result on hand-picked and on -200 randomised inputs. - -The scanner needs no bytes at all, so scanning never opens the file. - -**2. Three Celery tasks collapsed into one.** `scan → extract_metadata → -send_alert` chained through the broker, each task opening its own session and -re-loading the same row: 3 broker round-trips, 3 connection checkouts, 3 SELECTs -per upload. They are still three explicit business steps (`ProcessFileUseCase` -calls them in order, with the same commit boundaries, so the intermediate -`processing` state stays observable) but they run in one invocation on one -session: 1 round-trip, 1 checkout, 1 SELECT. - -**3. Indexes for the queries that actually run.** Both list endpoints sort by -`created_at DESC` and neither column was indexed; `alerts.file_id` had no index -either, because Postgres does not create one for a foreign key, so every file -deletion scanned the whole alerts table. Measured on 50 000 rows: +| 1 | Удаление файла, по которому уже был алерт, падало: у `alerts.file_id` не было `ON DELETE`, и внешний ключ блокировал `DELETE` | `ON DELETE CASCADE` (миграция `a1c4f2b7e903`) | +| 2 | `delete_file` удалял файл с диска *до* коммита — неудачный коммит уничтожал содержимое файла, который остался в списке | сначала удаляется и коммитится строка, потом файл | +| 3 | `create_file` писал файл на диск до вставки строки — неудачная вставка навсегда оставляла мусор на диске | при неудачной вставке файл удаляется | +| 4 | Блокирующий I/O в event loop: `Path.write_bytes`, `Path.exists` и `.delay()` Celery (синхронный сокет к брокеру) внутри `async def` | весь файловый I/O идёт через `anyio`, `send_task` — в отдельном потоке | +| 5 | `greenlet` нигде не был объявлен, а без него async-слой SQLAlchemy просто отказывается работать | зависимость стала `sqlalchemy[asyncio]` | +| 6 | DSN собирался из `os.environ.get(...)`, поэтому пропущенная переменная молча давала `postgresql+asyncpg://None:None@None:None/None` | типизированный `Settings` на pydantic-settings | +| 7 | Размер загрузки ничем не ограничен — один большой запрос выедал память и диск | потоковая запись с лимитом `max_upload_size`, обрыв прямо в процессе (HTTP 413) | +| 8 | Имя файла из multipart-тела использовалось без очистки и для имени на диске, и для `Content-Disposition` | `sanitize_filename` вырезает пути, CR/LF и кавычки; хранилище отклоняет любой путь, выходящий за свой корень | +| 9 | `GET /files` и `GET /alerts` всегда возвращали вообще все строки | `limit` / `offset` с валидируемым потолком | +| 10 | Данные Postgres лежали вне смонтированной директории (`/var/lib/postgresql` против `PGDATA`), то есть volume был пустым | `PGDATA` указывает внутрь монтирования | +| 11 | Ни API, ни воркер не дожидались Redis; воркер его даже не объявлял | healthcheck'и + `depends_on: service_healthy` | +| 12 | Воркер читал файлы из своей локальной директории — файлы API он видел только по случайности bind mount'а | явный общий volume `backend-storage` | +| 13 | `alembic.ini` и `migrations/` не попадали в образ, и `alembic upgrade head` работал лишь благодаря dev-монтированию | оба копируются в образ | +| 14 | Пустое название или название из одних пробелов принималось | валидация в сущности, до любой записи | + +## Оптимизации + +**1. Постоянный расход памяти при работе с файлами (та самая неочевидная).** +Старый код целиком загружал файл в память трижды: `await upload_file.read()` при +загрузке, затем `read_text()` или `read_bytes()` в задаче метаданных — и всё это +ради подсчёта строк, символов и маркеров страниц PDF. Пиковая память росла +линейно с размером файла, в сервисе, чей собственный сканер считает подозрительным +всё, что больше 10 МБ. + +Теперь оба направления читаются потоком по 1 МБ. Сложность была в том, чтобы +подсчёт пережил нарезку на произвольные куски: `TextContentAnalyzer` декодирует +UTF-8 инкрементально и в точности воспроизводит семантику `str.splitlines()` — +включая пару `\r\n`, разорванную границей чанка, и все десять символов, которые +Python считает переводом строки. `PdfContentAnalyzer` держит 10-байтовое +перекрытие, чтобы маркер на стыке чанков был посчитан ровно один раз. +[`tests/unit/test_metadata.py`](tests/unit/test_metadata.py) проверяет, что +потоковый результат совпадает с результатом чтения целиком — на подобранных и на +200 случайных входных данных. + +Сканеру байты не нужны вообще, поэтому сканирование файл вовсе не открывает. + +**2. Три Celery-задачи свёрнуты в одну.** Цепочка `scan → extract_metadata → +send_alert` шла через брокер, и каждая задача открывала свою сессию и заново +читала ту же строку: 3 обращения к брокеру, 3 взятия соединения из пула, 3 +SELECT'а на одну загрузку. Это по-прежнему три явных бизнес-шага +(`ProcessFileUseCase` вызывает их по порядку, с теми же границами транзакций, +поэтому промежуточное состояние `processing` так же наблюдаемо), но выполняются +они за один вызов на одной сессии: 1 обращение, 1 соединение, 1 SELECT. + +**3. Индексы под те запросы, которые реально выполняются.** Оба списочных +эндпоинта сортируют по `created_at DESC`, и ни один из столбцов не был +проиндексирован. У `alerts.file_id` индекса тоже не было — Postgres не создаёт его +для внешнего ключа автоматически, — поэтому каждое удаление файла сканировало всю +таблицу алертов. Замер на 50 000 строк: ``` -with ix_files_created_at_id: Index Scan Backward ... (actual rows=100) 0.140 ms -without it: Seq Scan on files ... (actual rows=50001) + top-N sort +с ix_files_created_at_id: Index Scan Backward ... (actual rows=100) 0.140 ms +без него: Seq Scan on files ... (actual rows=50001) + top-N sort ``` -**4. One event loop per worker process.** The old `run_in_worker_loop` created -and re-created a module-level loop by hand. An `asyncio.Runner` is now held for -the process lifetime, so the asyncpg pool is established once instead of being -rebuilt per task, and it is disposed on `worker_process_shutdown`. +**4. Один event loop на процесс воркера.** Старый `run_in_worker_loop` вручную +создавал и пересоздавал модульный loop. Теперь `asyncio.Runner` живёт всё время +жизни процесса, поэтому пул asyncpg поднимается один раз, а не собирается заново +на каждую задачу, и закрывается по сигналу `worker_process_shutdown`. -**5. `eager_defaults=True` on the mappers.** Server-generated `created_at` / -`updated_at` come back through `RETURNING` as part of the INSERT/UPDATE, instead -of the extra `SELECT` the original `session.refresh()` issued after every write. +**5. `eager_defaults=True` на мапперах.** Генерируемые базой `created_at` и +`updated_at` возвращаются через `RETURNING` прямо в INSERT/UPDATE, вместо +дополнительного `SELECT`, который делал `session.refresh()` после каждой записи. -**6. Zero-copy downloads.** When the storage adapter is backed by a local disk it -exposes the path and the response is served with `sendfile`; a remote adapter -returns `None` and the same endpoint falls back to streaming. Downloads no longer -pull the file through Python either way. +**6. Отдача файлов без копирования.** Если адаптер хранилища работает поверх +локального диска, он отдаёт путь, и ответ уходит через `sendfile`; удалённый +адаптер вернёт `None`, и тот же эндпоинт переключится на потоковую отдачу. В обоих +случаях файл больше не протаскивается через Python. -## Running +## Запуск ```bash docker compose -f docker-compose.dev.yml up docker exec -it backend alembic upgrade head ``` -API docs: · health: +Документация API: · health: -## Development +## Разработка ```bash -uv sync # install (uv manages the venv and the lockfile) -uv run ruff check . # lint -uv run ruff format . # format -uv run ty check src tests # type check -uv run pytest # tests +uv sync # установка (uv ведёт виртуальное окружение и лок-файл) +uv run ruff check . # линтер +uv run ruff format . # форматирование +uv run ty check src tests # проверка типов +uv run pytest # тесты ``` -The test suite needs no Postgres and no Redis: the ports are filled with SQLite, -a temporary directory and in-memory doubles, which is the practical payoff of the -layering. `tests/unit` covers the domain and the use cases, `tests/integration` -drives the real adapters and the real FastAPI app. +Тестам не нужны ни Postgres, ни Redis: порты закрываются SQLite, временной +директорией и in-memory заглушками — это и есть практическая отдача от слоёв. +`tests/unit` покрывает домен и use case'ы, `tests/integration` гоняет настоящие +адаптеры и настоящее приложение FastAPI. diff --git a/frontend/README.md b/frontend/README.md index 2b2f99e3..c28b653e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,57 +1,57 @@ -# Frontend +# Фронтенд -Next.js (App Router) dashboard for the file exchange service. +Дашборд файлообменника на Next.js (App Router). -## Layers +## Слои -The page used to be a single 400-line `page.tsx` holding types, formatting, -status-to-colour mapping, `fetch` calls, error handling and markup. It is now -split along [Feature-Sliced Design](https://feature-sliced.design) lines, with -imports only ever pointing **downwards**: +Раньше это был один `page.tsx` на 400 строк, в котором лежали типы, +форматирование, маппинг статусов в цвета, вызовы `fetch`, обработка ошибок и +разметка. Теперь всё разбито по [Feature-Sliced Design](https://feature-sliced.design), +и импорты идут строго **вниз**: ``` -app/ Next.js routing and the root layout - nothing else +app/ роутинг Next.js и корневой layout — больше ничего ↓ -views/ a whole screen: files-dashboard (data flow in model/, layout in ui/) +views/ целый экран: files-dashboard (поток данных в model/, вёрстка в ui/) ↓ -widgets/ self-contained blocks: file-table, alert-table +widgets/ самодостаточные блоки: file-table, alert-table ↓ -features/ a user action with its own state: upload-file +features/ пользовательское действие со своим состоянием: upload-file ↓ -entities/ a business object: file, alert - its type, its API calls, its display rules +entities/ бизнес-объект: file, alert — его тип, его вызовы API, его правила отображения ↓ -shared/ reusable and domain-agnostic: http client, config, formatters, UI primitives +shared/ переиспользуемое и не знающее о домене: HTTP-клиент, конфиг, форматтеры, UI-примитивы ``` -| Slice | Responsibility | +| Слайс | Зона ответственности | | --- | --- | -| `shared/config/env.ts` | the API origin, read from `NEXT_PUBLIC_API_URL` instead of `http://localhost:8000` hard-coded in the page | -| `shared/api/http.ts` | the only module that knows how this API reports failures - checks `response.ok`, reads `detail`, throws `ApiError` | +| `shared/config/env.ts` | origin API из `NEXT_PUBLIC_API_URL` вместо зашитого в страницу `http://localhost:8000` | +| `shared/api/http.ts` | единственный модуль, который знает, как этот API сообщает об ошибках: проверяет `response.ok`, читает `detail`, бросает `ApiError` | | `shared/lib/format.ts` | `formatDate`, `formatSize` | -| `shared/ui/` | `DataTable`, `SectionCard`, `AsyncSection`, `StatusBadge` - the table/card/spinner markup that was duplicated between the two tables | -| `entities/file`, `entities/alert` | types, endpoint calls, and the status → badge-variant mapping | -| `features/upload-file` | `useUploadFile` owns the form state and the submit workflow; `UploadFileModal` renders it | -| `views/files-dashboard` | `useFilesDashboard` owns loading, errors and refresh; `FilesDashboard` is layout only | - -`@/*` maps to `src/*` (see `tsconfig.json`). - -## Changes beyond the split - -- `strict: true` in `tsconfig.json` (it was `false`), plus `noUncheckedIndexedAccess` - and `noUnusedLocals`. -- The Docker build was broken: it copied `/app/.env.production`, a file that is not - in the repository, so `docker compose build frontend` failed outright. -- `next: "latest"` and the other floating ranges are pinned to the versions in - `package-lock.json`, so a build is reproducible. -- The favicon pointed at `/public/favicon.ico`, which is not a served path. Next - serves `public/favicon.ico` itself, and now does. -- Errors from the upload form are shown inside the modal rather than behind it. -- Processing happens in a background worker, so a freshly uploaded file is still - `uploaded` when the response arrives. The dashboard now polls quietly (2 s) - while any file is unfinished and stops once everything has settled, instead of - leaving the user to press *Обновить*. - -## Development +| `shared/ui/` | `DataTable`, `SectionCard`, `AsyncSection`, `StatusBadge` — разметка таблиц, карточек и спиннеров, дублировавшаяся между двумя таблицами | +| `entities/file`, `entities/alert` | типы, вызовы эндпоинтов и маппинг статуса в вариант бейджа | +| `features/upload-file` | `useUploadFile` держит состояние формы и сценарий отправки, `UploadFileModal` его рисует | +| `views/files-dashboard` | `useFilesDashboard` держит загрузку, ошибки и обновление, `FilesDashboard` — только вёрстка | + +`@/*` указывает на `src/*` (см. `tsconfig.json`). + +## Что изменилось помимо разбиения + +- `strict: true` в `tsconfig.json` (было `false`), плюс `noUncheckedIndexedAccess` + и `noUnusedLocals`. +- Сборка Docker была сломана: она копировала `/app/.env.production` — файл, + которого нет в репозитории, — поэтому `docker compose build frontend` падал. +- `next: "latest"` и остальные плавающие диапазоны зафиксированы по версиям из + `package-lock.json`, чтобы сборка была воспроизводимой. +- Иконка ссылалась на `/public/favicon.ico` — такого пути не существует. Next + раздаёт `public/favicon.ico` сам, теперь так и происходит. +- Ошибки формы загрузки показываются внутри модалки, а не за ней. +- Обработка идёт в фоновом воркере, поэтому только что загруженный файл в момент + ответа ещё имеет статус `uploaded`. Дашборд теперь тихо опрашивает бэкенд + (раз в 2 с), пока есть незавершённые файлы, и перестаёт, когда всё + обработано, — вместо того чтобы оставлять пользователя жать *Обновить*. + +## Разработка ```bash npm install @@ -60,4 +60,4 @@ npm run typecheck npm run build ``` -Requires Node 20.9+ (Next 16). +Нужен Node 20.9+ (Next 16). From 0f4d0a6f3c5e04498b1ffad32df6d515a2a27d31 Mon Sep 17 00:00:00 2001 From: Nikita Sysoev Date: Mon, 7 Sep 2026 11:39:45 +0300 Subject: [PATCH 3/3] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=81=D1=82=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B8=20=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=D0=BD=D0=B0=20=D1=80=D1=83?= =?UTF-8?q?=D1=81=D1=81=D0=BA=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Затронуты src/, tests/, миграции, Dockerfile'ы, docker-compose и pyproject. Исполняемый код не менялся: AST всех 57 модулей (без докстрингов) совпадает с предыдущим коммитом. Отключены правила ruff RUF001-RUF003: проверка на омоглифы рассчитана на кодовые базы, где кириллица неожиданна, и на русских комментариях даёт только ложные срабатывания. Co-Authored-By: Claude Opus 5 (1M context) --- backend/Dockerfile | 8 ++-- backend/migrations/env.py | 10 ++-- ...4f2b7e903_add_indexes_and_alert_cascade.py | 17 +++---- backend/pyproject.toml | 21 +++++---- backend/src/app.py | 2 +- backend/src/application/dto.py | 4 +- backend/src/application/ports.py | 6 +-- .../src/application/use_cases/manage_files.py | 8 ++-- .../src/application/use_cases/process_file.py | 24 +++++----- .../src/application/use_cases/upload_file.py | 12 ++--- backend/src/container.py | 8 ++-- backend/src/domain/entities.py | 41 +++++++++------- backend/src/domain/errors.py | 14 +++--- backend/src/domain/repositories.py | 13 +++-- backend/src/domain/services/alert_policy.py | 2 +- backend/src/domain/services/metadata.py | 47 ++++++++++--------- backend/src/domain/services/naming.py | 16 +++---- backend/src/domain/services/threat_scanner.py | 6 +-- backend/src/domain/storage.py | 16 +++---- backend/src/domain/value_objects.py | 6 +-- backend/src/infrastructure/config.py | 8 ++-- backend/src/infrastructure/db/engine.py | 4 +- backend/src/infrastructure/db/repositories.py | 6 +-- backend/src/infrastructure/db/tables.py | 30 ++++++------ backend/src/infrastructure/db/types.py | 10 ++-- backend/src/infrastructure/db/unit_of_work.py | 16 +++---- .../src/infrastructure/queue/celery_app.py | 2 +- backend/src/infrastructure/queue/runner.py | 10 ++-- .../src/infrastructure/queue/task_queue.py | 8 ++-- backend/src/infrastructure/queue/tasks.py | 4 +- backend/src/infrastructure/storage/local.py | 14 +++--- backend/src/presentation/http/app.py | 2 +- backend/src/presentation/http/dependencies.py | 4 +- .../src/presentation/http/error_handlers.py | 8 ++-- .../src/presentation/http/routers/alerts.py | 2 +- .../src/presentation/http/routers/files.py | 6 +-- backend/src/presentation/http/schemas.py | 8 ++-- backend/tests/conftest.py | 15 +++--- backend/tests/doubles.py | 8 ++-- backend/tests/integration/test_api.py | 2 +- backend/tests/integration/test_pipeline.py | 8 ++-- backend/tests/unit/test_architecture.py | 9 ++-- docker-compose.dev.yml | 8 ++-- frontend/Dockerfile | 4 +- frontend/src/entities/file/model/status.ts | 2 +- .../upload-file/model/useUploadFile.ts | 4 +- frontend/src/shared/api/http.ts | 4 +- frontend/src/shared/config/env.ts | 8 ++-- frontend/src/shared/ui/AsyncSection.tsx | 2 +- .../model/useFilesDashboard.ts | 11 +++-- 50 files changed, 261 insertions(+), 247 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 2a02c862..74b64663 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,14 +12,14 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ COPY pyproject.toml uv.lock ./ RUN uv sync --locked -# alembic.ini and migrations/ are part of the image so that -# `alembic upgrade head` works without a bind mount. +# alembic.ini и migrations/ лежат в образе, чтобы `alembic upgrade head` +# работал без bind mount'а. COPY alembic.ini ./ COPY migrations ./migrations COPY src ./src -# The storage directory is created at startup by the storage adapter; make sure -# the unprivileged user owns it. +# Директорию хранилища создаёт при старте адаптер; следим, чтобы она +# принадлежала непривилегированному пользователю. RUN useradd --create-home --uid 1000 app \ && mkdir -p /backend/storage/files \ && chown -R app:app /backend diff --git a/backend/migrations/env.py b/backend/migrations/env.py index a6944e06..c8ac3340 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -1,8 +1,8 @@ -"""Alembic environment. +"""Окружение Alembic. -Autogeneration targets the metadata declared in -:mod:`src.infrastructure.db.tables`; the URL comes from the same typed settings -the application uses, so the two can never drift apart. +Автогенерация опирается на метаданные из :mod:`src.infrastructure.db.tables`, а +URL берётся из тех же типизированных настроек, что и у приложения, — поэтому +разъехаться они не могут. """ import asyncio @@ -26,7 +26,7 @@ def run_migrations_offline() -> None: - """Run migrations without a DBAPI connection, emitting SQL to stdout.""" + """Прогнать миграции без подключения к БД, выводя SQL в stdout.""" context.configure( url=config.get_main_option("sqlalchemy.url"), target_metadata=target_metadata, diff --git a/backend/migrations/versions/a1c4f2b7e903_add_indexes_and_alert_cascade.py b/backend/migrations/versions/a1c4f2b7e903_add_indexes_and_alert_cascade.py index b22cc805..0294353f 100644 --- a/backend/migrations/versions/a1c4f2b7e903_add_indexes_and_alert_cascade.py +++ b/backend/migrations/versions/a1c4f2b7e903_add_indexes_and_alert_cascade.py @@ -1,13 +1,14 @@ """add listing indexes and cascade alerts on file delete -Two problems this fixes: - -* ``GET /files`` and ``GET /alerts`` sort by ``created_at DESC`` with no - supporting index, so every request was a sequential scan plus a sort. -* ``alerts.file_id`` had no index (Postgres does not create one for a foreign - key), which made the referential check on ``DELETE FROM files`` scan the whole - alerts table - and, because the constraint had no ``ON DELETE`` action, - deleting a file that had already produced an alert failed outright. +Исправляет две проблемы: + +* ``GET /files`` и ``GET /alerts`` сортируют по ``created_at DESC``, и ни одного + подходящего индекса не было — каждый запрос означал последовательное + сканирование плюс сортировку. +* У ``alerts.file_id`` не было индекса (Postgres не создаёт его для внешнего + ключа), поэтому проверка ссылочной целостности при ``DELETE FROM files`` + сканировала всю таблицу алертов. А поскольку у ограничения не было действия + ``ON DELETE``, удаление файла, по которому уже был алерт, просто падало. Revision ID: a1c4f2b7e903 Revises: 0d6439d2e79f diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ca1db250..d372196f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,8 +13,8 @@ dependencies = [ "pydantic>=2.12.5", "pydantic-settings>=2.7.0", "python-multipart>=0.0.20", - # the [asyncio] extra pulls in greenlet, without which every async query - # raises "the greenlet library is required" + # экстра [asyncio] тянет greenlet, без которого любой async-запрос падает с + # "the greenlet library is required" "sqlalchemy[asyncio]>=2.0.48", "uvicorn>=0.42.0", ] @@ -31,7 +31,7 @@ dev = [ [tool.uv] default-groups = ["dev"] -# The service is run from the source tree, not installed as a distribution. +# Сервис запускается из дерева исходников, а не устанавливается как дистрибутив. package = false # --------------------------------------------------------------------------- @@ -67,9 +67,14 @@ select = [ "RUF", ] ignore = [ - "ANN401", # Any is required by a few adapter signatures - "PLR0913", # use cases legitimately take several collaborators - "S104", # binding 0.0.0.0 inside a container is intentional + "ANN401", # Any нужен в сигнатурах нескольких адаптеров + "PLR0913", # use case'ы обоснованно принимают несколько зависимостей + "S104", # привязка к 0.0.0.0 внутри контейнера сделана намеренно + # Документация и комментарии на русском, поэтому проверка на омоглифы + # (кириллические буквы, похожие на латинские) даёт только ложные срабатывания. + "RUF001", # неоднозначные символы в идентификаторах и строках + "RUF002", # то же в докстрингах + "RUF003", # то же в комментариях ] [tool.ruff.lint.per-file-ignores] @@ -90,8 +95,8 @@ line-ending = "lf" # --------------------------------------------------------------------------- [tool.ty.environment] python-version = "3.14" -# Imports are absolute from the project root ("src.domain...."), so the root -# is the directory that *contains* the src package. +# Импорты абсолютные от корня проекта ("src.domain...."), поэтому корень — это +# директория, *содержащая* пакет src. root = ["."] [tool.ty.src] diff --git a/backend/src/app.py b/backend/src/app.py index 1a2c8b4c..bc3df7f3 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,4 +1,4 @@ -"""ASGI entrypoint (``uvicorn src.app:app``).""" +"""Точка входа ASGI (``uvicorn src.app:app``).""" from src.presentation.http.app import create_app diff --git a/backend/src/application/dto.py b/backend/src/application/dto.py index c3e84c10..5178c686 100644 --- a/backend/src/application/dto.py +++ b/backend/src/application/dto.py @@ -1,4 +1,4 @@ -"""Data carried across the application boundary.""" +"""Данные, пересекающие границу слоя приложения.""" from collections.abc import AsyncIterator, Callable from dataclasses import dataclass @@ -26,7 +26,7 @@ class UploadFileCommand: @dataclass(slots=True) class FileDownload: - """Everything the transport needs to serve a stored file.""" + """Всё, что нужно транспорту, чтобы отдать сохранённый файл.""" file: StoredFile open_stream: Callable[[], AsyncIterator[bytes]] diff --git a/backend/src/application/ports.py b/backend/src/application/ports.py index 385bdd5d..05d54ba1 100644 --- a/backend/src/application/ports.py +++ b/backend/src/application/ports.py @@ -1,15 +1,15 @@ -"""Application-level ports for outbound infrastructure.""" +"""Порты уровня приложения к внешней инфраструктуре.""" from typing import Protocol class FileProcessingQueue(Protocol): - """Hands a freshly uploaded file over to the asynchronous pipeline.""" + """Передаёт только что загруженный файл в асинхронный конвейер.""" async def enqueue_processing(self, file_id: str) -> None: ... class IdGenerator(Protocol): - """Supplies identifiers for new aggregates (injected so tests stay deterministic).""" + """Выдаёт идентификаторы для новых агрегатов (внедряется, чтобы тесты оставались детерминированными).""" def __call__(self) -> str: ... diff --git a/backend/src/application/use_cases/manage_files.py b/backend/src/application/use_cases/manage_files.py index 484971fe..7860c362 100644 --- a/backend/src/application/use_cases/manage_files.py +++ b/backend/src/application/use_cases/manage_files.py @@ -1,4 +1,4 @@ -"""Read and lifecycle use cases for stored files and alerts.""" +"""Use case'ы чтения и жизненного цикла для файлов и алертов.""" from src.application.dto import FileDownload, Page from src.domain.entities import Alert, StoredFile @@ -63,9 +63,9 @@ async def execute(self, file_id: str) -> None: file = await _require_file(uow, file_id) stored_name = file.stored_name await uow.files.delete(file) - # The row is dropped first: if the transaction fails we still have - # the blob, whereas the original order could destroy the content of - # a file that remained listed in the database. + # Сначала удаляется строка: если транзакция упадёт, файл на диске + # останется. Исходный порядок мог уничтожить содержимое файла, + # который при этом остался в базе. await uow.commit() await self._storage.delete(stored_name) diff --git a/backend/src/application/use_cases/process_file.py b/backend/src/application/use_cases/process_file.py index b58b284c..8bca01e0 100644 --- a/backend/src/application/use_cases/process_file.py +++ b/backend/src/application/use_cases/process_file.py @@ -1,12 +1,12 @@ -"""The asynchronous post-upload pipeline: scan -> extract metadata -> alert. +"""Асинхронный конвейер после загрузки: сканирование -> метаданные -> алерт. -Originally these were three Celery tasks that chained into each other, each one -re-opening a database session and re-loading the same row. They are three -distinct business steps, so they stay three explicit steps here - but they run -inside a single worker invocation and a single session, which removes two -broker round-trips and two connection acquisitions per upload. The commit -boundaries are unchanged, so the intermediate states ("processing", scan -verdict before metadata) remain observable exactly as before. +Изначально это были три Celery-задачи, вызывавшие друг друга по цепочке, и +каждая заново открывала сессию к базе и заново читала ту же строку. Это три +разных бизнес-шага, поэтому здесь они остаются тремя явными шагами — но +выполняются за один вызов воркера и на одной сессии, что убирает два обращения +к брокеру и два взятия соединения на каждую загрузку. Границы коммитов не +изменились, поэтому промежуточные состояния («processing», вердикт сканера до +метаданных) наблюдаемы ровно как раньше. """ import logging @@ -44,8 +44,8 @@ async def execute(self, file_id: str) -> None: async with self._uow_factory() as uow: file = await uow.files.get(file_id) if file is None: - # The file was deleted while the job sat in the queue: nothing - # to process and nothing to alert about. + # Файл удалили, пока задача лежала в очереди: обрабатывать + # нечего и алертить не о чем. logger.warning("Skipping processing of unknown file %s", file_id) return @@ -70,8 +70,8 @@ async def _collect_metadata(self, file: StoredFile) -> dict[str, Any]: analyzer = self._metadata_extractor.analyzer_for(file.mime_type) if analyzer is not None: - # Constant memory: the file is consumed chunk by chunk and never - # materialised in full. + # Постоянный расход памяти: файл читается чанк за чанком и целиком + # нигде не материализуется. async for chunk in self._storage.read_chunks(file.stored_name): analyzer.feed(chunk) metadata.update(analyzer.result()) diff --git a/backend/src/application/use_cases/upload_file.py b/backend/src/application/use_cases/upload_file.py index 4bf827f3..1ee0e7b4 100644 --- a/backend/src/application/use_cases/upload_file.py +++ b/backend/src/application/use_cases/upload_file.py @@ -1,4 +1,4 @@ -"""Upload a file, persist its record and schedule asynchronous processing.""" +"""Загрузить файл, сохранить его запись и поставить асинхронную обработку в очередь.""" import logging from collections.abc import AsyncIterator @@ -32,8 +32,8 @@ def __init__( self._id_generator = id_generator async def execute(self, command: UploadFileCommand) -> StoredFile: - # Validate before touching storage: a rejected command must not leave a - # blob behind. + # Проверяем до обращения к хранилищу: отклонённая команда не должна + # оставлять за собой файл на диске. title = StoredFile.normalize_title(command.title) file_id = self._id_generator() @@ -56,7 +56,7 @@ async def execute(self, command: UploadFileCommand) -> StoredFile: await uow.files.add(file) await uow.commit() except Exception: - # Never leave an orphan blob behind when the row could not be written. + # Не оставляем осиротевший файл, если строку записать не удалось. await self._storage.delete(stored_name) raise @@ -64,7 +64,7 @@ async def execute(self, command: UploadFileCommand) -> StoredFile: return file async def _store_content(self, stored_name: str, chunks: AsyncIterator[bytes]) -> int: - """Stream the upload straight to storage, enforcing the size cap as it goes.""" + """Писать загрузку прямо в хранилище потоком, попутно следя за лимитом размера.""" try: size = await self._storage.save(stored_name, self._capped(chunks)) except Exception: @@ -77,7 +77,7 @@ async def _store_content(self, stored_name: str, chunks: AsyncIterator[bytes]) - return size async def _capped(self, chunks: AsyncIterator[bytes]) -> AsyncIterator[bytes]: - """Abort as soon as the stream exceeds the limit instead of buffering it all.""" + """Оборвать поток сразу, как только он превысит лимит, а не буферизовать целиком.""" written = 0 async for chunk in chunks: written += len(chunk) diff --git a/backend/src/container.py b/backend/src/container.py index 7d23e08d..48f2e958 100644 --- a/backend/src/container.py +++ b/backend/src/container.py @@ -1,7 +1,7 @@ """Composition root. -The only module allowed to know about every layer at once: it wires concrete -adapters into the use cases. Everything else depends on abstractions. +Единственный модуль, которому позволено знать про все слои сразу: он подставляет +конкретные адаптеры в use case'ы. Всё остальное зависит от абстракций. """ from dataclasses import dataclass @@ -68,7 +68,7 @@ def alert_policy(self) -> AlertPolicy: def unit_of_work(self) -> UnitOfWork: return SqlAlchemyUnitOfWork(self.session_factory) - # --- use cases ------------------------------------------------------- + # --- use case'ы ------------------------------------------------------ def upload_file(self) -> UploadFileUseCase: return UploadFileUseCase( @@ -112,5 +112,5 @@ async def dispose(self) -> None: @lru_cache(maxsize=1) def get_container() -> Container: - """Process-wide singleton: one engine and one connection pool per process.""" + """Синглтон на процесс: один engine и один пул соединений на процесс.""" return Container(settings=get_settings()) diff --git a/backend/src/domain/entities.py b/backend/src/domain/entities.py index b1b0702b..ce51236f 100644 --- a/backend/src/domain/entities.py +++ b/backend/src/domain/entities.py @@ -1,13 +1,13 @@ -"""Domain entities. +"""Доменные сущности. -These are plain dataclasses: no SQLAlchemy, Pydantic or FastAPI imports. They -are persisted through SQLAlchemy's *imperative* mapping -(:mod:`src.infrastructure.db.mapping`), which keeps the domain free of ORM -concerns while still avoiding a hand-written entity <-> row mapper. +Это обычные dataclass'ы: ни SQLAlchemy, ни Pydantic, ни FastAPI. В базу они +кладутся через *imperative* mapping SQLAlchemy +(:mod:`src.infrastructure.db.tables`) — домен остаётся свободным от ORM, и при +этом не нужен ручной маппер «сущность <-> строка». -All state transitions live here as methods, so the rules ("a failed file -requires attention", "renaming trims the title") cannot be bypassed by a caller -that pokes at the attributes directly. +Все переходы состояний живут здесь в виде методов, поэтому правила («упавший +файл требует внимания», «переименование обрезает пробелы») нельзя обойти, +присвоив атрибут напрямую. """ from dataclasses import dataclass, field @@ -24,20 +24,20 @@ @dataclass class ScanReport: - """Outcome of a threat scan, produced by :class:`~src.domain.services.threat_scanner.ThreatScanner`.""" + """Результат проверки, который выдаёт :class:`~src.domain.services.threat_scanner.ThreatScanner`.""" status: ScanStatus details: str requires_attention: bool -# ``eq=False`` keeps identity-based equality/hashing: entities are identified by -# their id, and SQLAlchemy's identity map requires hashable instances. -# ``repr=False`` avoids touching every attribute (and triggering a lazy load) -# from a log statement. +# ``eq=False`` оставляет сравнение и хеширование по идентичности: сущность +# определяется своим id, а identity map SQLAlchemy требует хешируемых объектов. +# ``repr=False`` не даёт логированию задеть все атрибуты сразу (и спровоцировать +# ленивую загрузку). @dataclass(eq=False, repr=False) class StoredFile: - """An uploaded file together with its processing state.""" + """Загруженный файл вместе с состоянием его обработки.""" id: str title: str @@ -70,8 +70,8 @@ def apply_metadata(self, metadata: dict[str, Any]) -> None: def mark_failed(self, reason: str) -> None: self.processing_status = ProcessingStatus.FAILED - # A file that already carries a scan verdict keeps it; otherwise the - # scan is considered failed as well. + # Файл, у которого уже есть вердикт сканера, сохраняет его; иначе + # сканирование тоже считается провалившимся. self.scan_status = self.scan_status or ScanStatus.FAILED self.scan_details = reason[:MAX_SCAN_DETAILS_LENGTH] @@ -81,6 +81,11 @@ def has_failed(self) -> bool: @staticmethod def normalize_title(title: str) -> str: + """Обрезать и проверить название. + + Публичный метод: вызывающий код может упасть до того, как начнёт дорогую + работу вроде записи файла на диск. + """ cleaned = title.strip() if not cleaned: raise ValidationError("Title must not be empty") @@ -92,7 +97,7 @@ def normalize_title(title: str) -> str: def create( cls, *, - id: str, # noqa: A002 - mirrors the persisted column name + id: str, # noqa: A002 - повторяет имя колонки в базе title: str, original_name: str, stored_name: str, @@ -112,7 +117,7 @@ def create( @dataclass(eq=False, repr=False) class Alert: - """A notification emitted about a file at the end of the processing pipeline.""" + """Уведомление о файле, выпускаемое в конце конвейера обработки.""" file_id: str level: AlertLevel diff --git a/backend/src/domain/errors.py b/backend/src/domain/errors.py index eb94b4ba..2c65128d 100644 --- a/backend/src/domain/errors.py +++ b/backend/src/domain/errors.py @@ -1,17 +1,17 @@ -"""Domain-level errors. +"""Ошибки доменного уровня. -The domain never knows about HTTP, Celery or SQLAlchemy, so it raises its own -exceptions. The presentation layer is responsible for translating them into -transport-specific responses (see ``src.presentation.http.error_handlers``). +Домен ничего не знает про HTTP, Celery и SQLAlchemy, поэтому бросает собственные +исключения. Переводить их в ответы конкретного транспорта — задача слоя +представления (см. ``src.presentation.http.error_handlers``). """ class DomainError(Exception): - """Base class for every error the domain can raise.""" + """Базовый класс для всех ошибок, которые может бросить домен.""" class NotFoundError(DomainError): - """A requested aggregate does not exist.""" + """Запрошенный агрегат не существует.""" class StoredFileNotFoundError(NotFoundError): @@ -27,7 +27,7 @@ def __init__(self, stored_name: str) -> None: class ValidationError(DomainError): - """The command violates a business rule.""" + """Команда нарушает бизнес-правило.""" class EmptyFileError(ValidationError): diff --git a/backend/src/domain/repositories.py b/backend/src/domain/repositories.py index e032af89..cf59c9da 100644 --- a/backend/src/domain/repositories.py +++ b/backend/src/domain/repositories.py @@ -1,8 +1,7 @@ -"""Persistence ports. +"""Порты хранения. -Declared in the domain and implemented in the infrastructure layer, so the -dependency arrow points inwards: use cases depend on these protocols, never on -SQLAlchemy. +Объявлены в домене, реализованы в инфраструктуре — стрелка зависимости смотрит +внутрь: use case'ы зависят от этих протоколов, а не от SQLAlchemy. """ from types import TracebackType @@ -28,10 +27,10 @@ async def list_recent(self, *, limit: int, offset: int) -> list[Alert]: ... class UnitOfWork(Protocol): - """A transactional scope grouping the repositories. + """Транзакционная область, объединяющая репозитории. - Used as an async context manager; leaving the block without an explicit - :meth:`commit` rolls back. + Используется как асинхронный контекстный менеджер: выход из блока без явного + :meth:`commit` откатывает изменения. """ files: FileRepository diff --git a/backend/src/domain/services/alert_policy.py b/backend/src/domain/services/alert_policy.py index 5e657955..91158e57 100644 --- a/backend/src/domain/services/alert_policy.py +++ b/backend/src/domain/services/alert_policy.py @@ -1,4 +1,4 @@ -"""Decides which alert a processed file deserves.""" +"""Решает, какого алерта заслуживает обработанный файл.""" from src.domain.entities import Alert, StoredFile from src.domain.value_objects import AlertLevel diff --git a/backend/src/domain/services/metadata.py b/backend/src/domain/services/metadata.py index de4f2063..2d14c31c 100644 --- a/backend/src/domain/services/metadata.py +++ b/backend/src/domain/services/metadata.py @@ -1,10 +1,10 @@ -"""Metadata extraction rules. +"""Правила извлечения метаданных. -The original implementation loaded whole files into memory -(``read_text()`` / ``read_bytes()``) just to count lines, characters and PDF -pages. The analyzers below consume the file as a stream of chunks and keep a -constant amount of state, so peak memory no longer scales with file size while -the produced metadata stays byte-for-byte identical. +Исходная реализация целиком загружала файл в память (``read_text()`` / +``read_bytes()``) только ради подсчёта строк, символов и страниц PDF. +Анализаторы ниже читают файл потоком по чанкам и хранят постоянный объём +состояния: пиковая память больше не зависит от размера файла, а получаемые +метаданные совпадают байт в байт. """ from codecs import getincrementaldecoder @@ -16,17 +16,17 @@ TEXT_MIME_PREFIX = "text/" PDF_MIME_TYPE = "application/pdf" -# Byte sequence Adobe uses to introduce a page object. Counting it is a rough -# but cheap approximation of the page count - kept from the original code. +# Последовательность байт, которой Adobe открывает объект страницы. Её подсчёт — +# грубая, но дешёвая оценка числа страниц; оставлена из исходного кода. _PDF_PAGE_MARKER = b"/Type /Page" -# The exact set of characters ``str.splitlines()`` treats as a line boundary. +# Ровно тот набор символов, который ``str.splitlines()`` считает границей строки. _LINE_BOUNDARIES = frozenset("\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029") @runtime_checkable class ContentAnalyzer(Protocol): - """Incrementally derives metadata from the raw bytes of a file.""" + """Инкрементально выводит метаданные из сырых байт файла.""" def feed(self, chunk: bytes) -> None: ... @@ -34,11 +34,11 @@ def result(self) -> dict[str, Any]: ... class TextContentAnalyzer: - """Counts lines and characters exactly like ``len(text.splitlines())``/``len(text)``. + """Считает строки и символы ровно как ``len(text.splitlines())`` и ``len(text)``. - UTF-8 is decoded incrementally so that a multi-byte character split across - two chunks is still decoded as one character, and a ``\\r\\n`` pair split - across two chunks is still counted as a single line break. + UTF-8 декодируется инкрементально, поэтому многобайтовый символ, разорванный + границей чанков, всё равно декодируется как один символ, а пара ``\\r\\n``, + разорванная границей, всё равно считается одним переводом строки. """ def __init__(self, encoding: str = "utf-8", errors: str = "ignore") -> None: @@ -62,9 +62,9 @@ def _consume(self, text: str) -> None: return tail = parts[-1] - # The tail is incomplete when it is not terminated by a boundary, and - # also when it ends with a bare "\r": the next chunk may start with a - # "\n" that turns it into a single CRLF break. + # Хвост неполон, если он не заканчивается границей строки, а также если + # он оканчивается одиночным "\r": следующий чанк может начаться с "\n", + # и вместе они дадут один перевод строки CRLF. if tail[-1] not in _LINE_BOUNDARIES or tail.endswith("\r"): self._pending = tail parts = parts[:-1] @@ -78,7 +78,7 @@ def result(self) -> dict[str, Any]: class PdfContentAnalyzer: - """Approximates a page count by counting page markers across chunk boundaries.""" + """Оценивает число страниц, считая маркеры страниц через границы чанков.""" def __init__(self) -> None: self._pages = 0 @@ -89,8 +89,9 @@ def feed(self, chunk: bytes) -> None: return buffer = self._overlap + chunk self._pages += buffer.count(_PDF_PAGE_MARKER) - # Keep just enough bytes for a marker that straddles two chunks; a full - # marker can never fit in the overlap, so nothing is counted twice. + # Оставляем ровно столько байт, сколько нужно маркеру на стыке двух + # чанков; целиком маркер в перекрытие не помещается, поэтому дважды + # ничего не посчитается. self._overlap = buffer[-(len(_PDF_PAGE_MARKER) - 1) :] def result(self) -> dict[str, Any]: @@ -98,7 +99,7 @@ def result(self) -> dict[str, Any]: class MetadataExtractor: - """Decides *what* to derive from a file; the caller supplies the bytes.""" + """Решает, *что* извлекать из файла; байты подаёт вызывающий код.""" def base_metadata(self, file: StoredFile) -> dict[str, Any]: return { @@ -108,9 +109,9 @@ def base_metadata(self, file: StoredFile) -> dict[str, Any]: } def analyzer_for(self, mime_type: str) -> ContentAnalyzer | None: - """Return an analyzer for ``mime_type``, or ``None`` when the bytes are irrelevant. + """Вернуть анализатор для ``mime_type`` или ``None``, если байты не нужны. - Returning ``None`` lets the caller skip reading the file entirely. + ``None`` позволяет вызывающему коду вовсе не читать файл. """ if mime_type.startswith(TEXT_MIME_PREFIX): return TextContentAnalyzer() diff --git a/backend/src/domain/services/naming.py b/backend/src/domain/services/naming.py index fd3694dd..9ac6739a 100644 --- a/backend/src/domain/services/naming.py +++ b/backend/src/domain/services/naming.py @@ -1,25 +1,25 @@ -"""Filename helpers shared by the domain and the storage adapters.""" +"""Помощники для работы с именами файлов, общие для домена и адаптеров хранилища.""" import mimetypes from pathlib import PurePosixPath, PureWindowsPath -# Everything that could let a crafted upload name escape the storage directory -# or poison a Content-Disposition header. +# Всё, чем подобранное имя файла могло бы вырваться за пределы директории +# хранилища или испортить заголовок Content-Disposition. _UNSAFE_CHARS = str.maketrans({"\r": "_", "\n": "_", "\x00": "_", '"': "_"}) MAX_FILENAME_LENGTH = 255 def file_extension(name: str) -> str: - """Return the lower-cased extension of ``name`` (``".pdf"``, or ``""``). + """Вернуть расширение ``name`` в нижнем регистре (``".pdf"`` или ``""``). - Accepts both POSIX and Windows separators because the value comes straight - from a browser's multipart payload. + Понимает и POSIX-, и Windows-разделители: значение приходит прямо из + multipart-тела браузера. """ return PurePosixPath(PureWindowsPath(name).name).suffix.lower() def sanitize_filename(name: str, *, fallback: str) -> str: - """Strip any directory component and control characters from ``name``.""" + """Убрать из ``name`` любые компоненты пути и управляющие символы.""" base = PurePosixPath(PureWindowsPath(name).name).name.translate(_UNSAFE_CHARS).strip() if not base or base in {".", ".."}: return fallback @@ -27,5 +27,5 @@ def sanitize_filename(name: str, *, fallback: str) -> str: def guess_mime_type(name: str, *, default: str = "application/octet-stream") -> str: - """Best-effort MIME type for a filename, used when the client sends none.""" + """MIME-тип по имени файла — на случай, если клиент его не прислал.""" return mimetypes.guess_type(name)[0] or default diff --git a/backend/src/domain/services/threat_scanner.py b/backend/src/domain/services/threat_scanner.py index a27dcc9d..1bb5fec2 100644 --- a/backend/src/domain/services/threat_scanner.py +++ b/backend/src/domain/services/threat_scanner.py @@ -1,7 +1,7 @@ -"""Threat scanning rules. +"""Правила проверки на угрозы. -Pure business logic: it only needs the declared metadata of an upload, never -its bytes, which is why scanning does not touch the filesystem at all. +Чистая бизнес-логика: нужны только заявленные метаданные загрузки, но не её +байты — поэтому сканирование вообще не обращается к файловой системе. """ from collections.abc import Iterator diff --git a/backend/src/domain/storage.py b/backend/src/domain/storage.py index 0f3dec30..4968e93c 100644 --- a/backend/src/domain/storage.py +++ b/backend/src/domain/storage.py @@ -1,4 +1,4 @@ -"""Binary storage port.""" +"""Порт бинарного хранилища.""" from collections.abc import AsyncIterator from pathlib import Path @@ -9,11 +9,11 @@ class FileStorage(Protocol): async def save(self, stored_name: str, chunks: AsyncIterator[bytes]) -> int: - """Persist ``chunks`` under ``stored_name`` and return the number of bytes written.""" + """Сохранить ``chunks`` под именем ``stored_name`` и вернуть число записанных байт.""" ... def read_chunks(self, stored_name: str, chunk_size: int | None = None) -> AsyncIterator[bytes]: - """Stream the stored object back.""" + """Отдать сохранённый объект потоком.""" ... async def delete(self, stored_name: str) -> None: ... @@ -21,11 +21,11 @@ async def delete(self, stored_name: str) -> None: ... async def exists(self, stored_name: str) -> bool: ... def local_path(self, stored_name: str) -> Path | None: - """Filesystem path of the object, when the adapter is backed by a local disk. + """Путь к объекту в файловой системе, если адаптер работает поверх локального диска. - Purely an optimisation hook: it lets the HTTP layer hand the descriptor - to the kernel (``sendfile``) instead of pumping bytes through Python. - Adapters backed by a remote object store return ``None`` and callers - fall back to :meth:`read_chunks`. + Чисто оптимизационный хук: позволяет HTTP-слою отдать дескриптор ядру + (``sendfile``) вместо того, чтобы гнать байты через Python. Адаптеры + поверх удалённого объектного хранилища возвращают ``None``, и вызывающий + код откатывается на :meth:`read_chunks`. """ ... diff --git a/backend/src/domain/value_objects.py b/backend/src/domain/value_objects.py index 6b1632a2..1325726b 100644 --- a/backend/src/domain/value_objects.py +++ b/backend/src/domain/value_objects.py @@ -1,7 +1,7 @@ -"""Value objects shared across the domain. +"""Value objects, общие для всего домена. -The string values are part of the persisted contract (they are stored verbatim -in Postgres and returned by the public API), so they must not be renamed. +Строковые значения — часть контракта хранения: они лежат в Postgres как есть и +возвращаются публичным API, поэтому переименовывать их нельзя. """ from enum import StrEnum diff --git a/backend/src/infrastructure/config.py b/backend/src/infrastructure/config.py index 6036e8fb..8821986e 100644 --- a/backend/src/infrastructure/config.py +++ b/backend/src/infrastructure/config.py @@ -1,7 +1,7 @@ -"""Typed application settings. +"""Типизированные настройки приложения. -Replaces the scattered ``os.environ.get(...)`` calls, which silently produced a -DSN containing the literal string ``None`` when a variable was missing. +Заменяют разбросанные вызовы ``os.environ.get(...)``, которые при отсутствующей +переменной молча собирали DSN со строкой ``None`` внутри. """ from functools import lru_cache @@ -19,7 +19,7 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False) postgres_user: str = "postgres" - postgres_password: str = "postgres" # noqa: S105 - local-dev fallback, overridden by the environment + postgres_password: str = "postgres" # noqa: S105 - значение для локальной разработки, перекрывается окружением postgres_db: str = "test" postgres_host: str = "backend-db" pgport: int = 5432 diff --git a/backend/src/infrastructure/db/engine.py b/backend/src/infrastructure/db/engine.py index a21b05aa..1d37544a 100644 --- a/backend/src/infrastructure/db/engine.py +++ b/backend/src/infrastructure/db/engine.py @@ -1,9 +1,9 @@ -"""Engine and session factory construction.""" +"""Создание engine и фабрики сессий.""" from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from src.infrastructure.config import Settings -from src.infrastructure.db import tables # noqa: F401 - registers the imperative mappings +from src.infrastructure.db import tables # noqa: F401 - регистрирует imperative-маппинги def create_engine(settings: Settings) -> AsyncEngine: diff --git a/backend/src/infrastructure/db/repositories.py b/backend/src/infrastructure/db/repositories.py index 2eee27ca..6cc12f99 100644 --- a/backend/src/infrastructure/db/repositories.py +++ b/backend/src/infrastructure/db/repositories.py @@ -1,4 +1,4 @@ -"""SQLAlchemy implementations of the domain repository ports.""" +"""Реализации доменных портов репозиториев на SQLAlchemy.""" from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -20,8 +20,8 @@ async def get(self, file_id: str) -> StoredFile | None: async def list_recent(self, *, limit: int, offset: int) -> list[StoredFile]: stmt = ( select(StoredFile) - # ``id`` breaks ties so that paging cannot show or skip a row twice - # when several uploads share a timestamp. + # ``id`` разрешает ничьи, чтобы при одинаковых метках времени + # пагинация не показала и не пропустила строку дважды. .order_by(files_table.c.created_at.desc(), files_table.c.id.desc()) .limit(limit) .offset(offset) diff --git a/backend/src/infrastructure/db/tables.py b/backend/src/infrastructure/db/tables.py index e431be80..ff0754a0 100644 --- a/backend/src/infrastructure/db/tables.py +++ b/backend/src/infrastructure/db/tables.py @@ -1,9 +1,9 @@ -"""Table definitions and the imperative mapping onto the domain entities. +"""Определения таблиц и imperative mapping на доменные сущности. -Using SQLAlchemy's *imperative* (classical) mapping instead of the declarative -base keeps the persistence schema here and the business rules in -:mod:`src.domain.entities`, without the duplication of a separate ORM model plus -a hand-written mapper. Alembic still autogenerates from ``metadata``. +*Imperative* (классический) маппинг вместо declarative base оставляет схему +хранения здесь, а бизнес-правила — в :mod:`src.domain.entities`, и при этом не +появляется дублирующей ORM-модели с ручным маппером. Alembic по-прежнему +автогенерирует миграции из ``metadata``. """ from sqlalchemy import ( @@ -44,8 +44,8 @@ Column("requires_attention", Boolean, nullable=False, default=False), Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), Column("updated_at", DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False), - # The listing is always "newest first"; without this index every page is a - # full scan plus a sort. + # Список всегда «сначала новые»; без этого индекса каждая страница — полное + # сканирование плюс сортировка. Index("ix_files_created_at_id", "created_at", "id"), ) @@ -57,21 +57,21 @@ Column("level", StrEnumType(AlertLevel, 50), nullable=False), Column("message", String(500), nullable=False), Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), - # Postgres does not index foreign keys automatically, so deleting a file had - # to scan the whole alerts table to check the constraint. + # Postgres не индексирует внешние ключи автоматически, поэтому при удалении + # файла проверка ограничения сканировала всю таблицу алертов. Index("ix_alerts_file_id", "file_id"), Index("ix_alerts_created_at_id", "created_at", "id"), ) def configure_mappings() -> None: - """Bind the domain entities to their tables (idempotent).""" + """Привязать доменные сущности к их таблицам (идемпотентно).""" if not mapper_registry.mappers: - # ``eager_defaults`` makes SQLAlchemy fetch server-generated columns - # (created_at / updated_at) via RETURNING as part of the INSERT or - # UPDATE, instead of leaving them expired and needing an extra - # round-trip refresh - which is what the original code paid for on - # every write. + # ``eager_defaults`` заставляет SQLAlchemy забирать колонки, которые + # генерирует база (created_at / updated_at), через RETURNING прямо в + # INSERT или UPDATE — вместо того чтобы оставлять их протухшими и делать + # лишний refresh отдельным запросом, как платил исходный код на каждой + # записи. mapper_registry.map_imperatively(StoredFile, files_table, eager_defaults=True) mapper_registry.map_imperatively(Alert, alerts_table, eager_defaults=True) diff --git a/backend/src/infrastructure/db/types.py b/backend/src/infrastructure/db/types.py index 81758cbe..9aa708b5 100644 --- a/backend/src/infrastructure/db/types.py +++ b/backend/src/infrastructure/db/types.py @@ -1,4 +1,4 @@ -"""Custom SQLAlchemy types bridging domain value objects and plain columns.""" +"""Свои типы SQLAlchemy, связывающие доменные value objects с обычными колонками.""" from enum import StrEnum from typing import Any @@ -8,11 +8,11 @@ class StrEnumType(TypeDecorator[StrEnum]): - """Stores a :class:`~enum.StrEnum` as a plain ``VARCHAR``. + """Хранит :class:`~enum.StrEnum` как обычный ``VARCHAR``. - Deliberately not ``sqlalchemy.Enum``: the existing columns are ``VARCHAR`` - and must stay that way, and a native enum would make adding a status a - migration instead of a code change. + Намеренно не ``sqlalchemy.Enum``: существующие колонки — ``VARCHAR`` и + должны такими остаться, а нативный enum превратил бы добавление статуса из + правки кода в миграцию. """ impl = String diff --git a/backend/src/infrastructure/db/unit_of_work.py b/backend/src/infrastructure/db/unit_of_work.py index f17c6934..89660ecf 100644 --- a/backend/src/infrastructure/db/unit_of_work.py +++ b/backend/src/infrastructure/db/unit_of_work.py @@ -1,4 +1,4 @@ -"""Transactional scope backed by an ``AsyncSession``.""" +"""Транзакционная область поверх ``AsyncSession``.""" from types import TracebackType @@ -9,10 +9,10 @@ class SqlAlchemyUnitOfWork: - """One session, one transaction, both repositories. + """Одна сессия, одна транзакция, оба репозитория. - Leaving the ``async with`` block without committing rolls the transaction - back, so a failing use case can never half-persist an aggregate. + Выход из блока ``async with`` без коммита откатывает транзакцию, поэтому + упавший use case не может сохранить агрегат наполовину. """ files: FileRepository @@ -39,10 +39,10 @@ async def __aexit__( if exc_type is not None and session.in_transaction(): await session.rollback() finally: - # ``close()`` releases the connection, which discards anything that - # was not committed, and - unlike ``rollback()`` - leaves the loaded - # entities readable after they are detached. Use cases return - # entities to the caller, so that difference matters. + # ``close()`` возвращает соединение в пул, отбрасывая всё + # незакоммиченное, и — в отличие от ``rollback()`` — оставляет + # загруженные сущности читаемыми после отвязки от сессии. Use case'ы + # возвращают сущности наружу, так что разница существенна. await session.close() self._session = None diff --git a/backend/src/infrastructure/queue/celery_app.py b/backend/src/infrastructure/queue/celery_app.py index c4898eae..19be9d87 100644 --- a/backend/src/infrastructure/queue/celery_app.py +++ b/backend/src/infrastructure/queue/celery_app.py @@ -1,4 +1,4 @@ -"""Celery application used by the worker and by the producing API process.""" +"""Приложение Celery, общее для воркера и для процесса API, который ставит задачи.""" from celery import Celery diff --git a/backend/src/infrastructure/queue/runner.py b/backend/src/infrastructure/queue/runner.py index d32e2deb..2b0d6c9a 100644 --- a/backend/src/infrastructure/queue/runner.py +++ b/backend/src/infrastructure/queue/runner.py @@ -1,8 +1,8 @@ -"""Bridges Celery's synchronous worker to the async application layer. +"""Мост между синхронным воркером Celery и асинхронным слоем приложения. -One :class:`asyncio.Runner` is kept alive for the lifetime of the worker -process, so the asyncpg connection pool is reused across tasks instead of being -rebuilt - or, worse, bound to an event loop that has already been closed. +Один :class:`asyncio.Runner` живёт всё время жизни процесса воркера, поэтому пул +соединений asyncpg переиспользуется между задачами, а не пересоздаётся — и, что +хуже, не привязывается к уже закрытому event loop'у. """ import asyncio @@ -11,7 +11,7 @@ class WorkerLoop: - """Owns the worker's event loop; created lazily on the first task.""" + """Владеет event loop'ом воркера; создаётся лениво на первой задаче.""" def __init__(self) -> None: self._runner: asyncio.Runner | None = None diff --git a/backend/src/infrastructure/queue/task_queue.py b/backend/src/infrastructure/queue/task_queue.py index 923e3160..718bb880 100644 --- a/backend/src/infrastructure/queue/task_queue.py +++ b/backend/src/infrastructure/queue/task_queue.py @@ -1,4 +1,4 @@ -"""Celery-backed implementation of the :class:`~src.application.ports.FileProcessingQueue` port.""" +"""Реализация порта :class:`~src.application.ports.FileProcessingQueue` поверх Celery.""" from anyio import to_thread from celery import Celery @@ -7,15 +7,15 @@ class CeleryFileProcessingQueue: - """Publishes by task *name*, so the API process never imports the task module.""" + """Публикует задачу по *имени*, поэтому процесс API не импортирует модуль задач.""" def __init__(self, celery_app: Celery, task_name: str = PROCESS_FILE_TASK) -> None: self._celery_app = celery_app self._task_name = task_name async def enqueue_processing(self, file_id: str) -> None: - # ``send_task`` talks to the broker over a blocking socket; running it - # on a worker thread keeps the API event loop responsive. + # ``send_task`` ходит к брокеру по блокирующему сокету; вынос в + # отдельный поток не даёт event loop'у API встать. await to_thread.run_sync(self._send, file_id) def _send(self, file_id: str) -> None: diff --git a/backend/src/infrastructure/queue/tasks.py b/backend/src/infrastructure/queue/tasks.py index fde23a70..a39cbe39 100644 --- a/backend/src/infrastructure/queue/tasks.py +++ b/backend/src/infrastructure/queue/tasks.py @@ -1,4 +1,4 @@ -"""Celery tasks: thin adapters that hand off to a use case.""" +"""Задачи Celery: тонкие адаптеры, передающие управление use case'у.""" import logging @@ -13,7 +13,7 @@ @celery_app.task(name=PROCESS_FILE_TASK) def process_file(file_id: str) -> None: - """Run the scan -> metadata -> alert pipeline for one uploaded file.""" + """Прогнать конвейер сканирование -> метаданные -> алерт для одного файла.""" worker_loop.run(get_container().process_file().execute(file_id)) diff --git a/backend/src/infrastructure/storage/local.py b/backend/src/infrastructure/storage/local.py index 0c677423..6811b306 100644 --- a/backend/src/infrastructure/storage/local.py +++ b/backend/src/infrastructure/storage/local.py @@ -1,8 +1,8 @@ -"""Filesystem-backed implementation of the :class:`~src.domain.storage.FileStorage` port. +"""Реализация порта :class:`~src.domain.storage.FileStorage` поверх файловой системы. -Every operation is awaited off the event loop (``anyio``), so a slow disk can no -longer stall the whole API process the way the previous blocking -``Path.write_bytes`` / ``Path.exists`` calls did. +Каждая операция уходит с event loop'а (через ``anyio``), поэтому медленный диск +больше не может застопорить весь процесс API — как это делали прежние +блокирующие ``Path.write_bytes`` и ``Path.exists``. """ import logging @@ -17,7 +17,7 @@ class UnsafeStoredNameError(ValueError): - """Raised when a stored name would resolve outside the storage root.""" + """Бросается, когда имя объекта разрешается за пределы корня хранилища.""" class LocalFileStorage: @@ -47,8 +47,8 @@ async def delete(self, stored_name: str) -> None: try: await anyio.Path(self._resolve(stored_name)).unlink(missing_ok=True) except OSError: - # Losing a blob must not fail the surrounding transaction; the row - # is already gone and the leftover is visible in the logs. + # Неудача при удалении файла не должна ронять внешнюю транзакцию: + # строка уже удалена, а остаток на диске виден в логах. logger.exception("Could not delete stored file %s", stored_name) async def exists(self, stored_name: str) -> bool: diff --git a/backend/src/presentation/http/app.py b/backend/src/presentation/http/app.py index d1d7e613..acfef3d7 100644 --- a/backend/src/presentation/http/app.py +++ b/backend/src/presentation/http/app.py @@ -1,4 +1,4 @@ -"""FastAPI application factory.""" +"""Фабрика приложения FastAPI.""" import logging from collections.abc import AsyncIterator diff --git a/backend/src/presentation/http/dependencies.py b/backend/src/presentation/http/dependencies.py index aaa785a8..cd1ae4c3 100644 --- a/backend/src/presentation/http/dependencies.py +++ b/backend/src/presentation/http/dependencies.py @@ -1,6 +1,6 @@ -"""FastAPI dependency providers. +"""Провайдеры зависимостей FastAPI. -Routers ask for a use case, never for a session, an engine or a Celery app. +Роутеры просят use case, а не сессию, engine или приложение Celery. """ from typing import Annotated diff --git a/backend/src/presentation/http/error_handlers.py b/backend/src/presentation/http/error_handlers.py index 1eca5b0f..91fc05ee 100644 --- a/backend/src/presentation/http/error_handlers.py +++ b/backend/src/presentation/http/error_handlers.py @@ -1,8 +1,8 @@ -"""Translates domain errors into HTTP responses. +"""Переводит доменные ошибки в HTTP-ответы. -Keeping this mapping in one place is what lets the use cases stay free of -``HTTPException`` - previously the persistence layer raised HTTP errors, which -made it unusable from the Celery worker. +Именно то, что этот маппинг собран в одном месте, позволяет use case'ам +обходиться без ``HTTPException``: раньше HTTP-ошибки бросал слой доступа к +данным, из-за чего он был непригоден для Celery-воркера. """ import logging diff --git a/backend/src/presentation/http/routers/alerts.py b/backend/src/presentation/http/routers/alerts.py index f4d44d6a..2dcc4486 100644 --- a/backend/src/presentation/http/routers/alerts.py +++ b/backend/src/presentation/http/routers/alerts.py @@ -1,4 +1,4 @@ -"""HTTP endpoints for the alert feed.""" +"""HTTP-эндпоинты ленты алертов.""" from typing import Annotated diff --git a/backend/src/presentation/http/routers/files.py b/backend/src/presentation/http/routers/files.py index 3d58d907..8ca4947f 100644 --- a/backend/src/presentation/http/routers/files.py +++ b/backend/src/presentation/http/routers/files.py @@ -1,4 +1,4 @@ -"""HTTP endpoints for files: parse, delegate, serialise. No business rules here.""" +"""HTTP-эндпоинты для файлов: разобрать, делегировать, сериализовать. Бизнес-правил здесь нет.""" from collections.abc import AsyncIterator from typing import Annotated @@ -33,7 +33,7 @@ async def _iter_upload(upload: UploadFile, chunk_size: int = DEFAULT_CHUNK_SIZE) -> AsyncIterator[bytes]: - """Yield the upload in chunks instead of materialising it in memory.""" + """Отдавать загрузку чанками, а не материализовать её в памяти.""" while chunk := await upload.read(chunk_size): yield chunk @@ -90,7 +90,7 @@ async def download_file( headers = {"Content-Length": str(download.file.size)} if download.local_path is not None: - # Local disk: let the kernel send the file, no bytes through Python. + # Локальный диск: отдать файл силами ядра, не гоняя байты через Python. return FileResponse( path=download.local_path, media_type=download.file.mime_type, diff --git a/backend/src/presentation/http/schemas.py b/backend/src/presentation/http/schemas.py index 7062db53..c3b5382e 100644 --- a/backend/src/presentation/http/schemas.py +++ b/backend/src/presentation/http/schemas.py @@ -1,8 +1,8 @@ -"""Transport models. +"""Транспортные модели. -Deliberately separate from the domain entities: the wire format is a contract -with the frontend and must be free to evolve independently of the business -model. The field set is unchanged from the original API. +Намеренно отделены от доменных сущностей: формат на проводе — это контракт с +фронтендом, и он должен меняться независимо от бизнес-модели. Набор полей +совпадает с исходным API. """ from datetime import datetime diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 25b20433..9ca3a29a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,7 +1,7 @@ -"""Shared fixtures. +"""Общие фикстуры. -The suite never needs Postgres or Redis: the domain and application layers only -know about ports, so tests plug in SQLite and in-memory doubles. +Набору тестов не нужны ни Postgres, ни Redis: домен и слой приложения знают +только про порты, поэтому тесты подставляют SQLite и in-memory заглушки. """ import sqlite3 @@ -31,8 +31,8 @@ async def engine(tmp_path: Path) -> AsyncIterator[AsyncEngine]: @event.listens_for(engine.sync_engine, "connect") def _enable_foreign_keys(connection: sqlite3.Connection, _: object) -> None: - # SQLite ignores foreign keys unless asked, and we want the - # ON DELETE CASCADE behaviour to be exercised here too. + # SQLite игнорирует внешние ключи, пока его не попросишь, а нам нужно + # проверить в том числе поведение ON DELETE CASCADE. connection.execute("PRAGMA foreign_keys=ON") async with engine.begin() as connection: @@ -50,8 +50,9 @@ def queue() -> RecordingQueue: @pytest.fixture def container(settings: Settings, engine: AsyncEngine, queue: RecordingQueue) -> Container: container = Container(settings=settings) - # ``cached_property`` reads through ``__dict__``: seeding it swaps the real - # Postgres engine and Celery broker for test doubles without any patching. + # ``cached_property`` читает через ``__dict__``: заполнив его заранее, мы + # подменяем настоящие Postgres и брокер Celery на заглушки без всякого + # патчинга. container.__dict__["engine"] = engine container.__dict__["session_factory"] = create_session_factory(engine) container.__dict__["storage"] = LocalFileStorage(settings.storage_dir) diff --git a/backend/tests/doubles.py b/backend/tests/doubles.py index 3eaa9a7e..1e80aa51 100644 --- a/backend/tests/doubles.py +++ b/backend/tests/doubles.py @@ -1,4 +1,4 @@ -"""In-memory implementations of the application ports.""" +"""In-memory реализации портов приложения.""" from collections.abc import AsyncIterator from pathlib import Path @@ -9,7 +9,7 @@ class RecordingQueue: - """A :class:`~src.application.ports.FileProcessingQueue` that just remembers calls.""" + """:class:`~src.application.ports.FileProcessingQueue`, который просто запоминает вызовы.""" def __init__(self) -> None: self.enqueued: list[str] = [] @@ -19,7 +19,7 @@ async def enqueue_processing(self, file_id: str) -> None: class InMemoryStorage: - """A :class:`~src.domain.storage.FileStorage` backed by a dict.""" + """:class:`~src.domain.storage.FileStorage` поверх обычного словаря.""" def __init__(self, chunk_size: int = 8) -> None: self.objects: dict[str, bytes] = {} @@ -82,7 +82,7 @@ async def list_recent(self, *, limit: int, offset: int) -> list[Alert]: class FakeUnitOfWork: - """Shares its state across instances so repeated ``uow_factory()`` calls see the same data.""" + """Делит состояние между экземплярами, чтобы повторные вызовы ``uow_factory()`` видели те же данные.""" files: FileRepository alerts: AlertRepository diff --git a/backend/tests/integration/test_api.py b/backend/tests/integration/test_api.py index 19925fc6..400b74b4 100644 --- a/backend/tests/integration/test_api.py +++ b/backend/tests/integration/test_api.py @@ -1,4 +1,4 @@ -"""HTTP contract tests driven through the real FastAPI app.""" +"""Тесты HTTP-контракта, прогоняемые через настоящее приложение FastAPI.""" from collections.abc import AsyncIterator diff --git a/backend/tests/integration/test_pipeline.py b/backend/tests/integration/test_pipeline.py index 59db7d02..8df334a9 100644 --- a/backend/tests/integration/test_pipeline.py +++ b/backend/tests/integration/test_pipeline.py @@ -1,4 +1,4 @@ -"""End-to-end exercise of the real adapters: SQLAlchemy, local disk, use cases.""" +"""Сквозная проверка настоящих адаптеров: SQLAlchemy, локальный диск, use case'ы.""" import pytest @@ -65,7 +65,7 @@ async def test_missing_blob_fails_the_file_and_raises_a_critical_alert(container file = await container.get_file().execute(file_id) assert file.processing_status is ProcessingStatus.FAILED assert file.scan_details == "stored file not found during metadata extraction" - # The clean verdict from the scan step survives; only processing failed. + # Вердикт «чисто» от шага сканирования сохраняется; упала только обработка. assert file.scan_status is ScanStatus.CLEAN (alert,) = await container.list_alerts().execute(Page()) @@ -107,13 +107,13 @@ async def test_rename_and_delete(container: Container) -> None: renamed = await container.rename_file().execute(file_id, " New name ") assert renamed.title == "New name" - await container.process_file().execute(file_id) # produces an alert referencing the file + await container.process_file().execute(file_id) # создаёт алерт, ссылающийся на файл await container.delete_file().execute(file_id) assert await container.storage.exists(stored_name) is False with pytest.raises(StoredFileNotFoundError): await container.get_file().execute(file_id) - # The FK now cascades, so deleting an already-alerted file no longer fails. + # Внешний ключ теперь каскадный, поэтому удаление файла с алертом не падает. assert await container.list_alerts().execute(Page()) == [] diff --git a/backend/tests/unit/test_architecture.py b/backend/tests/unit/test_architecture.py index 4db3ba3c..c92ee103 100644 --- a/backend/tests/unit/test_architecture.py +++ b/backend/tests/unit/test_architecture.py @@ -1,7 +1,8 @@ -"""Executable version of the dependency rule. +"""Исполняемая версия правила зависимостей. -Clean architecture is only worth something if it is enforced, so the layering -is asserted rather than described: inner layers must not import outer ones. +Clean architecture чего-то стоит, только если её соблюдение проверяется, поэтому +слои здесь не описаны, а утверждены: внутренние слои не должны импортировать +внешние. """ import ast @@ -13,7 +14,7 @@ FRAMEWORKS = ("sqlalchemy", "fastapi", "starlette", "celery", "anyio") -# layer package -> module prefixes it must never import +# пакет слоя -> префиксы модулей, которые он не должен импортировать никогда FORBIDDEN_IMPORTS = { "domain": (*FRAMEWORKS, "pydantic", "src.application", "src.infrastructure", "src.presentation"), "application": (*FRAMEWORKS, "src.infrastructure", "src.presentation"), diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 62856bbd..dd1a2cc5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -22,8 +22,8 @@ services: env_file: ".env.dev" volumes: - ./backend:/backend - # The worker reads the bytes the API wrote, so both must see the same - # storage volume. + # Воркер читает байты, которые записал API, поэтому оба должны видеть + # один и тот же volume хранилища. - backend-storage:/backend/storage depends_on: backend-db: @@ -38,8 +38,8 @@ services: - "5433:5433" env_file: ".env.dev" environment: - # Without PGDATA pointing inside the mount, the volume holds nothing and - # the database is recreated empty on every restart. + # Если PGDATA не указывает внутрь монтирования, volume остаётся пустым и + # база пересоздаётся с нуля при каждом перезапуске. PGDATA: /var/lib/postgresql/data/pgdata volumes: - backend-db-volume:/var/lib/postgresql/data diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b0c9ef99..be0066bf 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -24,8 +24,8 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -# NEXT_PUBLIC_* values are inlined at build time, so the API origin has to be -# known here rather than at runtime. +# Значения NEXT_PUBLIC_* подставляются на этапе сборки, поэтому origin API +# должен быть известен здесь, а не во время выполнения. ARG NEXT_PUBLIC_API_URL=http://localhost:8000 ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL diff --git a/frontend/src/entities/file/model/status.ts b/frontend/src/entities/file/model/status.ts index ab59aaed..450357b6 100644 --- a/frontend/src/entities/file/model/status.ts +++ b/frontend/src/entities/file/model/status.ts @@ -16,7 +16,7 @@ export function getScanVariant(file: FileItem): BadgeVariant { return file.requires_attention ? "warning" : "success"; } -/** A file the backend is still working on; the dashboard keeps polling these. */ +/** Файл, над которым бэкенд ещё работает; такие дашборд продолжает опрашивать. */ export function isPending(file: FileItem): boolean { return file.processing_status !== "processed" && file.processing_status !== "failed"; } diff --git a/frontend/src/features/upload-file/model/useUploadFile.ts b/frontend/src/features/upload-file/model/useUploadFile.ts index 9c239304..bc37353f 100644 --- a/frontend/src/features/upload-file/model/useUploadFile.ts +++ b/frontend/src/features/upload-file/model/useUploadFile.ts @@ -8,8 +8,8 @@ import { toMessage } from "@/shared/api/http"; type Options = { onUploaded: () => Promise | void }; /** - * Owns the upload form state and the submit workflow; the modal below is a - * pure rendering of what this hook exposes. + * Держит состояние формы загрузки и сценарий отправки; модалка ниже — чистая + * отрисовка того, что отдаёт этот хук. */ export function useUploadFile({ onUploaded }: Options) { const [isOpen, setIsOpen] = useState(false); diff --git a/frontend/src/shared/api/http.ts b/frontend/src/shared/api/http.ts index 33c86490..d0cca466 100644 --- a/frontend/src/shared/api/http.ts +++ b/frontend/src/shared/api/http.ts @@ -22,8 +22,8 @@ async function readErrorMessage(response: Response, fallback: string): Promise( path: string, diff --git a/frontend/src/shared/config/env.ts b/frontend/src/shared/config/env.ts index 50b3f114..69e89f1e 100644 --- a/frontend/src/shared/config/env.ts +++ b/frontend/src/shared/config/env.ts @@ -1,11 +1,11 @@ /** - * Runtime configuration. + * Конфигурация времени выполнения. * - * The API origin used to be hard-coded in the page component, which made the - * app impossible to deploy anywhere but a developer's laptop. + * Раньше origin API был зашит прямо в компонент страницы, из-за чего приложение + * невозможно было развернуть нигде, кроме ноутбука разработчика. */ export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ?? "http://localhost:8000"; -/** How often the dashboard re-checks files that are still being processed. */ +/** Как часто дашборд перепроверяет файлы, которые ещё обрабатываются. */ export const PROCESSING_POLL_INTERVAL_MS = 2000; diff --git a/frontend/src/shared/ui/AsyncSection.tsx b/frontend/src/shared/ui/AsyncSection.tsx index 9056b8d8..7baf0c07 100644 --- a/frontend/src/shared/ui/AsyncSection.tsx +++ b/frontend/src/shared/ui/AsyncSection.tsx @@ -1,6 +1,6 @@ import { Spinner } from "react-bootstrap"; -/** Renders a spinner while loading, otherwise the children. */ +/** Показывает спиннер во время загрузки, иначе — вложенное содержимое. */ export function AsyncSection({ isLoading, children }: { isLoading: boolean; children: React.ReactNode }) { if (isLoading) { return ( diff --git a/frontend/src/views/files-dashboard/model/useFilesDashboard.ts b/frontend/src/views/files-dashboard/model/useFilesDashboard.ts index d1c25140..ab0019ab 100644 --- a/frontend/src/views/files-dashboard/model/useFilesDashboard.ts +++ b/frontend/src/views/files-dashboard/model/useFilesDashboard.ts @@ -11,8 +11,8 @@ import { toMessage } from "@/shared/api/http"; import { PROCESSING_POLL_INTERVAL_MS } from "@/shared/config/env"; /** - * All of the dashboard's data flow lives here, so the components below stay - * declarative: they render what they are given and raise events. + * Весь поток данных дашборда живёт здесь, поэтому компоненты ниже остаются + * декларативными: они рисуют то, что им дали, и порождают события. */ export function useFilesDashboard() { const [files, setFiles] = useState([]); @@ -56,9 +56,10 @@ export function useFilesDashboard() { void load(); }, [load]); - // Processing happens in a Celery worker, so a freshly uploaded file reaches - // its final status a moment after the upload response. Refresh quietly until - // everything has settled instead of making the user press "Обновить". + // Обработка идёт в воркере Celery, поэтому только что загруженный файл + // доходит до финального статуса чуть позже ответа на загрузку. Тихо обновляем + // данные, пока всё не устоится, вместо того чтобы заставлять пользователя + // жать "Обновить". const hasPending = files.some(isPending); useEffect(() => { if (!hasPending) {