"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`. `APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env, запечённые в образ через build-args в `backend/Dockerfile` (см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) — там же ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и `date -u +%Y-%m-%dT%H:%M:%SZ`. Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env — тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов, см. `_find_version_file`), sha фолбэчит на `"dev"`, дата — на момент импорта модуля. Ничего здесь не должно падать при отсутствии env (потребитель — и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`). Номер версии НЕ дублируется больше нигде в коде — читай `APP_VERSION` отсюда. Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION` в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и `/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля / `@/lib/buildInfo` соответственно; не заводи третий. """ from __future__ import annotations import datetime as dt import os from pathlib import Path _DEFAULT_VERSION = "0.0.0" # Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает # и локальный layout (backend/app/core/version.py → ../../../VERSION == # tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/ # version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION). _MAX_ANCESTORS = 6 def _find_version_file() -> Path | None: here = Path(__file__).resolve() for ancestor in list(here.parents)[:_MAX_ANCESTORS]: candidate = ancestor / "VERSION" if candidate.is_file(): return candidate return None def _read_version_file() -> str: path = _find_version_file() if path is None: return _DEFAULT_VERSION try: text = path.read_text(encoding="utf-8").strip() except OSError: return _DEFAULT_VERSION return text or _DEFAULT_VERSION def _default_build_date() -> str: return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") # Читаются один раз при импорте модуля (совпадает с паттерном `settings = # Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем, # перечитывать на каждый запрос незачем. APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file() BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev" BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date() def format_build_date_human(build_date: str = BUILD_DATE) -> str: """ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF колонтитул). Никогда не бросает исключение — при неразборчивой строке возвращает её как есть (это футер отчёта, не API-контракт).""" try: parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00")) except (ValueError, AttributeError): return build_date return parsed.strftime("%d.%m.%Y") def product_version_line(product_name: str = "Мера") -> str: """`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта 2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`.""" return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}"