From e1d19078356a2259a0ebfa0edee1826e47a7deff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 08:59:52 +0000 Subject: [PATCH 1/2] Make boilerplate production-ready: latest deps, header-based API versioning - Update all dependencies to latest (Django 6.0.6, DRF 3.17.1, redis 8, ruff 0.15, pytest 9.0.3, etc.) and regenerate uv.lock - Add production packages: gunicorn, psycopg, whitenoise, drf-spectacular - Switch API versioning to AcceptHeaderVersioning: version is passed via Accept header (application/json; version=v1), URLs drop the /v1/ prefix - Add OpenAPI schema, Swagger UI and ReDoc endpoints (drf-spectacular) - Add /health/ endpoint checking database and Redis - Fill in dev/prod settings: security hardening (HSTS, secure cookies, SSL redirect), whitenoise static storage, Redis cache, logging, DATABASE_URL-based config, DRF pagination/throttling/permissions - Fix entrypoint.sh: config.wsgi instead of core.wsgi, drop unused gevent/celery commands, wait for PostgreSQL - Add PostgreSQL service to docker-compose with healthcheck and wiring - Add GitHub Actions CI (lint + tests), expand Makefile targets - Add header-versioning tests, fix all ruff/bandit findings - Rewrite README with quickstart, versioning guide and configuration docs https://claude.ai/code/session_01YZr4TbzTw6EkkFqZz2onTF --- .env.example | 24 +- .github/workflows/ci.yml | 45 ++ Dockerfile | 3 +- Makefile | 40 +- README.md | 201 +++++-- apps/users/admin.py | 2 + apps/users/api/serializers.py | 3 + apps/users/api/urls.py | 3 +- apps/users/api/views.py | 16 +- apps/users/apps.py | 2 + apps/users/managers.py | 8 +- apps/users/migrations/0001_initial.py | 41 +- apps/users/selectors.py | 1 + apps/users/tests/conftest.py | 4 +- apps/users/tests/test_get_user_api_view.py | 58 +- .../tests/test_register_user_api_view.py | 44 +- apps/users/urls.py | 4 +- apps/users/views.py | 2 - commons/mixins/models.py | 2 +- config/settings/base.py | 84 ++- config/settings/dev.py | 20 +- config/settings/prod.py | 25 +- config/urls.py | 34 +- config/views.py | 30 + docker-compose.yml | 30 +- entrypoint.sh | 39 +- manage.py | 5 +- pyproject.toml | 33 +- uv.lock | 528 +++++++++++++----- 29 files changed, 1020 insertions(+), 311 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 config/views.py diff --git a/.env.example b/.env.example index 07f88a4..645ad68 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,27 @@ -# Project environment +# Project environment: dev | prod ENVIRONMENT=dev # Django settings DJANGO_SECRET_KEY=your_secret_key_here DJANGO_DEBUG=True -DJANGO_ALLOWED_HOSTS=localhost, +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 +# DJANGO_CSRF_TRUSTED_ORIGINS=https://example.com +# DJANGO_LOG_LEVEL=INFO -# Database settings -DATABASE_URL=postgres://user:password@localhost:5432/dbname +# Database settings (по умолчанию — sqlite, для PostgreSQL раскомментируйте) +# DATABASE_URL=postgres://app:app@localhost:5432/app + +# Postgres (используется docker-compose) +POSTGRES_DB=app +POSTGRES_USER=app +POSTGRES_PASSWORD=app # Redis settings -REDIS_URL=redis://localhost:6379/0 \ No newline at end of file +REDIS_URL=redis://localhost:6379/1 +REDIS_HOST=localhost +REDIS_PORT=6379 + +# Production security (используются при ENVIRONMENT=prod) +# DJANGO_SECURE_SSL_REDIRECT=True +# DJANGO_SECURE_HSTS_SECONDS=2592000 +# GUNICORN_WORKERS=4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1f0af00 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --group dev + + - name: Ruff check + run: uv run ruff check . + + - name: Ruff format check + run: uv run ruff format --check . + + - name: Bandit + run: uv run bandit -c pyproject.toml -r . + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --group dev + + - name: Run tests + run: uv run pytest -v diff --git a/Dockerfile b/Dockerfile index c086bc1..a5db3bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ARG UID=1000 -ARG PYTHON_VERSION=3.13.7 +ARG PYTHON_VERSION=3.13 FROM python:${PYTHON_VERSION}-slim-trixie AS builder @@ -35,6 +35,7 @@ RUN apt-get update && apt-get install -y \ netcat-openbsd \ && rm -rf /var/lib/apt/lists/* +ARG UID RUN useradd --create-home --shell /bin/bash --uid ${UID:-1000} django USER django WORKDIR /home/django/app diff --git a/Makefile b/Makefile index df5d283..2658280 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,44 @@ -.PHONY: env +.PHONY: env install run migrate makemigrations superuser test lint format up down logs env: @if [ -f .env ]; then \ echo ".env уже существует, пропускаю."; \ else \ cp .env.example .env && echo "Создано .env из .env.example."; \ - fi \ No newline at end of file + fi + +install: + uv sync --group dev + +run: + uv run python manage.py runserver + +migrate: + uv run python manage.py migrate + +makemigrations: + uv run python manage.py makemigrations + +superuser: + uv run python manage.py createsuperuser + +test: + uv run pytest + +lint: + uv run ruff check . + uv run ruff format --check . + uv run bandit -c pyproject.toml -r . + +format: + uv run ruff check --fix . + uv run ruff format . + +up: + docker compose up -d --build + +down: + docker compose down + +logs: + docker compose logs -f django diff --git a/README.md b/README.md index b88a643..bf92ca7 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,160 @@ -### Общая архитектура - -Проект построен как **монолитное API-ориентированное приложение** (API-first Monolith). Архитектура спроектирована для быстрого старта разработки, но с запасом прочности для масштабирования до HighLoad-системы. В основе лежит принцип **модульности**: весь бизнес-функционал инкапсулирован в изолированные приложения внутри директории `apps/`. - -Система следует стандартам **Twelve-Factor App**, что обеспечивает её готовность к развертыванию в облачных средах (Kubernetes/Docker) и легкость в горизонтальном масштабировании. - -### Уровни системы - -Архитектура разделена на логические слои, чтобы снизить связность кода (Loose Coupling): - -1. **Инфраструктурный слой (`config/`, `pyproject.toml`)**: - * Централизованная конфигурация, отделенная от кода бизнес-логики. - * Управление зависимостями через современный стандарт PEP 735 (dependency groups), что упрощает CI/CD пайплайны. - * Сюда входят настройки безопасности, логирования и подключений к БД. -2. **Слой API (Interface Layer)**: - * Отвечает за принятие запросов и валидацию входных данных. - * Реализован на **Django REST Framework**. - * Строгое разделение: ViewSet'ы занимаются только HTTP-логикой, не содержат бизнес-правил. -3. **Слой бизнес-логики (Domain Layer)**: - * Расположен внутри приложений в `apps/`. - * Инкапсулирует правила работы системы. Здесь живут сервисы (Services) и селекторы (Selectors), которые отделены от моделей и view. - * Модели данных ("толстые модели" используются умеренно) отвечают только за целостность данных, но не за сложные процессы. -4. **Слой данных (Persistence Layer)**: - * PostgreSQL как основное хранилище. - * Использование миграций Django для версионирования схемы БД. - -### Ключевые архитектурные решения - -* **Изоляция приложений**: -Каждый модуль в папке `apps/` (например, `users`, `billing`) проектируется как независимый компонент с собственным пространством имен (`apps.users`). Это предотвращает "спагетти-импорты" и облегчает будущий вынос модулей в микросервисы при необходимости. -* **Безопасность по умолчанию**: -В архитектуру встроены инструменты статического анализа (`bandit`, `ruff`), которые на этапе разработки блокируют уязвимый код. Конфигурация Django (`settings.py`) ужесточена для production-среды. -* **Строгая типизация и качество кода**: -Проект не допускает неявных типов. Использование `mypy` и `ruff` гарантирует, что кодовая база остается чистой, читаемой и предсказуемой, что критично для командной разработки. -* **Готовность к Observability**: -Архитектура подразумевает, что логирование и метрики (Tracing/Metrics) внедряются через middleware и не загрязняют бизнес-код, что упрощает подключение систем мониторинга (Prometheus/Grafana/Sentry). - - ---- - -Это **Modern Python Backend** архитектура: она отказывается от устаревших практик (как requirements.txt или запутанных settings) в пользу строгих стандартов, типизации и модульности. Она идеально подходит для команд, где важны скорость разработки (Time-to-Market) и надежность (Reliability). +# Django Boilerplate + +Production-ready шаблон для создания API-приложений на Django. + +**Стек:** Python 3.13 · Django 6.0 · DRF 3.17 · PostgreSQL · Redis · uv · Docker + +## Возможности + +- **API-first монолит** с разделением на слои: views → services/selectors → models. +- **Версионирование API через заголовок** (`AcceptHeaderVersioning`) — версия не загрязняет URL. +- **OpenAPI-документация** из коробки (drf-spectacular): Swagger UI и ReDoc. +- **12-factor конфигурация** через переменные окружения (`django-environ`) и разделение настроек dev/prod (`django-split-settings`). +- **Production-настройки по умолчанию**: HSTS, secure cookies, SSL redirect, манифестная статика через whitenoise, gunicorn. +- **Кастомная модель пользователя** с email-логином и `external_id` (UUID) для внешних интеграций. +- **Health-check** (`/health/`) с проверкой БД и Redis — готов для Kubernetes liveness/readiness probes. +- **Качество кода**: ruff (lint + format), bandit, ty, pre-commit, pytest, GitHub Actions CI. +- **Docker**: multi-stage Dockerfile (non-root user), docker-compose с PostgreSQL и Redis. + +## Быстрый старт + +### Docker (рекомендуется) + +```bash +make env # создать .env из .env.example +make up # собрать и запустить postgres + redis + django +make logs # смотреть логи +``` + +Приложение: http://localhost:8000, Swagger UI: http://localhost:8000/api/docs/ + +### Локально + +```bash +make env +make install # uv sync --group dev +make migrate +make run +``` + +По умолчанию локально используется SQLite; для PostgreSQL задайте `DATABASE_URL` в `.env`. + +## Версионирование API + +Используется `AcceptHeaderVersioning`: клиент передаёт версию в заголовке `Accept`, +URL остаются стабильными (`/api/users/...` без `/v1/`). + +```bash +# Явно указанная версия +curl http://localhost:8000/api/users/me/ \ + -H 'Accept: application/json; version=v1' + +# Без версии — используется DEFAULT_VERSION (v1) +curl http://localhost:8000/api/users/me/ + +# Неподдерживаемая версия → 406 Not Acceptable +curl http://localhost:8000/api/users/me/ \ + -H 'Accept: application/json; version=v999' +``` + +Версия доступна во view через `request.version`. Список поддерживаемых версий — +`ALLOWED_VERSIONS` в `config/settings/base.py`. Чтобы выпустить `v2`, добавьте версию +в `ALLOWED_VERSIONS` и ветвите логику по `request.version` (или подключайте другой +сериализатор), не меняя URL. + +## Эндпоинты + +| URL | Описание | +| --- | --- | +| `POST /api/users/register/` | Регистрация пользователя | +| `GET /api/users/me/` | Профиль текущего пользователя | +| `GET /api/schema/` | OpenAPI-схема | +| `GET /api/docs/` | Swagger UI | +| `GET /api/redoc/` | ReDoc | +| `GET /health/` | Health-check (БД + Redis), 503 при сбое | +| `/admin/` | Django admin | + +## Команды + +```bash +make install # установить зависимости (с dev-группой) +make run # запустить dev-сервер +make migrate # применить миграции +make makemigrations # создать миграции +make superuser # создать суперпользователя +make test # запустить тесты +make lint # ruff check + format check + bandit +make format # автоформатирование +make up / down # docker compose up/down +``` + +## Структура проекта + +``` +├── apps/ # Бизнес-модули (изолированные приложения) +│ └── users/ +│ ├── api/ # Слой API: serializers, views, urls +│ ├── services.py # Бизнес-логика (запись) +│ ├── selectors.py # Бизнес-логика (чтение) +│ ├── models.py +│ └── tests/ +├── commons/ # Переиспользуемый код (mixins, redis-клиент) +├── config/ # Конфигурация проекта +│ ├── settings/ # base.py + dev.py / prod.py (split-settings) +│ ├── urls.py +│ └── views.py # health-check +├── externals/ # Клиенты внешних сервисов +├── docs/styleguide/ # Соглашения по коду +├── Dockerfile # Multi-stage, non-root +├── docker-compose.yml # postgres + redis + django +└── pyproject.toml # Зависимости (uv), ruff, bandit, pytest +``` + +## Конфигурация + +Все настройки — через переменные окружения (см. `.env.example`). Ключевые: + +| Переменная | Назначение | По умолчанию | +| --- | --- | --- | +| `ENVIRONMENT` | `dev` или `prod` (выбор настроек) | `dev` | +| `DJANGO_SECRET_KEY` | Секретный ключ (обязателен) | — | +| `DJANGO_DEBUG` | Режим отладки | `False` (prod), `True` (dev) | +| `DJANGO_ALLOWED_HOSTS` | Разрешённые хосты через запятую | — | +| `DATABASE_URL` | URL базы данных | SQLite | +| `REDIS_URL` | URL Redis для кэша | `redis://redis:6379/1` | +| `GUNICORN_WORKERS` | Количество воркеров gunicorn | `4` | + +### Production + +`ENVIRONMENT=prod` включает: `DEBUG=False`, SSL redirect, HSTS, secure cookies, +JSON-only рендеринг DRF, манифестную статику (whitenoise) и запуск через gunicorn +(`MODE=prod` в docker-compose). + +```bash +ENVIRONMENT=prod MODE=prod INSTALL_DEV=false docker compose up -d --build +``` + +## Архитектура + +Проект построен как **монолитное API-ориентированное приложение** (API-first Monolith) +по стандартам **Twelve-Factor App**. Бизнес-функционал инкапсулирован в изолированные +приложения внутри `apps/` — каждый модуль проектируется как независимый компонент, +что облегчает будущий вынос в микросервисы. + +Слои: + +1. **API (Interface Layer)** — DRF views валидируют ввод/вывод, не содержат бизнес-правил. +2. **Бизнес-логика (Domain Layer)** — services (запись) и selectors (чтение) внутри приложений. +3. **Данные (Persistence Layer)** — PostgreSQL, миграции Django. + +Подробнее — в `docs/styleguide/`. + +## Качество кода + +```bash +uv run pre-commit install # включить pre-commit хуки +make lint # проверка +make test # тесты +``` + +CI (GitHub Actions) запускает линтеры и тесты на каждый push в `main` и pull request. diff --git a/apps/users/admin.py b/apps/users/admin.py index d761427..638b75e 100644 --- a/apps/users/admin.py +++ b/apps/users/admin.py @@ -6,6 +6,8 @@ @admin.register(User) class UserAdmin(BaseUserAdmin): + """Админ-панель для модели пользователя с email в качестве логина.""" + ordering = ['email'] list_display = ['email', 'is_staff', 'is_active'] list_filter = ['is_staff', 'is_active'] diff --git a/apps/users/api/serializers.py b/apps/users/api/serializers.py index 217a1d4..006a9f8 100644 --- a/apps/users/api/serializers.py +++ b/apps/users/api/serializers.py @@ -1,6 +1,8 @@ from rest_framework import serializers + from apps.users.models import User + class UserRegisterRequestSerializer(serializers.Serializer): """Сериализатор для валидации входящих данных при регистрации.""" @@ -26,6 +28,7 @@ class UserResponseSerializer(serializers.ModelSerializer): Сериализатор для формирования ответа с данными пользователя. Исключает чувствительные данные (пароли, права доступа). """ + class Meta: model = User fields = ('external_id', 'email', 'date_joined', 'is_active') diff --git a/apps/users/api/urls.py b/apps/users/api/urls.py index 0c91361..a14548a 100644 --- a/apps/users/api/urls.py +++ b/apps/users/api/urls.py @@ -1,5 +1,6 @@ from django.urls import path -from apps.users.api.views import UserRegisterApi, UserMeApi + +from apps.users.api.views import UserMeApi, UserRegisterApi app_name = 'users_api' urlpatterns = [ diff --git a/apps/users/api/views.py b/apps/users/api/views.py index ba30562..8081349 100644 --- a/apps/users/api/views.py +++ b/apps/users/api/views.py @@ -1,4 +1,5 @@ from django.core.exceptions import ValidationError +from drf_spectacular.utils import extend_schema from rest_framework import status from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request @@ -16,8 +17,14 @@ class UserRegisterApi(APIView): request_serializer = UserRegisterRequestSerializer response_serializer = UserResponseSerializer + @extend_schema( + request=UserRegisterRequestSerializer, + responses={status.HTTP_201_CREATED: UserResponseSerializer}, + summary='Регистрация нового пользователя', + ) def post(self, request: Request) -> Response: - """Обрабатывает POST запрос на регистрацию. + """ + Обрабатывает POST запрос на регистрацию. 1. Валидирует входящие данные через InputSerializer. 2. Вызывает сервис создания пользователя. @@ -44,8 +51,13 @@ class UserMeApi(APIView): permission_classes = [IsAuthenticated] response_serializer = UserResponseSerializer + @extend_schema( + responses={status.HTTP_200_OK: UserResponseSerializer}, + summary='Профиль текущего пользователя', + ) def get(self, request: Request) -> Response: - """Возвращает данные текущего авторизованного пользователя. + """ + Возвращает данные текущего авторизованного пользователя. :param request: Объект HTTP запроса. :return: HTTP ответ с данными профиля. diff --git a/apps/users/apps.py b/apps/users/apps.py index a173db1..5250607 100644 --- a/apps/users/apps.py +++ b/apps/users/apps.py @@ -2,5 +2,7 @@ class UsersConfig(AppConfig): + """Конфигурация приложения пользователей.""" + name = 'apps.users' verbose_name = 'Пользователи' diff --git a/apps/users/managers.py b/apps/users/managers.py index 09d987d..cd63bac 100644 --- a/apps/users/managers.py +++ b/apps/users/managers.py @@ -39,9 +39,9 @@ def create_superuser(self, email: str, password: str | None = None, **extra_fiel extra_fields.setdefault('is_superuser', True) extra_fields.setdefault('is_active', True) - if extra_fields.get("is_staff") is not True: - raise ValueError("Superuser must have is_staff=True.") - if extra_fields.get("is_superuser") is not True: - raise ValueError("Superuser must have is_superuser=True.") + if extra_fields.get('is_staff') is not True: + raise ValueError('Superuser must have is_staff=True.') + if extra_fields.get('is_superuser') is not True: + raise ValueError('Superuser must have is_superuser=True.') return self.create_user(email, password, **extra_fields) diff --git a/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py index fe6e3db..e2189d6 100644 --- a/apps/users/migrations/0001_initial.py +++ b/apps/users/migrations/0001_initial.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -19,15 +18,47 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ( + 'is_superuser', + models.BooleanField( + default=False, + help_text='Designates that this user has all permissions without explicitly assigning them.', + verbose_name='superuser status', + ), + ), ('external_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), ('email', models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='Email')), - ('slug', models.CharField(blank=True, max_length=255, null=True, unique=True, verbose_name='Слаг страницы пользователя')), + ( + 'slug', + models.CharField( + blank=True, max_length=255, null=True, unique=True, verbose_name='Слаг страницы пользователя' + ), + ), ('is_staff', models.BooleanField(default=False, verbose_name='Персонал')), ('is_active', models.BooleanField(default=True, verbose_name='Активный')), ('date_joined', models.DateTimeField(auto_now_add=True)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ( + 'groups', + models.ManyToManyField( + blank=True, + help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', + related_name='user_set', + related_query_name='user', + to='auth.group', + verbose_name='groups', + ), + ), + ( + 'user_permissions', + models.ManyToManyField( + blank=True, + help_text='Specific permissions for this user.', + related_name='user_set', + related_query_name='user', + to='auth.permission', + verbose_name='user permissions', + ), + ), ], options={ 'verbose_name': 'Пользователь', diff --git a/apps/users/selectors.py b/apps/users/selectors.py index 8b6e4c1..78b845c 100644 --- a/apps/users/selectors.py +++ b/apps/users/selectors.py @@ -1,5 +1,6 @@ from apps.users.models import User + def get_user_by_email(*, email: str) -> User | None: """ Получает пользователя по email. diff --git a/apps/users/tests/conftest.py b/apps/users/tests/conftest.py index 54722fd..3b5ceee 100644 --- a/apps/users/tests/conftest.py +++ b/apps/users/tests/conftest.py @@ -30,8 +30,8 @@ def user_factory( """ def make_user( - email: str = "user@example.com", - password: str = "password", + email: str = 'user@example.com', + password: str = 'password', **extra: Any, ) -> AbstractBaseUser: """ diff --git a/apps/users/tests/test_get_user_api_view.py b/apps/users/tests/test_get_user_api_view.py index 02c8d43..141503c 100644 --- a/apps/users/tests/test_get_user_api_view.py +++ b/apps/users/tests/test_get_user_api_view.py @@ -4,7 +4,6 @@ from rest_framework import status from rest_framework.test import APIClient - pytestmark = [pytest.mark.django_db] @@ -15,7 +14,7 @@ def test_user_me_get_success(api_client: APIClient, user_factory: Any) -> None: Arrange: - Создаем пользователя. - Аутентифицируем клиента этим пользователем. - - Готовим GET-запрос к /api/v1/users/me/. + - Готовим GET-запрос к /api/users/me/. Act: - Выполняем запрос через APIClient. @@ -27,19 +26,64 @@ def test_user_me_get_success(api_client: APIClient, user_factory: Any) -> None: user = user_factory(email='exist@example.com', password='pwd123') api_client.force_authenticate(user=user) - response = api_client.get("/api/v1/users/me/") + response = api_client.get('/api/users/me/') + + assert response.status_code == status.HTTP_200_OK + assert response.data.get('email') == user.email + + +def test_user_me_with_version_header(api_client: APIClient, user_factory: Any) -> None: + """ + Проверяет работу версионирования API через заголовок Accept. + + Arrange: + - Создаем пользователя и аутентифицируем клиента. + - Готовим GET-запрос с заголовком Accept: application/json; version=v1. + + Act: + - Выполняем запрос через APIClient. + + Assert: + - Проверяем HTTP-статус 200 (OK) для поддерживаемой версии. + """ + user = user_factory(email='versioned@example.com', password='pwd123') + + api_client.force_authenticate(user=user) + response = api_client.get('/api/users/me/', HTTP_ACCEPT='application/json; version=v1') assert response.status_code == status.HTTP_200_OK - assert response.data.get("email") == user.email + assert response.data.get('email') == user.email + + +def test_user_me_with_unsupported_version_header(api_client: APIClient, user_factory: Any) -> None: + """ + Проверяет отказ при запросе неподдерживаемой версии API в заголовке Accept. + + Arrange: + - Создаем пользователя и аутентифицируем клиента. + - Готовим GET-запрос с заголовком Accept: application/json; version=v999. + + Act: + - Выполняем запрос через APIClient. + + Assert: + - Проверяем HTTP-статус 406 (Not Acceptable). + """ + user = user_factory(email='unsupported@example.com', password='pwd123') + + api_client.force_authenticate(user=user) + response = api_client.get('/api/users/me/', HTTP_ACCEPT='application/json; version=v999') + + assert response.status_code == status.HTTP_406_NOT_ACCEPTABLE -def test_user_me_unauthorized( api_client: APIClient) -> None: +def test_user_me_unauthorized(api_client: APIClient) -> None: """ Проверяет запрет доступа к профилю для анонимного пользователя. Arrange: - Не аутентифицируем клиента. - - Готовим GET-запрос к /api/v1/users/me/. + - Готовим GET-запрос к /api/users/me/. Act: - Выполняем запрос через APIClient. @@ -47,7 +91,7 @@ def test_user_me_unauthorized( api_client: APIClient) -> None: Assert: - Проверяем HTTP-статус 401 или 403. """ - response = api_client.get("/api/v1/users/me/") + response = api_client.get('/api/users/me/') assert response.status_code in ( status.HTTP_401_UNAUTHORIZED, diff --git a/apps/users/tests/test_register_user_api_view.py b/apps/users/tests/test_register_user_api_view.py index 262cef2..2e396b1 100644 --- a/apps/users/tests/test_register_user_api_view.py +++ b/apps/users/tests/test_register_user_api_view.py @@ -16,7 +16,7 @@ def test_user_register_success( Arrange: - Подготавливаем валидные данные (email, совпадающие пароли). - - Формируем POST-запрос к /api/v1/users/register/. + - Формируем POST-запрос к /api/users/register/. Act: - Выполняем запрос через APIClient. @@ -25,18 +25,18 @@ def test_user_register_success( - Проверяем HTTP-статус 201 (Created). - Проверяем, что возвращенный email соответствует переданному. """ - email = "newuser@example.com" - password = "strongpassword" + email = 'newuser@example.com' + password = 'strongpassword' payload = { - "email": email, - "password": password, - "confirm_password": password, + 'email': email, + 'password': password, + 'confirm_password': password, } response = api_client.post( - "/api/v1/users/register/", + '/api/users/register/', data=payload, - format="json", + format='json', ) assert response.status_code == status.HTTP_201_CREATED @@ -53,7 +53,7 @@ def test_user_register_service_validation_error( Arrange: - Подготавливаем данные для регистрации. - Мокаем сервисный слой create_user на выброс ошибки. - - Формируем POST-запрос к /api/v1/users/register/. + - Формируем POST-запрос к /api/users/register/. Act: - Выполняем запрос через APIClient. @@ -63,21 +63,21 @@ def test_user_register_service_validation_error( - Проверяем наличие сообщения об ошибке. """ mock_create_user = mocker.patch( - "apps.users.api.views.create_user", + 'apps.users.api.views.create_user', ) mock_create_user.side_effect = ValidationError('User already exists') - password = "strongpassword" + password = 'strongpassword' payload = { - "email": "bad@example.com", - "password": password, - "confirm_password": password, + 'email': 'bad@example.com', + 'password': password, + 'confirm_password': password, } response = api_client.post( - "/api/v1/users/register/", + '/api/users/register/', data=payload, - format="json", + format='json', ) assert response.status_code == status.HTTP_400_BAD_REQUEST @@ -92,7 +92,7 @@ def test_user_register_password_mismatch( Arrange: - Подготавливаем данные с разными паролями. - - Формируем запрос к /api/v1/users/register/. + - Формируем запрос к /api/users/register/. Act: - Выполняем запрос через APIClient. @@ -101,15 +101,15 @@ def test_user_register_password_mismatch( - Проверяем HTTP-статус 400 (Bad Request). """ payload = { - "email": "test@example.com", - "password": "password123", - "confirm_password": "password456", + 'email': 'test@example.com', + 'password': 'password123', + 'confirm_password': 'password456', } response = api_client.post( - "/api/v1/users/register/", + '/api/users/register/', data=payload, - format="json", + format='json', ) assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/apps/users/urls.py b/apps/users/urls.py index 437f584..63d6d34 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -1,4 +1,2 @@ -from django.urls import path - app_name = 'users' -urlpatterns = [] \ No newline at end of file +urlpatterns = [] diff --git a/apps/users/views.py b/apps/users/views.py index 91ea44a..60f00ef 100644 --- a/apps/users/views.py +++ b/apps/users/views.py @@ -1,3 +1 @@ -from django.shortcuts import render - # Create your views here. diff --git a/commons/mixins/models.py b/commons/mixins/models.py index 4d4ea18..9c7b82d 100644 --- a/commons/mixins/models.py +++ b/commons/mixins/models.py @@ -8,4 +8,4 @@ class AutoTimestampMixin(models.Model): updated_at = models.DateTimeField(auto_now=True, verbose_name='Дата обновления') class Meta: - abstract = True \ No newline at end of file + abstract = True diff --git a/config/settings/base.py b/config/settings/base.py index 3d8b09f..4b663ad 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -5,7 +5,9 @@ env = environ.Env() BASE_DIR = Path(__file__).resolve().parent.parent.parent -environ.Env.read_env(BASE_DIR / '.env') + +if (BASE_DIR / '.env').exists(): + environ.Env.read_env(BASE_DIR / '.env') SECRET_KEY = env.str('DJANGO_SECRET_KEY') @@ -13,6 +15,8 @@ ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=[]) +CSRF_TRUSTED_ORIGINS = env.list('DJANGO_CSRF_TRUSTED_ORIGINS', default=[]) + INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -22,12 +26,15 @@ 'django.contrib.staticfiles', # Dependency apps 'rest_framework', + 'django_filters', + 'drf_spectacular', # Local apps 'apps.users', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -56,11 +63,12 @@ WSGI_APPLICATION = 'config.wsgi.application' DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - }, + 'default': env.db('DATABASE_URL', default=f'sqlite:///{BASE_DIR / "db.sqlite3"}'), } +DATABASES['default']['CONN_MAX_AGE'] = env.int('DJANGO_DB_CONN_MAX_AGE', default=60) +DATABASES['default']['CONN_HEALTH_CHECKS'] = True + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' AUTH_USER_MODEL = 'users.User' @@ -88,12 +96,78 @@ USE_TZ = True STATIC_URL = 'static/' +STATIC_ROOT = env.path('DJANGO_STATIC_ROOT', default=BASE_DIR / 'staticfiles') + +STORAGES = { + 'default': { + 'BACKEND': 'django.core.files.storage.FileSystemStorage', + }, + 'staticfiles': { + 'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage', + }, +} REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.BasicAuthentication', ), + 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',), + # Версионирование API через заголовок Accept: + # Accept: application/json; version=v1 + 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', + 'DEFAULT_VERSION': 'v1', + 'ALLOWED_VERSIONS': ('v1',), + 'DEFAULT_FILTER_BACKENDS': ('django_filters.rest_framework.DjangoFilterBackend',), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 20, + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_THROTTLE_CLASSES': ('rest_framework.throttling.AnonRateThrottle',), + 'DEFAULT_THROTTLE_RATES': { + 'anon': env.str('DJANGO_THROTTLE_ANON', default='100/min'), + }, +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Django Boilerplate API', + 'DESCRIPTION': ('Версия API передаётся через заголовок Accept, например: `Accept: application/json; version=v1`.'), + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} + +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': env.str('REDIS_URL', default='redis://redis:6379/1'), + }, +} + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{asctime} {levelname} {name} {module}:{lineno} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': env.str('DJANGO_LOG_LEVEL', default='INFO'), + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': env.str('DJANGO_LOG_LEVEL', default='INFO'), + 'propagate': False, + }, + }, } REDIS_HOST: str = env.str('REDIS_HOST', 'redis') diff --git a/config/settings/dev.py b/config/settings/dev.py index 0008700..52bb01e 100644 --- a/config/settings/dev.py +++ b/config/settings/dev.py @@ -1 +1,19 @@ -# Development settings \ No newline at end of file +# Development settings +DEBUG = env.bool('DJANGO_DEBUG', default=True) # noqa: F821 + +if not ALLOWED_HOSTS: # noqa: F821 + ALLOWED_HOSTS = ['localhost', '127.0.0.1'] + +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +# Whitenoise отдаёт статику напрямую из finders без collectstatic +WHITENOISE_AUTOREFRESH = True +WHITENOISE_USE_FINDERS = True + +# Локальная разработка без Redis: кэш в памяти процесса +if env.bool('DJANGO_USE_LOCMEM_CACHE', default=False): # noqa: F821 + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + }, + } diff --git a/config/settings/prod.py b/config/settings/prod.py index e6fd2af..1a40ac0 100644 --- a/config/settings/prod.py +++ b/config/settings/prod.py @@ -1 +1,24 @@ -# Production settings \ No newline at end of file +# Production settings +DEBUG = False + +# Приложение работает за обратным прокси (nginx/traefik), который терминирует TLS +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SECURE_SSL_REDIRECT = env.bool('DJANGO_SECURE_SSL_REDIRECT', default=True) # noqa: F821 + +SECURE_HSTS_SECONDS = env.int('DJANGO_SECURE_HSTS_SECONDS', default=60 * 60 * 24 * 30) # noqa: F821 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True + +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SESSION_COOKIE_HTTPONLY = True + +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = 'same-origin' +X_FRAME_OPTIONS = 'DENY' + +# Манифестное хранилище статики: хэши в именах файлов + сжатие +STORAGES['staticfiles']['BACKEND'] = 'whitenoise.storage.CompressedManifestStaticFilesStorage' # noqa: F821 + +# В production браузерные формы DRF не нужны +REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = ('rest_framework.renderers.JSONRenderer',) # noqa: F821 diff --git a/config/urls.py b/config/urls.py index 2c177f9..d06e436 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,21 +1,20 @@ """ -URL configuration for config project. +URL configuration проекта. -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/6.0/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +Версионирование API реализовано через заголовок Accept (AcceptHeaderVersioning), +поэтому версия не входит в URL. Клиент указывает версию так: + + Accept: application/json; version=v1 + +Если заголовок не передан, используется версия по умолчанию (см. REST_FRAMEWORK +в config/settings/base.py). """ + from django.contrib import admin -from django.urls import path, include +from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + +from config.views import health_check api_urlpatterns = [ path('users/', include('apps.users.api.urls', namespace='users_api')), @@ -23,5 +22,10 @@ urlpatterns = [ path('admin/', admin.site.urls), - path('api/v1/', include(api_urlpatterns)), + path('health/', health_check, name='health_check'), + path('api/', include(api_urlpatterns)), + # OpenAPI schema & docs + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), ] diff --git a/config/views.py b/config/views.py new file mode 100644 index 0000000..60fd98e --- /dev/null +++ b/config/views.py @@ -0,0 +1,30 @@ +from django.db import connections +from django.http import HttpRequest, JsonResponse + +from commons.redis import get_redis + + +def health_check(request: HttpRequest) -> JsonResponse: + """ + Проверяет работоспособность приложения и его зависимостей (БД, Redis). + + :param request: Объект HTTP запроса. + :return: JSON со статусом каждого компонента; 503, если хоть один недоступен. + """ + checks = {'database': True, 'redis': True} + + try: + connections['default'].cursor().execute('SELECT 1') + except Exception: # noqa: BLE001 + checks['database'] = False + + try: + get_redis().ping() + except Exception: # noqa: BLE001 + checks['redis'] = False + + healthy = all(checks.values()) + return JsonResponse( + {'status': 'ok' if healthy else 'error', 'checks': checks}, + status=200 if healthy else 503, + ) diff --git a/docker-compose.yml b/docker-compose.yml index 1b85b8c..e86aae9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,27 @@ services: + postgres: + image: postgres:18-alpine + hostname: postgres + ports: + - "5432:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB:-app} + POSTGRES_USER: ${POSTGRES_USER:-app} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app} + volumes: + - pg_data:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app} -d ${POSTGRES_DB:-app}" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + networks: + - backend + redis: - image: redis:7-alpine + image: redis:8-alpine hostname: redis ports: - "6379:6379" @@ -27,7 +48,7 @@ services: dockerfile: Dockerfile target: runtime args: - PYTHON_VERSION: ${PYTHON_VERSION:-3.13.7} + PYTHON_VERSION: ${PYTHON_VERSION:-3.13} UID: ${UID:-1000} INSTALL_DEV: ${INSTALL_DEV:-true} hostname: django @@ -41,9 +62,14 @@ services: environment: ENVIRONMENT: ${ENVIRONMENT:-dev} DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-config.settings} + DATABASE_URL: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-app}@postgres:5432/${POSTGRES_DB:-app} + REDIS_URL: redis://redis:6379/1 + POSTGRES_HOST: postgres env_file: - ./.env depends_on: + postgres: + condition: service_healthy redis: condition: service_healthy command: ${MODE:-dev} diff --git a/entrypoint.sh b/entrypoint.sh index 6c7b42f..f00de56 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,11 +1,7 @@ #!/bin/bash set -e -# Проверка виртуального окружения -echo "Проверка Python окружения:" -echo "Python path: $(which python)" -echo "Python version: $(python --version)" -echo "Virtual env: ${VIRTUAL_ENV:-'Используется PATH для активации venv'}" +echo "Python: $(python --version) ($(which python))" # Функция для ожидания готовности сервисов wait_for_service() { @@ -20,9 +16,13 @@ wait_for_service() { echo "$service готов!" } -# Ожидание Redis если он используется +# Ожидание зависимостей if [ -n "$REDIS_URL" ]; then - wait_for_service redis 6379 "Redis" + wait_for_service "${REDIS_HOST:-redis}" "${REDIS_PORT:-6379}" "Redis" +fi + +if [ -n "$POSTGRES_HOST" ]; then + wait_for_service "$POSTGRES_HOST" "${POSTGRES_PORT:-5432}" "PostgreSQL" fi # Применение миграций @@ -30,7 +30,7 @@ echo "Применение миграций..." python manage.py migrate --no-input # Сбор статических файлов в продакшене -if [ "$MODE" = "prod" ] || [ "$DEBUG" = "False" ]; then +if [ "$1" = "prod" ] || [ "$ENVIRONMENT" = "prod" ]; then echo "Сбор статических файлов..." python manage.py collectstatic --no-input --clear fi @@ -43,11 +43,9 @@ case "$1" in ;; "prod") echo "Запуск в продакшн режиме..." - exec gunicorn core.wsgi:application \ + exec gunicorn config.wsgi:application \ --bind 0.0.0.0:8000 \ - --workers 4 \ - --worker-class gevent \ - --worker-connections 1000 \ + --workers "${GUNICORN_WORKERS:-4}" \ --max-requests 1000 \ --max-requests-jitter 100 \ --timeout 30 \ @@ -56,23 +54,6 @@ case "$1" in --access-logfile - \ --error-logfile - ;; - "celery-worker") - echo "Запуск Celery Worker..." - exec celery -A core worker \ - --loglevel=info \ - --concurrency=4 \ - --max-tasks-per-child=1000 \ - --time-limit=300 \ - --soft-time-limit=240 - ;; - "celery-beat") - echo "Запуск Celery Beat..." - # Удаление старого расписания если есть - rm -f celerybeat.pid - exec celery -A core beat \ - --loglevel=info \ - --scheduler django_celery_beat.schedulers:DatabaseScheduler - ;; "shell") echo "Запуск Django Shell..." exec python manage.py shell diff --git a/manage.py b/manage.py index 8e7ac79..5e1dd6b 100755 --- a/manage.py +++ b/manage.py @@ -1,5 +1,6 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys @@ -12,8 +13,8 @@ def main(): except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" + 'available on your PYTHONPATH environment variable? Did you ' + 'forget to activate a virtual environment?' ) from exc execute_from_command_line(sys.argv) diff --git a/pyproject.toml b/pyproject.toml index 916cfd4..31da175 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,23 +5,28 @@ description = "Add your description here" readme = "README.md" requires-python = "==3.13.*" dependencies = [ - "django==6.0", - "django-environ==0.12.0", + "django==6.0.6", + "django-environ==0.13.0", "django-filter==25.2", "django-split-settings==1.3.2", - "djangorestframework==3.16.1", - "redis[hiredis]==7.1.0", + "djangorestframework==3.17.1", + "drf-spectacular==0.29.0", + "gunicorn==26.0.0", + "psycopg[binary,pool]==3.3.4", + "redis[hiredis]==8.0.0", + "whitenoise==6.12.0", ] [dependency-groups] dev = [ - "bandit==1.9.2", - "pre-commit==4.5.0", - "pytest==9.0.1", - "pytest-django==4.11.1", + "bandit==1.9.4", + "pre-commit==4.6.0", + "pytest==9.0.3", + "pytest-django==4.12.0", + "pytest-env==1.6.0", "pytest-mock==3.15.1", - "ruff==0.14.7", - "ty==0.0.1a34", + "ruff==0.15.17", + "ty==0.0.49", ] [tool.ruff] @@ -62,3 +67,11 @@ exclude_dirs = ['.venv', 'migrations', '__pycache__', 'tests'] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = 'config.settings' python_files = ['test_*.py', '*_test.py'] +# Значения по умолчанию для запуска тестов без .env (локально и в CI). +# Префикс D: — не перезаписывать уже установленные переменные окружения. +env = [ + 'D:ENVIRONMENT=dev', + 'D:DJANGO_SECRET_KEY=insecure-test-secret-key', + 'D:DJANGO_DEBUG=True', + 'D:DJANGO_USE_LOCMEM_CACHE=True', +] diff --git a/uv.lock b/uv.lock index 396de91..41607e9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,19 +1,28 @@ version = 1 -revision = 1 +revision = 3 requires-python = "==3.13.*" [[package]] name = "asgiref" version = "3.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969 } +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096 }, + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] name = "bandit" -version = "1.9.2" +version = "1.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -21,50 +30,50 @@ dependencies = [ { name = "rich" }, { name = "stevedore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/72/f704a97aac430aeb704fa16435dfa24fbeaf087d46724d0965eb1f756a2c/bandit-1.9.2.tar.gz", hash = "sha256:32410415cd93bf9c8b91972159d5cf1e7f063a9146d70345641cd3877de348ce", size = 4241659 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/1a/5b0320642cca53a473e79c7d273071b5a9a8578f9e370b74da5daa2768d7/bandit-1.9.2-py3-none-any.whl", hash = "sha256:bda8d68610fc33a6e10b7a8f1d61d92c8f6c004051d5e946406be1fb1b16a868", size = 134377 }, + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, ] [[package]] name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334 } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445 }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[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]] name = "distlib" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] name = "django" -version = "6.0" +version = "6.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/75/19762bfc4ea556c303d9af8e36f0cd910ab17dff6c8774644314427a2120/django-6.0.tar.gz", hash = "sha256:7b0c1f50c0759bbe6331c6a39c89ae022a84672674aeda908784617ef47d8e26", size = 10932418 } +sdist = { url = "https://files.pythonhosted.org/packages/78/29/ac41e16097af67066d97a7d5775c5d8e7efc5d0284f6b0a159e07b9adb92/django-6.0.6.tar.gz", hash = "sha256:ad03916ba59523d781ae5c3f631960c23d69a9d9c43cecda52fc23b47e953713", size = 10905525, upload-time = "2026-06-03T13:02:46.503Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ae/f19e24789a5ad852670d6885f5480f5e5895576945fcc01817dfd9bc002a/django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d", size = 8339181 }, + { url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" }, ] [[package]] @@ -77,7 +86,11 @@ dependencies = [ { name = "django-filter" }, { name = "django-split-settings" }, { name = "djangorestframework" }, + { name = "drf-spectacular" }, + { name = "gunicorn" }, + { name = "psycopg", extra = ["binary", "pool"] }, { name = "redis", extra = ["hiredis"] }, + { name = "whitenoise" }, ] [package.dev-dependencies] @@ -86,6 +99,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-django" }, + { name = "pytest-env" }, { name = "pytest-mock" }, { name = "ruff" }, { name = "ty" }, @@ -93,32 +107,37 @@ dev = [ [package.metadata] requires-dist = [ - { name = "django", specifier = "==6.0" }, - { name = "django-environ", specifier = "==0.12.0" }, + { name = "django", specifier = "==6.0.6" }, + { name = "django-environ", specifier = "==0.13.0" }, { name = "django-filter", specifier = "==25.2" }, { name = "django-split-settings", specifier = "==1.3.2" }, - { name = "djangorestframework", specifier = "==3.16.1" }, - { name = "redis", extras = ["hiredis"], specifier = "==7.1.0" }, + { name = "djangorestframework", specifier = "==3.17.1" }, + { name = "drf-spectacular", specifier = "==0.29.0" }, + { name = "gunicorn", specifier = "==26.0.0" }, + { name = "psycopg", extras = ["binary", "pool"], specifier = "==3.3.4" }, + { name = "redis", extras = ["hiredis"], specifier = "==8.0.0" }, + { name = "whitenoise", specifier = "==6.12.0" }, ] [package.metadata.requires-dev] dev = [ - { name = "bandit", specifier = "==1.9.2" }, - { name = "pre-commit", specifier = "==4.5.0" }, - { name = "pytest", specifier = "==9.0.1" }, - { name = "pytest-django", specifier = "==4.11.1" }, + { name = "bandit", specifier = "==1.9.4" }, + { name = "pre-commit", specifier = "==4.6.0" }, + { name = "pytest", specifier = "==9.0.3" }, + { name = "pytest-django", specifier = "==4.12.0" }, + { name = "pytest-env", specifier = "==1.6.0" }, { name = "pytest-mock", specifier = "==3.15.1" }, - { name = "ruff", specifier = "==0.14.7" }, - { name = "ty", specifier = "==0.0.1a34" }, + { name = "ruff", specifier = "==0.15.17" }, + { name = "ty", specifier = "==0.0.49" }, ] [[package]] name = "django-environ" -version = "0.12.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/04/65d2521842c42f4716225f20d8443a50804920606aec018188bbee30a6b0/django_environ-0.12.0.tar.gz", hash = "sha256:227dc891453dd5bde769c3449cf4a74b6f2ee8f7ab2361c93a07068f4179041a", size = 56804 } +sdist = { url = "https://files.pythonhosted.org/packages/18/3c/60983e6ec9b24a8d8588eecebfd21123cba980bce0a905807a27692f0860/django_environ-0.13.0.tar.gz", hash = "sha256:6c401e4c219442c2c4588c2116d5292b5484a6f69163ed09cd41f3943bfb645f", size = 63529, upload-time = "2026-02-18T01:08:08.791Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/b3/0a3bec4ecbfee960f39b1842c2f91e4754251e0a6ed443db9fe3f666ba8f/django_environ-0.12.0-py2.py3-none-any.whl", hash = "sha256:92fb346a158abda07ffe6eb23135ce92843af06ecf8753f43adf9d2366dcc0ca", size = 19957 }, + { url = "https://files.pythonhosted.org/packages/c4/00/3767393ece946084e1c6830a33ffb8e39d68642e27ad5ac7d4c8bd5de866/django_environ-0.13.0-py3-none-any.whl", hash = "sha256:37799d14cd78222c6fd8298e48bfe17965ff8e586091ad66a463e52e0e7b799e", size = 20682, upload-time = "2026-02-18T01:08:07.359Z" }, ] [[package]] @@ -128,78 +147,143 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818 } +sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145 }, + { url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" }, ] [[package]] name = "django-split-settings" version = "1.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/b9/1c13089454afd7a42d492b8aa8a0c7e49eeca58c0f2ad331f361a067c876/django_split_settings-1.3.2.tar.gz", hash = "sha256:d3975aa3601e37f65c59b9977b6bcb1de8bc27496930054078589c7d56998a9d", size = 5751 } +sdist = { url = "https://files.pythonhosted.org/packages/de/b9/1c13089454afd7a42d492b8aa8a0c7e49eeca58c0f2ad331f361a067c876/django_split_settings-1.3.2.tar.gz", hash = "sha256:d3975aa3601e37f65c59b9977b6bcb1de8bc27496930054078589c7d56998a9d", size = 5751, upload-time = "2024-07-05T14:30:05.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/69/d94db8dac55bcfb6b3243578a3096cfda6c42ea5da292c36919768152ec6/django_split_settings-1.3.2-py3-none-any.whl", hash = "sha256:72bd7dd9f12602585681074d1f859643fb4f6b196b584688fab86bdd73a57dff", size = 6435 }, + { url = "https://files.pythonhosted.org/packages/63/69/d94db8dac55bcfb6b3243578a3096cfda6c42ea5da292c36919768152ec6/django_split_settings-1.3.2-py3-none-any.whl", hash = "sha256:72bd7dd9f12602585681074d1f859643fb4f6b196b584688fab86bdd73a57dff", size = 6435, upload-time = "2024-07-05T14:29:59.756Z" }, ] [[package]] name = "djangorestframework" -version = "3.16.1" +version = "3.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735 } +sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442 }, + { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" }, +] + +[[package]] +name = "drf-spectacular" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "inflection" }, + { name = "jsonschema" }, + { name = "pyyaml" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/0e/a4f50d83e76cbe797eda88fc0083c8ca970cfa362b5586359ef06ec6f70a/drf_spectacular-0.29.0.tar.gz", hash = "sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc", size = 241722, upload-time = "2025-11-02T03:40:26.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d9/502c56fc3ca960075d00956283f1c44e8cafe433dada03f9ed2821f3073b/drf_spectacular-0.29.0-py3-none-any.whl", hash = "sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a", size = 105433, upload-time = "2025-11-02T03:40:24.823Z" }, ] [[package]] name = "filelock" version = "3.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922 } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054 }, + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[package]] name = "hiredis" version = "3.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/82/d2817ce0653628e0a0cb128533f6af0dd6318a49f3f3a6a7bd1f2f2154af/hiredis-3.3.0.tar.gz", hash = "sha256:105596aad9249634361815c574351f1bd50455dc23b537c2940066c4a9dea685", size = 89048 } +sdist = { url = "https://files.pythonhosted.org/packages/65/82/d2817ce0653628e0a0cb128533f6af0dd6318a49f3f3a6a7bd1f2f2154af/hiredis-3.3.0.tar.gz", hash = "sha256:105596aad9249634361815c574351f1bd50455dc23b537c2940066c4a9dea685", size = 89048, upload-time = "2025-10-14T16:33:34.263Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/39/2b789ebadd1548ccb04a2c18fbc123746ad1a7e248b7f3f3cac618ca10a6/hiredis-3.3.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:b7048b4ec0d5dddc8ddd03da603de0c4b43ef2540bf6e4c54f47d23e3480a4fa", size = 82035 }, - { url = "https://files.pythonhosted.org/packages/85/74/4066d9c1093be744158ede277f2a0a4e4cd0fefeaa525c79e2876e9e5c72/hiredis-3.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:e5f86ce5a779319c15567b79e0be806e8e92c18bb2ea9153e136312fafa4b7d6", size = 46219 }, - { url = "https://files.pythonhosted.org/packages/fa/3f/f9e0f6d632f399d95b3635703e1558ffaa2de3aea4cfcbc2d7832606ba43/hiredis-3.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbdb97a942e66016fff034df48a7a184e2b7dc69f14c4acd20772e156f20d04b", size = 41860 }, - { url = "https://files.pythonhosted.org/packages/4a/c5/b7dde5ec390dabd1cabe7b364a509c66d4e26de783b0b64cf1618f7149fc/hiredis-3.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0fb4bea72fe45ff13e93ddd1352b43ff0749f9866263b5cca759a4c960c776f", size = 170094 }, - { url = "https://files.pythonhosted.org/packages/3e/d6/7f05c08ee74d41613be466935688068e07f7b6c55266784b5ace7b35b766/hiredis-3.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85b9baf98050e8f43c2826ab46aaf775090d608217baf7af7882596aef74e7f9", size = 181746 }, - { url = "https://files.pythonhosted.org/packages/0e/d2/aaf9f8edab06fbf5b766e0cae3996324297c0516a91eb2ca3bd1959a0308/hiredis-3.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69079fb0f0ebb61ba63340b9c4bce9388ad016092ca157e5772eb2818209d930", size = 180465 }, - { url = "https://files.pythonhosted.org/packages/8d/1e/93ded8b9b484519b211fc71746a231af98c98928e3ebebb9086ed20bb1ad/hiredis-3.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c17f77b79031ea4b0967d30255d2ae6e7df0603ee2426ad3274067f406938236", size = 172419 }, - { url = "https://files.pythonhosted.org/packages/68/13/02880458e02bbfcedcaabb8f7510f9dda1c89d7c1921b1bb28c22bb38cbf/hiredis-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d14f745fc177bc05fc24bdf20e2b515e9a068d3d4cce90a0fb78d04c9c9d9a", size = 166400 }, - { url = "https://files.pythonhosted.org/packages/11/60/896e03267670570f19f61dc65a2137fcb2b06e83ab0911d58eeec9f3cb88/hiredis-3.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ba063fdf1eff6377a0c409609cbe890389aefddfec109c2d20fcc19cfdafe9da", size = 176845 }, - { url = "https://files.pythonhosted.org/packages/f1/90/a1d4bd0cdcf251fda72ac0bd932f547b48ad3420f89bb2ef91bf6a494534/hiredis-3.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1799cc66353ad066bfdd410135c951959da9f16bcb757c845aab2f21fc4ef099", size = 170365 }, - { url = "https://files.pythonhosted.org/packages/f1/9a/7c98f7bb76bdb4a6a6003cf8209721f083e65d2eed2b514f4a5514bda665/hiredis-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2cbf71a121996ffac82436b6153290815b746afb010cac19b3290a1644381b07", size = 168022 }, - { url = "https://files.pythonhosted.org/packages/0d/ca/672ee658ffe9525558615d955b554ecd36aa185acd4431ccc9701c655c9b/hiredis-3.3.0-cp313-cp313-win32.whl", hash = "sha256:a7cbbc6026bf03659f0b25e94bbf6e64f6c8c22f7b4bc52fe569d041de274194", size = 20533 }, - { url = "https://files.pythonhosted.org/packages/20/93/511fd94f6a7b6d72a4cf9c2b159bf3d780585a9a1dca52715dd463825299/hiredis-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:a8def89dd19d4e2e4482b7412d453dec4a5898954d9a210d7d05f60576cedef6", size = 22387 }, + { url = "https://files.pythonhosted.org/packages/6d/39/2b789ebadd1548ccb04a2c18fbc123746ad1a7e248b7f3f3cac618ca10a6/hiredis-3.3.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:b7048b4ec0d5dddc8ddd03da603de0c4b43ef2540bf6e4c54f47d23e3480a4fa", size = 82035, upload-time = "2025-10-14T16:32:23.715Z" }, + { url = "https://files.pythonhosted.org/packages/85/74/4066d9c1093be744158ede277f2a0a4e4cd0fefeaa525c79e2876e9e5c72/hiredis-3.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:e5f86ce5a779319c15567b79e0be806e8e92c18bb2ea9153e136312fafa4b7d6", size = 46219, upload-time = "2025-10-14T16:32:24.554Z" }, + { url = "https://files.pythonhosted.org/packages/fa/3f/f9e0f6d632f399d95b3635703e1558ffaa2de3aea4cfcbc2d7832606ba43/hiredis-3.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbdb97a942e66016fff034df48a7a184e2b7dc69f14c4acd20772e156f20d04b", size = 41860, upload-time = "2025-10-14T16:32:25.356Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c5/b7dde5ec390dabd1cabe7b364a509c66d4e26de783b0b64cf1618f7149fc/hiredis-3.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0fb4bea72fe45ff13e93ddd1352b43ff0749f9866263b5cca759a4c960c776f", size = 170094, upload-time = "2025-10-14T16:32:26.148Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d6/7f05c08ee74d41613be466935688068e07f7b6c55266784b5ace7b35b766/hiredis-3.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85b9baf98050e8f43c2826ab46aaf775090d608217baf7af7882596aef74e7f9", size = 181746, upload-time = "2025-10-14T16:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d2/aaf9f8edab06fbf5b766e0cae3996324297c0516a91eb2ca3bd1959a0308/hiredis-3.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69079fb0f0ebb61ba63340b9c4bce9388ad016092ca157e5772eb2818209d930", size = 180465, upload-time = "2025-10-14T16:32:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1e/93ded8b9b484519b211fc71746a231af98c98928e3ebebb9086ed20bb1ad/hiredis-3.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c17f77b79031ea4b0967d30255d2ae6e7df0603ee2426ad3274067f406938236", size = 172419, upload-time = "2025-10-14T16:32:30.059Z" }, + { url = "https://files.pythonhosted.org/packages/68/13/02880458e02bbfcedcaabb8f7510f9dda1c89d7c1921b1bb28c22bb38cbf/hiredis-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d14f745fc177bc05fc24bdf20e2b515e9a068d3d4cce90a0fb78d04c9c9d9a", size = 166400, upload-time = "2025-10-14T16:32:31.173Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/896e03267670570f19f61dc65a2137fcb2b06e83ab0911d58eeec9f3cb88/hiredis-3.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ba063fdf1eff6377a0c409609cbe890389aefddfec109c2d20fcc19cfdafe9da", size = 176845, upload-time = "2025-10-14T16:32:32.12Z" }, + { url = "https://files.pythonhosted.org/packages/f1/90/a1d4bd0cdcf251fda72ac0bd932f547b48ad3420f89bb2ef91bf6a494534/hiredis-3.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1799cc66353ad066bfdd410135c951959da9f16bcb757c845aab2f21fc4ef099", size = 170365, upload-time = "2025-10-14T16:32:33.035Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9a/7c98f7bb76bdb4a6a6003cf8209721f083e65d2eed2b514f4a5514bda665/hiredis-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2cbf71a121996ffac82436b6153290815b746afb010cac19b3290a1644381b07", size = 168022, upload-time = "2025-10-14T16:32:34.81Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/672ee658ffe9525558615d955b554ecd36aa185acd4431ccc9701c655c9b/hiredis-3.3.0-cp313-cp313-win32.whl", hash = "sha256:a7cbbc6026bf03659f0b25e94bbf6e64f6c8c22f7b4bc52fe569d041de274194", size = 20533, upload-time = "2025-10-14T16:32:35.7Z" }, + { url = "https://files.pythonhosted.org/packages/20/93/511fd94f6a7b6d72a4cf9c2b159bf3d780585a9a1dca52715dd463825299/hiredis-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:a8def89dd19d4e2e4482b7412d453dec4a5898954d9a210d7d05f60576cedef6", size = 22387, upload-time = "2025-10-14T16:32:36.441Z" }, ] [[package]] name = "identify" version = "2.6.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183 }, + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "inflection" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, ] [[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 } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] [[package]] @@ -209,59 +293,59 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "platformdirs" version = "4.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632 } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651 }, + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +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 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pre-commit" -version = "4.5.0" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -270,23 +354,73 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] +pool = [ + { name = "psycopg-pool" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429 }, + { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pytest" -version = "9.0.1" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -295,21 +429,34 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668 }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] name = "pytest-django" -version = "4.11.1" +version = "4.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202 } +sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281 }, + { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, +] + +[[package]] +name = "pytest-env" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/69/4db1c30625af0621df8dbe73797b38b6d1b04e15d021dd5d26a6d297f78c/pytest_env-1.6.0.tar.gz", hash = "sha256:ac02d6fba16af54d61e311dd70a3c61024a4e966881ea844affc3c8f0bf207d3", size = 16163, upload-time = "2026-03-12T22:39:43.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/16/ad52f56b96d851a2bcfdc1e754c3531341885bd7177a128c13ff2ca72ab4/pytest_env-1.6.0-py3-none-any.whl", hash = "sha256:1e7f8a62215e5885835daaed694de8657c908505b964ec8097a7ce77b403d9a3", size = 10400, upload-time = "2026-03-12T22:39:41.887Z" }, ] [[package]] @@ -319,36 +466,45 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036 } +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 }, + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] [[package]] name = "redis" -version = "7.1.0" +version = "8.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669 } +sdist = { url = "https://files.pythonhosted.org/packages/53/ae/ed461cca5780b5fc8b9fe8ca0ed98d89508645fb9d880c24cc42c087678f/redis-8.0.0.tar.gz", hash = "sha256:a00c5355432051ac14e593b8b197fc76c887ee12d55a0984f69328a1115fdc49", size = 5101591, upload-time = "2026-05-28T12:45:13.5Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159 }, + { url = "https://files.pythonhosted.org/packages/27/e3/b519734372d305bd547534a9f32e4ce9f98552af753dce72cf3483a0ff0b/redis-8.0.0-py3-none-any.whl", hash = "sha256:c938c18338585009f0bc310f4c7e4e4b4d37639356c4ac072cedf3af570c8dc7", size = 499870, upload-time = "2026-05-28T12:45:11.697Z" }, ] [package.optional-dependencies] @@ -356,6 +512,19 @@ hiredis = [ { name = "hiredis" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -364,87 +533,141 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393 }, +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, ] [[package]] name = "ruff" -version = "0.14.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/5b/dd7406afa6c95e3d8fa9d652b6d6dd17dd4a6bf63cb477014e8ccd3dcd46/ruff-0.14.7.tar.gz", hash = "sha256:3417deb75d23bd14a722b57b0a1435561db65f0ad97435b4cf9f85ffcef34ae5", size = 5727324 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/b1/7ea5647aaf90106f6d102230e5df874613da43d1089864da1553b899ba5e/ruff-0.14.7-py3-none-linux_armv6l.whl", hash = "sha256:b9d5cb5a176c7236892ad7224bc1e63902e4842c460a0b5210701b13e3de4fca", size = 13414475 }, - { url = "https://files.pythonhosted.org/packages/af/19/fddb4cd532299db9cdaf0efdc20f5c573ce9952a11cb532d3b859d6d9871/ruff-0.14.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3f64fe375aefaf36ca7d7250292141e39b4cea8250427482ae779a2aa5d90015", size = 13634613 }, - { url = "https://files.pythonhosted.org/packages/40/2b/469a66e821d4f3de0440676ed3e04b8e2a1dc7575cf6fa3ba6d55e3c8557/ruff-0.14.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93e83bd3a9e1a3bda64cb771c0d47cda0e0d148165013ae2d3554d718632d554", size = 12765458 }, - { url = "https://files.pythonhosted.org/packages/f1/05/0b001f734fe550bcfde4ce845948ac620ff908ab7241a39a1b39bb3c5f49/ruff-0.14.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3838948e3facc59a6070795de2ae16e5786861850f78d5914a03f12659e88f94", size = 13236412 }, - { url = "https://files.pythonhosted.org/packages/11/36/8ed15d243f011b4e5da75cd56d6131c6766f55334d14ba31cce5461f28aa/ruff-0.14.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24c8487194d38b6d71cd0fd17a5b6715cda29f59baca1defe1e3a03240f851d1", size = 13182949 }, - { url = "https://files.pythonhosted.org/packages/3b/cf/fcb0b5a195455729834f2a6eadfe2e4519d8ca08c74f6d2b564a4f18f553/ruff-0.14.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79c73db6833f058a4be8ffe4a0913b6d4ad41f6324745179bd2aa09275b01d0b", size = 13816470 }, - { url = "https://files.pythonhosted.org/packages/7f/5d/34a4748577ff7a5ed2f2471456740f02e86d1568a18c9faccfc73bd9ca3f/ruff-0.14.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:12eb7014fccff10fc62d15c79d8a6be4d0c2d60fe3f8e4d169a0d2def75f5dad", size = 15289621 }, - { url = "https://files.pythonhosted.org/packages/53/53/0a9385f047a858ba133d96f3f8e3c9c66a31cc7c4b445368ef88ebeac209/ruff-0.14.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c623bbdc902de7ff715a93fa3bb377a4e42dd696937bf95669118773dbf0c50", size = 14975817 }, - { url = "https://files.pythonhosted.org/packages/a8/d7/2f1c32af54c3b46e7fadbf8006d8b9bcfbea535c316b0bd8813d6fb25e5d/ruff-0.14.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f53accc02ed2d200fa621593cdb3c1ae06aa9b2c3cae70bc96f72f0000ae97a9", size = 14284549 }, - { url = "https://files.pythonhosted.org/packages/92/05/434ddd86becd64629c25fb6b4ce7637dd52a45cc4a4415a3008fe61c27b9/ruff-0.14.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:281f0e61a23fcdcffca210591f0f53aafaa15f9025b5b3f9706879aaa8683bc4", size = 14071389 }, - { url = "https://files.pythonhosted.org/packages/ff/50/fdf89d4d80f7f9d4f420d26089a79b3bb1538fe44586b148451bc2ba8d9c/ruff-0.14.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dbbaa5e14148965b91cb090236931182ee522a5fac9bc5575bafc5c07b9f9682", size = 14202679 }, - { url = "https://files.pythonhosted.org/packages/77/54/87b34988984555425ce967f08a36df0ebd339bb5d9d0e92a47e41151eafc/ruff-0.14.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1464b6e54880c0fe2f2d6eaefb6db15373331414eddf89d6b903767ae2458143", size = 13147677 }, - { url = "https://files.pythonhosted.org/packages/67/29/f55e4d44edfe053918a16a3299e758e1c18eef216b7a7092550d7a9ec51c/ruff-0.14.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f217ed871e4621ea6128460df57b19ce0580606c23aeab50f5de425d05226784", size = 13151392 }, - { url = "https://files.pythonhosted.org/packages/36/69/47aae6dbd4f1d9b4f7085f4d9dcc84e04561ee7ad067bf52e0f9b02e3209/ruff-0.14.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6be02e849440ed3602d2eb478ff7ff07d53e3758f7948a2a598829660988619e", size = 13412230 }, - { url = "https://files.pythonhosted.org/packages/b7/4b/6e96cb6ba297f2ba502a231cd732ed7c3de98b1a896671b932a5eefa3804/ruff-0.14.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19a0f116ee5e2b468dfe80c41c84e2bbd6b74f7b719bee86c2ecde0a34563bcc", size = 14195397 }, - { url = "https://files.pythonhosted.org/packages/69/82/251d5f1aa4dcad30aed491b4657cecd9fb4274214da6960ffec144c260f7/ruff-0.14.7-py3-none-win32.whl", hash = "sha256:e33052c9199b347c8937937163b9b149ef6ab2e4bb37b042e593da2e6f6cccfa", size = 13126751 }, - { url = "https://files.pythonhosted.org/packages/a8/b5/d0b7d145963136b564806f6584647af45ab98946660d399ec4da79cae036/ruff-0.14.7-py3-none-win_amd64.whl", hash = "sha256:e17a20ad0d3fad47a326d773a042b924d3ac31c6ca6deb6c72e9e6b5f661a7c6", size = 14531726 }, - { url = "https://files.pythonhosted.org/packages/1d/d2/1637f4360ada6a368d3265bf39f2cf737a0aaab15ab520fc005903e883f8/ruff-0.14.7-py3-none-win_arm64.whl", hash = "sha256:be4d653d3bea1b19742fcc6502354e32f65cd61ff2fbdb365803ef2c2aec6228", size = 13609215 }, +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, ] [[package]] name = "sqlparse" version = "0.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/67/701f86b28d63b2086de47c942eccf8ca2208b3be69715a1119a4e384415a/sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e", size = 120112 } +sdist = { url = "https://files.pythonhosted.org/packages/18/67/701f86b28d63b2086de47c942eccf8ca2208b3be69715a1119a4e384415a/sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e", size = 120112, upload-time = "2025-11-28T07:10:18.377Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933 }, + { url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" }, ] [[package]] name = "stevedore" version = "5.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/5b/496f8abebd10c3301129abba7ddafd46c71d799a70c44ab080323987c4c9/stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945", size = 516074 } +sdist = { url = "https://files.pythonhosted.org/packages/96/5b/496f8abebd10c3301129abba7ddafd46c71d799a70c44ab080323987c4c9/stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945", size = 516074, upload-time = "2025-11-20T10:06:07.264Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428 }, + { url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428, upload-time = "2025-11-20T10:06:05.946Z" }, ] [[package]] name = "ty" -version = "0.0.1a34" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/f9/f467d2fbf02a37af5d779eb21c59c7d5c9ce8c48f620d590d361f5220208/ty-0.0.1a34.tar.gz", hash = "sha256:659e409cc3b5c9fb99a453d256402a4e3bd95b1dbcc477b55c039697c807ab79", size = 4735988 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/b7/d5a5c611baaa20e85971a7c9a527aaf3e8fb47e15de88d1db39c64ee3638/ty-0.0.1a34-py3-none-linux_armv6l.whl", hash = "sha256:00c138e28b12a80577ee3e15fc638eb1e35cf5aa75f5967bf2d1893916ce571c", size = 9708675 }, - { url = "https://files.pythonhosted.org/packages/cb/62/0b78976c8da58b90a86d1a1b8816ff4a6e8437f6e52bb6800c4483242e7f/ty-0.0.1a34-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cbb9c187164675647143ecb56e684d6766f7d5ba7f6874a369fe7c3d380a6c92", size = 9515760 }, - { url = "https://files.pythonhosted.org/packages/39/1f/4e3d286b37aab3428a30b8f5db5533b8ce6e23b1bd84f77a137bd782b418/ty-0.0.1a34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:68b2375b366ee799a896594cde393a1b60414efdfd31399c326bfc136bfc41f3", size = 9064633 }, - { url = "https://files.pythonhosted.org/packages/5d/31/e17049b868f5cac7590c000f31ff9453e4360125416da4e8195e82b5409a/ty-0.0.1a34-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f6b68d9673e43bdd5bdcaa6b5db50e873431fc44dde5e25e253e8226ec93ac1", size = 9310295 }, - { url = "https://files.pythonhosted.org/packages/77/1d/7a89b3032e84a01223d0c33e47f33eef436ca36949b28600554a2a4da1f8/ty-0.0.1a34-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:832b360fd397c076e294c252db52581b9ecb38d8063d6262ac927610540702be", size = 9498451 }, - { url = "https://files.pythonhosted.org/packages/fa/5e/e782c4367d14b965b1ee9bddc3f3102982ff1cc2dae699c201ecd655e389/ty-0.0.1a34-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb6fc497f1feb67e299fd3507ed30498c7e15b31099b3dcdbeca6b7ac2d3129", size = 9912522 }, - { url = "https://files.pythonhosted.org/packages/9c/25/4d72d7174b60adeb9df6e4c5d8552161da2b84ddcebed8ab37d0f7f266ab/ty-0.0.1a34-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:284c8cfd64f255d942ef21953e3d40d087c74dec27e16495bd656decdd208f59", size = 10518743 }, - { url = "https://files.pythonhosted.org/packages/05/c5/30a6e377bcab7d5b65d5c78740635b23ecee647bf268c9dc82a91d41c9ba/ty-0.0.1a34-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c34b028305642fd3a9076d4b07d651a819c61a65371ef38cde60f0b54dce6180", size = 10285473 }, - { url = "https://files.pythonhosted.org/packages/97/aa/d2cd564ee37a587c8311383a5687584c9aed241a9e67301ee0280301eef3/ty-0.0.1a34-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad997a21648dc64017f11a96b7bb44f088ab0fd589decadc2d686fc97b102f4e", size = 10298873 }, - { url = "https://files.pythonhosted.org/packages/2e/80/c427dabd51b5d8b50fc375e18674c098877a9d6545af810ccff4e40ff74a/ty-0.0.1a34-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1afe9798f94c0fbb9e42ff003dfcb4df982f97763d93e5b1d53f9da865a53af", size = 9851399 }, - { url = "https://files.pythonhosted.org/packages/cc/d8/7240c0e13bc3405b190b4437fbc67c86aa70e349b282e5fa79282181532b/ty-0.0.1a34-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bd335010aa211fbf8149d3507d6331bdb947d5328ca31388cecdbd2eb49275c3", size = 9261475 }, - { url = "https://files.pythonhosted.org/packages/6b/a1/6538f8fe7a5b1a71b20461d905969b7f62574cf9c8c6af580b765a647289/ty-0.0.1a34-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:29ebcc56aabaf6aa85c3baf788e211455ffc9935b807ddc9693954b6990e9a3c", size = 9554878 }, - { url = "https://files.pythonhosted.org/packages/3d/f2/b8ab163b928de329d88a5f04a5c399a40c1c099b827c70e569e539f9a755/ty-0.0.1a34-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0cbb5a68fddec83c39db6b5f0a5c5da5a3f7d7620e4bcb4ad5bf3a0c7f89ab45", size = 9651340 }, - { url = "https://files.pythonhosted.org/packages/dc/1b/1e4e24b684ee5f22dda18d86846430b123fb2e985f0c0eb986e6eccec1b9/ty-0.0.1a34-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f9b3fd934982a9497237bf39fa472f6d201260ac95b3dc75ba9444d05ec01654", size = 9944488 }, - { url = "https://files.pythonhosted.org/packages/80/b0/6435f1795f76c57598933624af58bf67385c96b8fa3252f5f9087173e21a/ty-0.0.1a34-py3-none-win32.whl", hash = "sha256:bdabc3f1a048bc2891d4184b818a7ee855c681dd011d00ee672a05bfe6451156", size = 9151401 }, - { url = "https://files.pythonhosted.org/packages/73/2e/adce0d7c07f6de30c7f3c125744ec818c7f04b14622a739fe17d4d0bdb93/ty-0.0.1a34-py3-none-win_amd64.whl", hash = "sha256:a4caa2e58685d6801719becbd0504fe61e3ab94f2509e84759f755a0ca480ada", size = 10031079 }, - { url = "https://files.pythonhosted.org/packages/23/0d/1f123c69ce121dcabf5449a456a9a37c3bbad396e9e7484514f1fe568f96/ty-0.0.1a34-py3-none-win_arm64.whl", hash = "sha256:dd02c22b538657b042d154fe2d5e250dfb20c862b32e6036a6ffce2fd1ebca9d", size = 9534879 }, +version = "0.0.49" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/8d/37cb91808069509d43a2a11743e12f1e854fd808dbef2203309d256718cd/ty-0.0.49.tar.gz", hash = "sha256:0a027bd0c9c75d035641a365d087ad883446057f9be0b9826251c2aecafbf145", size = 5884753, upload-time = "2026-06-12T03:08:20.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/de/9237c6a96356612dd0393db1e94cf21f903616adf3a3701bf3da6e4adc92/ty-0.0.49-py3-none-linux_armv6l.whl", hash = "sha256:12c0c4310b936d762a8586c210b53d4fa4bb361a04429afa89bf84b922e5e065", size = 11834671, upload-time = "2026-06-12T03:07:53.062Z" }, + { url = "https://files.pythonhosted.org/packages/8f/15/daf5a14a5e07012277d450c75325c94614e2acfec4c620c881486118c410/ty-0.0.49-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:737bfdc2caf9712a8580944dcdc80a450a37a4f2bc83c8fa9b7433b374f9e471", size = 11589570, upload-time = "2026-06-12T03:08:25.779Z" }, + { url = "https://files.pythonhosted.org/packages/7d/58/30bdf98436488aca25f0763bf7f92a061528d42461b686453029e845e4c5/ty-0.0.49-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab90c1baf3b1701d282fce4b02fa552a962d109f8972c46ef6b22429503bfea4", size = 10985236, upload-time = "2026-06-12T03:08:36.664Z" }, + { url = "https://files.pythonhosted.org/packages/22/45/ece503e4a1396e13a1a9a0cde51afe476a6506a1d557eeadf8ad45c83bc0/ty-0.0.49-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ce8ecf6ba6fc79bd137cc0557a754f7e5f2dfe9436412551d480d680e248ad", size = 11504302, upload-time = "2026-06-12T03:08:01.664Z" }, + { url = "https://files.pythonhosted.org/packages/17/dc/5d09333d289dfbca1804eaade125c9e8a1a992a2a592a8b80c5e9b589ca9/ty-0.0.49-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10d85c6865c984e78661e0bd20b180514b4a289739224e84816e342bdf381e04", size = 11626629, upload-time = "2026-06-12T03:08:06.844Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/155f41c9dd7237c4b609211f29f77755a139ee6218605dadc7fe21d5e3c8/ty-0.0.49-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d96a67a206619e01fa92f35a22267ec634bba62be24b1d0e947020cc179995b", size = 12074481, upload-time = "2026-06-12T03:08:09.643Z" }, + { url = "https://files.pythonhosted.org/packages/96/4c/998ee13cd5045f1f8b36982de7343163832ac53f27debe01b0de0e8bd968/ty-0.0.49-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3de9f648564e0a66344ef397770387cb0d093735f8679d2c5a08a4741e79814d", size = 12678042, upload-time = "2026-06-12T03:08:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/85/c9/9a505aba85c41ce54cbcaa14f8d79aa084b86151d2d70df11c4655b92898/ty-0.0.49-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5779179ab397d15f8c9dbb8f506ec1b1745f54eac639982f76ef3ce538943b50", size = 12316194, upload-time = "2026-06-12T03:08:18.023Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/ded37fb93503294abbc83c36470bb1413bea05048b745881d4470b518a06/ty-0.0.49-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:792d4974e93cc09bd32f934586080bbbe21b8e777099cb521cb2de18b68a49f0", size = 12145507, upload-time = "2026-06-12T03:07:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/2f/07/392e80d78f02445f695b815bb9eb0fffacda68b03faee38c900f7b990815/ty-0.0.49-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:727bda86deb136073e525c2e78d60e38aedcce5d80579170844a52bbf7c1440d", size = 12365967, upload-time = "2026-06-12T03:08:12.553Z" }, + { url = "https://files.pythonhosted.org/packages/50/d3/31b0c2a7fbedd3373e389cb1d81b8d2128f6f868fafb46557736a6f9aca8/ty-0.0.49-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4f2fc2bc4a8d2ff1cca59fd94772cabdfec4062d47a0b3a0784be46d94d0540b", size = 11475283, upload-time = "2026-06-12T03:08:28.334Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5b/329e101638920b468a3bb63059c9f66ef99b44aac501222c44832a507321/ty-0.0.49-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3724bd9badef333321578b6a941fbc571ebf49141ec2356a8590fbe4c9aa588d", size = 11645343, upload-time = "2026-06-12T03:08:15.246Z" }, + { url = "https://files.pythonhosted.org/packages/a9/76/c897e615e32f80ca81c8c1bc49b9a1f72ff9e3cfea0f8345ba505fe28472/ty-0.0.49-py3-none-musllinux_1_2_i686.whl", hash = "sha256:166c6eb52ee4af3c5a9bb267d165d93000daa55c6758cd8ff3199741fb75917d", size = 11725585, upload-time = "2026-06-12T03:08:33.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/e1/fdb42ee239f618800842681af5bb8598117e74512c10974a8b7b9086a898/ty-0.0.49-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:91e81d832c287b05782ee32eb1b801f62c1fa08df37d589d2b88c3f1d51c9731", size = 12237261, upload-time = "2026-06-12T03:08:31.105Z" }, + { url = "https://files.pythonhosted.org/packages/98/0f/a2d6a5fc9d0786cbeb3c200786da4e18c203589be3984bb5def83ca92320/ty-0.0.49-py3-none-win32.whl", hash = "sha256:7186af5ca9829d1f5d8916bcf767b8e819bfbf61b1b8ec843bb3fc699cb502e1", size = 11100789, upload-time = "2026-06-12T03:07:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9d/473ac8bc57b5a2d121da893bf9dd74a118efb19a01d711df1a6e397f05cc/ty-0.0.49-py3-none-win_amd64.whl", hash = "sha256:ae2142fc126a01effcca0c222908b0e6654b5ba1266d4e4d406e4866aef8e1d1", size = 12204644, upload-time = "2026-06-12T03:08:04.327Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/8959249da951ba3977fee20e688d28678b8a1d30a9ed4464228a85d45853/ty-0.0.49-py3-none-win_arm64.whl", hash = "sha256:75d5e2e7649765f31f4bed6c8adb149a75b18edd3fa6336dac4d0efc1a66466f", size = 11558965, upload-time = "2026-06-12T03:08:23.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "tzdata" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, ] [[package]] @@ -456,7 +679,16 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799 } +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095 }, + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, ] From c799f90f12b80723b559780cbccff4d889ab2e47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 09:04:32 +0000 Subject: [PATCH 2/2] Restructure packages per best practices, modern README, drop docs - Merge commons/ and externals/ into a single core/ package (mixins, redis client, integrations) per HackSoft/cookiecutter-django conventions: apps/ = business logic, core/ = reusable infra, config/ = configuration only - Move health-check view from config/views.py to core/health.py - Remove docs/ directory - Rewrite README: animated header/footer (capsule-render), typing SVG, shields.io badges, emoji sections, mermaid architecture diagram, collapsible configuration sections https://claude.ai/code/session_01YZr4TbzTw6EkkFqZz2onTF --- README.md | 210 ++++++++----- config/urls.py | 2 +- {commons => core}/__init__.py | 0 config/views.py => core/health.py | 2 +- .../mixins => core/integrations}/__init__.py | 0 {externals => core/mixins}/__init__.py | 0 {commons => core}/mixins/models.py | 0 {commons => core}/redis/__init__.py | 0 {commons => core}/redis/keys.py | 0 docs/styleguide/api_view.md | 185 ----------- docs/styleguide/docstring.md | 93 ------ docs/styleguide/services_and_selectors.md | 297 ------------------ 12 files changed, 134 insertions(+), 655 deletions(-) rename {commons => core}/__init__.py (100%) rename config/views.py => core/health.py (96%) rename {commons/mixins => core/integrations}/__init__.py (100%) rename {externals => core/mixins}/__init__.py (100%) rename {commons => core}/mixins/models.py (100%) rename {commons => core}/redis/__init__.py (100%) rename {commons => core}/redis/keys.py (100%) delete mode 100644 docs/styleguide/api_view.md delete mode 100644 docs/styleguide/docstring.md delete mode 100644 docs/styleguide/services_and_selectors.md diff --git a/README.md b/README.md index bf92ca7..1d98784 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,40 @@ -# Django Boilerplate +
-Production-ready шаблон для создания API-приложений на Django. +![Header](https://capsule-render.vercel.app/api?type=waving&color=0:092E20,100:44B78B&height=200§ion=header&text=Django%20Boilerplate&fontSize=60&fontColor=ffffff&animation=fadeIn&fontAlignY=35&desc=Production-ready%20шаблон%20для%20API-приложений&descSize=18&descAlignY=55) -**Стек:** Python 3.13 · Django 6.0 · DRF 3.17 · PostgreSQL · Redis · uv · Docker +[![Typing SVG](https://readme-typing-svg.demolab.com?font=Fira+Code&weight=500&size=20&pause=1000&color=44B78B¢er=true&vCenter=true&width=600&lines=API-first+монолит+на+Django+6;Версионирование+API+через+заголовок+Accept;OpenAPI+%2B+Swagger+UI+из+коробки;Docker+%C2%B7+PostgreSQL+%C2%B7+Redis+%C2%B7+uv)](https://git.io/typing-svg) -## Возможности +[![CI](https://github.com/ak4code/django_boilerplate/actions/workflows/ci.yml/badge.svg)](https://github.com/ak4code/django_boilerplate/actions/workflows/ci.yml) +[![Python](https://img.shields.io/badge/Python-3.13-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![Django](https://img.shields.io/badge/Django-6.0-092E20?logo=django&logoColor=white)](https://www.djangoproject.com/) +[![DRF](https://img.shields.io/badge/DRF-3.17-A30000?logo=django&logoColor=white)](https://www.django-rest-framework.org/) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-18-4169E1?logo=postgresql&logoColor=white)](https://www.postgresql.org/) +[![Redis](https://img.shields.io/badge/Redis-8-FF4438?logo=redis&logoColor=white)](https://redis.io/) +[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](https://www.docker.com/) +[![uv](https://img.shields.io/badge/uv-package%20manager-DE5FE9?logo=uv&logoColor=white)](https://docs.astral.sh/uv/) +[![Ruff](https://img.shields.io/badge/code%20style-ruff-D7FF64?logo=ruff&logoColor=black)](https://docs.astral.sh/ruff/) -- **API-first монолит** с разделением на слои: views → services/selectors → models. -- **Версионирование API через заголовок** (`AcceptHeaderVersioning`) — версия не загрязняет URL. -- **OpenAPI-документация** из коробки (drf-spectacular): Swagger UI и ReDoc. -- **12-factor конфигурация** через переменные окружения (`django-environ`) и разделение настроек dev/prod (`django-split-settings`). -- **Production-настройки по умолчанию**: HSTS, secure cookies, SSL redirect, манифестная статика через whitenoise, gunicorn. -- **Кастомная модель пользователя** с email-логином и `external_id` (UUID) для внешних интеграций. -- **Health-check** (`/health/`) с проверкой БД и Redis — готов для Kubernetes liveness/readiness probes. -- **Качество кода**: ruff (lint + format), bandit, ty, pre-commit, pytest, GitHub Actions CI. -- **Docker**: multi-stage Dockerfile (non-root user), docker-compose с PostgreSQL и Redis. +
-## Быстрый старт +--- -### Docker (рекомендуется) +## ✨ Возможности + +| | | +| --- | --- | +| 🏗️ **API-first монолит** | Слои: views → services / selectors → models | +| 🔖 **Версионирование через заголовок** | `AcceptHeaderVersioning` — чистые URL без `/v1/` | +| 📖 **OpenAPI из коробки** | Swagger UI и ReDoc (drf-spectacular) | +| ⚙️ **12-factor конфигурация** | Всё через переменные окружения (django-environ) | +| 🔒 **Безопасный production** | HSTS, secure cookies, SSL redirect, whitenoise, gunicorn | +| 👤 **Кастомный пользователь** | Email-логин + `external_id` (UUID) для интеграций | +| 💓 **Health-check** | `/health/` с проверкой БД и Redis — готов для k8s-проб | +| 🧹 **Качество кода** | ruff · bandit · ty · pre-commit · pytest · GitHub Actions | +| 🐳 **Docker** | Multi-stage образ (non-root) + compose с PostgreSQL и Redis | + +## 🚀 Быстрый старт + +### 🐳 Docker (рекомендуется) ```bash make env # создать .env из .env.example @@ -26,9 +42,9 @@ make up # собрать и запустить postgres + redis + django make logs # смотреть логи ``` -Приложение: http://localhost:8000, Swagger UI: http://localhost:8000/api/docs/ +➜ Приложение: http://localhost:8000 · Swagger UI: http://localhost:8000/api/docs/ -### Локально +### 💻 Локально ```bash make env @@ -37,22 +53,22 @@ make migrate make run ``` -По умолчанию локально используется SQLite; для PostgreSQL задайте `DATABASE_URL` в `.env`. +> 💡 По умолчанию локально используется SQLite; для PostgreSQL задайте `DATABASE_URL` в `.env`. -## Версионирование API +## 🔖 Версионирование API Используется `AcceptHeaderVersioning`: клиент передаёт версию в заголовке `Accept`, URL остаются стабильными (`/api/users/...` без `/v1/`). ```bash -# Явно указанная версия +# ✅ Явно указанная версия curl http://localhost:8000/api/users/me/ \ -H 'Accept: application/json; version=v1' -# Без версии — используется DEFAULT_VERSION (v1) +# ✅ Без версии — используется DEFAULT_VERSION (v1) curl http://localhost:8000/api/users/me/ -# Неподдерживаемая версия → 406 Not Acceptable +# ❌ Неподдерживаемая версия → 406 Not Acceptable curl http://localhost:8000/api/users/me/ \ -H 'Accept: application/json; version=v999' ``` @@ -62,57 +78,96 @@ curl http://localhost:8000/api/users/me/ \ в `ALLOWED_VERSIONS` и ветвите логику по `request.version` (или подключайте другой сериализатор), не меняя URL. -## Эндпоинты - -| URL | Описание | -| --- | --- | -| `POST /api/users/register/` | Регистрация пользователя | -| `GET /api/users/me/` | Профиль текущего пользователя | -| `GET /api/schema/` | OpenAPI-схема | -| `GET /api/docs/` | Swagger UI | -| `GET /api/redoc/` | ReDoc | -| `GET /health/` | Health-check (БД + Redis), 503 при сбое | -| `/admin/` | Django admin | - -## Команды - -```bash -make install # установить зависимости (с dev-группой) -make run # запустить dev-сервер -make migrate # применить миграции -make makemigrations # создать миграции -make superuser # создать суперпользователя -make test # запустить тесты -make lint # ruff check + format check + bandit -make format # автоформатирование -make up / down # docker compose up/down +## 🌐 Эндпоинты + +| Метод | URL | Описание | +| :---: | --- | --- | +| `POST` | `/api/users/register/` | 📝 Регистрация пользователя | +| `GET` | `/api/users/me/` | 👤 Профиль текущего пользователя | +| `GET` | `/api/schema/` | 📄 OpenAPI-схема | +| `GET` | `/api/docs/` | 📖 Swagger UI | +| `GET` | `/api/redoc/` | 📕 ReDoc | +| `GET` | `/health/` | 💓 Health-check (БД + Redis), 503 при сбое | +| — | `/admin/` | 🛠️ Django admin | + +## 🏛️ Архитектура + +Монолитное API-ориентированное приложение по стандартам **Twelve-Factor App**. +Бизнес-функционал инкапсулирован в изолированные приложения внутри `apps/` — +каждый модуль проектируется как независимый компонент, что облегчает будущий +вынос в микросервисы. + +```mermaid +flowchart LR + Client([🌍 Клиент]) -->|"Accept: application/json; version=v1"| API + + subgraph Django["🐍 Django Boilerplate"] + direction LR + API["🔌 API Layer
(serializers, views)"] --> Domain["🧠 Domain Layer
(services, selectors)"] + Domain --> Models["🗃️ Models"] + end + + Models --> PG[("🐘 PostgreSQL")] + Domain --> RD[("⚡ Redis")] ``` -## Структура проекта +## 📁 Структура проекта + +Структура следует лучшим практикам ([HackSoft Django Styleguide](https://github.com/HackSoftware/Django-Styleguide), +[cookiecutter-django](https://github.com/cookiecutter/cookiecutter-django)) — три корневых пакета +с чёткими зонами ответственности: ``` -├── apps/ # Бизнес-модули (изолированные приложения) +├── 📂 apps/ # Бизнес-приложения (изолированные модули) │ └── users/ -│ ├── api/ # Слой API: serializers, views, urls -│ ├── services.py # Бизнес-логика (запись) -│ ├── selectors.py # Бизнес-логика (чтение) +│ ├── api/ # Слой API: serializers, views, urls +│ ├── services.py # Бизнес-логика (запись) +│ ├── selectors.py # Бизнес-логика (чтение) │ ├── models.py │ └── tests/ -├── commons/ # Переиспользуемый код (mixins, redis-клиент) -├── config/ # Конфигурация проекта -│ ├── settings/ # base.py + dev.py / prod.py (split-settings) +├── ⚙️ config/ # Только конфигурация проекта +│ ├── settings/ # base.py + dev.py / prod.py (split-settings) │ ├── urls.py -│ └── views.py # health-check -├── externals/ # Клиенты внешних сервисов -├── docs/styleguide/ # Соглашения по коду -├── Dockerfile # Multi-stage, non-root -├── docker-compose.yml # postgres + redis + django -└── pyproject.toml # Зависимости (uv), ruff, bandit, pytest +│ ├── asgi.py +│ └── wsgi.py +├── 🧰 core/ # Переиспользуемый инфраструктурный код +│ ├── health.py # Health-check view +│ ├── integrations/ # Клиенты внешних сервисов +│ ├── mixins/ # Примеси для моделей (timestamps и т.п.) +│ └── redis/ # Redis-клиент +├── 🐳 Dockerfile # Multi-stage, non-root +├── 🐳 docker-compose.yml # postgres + redis + django +└── 📦 pyproject.toml # Зависимости (uv), ruff, bandit, pytest ``` -## Конфигурация +| Пакет | Ответственность | Правило | +| --- | --- | --- | +| `apps/` | Бизнес-логика | Может импортировать `core/`, но не `config/` | +| `core/` | Инфраструктура, переиспользуемый код | Не знает о бизнес-логике | +| `config/` | Настройки, маршрутизация, WSGI/ASGI | Только конфигурация, без логики | -Все настройки — через переменные окружения (см. `.env.example`). Ключевые: +## 🛠️ Команды + +```bash +make install # 📦 установить зависимости (с dev-группой) +make run # 🚀 запустить dev-сервер +make migrate # 🗃️ применить миграции +make makemigrations # 📝 создать миграции +make superuser # 👑 создать суперпользователя +make test # 🧪 запустить тесты +make lint # 🔍 ruff check + format check + bandit +make format # ✨ автоформатирование +make up / down # 🐳 docker compose up/down +``` + +## ⚙️ Конфигурация + +Все настройки — через переменные окружения (см. [`.env.example`](.env.example)). + +
+📋 Ключевые переменные окружения + +
| Переменная | Назначение | По умолчанию | | --- | --- | --- | @@ -122,9 +177,15 @@ make up / down # docker compose up/down | `DJANGO_ALLOWED_HOSTS` | Разрешённые хосты через запятую | — | | `DATABASE_URL` | URL базы данных | SQLite | | `REDIS_URL` | URL Redis для кэша | `redis://redis:6379/1` | +| `DJANGO_LOG_LEVEL` | Уровень логирования | `INFO` | | `GUNICORN_WORKERS` | Количество воркеров gunicorn | `4` | -### Production +
+ +
+🔒 Production-развёртывание + +
`ENVIRONMENT=prod` включает: `DEBUG=False`, SSL redirect, HSTS, secure cookies, JSON-only рендеринг DRF, манифестную статику (whitenoise) и запуск через gunicorn @@ -134,22 +195,9 @@ JSON-only рендеринг DRF, манифестную статику (whiteno ENVIRONMENT=prod MODE=prod INSTALL_DEV=false docker compose up -d --build ``` -## Архитектура - -Проект построен как **монолитное API-ориентированное приложение** (API-first Monolith) -по стандартам **Twelve-Factor App**. Бизнес-функционал инкапсулирован в изолированные -приложения внутри `apps/` — каждый модуль проектируется как независимый компонент, -что облегчает будущий вынос в микросервисы. +
-Слои: - -1. **API (Interface Layer)** — DRF views валидируют ввод/вывод, не содержат бизнес-правил. -2. **Бизнес-логика (Domain Layer)** — services (запись) и selectors (чтение) внутри приложений. -3. **Данные (Persistence Layer)** — PostgreSQL, миграции Django. - -Подробнее — в `docs/styleguide/`. - -## Качество кода +## 🧹 Качество кода ```bash uv run pre-commit install # включить pre-commit хуки @@ -158,3 +206,9 @@ make test # тесты ``` CI (GitHub Actions) запускает линтеры и тесты на каждый push в `main` и pull request. + +
+ +![Footer](https://capsule-render.vercel.app/api?type=waving&color=0:44B78B,100:092E20&height=120§ion=footer) + +
diff --git a/config/urls.py b/config/urls.py index d06e436..799f8d6 100644 --- a/config/urls.py +++ b/config/urls.py @@ -14,7 +14,7 @@ from django.urls import include, path from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView -from config.views import health_check +from core.health import health_check api_urlpatterns = [ path('users/', include('apps.users.api.urls', namespace='users_api')), diff --git a/commons/__init__.py b/core/__init__.py similarity index 100% rename from commons/__init__.py rename to core/__init__.py diff --git a/config/views.py b/core/health.py similarity index 96% rename from config/views.py rename to core/health.py index 60fd98e..d6fcdad 100644 --- a/config/views.py +++ b/core/health.py @@ -1,7 +1,7 @@ from django.db import connections from django.http import HttpRequest, JsonResponse -from commons.redis import get_redis +from core.redis import get_redis def health_check(request: HttpRequest) -> JsonResponse: diff --git a/commons/mixins/__init__.py b/core/integrations/__init__.py similarity index 100% rename from commons/mixins/__init__.py rename to core/integrations/__init__.py diff --git a/externals/__init__.py b/core/mixins/__init__.py similarity index 100% rename from externals/__init__.py rename to core/mixins/__init__.py diff --git a/commons/mixins/models.py b/core/mixins/models.py similarity index 100% rename from commons/mixins/models.py rename to core/mixins/models.py diff --git a/commons/redis/__init__.py b/core/redis/__init__.py similarity index 100% rename from commons/redis/__init__.py rename to core/redis/__init__.py diff --git a/commons/redis/keys.py b/core/redis/keys.py similarity index 100% rename from commons/redis/keys.py rename to core/redis/keys.py diff --git a/docs/styleguide/api_view.md b/docs/styleguide/api_view.md deleted file mode 100644 index f19810a..0000000 --- a/docs/styleguide/api_view.md +++ /dev/null @@ -1,185 +0,0 @@ -# Единый HTTP-слой на APIView - -## Цели - -- Единообразный стиль реализации HTTP-слоя. -- Прозрачное разделение обязанностей между APIView и доменным слоем (services/selectors). -- Упрощение чтения и сопровождения кода для всей команды. - -*** - -## Почему APIView - -В проекте используется единый подход: все HTTP-эндпоинты реализуются на базе `rest_framework.views.APIView`, а не через Function-Based Views или GenericViewSet/ModelViewSet. - -Плюсы такого подхода: - -- Явный и предсказуемый жизненный цикл запроса (`dispatch`, `get/post/...`). -- Полный контроль над тем, как вызываются сериализаторы, сервисы и селекторы. -- Отсутствие скрытой магии generic-классов, проще читать и отлаживать код. -- Единый паттерн для всех разработчиков - меньше когнитивной нагрузки при переходе между модулями. - -*** - -## Общие правила для APIView - -1. Один публичный HTTP-метод - одна основная операция (например, `post` для создания, `get` для получения). -2. В APIView запрещено: - - Писать бизнес-логику. - - Работать напрямую с моделями (кроме простых случаев чтения id из URL). - - Дублировать логику валидации данных, уже реализованную в сериализаторах или сервисах. - -3. В APIView разрешено и рекомендуется: - - Делегировать доменную логику в `services` и `selectors`. - - Использовать сериализаторы только для валидации входных данных и формирования ответа. - - Обрабатывать только HTTP-аспекты: статус-коды, формат ответа, маппинг исключений. - -*** - -## Структура APIView - -Каждый метод (`get`, `post`, `put`, `patch`, `delete`) логически делится на 4 шага: - -1. Извлечение и валидация входных данных (`RequestSerializer` / query params / path params). -2. Вызов селектора/сервиса. -3. Преобразование результата в `ResponseSerializer`. -4. Формирование `Response` с корректным HTTP-статусом. - -Пример шаблона: - -```python -from typing import Any - -from django.core.exceptions import ValidationError -from rest_framework import status -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.users.api.serializers import UserRegisterRequestSerializer, UserResponseSerializer -from apps.users.services import create_user - - -class UserRegisterApi(APIView): - """ - Регистрация нового пользователя. - """ - - def post(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """ - Обрабатывает POST-запрос на регистрацию пользователя. - - :param request: HTTP-запрос - :return: HTTP-ответ с данными пользователя или ошибкой - """ - # 1. Валидация входа - serializer = UserRegisterRequestSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - # 2. Вызов сервиса - try: - user = create_user( - email=serializer.validated_data['email'], - password=serializer.validated_data['password'], - ) - except ValidationError as exc: - return Response( - {'detail': str(exc)}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # 3. Сериализация результата - output_serializer = UserResponseSerializer(user) - - # 4. Формирование ответа - return Response( - output_serializer.data, - status=status.HTTP_201_CREATED, - ) -``` - -*** - -## Требования к сериализаторам для APIView - -Для единообразия: - -- Используются отдельные сериализаторы для: - - входных данных: `*RequestSerializer`, - - выходных данных: `*ResponseSerializer`, если это оправдано. -- APIView не должна "догадываться", какие поля можно принимать, это описано в `RequestSerializer`. -- APIView не должна собирать response-JSON вручную - это обязанность `ResponseSerializer`. - -*** - -## Тестирование APIView - -- Для всех APIView пишутся интеграционные тесты через `APIClient`. -- Структура тестов: - - docstring в формате AAA (Arrange / Act / Assert). - - Проверка: - - кодов ответов, - - структуры и содержания ответа, - - взаимодействия с сервисами (при необходимости через моки). - -Пример: - -```python -from typing import Any - -from rest_framework import status -from rest_framework.test import APIClient - - -def test_user_register_success(api_client: APIClient) -> None: - """ - Проверяет успешную регистрацию пользователя. - - Arrange: - - Подготавливаем валидные данные. - - Формируем POST-запрос к /api/v1/users/register/. - - Act: - - Выполняем запрос через APIClient. - - Assert: - - Статус 201. - - Email в ответе совпадает с переданным. - """ - payload = { - 'email': 'new@example.com', - 'password': 'pwd123', - 'confirm_password': 'pwd123', - } - - response = api_client.post( - '/api/v1/users/register/', - data=payload, - format='json', - ) - - assert response.status_code == status.HTTP_201_CREATED - assert response.data['email'] == 'new@example.com' -``` - -*** - -## Организация кода в приложении - -Рекомендуемая структура модуля приложения: - -```text -apps/ - users/ - api/ - views.py # APIView-контроллеры - serializers.py # Request/Response сериализаторы - urls.py # Роутинг API - services.py # Бизнес-логика (изменение состояния) - selectors.py # Чтение и выборки - models.py - tests/ - test_api_*.py - test_services_*.py - test_selectors_*.py -``` \ No newline at end of file diff --git a/docs/styleguide/docstring.md b/docs/styleguide/docstring.md deleted file mode 100644 index 3143f48..0000000 --- a/docs/styleguide/docstring.md +++ /dev/null @@ -1,93 +0,0 @@ -## Общие правила для docstring и комментариев - -- В теле функций и тестов использовать одинарные кавычки `'...'`, кроме docstring. -- Многострочный docstring: - - Открывающие `"""` на отдельной строке. - - Со следующей строки - краткое описание. - -Пример: - -```python -def do_something(x: int) -> int: - """ - Делает что-то с числом. - - :param x: входное значение - :return: результат обработки - """ - result = x + 1 - return result -``` - -## Docstring для обычного кода - -- Не использовать AAA. -- Использовать reST-нотацию с `:param`, `:return`, `:raises` при необходимости. -- Если функция ничего не возвращает, `:return` можно опускать. -- Если функция может выбрасывать исключения - документировать через `:raises`. - -Пример сервиса: - -```python -def create_user(email: str, password: str) -> User: - """ - Создает нового пользователя в системе. - - :param email: адрес электронной почты пользователя - :param password: пароль пользователя в открытом виде - :return: созданный пользователь - :raises ValueError: если email уже занят или некорректен - """ - if not email: - raise ValueError('Email is required') - - user = User.objects.create_user(email=email, password=password) - return user -``` - -## Комментарии в коде - -- Не писать комментарии для очевидных вещей. -- Комментарии добавляются только по отдельному запросу или для пояснения нетривиальной логики. -- Предпочтение - чистый код и качественные docstring вместо обилия комментариев. - -## Кавычки - -- В теле функций и тестов - одинарные кавычки `'...'`. -- В docstring - тройные двойные кавычки `"""..."""`. - -## Docstring для тестов - -- Всегда использовать формат AAA: - - `Arrange:` - подготовка данных и окружения. - - `Act:` - одно действие, которое тестируется. - - `Assert:` - проверки результата. -- Описание теста кратко формулирует, что именно проверяется. - -Пример: - -```python -def test_user_me_get_success(api_client: APIClient, user_factory: Any) -> None: - """ - Проверяет успешное получение профиля текущего пользователя. - - Arrange: - - Создаем пользователя. - - Аутентифицируем клиента. - - Act: - - Выполняем GET-запрос к /api/v1/users/me/. - - Assert: - - Проверяем статус 200. - - Проверяем, что email в ответе совпадает с ожидаемым. - """ - email = 'user@example.com' - user = user_factory(email=email, password='password123') - - api_client.force_authenticate(user=user) - response = api_client.get('/api/v1/users/me/') - - assert response.status_code == status.HTTP_200_OK - assert response.data['email'] == email -``` \ No newline at end of file diff --git a/docs/styleguide/services_and_selectors.md b/docs/styleguide/services_and_selectors.md deleted file mode 100644 index 9573125..0000000 --- a/docs/styleguide/services_and_selectors.md +++ /dev/null @@ -1,297 +0,0 @@ -# Services и Selectors в Django/DRF - -## Цели - -- Явное разделение чтения и изменения состояния. -- Упрощение тестирования и повторного использования логики. -- "Тонкие" контроллеры (APIView/ViewSet), "толстый" домен (services/selectors). - -*** - -## Selectors - -### Назначение - -Selectors отвечают только за чтение данных: - -- Не изменяют состояние БД (никаких `.save()`, `.create()`, `.delete()`). -- Инкапсулируют логику выборок и оптимизации запросов (фильтры, `select_related`, `prefetch_related`). -- Возвращают: - - `QuerySet`, - - конкретные модели, - - DTO/словари/агрегированные структуры. - -### Пример - -```python -# apps/users/selectors.py - -from typing import Iterable - -from django.db.models import QuerySet - -from apps.users.models import User - - -def get_active_users() -> QuerySet[User]: - """ - Возвращает queryset активных пользователей. - """ - return User.objects.filter(is_active=True) - - -def get_user_by_email(email: str) -> User | None: - """ - Возвращает пользователя по email или None. - - :param email: адрес электронной почты - :return: пользователь или None, если не найден - """ - return User.objects.filter(email=email).first() -``` - -*** - -## Services - -### Назначение - -Services отвечают за изменение состояния и бизнес-правила: - -- Создание, обновление, удаление сущностей. -- Внешние интеграции (HTTP-клиенты, email/SMS, очереди задач). -- Доменные проверки и бизнес-валидация (ограничения, workflow). - -APIView не должна содержать бизнес-логику; она вызывает сервис и мапит результат в HTTP-ответ. - -### Пример - -```python -# apps/users/services.py - -from django.core.exceptions import ValidationError - -from apps.users.models import User - - -def create_user(email: str, password: str) -> User: - """ - Создает нового пользователя. - - :param email: адрес электронной почты - :param password: пароль пользователя в открытом виде - :return: созданный пользователь - :raises ValidationError: если пользователь с таким email уже существует - """ - if User.objects.filter(email=email).exists(): - raise ValidationError('User with this email already exists') - - user = User.objects.create_user(email=email, password=password) - return user -``` - -*** - -## Связка APIView → Services/Selectors - -### Принципы - -APIView/ViewSet выполняет только: - -- Авторизацию, permissions, throttling. -- Валидацию входа через сериализатор. -- Вызов нужного `service` или `selector`. -- Превращение результата в сериализатор/JSON. -- Маппинг исключений домена в HTTP-коды. - -### Пример APIView - -```python -# apps/users/api/views.py - -from typing import Any - -from django.core.exceptions import ValidationError -from rest_framework import status -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.users.api.serializers import UserRegisterInputSerializer, UserOutputSerializer -from apps.users.services import create_user - - -class UserRegisterApi(APIView): - """ - Регистрация нового пользователя. - """ - - def post(self, request: Request, *args: Any, **kwargs: Any) -> Response: - """ - Обрабатывает POST-запрос на регистрацию. - - :param request: HTTP-запрос - :return: HTTP-ответ с данными пользователя или ошибкой - """ - serializer = UserRegisterInputSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - try: - user = create_user( - email=serializer.validated_data['email'], - password=serializer.validated_data['password'], - ) - except ValidationError as exc: - return Response( - {'detail': str(exc)}, - status=status.HTTP_400_BAD_REQUEST, - ) - - output = UserOutputSerializer(user) - return Response(output.data, status=status.HTTP_201_CREATED) -``` - -*** - -## Тестирование - -### Selectors - -- Чистые unit-тесты без HTTP. -- Проверяем: - - корректность фильтров и результата, - - количество запросов (при необходимости), - - граничные случаи (нет записей, несколько записей). - -Пример: - -```python -def test_get_active_users_returns_only_active_users(django_user_model) -> None: - """ - Проверяет, что возвращаются только активные пользователи. - - Arrange: - - Создаем активного и неактивного пользователей. - - Act: - - Вызываем get_active_users. - - Assert: - - В результирующем queryset только активные пользователи. - """ - django_user_model.objects.create_user(email='a@example.com', password='pwd', is_active=True) - django_user_model.objects.create_user(email='b@example.com', password='pwd', is_active=False) - - qs = get_active_users() - - emails = {u.email for u in qs} - assert emails == {'a@example.com'} -``` - -### Services - -- Unit-тесты на уровне Python/ORM. -- Мокаем внешние интеграции. -- Проверяем: - - эффект в БД, - - выбрасываемые исключения, - - вызовы внешних сервисов. - -Пример: - -```python -def test_create_user_success(django_user_model) -> None: - """ - Проверяет успешное создание пользователя. - - Arrange: - - Подготавливаем email и пароль. - - Act: - - Вызываем create_user. - - Assert: - - Пользователь создается в БД. - """ - email = 'test@example.com' - password = 'pwd123' - - user = create_user(email=email, password=password) - - assert user.email == email - assert django_user_model.objects.filter(email=email).exists() -``` - -### APIView - -- Интеграционные тесты через `APIClient`. -- Проверяем: - - auth/permissions, - - 200/201 vs 400/401/403/409, - - маппинг исключений сервиса в HTTP. -- При необходимости мокаем `services` в негативных сценариях. - -Пример: - -```python -from rest_framework import status -from rest_framework.test import APIClient - - -def test_user_register_success(api_client: APIClient) -> None: - """ - Проверяет успешную регистрацию пользователя. - - Arrange: - - Подготавливаем валидные данные. - - Формируем POST-запрос к /api/v1/users/register/. - - Act: - - Выполняем запрос через APIClient. - - Assert: - - Статус 201. - - Email в ответе совпадает с переданным. - """ - payload = { - 'email': 'new@example.com', - 'password': 'pwd123', - 'confirm_password': 'pwd123', - } - - response = api_client.post( - '/api/v1/users/register/', - data=payload, - format='json', - ) - - assert response.status_code == status.HTTP_201_CREATED - assert response.data['email'] == 'new@example.com' -``` - -*** - -## Структура проекта - -Рекомендуемая структура для каждого приложения: - -```text -apps/ - users/ - api/ - views.py - serializers.py - urls.py - services.py - selectors.py - models.py - tests/ - test_api_*.py - test_services_*.py - test_selectors_*.py -``` - -- `api/` – HTTP-слой (DRF). -- `services.py` – изменение состояния и бизнес-логика. -- `selectors.py` – чтение и выборки. -- `tests/` разделяются по тем же слоям. \ No newline at end of file