/
systemsstrategyy
/
Treker
Обзор
Документация
Войти
/
systemsstrategyy
/
Treker
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
docs/_build_coding_principles.py
522 строки
28 KB
SystemsStrategy
Initial import
02 июл 2026, 15:15
02 июл 2026, 15:15
9624ed1
Код
Авторство
О чём код?
# -*- coding: utf-8 -*- """ Генератор документа «Принципы и подходы разработки» v1.1. Формат: A4, 3-4 страницы, Times New Roman, простые таблицы. Структура соответствует Coding_Principles_v1.1.md. """ from docx import Document from docx.shared import Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.table import WD_TABLE_ALIGNMENT from pathlib import Path OUT = Path(r"C:\Users\g.trofimov\Desktop\work\strategiya-sistem-tracker\docs\Coding_Principles_v1.1.docx") FONT = "Times New Roman" SIZE_BODY = 11 SIZE_HEAD = 13 SIZE_TITLE = 16 def setup(): doc = Document() style = doc.styles['Normal'] style.font.name = FONT style.font.size = Pt(SIZE_BODY) style.paragraph_format.space_after = Pt(2) style.paragraph_format.line_spacing = 1.1 for s in doc.sections: s.top_margin = Cm(1.5) s.bottom_margin = Cm(1.5) s.left_margin = Cm(1.9) s.right_margin = Cm(1.7) return doc def h1(doc, text): p = doc.add_paragraph() p.paragraph_format.space_before = Pt(6) p.paragraph_format.space_after = Pt(2) r = p.add_run(text) r.bold = True r.font.name = FONT r.font.size = Pt(SIZE_HEAD) def para(doc, text, *, indent_first=Cm(0.6)): p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY p.paragraph_format.first_line_indent = indent_first p.paragraph_format.space_after = Pt(2) r = p.add_run(text) r.font.name = FONT r.font.size = Pt(SIZE_BODY) return p def para_lead(doc, lead, rest, *, italic_lead=False): """Параграф с жирным/курсивным началом, потом обычный текст.""" p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY p.paragraph_format.first_line_indent = Cm(0.6) p.paragraph_format.space_after = Pt(2) r1 = p.add_run(lead) r1.bold = not italic_lead r1.italic = italic_lead r1.font.name = FONT r1.font.size = Pt(SIZE_BODY) r2 = p.add_run(" " + rest) r2.font.name = FONT r2.font.size = Pt(SIZE_BODY) def bullet(doc, lead, rest=""): """Универсальный буллет: жирный лид + обычный текст.""" p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY p.paragraph_format.left_indent = Cm(0.6) p.paragraph_format.space_after = Pt(1) r1 = p.add_run("• ") r1.font.name = FONT r1.font.size = Pt(SIZE_BODY) r2 = p.add_run(lead) r2.bold = True r2.font.name = FONT r2.font.size = Pt(SIZE_BODY) if rest: r3 = p.add_run(" " + rest) r3.font.name = FONT r3.font.size = Pt(SIZE_BODY) def table(doc, header, rows, *, widths=None): n = len(header) t = doc.add_table(rows=0, cols=n) t.style = "Table Grid" t.alignment = WD_TABLE_ALIGNMENT.LEFT hdr = t.add_row().cells for i, h in enumerate(header): hdr[i].text = "" p = hdr[i].paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER r = p.add_run(h) r.bold = True r.font.name = FONT r.font.size = Pt(SIZE_BODY) for row in rows: cells = t.add_row().cells for i, val in enumerate(row): cells[i].text = "" r = cells[i].paragraphs[0].add_run(str(val)) r.font.name = FONT r.font.size = Pt(SIZE_BODY) if widths: for r in t.rows: for i, w in enumerate(widths): r.cells[i].width = Cm(w) return t def build(): doc = setup() # Заголовок p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.paragraph_format.space_after = Pt(0) r = p.add_run("Принципы и подходы разработки") r.bold = True r.font.name = FONT r.font.size = Pt(SIZE_TITLE) p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.paragraph_format.space_after = Pt(4) r = p.add_run("Версия 1.1 · 2026-05-08 · Для команды разработки") r.italic = True r.font.name = FONT r.font.size = Pt(SIZE_BODY - 1) para(doc, "Чек-лист принципов и паттернов которыми мы пользуемся в проекте. " "Не теория, а как именно у нас. v1.1 учитывает архитектурное ревью v1.0 " "— добавлены критические операционные темы (auth, очереди, реал-тайм, " "observability, бэкап, версионирование API) и переформулирован раздел " "про слои в сторону vertical slice.") # ───────────── 1. Принципы написания кода ───────────── h1(doc, "1. Принципы написания кода") para_lead(doc, "SOLID, DRY, KISS, YAGNI", "— соблюдаем без расшифровки в этом документе, на код-ревью проверяется " "по факту. Если правило не очевидно из кода — это сигнал что переусложнили.") para_lead(doc, "Composition over inheritance.", "Используем typing.Protocol (структурная типизация) вместо абстрактных " "классов — mypy ловит несоответствия без жёсткого наследования.") para_lead(doc, "Tell, don't ask.", "Если у объекта есть инвариант — даём метод. card.move_to(column, by=user) " "лучше чем service.move(...) с проверками снаружи. Защита от анемичной модели.") para_lead(doc, "Общий код", "— Pydantic-схемы между модулями в shared/schemas/, общие зависимости — " "в shared/deps/. Не закладываем абстракции «на будущее» без конкретного " "второго применения.") # ───────────── 2. Архитектурный паттерн ───────────── h1(doc, "2. Архитектурный паттерн — модульный монолит, vertical slice внутри") para(doc, "Один FastAPI-процесс, разделённый на модули по доменам (auth, boards, " "tasks, notifications). Между модулями жёсткие границы — общаются через " "публичные интерфейсы (module/api.py).") para(doc, "Внутри модуля используем vertical slice. Базовый набор:", indent_first=Cm(0)) table(doc, header=("Файл", "Что делает"), rows=[ ("router.py", "FastAPI-эндпоинты, проверка прав"), ("service.py", "Бизнес-логика модуля"), ("schemas.py", "Pydantic-DTO для API"), ("models.py", "SQLAlchemy ORM-модели"), ("api.py", "Публичный интерфейс модуля для других модулей"), ], widths=[3.5, 13.0] ) para_lead(doc, "domain.py — опциональный слой,", "добавляется только когда у агрегата есть инварианты: WIP-лимит колонки, " "нельзя двигать архивную карточку. Для тривиального CRUD не нужен. Это " "противоположно догматичному Clean Architecture — мы не делаем " "interfaces/usecases/presenters/ без необходимости.") para_lead(doc, "Repository — выборочно.", "SQLAlchemy AsyncSession уже Unit of Work + Identity Map; обёртки " "def get(id): return session.get(Card, id) — overhead. Repository " "добавляем только для сложных запросов с осмысленным именем " "(find_overdue_cards_for_assignee).") para_lead(doc, "Cross-cutting через события.", "Уведомления из любого модуля — через in-process domain events: модули " "публикуют (card.assigned), notifications подписан и обрабатывает. Это " "снимает круговые зависимости.") # ───────────── 3. Паттерны проектирования ───────────── h1(doc, "3. Паттерны проектирования") para_lead(doc, "Service Layer.", "Бизнес-логика модуля собрана в классах сервисов. Другие модули вызывают " "эти методы через module/api.py, не лезут в чужие модели.") para_lead(doc, "Dependency Injection", "через FastAPI Depends(). Зависимости передаются, не создаются внутри. " "Это делает код тестируемым.") para_lead(doc, "Unit of Work.", "Один HTTP-запрос = одна сессия = одна транзакция. Коммит в обёртке " "async with session.begin(). Сервисы НЕ коммитят посреди процесса.") para_lead(doc, "Strategy / Plugin.", "Когда у логики несколько вариантов — Protocol-интерфейс и стратегии. " "Так у нас сделаны NotificationChannel и FileStorage.") para_lead(doc, "Circuit Breaker + Retry", "для внешних сервисов: purgatory (async-native CB, state в Redis для " "shared между воркерами) + tenacity (retry с exponential backoff внутри CB). " "Исключения: Telegram — только tenacity без CB. Bugsink/sentry-sdk " "не оборачиваем.") # ───────────── 4. Auth ───────────── h1(doc, "4. Аутентификация и авторизация") para_lead(doc, "JWT", "для access/refresh. Access короткий (15-30 мин), refresh длинный " "(30 дней), оба в httpOnly Secure SameSite=Strict cookie. Алгоритм RS256 " "(asymmetric — ротация ключей без выкатывания приватного на клиенты). " "Claims: sub (user_id), exp, iat, jti, scope.") para_lead(doc, "Refresh-rotation.", "Каждый /refresh выдаёт новую пару и инвалидирует старый refresh. Reuse " "использованного refresh = compromise → revoke всей token family. " "Denylist по jti в Redis с TTL равным остатку жизни токена (мгновенный " "revoke без хранения всей таблицы сессий).") para_lead(doc, "RBAC через матрицу role × action.", "Роли: admin, teamlead, member, viewer. Атрибутные правила («assignee " "может редактировать карточку») — в зависимости-факториях через Depends(), " "без OPA/Casbin.") para_lead(doc, "Auth по умолчанию через dependencies=[...] на роутере.", "Открытые эндпоинты помечаем явно — Depends(allow_anonymous). Fail-safe " "defaults — забыл проверку, сработает «нельзя».") # ───────────── 5. Фоновые задачи + кэш + реал-тайм ───────────── h1(doc, "5. Фоновые задачи, реал-тайм, кэш") para_lead(doc, "Очередь задач.", "Стартуем с Procrastinate (Postgres-backed, не требует Redis для брокера) " "— хватит на 50-200 нотификаций/день. Когда поднимем Redis для кэша или " "sessions — переходим на ARQ или Taskiq (FastAPI-friendly DI, лучшая " "производительность).") para_lead(doc, "Кэш через Redis", "для тяжёлых выборок (доска со всеми карточками, агрегаты для скорборда). " "Стратегия: cache-aside с TTL 30-60с, инвалидация по событию (после " "card.updated — DEL board:{board_id}). Не кэшируем то что не профилировали.") para_lead(doc, "BackgroundTasks FastAPI допустим", "только для post-write-эффектов под секунду без потери при рестарте: " "инвалидация кэша, audit-лог. Всё остальное — в очередь.") para_lead(doc, "Реал-тайм через SSE + LISTEN/NOTIFY.", "Server-Sent Events — однонаправленный поток server→client, plain HTTP, " "авто-reconnect. Один dedicated asyncpg-listener в lifespan, fan-out через " "локальную очередь per worker. WebSocket — только если понадобится duplex.") para_lead(doc, "Telegram через webhook,", "не polling — polling не масштабируется на несколько реплик.") # ───────────── 6. Тестирование ───────────── h1(doc, "6. Тестирование — Testing Trophy") para(doc, "Для backend API адекватнее Trophy (Kent C. Dodds), не классическая " "пирамида: больше всего отдачи дают интеграционные тесты через " "TestClient + реальный Postgres из testcontainers-python.") table(doc, header=("Уровень", "Что покрывает"), rows=[ ("Static", "mypy strict + ruff на весь код"), ("Integration", "TestClient + testcontainers Postgres — основной фокус"), ("Unit", "чистая логика в domain.py где есть инварианты"), ("E2E", "Playwright по критичным сценариям, минимум"), ], widths=[3.5, 13.0] ) para_lead(doc, "TDD-first для domain.py", "обязателен. Для тонких роутов — test-after прагматичнее. Для алгоритмов " "позиционирования и прав — hypothesis (property-based). На критичных " "модулях (auth, billing) — mutmut (mutation testing). Coverage 80% — " "ориентир, не цель. Внешние сервисы всегда мокаем.") # ───────────── 7. Эксплуатация ───────────── h1(doc, "7. Эксплуатация: ошибки, логи, миграции, данные") para_lead(doc, "Ошибки по RFC 9457 (Problem Details).", "Иерархия исключений: DomainException → ValidationError, NotFoundError, " "PermissionDenied, QuotaExceeded, BusinessRuleViolation. FastAPI-обработчик " "превращает их в Problem Details JSON. У каждого исключения есть поле " "code (snake_case, stable identifier) — фронтенд парсит его, не текст. " "Никаких raise Exception(...) без типа.") para_lead(doc, "Логи через structlog в JSON.", "Уровни: DEBUG (только dev), INFO (значимые события: card.created, " "payment.captured), WARNING (recoverable: 429 от Telegram), ERROR " "(auto-flow в Bugsink). Каждое сообщение — событие с полями: " "event=\"card.created\", card_id=..., user_id=... Никогда не логируем: " "пароли, токены, полные номера карт, тело payment-webhook без маскирования, " "PII (email/ФИО/телефон) без необходимости. request_id обязателен через " "ASGI middleware → structlog.contextvars.") para_lead(doc, "Миграции backwards-compatible в две фазы:", "добавляем поле/таблицу → деплой нового кода → удаляем старое. Большие " "индексы — CREATE INDEX CONCURRENTLY через op.execute в autocommit_block. " "Каждая миграция имеет работающий downgrade() — проверяется через " "pytest-alembic в CI (test_single_head_revision, test_upgrade, round-trip " "up→down→up). Naming convention в MetaData обязательна.") para(doc, "Soft delete vs hard delete:", indent_first=Cm(0)) table(doc, header=("Тип", "Что", "Как"), rows=[ ("Soft (deleted_at)", "карточки, комментарии, борды, колонки", "UX «в корзину», восстановление = UPDATE SET deleted_at = NULL"), ("Hard (DELETE)", "PII на GDPR-запрос, idempotency_keys по TTL, audit_log по retention", "физическое удаление, не подлежит восстановлению"), ], widths=[3.5, 6.0, 7.0] ) para(doc, "Все запросы на soft-deleted сущности фильтруют WHERE deleted_at IS NULL " "через SQLAlchemy event-listener — забыть нельзя.") # ───────────── 8. Observability ───────────── h1(doc, "8. Observability и health checks") para_lead(doc, "Метрики через prometheus-fastapi-instrumentator", "v7+ — в будущем. Из коробки rate / errors / duration. НЕ кладём user_id " "или card_id в labels — cardinality explosion положит Prometheus.") para_lead(doc, "Трейсинг через OpenTelemetry — в будущем.", "opentelemetry-instrumentation-{fastapi,sqlalchemy,asyncpg,redis,httpx}. " "Span-разбивка показывает какой SQL внутри handler доминирует latency.") para_lead(doc, "Ошибки — Bugsink", "(self-hosted Sentry-совместимый). DSN указывает на Bugsink, sentry-sdk " "без изменений. traces_sample_rate=0. before_send фильтрует PII.") para(doc, "Health checks — три отдельных пробы:", indent_first=Cm(0)) bullet(doc, "/livez", "— только in-process, БЕЗ DB-чека (иначе при отказе БД " "pod в crash-loop без пользы);") bullet(doc, "/readyz", "— SELECT 1 + redis.ping() с таймаутом 1с, кэш 1-2с;") bullet(doc, "/startupz", "— для медленного старта (миграции — отдельный " "init-container, не lifespan).") para_lead(doc, "SLO зафиксированы:", "p95 /api/* < 200мс, p99 < 500мс, error rate < 0.1%.") # ───────────── 9. Конфигурация ───────────── h1(doc, "9. Конфигурация и секреты") para(doc, "Все настройки через pydantic-settings v2 — env-переменные, поддержка " ".env для локальной разработки, nested-models для секций. В коде нет " "хардкода URL, секретов, токенов.") para_lead(doc, "Секреты в production", "— Yandex Lockbox / Vault / sops-encrypted git, инжектируются как " "env-переменные. .env с реальными секретами в git никогда — только " ".env.example с шаблоном. 12-factor: настройки в env, логи в stdout, " "процессы stateless.") # ───────────── 10. Безопасность ───────────── h1(doc, "10. Безопасность") para_lead(doc, "Fail-safe defaults.", "По умолчанию запрещено всё.") para_lead(doc, "Defense in depth.", "Несколько слоёв: Depends(get_current_user) → проверка прав → " "автоматический фильтр company_id в SQL → разделение Postgres-схем " "с отдельными DB-юзерами.") para_lead(doc, "Idempotency-Key", "на всех write-эндпоинтах. Для финансовых операций — Postgres-таблица " "idempotency_keys с UNIQUE-индексом и INSERT ... ON CONFLICT DO NOTHING " "(Stripe-стиль); для остальных — Redis SET NX EX 86400.") para_lead(doc, "Webhook ЮKassa", "— два слоя защиты вместо HMAC (его у ЮKassa нет): IP allow-list через " "SecurityHelper().is_ip_trusted(ip) + re-fetch объекта через API " "(Payment.find_one(payment_id)). Дедупликация — " "processed_notifications(object_id, event) UNIQUE.") para(doc, "Rate limiting через slowapi (или fastapi-limiter если Redis уже есть):", indent_first=Cm(0)) bullet(doc, "мягкий", "на write-эндпоинты: 10 req/s/user;") bullet(doc, "жёсткий", "на webhook-приёмники (по IP);") bullet(doc, "brute-force", "на /auth/login: 5 попыток / 15 мин по IP+username.") para_lead(doc, "Multi-tenancy: schema-per-tenant", "для масштаба <500 тенантов. Trigger миграции на shared-schema + RLS — " "отдельный ADR.") # ───────────── 11. API ───────────── h1(doc, "11. API: версионирование и документация") para_lead(doc, "Версионирование через URL prefix.", "/v1/..., /v2/... — самая дешёвая стратегия в эксплуатации (видно в " "логах, в Swagger UI, в curl без специальных заголовков). Header-based " "и content-negotiation отвергнуты — сложнее debug. Правило: /v1 живёт " "минимум 6 месяцев после релиза /v2. Breaking changes — только на major " "bump; backwards-compatible добавления — в той же версии. Депрекация — " "через HTTP-заголовки Deprecation и Sunset (RFC 8594).") para_lead(doc, "OpenAPI генерируется FastAPI автоматически,", "но обязательны на каждом эндпоинте: tags=, summary=, description=, " "responses={...: ...}. Export OpenAPI JSON в CI (артефакт), schema diff " "в PR — ловим случайные breaking changes до мерджа.") para_lead(doc, "ADR (Architecture Decision Records)", "в docs/adr/ADR-NNNN-*.md (формат Michael Nygard: Context / Decision / " "Consequences). Этот документ — ADR-0001. Все последующие архитектурные " "решения оформляются отдельными ADR со ссылкой на него.") # ───────────── 12. Backup ───────────── h1(doc, "12. Backup и disaster recovery") para_lead(doc, "Бэкапы:", "pg_basebackup + WAL-G (или pgBackRest) для PITR (point-in-time-recovery). " "Ежедневный full + непрерывный WAL-архив в S3-совместимое хранилище.") para_lead(doc, "Тестирование восстановления — ежемесячно на staging.", "Backup, который не тестировался, не существует. Скрипт восстановления " "и time-to-recover фиксируются в runbook.") para_lead(doc, "RPO = 1 час, RTO = 4 часа", "— зафиксировано. Детальный план DR — отдельный ADR-0005.") # ───────────── 13. Соглашения ───────────── h1(doc, "13. Соглашения по коду") table(doc, header=("Что", "Как"), rows=[ ("Имена в коде", "English по PEP8: snake_case для функций, PascalCase для классов"), ("Имена в базе", "snake_case"), ("Docstring и комментарии", "Только на русском"), ("Форматирование", "ruff format (не black — заменяет с 2024)"), ("Линтинг", "ruff check + mypy --strict (или pyright) в CI"), ("Запреты в async-коде", "time.sleep, requests, psycopg2, sync boto3 — блокируют event loop"), ("Datetime", "datetime.now(timezone.utc) — не utcnow() (deprecated в 3.12)"), ("Деньги", "BIGINT копейки, никогда float"), ("Type hints", "На все публичные функции и методы"), ("expire_on_commit=False", "На async_sessionmaker обязательно — иначе MissingGreenlet"), ("selectinload для коллекций", "joinedload только для скалярных связей"), ("Pydantic v2", "model_validate(), model_dump(), не v1-формы"), ], widths=[5.0, 11.5] ) # ───────────── ADR list ───────────── h1(doc, "Что отложено в отдельные ADR") bullet(doc, "ADR-0001:", "этот документ — Coding Principles.") bullet(doc, "ADR-0002:", "Multi-tenancy — schema-per-tenant с триггерами миграции на RLS.") bullet(doc, "ADR-0003:", "Authentication — JWT + refresh rotation + denylist " "(детальный runbook ротации ключей).") bullet(doc, "ADR-0004:", "GDPR / Right to erasure — процесс анонимизации PII.") bullet(doc, "ADR-0005:", "Backup/DR — RPO=1ч, RTO=4ч, ежемесячные тесты восстановления, runbook.") para(doc, "Эти темы получат отдельные документы по мере приближения к их актуальности.") # ───────────── Сохранение ───────────── saved = False for suffix in ["", ".v2", ".v3"]: target = OUT if suffix == "" else OUT.with_suffix(f"{suffix}.docx") try: doc.save(str(target)) print(f"[+] Saved: {target}") saved = True break except PermissionError: continue if not saved: print("[!] Файл занят. Закройте Word и запустите снова.") if __name__ == "__main__": import sys try: sys.stdout.reconfigure(encoding='utf-8') except Exception: pass build() print("[+] Done.")