From 53d8c6c3cd998640f4ac7b51283ac5922b174c7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Fri, 4 Sep 2026 07:20:42 +0300 Subject: [PATCH 1/4] feat: add modular multitenant foundation --- .github/workflows/p1-quality.yml | 63 + app/api/__init__.py | 0 app/api/core_router.py | 7 + app/api/direct_extensions_router.py | 7 + app/api/direct_router.py | 7 + app/api/legacy_handlers.py | 4364 ++++++++++++++++ app/api/legacy_router.py | 30 + app/api/metrika_router.py | 7 + app/api/route_registry.py | 115 + app/api/wordstat_router.py | 7 + app/bootstrap/application.py | 23 + app/bootstrap/dependencies.py | 74 + app/core/__init__.py | 0 app/core/errors.py | 69 + app/core/logging.py | 65 + app/core/request_context.py | 76 + app/main.py | 4413 +---------------- app/providers/__init__.py | 0 app/providers/protocols.py | 23 + app/providers/yandex.py | 23 + app/repositories/__init__.py | 0 app/repositories/context.py | 34 + app/repositories/mock_store.py | 41 + app/repositories/protocols.py | 27 + docs/dependency-rationale.md | 29 + pyproject.toml | 49 + tests/characterization/__init__.py | 1 + tests/characterization/conftest.py | 92 + .../characterization/test_auction_forecast.py | 233 + .../test_audit_idempotency.py | 77 + .../test_campaigns_reports_queries.py | 191 + .../test_preview_write_gate.py | 56 + .../characterization/test_route_inventory.py | 44 + tests/test_application_factory.py | 12 + tests/test_dependency_seams.py | 81 + tests/test_request_context.py | 61 + tests/test_respx_harness.py | 17 + tests/test_router_decomposition.py | 41 + uv.lock | 305 ++ 39 files changed, 6374 insertions(+), 4390 deletions(-) create mode 100644 .github/workflows/p1-quality.yml create mode 100644 app/api/__init__.py create mode 100644 app/api/core_router.py create mode 100644 app/api/direct_extensions_router.py create mode 100644 app/api/direct_router.py create mode 100644 app/api/legacy_handlers.py create mode 100644 app/api/legacy_router.py create mode 100644 app/api/metrika_router.py create mode 100644 app/api/route_registry.py create mode 100644 app/api/wordstat_router.py create mode 100644 app/bootstrap/application.py create mode 100644 app/bootstrap/dependencies.py create mode 100644 app/core/__init__.py create mode 100644 app/core/errors.py create mode 100644 app/core/logging.py create mode 100644 app/core/request_context.py create mode 100644 app/providers/__init__.py create mode 100644 app/providers/protocols.py create mode 100644 app/providers/yandex.py create mode 100644 app/repositories/__init__.py create mode 100644 app/repositories/context.py create mode 100644 app/repositories/mock_store.py create mode 100644 app/repositories/protocols.py create mode 100644 docs/dependency-rationale.md create mode 100644 tests/characterization/__init__.py create mode 100644 tests/characterization/conftest.py create mode 100644 tests/characterization/test_auction_forecast.py create mode 100644 tests/characterization/test_audit_idempotency.py create mode 100644 tests/characterization/test_campaigns_reports_queries.py create mode 100644 tests/characterization/test_preview_write_gate.py create mode 100644 tests/characterization/test_route_inventory.py create mode 100644 tests/test_application_factory.py create mode 100644 tests/test_dependency_seams.py create mode 100644 tests/test_request_context.py create mode 100644 tests/test_respx_harness.py create mode 100644 tests/test_router_decomposition.py diff --git a/.github/workflows/p1-quality.yml b/.github/workflows/p1-quality.yml new file mode 100644 index 0000000..fcf2e5f --- /dev/null +++ b/.github/workflows/p1-quality.yml @@ -0,0 +1,63 @@ +name: P1 quality + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + p1-quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: astral-sh/setup-uv@v5 + - name: Install locked dependencies + run: uv sync --frozen --python 3.11 + - name: Run P1 characterization and tracer tests + run: >- + uv run pytest -q + tests/characterization + tests/test_application_factory.py + tests/test_dependency_seams.py + tests/test_request_context.py + tests/test_respx_harness.py + tests/test_router_decomposition.py + - name: Check OpenAPI snapshot + run: uv run python scripts/check_openapi_sync.py + - name: Lint bounded P1 modules + run: >- + uv run ruff check + app/api/core_router.py + app/api/direct_extensions_router.py + app/api/direct_router.py + app/api/legacy_router.py + app/api/metrika_router.py + app/api/route_registry.py + app/api/wordstat_router.py + app/bootstrap/application.py + app/bootstrap/dependencies.py + app/main.py + app/core + app/providers + app/repositories + - name: Type-check bounded P1 modules + run: >- + uv run mypy + app/api/core_router.py + app/api/direct_extensions_router.py + app/api/direct_router.py + app/api/legacy_router.py + app/api/metrika_router.py + app/api/route_registry.py + app/api/wordstat_router.py + app/bootstrap/application.py + app/bootstrap/dependencies.py + app/main.py + app/core + app/providers + app/repositories diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/core_router.py b/app/api/core_router.py new file mode 100644 index 0000000..182972f --- /dev/null +++ b/app/api/core_router.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from app.api.legacy_handlers import route_declarations +from app.api.route_registry import register_domain_routes + +router = APIRouter() +register_domain_routes(router, route_declarations, "core", __name__) diff --git a/app/api/direct_extensions_router.py b/app/api/direct_extensions_router.py new file mode 100644 index 0000000..e60e7f4 --- /dev/null +++ b/app/api/direct_extensions_router.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from app.api.legacy_handlers import route_declarations +from app.api.route_registry import register_domain_routes + +router = APIRouter() +register_domain_routes(router, route_declarations, "direct_extensions", __name__) diff --git a/app/api/direct_router.py b/app/api/direct_router.py new file mode 100644 index 0000000..e0068fb --- /dev/null +++ b/app/api/direct_router.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from app.api.legacy_handlers import route_declarations +from app.api.route_registry import register_domain_routes + +router = APIRouter() +register_domain_routes(router, route_declarations, "direct", __name__) diff --git a/app/api/legacy_handlers.py b/app/api/legacy_handlers.py new file mode 100644 index 0000000..fdec346 --- /dev/null +++ b/app/api/legacy_handlers.py @@ -0,0 +1,4364 @@ +from __future__ import annotations + +from datetime import date, timedelta +import re +from typing import Any +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from fastapi import Depends, HTTPException, Query +from fastapi.responses import JSONResponse + +from app.api.route_registry import RouteDeclarationRegistry +from app.config import Settings, get_settings +from app.services import check_yandex_direct +from app.models import ( + WEEK_DAY_NAMES, + AdCreate, + AdGroupCreate, + AdGroupUpdate, + AdUpdate, + ApiErrorResponse, + ApplyActionRequest, + ApplyActionResult, + ApprovalResult, + AuditCheck, + AuditLog, + BidUpdate, + BudgetSimulationRequest, + BudgetSimulationResult, + BudgetUpdate, + CampaignAuditResult, + CampaignDraft, + CampaignDraftBaseUpdate, + CampaignDraftKeywordsAdd, + CampaignDraftKeywordsRemove, + CampaignDraftKeywordsUpdate, + CampaignDraftList, + CampaignDraftRequest, + CampaignList, + GenerateStructureRequest, + GenerateStructureResult, + LiveCreateCampaignRequest, + LiveCreateCampaignResult, + NegativeKeywordsReplace, + PreviewPayload, + RecommendationList, + ReportSummary, + SemanticChangeApplyRequest, + SemanticChangeApplyResult, + SemanticChangePackage, + SemanticChangeRequest, + UtmGenerateRequest, + UtmGenerateResult, + ValidationResult, + YandexAccountBalance, + YandexAccountBalanceResult, + YandexAdsBusinessAttachRequest, + YandexAdsBusinessAttachResult, + LandingUrlMigrationRequest, + LandingUrlMigrationsRequest, + SitelinkUrlMigrationRequest, + UrlMigrationResult, + YandexAd, + YandexAdGroup, + YandexAdGroupList, + YandexAdList, + YandexAdAssetItem, + YandexAdAssetsMissing, + YandexAdAssetsResult, + YandexBusinessAssetItem, + YandexCampaign, + YandexCampaignFinance, + YandexCampaignFinanceList, + YandexCampaignList, + YandexControlRequest, + YandexControlResult, + YandexKeyword, + YandexKeywordList, + YandexMetrikaResult, + YandexRawResult, + YandexSearchApiResult, + YandexSearchQueriesReport, + YandexSearchQuery, + YandexSitelinkItem, + YandexSitelinkSetItem, + YandexTimeTargetingRequest, + YandexTimeTargetingHourly, + YandexTimeTargetingReadResult, + YandexTimeTargetingResult, + YandexTimeTargetingSchedule, + YandexVCardRequest, + YandexVCardResult, + YandexVCardAssetItem, + YandexStrategyReadResult, + YandexStrategyRequest, + YandexStrategyResult, + LiveAdCreateRequest, + LiveAdCreateResult, + YandexAdGroupNegativeKeywords, + YandexAdGroupNegativeKeywordsList, + YandexAdGroupNegativeKeywordsRequest, + YandexAdGroupNegativeKeywordsResult, + LiveAdGroupCreateRequest, + LiveAdGroupCreateResult, + AdsModerateRequest, + AdsModerateResult, + ProviderWarning, + # Autotargeting + YandexAutotargetingReadResult, + YandexAutotargetingRequest, + YandexAutotargetingResult, + # UTM + UtmAuditResult, + UtmApplyRequest, + UtmApplyResult, + UtmConfig, + UtmPlanRequest, + UtmPlanResult, + # Keyword bids + KeywordBidItem, + KeywordBidUpdateRequest, + KeywordBidUpdateResult, + KeywordBidsGetResult, + KeywordBidsSetAutoRequest, + KeywordBidsSetAutoResult, + # Bid modifiers + YandexBidModifierItem, + YandexBidModifiersReadResult, + BidModifiersCreateRequest, + BidModifiersCreateResult, + BidModifiersUpdateRequest, + BidModifiersUpdateResult, +) +from app.bootstrap.dependencies import ( + get_yandex_client, + get_yandex_metrika_client, + get_yandex_search_wordstat_client, + legacy_store as store, +) +from app.yandex_direct import YandexDirectClient, YandexDirectError +from app.yandex_facade import mock_yandex +from app.yandex_metrika import ( + YandexMetrikaClient, + YandexMetrikaError, + YandexMetrikaMissingTokenError, +) +from app.yandex_search_wordstat import ( + YandexSearchWordstatClient, + YandexSearchWordstatError, + YandexSearchWordstatMissingKeyError, +) + + +route_declarations = RouteDeclarationRegistry() +router = route_declarations.for_domain("core") + + +YANDEX_DIRECT_ERROR_RESPONSES = { + 502: {"model": ApiErrorResponse, "description": "Yandex Direct upstream error"}, + 503: {"model": ApiErrorResponse, "description": "YANDEX_OAUTH_TOKEN is not configured"}, +} + + +WORDSTAT_ERROR_RESPONSES = { + 502: {"model": ApiErrorResponse, "description": "Yandex Search API upstream error"}, + 503: {"model": ApiErrorResponse, "description": "YANDEX_SEARCH_API_KEY is not configured"}, +} + + +METRIKA_ERROR_RESPONSES = { + 502: {"model": ApiErrorResponse, "description": "Yandex Metrika upstream error"}, + 503: {"model": ApiErrorResponse, "description": "YANDEX_METRIKA_OAUTH_TOKEN is not configured"}, +} + + +# --------------------------------------------------------------------------- +# Non-product guard +# --------------------------------------------------------------------------- +# Demo/UI routes (HTML home + 6 /demo/* pages) are not part of the DirectPilot +# product surface: they were built for early stakeholder reviews and are no +# longer shipped. They are registered only as explicit non-product guards so +# old links/bookmarks get a 404 instead of silently routing elsewhere. These +# guard handlers are NOT included in the OpenAPI schema and must not return +# product/demo data. + +_NON_PRODUCT_PATHS = { + "/", + "/demo/yandex-status", + "/demo/campaigns", + "/demo/report", + "/demo/recommendations", + "/demo/tools", + "/demo/security-approval", +} + + +def _non_product_guard(path: str): + """Return a 404 JSONResponse for retired demo/UI paths, or None. + + Keeping this as a small explicit allow-list (rather than re-registering + the original HTML routes) ensures the demo surface cannot accidentally + come back online and cannot leak into OpenAPI. + """ + if path in _NON_PRODUCT_PATHS: + return JSONResponse( + status_code=404, + content={ + "detail": "Not part of DirectPilot product surface", + "path": path, + }, + ) + return None + + +@router.get("/", include_in_schema=False) +def _non_product_root(): + return _non_product_guard("/") + + +@router.get("/demo/yandex-status", include_in_schema=False) +def _non_product_yandex_status(): + return _non_product_guard("/demo/yandex-status") + + +@router.get("/demo/campaigns", include_in_schema=False) +def _non_product_demo_campaigns(): + return _non_product_guard("/demo/campaigns") + + +@router.get("/demo/report", include_in_schema=False) +def _non_product_demo_report(): + return _non_product_guard("/demo/report") + + +@router.get("/demo/recommendations", include_in_schema=False) +def _non_product_demo_recommendations(): + return _non_product_guard("/demo/recommendations") + + +@router.get("/demo/tools", include_in_schema=False) +def _non_product_demo_tools(): + return _non_product_guard("/demo/tools") + + +@router.get("/demo/security-approval", include_in_schema=False) +def _non_product_demo_security_approval(): + return _non_product_guard("/demo/security-approval") + + +@router.get("/health") +def health() -> dict: + settings = get_settings() + return { + "service": "directpilot-beta", + "mode": settings.directpilot_mode, + "yandex": settings.safe_status(), + } + + +@router.get("/integrations/yandex/direct/status") +def yandex_direct_status() -> dict: + settings = get_settings() + return check_yandex_direct(settings) + + +@router.post("/utm/generate", response_model=UtmGenerateResult) +def generate_utm(payload: UtmGenerateRequest) -> UtmGenerateResult: + parts = urlsplit(str(payload.landing_url)) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query.update( + { + "utm_source": "yandex", + "utm_medium": "cpc", + "utm_campaign": payload.campaign, + "utm_content": payload.content, + "utm_term": payload.term, + } + ) + url = urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) + return UtmGenerateResult(url=url) + + +@router.post("/simulations/budget", response_model=BudgetSimulationResult) +def simulate_budget(payload: BudgetSimulationRequest) -> BudgetSimulationResult: + estimated_clicks = int(payload.daily_budget // payload.avg_cpc) + estimated_conversions = round(estimated_clicks * payload.conversion_rate / 100, 2) + return BudgetSimulationResult( + estimated_clicks=estimated_clicks, + estimated_conversions=estimated_conversions, + estimated_spend=round(estimated_clicks * payload.avg_cpc, 2), + ) + + +@router.get("/audit/campaigns", response_model=CampaignAuditResult) +def audit_campaigns() -> CampaignAuditResult: + return CampaignAuditResult( + items=[ + AuditCheck( + code="missing_utm", + severity="medium", + title="Нет UTM-разметки", + recommendation="Добавить UTM, чтобы связать клики с отчётами и заявками.", + ), + AuditCheck( + code="no_metrica_goal", + severity="high", + title="Не выбрана цель Метрики", + recommendation="Связать основную цель Метрики с кампанией до включения auto-apply.", + ), + AuditCheck( + code="high_cpc", + severity="medium", + title="Высокая цена клика", + recommendation="Запустить dry-run симуляцию бюджета и проверить ставки по ключам.", + ), + ] + ) + + +@router.get("/campaigns", response_model=CampaignList) +def list_campaigns() -> CampaignList: + return CampaignList(items=list(store.campaigns.values())) + + +@router.get("/reports/summary", response_model=ReportSummary) +def report_summary() -> ReportSummary: + return ReportSummary( + spend=1250.0, + clicks=42, + impressions=2100, + ctr=2.0, + cpc=29.76, + conversions=None, + cpa=None, + ) + + +# --------------------------------------------------------------------------- +# Campaign draft constructor +# --------------------------------------------------------------------------- + + +@router.post("/campaign-drafts", response_model=CampaignDraft) +def create_campaign_draft(payload: CampaignDraftRequest) -> CampaignDraft: + return store.create_draft(payload) + + +@router.get("/campaign-drafts", response_model=CampaignDraftList) +def list_campaign_drafts() -> CampaignDraftList: + return CampaignDraftList(items=list(store.drafts.values())) + + +@router.get("/campaign-drafts/{draft_id}", response_model=CampaignDraft) +def get_campaign_draft(draft_id: str) -> CampaignDraft: + try: + return store.drafts[draft_id] + except KeyError as exc: + raise HTTPException(status_code=404, detail="Campaign draft not found") from exc + + +@router.patch("/campaign-drafts/{draft_id}", response_model=CampaignDraft) +def patch_campaign_draft(draft_id: str, payload: CampaignDraftBaseUpdate) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + return store.update_draft_base(draft_id, payload.model_dump(exclude_unset=True)) + + +@router.patch("/campaign-drafts/{draft_id}/keywords", response_model=CampaignDraft) +def update_campaign_draft_keywords( + draft_id: str, payload: CampaignDraftKeywordsUpdate +) -> CampaignDraft: + try: + return store.replace_keywords(draft_id, payload.keywords) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Campaign draft not found") from exc + + +@router.post("/campaign-drafts/{draft_id}/keywords", response_model=CampaignDraft) +def add_campaign_draft_keywords( + draft_id: str, payload: CampaignDraftKeywordsAdd +) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + return store.append_keywords(draft_id, payload.keywords) + + +@router.delete("/campaign-drafts/{draft_id}/keywords", response_model=CampaignDraft) +def delete_campaign_draft_keywords( + draft_id: str, payload: CampaignDraftKeywordsRemove +) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + return store.remove_keywords(draft_id, payload.keywords) + + +@router.patch( + "/campaign-drafts/{draft_id}/negative-keywords", response_model=CampaignDraft +) +def patch_negative_keywords( + draft_id: str, payload: NegativeKeywordsReplace +) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + return store.replace_negative_keywords(draft_id, payload) + + +@router.post("/campaign-drafts/{draft_id}/ad-groups", response_model=CampaignDraft) +def create_ad_group(draft_id: str, payload: AdGroupCreate) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + return store.create_ad_group(draft_id, payload) + + +@router.patch( + "/campaign-drafts/{draft_id}/ad-groups/{group_id}", response_model=CampaignDraft +) +def update_ad_group( + draft_id: str, group_id: str, payload: AdGroupUpdate +) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + try: + return store.update_ad_group(draft_id, group_id, payload) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Ad group not found") from exc + + +@router.delete( + "/campaign-drafts/{draft_id}/ad-groups/{group_id}", response_model=CampaignDraft +) +def delete_ad_group(draft_id: str, group_id: str) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + try: + return store.delete_ad_group(draft_id, group_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Ad group not found") from exc + + +@router.post("/campaign-drafts/{draft_id}/ads", response_model=CampaignDraft) +def create_ad(draft_id: str, payload: AdCreate) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + try: + return store.create_ad(draft_id, payload) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Ad group not found") from exc + + +@router.patch("/campaign-drafts/{draft_id}/ads/{ad_id}", response_model=CampaignDraft) +def update_ad(draft_id: str, ad_id: str, payload: AdUpdate) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + try: + return store.update_ad(draft_id, ad_id, payload) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Ad not found") from exc + + +@router.delete("/campaign-drafts/{draft_id}/ads/{ad_id}", response_model=CampaignDraft) +def delete_ad(draft_id: str, ad_id: str) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + try: + return store.delete_ad(draft_id, ad_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Ad not found") from exc + + +@router.post( + "/campaign-drafts/{draft_id}/generate-structure", + response_model=GenerateStructureResult, +) +def generate_structure( + draft_id: str, payload: GenerateStructureRequest +) -> GenerateStructureResult: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + draft = store.generate_structure(draft_id, payload) + return GenerateStructureResult( + ad_groups=draft.ad_groups, + keywords=draft.keywords, + negative_keywords=draft.negative_keywords, + ads=draft.ads, + ) + + +@router.post("/campaign-drafts/{draft_id}/validate", response_model=ValidationResult) +def validate_draft(draft_id: str) -> ValidationResult: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + return store.validate_draft(draft_id) + + +@router.get("/campaign-drafts/{draft_id}/preview", response_model=PreviewPayload) +def preview_draft(draft_id: str) -> PreviewPayload: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + return store.preview_draft(draft_id) + + +@router.patch("/campaign-drafts/{draft_id}/budget", response_model=CampaignDraft) +def patch_draft_budget(draft_id: str, payload: BudgetUpdate) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + return store.update_budget(draft_id, payload) + + +@router.patch("/campaign-drafts/{draft_id}/bids", response_model=CampaignDraft) +def patch_draft_bids(draft_id: str, payload: BidUpdate) -> CampaignDraft: + if draft_id not in store.drafts: + raise HTTPException(status_code=404, detail="Campaign draft not found") + return store.update_bids(draft_id, payload) + + +# --------------------------------------------------------------------------- +# Recommendations / approval / apply +# --------------------------------------------------------------------------- + + +@router.get("/recommendations", response_model=RecommendationList) +def list_recommendations() -> RecommendationList: + return RecommendationList(items=list(store.recommendations.values())) + + +@router.post("/recommendations/{recommendation_id}/approve", response_model=ApprovalResult) +def approve_recommendation(recommendation_id: str) -> ApprovalResult: + if recommendation_id not in store.recommendations: + raise HTTPException(status_code=404, detail="Recommendation not found") + store.recommendations[recommendation_id].status = "approved" + store.append_audit("recommendation_approved", recommendation_id) + return ApprovalResult(recommendation_id=recommendation_id, status="approved") + + +@router.post("/recommendations/{recommendation_id}/reject", response_model=ApprovalResult) +def reject_recommendation(recommendation_id: str) -> ApprovalResult: + if recommendation_id not in store.recommendations: + raise HTTPException(status_code=404, detail="Recommendation not found") + store.recommendations[recommendation_id].status = "rejected" + store.append_audit("recommendation_rejected", recommendation_id) + return ApprovalResult(recommendation_id=recommendation_id, status="rejected") + + +@router.post("/actions/{action_id}/apply", response_model=ApplyActionResult) +def apply_action(action_id: str, payload: ApplyActionRequest) -> ApplyActionResult: + if not payload.approved: + raise HTTPException(status_code=409, detail="Action requires explicit approval before apply") + if payload.idempotency_key in store.apply_results_by_key: + return store.apply_results_by_key[payload.idempotency_key] + recommendation = next((r for r in store.recommendations.values() if r.action_id == action_id), None) + if recommendation is None: + raise HTTPException(status_code=404, detail="Action not found") + if recommendation.status != "approved": + raise HTTPException(status_code=409, detail="Recommendation must be approved before apply") + event = store.append_audit("action_applied", action_id, dry_run=payload.dry_run) + recommendation.status = "applied" + result = ApplyActionResult( + action_id=action_id, + dry_run=payload.dry_run, + applied=not payload.dry_run, + risk_level=recommendation.risk_level, + audit_id=event.id, + ) + store.apply_results_by_key[payload.idempotency_key] = result + return result + + +@router.get("/audit-log", response_model=AuditLog) +def audit_log() -> AuditLog: + return AuditLog(items=store.audit_events) + + +router = route_declarations.for_domain("direct") + + +# --------------------------------------------------------------------------- +# Yandex Direct read-only facade +# +# Mock mode returns deterministic in-memory data (no network). +# Sandbox / live_readonly / live_write hit the real Direct API v5 +# (campaigns.get / adgroups.get / ads.get / keywords.get). These are all +# read-only — the live modes never trigger a write call from this facade. +# --------------------------------------------------------------------------- + + +def _yandex_error_to_502(exc: YandexDirectError) -> HTTPException: + """Translate a YandexDirectError into an HTTP 502 with no token in detail.""" + detail: dict[str, Any] = { + "error_type": "YandexDirectError", + "message": str(exc), + } + for key in ( + "provider", + "service", + "method", + "operation", + "http_status", + "error_code", + "error_string", + "error_detail", + ): + value = exc.diagnostics.get(key) + if value is not None: + detail[key] = value + return HTTPException( + status_code=502, + detail=detail, + ) + + +def _yandex_business_error_to_502(response: dict[str, Any], action: str) -> HTTPException: + """Translate a Direct API ok-false envelope into HTTP 502. + + The helper keeps upstream machine-readable keys and avoids leaking + provider payload. Missing fields are passed as None rather than + interpolated into a potentially sensitive message. + """ + error = response.get("error") if isinstance(response, dict) else None + if not isinstance(error, dict): + error = {} + return HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "error_code": error.get("error_code"), + "error_detail": error.get("error_detail") or error.get("error_string"), + "message": f"Yandex Direct rejected {action}", + }, + ) + + +# --- mapping helpers -------------------------------------------------------- +# +# These helpers are intentionally permissive: Direct API v5 may omit +# fields (status on a fresh ad group, daily_budget on a campaign created +# without a budget cap, etc.). We never let a missing field crash the +# endpoint — we fall back to safe defaults. +# --------------------------------------------------------------------------- + + +def _extract_campaigns(result: dict[str, Any] | None) -> list[dict[str, Any]]: + """Map Direct API v5 campaigns.get result → list of YandexCampaign dicts.""" + items: list[dict[str, Any]] = [] + if not isinstance(result, dict): + return items + raw = result.get("Campaigns") or result.get("campaigns") or [] + for c in raw: + if not isinstance(c, dict): + continue + daily_budget = c.get("DailyBudget") + if isinstance(daily_budget, dict): + # Yandex returns amount in micro-units (1/1_000_000 of currency). + amount = daily_budget.get("Amount", 0) or 0 + try: + budget_value = float(amount) / 1_000_000 + except (TypeError, ValueError): + budget_value = 0.0 + else: + budget_value = 0.0 + items.append( + { + "id": str(c.get("Id") or c.get("id") or ""), + "name": str(c.get("Name") or c.get("name") or ""), + "status": str(c.get("Status") or c.get("status") or "UNKNOWN"), + "type": str(c.get("Type") or c.get("type") or "UNKNOWN"), + "daily_budget": budget_value, + } + ) + return items + + +def _extract_ad_groups(result: dict[str, Any] | None) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + if not isinstance(result, dict): + return items + raw = result.get("AdGroups") or result.get("adgroups") or [] + for g in raw: + if not isinstance(g, dict): + continue + items.append( + { + "id": str(g.get("Id") or g.get("id") or ""), + "campaign_id": str(g.get("CampaignId") or g.get("campaignId") or ""), + "name": str(g.get("Name") or g.get("name") or ""), + "status": str(g.get("Status") or g.get("status") or "UNKNOWN"), + } + ) + return items + + +def _normalize_negative_keyword(value: str) -> str: + return value.strip().lstrip("-").strip() + + +def _normalize_negative_keywords(values: list[str]) -> list[str]: + seen: set[str] = set() + normalized: list[str] = [] + for raw in values: + item = _normalize_negative_keyword(str(raw)) + if not item or item in seen: + continue + seen.add(item) + normalized.append(item) + return normalized + + +def _negative_keywords_from_adgroup(group: dict[str, Any]) -> list[str]: + raw = group.get("NegativeKeywords") or group.get("negativeKeywords") or {} + if isinstance(raw, dict): + values = raw.get("Items") or raw.get("items") or [] + elif isinstance(raw, list): + values = raw + else: + values = [] + return _normalize_negative_keywords([str(v) for v in values]) + + +def _extract_ad_group_negative_keywords( + result: dict[str, Any] | None, *, source: str, read_only: bool +) -> list[YandexAdGroupNegativeKeywords]: + items: list[YandexAdGroupNegativeKeywords] = [] + if not isinstance(result, dict): + return items + raw = result.get("AdGroups") or result.get("adgroups") or [] + for g in raw: + if not isinstance(g, dict): + continue + negatives = _negative_keywords_from_adgroup(g) + items.append( + YandexAdGroupNegativeKeywords( + ad_group_id=str(g.get("Id") or g.get("id") or ""), + campaign_id=str(g.get("CampaignId") or g.get("campaignId") or ""), + name=str(g.get("Name") or g.get("name") or ""), + status=str(g.get("Status") or g.get("status") or "UNKNOWN"), + negative_keywords=negatives, + has_negative_keywords=bool(negatives), + source=source, + read_only=read_only, + ) + ) + return items + + +def _require_live_write_for_apply(settings: Settings, *, dry_run: bool, approved: bool) -> None: + if not approved: + raise HTTPException(status_code=409, detail="Action requires explicit approval before apply") + if dry_run: + return + if settings.directpilot_mode != "live_write": + raise HTTPException( + status_code=409, + detail="Live writes require DIRECTPILOT_MODE=live_write; current mode blocks mutation", + ) + + +def _extract_ads(result: dict[str, Any] | None) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + if not isinstance(result, dict): + return items + raw = result.get("Ads") or result.get("ads") or [] + for a in raw: + if not isinstance(a, dict): + continue + text_ad = a.get("TextAd") or a.get("textAd") + title = "" + if isinstance(text_ad, dict): + raw_title = text_ad.get("Title") or text_ad.get("title") + if isinstance(raw_title, str): + title = raw_title + items.append( + { + "id": str(a.get("Id") or a.get("id") or ""), + "ad_group_id": str(a.get("AdGroupId") or a.get("adGroupId") or ""), + "campaign_id": str(a.get("CampaignId") or a.get("campaignId") or ""), + "title": title, + "status": str(a.get("Status") or a.get("status") or "UNKNOWN"), + } + ) + return items + + +def _extract_keywords(result: dict[str, Any] | None) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + if not isinstance(result, dict): + return items + raw = result.get("Keywords") or result.get("keywords") or [] + for k in raw: + if not isinstance(k, dict): + continue + items.append( + { + "id": str(k.get("Id") or k.get("id") or ""), + "ad_group_id": str(k.get("AdGroupId") or k.get("adGroupId") or ""), + "phrase": str(k.get("Keyword") or k.get("keyword") or ""), + "status": str(k.get("Status") or k.get("status") or "UNKNOWN"), + } + ) + return items + + +def _is_live_read_mode(settings: Settings) -> bool: + """True for any non-mock mode that should use the real get endpoints.""" + return settings.directpilot_mode in ("sandbox", "live_readonly", "live_write") + + +# --- endpoint handlers ------------------------------------------------------ + + +@router.get("/yandex/campaigns", response_model=YandexCampaignList) +def yandex_campaigns( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexCampaignList: + if _is_live_read_mode(settings) and client is not None: + try: + response = client.campaigns_get() + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"Yandex Direct rejected campaigns.get: " + f"error_code={err.get('error_code')!r}" + ), + }, + ) + items = _extract_campaigns(response.get("result")) + return YandexCampaignList( + items=[YandexCampaign(**c) for c in items], + source="yandex", + read_only=True, + ) + return YandexCampaignList( + items=[YandexCampaign(**campaign) for campaign in mock_yandex.list_campaigns()], + source="mock", + read_only=True, + ) + + +@router.get( + "/yandex/campaigns/{campaign_id}/ad-groups", + response_model=YandexAdGroupList, +) +def yandex_ad_groups( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexAdGroupList: + if _is_live_read_mode(settings) and client is not None: + try: + response = client.adgroups_get(campaign_id) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"Yandex Direct rejected adgroups.get: " + f"error_code={err.get('error_code')!r}" + ), + }, + ) + items = _extract_ad_groups(response.get("result")) + return YandexAdGroupList( + items=[YandexAdGroup(**g) for g in items], + source="yandex", + read_only=True, + ) + items = [YandexAdGroup(**g) for g in mock_yandex.list_ad_groups(campaign_id)] + return YandexAdGroupList(items=items, source="mock", read_only=True) + + +@router.get( + "/yandex/campaigns/{campaign_id}/ad-groups/negative-keywords", + response_model=YandexAdGroupNegativeKeywordsList, +) +def yandex_ad_group_negative_keywords( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexAdGroupNegativeKeywordsList: + if _is_live_read_mode(settings) and client is not None: + try: + response = client.adgroups_get(campaign_id) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"Yandex Direct rejected adgroups.get: " + f"error_code={err.get('error_code')!r}" + ), + }, + ) + return YandexAdGroupNegativeKeywordsList( + items=_extract_ad_group_negative_keywords( + response.get("result"), source="yandex", read_only=True + ), + source="yandex", + read_only=True, + ) + mock_groups = {"AdGroups": mock_yandex.list_ad_groups(campaign_id)} + return YandexAdGroupNegativeKeywordsList( + items=_extract_ad_group_negative_keywords(mock_groups, source="mock", read_only=True), + source="mock", + read_only=True, + ) + + +@router.get("/yandex/campaigns/{campaign_id}/ads", response_model=YandexAdList) +def yandex_ads( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexAdList: + if _is_live_read_mode(settings) and client is not None: + try: + response = client.ads_get(campaign_id) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"Yandex Direct rejected ads.get: " + f"error_code={err.get('error_code')!r}" + ), + }, + ) + items = _extract_ads(response.get("result")) + return YandexAdList( + items=[YandexAd(**a) for a in items], + source="yandex", + read_only=True, + ) + items = [YandexAd(**a) for a in mock_yandex.list_ads(campaign_id)] + return YandexAdList(items=items, source="mock", read_only=True) + + +@router.get( + "/yandex/campaigns/{campaign_id}/keywords", + response_model=YandexKeywordList, +) +def yandex_keywords( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexKeywordList: + if _is_live_read_mode(settings) and client is not None: + try: + response = client.keywords_get(campaign_id) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"Yandex Direct rejected keywords.get: " + f"error_code={err.get('error_code')!r}" + ), + }, + ) + items = _extract_keywords(response.get("result")) + return YandexKeywordList( + items=[YandexKeyword(**kw) for kw in items], + source="yandex", + read_only=True, + ) + items = mock_yandex.list_keywords(campaign_id) + return YandexKeywordList( + items=[YandexKeyword(**kw) for kw in items], + source="mock", + read_only=True, + ) + + +def _raw_yandex_result(service: str, method: str, response: dict[str, Any]) -> YandexRawResult: + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": f"Yandex Direct rejected {service}.{method}: error_code={err.get('error_code')!r}", + }, + ) + return YandexRawResult( + service=service, + method=method, + data=response.get("result"), + source="yandex", + read_only=True, + ) + + +def _require_yandex_read_client( + settings: Settings, + client: YandexDirectClient | None, +) -> YandexDirectClient: + if not _is_live_read_mode(settings) or client is None: + raise HTTPException( + status_code=409, + detail="This endpoint requires sandbox, live_readonly, or live_write mode with Yandex credentials", + ) + return client + + +def _call_raw_read( + settings: Settings, + client: YandexDirectClient | None, + service: str, + method: str, + call, +) -> YandexRawResult: + direct = _require_yandex_read_client(settings, client) + try: + response = call(direct) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + return _raw_yandex_result(service, method, response) + + +@router.get( + "/yandex/campaigns/{campaign_id}/bids", + response_model=YandexRawResult, + deprecated=True, +) +def yandex_bids( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "bids", "get", lambda c: c.bids_get(campaign_id)) + + +@router.get( + "/yandex/campaigns/{campaign_id}/keyword-bids", + response_model=KeywordBidsGetResult, + responses=YANDEX_DIRECT_ERROR_RESPONSES, +) +def yandex_keyword_bids_get( + campaign_id: str, + ad_group_ids: list[int] | None = Query(default=None), + keyword_ids: list[int] | None = Query(default=None), + serving_statuses: list[str] | None = Query(default=None), + limit: int = 1000, + offset: int = 0, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> KeywordBidsGetResult: + """Read typed current bids through v5 ``keywordbids.get``. + + The legacy ``/bids`` route remains available for its raw ``bids.get`` + compatibility envelope. This route accepts only controlled selectors and + maps keyword/autotargeting identity via the existing ``keywords.get`` read. + """ + direct = _require_yandex_read_client(settings, client) + try: + return store.yandex_keyword_bids_get( + campaign_id, + client=direct, + ad_group_ids=ad_group_ids, + keyword_ids=keyword_ids, + serving_statuses=serving_statuses, + limit=limit, + offset=offset, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + + +@router.get("/yandex/changes/check", response_model=YandexRawResult) +def yandex_changes_check( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "changes", "check", lambda c: c.changes_check()) + + +@router.get("/yandex/changes", response_model=YandexRawResult) +def yandex_changes_get( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "changes", "get", lambda c: c.changes_get()) + + +@router.get("/yandex/dictionaries", response_model=YandexRawResult) +def yandex_dictionaries( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "dictionaries", "get", lambda c: c.dictionaries_get()) + + +def _safe_int(value: Any) -> int | None: + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + return None + + +def _extract_bid_modifier_items(result_payload: Any) -> list[dict[str, Any]]: + if not isinstance(result_payload, dict): + return [] + raw_items = result_payload.get("BidModifiers") or result_payload.get("Items") or [] + return [item for item in raw_items if isinstance(item, dict)] + + +def _infer_bid_modifier_type(item: dict[str, Any]) -> str: + for key in ( + "Type", + "BidModifierType", + "Level", + "Demographics", + "MobileAdjustment", + "DesktopAdjustment", + "RetargetingAdjustment", + "RegionalAdjustment", + "VideoAdjustment", + "SmartAdAdjustment", + "SerpLayoutAdjustment", + "WeatherAdjustment", + "Weather", + ): + value = item.get(key) + if key in item and isinstance(value, str) and value: + return value + if key in item and isinstance(value, dict): + return key.replace("Adjustment", "").upper() + return "UNKNOWN" + + +def _bid_modifier_conditions(item: dict[str, Any]) -> dict[str, Any]: + common = {"Id", "CampaignId", "AdGroupId", "BidModifier", "Type", "BidModifierType", "Level"} + return {key: value for key, value in item.items() if key not in common} + + +def _normalize_bid_modifier_item(item: dict[str, Any]) -> YandexBidModifierItem: + bid_modifier = item.get("BidModifier") + bid_modifier_int = _safe_int(bid_modifier) if bid_modifier is not None else None + return YandexBidModifierItem( + id=_safe_int(item.get("Id")) if item.get("Id") is not None else None, + campaign_id=_safe_int(item.get("CampaignId")) if item.get("CampaignId") is not None else None, + type=_infer_bid_modifier_type(item), + bid_modifier=bid_modifier_int, + adjustment_percent=bid_modifier_int - 100 if bid_modifier_int is not None else None, + conditions=_bid_modifier_conditions(item), + raw=item, + ) + + +@router.get("/yandex/campaigns/{campaign_id}/bid-modifiers", response_model=YandexBidModifiersReadResult) +def yandex_bid_modifiers( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexBidModifiersReadResult: + if not _is_live_read_mode(settings): + return YandexBidModifiersReadResult(campaign_id=campaign_id, source="mock", items=[]) + direct = _require_yandex_read_client(settings, client) + try: + response = direct.bidmodifiers_get(campaign_id) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": f"Yandex Direct rejected bidmodifiers.get: error_code={err.get('error_code')!r}", + }, + ) + result = response.get("result") or {} + items = [_normalize_bid_modifier_item(item) for item in _extract_bid_modifier_items(result)] + return YandexBidModifiersReadResult( + campaign_id=campaign_id, + source="yandex", + read_only=True, + items=items, + raw=result if isinstance(result, dict) else None, + ) + + +@router.post( + "/yandex/campaigns/{campaign_id}/bid-modifiers/create", + response_model=BidModifiersCreateResult, + responses={ + 409: { + "description": "Safety gate or idempotency conflict for bid modifier create.", + }, + 502: { + "description": "Upstream Yandex Direct bidmodifiers.add / readback failure, with redacted diagnostics only.", + }, + }, +) +def yandex_bid_modifiers_create( + campaign_id: str, + payload: BidModifiersCreateRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> BidModifiersCreateResult: + for item in payload.items: + if item.campaign_id is not None and str(item.campaign_id) != str(campaign_id): + raise HTTPException( + status_code=409, + detail="CampaignId in bid modifier payload must match path campaign_id", + ) + if not payload.approved: + raise HTTPException( + status_code=409, + detail="Action requires explicit approval before bid modifiers create", + ) + if not payload.idempotency_key: + raise HTTPException( + status_code=409, + detail="idempotency_key is required before bid modifiers create", + ) + if not payload.dry_run and settings.directpilot_mode != "live_write": + raise HTTPException( + status_code=409, + detail=( + f"Live writes require DIRECTPILOT_MODE=live_write; " + f"current mode is {settings.directpilot_mode!r}; " + f"bid modifiers create is not allowed in this mode " + f"(dry_run=True is the only allowed path)" + ), + ) + try: + return store.yandex_bid_modifiers_create( + campaign_id, payload, settings=settings, client=client + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + diagnostics = exc.diagnostics or {} + detail: dict[str, Any] = { + "error_type": "YandexDirectError", + "message": str(exc), + } + if "error_code" in diagnostics: + detail["error_code"] = diagnostics["error_code"] + if "error_detail" in diagnostics: + detail["error_detail"] = diagnostics["error_detail"] + if "payload_preview" in diagnostics: + detail["payload_preview"] = diagnostics["payload_preview"] + raise HTTPException(status_code=502, detail=detail) from exc + except Exception as exc: + try: + store.append_audit( + "yandex_bid_modifiers_create_failed", + campaign_id, + dry_run=payload.dry_run, + details={ + "campaign_id": campaign_id, + "approved": payload.approved, + "idempotency_key": payload.idempotency_key, + "endpoint_safety_net": True, + "yandex_error": ( + f"unexpected error in bid modifiers create endpoint: " + f"{type(exc).__name__}: {exc}" + ), + "exception_type": type(exc).__name__, + }, + ) + except Exception: + pass + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"unexpected error during bid modifiers create: " + f"{type(exc).__name__}" + ), + }, + ) from exc + + +@router.get("/yandex/campaigns/{campaign_id}/negative-keywords", response_model=YandexRawResult) +def yandex_negative_keywords( + campaign_id: str, + ids: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read( + settings, + client, + "negativekeywordsharedsets", + "get", + lambda c: c.negativekeywords_get(campaign_id, _csv_ints(ids)), + ) + + +@router.get("/yandex/retargeting-lists", response_model=YandexRawResult) +def yandex_retargeting_lists( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "retargetinglists", "get", lambda c: c.retargetinglists_get()) + + +@router.get("/yandex/campaigns/{campaign_id}/audience-targets", response_model=YandexRawResult) +def yandex_audience_targets( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "audiencetargets", "get", lambda c: c.audiencetargets_get(campaign_id)) + + +@router.get("/yandex/sitelinks", response_model=YandexRawResult) +def yandex_sitelinks( + ids: list[int] | None = Query(default=None), + limit: int | None = Query(default=None), + offset: int | None = Query(default=None), + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "sitelinks", "get", lambda c: c.sitelinks_get(ids=ids, limit=limit, offset=offset)) + + +@router.get( + "/yandex/campaigns/{campaign_id}/ad-assets", + response_model=YandexAdAssetsResult, +) +def yandex_campaign_ad_assets( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexAdAssetsResult: + """Read-only campaign ad-assets aggregator for marketing/audit. + + Returns ads with extended TextAd fields, resolved sitelink sets, + businesses, vcards, and callouts (not yet implemented). + No writes — only get/read methods. + """ + if _is_live_read_mode(settings): + if client is None: + raise HTTPException( + status_code=409, + detail="This endpoint requires sandbox, live_readonly, or live_write mode with Yandex credentials", + ) + direct = client + try: + ads_response = direct.ads_get_detailed(campaign_id) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + + if not ads_response.get("ok"): + err = ads_response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"Yandex Direct rejected ads.get: " + f"error_code={err.get('error_code')!r}" + ), + }, + ) + + raw_ads = (ads_response.get("result") or {}).get("Ads") or [] + + # Extract ads with extended fields + ads: list[YandexAdAssetItem] = [] + sitelink_set_ids: set[int] = set() + business_ids: set[int] = set() + vcard_ids: set[int] = set() + + for a in raw_ads: + if not isinstance(a, dict): + continue + text_ad = a.get("TextAd") or {} + sl_set_id = text_ad.get("SitelinkSetId") + biz_id = text_ad.get("BusinessId") + vc_id = text_ad.get("VCardId") + + ad_item = YandexAdAssetItem( + id=str(a.get("Id") or ""), + ad_group_id=str(a.get("AdGroupId") or ""), + campaign_id=str(a.get("CampaignId") or campaign_id), + status=str(a.get("Status") or "UNKNOWN"), + state=str(a.get("State") or "UNKNOWN"), + type=str(a.get("Type") or "TEXT_AD"), + title=str(text_ad.get("Title") or ""), + title2=text_ad.get("Title2") if isinstance(text_ad.get("Title2"), str) else None, + text=str(text_ad.get("Text") or ""), + href=str(text_ad.get("Href") or ""), + display_url_path=text_ad.get("DisplayUrlPath") if isinstance(text_ad.get("DisplayUrlPath"), str) else None, + sitelink_set_id=str(sl_set_id) if sl_set_id is not None else None, + business_id=str(biz_id) if biz_id is not None else None, + vcard_id=str(vc_id) if vc_id is not None else None, + prefer_vcard_over_business=text_ad.get("PreferVCardOverBusiness") if isinstance(text_ad.get("PreferVCardOverBusiness"), str) else None, + ad_extension_ids=text_ad.get("AdExtensions") if isinstance(text_ad.get("AdExtensions"), list) else None, + ) + ads.append(ad_item) + + if isinstance(sl_set_id, int): + sitelink_set_ids.add(sl_set_id) + if isinstance(biz_id, int): + business_ids.add(biz_id) + if isinstance(vc_id, int): + vcard_ids.add(vc_id) + + # Resolve sitelinks + sitelinks_sets: list[YandexSitelinkSetItem] = [] + if sitelink_set_ids: + try: + sl_response = direct.sitelinks_get(ids=sorted(sitelink_set_ids)) + except YandexDirectError: + sl_response = None + if sl_response and sl_response.get("ok"): + sl_result = sl_response.get("result") or {} + for sl_set in sl_result.get("SitelinksSets") or []: + sl_items = [ + YandexSitelinkItem( + title=str(s.get("Title") or ""), + href=s.get("Href") if isinstance(s.get("Href"), str) else None, + description=s.get("Description") if isinstance(s.get("Description"), str) else None, + ) + for s in (sl_set.get("Sitelinks") or []) + if isinstance(s, dict) + ] + sitelinks_sets.append( + YandexSitelinkSetItem( + id=str(sl_set.get("Id") or ""), + sitelinks=sl_items, + ) + ) + + # Resolve businesses + businesses: list[YandexBusinessAssetItem] = [] + if business_ids: + try: + biz_response = direct.businesses_get() + except YandexDirectError: + biz_response = None + if biz_response and biz_response.get("ok"): + biz_result = biz_response.get("result") or {} + for b in biz_result.get("Businesses") or []: + if not isinstance(b, dict): + continue + b_id = b.get("Id") + if b_id in business_ids: + businesses.append( + YandexBusinessAssetItem( + id=str(b_id), + name=str(b.get("Name") or ""), + address=b.get("Address") if isinstance(b.get("Address"), str) else None, + ) + ) + + # Resolve vcards + vcards: list[YandexVCardAssetItem] = [] + if vcard_ids: + try: + vc_response = direct.vcards_get() + except YandexDirectError: + vc_response = None + if vc_response and vc_response.get("ok"): + vc_result = vc_response.get("result") or {} + for v in vc_result.get("VCards") or []: + if not isinstance(v, dict): + continue + v_id = v.get("Id") + if v_id in vcard_ids: + phone_raw = v.get("Phone") + phone_str: str | None = None + if isinstance(phone_raw, dict): + parts = [ + str(phone_raw.get("CountryCode") or ""), + str(phone_raw.get("CityCode") or ""), + str(phone_raw.get("PhoneNumber") or ""), + ] + phone_str = " ".join(p for p in parts if p) or None + vcards.append( + YandexVCardAssetItem( + id=str(v_id), + company_name=str(v.get("CompanyName") or ""), + phone=phone_str, + ) + ) + + return YandexAdAssetsResult( + campaign_id=campaign_id, + source="yandex", + read_only=True, + ads=ads, + sitelinks_sets=sitelinks_sets, + businesses=businesses, + vcards=vcards, + callouts=[], + missing=YandexAdAssetsMissing(), + ) + + # Mock mode + mock_ads = mock_yandex.list_ads(campaign_id) + ad_items = [ + YandexAdAssetItem( + id=a.get("id", ""), + ad_group_id=a.get("ad_group_id", ""), + campaign_id=a.get("campaign_id", campaign_id), + status=a.get("status", "active"), + state="ON", + type="TEXT_AD", + title=a.get("title", ""), + text="", + href="", + ) + for a in mock_ads + ] + return YandexAdAssetsResult( + campaign_id=campaign_id, + source="mock", + read_only=True, + ads=ad_items, + sitelinks_sets=[], + businesses=[], + vcards=[], + callouts=[], + ) + + +@router.get("/yandex/vcards", response_model=YandexRawResult) +def yandex_vcards( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "vcards", "get", lambda c: c.vcards_get()) + + +@router.post("/yandex/vcards", response_model=YandexVCardResult) +def yandex_vcards_add( + payload: YandexVCardRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexVCardResult: + try: + return store.yandex_vcard_add(payload, settings=settings, client=client) + except ValueError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + + +@router.post( + "/yandex/campaigns/{campaign_id}/ads/landing-urls", + response_model=UrlMigrationResult, + responses={ + 409: {"description": "Safety gate, optimistic-concurrency, or idempotency conflict."}, + 502: {"description": "Redacted Yandex Direct preflight failure."}, + }, +) +def yandex_ads_landing_urls( + campaign_id: str, + payload: LandingUrlMigrationRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> UrlMigrationResult: + try: + return store.yandex_ads_landing_urls( + campaign_id, payload, settings=settings, client=client + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + if "Live writes require" in str(exc): + raise HTTPException(status_code=409, detail=str(exc)) from exc + raise _yandex_error_to_502(exc) from exc + + +@router.post( + "/yandex/campaigns/{campaign_id}/sitelinks/migrate-urls", + response_model=UrlMigrationResult, + responses={ + 409: {"description": "Safety gate, source mismatch, or idempotency conflict."}, + 502: {"description": "Redacted Yandex Direct preflight failure."}, + }, +) +def yandex_sitelinks_migrate_urls( + campaign_id: str, + payload: SitelinkUrlMigrationRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> UrlMigrationResult: + try: + return store.yandex_sitelinks_migrate_urls( + campaign_id, payload, settings=settings, client=client + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + if "Live writes require" in str(exc): + raise HTTPException(status_code=409, detail=str(exc)) from exc + raise _yandex_error_to_502(exc) from exc + + +@router.post( + "/yandex/campaigns/{campaign_id}/landing-url-migrations", + response_model=UrlMigrationResult, + responses={ + 409: {"description": "Safety gate, optimistic-concurrency, or idempotency conflict."}, + 502: {"description": "Redacted Yandex Direct preflight failure."}, + }, +) +def yandex_landing_url_migrations( + campaign_id: str, + payload: LandingUrlMigrationsRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> UrlMigrationResult: + try: + return store.yandex_landing_url_migrations( + campaign_id, payload, settings=settings, client=client + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + if "Live writes require" in str(exc): + raise HTTPException(status_code=409, detail=str(exc)) from exc + raise _yandex_error_to_502(exc) from exc + + +@router.post("/yandex/ads/business", response_model=YandexAdsBusinessAttachResult) +def yandex_ads_business_attach( + payload: YandexAdsBusinessAttachRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexAdsBusinessAttachResult: + try: + return store.yandex_ads_business_attach(payload, settings=settings, client=client) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + message = str(exc) + if "Live writes require" in message or "live_readonly" in message: + raise HTTPException(status_code=409, detail=message) from exc + raise _yandex_error_to_502(exc) from exc + + +@router.post( + "/yandex/campaigns/{campaign_id}/ad-groups/{ad_group_id}/negative-keywords", + response_model=YandexAdGroupNegativeKeywordsResult, +) +def yandex_ad_group_negative_keywords_update( + campaign_id: str, + ad_group_id: str, + payload: YandexAdGroupNegativeKeywordsRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexAdGroupNegativeKeywordsResult: + _require_live_write_for_apply(settings, dry_run=payload.dry_run, approved=payload.approved) + requested = _normalize_negative_keywords(payload.negative_keywords) + if not requested: + raise HTTPException(status_code=422, detail="negative_keywords must contain at least one non-empty item") + + current: list[str] = [] + if client is not None and _is_live_read_mode(settings): + try: + response = client.adgroups_get(campaign_id) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + raise _yandex_business_error_to_502(response, "adgroups.get") + for group in (response.get("result") or {}).get("AdGroups") or []: + if str(group.get("Id")) == str(ad_group_id): + current = _negative_keywords_from_adgroup(group) + break + merged = _normalize_negative_keywords(current + requested) if payload.operation == "add" else requested + update_item = { + "Id": int(ad_group_id) if str(ad_group_id).isdigit() else ad_group_id, + "NegativeKeywords": {"Items": merged}, + } + preview = {"method": "adgroups.update", "params": {"AdGroups": [update_item]}} + audit = store.append_audit( + "yandex_ad_group_negative_keywords_preview" if payload.dry_run else "yandex_ad_group_negative_keywords_apply", + str(ad_group_id), + dry_run=payload.dry_run, + details={"campaign_id": campaign_id, "operation": payload.operation}, + ) + if payload.dry_run: + return YandexAdGroupNegativeKeywordsResult( + dry_run=True, + applied=False, + source="yandex" if _is_live_read_mode(settings) else "mock", + mode=settings.directpilot_mode, + audit_id=audit.id, + campaign_id=campaign_id, + ad_group_id=ad_group_id, + operation=payload.operation, + negative_keywords=merged, + previous_negative_keywords=current, + payload_preview=preview, + ) + if payload.idempotency_key in store.apply_results_by_key: + return store.apply_results_by_key[payload.idempotency_key] + if client is None: + raise HTTPException(status_code=409, detail="Yandex client is required for live_write apply") + try: + response = client.adgroups_update([update_item]) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + raise _yandex_business_error_to_502(response, "negative keyword update") + result = YandexAdGroupNegativeKeywordsResult( + dry_run=False, + applied=True, + source="yandex", + mode=settings.directpilot_mode, + audit_id=audit.id, + campaign_id=campaign_id, + ad_group_id=ad_group_id, + operation=payload.operation, + negative_keywords=merged, + previous_negative_keywords=current, + provider_response=response.get("result") if response.get("ok") else response, + ) + store.apply_results_by_key[payload.idempotency_key] = result + return result + + +@router.post( + "/yandex/campaigns/{campaign_id}/ad-groups", + response_model=LiveAdGroupCreateResult, +) +def yandex_campaign_ad_group_create( + campaign_id: str, + payload: LiveAdGroupCreateRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> LiveAdGroupCreateResult: + _require_live_write_for_apply(settings, dry_run=payload.dry_run, approved=payload.approved) + ad_group = { + "Name": payload.name, + "CampaignId": int(campaign_id) if str(campaign_id).isdigit() else campaign_id, + "RegionIds": payload.region_ids, + } + negatives = _normalize_negative_keywords(payload.negative_keywords) + if negatives: + ad_group["NegativeKeywords"] = {"Items": negatives} + preview = {"method": "adgroups.add", "params": {"AdGroups": [ad_group]}} + audit = store.append_audit( + "yandex_ad_group_create_preview" if payload.dry_run else "yandex_ad_group_create_apply", + str(campaign_id), + dry_run=payload.dry_run, + details={"name": payload.name}, + ) + if payload.dry_run: + return LiveAdGroupCreateResult( + dry_run=True, + applied=False, + source="yandex" if _is_live_read_mode(settings) else "mock", + mode=settings.directpilot_mode, + audit_id=audit.id, + campaign_id=campaign_id, + payload_preview=preview, + warnings=["Creates only an ad group; ads, keywords, and moderation are separate next steps."], + ) + if payload.idempotency_key in store.apply_results_by_key: + return store.apply_results_by_key[payload.idempotency_key] + if client is None: + raise HTTPException(status_code=409, detail="Yandex client is required for live_write apply") + try: + response = client.adgroups_add([ad_group]) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + raise _yandex_business_error_to_502(response, "ad-group create") + add_results = [] + ad_group_ids: list[int] = [] + if response.get("ok"): + add_results = (response.get("result") or {}).get("AddResults") or [] + for item in add_results: + if isinstance(item, dict) and item.get("Id") is not None: + ad_group_ids.append(int(item["Id"])) + result = LiveAdGroupCreateResult( + dry_run=False, + applied=True, + source="yandex", + mode=settings.directpilot_mode, + audit_id=audit.id, + campaign_id=campaign_id, + ad_group_ids=ad_group_ids, + add_results=add_results, + provider_response=response.get("result") if response.get("ok") else response, + warnings=["Creates only an ad group; ads, keywords, and moderation are separate next steps."], + ) + store.apply_results_by_key[payload.idempotency_key] = result + return result + + +@router.post( + "/yandex/ad-groups/{ad_group_id}/ads", + response_model=LiveAdCreateResult, +) +def yandex_ad_group_ads_add( + ad_group_id: str, + payload: LiveAdCreateRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> LiveAdCreateResult: + try: + return store.yandex_ad_group_ads_add( + ad_group_id, payload, settings=settings, client=client + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + message = str(exc) + if "Live writes require" in message or "live_readonly" in message: + raise HTTPException(status_code=409, detail=message) from exc + raise _yandex_error_to_502(exc) from exc + + +@router.post("/yandex/ads/moderate", response_model=AdsModerateResult) +def yandex_ads_moderate( + payload: AdsModerateRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> AdsModerateResult: + try: + return store.yandex_ads_moderate(payload, settings=settings, client=client) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + message = str(exc) + if "Live writes require" in message or "live_readonly" in message: + raise HTTPException(status_code=409, detail=message) from exc + raise _yandex_error_to_502(exc) from exc + + +@router.get("/yandex/ad-images", response_model=YandexRawResult) +def yandex_ad_images( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "adimages", "get", lambda c: c.adimages_get()) + + +@router.get("/yandex/creatives", response_model=YandexRawResult) +def yandex_creatives( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "creatives", "get", lambda c: c.creatives_get()) + + +@router.get("/yandex/feeds", response_model=YandexRawResult) +def yandex_feeds( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "feeds", "get", lambda c: c.feeds_get()) + + +@router.get("/yandex/businesses", response_model=YandexRawResult) +def yandex_businesses( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "businesses", "get", lambda c: c.businesses_get()) + + +@router.get("/yandex/agency-clients", response_model=YandexRawResult) +def yandex_agency_clients( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read(settings, client, "agencyclients", "get", lambda c: c.agencyclients_get()) + + +def _csv_items(value: str) -> list[str]: + return [item.strip() for item in value.split(",") if item.strip()] + + +def _csv_ints(value: str) -> list[int]: + return [int(item.strip()) for item in value.split(",") if item.strip()] + + +@router.get("/yandex/keywords-research/has-search-volume", response_model=YandexRawResult) +def yandex_keywords_has_search_volume( + keywords: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read( + settings, + client, + "keywordsresearch", + "hasSearchVolume", + lambda c: c.keywordsresearch_has_search_volume(_csv_items(keywords)), + ) + + +@router.get("/yandex/keywords-research/deduplicate", response_model=YandexRawResult) +def yandex_keywords_deduplicate( + keywords: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read( + settings, + client, + "keywordsresearch", + "deduplicate", + lambda c: c.keywordsresearch_deduplicate(_csv_items(keywords)), + ) + + +@router.get( + "/yandex/keywords-research/wordstat/create", + response_model=YandexRawResult, + responses=YANDEX_DIRECT_ERROR_RESPONSES, + deprecated=True, +) +def yandex_wordstat_create( + phrases: str, + geo_ids: str = "213", + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read( + settings, + client, + "keywordsresearch", + "createNewWordstatReport", + lambda c: c.keywordsresearch_create_wordstat_report(_csv_items(phrases), _csv_ints(geo_ids)), + ) + + +@router.get( + "/yandex/keywords-research/wordstat/{report_id}", + response_model=YandexRawResult, + responses=YANDEX_DIRECT_ERROR_RESPONSES, + deprecated=True, +) +def yandex_wordstat_get( + report_id: int, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read( + settings, + client, + "keywordsresearch", + "getWordstatReport", + lambda c: c.keywordsresearch_get_wordstat_report(report_id), + ) + + +@router.delete( + "/yandex/keywords-research/wordstat/{report_id}", + response_model=YandexRawResult, + responses=YANDEX_DIRECT_ERROR_RESPONSES, + deprecated=True, +) +def yandex_wordstat_delete( + report_id: int, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read( + settings, + client, + "keywordsresearch", + "deleteWordstatReport", + lambda c: c.keywordsresearch_delete_wordstat_report(report_id), + ) + + +@router.get("/yandex/reports/live/{report_type}", response_model=YandexRawResult) +def yandex_report( + report_type: str, + date_from: str, + date_to: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read( + settings, + client, + "reports", + report_type, + lambda c: c.report(report_type, date_from=date_from, date_to=date_to), + ) + + +@router.get("/yandex/reports/search-queries-live", response_model=YandexRawResult) +def yandex_search_queries_live( + date_from: str, + date_to: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexRawResult: + return _call_raw_read( + settings, + client, + "reports", + "SEARCH_QUERY_PERFORMANCE_REPORT", + lambda c: c.report( + "SEARCH_QUERY_PERFORMANCE_REPORT", + date_from=date_from, + date_to=date_to, + field_names=list(_SEARCH_QUERY_REPORT_FIELDS), + ), + ) + +@router.get( + "/yandex/reports/summary", + response_model=ReportSummary, + responses={ + 409: {"description": "Non-mock mode requires configured Yandex credentials/client."}, + 502: {"description": "Redacted Yandex Direct Reports API error."}, + }, +) +def yandex_reports_summary( + date_from: str | None = Query(default=None, description="ISO date (YYYY-MM-DD). Defaults to 7 days ago."), + date_to: str | None = Query(default=None, description="ISO date (YYYY-MM-DD). Defaults to today."), + campaign_id: str | None = Query(default=None, description="Optional Yandex Direct campaign id filter."), + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> ReportSummary: + """Live read-only summary from CAMPAIGN_PERFORMANCE_REPORT. + + In non-mock modes (``sandbox`` / ``live_readonly`` / ``live_write``) + with an available Yandex Direct client this calls the v5 reports + endpoint, parses the TSV response, and aggregates spend/clicks/ + impressions across all returned rows. ``period`` reflects the + requested date range (or the default 7-day window). ``source`` is + ``"yandex"`` and ``read_only`` is ``True``. + + The mock payload is the fallback ONLY for ``DIRECTPILOT_MODE=mock`` + or when no Yandex client/token is available. Live mode without a + usable client surfaces HTTP 409 (the same contract used by other + read-only endpoints), not a silent mock — marketing must not + mistake mock numbers for live numbers. + """ + # Fallback path: mock mode, or live mode but no client/token. + if settings.directpilot_mode == "mock" or client is None: + if settings.directpilot_mode != "mock": + # Live read mode without a usable client — be explicit + # rather than silently returning mock data. + raise HTTPException( + status_code=409, + detail=( + "This endpoint requires sandbox, live_readonly, or live_write " + "mode with Yandex credentials" + ), + ) + data = mock_yandex.report_summary() + return ReportSummary(**data, source="mock") + + # Live read-only path: real CAMPAIGN_PERFORMANCE_REPORT, parsed. + today = date.today() + if date_to is None: + date_to = today.isoformat() + if date_from is None: + date_from = (today - timedelta(days=6)).isoformat() + + period = f"{date_from}..{date_to}" + + try: + report_kwargs: dict[str, Any] = { + "date_from": date_from, + "date_to": date_to, + } + if campaign_id is not None: + report_kwargs["campaign_ids"] = [campaign_id] + response = client.report("CAMPAIGN_PERFORMANCE_REPORT", **report_kwargs) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"Yandex Direct rejected reports: error_code=" + f"{err.get('error_code')!r}" + ), + }, + ) + + # The client returns the raw TSV text in ``result``. NEVER log it + # (it contains customer campaign data); parse and aggregate. + tsv_text = response.get("result") or "" + totals = _aggregate_campaign_performance_tsv(tsv_text, campaign_id=campaign_id) + + impressions = totals["impressions"] + clicks = totals["clicks"] + spend = totals["spend"] + # CTR / CPC are recomputed from totals so the response is consistent + # with the v5 column values, regardless of how Yandex formatted them. + ctr = (clicks / impressions * 100.0) if impressions else 0.0 + cpc = (spend / clicks) if clicks else 0.0 + + return ReportSummary( + period=period, + spend=spend, + clicks=int(totals["clicks"]), + impressions=int(totals["impressions"]), + ctr=round(ctr, 4), + cpc=round(cpc, 4), + conversions=None, + cpa=None, + source="yandex", + read_only=True, + ) + + +def _aggregate_campaign_performance_tsv( + tsv_text: str, campaign_id: str | None = None +) -> dict[str, float | int]: + """Aggregate a CAMPAIGN_PERFORMANCE_REPORT TSV into totals. + + Expected column order (matches ``YandexDirectClient.report`` defaults): + Date, CampaignId, CampaignName, Impressions, Clicks, Cost, Ctr + + Returns a dict with ``impressions``, ``clicks``, ``spend`` summed + across the rows. Rows whose ``CampaignId`` does not match an + optional ``campaign_id`` filter are dropped. Malformed rows are + skipped silently (the endpoint surfaces 502 only on transport / + envelope errors, not on per-row parse noise). + """ + impressions = 0 + clicks = 0 + spend = 0.0 + seen = False + for raw_line in tsv_text.splitlines(): + line = raw_line.strip() + if not line: + continue + cols = line.split("\t") + # Need at least Date, CampaignId, ..., Impressions, Clicks, Cost + # i.e. index 5 (Cost) reachable. CTR is index 6 — we ignore it + # and recompute CTR/CPC from totals instead. + if len(cols) < 6: + continue + # Skip the header row (TSV first line repeats the field names). + if cols[0].lower() == "date": + continue + if campaign_id is not None and cols[1] != campaign_id: + continue + try: + impressions += int(cols[3]) + clicks += int(cols[4]) + spend += float(cols[5]) + except ValueError: + # Malformed numeric — skip the row, do not raise. + continue + seen = True + # If we got a report body but nothing matched the filter, return zeros + # rather than 502 — the report is valid, it just has no rows for the + # requested campaign / period. + if not seen and not tsv_text.strip(): + return {"impressions": 0, "clicks": 0, "spend": 0.0} + return {"impressions": impressions, "clicks": clicks, "spend": spend} + + +# Default field set for SEARCH_QUERY_PERFORMANCE_REPORT. The order matches +# what we request from Yandex and what the parser expects by default: +# Query, CampaignId, AdGroupId, Impressions, Clicks, Ctr, Cost. +_SEARCH_QUERY_REPORT_FIELDS: tuple[str, ...] = ( + "Query", + "CampaignId", + "AdGroupId", + "Impressions", + "Clicks", + "Ctr", + "Cost", +) + + +def _normalize_direct_id(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + try: + return str(int(normalized)) + except ValueError: + return normalized + + +def _find_search_query_column( + column_name: str, + header_map: dict[str, int] | None, + fallback_index: int, +) -> int: + if header_map and column_name in header_map: + return header_map[column_name] + return fallback_index + + +_DIRECT_REPORT_DOT_DECIMAL = re.compile(r"[0-9]+(?:\.[0-9]+)?") + + +def _parse_yandex_report_number( + value: str, + *, + max_fractional_digits: int | None = None, +) -> float: + """Parse a Direct-owned ASCII dot-decimal report token.""" + if _DIRECT_REPORT_DOT_DECIMAL.fullmatch(value) is None: + raise ValueError("Direct report number must be an ASCII dot-decimal token") + if max_fractional_digits is not None and "." in value: + fractional_digits = len(value.rsplit(".", maxsplit=1)[1]) + if fractional_digits > max_fractional_digits: + raise ValueError("Direct report number has too many fractional digits") + return float(value) + + +def _lookup_search_query_campaign_names( + client: YandexDirectClient, + campaign_ids: set[str], +) -> dict[str, str]: + """Map requested campaign ids to names using ``campaigns.get``. + + Network failures here must never fail report parsing in the happy path, + so callers should treat an empty mapping as a non-blocking fallback. + """ + try: + campaigns_result = client.campaigns_get() + except YandexDirectError: + return {} + if not campaigns_result.get("ok"): + return {} + + result_payload = campaigns_result.get("result") + if not isinstance(result_payload, dict): + return {} + + names_by_id: dict[str, str] = {} + for campaign in _extract_campaigns(result_payload): + campaign_id = campaign.get("id") + if not isinstance(campaign_id, str): + continue + campaign_name = campaign.get("name") + if campaign_id in campaign_ids and isinstance(campaign_name, str) and campaign_name: + names_by_id[campaign_id] = campaign_name + return names_by_id + + +def _aggregate_search_query_tsv( + tsv_text: str, + campaign_id: str | None = None, + campaign_name_map: dict[str, str] | None = None, +) -> list[YandexSearchQuery]: + """Parse a SEARCH_QUERY_PERFORMANCE_REPORT TSV into YandexSearchQuery items. + + Expected input is the default field order requested from Yandex + (Query, CampaignId, AdGroupId, Impressions, Clicks, Ctr, Cost), + but the parser is tolerant of legacy payloads that include CampaignName. + + Rows whose ``CampaignId`` does not match the optional ``campaign_id`` filter + are dropped. Numeric parse errors on metric columns do not fail the endpoint; + malformed rows are skipped silently, while empty input remains a valid + response with ``items=[]``. + """ + normalized_filter = _normalize_direct_id(campaign_id) + + rows = [line for line in tsv_text.splitlines() if line.strip()] + if not rows: + return [] + + header_map: dict[str, int] | None = None + first_columns = rows[0].split("\t") + if first_columns and first_columns[0].lower() == "query": + header_map = {name: idx for idx, name in enumerate(first_columns) if name} + rows = rows[1:] + + items: list[YandexSearchQuery] = [] + for cols in [row.split("\t") for row in rows]: + query_idx = _find_search_query_column("Query", header_map, 0) + campaign_id_idx = _find_search_query_column("CampaignId", header_map, 1) + ad_group_id_idx = _find_search_query_column("AdGroupId", header_map, 2) + impressions_idx = _find_search_query_column("Impressions", header_map, 3) + clicks_idx = _find_search_query_column("Clicks", header_map, 4) + ctr_idx = _find_search_query_column("Ctr", header_map, 5) + cost_idx = _find_search_query_column("Cost", header_map, 6) + campaign_name_idx = _find_search_query_column("CampaignName", header_map, -1) + + if len(cols) <= max(campaign_id_idx, ad_group_id_idx, impressions_idx, clicks_idx, ctr_idx): + continue + row_campaign_id = _normalize_direct_id(cols[campaign_id_idx]) + if row_campaign_id is None: + row_campaign_id = cols[campaign_id_idx] + if normalized_filter is not None and row_campaign_id != normalized_filter: + continue + + try: + impressions = int(cols[impressions_idx]) + clicks = int(cols[clicks_idx]) + ctr = _parse_yandex_report_number(cols[ctr_idx]) + except (IndexError, ValueError): + continue + + campaign_name = cols[campaign_name_idx] if campaign_name_idx >= 0 and len(cols) > campaign_name_idx else None + if not campaign_name and campaign_name_map is not None: + campaign_name = campaign_name_map.get(_normalize_direct_id(row_campaign_id) or row_campaign_id) + + cost: float | None = None + if len(cols) > cost_idx: + cost_text = cols[cost_idx] + if cost_text: + try: + cost = _parse_yandex_report_number(cost_text, max_fractional_digits=2) + except ValueError: + cost = None + + items.append( + YandexSearchQuery( + query=cols[query_idx], + campaign_id=row_campaign_id, + campaign_name=campaign_name, + ad_group_id=cols[ad_group_id_idx], + impressions=impressions, + clicks=clicks, + ctr=round(ctr, 4), + cost=cost, + ) + ) + return items + + +@router.get( + "/yandex/reports/search-queries", + response_model=YandexSearchQueriesReport, + responses={ + 409: {"description": "Non-mock mode requires configured Yandex credentials/client."}, + 502: {"description": "Redacted Yandex Direct Reports API error."}, + }, +) +def yandex_search_queries( + date_from: str | None = Query(default=None, description="ISO date (YYYY-MM-DD). Defaults to 7 days ago."), + date_to: str | None = Query(default=None, description="ISO date (YYYY-MM-DD). Defaults to today."), + campaign_id: str | None = Query(default=None, description="Optional Yandex Direct campaign id filter."), + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexSearchQueriesReport: + """Search query performance for the requested period. + + In ``DIRECTPILOT_MODE=mock`` the deterministic mock payload is returned + (with ``source="mock"``). In any non-mock mode (``sandbox`` / + ``live_readonly`` / ``live_write``) with a configured Yandex Direct + client, the live ``SEARCH_QUERY_PERFORMANCE_REPORT`` v5 reports + endpoint is called and the TSV is parsed into YandexSearchQuery items + with ``source="yandex"``, ``read_only=True``. An empty live report is + a valid response — it returns ``items=[]`` and ``source="yandex"``, + not a mock fallback and not a 502. + + When the live mode is selected but no client/token is available the + endpoint surfaces HTTP 409 (same contract as + ``/yandex/reports/summary`` and the other read-only endpoints), not + a silent mock — marketing must not mistake mock numbers for live + numbers. + """ + # Fallback path: mock mode, or live mode but no client/token. + if settings.directpilot_mode == "mock" or client is None: + if settings.directpilot_mode != "mock": + # Live read mode without a usable client — be explicit + # rather than silently returning mock data. + raise HTTPException( + status_code=409, + detail=( + "This endpoint requires sandbox, live_readonly, or live_write " + "mode with Yandex credentials" + ), + ) + items = [YandexSearchQuery(**q) for q in mock_yandex.search_queries()] + return YandexSearchQueriesReport( + period="last_7_days", + items=items, + source="mock", + read_only=True, + ) + + # Live read-only path: real SEARCH_QUERY_PERFORMANCE_REPORT, parsed. + today = date.today() + if date_to is None: + date_to = today.isoformat() + if date_from is None: + date_from = (today - timedelta(days=6)).isoformat() + + period = f"{date_from}..{date_to}" + + try: + report_kwargs: dict[str, Any] = { + "date_from": date_from, + "date_to": date_to, + "field_names": list(_SEARCH_QUERY_REPORT_FIELDS), + } + if campaign_id is not None: + report_kwargs["campaign_ids"] = [campaign_id] + response = client.report( + "SEARCH_QUERY_PERFORMANCE_REPORT", **report_kwargs + ) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"Yandex Direct rejected reports: error_code=" + f"{err.get('error_code')!r}" + ), + }, + ) + + # The client returns the raw TSV text in ``result``. NEVER log it + # (it contains customer search query data); parse and aggregate. + tsv_text = response.get("result") or "" + items = _aggregate_search_query_tsv(tsv_text, campaign_id=campaign_id) + missing_campaign_name_ids = { + item.campaign_id for item in items if not item.campaign_name + } + if missing_campaign_name_ids: + campaign_name_map = _lookup_search_query_campaign_names( + client, missing_campaign_name_ids + ) + if campaign_name_map: + enriched_items: list[YandexSearchQuery] = [] + for item in items: + if not item.campaign_name and item.campaign_id in campaign_name_map: + enriched_items.append( + item.model_copy(update={"campaign_name": campaign_name_map[item.campaign_id]}) + ) + else: + enriched_items.append(item) + items = enriched_items + + return YandexSearchQueriesReport( + period=period, + items=items, + source="yandex", + read_only=True, + ) + + +# --------------------------------------------------------------------------- +# Yandex Direct control facade (pause / resume) +# --------------------------------------------------------------------------- + + +@router.post( + "/yandex/campaigns/{campaign_id}/pause", + response_model=YandexControlResult, +) +def yandex_pause( + campaign_id: str, + payload: YandexControlRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexControlResult: + if not payload.approved: + raise HTTPException(status_code=409, detail="Action requires explicit approval") + if settings.directpilot_mode == "live_readonly" and not payload.dry_run: + raise HTTPException( + status_code=409, + detail="Live writes require DIRECTPILOT_MODE=live_write; live_readonly only allows dry_run", + ) + try: + return store.yandex_control( + campaign_id, "pause", payload, settings=settings, client=client + ) + except YandexDirectError as exc: + # Never include the OAuth token in the response. + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": str(exc), + }, + ) from exc + + +@router.post( + "/yandex/campaigns/{campaign_id}/resume", + response_model=YandexControlResult, +) +def yandex_resume( + campaign_id: str, + payload: YandexControlRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexControlResult: + if not payload.approved: + raise HTTPException(status_code=409, detail="Action requires explicit approval") + if settings.directpilot_mode == "live_readonly" and not payload.dry_run: + raise HTTPException( + status_code=409, + detail="Live writes require DIRECTPILOT_MODE=live_write; live_readonly only allows dry_run", + ) + try: + return store.yandex_control( + campaign_id, "resume", payload, settings=settings, client=client + ) + except YandexDirectError as exc: + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": str(exc), + }, + ) from exc + + +# --------------------------------------------------------------------------- +# Yandex AI Studio / Search API v2 — Wordstat +# +# This is the modern documented v2 path (https://yandex.cloud/en/services/ +# search-api) and is fully separate from the v5 keywordsresearch service +# used by the rest of the /yandex/* facade. Wordstat on v5 is not +# implemented, which is why the older keywordsresearch.wordstat.* helpers +# return UNSUPPORTED_IN_V5 envelopes. +# --------------------------------------------------------------------------- + + +def _wordstat_error_to_503(exc: YandexSearchWordstatError) -> HTTPException: + """Translate a missing-config error into a 503 (service not configured).""" + return HTTPException( + status_code=503, + detail={ + "error_type": "YandexSearchWordstatError", + "message": str(exc), + }, + ) + + +def _wordstat_error_to_502(exc: YandexSearchWordstatError) -> HTTPException: + """Translate an upstream / transport error into a 502 with no key echo.""" + return HTTPException( + status_code=502, + detail={ + "error_type": "YandexSearchWordstatError", + "message": str(exc), + }, + ) + + +def _parse_int_list(raw: list[str] | None) -> list[int] | None: + """Parse repeated and/or CSV query params into ints. + + Supports both ``?regions=43®ions=213`` and ``?regions=43,213``. + ``None`` is returned for an empty list so callers can keep the "omit + when not provided" semantics intact. + """ + if not raw: + return None + values: list[int] = [] + for item in raw: + for part in item.split(","): + part = part.strip() + if part: + try: + values.append(int(part)) + except ValueError as exc: + raise HTTPException( + status_code=422, + detail=f"regions must contain integer ids; got {part!r}", + ) from exc + return values or None + + +def _raise_wordstat_http_error(exc: YandexSearchWordstatError) -> None: + if isinstance(exc, YandexSearchWordstatMissingKeyError): + raise _wordstat_error_to_503(exc) from exc + raise _wordstat_error_to_502(exc) from exc + + +router = route_declarations.for_domain("wordstat") + + +@router.get( + "/wordstat/top", + response_model=YandexSearchApiResult, + responses=WORDSTAT_ERROR_RESPONSES, +) +def wordstat_top( + phrase: str, + regions: list[str] | None = Query(default=None), + limit: int | None = None, + devices: list[str] | None = Query(default=None), + client: YandexSearchWordstatClient = Depends(get_yandex_search_wordstat_client), +) -> YandexSearchApiResult: + """Top related queries for a phrase (Yandex Search API v2 topRequests).""" + try: + result = client.wordstat_top_requests( + phrase, + region_ids=_parse_int_list(regions), + limit=limit, + devices=devices or None, + ) + except YandexSearchWordstatError as exc: + _raise_wordstat_http_error(exc) + return YandexSearchApiResult( + method="topRequests", + data=result["data"], + ) + + +@router.get( + "/wordstat/dynamics", + response_model=YandexSearchApiResult, + responses=WORDSTAT_ERROR_RESPONSES, +) +def wordstat_dynamics( + phrase: str, + date_from: str, + period: str = "PERIOD_MONTHLY", + date_to: str | None = None, + regions: list[str] | None = Query(default=None), + devices: list[str] | None = Query(default=None), + client: YandexSearchWordstatClient = Depends(get_yandex_search_wordstat_client), +) -> YandexSearchApiResult: + """Show / abs show per period (Yandex Search API v2 dynamics).""" + try: + result = client.wordstat_dynamics( + phrase, + period=period, + date_from=date_from, + date_to=date_to, + region_ids=_parse_int_list(regions), + devices=devices or None, + ) + except YandexSearchWordstatError as exc: + _raise_wordstat_http_error(exc) + return YandexSearchApiResult( + method="dynamics", + data=result["data"], + ) + + +@router.get( + "/wordstat/regions", + response_model=YandexSearchApiResult, + responses=WORDSTAT_ERROR_RESPONSES, +) +def wordstat_regions( + phrase: str, + region: str = "REGION_ALL", + devices: list[str] | None = Query(default=None), + client: YandexSearchWordstatClient = Depends(get_yandex_search_wordstat_client), +) -> YandexSearchApiResult: + """Share of impressions by region (Yandex Search API v2 regions).""" + try: + result = client.wordstat_regions_distribution( + phrase, + region=region, + devices=devices or None, + ) + except YandexSearchWordstatError as exc: + _raise_wordstat_http_error(exc) + return YandexSearchApiResult( + method="regions", + data=result["data"], + ) + + +@router.get( + "/wordstat/regions-tree", + response_model=YandexSearchApiResult, + responses=WORDSTAT_ERROR_RESPONSES, +) +def wordstat_regions_tree( + client: YandexSearchWordstatClient = Depends(get_yandex_search_wordstat_client), +) -> YandexSearchApiResult: + """Region tree (Yandex Search API v2 getRegionsTree, no phrase).""" + try: + result = client.wordstat_regions_tree() + except YandexSearchWordstatError as exc: + _raise_wordstat_http_error(exc) + return YandexSearchApiResult( + method="getRegionsTree", + data=result["data"], + ) + + +router = route_declarations.for_domain("direct_extensions") + + +# --------------------------------------------------------------------------- +# Yandex Direct Live v4 — account balance (read-only) +# +# Live v4 AccountManagement → Get is the canonical way to read the +# current account balance (Amount, AmountAvailableForTransfer, Currency, +# AccountDayBudget). The endpoint is read-only and never returns the +# token in any body. Without a configured `?login=` we fall back to +# `clients.get` to discover the login the current token is bound to. +# --------------------------------------------------------------------------- + + +def _parse_live_v4_account_block(block: Any, login: str | None) -> YandexAccountBalance: + if not isinstance(block, dict): + return YandexAccountBalance(login=login, raw={"value": block} if not isinstance(block, dict) else None) + + def _float_or_zero(value: Any) -> float: + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return float(value.replace(",", ".")) + except ValueError: + return 0.0 + return 0.0 + + day_budget = block.get("AccountDayBudget") + day_budget_amount: float | None = None + day_budget_mode: str | None = None + if isinstance(day_budget, dict): + raw_amount = day_budget.get("Amount") + if isinstance(raw_amount, (int, float)): + day_budget_amount = float(raw_amount) + elif isinstance(raw_amount, str): + try: + day_budget_amount = float(raw_amount) + except ValueError: + day_budget_amount = None + mode = day_budget.get("SpendMode") + if isinstance(mode, str): + day_budget_mode = mode + amount_raw = block.get("Amount") + available_raw = block.get("AmountAvailableForTransfer") + return YandexAccountBalance( + login=str(block.get("Login") or login) if block.get("Login") or login else None, + amount=_float_or_zero(amount_raw), + amount_available_for_transfer=_float_or_zero(available_raw), + currency=str(block.get("Currency")) if isinstance(block.get("Currency"), str) else None, + account_day_budget_amount=day_budget_amount, + account_day_budget_spend_mode=day_budget_mode, + raw=block, + ) + + +def _resolve_login_for_balance( + client: YandexDirectClient, +) -> str | None: + """Discover the login the current OAUTH token is bound to via clients.get. + + Returns ``None`` if the call fails or returns an unexpected envelope — + the caller then surfaces the underlying 502 to the user. + """ + try: + response = client.clients_get() + except YandexDirectError: + return None + if not response.get("ok"): + return None + result = response.get("result") + if not isinstance(result, dict): + return None + clients = result.get("Clients") or result.get("clients") or [] + if not clients: + return None + first = clients[0] + if not isinstance(first, dict): + return None + login = first.get("Login") or first.get("login") + return str(login) if isinstance(login, str) and login else None + + +def _require_direct_read_client( + settings: Settings, client: YandexDirectClient | None +) -> YandexDirectClient: + if not _is_live_read_mode(settings) or client is None: + raise HTTPException( + status_code=409, + detail="This endpoint requires sandbox, live_readonly, or live_write mode with Yandex credentials", + ) + return client + + +@router.get( + "/yandex/account/balance", + response_model=YandexAccountBalanceResult, + responses={ + 502: {"model": ApiErrorResponse, "description": "Yandex Direct upstream error"}, + 503: {"model": ApiErrorResponse, "description": "YANDEX_OAUTH_TOKEN is not configured"}, + }, +) +def yandex_account_balance( + login: str | None = None, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexAccountBalanceResult: + """Read-only Live v4 AccountManagement → Get. + + Without ``?login=`` we discover the login via ``clients.get`` and then + call Live v4. With ``?login=`` we call Live v4 directly. The token is + never echoed back in any body. + """ + direct = _require_direct_read_client(settings, client) + if not direct.settings.yandex_oauth_token: + raise HTTPException( + status_code=503, + detail={ + "error_type": "YandexDirectError", + "message": "YANDEX_OAUTH_TOKEN is required for Yandex Direct API calls", + }, + ) + if not login: + login = _resolve_login_for_balance(direct) + try: + response = direct.account_balance(login=login) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"Yandex Direct Live v4 rejected AccountManagement: " + f"error_code={err.get('error_code')!r}" + ), + }, + ) + data = response.get("data") or [] + accounts = [_parse_live_v4_account_block(block, login) for block in data] + return YandexAccountBalanceResult(accounts=accounts, source="yandex", read_only=True) + + +# --------------------------------------------------------------------------- +# Yandex Direct campaign finance (v5 campaigns.get with finance fields) +# --------------------------------------------------------------------------- + + +@router.get( + "/yandex/campaigns/finance", + response_model=YandexCampaignFinanceList, + responses=YANDEX_DIRECT_ERROR_RESPONSES, +) +def yandex_campaigns_finance( + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexCampaignFinanceList: + """v5 campaigns.get with Funds / Statistics / StartDate / EndDate. + + Surfaces the raw micro-unit values and the display floats for money + fields so the caller can pick whichever representation they need. + """ + direct = _require_direct_read_client(settings, client) + if not direct.settings.yandex_oauth_token: + raise HTTPException( + status_code=503, + detail={ + "error_type": "YandexDirectError", + "message": "YANDEX_OAUTH_TOKEN is required for Yandex Direct API calls", + }, + ) + try: + response = direct.campaigns_get_finance() + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"Yandex Direct rejected campaigns.get (finance): " + f"error_code={err.get('error_code')!r}" + ), + }, + ) + items = [YandexCampaignFinance(**row) for row in (response.get("data") or [])] + return YandexCampaignFinanceList(items=items, source="yandex", read_only=True) + + +# --------------------------------------------------------------------------- +# Semantic change package (staged / dry-run-first) +# +# Lets the user design a negative-keyword and/or positive-keyword +# change for a real Yandex Direct campaign (e.g. ``710382063``) and +# preview the exact Direct API v5 request bodies that WOULD be sent. +# Apply is gated by ``approved`` / ``idempotency_key`` / ``dry_run`` +# and the runtime mode (``live_readonly`` blocks real apply; +# ``live_write`` allows it). +# --------------------------------------------------------------------------- + + +@router.post( + "/campaigns/{campaign_id}/semantic-changes", + response_model=SemanticChangePackage, +) +def prepare_semantic_change( + campaign_id: str, + payload: SemanticChangeRequest, + settings: Settings = Depends(get_settings), +) -> SemanticChangePackage: + """Build a staged semantic-change package. + + Always pure-local: no network call, no approval required. The + response is a :class:`SemanticChangePackage` whose ``preview`` + lists the v5 ``keywords.add`` / ``adgroups.update`` operations + that *would* be sent on apply. The user (or another tool) can + inspect the proposed change before deciding to actually apply it. + + The Direct API v5 ``keywords.add`` method requires ``AdGroupId`` + per keyword and ``adgroups.update`` requires the target group + ``Id``. If the user supplies either keyword list without an + ``ad_group_id`` the request is rejected with HTTP 400 BEFORE any + package is built. + """ + try: + return store.prepare_semantic_change_package( + campaign_id, payload, settings=settings + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post( + "/semantic-changes/{package_id}/apply", + response_model=SemanticChangeApplyResult, +) +def apply_semantic_change( + package_id: str, + payload: SemanticChangeApplyRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> SemanticChangeApplyResult: + """Apply a previously prepared semantic change. + + Gate contract (matches the rest of the product): + + * ``approved`` must be ``True`` — otherwise 409. + * ``idempotency_key`` must be supplied (length >= 6) — same key + returns the cached result without re-sending. + * In ``live_readonly`` mode, ``dry_run=False`` is REJECTED before + any network call. + * In ``live_write`` mode with all gates satisfied, the operations + from the package are sent to Yandex via the injected client. + """ + if not payload.approved: + raise HTTPException( + status_code=409, + detail="Action requires explicit approval before apply", + ) + if settings.directpilot_mode == "live_readonly" and not payload.dry_run: + raise HTTPException( + status_code=409, + detail=( + "Live writes require DIRECTPILOT_MODE=live_write; " + "live_readonly only allows dry_run" + ), + ) + try: + return store.apply_semantic_change( + package_id, payload, settings=settings, client=client + ) + except KeyError as exc: + raise HTTPException( + status_code=404, detail="semantic change package not found" + ) from exc + except YandexDirectError as exc: + # Never include the OAuth token in the response. + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": str(exc), + }, + ) from exc + except Exception as exc: # noqa: BLE001 — safety net + # Last-resort safety net: even a non-typed exception from the + # store layer (e.g. a stale cache hit, a programming bug, or an + # unhandled httpx edge case) must be translated to 502 with a + # redacted message. The store already records a + # ``semantic_change_apply_failed`` audit event for typed errors; + # we add one here too so the operator can correlate the 502. + try: + store.append_audit( + "semantic_change_apply_failed", + str(package_id), + dry_run=False, + details={ + "package_id": package_id, + "approved": payload.approved, + "idempotency_key": payload.idempotency_key, + "endpoint_safety_net": True, + "yandex_error": ( + f"unexpected error in endpoint: " + f"{type(exc).__name__}: {exc}" + ), + "exception_type": type(exc).__name__, + }, + ) + except Exception: # noqa: BLE001 + # Audit is best-effort; never let it block the safe 502. + pass + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"unexpected error during semantic-change apply: " + f"{type(exc).__name__}" + ), + }, + ) from exc + + +# --------------------------------------------------------------------------- +# Yandex Direct live-create campaign +# +# ``POST /yandex/campaigns/live-create`` creates a real Yandex Direct +# campaign from an existing :class:`CampaignDraft` preview. The apply +# path chains ``campaigns.add`` → ``adgroups.add`` → ``ads.add`` → +# ``keywords.add``. ``negativekeywordsharedsets.add`` remains explicit +# ``not_implemented``; group-level negatives are sent through +# ``adgroups.add`` ``NegativeKeywords.Items``. Each stage is its own v5 +# call so a single failure stops the chain before the next stage. +# --------------------------------------------------------------------------- + + +@router.post( + "/yandex/campaigns/live-create", + response_model=LiveCreateCampaignResult, +) +def yandex_live_create_campaign( + payload: LiveCreateCampaignRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> LiveCreateCampaignResult: + """Create a real Yandex Direct campaign from a draft preview. + + Gates (mirrors the rest of the product): + + * ``approved`` must be ``True`` (HTTP 409 otherwise). + * ``idempotency_key`` is required. + * ``live_readonly`` + ``dry_run=False`` is REJECTED before any + network call (HTTP 409). + * ``live_write`` + ``approved`` + ``idempotency_key`` + ``dry_run=False`` + performs the real chain: ``campaigns.add`` → ``adgroups.add`` → + ``ads.add`` → ``keywords.add``. ``negativekeywordsharedsets.add`` + remains in ``not_implemented``; group-level negatives are sent via + ``adgroups.add`` ``NegativeKeywords.Items`` (block is OPTIONAL — + omitted when the draft has no negatives, included with the items + when it does). The chain does not auto-activate or call + ``campaigns.resume``; activation/moderation handoff stays a + separate approved step. + + Region / geo targeting: + + * ``adgroups.add`` items ALWAYS carry ``RegionIds`` (v5 rejects + items without a geo target). The ids are resolved from + ``draft.region`` via the explicit local map + ``_REGION_NAME_TO_V5_IDS`` in ``app/store.py`` (helper + ``_resolve_region_to_ids``). No external lookup, no network + call. Supported region names in the Beta: ``Казань`` → ``[43]``, + ``Москва`` → ``[213]``, ``Санкт-Петербург`` / ``СПб`` → ``[2]``, + ``Россия`` / ``Russia`` → ``[225]``. Trivially extensible. + * An unmapped / empty / whitespace region fails closed BEFORE any + ``campaigns.add`` network call. The chain raises + :class:`YandexDirectError` with a redacted message that names + the offending region, the public store method audits + ``live_create_campaign_failed`` (no token in the audit), and + the endpoint returns HTTP 502. The dry-run preview surfaces the + same failure so the operator sees the same mode in both paths. + * The ``adgroups.add`` payload does NOT carry a ``Status`` field — + lifecycle/moderation state is controlled by Direct and the + separate resume endpoint, not by the create chain. + """ + if not payload.approved: + raise HTTPException( + status_code=409, + detail="Action requires explicit approval before live-create", + ) + # Mode gate: only ``live_write`` may perform a real apply. The + # other three modes (``mock`` / ``sandbox`` / ``live_readonly``) + # are REJECTED before any network call so the rejection is + # guaranteed to be no-network. ``sandbox`` shares the v5 + # ``campaigns.add`` write shape with production — a real apply + # against a sandbox token would create a real campaign on the + # user's sandbox account, which is the same shape of + # misconfiguration we are protecting against. ``mock`` has no + # live client at all — silently returning ``applied=False`` (a + # dry-run shape) would lie to the operator. ``live_readonly`` is + # the documented read-only path. ``dry_run=True`` short-circuits + # all of this and is allowed in every mode. + if not payload.dry_run and settings.directpilot_mode != "live_write": + raise HTTPException( + status_code=409, + detail=( + f"Live writes require DIRECTPILOT_MODE=live_write; " + f"current mode is {settings.directpilot_mode!r}; " + f"live-create apply is not allowed in this mode " + f"(dry_run=True is the only allowed path)" + ), + ) + try: + return store.live_create_campaign( + payload, settings=settings, client=client + ) + except ValueError as exc: + # The store raises ``ValueError`` for in-store gate + # violations (e.g. unapproved apply if the endpoint gate is + # bypassed by a direct caller). Surface as 409 with the + # reason — never as the opaque FastAPI 500 default. A + # ``ValueError`` from anywhere else would also be caught by + # the generic ``Exception`` safety net below; this explicit + # branch ensures the gate-violation case is NEVER mis-coded + # as 502. + raise HTTPException(status_code=409, detail=str(exc)) from exc + except KeyError as exc: + raise HTTPException( + status_code=404, detail="Campaign draft not found" + ) from exc + except YandexDirectError as exc: + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": str(exc), + }, + ) from exc + except Exception as exc: # noqa: BLE001 — safety net + # Same last-resort contract as the semantic-change endpoint: + # any non-typed exception becomes 502 with a redacted message. + try: + store.append_audit( + "live_create_campaign_failed", + payload.draft_id, + dry_run=False, + details={ + "draft_id": payload.draft_id, + "approved": payload.approved, + "idempotency_key": payload.idempotency_key, + "endpoint_safety_net": True, + "yandex_error": ( + f"unexpected error in live-create endpoint: " + f"{type(exc).__name__}: {exc}" + ), + "exception_type": type(exc).__name__, + }, + ) + except Exception: # noqa: BLE001 + pass + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"unexpected error during live-create: " + f"{type(exc).__name__}" + ), + }, + ) from exc + + +# --------------------------------------------------------------------------- +# Yandex Direct campaign TimeTargeting read (GET) +# +# ``GET /yandex/campaigns/{campaign_id}/time-targeting`` reads the current +# ``TimeTargeting`` block from the campaign via v5 ``campaigns.get`` +# with the ``TimeTargeting`` field set. No write gate — this is a +# pure read-only endpoint available in ``mock``, ``sandbox``, +# ``live_readonly``, and ``live_write``. No ``approved``, no +# ``idempotency_key``, no network write call. +# --------------------------------------------------------------------------- + + +def _parse_v5_time_targeting_to_schedule( + time_targeting: dict, +) -> YandexTimeTargetingSchedule | None: + """Parse a v5 ``TimeTargeting`` block into a normalized schedule. + + The v5 shape is ``{Schedule: {Items: [str, ...]}, ...}`` where + each item is ``"daynum,percent0,percent1,...,percent23"``. + Returns ``None`` if the shape is unparseable. + """ + try: + schedule_block = time_targeting.get("Schedule", {}) + if not isinstance(schedule_block, dict): + return None + items = schedule_block.get("Items") + if not isinstance(items, list) or len(items) != 7: + return None + days: list[YandexTimeTargetingHourly] = [] + for item in items: + if not isinstance(item, str): + return None + parts = item.split(",") + if len(parts) != 25: # daynum + 24 percents + return None + try: + hours = [int(p) for p in parts[1:]] + except (ValueError, TypeError): + return None + if len(hours) != 24: + return None + # Validate range — out-of-range values mean unparseable. + if any(h < 0 or h > 100 for h in hours): + return None + days.append(YandexTimeTargetingHourly(hours=hours)) + return YandexTimeTargetingSchedule(days=days) + except Exception: + return None + + +@router.get( + "/yandex/campaigns/{campaign_id}/time-targeting", + response_model=YandexTimeTargetingReadResult, +) +def yandex_time_targeting_read( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexTimeTargetingReadResult: + """Read the current TimeTargeting / hourly schedule of a campaign. + + Pure read-only — no ``approved``, no ``idempotency_key``, no + network write call. Available in all modes. + + - **mock**: returns a deterministic schedule with + ``source="mock"``. + - **live** (sandbox / live_readonly / live_write): calls + ``campaigns_get_time_targeting`` (v5 ``campaigns.get`` with + ``TimeTargeting`` field) and returns the raw ``TimeTargeting`` + block plus a normalized 7×24 ``schedule``. + - Upstream Yandex errors are redacted (no token leakage) and + surfaced as 502. + """ + if _is_live_read_mode(settings) and client is not None: + try: + response = client.campaigns_get_time_targeting(campaign_id) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + "Yandex Direct rejected campaigns.get (TimeTargeting): " + f"error_code={err.get('error_code')!r}" + ), + }, + ) + result = response.get("result") or {} + campaigns = result.get("Campaigns") if isinstance(result, dict) else None + if isinstance(campaigns, list) and campaigns and isinstance(campaigns[0], dict): + camp = campaigns[0] + raw_tt = camp.get("TimeTargeting") + campaign_name = camp.get("Name") + else: + raw_tt = None + campaign_name = None + schedule = None + if isinstance(raw_tt, dict): + schedule = _parse_v5_time_targeting_to_schedule(raw_tt) + raw_tt = dict(raw_tt) # defensive copy + return YandexTimeTargetingReadResult( + campaign_id=campaign_id, + campaign_name=str(campaign_name) if campaign_name else None, + source="yandex", + read_only=True, + time_targeting=raw_tt if isinstance(raw_tt, dict) else None, + schedule=schedule, + ) + + # Mock mode (or no client): deterministic local data. + mock = mock_yandex.mock_time_targeting(campaign_id) + raw_tt = mock.get("time_targeting") + schedule = None + if isinstance(raw_tt, dict): + schedule = _parse_v5_time_targeting_to_schedule(raw_tt) + return YandexTimeTargetingReadResult( + campaign_id=campaign_id, + campaign_name=mock.get("campaign_name"), + source="mock", + read_only=True, + time_targeting=raw_tt if isinstance(raw_tt, dict) else None, + schedule=schedule, + ) + + +# --------------------------------------------------------------------------- +# Yandex Direct campaign TimeTargeting update +# +# ``POST /yandex/campaigns/{campaign_id}/time-targeting`` updates the +# hourly-bidding schedule (TimeTargeting) of an existing Yandex +# Direct campaign via v5 ``campaigns.update``. The gate contract is +# identical to the rest of the product surface: +# +# * ``approved`` must be ``True`` (HTTP 409 otherwise). +# * ``idempotency_key`` is required. +# * ``live_readonly`` + ``dry_run=False`` is REJECTED before any +# network call (HTTP 409). +# * ``live_write`` + ``approved`` + ``idempotency_key`` + +# ``dry_run=False`` performs the real apply: a v5 +# ``campaigns.update`` call with the canonical ``TimeTargeting`` +# block, followed by a read-back via ``campaigns.get`` to verify +# the schedule landed. ``sandbox`` is rejected (same write shape +# as production, so the gate is strict). +# +# The request body accepts the schedule in one of two shapes (see +# ``YandexTimeTargetingRequest``): the full 7 x 24 ``schedule`` +# matrix, or the flat ``hours`` list plus an optional ``days`` +# filter. The endpoint normalises both shapes into the canonical +# v5 day-of-week order MONDAY..SUNDAY before sending. +# +# Direct v5 ``campaigns.update`` is a REPLACE-shaped call for the +# ``TimeTargeting`` block — sending the new block atomically +# replaces the previous schedule. Other campaign fields are not +# included in the payload so the apply touches only the schedule. +# --------------------------------------------------------------------------- + + +@router.post( + "/yandex/campaigns/{campaign_id}/time-targeting", + response_model=YandexTimeTargetingResult, +) +def yandex_time_targeting( + campaign_id: str, + payload: YandexTimeTargetingRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexTimeTargetingResult: + """Update the TimeTargeting / hourly schedule of a campaign. + + Gates (mirrors the rest of the product): + + * ``approved`` must be ``True`` (HTTP 409 otherwise). + * ``idempotency_key`` is required (HTTP 422 otherwise — Pydantic). + * ``live_readonly`` + ``dry_run=False`` is REJECTED before any + network call (HTTP 409). The dry-run path is allowed in every + mode and never mutates. + * ``live_write`` + ``approved`` + ``idempotency_key`` + + ``dry_run=False`` performs the real apply: v5 + ``campaigns.update`` with the canonical ``TimeTargeting`` + block, followed by a read-back via v5 ``campaigns.get + TimeTargeting`` to verify the schedule landed. The response + surfaces the read-back so the operator can diff it against + ``schedule_applied`` without re-querying. + + The request body accepts the schedule in one of two shapes + (see :class:`YandexTimeTargetingRequest`): + + 1. ``schedule`` — the full 7 x 24 matrix (positional, in the + v5 day-of-week order MONDAY..SUNDAY). + 2. ``hours`` — a flat 24-value list (0..100) plus an optional + ``days`` filter (``["MONDAY", ..., "SUNDAY"]``). Convenience + for the common "use these hours every day" use case; the + endpoint expands it into the canonical 7 x 24 matrix. Days + not listed in ``days`` are set to all-zeros (paused) on the + apply so the operator sees an explicit zero schedule on the + missing days, not a silent carry-over of the previous + schedule. + + Either ``schedule`` or ``hours`` MUST be supplied; supplying + both is a 422 validation error. + + Audit events ``yandex_time_targeting_requested`` (every + request, dry-run or apply) and ``yandex_time_targeting_failed`` + (only on apply-path failure) record the request id, the + schedule, the timezone label, and the Yandex error (no token + in the audit). Mock mode does NOT call any client method — + the apply is a pure in-memory mirror with a deterministic + ``readback`` shape so the operator can preview the v5 payload + the apply would send. + """ + if not payload.approved: + raise HTTPException( + status_code=409, + detail="Action requires explicit approval before time-targeting update", + ) + if not payload.dry_run and settings.directpilot_mode != "live_write": + # Mode gate: only ``live_write`` may perform a real apply. + # ``live_readonly`` and ``sandbox`` are REJECTED before any + # network call. ``sandbox`` shares the v5 + # ``campaigns.update`` write shape with production; a + # sandbox-apply would mutate the user's sandbox account. + # ``mock`` has no live client — silently returning + # ``applied=False`` (a dry-run shape) would lie to the + # operator. ``dry_run=True`` short-circuits all of this + # and is allowed in every mode. + raise HTTPException( + status_code=409, + detail=( + f"Live writes require DIRECTPILOT_MODE=live_write; " + f"current mode is {settings.directpilot_mode!r}; " + f"time-targeting apply is not allowed in this mode " + f"(dry_run=True is the only allowed path)" + ), + ) + try: + return store.yandex_time_targeting( + campaign_id, payload, settings=settings, client=client + ) + except ValueError as exc: + # ``ValueError`` is the in-store gate violation signal + # (e.g. unapproved apply when the endpoint gate is + # bypassed). Surface as 409 with the reason — never as + # the opaque FastAPI 500 default. + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + # Two cases land here: + # + # 1. ``live_readonly`` / ``sandbox`` apply pre-flight gate + # (the endpoint gate is the primary; this is a + # defence-in-depth check from the store). + # 2. The apply path's v5 ``campaigns.update`` rejection. + # The store audits ``yandex_time_targeting_failed`` + # before re-raising, so the audit log already carries + # the failing stage and the redacted Yandex error. + # Both surface as 502 with a typed envelope. + diagnostics = exc.diagnostics or {} + detail: dict[str, Any] = { + "error_type": "YandexDirectError", + "message": str(exc), + } + if "error_code" in diagnostics: + detail["error_code"] = diagnostics["error_code"] + if "error_detail" in diagnostics: + detail["error_detail"] = diagnostics["error_detail"] + if "payload_preview" in diagnostics: + detail["payload_preview"] = diagnostics["payload_preview"] + raise HTTPException(status_code=502, detail=detail) from exc + except Exception as exc: # noqa: BLE001 — safety net + # Last-resort contract: any non-typed exception becomes + # 502 with a redacted message. The token is never + # included. + try: + store.append_audit( + "yandex_time_targeting_failed", + campaign_id, + dry_run=False, + details={ + "campaign_id": campaign_id, + "approved": payload.approved, + "idempotency_key": payload.idempotency_key, + "endpoint_safety_net": True, + "yandex_error": ( + f"unexpected error in time-targeting endpoint: " + f"{type(exc).__name__}: {exc}" + ), + "exception_type": type(exc).__name__, + }, + ) + except Exception: # noqa: BLE001 + pass + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"unexpected error during time-targeting: " + f"{type(exc).__name__}" + ), + }, + ) from exc + + +# --------------------------------------------------------------------------- +# Campaign strategy read (GET) +# --------------------------------------------------------------------------- + + +def _build_strategy_summary( + strategy: dict | None, + priority_goals: dict | None = None, +) -> dict | None: + """Build a human-readable strategy summary from a raw BiddingStrategy block. + + ``priority_goals`` is the optional raw ``TextCampaign.PriorityGoals`` + dict (``{\"Items\": [{GoalId, Value}, ...]}`` as returned by v5 readback). + Values are converted from Direct micros to RUBLES. + """ + import re + + if not isinstance(strategy, dict): + return None + summary: dict[str, Any] = {} + + def _to_snake(name: str) -> str: + # Convert CamelCase to snake_case: GoalId → goal_id + s1 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name) + s2 = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s1) + return s2.lower() + + search = strategy.get("Search") + if isinstance(search, dict): + search_summary: dict[str, Any] = { + "type": search.get("BiddingStrategyType", "UNKNOWN"), + } + for sub_key, sub_val in search.items(): + if isinstance(sub_val, dict): + params: dict[str, Any] = {} + for pk, pv in sub_val.items(): + snake_key = _to_snake(pk) + if pk in ("WeeklySpendLimit", "BidCeiling"): + try: + params[f"{snake_key}_rub"] = float(pv) / 1_000_000 + except (TypeError, ValueError): + params[snake_key] = pv + else: + params[snake_key] = pv + search_summary[sub_key] = params + summary["search"] = search_summary + + network = strategy.get("Network") + if isinstance(network, dict): + summary["network"] = { + "type": network.get("BiddingStrategyType", "UNKNOWN"), + } + + # Add PriorityGoals summary if present + if priority_goals is not None and isinstance(priority_goals, dict): + items = priority_goals.get("Items") + if isinstance(items, list) and items: + summary["priority_goals"] = [] + for item in items: + if isinstance(item, dict) and "GoalId" in item: + pg: dict[str, Any] = {"goal_id": item["GoalId"]} + if "Value" in item: + try: + pg["value_rub"] = float(item["Value"]) / 1_000_000 + except (TypeError, ValueError): + pg["value"] = item["Value"] + summary["priority_goals"].append(pg) + + return summary if summary else None + + +@router.get( + "/yandex/campaigns/{campaign_id}/strategy", + response_model=YandexStrategyReadResult, +) +def yandex_strategy_read( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexStrategyReadResult: + """Read the current bidding strategy of a campaign. + + Pure read-only — no write gate. Available in all modes. + """ + if _is_live_read_mode(settings) and client is not None: + try: + response = client.campaigns_get_full_strategy(campaign_id) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + if not response.get("ok"): + err = response.get("error") or {} + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + "Yandex Direct rejected campaigns.get (strategy): " + f"error_code={err.get('error_code')!r}" + ), + }, + ) + result = response.get("result") or {} + campaigns = result.get("Campaigns") if isinstance(result, dict) else None + if isinstance(campaigns, list) and campaigns and isinstance(campaigns[0], dict): + camp = campaigns[0] + campaign_name = camp.get("Name") + campaign_type = camp.get("Type") + state = camp.get("State") + status = camp.get("Status") + raw_daily_budget = camp.get("DailyBudget") + tc = camp.get("TextCampaign") + raw_counter_ids = tc.get("CounterIds") if isinstance(tc, dict) else None + raw_strategy = tc.get("BiddingStrategy") if isinstance(tc, dict) else None + raw_priority_goals = tc.get("PriorityGoals") if isinstance(tc, dict) else None + else: + campaign_name = None + campaign_type = None + state = None + status = None + raw_daily_budget = None + raw_counter_ids = None + raw_strategy = None + raw_priority_goals = None + + daily_budget = ( + dict(raw_daily_budget) + if isinstance(raw_daily_budget, dict) + else raw_daily_budget + ) + counter_ids = ( + list(raw_counter_ids) + if isinstance(raw_counter_ids, list) + else None + ) + strategy = ( + dict(raw_strategy) if isinstance(raw_strategy, dict) else None + ) + strategy_summary = _build_strategy_summary( + strategy, priority_goals=raw_priority_goals + ) + + return YandexStrategyReadResult( + campaign_id=campaign_id, + campaign_name=str(campaign_name) if campaign_name else None, + source="yandex", + read_only=True, + campaign_type=str(campaign_type) if campaign_type else None, + state=str(state) if state else None, + status=str(status) if status else None, + daily_budget=daily_budget, + counter_ids=counter_ids, + priority_goals=raw_priority_goals, + strategy=strategy, + strategy_summary=strategy_summary, + ) + + mock = mock_yandex.mock_strategy(campaign_id) + return YandexStrategyReadResult( + campaign_id=campaign_id, + campaign_name=mock.get("campaign_name"), + source="mock", + read_only=True, + campaign_type=mock.get("campaign_type"), + state=mock.get("state"), + status=mock.get("status"), + daily_budget=mock.get("daily_budget"), + counter_ids=mock.get("counter_ids"), + priority_goals=mock.get("priority_goals"), + strategy=mock.get("strategy"), + strategy_summary=mock.get("strategy_summary"), + ) + + +# --------------------------------------------------------------------------- +# Campaign strategy update (POST) +# --------------------------------------------------------------------------- + + +@router.post( + "/yandex/campaigns/{campaign_id}/strategy", + response_model=YandexStrategyResult, +) +def yandex_strategy_update( + campaign_id: str, + payload: YandexStrategyRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexStrategyResult: + """Update the bidding strategy of a campaign. + + Currently supports WB_MAXIMUM_CONVERSION_RATE. + weekly_spend_limit and bid_ceiling are in RUBLES. + """ + if not payload.approved: + raise HTTPException( + status_code=409, + detail="Action requires explicit approval before strategy update", + ) + if not payload.dry_run and settings.directpilot_mode != "live_write": + raise HTTPException( + status_code=409, + detail=( + f"Live writes require DIRECTPILOT_MODE=live_write; " + f"current mode is {settings.directpilot_mode!r}; " + f"strategy apply is not allowed in this mode " + f"(dry_run=True is the only allowed path)" + ), + ) + try: + return store.yandex_strategy_update( + campaign_id, payload, settings=settings, client=client + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + diagnostics = exc.diagnostics or {} + detail: dict[str, Any] = { + "error_type": "YandexDirectError", + "message": str(exc), + } + if "error_code" in diagnostics: + detail["error_code"] = diagnostics["error_code"] + if "error_detail" in diagnostics: + detail["error_detail"] = diagnostics["error_detail"] + if "payload_preview" in diagnostics: + detail["payload_preview"] = diagnostics["payload_preview"] + raise HTTPException(status_code=502, detail=detail) from exc + except Exception as exc: + try: + store.append_audit( + "yandex_strategy_failed", + campaign_id, + dry_run=False, + details={ + "campaign_id": campaign_id, + "approved": payload.approved, + "idempotency_key": payload.idempotency_key, + "endpoint_safety_net": True, + "yandex_error": ( + f"unexpected error in strategy endpoint: " + f"{type(exc).__name__}: {exc}" + ), + "exception_type": type(exc).__name__, + }, + ) + except Exception: + pass + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"unexpected error during strategy update: " + f"{type(exc).__name__}" + ), + }, + ) from exc + + +# --------------------------------------------------------------------------- +# Autotargeting settings read (GET) / update (POST) +# --------------------------------------------------------------------------- + + +@router.get( + "/yandex/campaigns/{campaign_id}/autotargeting", + response_model=YandexAutotargetingReadResult, +) +def yandex_autotargeting_read( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexAutotargetingReadResult: + """Read autotargeting settings for all ad groups in a campaign. + + Pure read-only — no write gate. Available in all modes. + Returns per-ad-group autotargeting categories and brand options + from the ``---autotargeting`` keyword rows. + """ + try: + return store.yandex_autotargeting_read( + campaign_id, settings=settings, client=client + ) + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + + +@router.post( + "/yandex/campaigns/{campaign_id}/autotargeting", + response_model=YandexAutotargetingResult, +) +def yandex_autotargeting_update( + campaign_id: str, + payload: YandexAutotargetingRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> YandexAutotargetingResult: + """Update autotargeting settings for ad groups in a campaign. + + Standard product gate contract: + ``dry_run=True`` (default) is preview-only and never performs a network + write; the response includes the exact v5 ``keywords.update`` payload + that WOULD be sent, with ``applied=False``. + + ``dry_run=False`` requires ``DIRECTPILOT_MODE=live_write``, + ``approved=True`` and a valid ``idempotency_key``. + + Categories are always sent with all five booleans explicitly (``YES`` + or ``NO``) to avoid the Direct API pitfall where missing categories + default to ``YES``. + + Default preset for local service-search campaigns: ``exact_narrow`` + (Exact=YES, Narrow=YES, Alternative=NO, Accessory=NO, Broader=NO). + Brand options default: WithoutBrands=YES, WithAdvertiserBrand=YES, + WithCompetitorsBrand=NO. + """ + if not payload.approved: + raise HTTPException( + status_code=409, + detail="Action requires explicit approval before autotargeting update", + ) + if not payload.dry_run and settings.directpilot_mode != "live_write": + raise HTTPException( + status_code=409, + detail=( + f"Live writes require DIRECTPILOT_MODE=live_write; " + f"current mode is {settings.directpilot_mode!r}; " + f"autotargeting apply is not allowed in this mode " + f"(dry_run=True is the only allowed path)" + ), + ) + try: + return store.yandex_autotargeting_update( + campaign_id, payload, settings=settings, client=client + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + diagnostics = exc.diagnostics or {} + detail: dict[str, Any] = { + "error_type": "YandexDirectError", + "message": str(exc), + } + if "error_code" in diagnostics: + detail["error_code"] = diagnostics["error_code"] + if "error_detail" in diagnostics: + detail["error_detail"] = diagnostics["error_detail"] + if "payload_preview" in diagnostics: + detail["payload_preview"] = diagnostics["payload_preview"] + raise HTTPException(status_code=502, detail=detail) from exc + except Exception as exc: + try: + store.append_audit( + "yandex_autotargeting_failed", + campaign_id, + dry_run=False, + details={ + "campaign_id": campaign_id, + "approved": payload.approved, + "idempotency_key": payload.idempotency_key, + "endpoint_safety_net": True, + "yandex_error": ( + f"unexpected error in autotargeting endpoint: " + f"{type(exc).__name__}: {exc}" + ), + "exception_type": type(exc).__name__, + }, + ) + except Exception: + pass + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"unexpected error during autotargeting update: " + f"{type(exc).__name__}" + ), + }, + ) from exc + + +# --------------------------------------------------------------------------- +# Keyword bids update — live-safe SearchBid / ContextBid changes +# --------------------------------------------------------------------------- + + +@router.post( + "/yandex/campaigns/{campaign_id}/bids", + response_model=KeywordBidUpdateResult, + responses={ + 409: { + "description": "Safety gate or idempotency conflict: missing approval, non-live_write apply, or replay payload mismatch.", + }, + 502: { + "description": "Upstream Yandex Direct keywordbids.set / readback failure, with redacted diagnostics only.", + }, + }, +) +def yandex_keyword_bids_update( + campaign_id: str, + payload: KeywordBidUpdateRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> KeywordBidUpdateResult: + """Update SearchBid / ContextBid for existing keywords via v5 ``keywordbids.set``. + + Standard product gate contract: + ``dry_run=True`` (default) is preview-only and never performs a network + write; the response includes the exact v5 ``keywordbids.set`` payload + that WOULD be sent, with ``applied=False``. + + ``dry_run=False`` requires ``DIRECTPILOT_MODE=live_write``, + ``approved=True`` and a valid ``idempotency_key``. + + Request items use RUBLES at the REST boundary; the store converts to + Direct micros (× 1 000 000). The minimal v5 item shape is + ``KeywordId + SearchBid`` / ``KeywordId + ContextBid`` — no + CampaignId / AdGroupId in the item. + + After apply, the endpoint reads back keyword bids for the campaign + and returns the changed keyword ids with current Bid/ContextBid. + """ + if not payload.approved: + raise HTTPException( + status_code=409, + detail="Action requires explicit approval before keyword bids update", + ) + if not payload.dry_run and settings.directpilot_mode != "live_write": + raise HTTPException( + status_code=409, + detail=( + f"Live writes require DIRECTPILOT_MODE=live_write; " + f"current mode is {settings.directpilot_mode!r}; " + f"keyword bids apply is not allowed in this mode " + f"(dry_run=True is the only allowed path)" + ), + ) + try: + return store.yandex_keyword_bids_update( + campaign_id, payload, settings=settings, client=client + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + diagnostics = exc.diagnostics or {} + detail: dict[str, Any] = { + "error_type": "YandexDirectError", + "message": str(exc), + } + if "error_code" in diagnostics: + detail["error_code"] = diagnostics["error_code"] + if "error_detail" in diagnostics: + detail["error_detail"] = diagnostics["error_detail"] + if "payload_preview" in diagnostics: + detail["payload_preview"] = diagnostics["payload_preview"] + raise HTTPException(status_code=502, detail=detail) from exc + except Exception as exc: + try: + store.append_audit( + "yandex_keyword_bids_failed", + campaign_id, + dry_run=False, + details={ + "campaign_id": campaign_id, + "approved": payload.approved, + "idempotency_key": payload.idempotency_key, + "endpoint_safety_net": True, + "yandex_error": ( + f"unexpected error in keyword bids endpoint: " + f"{type(exc).__name__}: {exc}" + ), + "exception_type": type(exc).__name__, + }, + ) + except Exception: + pass + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"unexpected error during keyword bids update: " + f"{type(exc).__name__}" + ), + }, + ) from exc + + +@router.post( + "/yandex/campaigns/{campaign_id}/keyword-bids/set-auto", + response_model=KeywordBidsSetAutoResult, + responses={ + 409: { + "description": "Safety gate or endpoint-scoped idempotency conflict.", + }, + 502: { + "description": "Upstream KeywordBids read/setAuto failure with redacted diagnostics.", + }, + }, +) +def yandex_keyword_bids_set_auto( + campaign_id: str, + payload: KeywordBidsSetAutoRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> KeywordBidsSetAutoResult: + """Preview or apply typed ``keywordbids.setAuto`` without changing strategy. + + A preview remains non-mutating. Apply is gated to ``live_write`` plus + explicit approval and a valid idempotency key; successful provider writes + are read back through ``keywordbids.get``. + """ + + if not payload.dry_run: + if not payload.approved: + raise HTTPException( + status_code=409, + detail="Action requires explicit approval before setAuto apply", + ) + if settings.directpilot_mode != "live_write": + raise HTTPException( + status_code=409, + detail=( + "Live writes require DIRECTPILOT_MODE=live_write; " + "dry_run=True is the only allowed path in this mode" + ), + ) + try: + return store.yandex_keyword_bids_set_auto( + campaign_id, payload, settings=settings, client=client + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + raise HTTPException( + status_code=502, + detail={"error_type": "YandexDirectError", "message": str(exc)}, + ) from exc + + +@router.post( + "/yandex/campaigns/{campaign_id}/bid-modifiers", + response_model=BidModifiersUpdateResult, + responses={ + 409: { + "description": "Safety gate or idempotency conflict for bid modifier update.", + }, + 502: { + "description": "Upstream Yandex Direct bidmodifiers.set / readback failure, with redacted diagnostics only.", + }, + }, +) +def yandex_bid_modifiers_update( + campaign_id: str, + payload: BidModifiersUpdateRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> BidModifiersUpdateResult: + """Preview/apply existing demographic bid modifier coefficient changes. + + Direct API v5 ``bidmodifiers.set`` updates an existing modifier by + ``Id`` and ``BidModifier``. The request keeps operator-facing + ``adjustment_percent`` semantics where ``-100`` becomes Direct + ``BidModifier=0``. Real apply still requires ``live_write``, + ``approved=True``, valid ``idempotency_key``, and ``dry_run=False``. + """ + if not payload.approved: + raise HTTPException( + status_code=409, + detail="Action requires explicit approval before bid modifiers update", + ) + if not payload.idempotency_key: + raise HTTPException( + status_code=409, + detail="idempotency_key is required before bid modifiers update", + ) + if not payload.dry_run and settings.directpilot_mode != "live_write": + raise HTTPException( + status_code=409, + detail=( + f"Live writes require DIRECTPILOT_MODE=live_write; " + f"current mode is {settings.directpilot_mode!r}; " + f"bid modifiers apply is not allowed in this mode " + f"(dry_run=True is the only allowed path)" + ), + ) + try: + return store.yandex_bid_modifiers_update( + campaign_id, payload, settings=settings, client=client + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + diagnostics = exc.diagnostics or {} + detail: dict[str, Any] = { + "error_type": "YandexDirectError", + "message": str(exc), + } + if "error_code" in diagnostics: + detail["error_code"] = diagnostics["error_code"] + if "error_detail" in diagnostics: + detail["error_detail"] = diagnostics["error_detail"] + if "payload_preview" in diagnostics: + detail["payload_preview"] = diagnostics["payload_preview"] + raise HTTPException(status_code=502, detail=detail) from exc + except Exception as exc: + try: + store.append_audit( + "yandex_bid_modifiers_failed", + campaign_id, + dry_run=payload.dry_run, + details={ + "campaign_id": campaign_id, + "approved": payload.approved, + "idempotency_key": payload.idempotency_key, + "endpoint_safety_net": True, + "yandex_error": ( + f"unexpected error in bid modifiers endpoint: " + f"{type(exc).__name__}: {exc}" + ), + "exception_type": type(exc).__name__, + }, + ) + except Exception: + pass + raise HTTPException( + status_code=502, + detail={ + "error_type": "YandexDirectError", + "message": ( + f"unexpected error during bid modifiers update: " + f"{type(exc).__name__}" + ), + }, + ) from exc + + +# --------------------------------------------------------------------------- +# Yandex Direct UTM — audit / plan / apply +# --------------------------------------------------------------------------- + + +@router.get( + "/yandex/campaigns/{campaign_id}/utm-audit", + response_model=UtmAuditResult, +) +def yandex_utm_audit( + campaign_id: str, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> UtmAuditResult: + """Read-only UTM audit for all ad and sitelink URLs in a campaign. + + In mock mode, returns deterministic mock data. In sandbox/live modes, + reads real ads and sitelinks from Yandex Direct (no writes). + """ + return store.utm_audit( + campaign_id, + settings=settings, + client=client, + ) + + +@router.post( + "/yandex/campaigns/{campaign_id}/utm-plan", + response_model=UtmPlanResult, +) +def yandex_utm_plan( + campaign_id: str, + payload: UtmPlanRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> UtmPlanResult: + """Generate UTM plan/preview — always dry_run, never writes. + + Returns the list of URL changes (old → new with UTM) and a + preview of the v5 ``ads.update`` payload that WOULD be sent on apply. + Sitelink previews are included and can be applied through ``utm-apply`` + via ``sitelinks.update`` when ``include_sitelinks=true``. + """ + return store.utm_plan( + campaign_id, + payload, + settings=settings, + client=client, + ) + + +@router.post( + "/yandex/campaigns/{campaign_id}/utm-apply", + response_model=UtmApplyResult, +) +def yandex_utm_apply( + campaign_id: str, + payload: UtmApplyRequest, + settings: Settings = Depends(get_settings), + client: YandexDirectClient | None = Depends(get_yandex_client), +) -> UtmApplyResult: + """Apply UTM URLs to live ads — write-gated. + + * ``dry_run=True`` → preview only, ``applied=False``. + * ``dry_run=False`` requires: + 1. ``DIRECTPILOT_MODE=live_write`` + 2. ``approved=true`` + 3. ``idempotency_key`` (>= 6 chars) + + Uses ``ads.update`` (REPLACE-shaped) to safely update TextAd.Href. + When requested, uses ``sitelinks.update`` to update attached sitelink Href values. + """ + if not payload.approved: + raise HTTPException( + status_code=409, + detail="Action requires explicit approval before UTM apply", + ) + if not payload.dry_run and settings.directpilot_mode != "live_write": + raise HTTPException( + status_code=409, + detail=( + f"Live writes require DIRECTPILOT_MODE=live_write; " + f"current mode is {settings.directpilot_mode!r}; " + f"UTM apply is not allowed in this mode " + f"(dry_run=True is the only allowed path)" + ), + ) + try: + return store.utm_apply( + campaign_id, + payload, + settings=settings, + client=client, + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except YandexDirectError as exc: + raise _yandex_error_to_502(exc) from exc + + +# --------------------------------------------------------------------------- +# Yandex Metrika — read-only (counters, goals, summary, traffic-sources) +# +# The Metrika Management API (api-metrika.yandex.net/management/v1) and +# the Stats API (api-metrika.yandex.net/stat/v1) are separate from the +# v5 Direct API. They use a service OAUTH token (NOT an Api-Key, NOT a +# v5 OAuth token) and `Authorization: OAuth ` for auth. +# --------------------------------------------------------------------------- + + +def _metrika_error_to_503(exc: YandexMetrikaError) -> HTTPException: + """Translate a missing-config error into a 503 (service not configured).""" + return HTTPException( + status_code=503, + detail={ + "error_type": "YandexMetrikaError", + "message": str(exc), + }, + ) + + +def _metrika_error_to_502(exc: YandexMetrikaError) -> HTTPException: + """Translate an upstream / transport error into a 502 with no token echo.""" + return HTTPException( + status_code=502, + detail={ + "error_type": "YandexMetrikaError", + "message": str(exc), + }, + ) + + +def _raise_metrika_http_error(exc: YandexMetrikaError) -> None: + if isinstance(exc, YandexMetrikaMissingTokenError): + raise _metrika_error_to_503(exc) from exc + raise _metrika_error_to_502(exc) from exc + + +router = route_declarations.for_domain("metrika") + + +@router.get( + "/metrika/counters", + response_model=YandexMetrikaResult, + responses=METRIKA_ERROR_RESPONSES, +) +def metrika_counters( + client: YandexMetrikaClient = Depends(get_yandex_metrika_client), +) -> YandexMetrikaResult: + """List Metrika counters accessible by the configured OAUTH token.""" + try: + result = client.list_counters() + except YandexMetrikaError as exc: + _raise_metrika_http_error(exc) + return YandexMetrikaResult( + service="management", + method="counters", + data=result["data"], + ) + + +@router.get( + "/metrika/counters/{counter_id}/goals", + response_model=YandexMetrikaResult, + responses=METRIKA_ERROR_RESPONSES, +) +def metrika_counter_goals( + counter_id: int, + client: YandexMetrikaClient = Depends(get_yandex_metrika_client), +) -> YandexMetrikaResult: + """List goals for one Metrika counter.""" + try: + result = client.goals(counter_id) + except YandexMetrikaError as exc: + _raise_metrika_http_error(exc) + return YandexMetrikaResult( + service="management", + method="counter_goals", + counter_id=counter_id, + data=result["data"], + ) + + +@router.get( + "/metrika/counters/{counter_id}/summary", + response_model=YandexMetrikaResult, + responses=METRIKA_ERROR_RESPONSES, +) +def metrika_counter_summary( + counter_id: int, + date1: str, + date2: str, + client: YandexMetrikaClient = Depends(get_yandex_metrika_client), +) -> YandexMetrikaResult: + """Goals-conversion summary (any-goal reaches per day) for date1..date2. + + Uses the documented ``ym:s:anyGoalReaches`` metric, NOT the per-goal + ``ym:s:goalReaches`` (the latter is per-goal and is no longer a valid + metric name in v2). + """ + try: + result = client.summary(counter_id, date1=date1, date2=date2) + except YandexMetrikaError as exc: + _raise_metrika_http_error(exc) + return YandexMetrikaResult( + service="stat", + method="summary", + counter_id=counter_id, + data=result["data"], + ) + + +@router.get( + "/metrika/counters/{counter_id}/traffic-sources", + response_model=YandexMetrikaResult, + responses=METRIKA_ERROR_RESPONSES, +) +def metrika_counter_traffic_sources( + counter_id: int, + date1: str, + date2: str, + limit: int = 10, + client: YandexMetrikaClient = Depends(get_yandex_metrika_client), +) -> YandexMetrikaResult: + """Visits split by the last-sign traffic source. + + Uses the documented ``ym:s:lastsignTrafficSource`` dimension, NOT the + older ``ym:s:TrafficSource`` (which is deprecated and breaks in v2). + """ + try: + result = client.traffic_sources( + counter_id, date1=date1, date2=date2, limit=limit + ) + except YandexMetrikaError as exc: + _raise_metrika_http_error(exc) + return YandexMetrikaResult( + service="stat", + method="traffic_sources", + counter_id=counter_id, + data=result["data"], + ) diff --git a/app/api/legacy_router.py b/app/api/legacy_router.py new file mode 100644 index 0000000..585bb38 --- /dev/null +++ b/app/api/legacy_router.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter + +from app.api.core_router import router as core_router +from app.api.direct_extensions_router import router as direct_extensions_router +from app.api.direct_router import router as direct_router +from app.api.legacy_handlers import ( + get_settings, + get_yandex_client, + get_yandex_metrika_client, + get_yandex_search_wordstat_client, + store, +) +from app.api.metrika_router import router as metrika_router +from app.api.wordstat_router import router as wordstat_router + +__all__ = [ + "get_settings", + "get_yandex_client", + "get_yandex_metrika_client", + "get_yandex_search_wordstat_client", + "router", + "store", +] + +router = APIRouter() +router.include_router(core_router) +router.include_router(direct_router) +router.include_router(wordstat_router) +router.include_router(direct_extensions_router) +router.include_router(metrika_router) diff --git a/app/api/metrika_router.py b/app/api/metrika_router.py new file mode 100644 index 0000000..8506bdf --- /dev/null +++ b/app/api/metrika_router.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from app.api.legacy_handlers import route_declarations +from app.api.route_registry import register_domain_routes + +router = APIRouter() +register_domain_routes(router, route_declarations, "metrika", __name__) diff --git a/app/api/route_registry.py b/app/api/route_registry.py new file mode 100644 index 0000000..8a5ef43 --- /dev/null +++ b/app/api/route_registry.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import inspect +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, TypeVar, get_type_hints + +from fastapi import APIRouter + +Endpoint = TypeVar("Endpoint", bound=Callable[..., Any]) + + +@dataclass(frozen=True, slots=True) +class RouteDeclaration: + domain: str + method: str + path: str + endpoint: Callable[..., Any] + options: dict[str, Any] + + +class DomainRouteDeclarations: + def __init__(self, registry: RouteDeclarationRegistry, domain: str) -> None: + self._registry = registry + self._domain = domain + + def get(self, path: str, **options: Any) -> Callable[[Endpoint], Endpoint]: + return self._route("GET", path, options) + + def post(self, path: str, **options: Any) -> Callable[[Endpoint], Endpoint]: + return self._route("POST", path, options) + + def patch(self, path: str, **options: Any) -> Callable[[Endpoint], Endpoint]: + return self._route("PATCH", path, options) + + def delete(self, path: str, **options: Any) -> Callable[[Endpoint], Endpoint]: + return self._route("DELETE", path, options) + + def _route( + self, method: str, path: str, options: dict[str, Any] + ) -> Callable[[Endpoint], Endpoint]: + def register(endpoint: Endpoint) -> Endpoint: + self._registry.add( + RouteDeclaration( + domain=self._domain, + method=method, + path=path, + endpoint=endpoint, + options=options, + ) + ) + return endpoint + + return register + + +class RouteDeclarationRegistry: + def __init__(self) -> None: + self._declarations: list[RouteDeclaration] = [] + + def for_domain(self, domain: str) -> DomainRouteDeclarations: + return DomainRouteDeclarations(self, domain) + + def add(self, declaration: RouteDeclaration) -> None: + self._declarations.append(declaration) + + def for_domain_routes(self, domain: str) -> tuple[RouteDeclaration, ...]: + return tuple( + declaration + for declaration in self._declarations + if declaration.domain == domain + ) + + +def _domain_endpoint( + handler: Callable[..., Any], module_name: str +) -> Callable[..., Any]: + def endpoint(*args: Any, **kwargs: Any) -> Any: + return handler(*args, **kwargs) + + signature = inspect.signature(handler) + type_hints = get_type_hints(handler) + setattr( + endpoint, + "__signature__", + signature.replace( + parameters=[ + parameter.replace( + annotation=type_hints.get(parameter.name, parameter.annotation) + ) + for parameter in signature.parameters.values() + ], + return_annotation=type_hints.get("return", signature.return_annotation), + ), + ) + endpoint.__name__ = handler.__name__ + endpoint.__qualname__ = handler.__qualname__ + endpoint.__doc__ = handler.__doc__ + endpoint.__module__ = module_name + return endpoint + + +def register_domain_routes( + router: APIRouter, + declarations: RouteDeclarationRegistry, + domain: str, + module_name: str, +) -> None: + for declaration in declarations.for_domain_routes(domain): + router.add_api_route( + declaration.path, + _domain_endpoint(declaration.endpoint, module_name), + methods=[declaration.method], + **declaration.options, + ) diff --git a/app/api/wordstat_router.py b/app/api/wordstat_router.py new file mode 100644 index 0000000..f37cc54 --- /dev/null +++ b/app/api/wordstat_router.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +from app.api.legacy_handlers import route_declarations +from app.api.route_registry import register_domain_routes + +router = APIRouter() +register_domain_routes(router, route_declarations, "wordstat", __name__) diff --git a/app/bootstrap/application.py b/app/bootstrap/application.py new file mode 100644 index 0000000..27d381f --- /dev/null +++ b/app/bootstrap/application.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI + +from app.bootstrap.dependencies import ( + ApplicationDependencies, + create_application_dependencies, +) +from app.core.errors import install_error_handlers +from app.core.request_context import RequestContextMiddleware + + +def create_app(*, dependencies: ApplicationDependencies | None = None) -> FastAPI: + """Create the DirectPilot ASGI application with stable public metadata.""" + app = FastAPI( + title="DirectPilot Beta API", + version="0.2.1", + description="Standalone API-first beta app for safe Yandex Direct automation.", + ) + app.state.dependencies = ( + dependencies if dependencies is not None else create_application_dependencies() + ) + app.add_middleware(RequestContextMiddleware) + install_error_handlers(app) + return app diff --git a/app/bootstrap/dependencies.py b/app/bootstrap/dependencies.py new file mode 100644 index 0000000..74867f2 --- /dev/null +++ b/app/bootstrap/dependencies.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast + +from fastapi import Depends, Request + +from app.config import Settings, get_settings +from app.providers.protocols import ( + DirectClientFactory, + MetrikaClientFactory, + WordstatClientFactory, +) +from app.providers.yandex import ( + DefaultDirectClientFactory, + DefaultMetrikaClientFactory, + DefaultWordstatClientFactory, +) +from app.repositories.context import RequestRepositoryProxy +from app.repositories.mock_store import MockStoreRepositoryAdapter +from app.repositories.protocols import LegacyStoreRepository +from app.store import store as mock_store +from app.yandex_direct import YandexDirectClient +from app.yandex_metrika import YandexMetrikaClient +from app.yandex_search_wordstat import YandexSearchWordstatClient + +legacy_store_adapter = MockStoreRepositoryAdapter(mock_store) +legacy_store = RequestRepositoryProxy(legacy_store_adapter) + + +@dataclass(frozen=True, slots=True) +class ApplicationDependencies: + repository: LegacyStoreRepository + direct_client_factory: DirectClientFactory + metrika_client_factory: MetrikaClientFactory + wordstat_client_factory: WordstatClientFactory + + +def create_application_dependencies() -> ApplicationDependencies: + return ApplicationDependencies( + repository=legacy_store_adapter, + direct_client_factory=DefaultDirectClientFactory(), + metrika_client_factory=DefaultMetrikaClientFactory(), + wordstat_client_factory=DefaultWordstatClientFactory(), + ) + + +def get_application_dependencies(request: Request) -> ApplicationDependencies: + return cast(ApplicationDependencies, request.app.state.dependencies) + + +def get_repository(request: Request) -> LegacyStoreRepository: + return get_application_dependencies(request).repository + + +def get_yandex_client( + request: Request, + settings: Settings = Depends(get_settings), +) -> YandexDirectClient | None: + return get_application_dependencies(request).direct_client_factory.create(settings) + + +def get_yandex_metrika_client( + request: Request, + settings: Settings = Depends(get_settings), +) -> YandexMetrikaClient: + return get_application_dependencies(request).metrika_client_factory.create(settings) + + +def get_yandex_search_wordstat_client( + request: Request, + settings: Settings = Depends(get_settings), +) -> YandexSearchWordstatClient: + return get_application_dependencies(request).wordstat_client_factory.create(settings) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/errors.py b/app/core/errors.py new file mode 100644 index 0000000..3f71142 --- /dev/null +++ b/app/core/errors.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import Request +from fastapi.exception_handlers import ( + http_exception_handler, + request_validation_exception_handler, +) +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, Response +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.core.logging import log_api_error + + +def is_api_v1_path(path: str) -> bool: + return path == "/api/v1" or path.startswith("/api/v1/") + + +def _request_id(request: Request) -> str: + context = getattr(request.state, "request_context", None) + return getattr(context, "request_id", "unknown") + + +def safe_error_response( + *, request: Request, status_code: int, code: str +) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={ + "error": { + "code": code, + "message": "Request failed", + "request_id": _request_id(request), + } + }, + ) + + +async def api_http_exception_handler( + request: Request, exc: StarletteHTTPException +) -> Response: + if not is_api_v1_path(request.url.path): + return await http_exception_handler(request, exc) + log_api_error(request_id=_request_id(request), status_code=exc.status_code) + return safe_error_response( + request=request, + status_code=exc.status_code, + code="http_error", + ) + + +async def api_validation_exception_handler( + request: Request, exc: RequestValidationError +) -> Response: + if not is_api_v1_path(request.url.path): + return await request_validation_exception_handler(request, exc) + log_api_error(request_id=_request_id(request), status_code=422) + return safe_error_response( + request=request, + status_code=422, + code="validation_error", + ) + + +def install_error_handlers(app: Any) -> None: + app.add_exception_handler(StarletteHTTPException, api_http_exception_handler) + app.add_exception_handler(RequestValidationError, api_validation_exception_handler) diff --git a/app/core/logging.py b/app/core/logging.py new file mode 100644 index 0000000..7788fca --- /dev/null +++ b/app/core/logging.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import logging +import re +from collections.abc import Mapping +from typing import Any + +_REQUEST_LOGGER = logging.getLogger("directpilot.request") +_SENSITIVE_FIELD_MARKERS = frozenset( + { + "authorization", + "credential", + "cookie", + "key", + "password", + "secret", + "token", + } +) +_BEARER_VALUE = re.compile(r"^Bearer\s+.+$", re.IGNORECASE) + + +def _is_sensitive_field(name: str) -> bool: + normalized = name.lower().replace("-", "_") + return any(marker in normalized for marker in _SENSITIVE_FIELD_MARKERS) + + +def redact_value(value: Any) -> Any: + """Return a recursively redacted value suitable for structured logging.""" + if isinstance(value, Mapping): + return { + str(key): "[REDACTED]" if _is_sensitive_field(str(key)) else redact_value(item) + for key, item in value.items() + } + if isinstance(value, list): + return [redact_value(item) for item in value] + if isinstance(value, tuple): + return tuple(redact_value(item) for item in value) + if isinstance(value, str) and _BEARER_VALUE.fullmatch(value): + return "[REDACTED]" + return value + + +def _log(event: str, **fields: Any) -> None: + _REQUEST_LOGGER.info(event, extra={"event": event, **redact_value(fields)}) + + +def log_request_completed( + *, request_id: str, method: str, path: str, status_code: int +) -> None: + _log( + "request_completed", + request_id=request_id, + method=method, + path=path, + status_code=status_code, + ) + + +def log_request_failed(*, request_id: str, method: str, path: str) -> None: + _log("request_failed", request_id=request_id, method=method, path=path) + + +def log_api_error(*, request_id: str, status_code: int) -> None: + _log("api_error", request_id=request_id, status_code=status_code) diff --git a/app/core/request_context.py b/app/core/request_context.py new file mode 100644 index 0000000..b32cb2f --- /dev/null +++ b/app/core/request_context.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import re +from contextvars import ContextVar, Token +from dataclasses import dataclass +from uuid import uuid4 + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import Response + +from app.bootstrap.dependencies import get_repository +from app.core.errors import is_api_v1_path, safe_error_response +from app.core.logging import log_request_completed, log_request_failed +from app.repositories.context import bind_request_repository, reset_request_repository + +_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_REQUEST_CONTEXT: ContextVar[RequestContext | None] = ContextVar( + "request_context", default=None +) + + +@dataclass(frozen=True, slots=True) +class RequestContext: + request_id: str + + +def create_request_context(request_id: str | None) -> RequestContext: + """Create a request context without echoing untrusted identifier values.""" + if request_id and _REQUEST_ID_PATTERN.fullmatch(request_id): + return RequestContext(request_id=request_id) + return RequestContext(request_id=uuid4().hex) + + +def get_request_context() -> RequestContext: + context = _REQUEST_CONTEXT.get() + if context is None: + raise RuntimeError("RequestContext is only available while handling a request") + return context + + +class RequestContextMiddleware(BaseHTTPMiddleware): + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + context = create_request_context(request.headers.get("X-Request-ID")) + token: Token[RequestContext | None] = _REQUEST_CONTEXT.set(context) + repository_token = bind_request_repository(get_repository(request)) + request.state.request_context = context + try: + try: + response = await call_next(request) + except Exception: + log_request_failed( + request_id=context.request_id, + method=request.method, + path=request.url.path, + ) + if not is_api_v1_path(request.url.path): + raise + response = safe_error_response( + request=request, + status_code=500, + code="internal_error", + ) + response.headers["X-Request-ID"] = context.request_id + log_request_completed( + request_id=context.request_id, + method=request.method, + path=request.url.path, + status_code=response.status_code, + ) + return response + finally: + reset_request_repository(repository_token) + _REQUEST_CONTEXT.reset(token) diff --git a/app/main.py b/app/main.py index d65d5cb..5124bb0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4390 +1,23 @@ -from __future__ import annotations - -from datetime import date, timedelta -import re -from typing import Any -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit - -from fastapi import Depends, FastAPI, HTTPException, Query -from fastapi.responses import JSONResponse - -from app.config import Settings, get_settings -from app.services import check_yandex_direct -from app.models import ( - WEEK_DAY_NAMES, - AdCreate, - AdGroupCreate, - AdGroupUpdate, - AdUpdate, - ApiErrorResponse, - ApplyActionRequest, - ApplyActionResult, - ApprovalResult, - AuditCheck, - AuditLog, - BidUpdate, - BudgetSimulationRequest, - BudgetSimulationResult, - BudgetUpdate, - CampaignAuditResult, - CampaignDraft, - CampaignDraftBaseUpdate, - CampaignDraftKeywordsAdd, - CampaignDraftKeywordsRemove, - CampaignDraftKeywordsUpdate, - CampaignDraftList, - CampaignDraftRequest, - CampaignList, - GenerateStructureRequest, - GenerateStructureResult, - LiveCreateCampaignRequest, - LiveCreateCampaignResult, - NegativeKeywordsReplace, - PreviewPayload, - RecommendationList, - ReportSummary, - SemanticChangeApplyRequest, - SemanticChangeApplyResult, - SemanticChangePackage, - SemanticChangeRequest, - UtmGenerateRequest, - UtmGenerateResult, - ValidationResult, - YandexAccountBalance, - YandexAccountBalanceResult, - YandexAdsBusinessAttachRequest, - YandexAdsBusinessAttachResult, - LandingUrlMigrationRequest, - LandingUrlMigrationsRequest, - SitelinkUrlMigrationRequest, - UrlMigrationResult, - YandexAd, - YandexAdGroup, - YandexAdGroupList, - YandexAdList, - YandexAdAssetItem, - YandexAdAssetsMissing, - YandexAdAssetsResult, - YandexBusinessAssetItem, - YandexCampaign, - YandexCampaignFinance, - YandexCampaignFinanceList, - YandexCampaignList, - YandexControlRequest, - YandexControlResult, - YandexKeyword, - YandexKeywordList, - YandexMetrikaResult, - YandexRawResult, - YandexSearchApiResult, - YandexSearchQueriesReport, - YandexSearchQuery, - YandexSitelinkItem, - YandexSitelinkSetItem, - YandexTimeTargetingRequest, - YandexTimeTargetingHourly, - YandexTimeTargetingReadResult, - YandexTimeTargetingResult, - YandexTimeTargetingSchedule, - YandexVCardRequest, - YandexVCardResult, - YandexVCardAssetItem, - YandexStrategyReadResult, - YandexStrategyRequest, - YandexStrategyResult, - LiveAdCreateRequest, - LiveAdCreateResult, - YandexAdGroupNegativeKeywords, - YandexAdGroupNegativeKeywordsList, - YandexAdGroupNegativeKeywordsRequest, - YandexAdGroupNegativeKeywordsResult, - LiveAdGroupCreateRequest, - LiveAdGroupCreateResult, - AdsModerateRequest, - AdsModerateResult, - ProviderWarning, - # Autotargeting - YandexAutotargetingReadResult, - YandexAutotargetingRequest, - YandexAutotargetingResult, - # UTM - UtmAuditResult, - UtmApplyRequest, - UtmApplyResult, - UtmConfig, - UtmPlanRequest, - UtmPlanResult, - # Keyword bids - KeywordBidItem, - KeywordBidUpdateRequest, - KeywordBidUpdateResult, - KeywordBidsGetResult, - KeywordBidsSetAutoRequest, - KeywordBidsSetAutoResult, - # Bid modifiers - YandexBidModifierItem, - YandexBidModifiersReadResult, - BidModifiersCreateRequest, - BidModifiersCreateResult, - BidModifiersUpdateRequest, - BidModifiersUpdateResult, -) -from app.store import store -from app.yandex_direct import YandexDirectClient, YandexDirectError -from app.yandex_facade import mock_yandex -from app.yandex_metrika import ( - YandexMetrikaClient, - YandexMetrikaError, - YandexMetrikaMissingTokenError, -) -from app.yandex_search_wordstat import ( - YandexSearchWordstatClient, - YandexSearchWordstatError, - YandexSearchWordstatMissingKeyError, -) - - -def get_yandex_client( - settings: Settings = Depends(get_settings), -) -> YandexDirectClient | None: - """Build a YandexDirectClient for credentialed Yandex modes. - - Mock mode stays no-network. sandbox/live_readonly/live_write can all read - real Direct data; write permission is enforced later by store.yandex_control. - Missing-token errors are raised inside YandexDirectClient._call(), so dry_run - paths remain safe while credentialed calls return redacted 502 errors. - """ - if settings.directpilot_mode not in ("sandbox", "live_readonly", "live_write"): - return None - return YandexDirectClient(settings=settings) - -app = FastAPI( - title="DirectPilot Beta API", - version="0.2.1", - description="Standalone API-first beta app for safe Yandex Direct automation.", -) - - -YANDEX_DIRECT_ERROR_RESPONSES = { - 502: {"model": ApiErrorResponse, "description": "Yandex Direct upstream error"}, - 503: {"model": ApiErrorResponse, "description": "YANDEX_OAUTH_TOKEN is not configured"}, -} - - -WORDSTAT_ERROR_RESPONSES = { - 502: {"model": ApiErrorResponse, "description": "Yandex Search API upstream error"}, - 503: {"model": ApiErrorResponse, "description": "YANDEX_SEARCH_API_KEY is not configured"}, -} - - -METRIKA_ERROR_RESPONSES = { - 502: {"model": ApiErrorResponse, "description": "Yandex Metrika upstream error"}, - 503: {"model": ApiErrorResponse, "description": "YANDEX_METRIKA_OAUTH_TOKEN is not configured"}, -} - - -# --------------------------------------------------------------------------- -# Non-product guard -# --------------------------------------------------------------------------- -# Demo/UI routes (HTML home + 6 /demo/* pages) are not part of the DirectPilot -# product surface: they were built for early stakeholder reviews and are no -# longer shipped. They are registered only as explicit non-product guards so -# old links/bookmarks get a 404 instead of silently routing elsewhere. These -# guard handlers are NOT included in the OpenAPI schema and must not return -# product/demo data. - -_NON_PRODUCT_PATHS = { - "/", - "/demo/yandex-status", - "/demo/campaigns", - "/demo/report", - "/demo/recommendations", - "/demo/tools", - "/demo/security-approval", -} - - -def _non_product_guard(path: str): - """Return a 404 JSONResponse for retired demo/UI paths, or None. - - Keeping this as a small explicit allow-list (rather than re-registering - the original HTML routes) ensures the demo surface cannot accidentally - come back online and cannot leak into OpenAPI. - """ - if path in _NON_PRODUCT_PATHS: - return JSONResponse( - status_code=404, - content={ - "detail": "Not part of DirectPilot product surface", - "path": path, - }, - ) - return None - - -@app.get("/", include_in_schema=False) -def _non_product_root(): - return _non_product_guard("/") - - -@app.get("/demo/yandex-status", include_in_schema=False) -def _non_product_yandex_status(): - return _non_product_guard("/demo/yandex-status") - - -@app.get("/demo/campaigns", include_in_schema=False) -def _non_product_demo_campaigns(): - return _non_product_guard("/demo/campaigns") - - -@app.get("/demo/report", include_in_schema=False) -def _non_product_demo_report(): - return _non_product_guard("/demo/report") - - -@app.get("/demo/recommendations", include_in_schema=False) -def _non_product_demo_recommendations(): - return _non_product_guard("/demo/recommendations") - - -@app.get("/demo/tools", include_in_schema=False) -def _non_product_demo_tools(): - return _non_product_guard("/demo/tools") - - -@app.get("/demo/security-approval", include_in_schema=False) -def _non_product_demo_security_approval(): - return _non_product_guard("/demo/security-approval") - - -@app.get("/health") -def health() -> dict: - settings = get_settings() - return { - "service": "directpilot-beta", - "mode": settings.directpilot_mode, - "yandex": settings.safe_status(), - } - - -@app.get("/integrations/yandex/direct/status") -def yandex_direct_status() -> dict: - settings = get_settings() - return check_yandex_direct(settings) - - -@app.post("/utm/generate", response_model=UtmGenerateResult) -def generate_utm(payload: UtmGenerateRequest) -> UtmGenerateResult: - parts = urlsplit(str(payload.landing_url)) - query = dict(parse_qsl(parts.query, keep_blank_values=True)) - query.update( - { - "utm_source": "yandex", - "utm_medium": "cpc", - "utm_campaign": payload.campaign, - "utm_content": payload.content, - "utm_term": payload.term, - } - ) - url = urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) - return UtmGenerateResult(url=url) - - -@app.post("/simulations/budget", response_model=BudgetSimulationResult) -def simulate_budget(payload: BudgetSimulationRequest) -> BudgetSimulationResult: - estimated_clicks = int(payload.daily_budget // payload.avg_cpc) - estimated_conversions = round(estimated_clicks * payload.conversion_rate / 100, 2) - return BudgetSimulationResult( - estimated_clicks=estimated_clicks, - estimated_conversions=estimated_conversions, - estimated_spend=round(estimated_clicks * payload.avg_cpc, 2), - ) - - -@app.get("/audit/campaigns", response_model=CampaignAuditResult) -def audit_campaigns() -> CampaignAuditResult: - return CampaignAuditResult( - items=[ - AuditCheck( - code="missing_utm", - severity="medium", - title="Нет UTM-разметки", - recommendation="Добавить UTM, чтобы связать клики с отчётами и заявками.", - ), - AuditCheck( - code="no_metrica_goal", - severity="high", - title="Не выбрана цель Метрики", - recommendation="Связать основную цель Метрики с кампанией до включения auto-apply.", - ), - AuditCheck( - code="high_cpc", - severity="medium", - title="Высокая цена клика", - recommendation="Запустить dry-run симуляцию бюджета и проверить ставки по ключам.", - ), - ] - ) - - -@app.get("/campaigns", response_model=CampaignList) -def list_campaigns() -> CampaignList: - return CampaignList(items=list(store.campaigns.values())) - - -@app.get("/reports/summary", response_model=ReportSummary) -def report_summary() -> ReportSummary: - return ReportSummary( - spend=1250.0, - clicks=42, - impressions=2100, - ctr=2.0, - cpc=29.76, - conversions=None, - cpa=None, - ) - - -# --------------------------------------------------------------------------- -# Campaign draft constructor -# --------------------------------------------------------------------------- - - -@app.post("/campaign-drafts", response_model=CampaignDraft) -def create_campaign_draft(payload: CampaignDraftRequest) -> CampaignDraft: - return store.create_draft(payload) - - -@app.get("/campaign-drafts", response_model=CampaignDraftList) -def list_campaign_drafts() -> CampaignDraftList: - return CampaignDraftList(items=list(store.drafts.values())) - - -@app.get("/campaign-drafts/{draft_id}", response_model=CampaignDraft) -def get_campaign_draft(draft_id: str) -> CampaignDraft: - try: - return store.drafts[draft_id] - except KeyError as exc: - raise HTTPException(status_code=404, detail="Campaign draft not found") from exc - - -@app.patch("/campaign-drafts/{draft_id}", response_model=CampaignDraft) -def patch_campaign_draft(draft_id: str, payload: CampaignDraftBaseUpdate) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - return store.update_draft_base(draft_id, payload.model_dump(exclude_unset=True)) - - -@app.patch("/campaign-drafts/{draft_id}/keywords", response_model=CampaignDraft) -def update_campaign_draft_keywords( - draft_id: str, payload: CampaignDraftKeywordsUpdate -) -> CampaignDraft: - try: - return store.replace_keywords(draft_id, payload.keywords) - except KeyError as exc: - raise HTTPException(status_code=404, detail="Campaign draft not found") from exc - - -@app.post("/campaign-drafts/{draft_id}/keywords", response_model=CampaignDraft) -def add_campaign_draft_keywords( - draft_id: str, payload: CampaignDraftKeywordsAdd -) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - return store.append_keywords(draft_id, payload.keywords) - - -@app.delete("/campaign-drafts/{draft_id}/keywords", response_model=CampaignDraft) -def delete_campaign_draft_keywords( - draft_id: str, payload: CampaignDraftKeywordsRemove -) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - return store.remove_keywords(draft_id, payload.keywords) - - -@app.patch( - "/campaign-drafts/{draft_id}/negative-keywords", response_model=CampaignDraft -) -def patch_negative_keywords( - draft_id: str, payload: NegativeKeywordsReplace -) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - return store.replace_negative_keywords(draft_id, payload) - - -@app.post("/campaign-drafts/{draft_id}/ad-groups", response_model=CampaignDraft) -def create_ad_group(draft_id: str, payload: AdGroupCreate) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - return store.create_ad_group(draft_id, payload) - - -@app.patch( - "/campaign-drafts/{draft_id}/ad-groups/{group_id}", response_model=CampaignDraft -) -def update_ad_group( - draft_id: str, group_id: str, payload: AdGroupUpdate -) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - try: - return store.update_ad_group(draft_id, group_id, payload) - except KeyError as exc: - raise HTTPException(status_code=404, detail="Ad group not found") from exc - - -@app.delete( - "/campaign-drafts/{draft_id}/ad-groups/{group_id}", response_model=CampaignDraft -) -def delete_ad_group(draft_id: str, group_id: str) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - try: - return store.delete_ad_group(draft_id, group_id) - except KeyError as exc: - raise HTTPException(status_code=404, detail="Ad group not found") from exc - - -@app.post("/campaign-drafts/{draft_id}/ads", response_model=CampaignDraft) -def create_ad(draft_id: str, payload: AdCreate) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - try: - return store.create_ad(draft_id, payload) - except KeyError as exc: - raise HTTPException(status_code=404, detail="Ad group not found") from exc - - -@app.patch("/campaign-drafts/{draft_id}/ads/{ad_id}", response_model=CampaignDraft) -def update_ad(draft_id: str, ad_id: str, payload: AdUpdate) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - try: - return store.update_ad(draft_id, ad_id, payload) - except KeyError as exc: - raise HTTPException(status_code=404, detail="Ad not found") from exc - - -@app.delete("/campaign-drafts/{draft_id}/ads/{ad_id}", response_model=CampaignDraft) -def delete_ad(draft_id: str, ad_id: str) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - try: - return store.delete_ad(draft_id, ad_id) - except KeyError as exc: - raise HTTPException(status_code=404, detail="Ad not found") from exc - - -@app.post( - "/campaign-drafts/{draft_id}/generate-structure", - response_model=GenerateStructureResult, -) -def generate_structure( - draft_id: str, payload: GenerateStructureRequest -) -> GenerateStructureResult: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - draft = store.generate_structure(draft_id, payload) - return GenerateStructureResult( - ad_groups=draft.ad_groups, - keywords=draft.keywords, - negative_keywords=draft.negative_keywords, - ads=draft.ads, - ) - - -@app.post("/campaign-drafts/{draft_id}/validate", response_model=ValidationResult) -def validate_draft(draft_id: str) -> ValidationResult: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - return store.validate_draft(draft_id) - - -@app.get("/campaign-drafts/{draft_id}/preview", response_model=PreviewPayload) -def preview_draft(draft_id: str) -> PreviewPayload: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - return store.preview_draft(draft_id) - - -@app.patch("/campaign-drafts/{draft_id}/budget", response_model=CampaignDraft) -def patch_draft_budget(draft_id: str, payload: BudgetUpdate) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - return store.update_budget(draft_id, payload) - - -@app.patch("/campaign-drafts/{draft_id}/bids", response_model=CampaignDraft) -def patch_draft_bids(draft_id: str, payload: BidUpdate) -> CampaignDraft: - if draft_id not in store.drafts: - raise HTTPException(status_code=404, detail="Campaign draft not found") - return store.update_bids(draft_id, payload) - - -# --------------------------------------------------------------------------- -# Recommendations / approval / apply -# --------------------------------------------------------------------------- - - -@app.get("/recommendations", response_model=RecommendationList) -def list_recommendations() -> RecommendationList: - return RecommendationList(items=list(store.recommendations.values())) - - -@app.post("/recommendations/{recommendation_id}/approve", response_model=ApprovalResult) -def approve_recommendation(recommendation_id: str) -> ApprovalResult: - if recommendation_id not in store.recommendations: - raise HTTPException(status_code=404, detail="Recommendation not found") - store.recommendations[recommendation_id].status = "approved" - store.append_audit("recommendation_approved", recommendation_id) - return ApprovalResult(recommendation_id=recommendation_id, status="approved") - - -@app.post("/recommendations/{recommendation_id}/reject", response_model=ApprovalResult) -def reject_recommendation(recommendation_id: str) -> ApprovalResult: - if recommendation_id not in store.recommendations: - raise HTTPException(status_code=404, detail="Recommendation not found") - store.recommendations[recommendation_id].status = "rejected" - store.append_audit("recommendation_rejected", recommendation_id) - return ApprovalResult(recommendation_id=recommendation_id, status="rejected") - - -@app.post("/actions/{action_id}/apply", response_model=ApplyActionResult) -def apply_action(action_id: str, payload: ApplyActionRequest) -> ApplyActionResult: - if not payload.approved: - raise HTTPException(status_code=409, detail="Action requires explicit approval before apply") - if payload.idempotency_key in store.apply_results_by_key: - return store.apply_results_by_key[payload.idempotency_key] - recommendation = next((r for r in store.recommendations.values() if r.action_id == action_id), None) - if recommendation is None: - raise HTTPException(status_code=404, detail="Action not found") - if recommendation.status != "approved": - raise HTTPException(status_code=409, detail="Recommendation must be approved before apply") - event = store.append_audit("action_applied", action_id, dry_run=payload.dry_run) - recommendation.status = "applied" - result = ApplyActionResult( - action_id=action_id, - dry_run=payload.dry_run, - applied=not payload.dry_run, - risk_level=recommendation.risk_level, - audit_id=event.id, - ) - store.apply_results_by_key[payload.idempotency_key] = result - return result - - -@app.get("/audit-log", response_model=AuditLog) -def audit_log() -> AuditLog: - return AuditLog(items=store.audit_events) - - -# --------------------------------------------------------------------------- -# Yandex Direct read-only facade -# -# Mock mode returns deterministic in-memory data (no network). -# Sandbox / live_readonly / live_write hit the real Direct API v5 -# (campaigns.get / adgroups.get / ads.get / keywords.get). These are all -# read-only — the live modes never trigger a write call from this facade. -# --------------------------------------------------------------------------- - - -def _yandex_error_to_502(exc: YandexDirectError) -> HTTPException: - """Translate a YandexDirectError into an HTTP 502 with no token in detail.""" - detail: dict[str, Any] = { - "error_type": "YandexDirectError", - "message": str(exc), - } - for key in ( - "provider", - "service", - "method", - "operation", - "http_status", - "error_code", - "error_string", - "error_detail", - ): - value = exc.diagnostics.get(key) - if value is not None: - detail[key] = value - return HTTPException( - status_code=502, - detail=detail, - ) - - -def _yandex_business_error_to_502(response: dict[str, Any], action: str) -> HTTPException: - """Translate a Direct API ok-false envelope into HTTP 502. - - The helper keeps upstream machine-readable keys and avoids leaking - provider payload. Missing fields are passed as None rather than - interpolated into a potentially sensitive message. - """ - error = response.get("error") if isinstance(response, dict) else None - if not isinstance(error, dict): - error = {} - return HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "error_code": error.get("error_code"), - "error_detail": error.get("error_detail") or error.get("error_string"), - "message": f"Yandex Direct rejected {action}", - }, - ) - - -# --- mapping helpers -------------------------------------------------------- -# -# These helpers are intentionally permissive: Direct API v5 may omit -# fields (status on a fresh ad group, daily_budget on a campaign created -# without a budget cap, etc.). We never let a missing field crash the -# endpoint — we fall back to safe defaults. -# --------------------------------------------------------------------------- - - -def _extract_campaigns(result: dict[str, Any] | None) -> list[dict[str, Any]]: - """Map Direct API v5 campaigns.get result → list of YandexCampaign dicts.""" - items: list[dict[str, Any]] = [] - if not isinstance(result, dict): - return items - raw = result.get("Campaigns") or result.get("campaigns") or [] - for c in raw: - if not isinstance(c, dict): - continue - daily_budget = c.get("DailyBudget") - if isinstance(daily_budget, dict): - # Yandex returns amount in micro-units (1/1_000_000 of currency). - amount = daily_budget.get("Amount", 0) or 0 - try: - budget_value = float(amount) / 1_000_000 - except (TypeError, ValueError): - budget_value = 0.0 - else: - budget_value = 0.0 - items.append( - { - "id": str(c.get("Id") or c.get("id") or ""), - "name": str(c.get("Name") or c.get("name") or ""), - "status": str(c.get("Status") or c.get("status") or "UNKNOWN"), - "type": str(c.get("Type") or c.get("type") or "UNKNOWN"), - "daily_budget": budget_value, - } - ) - return items - - -def _extract_ad_groups(result: dict[str, Any] | None) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - if not isinstance(result, dict): - return items - raw = result.get("AdGroups") or result.get("adgroups") or [] - for g in raw: - if not isinstance(g, dict): - continue - items.append( - { - "id": str(g.get("Id") or g.get("id") or ""), - "campaign_id": str(g.get("CampaignId") or g.get("campaignId") or ""), - "name": str(g.get("Name") or g.get("name") or ""), - "status": str(g.get("Status") or g.get("status") or "UNKNOWN"), - } - ) - return items - - -def _normalize_negative_keyword(value: str) -> str: - return value.strip().lstrip("-").strip() - - -def _normalize_negative_keywords(values: list[str]) -> list[str]: - seen: set[str] = set() - normalized: list[str] = [] - for raw in values: - item = _normalize_negative_keyword(str(raw)) - if not item or item in seen: - continue - seen.add(item) - normalized.append(item) - return normalized - - -def _negative_keywords_from_adgroup(group: dict[str, Any]) -> list[str]: - raw = group.get("NegativeKeywords") or group.get("negativeKeywords") or {} - if isinstance(raw, dict): - values = raw.get("Items") or raw.get("items") or [] - elif isinstance(raw, list): - values = raw - else: - values = [] - return _normalize_negative_keywords([str(v) for v in values]) - - -def _extract_ad_group_negative_keywords( - result: dict[str, Any] | None, *, source: str, read_only: bool -) -> list[YandexAdGroupNegativeKeywords]: - items: list[YandexAdGroupNegativeKeywords] = [] - if not isinstance(result, dict): - return items - raw = result.get("AdGroups") or result.get("adgroups") or [] - for g in raw: - if not isinstance(g, dict): - continue - negatives = _negative_keywords_from_adgroup(g) - items.append( - YandexAdGroupNegativeKeywords( - ad_group_id=str(g.get("Id") or g.get("id") or ""), - campaign_id=str(g.get("CampaignId") or g.get("campaignId") or ""), - name=str(g.get("Name") or g.get("name") or ""), - status=str(g.get("Status") or g.get("status") or "UNKNOWN"), - negative_keywords=negatives, - has_negative_keywords=bool(negatives), - source=source, - read_only=read_only, - ) - ) - return items - - -def _require_live_write_for_apply(settings: Settings, *, dry_run: bool, approved: bool) -> None: - if not approved: - raise HTTPException(status_code=409, detail="Action requires explicit approval before apply") - if dry_run: - return - if settings.directpilot_mode != "live_write": - raise HTTPException( - status_code=409, - detail="Live writes require DIRECTPILOT_MODE=live_write; current mode blocks mutation", - ) - - -def _extract_ads(result: dict[str, Any] | None) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - if not isinstance(result, dict): - return items - raw = result.get("Ads") or result.get("ads") or [] - for a in raw: - if not isinstance(a, dict): - continue - text_ad = a.get("TextAd") or a.get("textAd") - title = "" - if isinstance(text_ad, dict): - raw_title = text_ad.get("Title") or text_ad.get("title") - if isinstance(raw_title, str): - title = raw_title - items.append( - { - "id": str(a.get("Id") or a.get("id") or ""), - "ad_group_id": str(a.get("AdGroupId") or a.get("adGroupId") or ""), - "campaign_id": str(a.get("CampaignId") or a.get("campaignId") or ""), - "title": title, - "status": str(a.get("Status") or a.get("status") or "UNKNOWN"), - } - ) - return items - - -def _extract_keywords(result: dict[str, Any] | None) -> list[dict[str, Any]]: - items: list[dict[str, Any]] = [] - if not isinstance(result, dict): - return items - raw = result.get("Keywords") or result.get("keywords") or [] - for k in raw: - if not isinstance(k, dict): - continue - items.append( - { - "id": str(k.get("Id") or k.get("id") or ""), - "ad_group_id": str(k.get("AdGroupId") or k.get("adGroupId") or ""), - "phrase": str(k.get("Keyword") or k.get("keyword") or ""), - "status": str(k.get("Status") or k.get("status") or "UNKNOWN"), - } - ) - return items - - -def _is_live_read_mode(settings: Settings) -> bool: - """True for any non-mock mode that should use the real get endpoints.""" - return settings.directpilot_mode in ("sandbox", "live_readonly", "live_write") - - -# --- endpoint handlers ------------------------------------------------------ - - -@app.get("/yandex/campaigns", response_model=YandexCampaignList) -def yandex_campaigns( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexCampaignList: - if _is_live_read_mode(settings) and client is not None: - try: - response = client.campaigns_get() - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"Yandex Direct rejected campaigns.get: " - f"error_code={err.get('error_code')!r}" - ), - }, - ) - items = _extract_campaigns(response.get("result")) - return YandexCampaignList( - items=[YandexCampaign(**c) for c in items], - source="yandex", - read_only=True, - ) - return YandexCampaignList( - items=[YandexCampaign(**campaign) for campaign in mock_yandex.list_campaigns()], - source="mock", - read_only=True, - ) - - -@app.get( - "/yandex/campaigns/{campaign_id}/ad-groups", - response_model=YandexAdGroupList, -) -def yandex_ad_groups( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexAdGroupList: - if _is_live_read_mode(settings) and client is not None: - try: - response = client.adgroups_get(campaign_id) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"Yandex Direct rejected adgroups.get: " - f"error_code={err.get('error_code')!r}" - ), - }, - ) - items = _extract_ad_groups(response.get("result")) - return YandexAdGroupList( - items=[YandexAdGroup(**g) for g in items], - source="yandex", - read_only=True, - ) - items = [YandexAdGroup(**g) for g in mock_yandex.list_ad_groups(campaign_id)] - return YandexAdGroupList(items=items, source="mock", read_only=True) - - -@app.get( - "/yandex/campaigns/{campaign_id}/ad-groups/negative-keywords", - response_model=YandexAdGroupNegativeKeywordsList, -) -def yandex_ad_group_negative_keywords( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexAdGroupNegativeKeywordsList: - if _is_live_read_mode(settings) and client is not None: - try: - response = client.adgroups_get(campaign_id) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"Yandex Direct rejected adgroups.get: " - f"error_code={err.get('error_code')!r}" - ), - }, - ) - return YandexAdGroupNegativeKeywordsList( - items=_extract_ad_group_negative_keywords( - response.get("result"), source="yandex", read_only=True - ), - source="yandex", - read_only=True, - ) - mock_groups = {"AdGroups": mock_yandex.list_ad_groups(campaign_id)} - return YandexAdGroupNegativeKeywordsList( - items=_extract_ad_group_negative_keywords(mock_groups, source="mock", read_only=True), - source="mock", - read_only=True, - ) - - -@app.get("/yandex/campaigns/{campaign_id}/ads", response_model=YandexAdList) -def yandex_ads( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexAdList: - if _is_live_read_mode(settings) and client is not None: - try: - response = client.ads_get(campaign_id) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"Yandex Direct rejected ads.get: " - f"error_code={err.get('error_code')!r}" - ), - }, - ) - items = _extract_ads(response.get("result")) - return YandexAdList( - items=[YandexAd(**a) for a in items], - source="yandex", - read_only=True, - ) - items = [YandexAd(**a) for a in mock_yandex.list_ads(campaign_id)] - return YandexAdList(items=items, source="mock", read_only=True) - - -@app.get( - "/yandex/campaigns/{campaign_id}/keywords", - response_model=YandexKeywordList, -) -def yandex_keywords( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexKeywordList: - if _is_live_read_mode(settings) and client is not None: - try: - response = client.keywords_get(campaign_id) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"Yandex Direct rejected keywords.get: " - f"error_code={err.get('error_code')!r}" - ), - }, - ) - items = _extract_keywords(response.get("result")) - return YandexKeywordList( - items=[YandexKeyword(**kw) for kw in items], - source="yandex", - read_only=True, - ) - items = mock_yandex.list_keywords(campaign_id) - return YandexKeywordList( - items=[YandexKeyword(**kw) for kw in items], - source="mock", - read_only=True, - ) - - -def _raw_yandex_result(service: str, method: str, response: dict[str, Any]) -> YandexRawResult: - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": f"Yandex Direct rejected {service}.{method}: error_code={err.get('error_code')!r}", - }, - ) - return YandexRawResult( - service=service, - method=method, - data=response.get("result"), - source="yandex", - read_only=True, - ) - - -def _require_yandex_read_client( - settings: Settings, - client: YandexDirectClient | None, -) -> YandexDirectClient: - if not _is_live_read_mode(settings) or client is None: - raise HTTPException( - status_code=409, - detail="This endpoint requires sandbox, live_readonly, or live_write mode with Yandex credentials", - ) - return client - - -def _call_raw_read( - settings: Settings, - client: YandexDirectClient | None, - service: str, - method: str, - call, -) -> YandexRawResult: - direct = _require_yandex_read_client(settings, client) - try: - response = call(direct) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - return _raw_yandex_result(service, method, response) - - -@app.get( - "/yandex/campaigns/{campaign_id}/bids", - response_model=YandexRawResult, - deprecated=True, -) -def yandex_bids( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "bids", "get", lambda c: c.bids_get(campaign_id)) - - -@app.get( - "/yandex/campaigns/{campaign_id}/keyword-bids", - response_model=KeywordBidsGetResult, - responses=YANDEX_DIRECT_ERROR_RESPONSES, -) -def yandex_keyword_bids_get( - campaign_id: str, - ad_group_ids: list[int] | None = Query(default=None), - keyword_ids: list[int] | None = Query(default=None), - serving_statuses: list[str] | None = Query(default=None), - limit: int = 1000, - offset: int = 0, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> KeywordBidsGetResult: - """Read typed current bids through v5 ``keywordbids.get``. - - The legacy ``/bids`` route remains available for its raw ``bids.get`` - compatibility envelope. This route accepts only controlled selectors and - maps keyword/autotargeting identity via the existing ``keywords.get`` read. - """ - - direct = _require_yandex_read_client(settings, client) - try: - return store.yandex_keyword_bids_get( - campaign_id, - client=direct, - ad_group_ids=ad_group_ids, - keyword_ids=keyword_ids, - serving_statuses=serving_statuses, - limit=limit, - offset=offset, - ) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - - -@app.get("/yandex/changes/check", response_model=YandexRawResult) -def yandex_changes_check( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "changes", "check", lambda c: c.changes_check()) - - -@app.get("/yandex/changes", response_model=YandexRawResult) -def yandex_changes_get( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "changes", "get", lambda c: c.changes_get()) - - -@app.get("/yandex/dictionaries", response_model=YandexRawResult) -def yandex_dictionaries( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "dictionaries", "get", lambda c: c.dictionaries_get()) - - -def _safe_int(value: Any) -> int | None: - if isinstance(value, int): - return value - if isinstance(value, str) and value.isdigit(): - return int(value) - return None - - -def _extract_bid_modifier_items(result_payload: Any) -> list[dict[str, Any]]: - if not isinstance(result_payload, dict): - return [] - raw_items = result_payload.get("BidModifiers") or result_payload.get("Items") or [] - return [item for item in raw_items if isinstance(item, dict)] - - -def _infer_bid_modifier_type(item: dict[str, Any]) -> str: - for key in ( - "Type", - "BidModifierType", - "Level", - "Demographics", - "MobileAdjustment", - "DesktopAdjustment", - "RetargetingAdjustment", - "RegionalAdjustment", - "VideoAdjustment", - "SmartAdAdjustment", - "SerpLayoutAdjustment", - "WeatherAdjustment", - "Weather", - ): - value = item.get(key) - if key in item and isinstance(value, str) and value: - return value - if key in item and isinstance(value, dict): - return key.replace("Adjustment", "").upper() - return "UNKNOWN" - - -def _bid_modifier_conditions(item: dict[str, Any]) -> dict[str, Any]: - common = {"Id", "CampaignId", "AdGroupId", "BidModifier", "Type", "BidModifierType", "Level"} - return {key: value for key, value in item.items() if key not in common} - - -def _normalize_bid_modifier_item(item: dict[str, Any]) -> YandexBidModifierItem: - bid_modifier = item.get("BidModifier") - bid_modifier_int = _safe_int(bid_modifier) if bid_modifier is not None else None - return YandexBidModifierItem( - id=_safe_int(item.get("Id")) if item.get("Id") is not None else None, - campaign_id=_safe_int(item.get("CampaignId")) if item.get("CampaignId") is not None else None, - type=_infer_bid_modifier_type(item), - bid_modifier=bid_modifier_int, - adjustment_percent=bid_modifier_int - 100 if bid_modifier_int is not None else None, - conditions=_bid_modifier_conditions(item), - raw=item, - ) - - -@app.get("/yandex/campaigns/{campaign_id}/bid-modifiers", response_model=YandexBidModifiersReadResult) -def yandex_bid_modifiers( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexBidModifiersReadResult: - if not _is_live_read_mode(settings): - return YandexBidModifiersReadResult(campaign_id=campaign_id, source="mock", items=[]) - direct = _require_yandex_read_client(settings, client) - try: - response = direct.bidmodifiers_get(campaign_id) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": f"Yandex Direct rejected bidmodifiers.get: error_code={err.get('error_code')!r}", - }, - ) - result = response.get("result") or {} - items = [_normalize_bid_modifier_item(item) for item in _extract_bid_modifier_items(result)] - return YandexBidModifiersReadResult( - campaign_id=campaign_id, - source="yandex", - read_only=True, - items=items, - raw=result if isinstance(result, dict) else None, - ) - - -@app.post( - "/yandex/campaigns/{campaign_id}/bid-modifiers/create", - response_model=BidModifiersCreateResult, - responses={ - 409: { - "description": "Safety gate or idempotency conflict for bid modifier create.", - }, - 502: { - "description": "Upstream Yandex Direct bidmodifiers.add / readback failure, with redacted diagnostics only.", - }, - }, -) -def yandex_bid_modifiers_create( - campaign_id: str, - payload: BidModifiersCreateRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> BidModifiersCreateResult: - for item in payload.items: - if item.campaign_id is not None and str(item.campaign_id) != str(campaign_id): - raise HTTPException( - status_code=409, - detail="CampaignId in bid modifier payload must match path campaign_id", - ) - if not payload.approved: - raise HTTPException( - status_code=409, - detail="Action requires explicit approval before bid modifiers create", - ) - if not payload.idempotency_key: - raise HTTPException( - status_code=409, - detail="idempotency_key is required before bid modifiers create", - ) - if not payload.dry_run and settings.directpilot_mode != "live_write": - raise HTTPException( - status_code=409, - detail=( - f"Live writes require DIRECTPILOT_MODE=live_write; " - f"current mode is {settings.directpilot_mode!r}; " - f"bid modifiers create is not allowed in this mode " - f"(dry_run=True is the only allowed path)" - ), - ) - try: - return store.yandex_bid_modifiers_create( - campaign_id, payload, settings=settings, client=client - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - diagnostics = exc.diagnostics or {} - detail: dict[str, Any] = { - "error_type": "YandexDirectError", - "message": str(exc), - } - if "error_code" in diagnostics: - detail["error_code"] = diagnostics["error_code"] - if "error_detail" in diagnostics: - detail["error_detail"] = diagnostics["error_detail"] - if "payload_preview" in diagnostics: - detail["payload_preview"] = diagnostics["payload_preview"] - raise HTTPException(status_code=502, detail=detail) from exc - except Exception as exc: - try: - store.append_audit( - "yandex_bid_modifiers_create_failed", - campaign_id, - dry_run=payload.dry_run, - details={ - "campaign_id": campaign_id, - "approved": payload.approved, - "idempotency_key": payload.idempotency_key, - "endpoint_safety_net": True, - "yandex_error": ( - f"unexpected error in bid modifiers create endpoint: " - f"{type(exc).__name__}: {exc}" - ), - "exception_type": type(exc).__name__, - }, - ) - except Exception: - pass - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"unexpected error during bid modifiers create: " - f"{type(exc).__name__}" - ), - }, - ) from exc - - -@app.get("/yandex/campaigns/{campaign_id}/negative-keywords", response_model=YandexRawResult) -def yandex_negative_keywords( - campaign_id: str, - ids: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read( - settings, - client, - "negativekeywordsharedsets", - "get", - lambda c: c.negativekeywords_get(campaign_id, _csv_ints(ids)), - ) - - -@app.get("/yandex/retargeting-lists", response_model=YandexRawResult) -def yandex_retargeting_lists( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "retargetinglists", "get", lambda c: c.retargetinglists_get()) - - -@app.get("/yandex/campaigns/{campaign_id}/audience-targets", response_model=YandexRawResult) -def yandex_audience_targets( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "audiencetargets", "get", lambda c: c.audiencetargets_get(campaign_id)) - - -@app.get("/yandex/sitelinks", response_model=YandexRawResult) -def yandex_sitelinks( - ids: list[int] | None = Query(default=None), - limit: int | None = Query(default=None), - offset: int | None = Query(default=None), - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "sitelinks", "get", lambda c: c.sitelinks_get(ids=ids, limit=limit, offset=offset)) - - -@app.get( - "/yandex/campaigns/{campaign_id}/ad-assets", - response_model=YandexAdAssetsResult, -) -def yandex_campaign_ad_assets( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexAdAssetsResult: - """Read-only campaign ad-assets aggregator for marketing/audit. - - Returns ads with extended TextAd fields, resolved sitelink sets, - businesses, vcards, and callouts (not yet implemented). - No writes — only get/read methods. - """ - if _is_live_read_mode(settings): - if client is None: - raise HTTPException( - status_code=409, - detail="This endpoint requires sandbox, live_readonly, or live_write mode with Yandex credentials", - ) - direct = client - try: - ads_response = direct.ads_get_detailed(campaign_id) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - - if not ads_response.get("ok"): - err = ads_response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"Yandex Direct rejected ads.get: " - f"error_code={err.get('error_code')!r}" - ), - }, - ) - - raw_ads = (ads_response.get("result") or {}).get("Ads") or [] - - # Extract ads with extended fields - ads: list[YandexAdAssetItem] = [] - sitelink_set_ids: set[int] = set() - business_ids: set[int] = set() - vcard_ids: set[int] = set() - - for a in raw_ads: - if not isinstance(a, dict): - continue - text_ad = a.get("TextAd") or {} - sl_set_id = text_ad.get("SitelinkSetId") - biz_id = text_ad.get("BusinessId") - vc_id = text_ad.get("VCardId") - - ad_item = YandexAdAssetItem( - id=str(a.get("Id") or ""), - ad_group_id=str(a.get("AdGroupId") or ""), - campaign_id=str(a.get("CampaignId") or campaign_id), - status=str(a.get("Status") or "UNKNOWN"), - state=str(a.get("State") or "UNKNOWN"), - type=str(a.get("Type") or "TEXT_AD"), - title=str(text_ad.get("Title") or ""), - title2=text_ad.get("Title2") if isinstance(text_ad.get("Title2"), str) else None, - text=str(text_ad.get("Text") or ""), - href=str(text_ad.get("Href") or ""), - display_url_path=text_ad.get("DisplayUrlPath") if isinstance(text_ad.get("DisplayUrlPath"), str) else None, - sitelink_set_id=str(sl_set_id) if sl_set_id is not None else None, - business_id=str(biz_id) if biz_id is not None else None, - vcard_id=str(vc_id) if vc_id is not None else None, - prefer_vcard_over_business=text_ad.get("PreferVCardOverBusiness") if isinstance(text_ad.get("PreferVCardOverBusiness"), str) else None, - ad_extension_ids=text_ad.get("AdExtensions") if isinstance(text_ad.get("AdExtensions"), list) else None, - ) - ads.append(ad_item) - - if isinstance(sl_set_id, int): - sitelink_set_ids.add(sl_set_id) - if isinstance(biz_id, int): - business_ids.add(biz_id) - if isinstance(vc_id, int): - vcard_ids.add(vc_id) - - # Resolve sitelinks - sitelinks_sets: list[YandexSitelinkSetItem] = [] - if sitelink_set_ids: - try: - sl_response = direct.sitelinks_get(ids=sorted(sitelink_set_ids)) - except YandexDirectError: - sl_response = None - if sl_response and sl_response.get("ok"): - sl_result = sl_response.get("result") or {} - for sl_set in sl_result.get("SitelinksSets") or []: - sl_items = [ - YandexSitelinkItem( - title=str(s.get("Title") or ""), - href=s.get("Href") if isinstance(s.get("Href"), str) else None, - description=s.get("Description") if isinstance(s.get("Description"), str) else None, - ) - for s in (sl_set.get("Sitelinks") or []) - if isinstance(s, dict) - ] - sitelinks_sets.append( - YandexSitelinkSetItem( - id=str(sl_set.get("Id") or ""), - sitelinks=sl_items, - ) - ) - - # Resolve businesses - businesses: list[YandexBusinessAssetItem] = [] - if business_ids: - try: - biz_response = direct.businesses_get() - except YandexDirectError: - biz_response = None - if biz_response and biz_response.get("ok"): - biz_result = biz_response.get("result") or {} - for b in biz_result.get("Businesses") or []: - if not isinstance(b, dict): - continue - b_id = b.get("Id") - if b_id in business_ids: - businesses.append( - YandexBusinessAssetItem( - id=str(b_id), - name=str(b.get("Name") or ""), - address=b.get("Address") if isinstance(b.get("Address"), str) else None, - ) - ) - - # Resolve vcards - vcards: list[YandexVCardAssetItem] = [] - if vcard_ids: - try: - vc_response = direct.vcards_get() - except YandexDirectError: - vc_response = None - if vc_response and vc_response.get("ok"): - vc_result = vc_response.get("result") or {} - for v in vc_result.get("VCards") or []: - if not isinstance(v, dict): - continue - v_id = v.get("Id") - if v_id in vcard_ids: - phone_raw = v.get("Phone") - phone_str: str | None = None - if isinstance(phone_raw, dict): - parts = [ - str(phone_raw.get("CountryCode") or ""), - str(phone_raw.get("CityCode") or ""), - str(phone_raw.get("PhoneNumber") or ""), - ] - phone_str = " ".join(p for p in parts if p) or None - vcards.append( - YandexVCardAssetItem( - id=str(v_id), - company_name=str(v.get("CompanyName") or ""), - phone=phone_str, - ) - ) - - return YandexAdAssetsResult( - campaign_id=campaign_id, - source="yandex", - read_only=True, - ads=ads, - sitelinks_sets=sitelinks_sets, - businesses=businesses, - vcards=vcards, - callouts=[], - missing=YandexAdAssetsMissing(), - ) - - # Mock mode - mock_ads = mock_yandex.list_ads(campaign_id) - ad_items = [ - YandexAdAssetItem( - id=a.get("id", ""), - ad_group_id=a.get("ad_group_id", ""), - campaign_id=a.get("campaign_id", campaign_id), - status=a.get("status", "active"), - state="ON", - type="TEXT_AD", - title=a.get("title", ""), - text="", - href="", - ) - for a in mock_ads - ] - return YandexAdAssetsResult( - campaign_id=campaign_id, - source="mock", - read_only=True, - ads=ad_items, - sitelinks_sets=[], - businesses=[], - vcards=[], - callouts=[], - ) - - -@app.get("/yandex/vcards", response_model=YandexRawResult) -def yandex_vcards( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "vcards", "get", lambda c: c.vcards_get()) - - -@app.post("/yandex/vcards", response_model=YandexVCardResult) -def yandex_vcards_add( - payload: YandexVCardRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexVCardResult: - try: - return store.yandex_vcard_add(payload, settings=settings, client=client) - except ValueError as exc: - raise HTTPException(status_code=403, detail=str(exc)) from exc - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - - -@app.post( - "/yandex/campaigns/{campaign_id}/ads/landing-urls", - response_model=UrlMigrationResult, - responses={ - 409: {"description": "Safety gate, optimistic-concurrency, or idempotency conflict."}, - 502: {"description": "Redacted Yandex Direct preflight failure."}, - }, -) -def yandex_ads_landing_urls( - campaign_id: str, - payload: LandingUrlMigrationRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> UrlMigrationResult: - try: - return store.yandex_ads_landing_urls( - campaign_id, payload, settings=settings, client=client - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - if "Live writes require" in str(exc): - raise HTTPException(status_code=409, detail=str(exc)) from exc - raise _yandex_error_to_502(exc) from exc - - -@app.post( - "/yandex/campaigns/{campaign_id}/sitelinks/migrate-urls", - response_model=UrlMigrationResult, - responses={ - 409: {"description": "Safety gate, source mismatch, or idempotency conflict."}, - 502: {"description": "Redacted Yandex Direct preflight failure."}, - }, -) -def yandex_sitelinks_migrate_urls( - campaign_id: str, - payload: SitelinkUrlMigrationRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> UrlMigrationResult: - try: - return store.yandex_sitelinks_migrate_urls( - campaign_id, payload, settings=settings, client=client - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - if "Live writes require" in str(exc): - raise HTTPException(status_code=409, detail=str(exc)) from exc - raise _yandex_error_to_502(exc) from exc - - -@app.post( - "/yandex/campaigns/{campaign_id}/landing-url-migrations", - response_model=UrlMigrationResult, - responses={ - 409: {"description": "Safety gate, optimistic-concurrency, or idempotency conflict."}, - 502: {"description": "Redacted Yandex Direct preflight failure."}, - }, -) -def yandex_landing_url_migrations( - campaign_id: str, - payload: LandingUrlMigrationsRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> UrlMigrationResult: - try: - return store.yandex_landing_url_migrations( - campaign_id, payload, settings=settings, client=client - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - if "Live writes require" in str(exc): - raise HTTPException(status_code=409, detail=str(exc)) from exc - raise _yandex_error_to_502(exc) from exc - - -@app.post("/yandex/ads/business", response_model=YandexAdsBusinessAttachResult) -def yandex_ads_business_attach( - payload: YandexAdsBusinessAttachRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexAdsBusinessAttachResult: - try: - return store.yandex_ads_business_attach(payload, settings=settings, client=client) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - message = str(exc) - if "Live writes require" in message or "live_readonly" in message: - raise HTTPException(status_code=409, detail=message) from exc - raise _yandex_error_to_502(exc) from exc - - -@app.post( - "/yandex/campaigns/{campaign_id}/ad-groups/{ad_group_id}/negative-keywords", - response_model=YandexAdGroupNegativeKeywordsResult, -) -def yandex_ad_group_negative_keywords_update( - campaign_id: str, - ad_group_id: str, - payload: YandexAdGroupNegativeKeywordsRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexAdGroupNegativeKeywordsResult: - _require_live_write_for_apply(settings, dry_run=payload.dry_run, approved=payload.approved) - requested = _normalize_negative_keywords(payload.negative_keywords) - if not requested: - raise HTTPException(status_code=422, detail="negative_keywords must contain at least one non-empty item") - - current: list[str] = [] - if client is not None and _is_live_read_mode(settings): - try: - response = client.adgroups_get(campaign_id) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - raise _yandex_business_error_to_502(response, "adgroups.get") - for group in (response.get("result") or {}).get("AdGroups") or []: - if str(group.get("Id")) == str(ad_group_id): - current = _negative_keywords_from_adgroup(group) - break - merged = _normalize_negative_keywords(current + requested) if payload.operation == "add" else requested - update_item = { - "Id": int(ad_group_id) if str(ad_group_id).isdigit() else ad_group_id, - "NegativeKeywords": {"Items": merged}, - } - preview = {"method": "adgroups.update", "params": {"AdGroups": [update_item]}} - audit = store.append_audit( - "yandex_ad_group_negative_keywords_preview" if payload.dry_run else "yandex_ad_group_negative_keywords_apply", - str(ad_group_id), - dry_run=payload.dry_run, - details={"campaign_id": campaign_id, "operation": payload.operation}, - ) - if payload.dry_run: - return YandexAdGroupNegativeKeywordsResult( - dry_run=True, - applied=False, - source="yandex" if _is_live_read_mode(settings) else "mock", - mode=settings.directpilot_mode, - audit_id=audit.id, - campaign_id=campaign_id, - ad_group_id=ad_group_id, - operation=payload.operation, - negative_keywords=merged, - previous_negative_keywords=current, - payload_preview=preview, - ) - if payload.idempotency_key in store.apply_results_by_key: - return store.apply_results_by_key[payload.idempotency_key] - if client is None: - raise HTTPException(status_code=409, detail="Yandex client is required for live_write apply") - try: - response = client.adgroups_update([update_item]) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - raise _yandex_business_error_to_502(response, "negative keyword update") - result = YandexAdGroupNegativeKeywordsResult( - dry_run=False, - applied=True, - source="yandex", - mode=settings.directpilot_mode, - audit_id=audit.id, - campaign_id=campaign_id, - ad_group_id=ad_group_id, - operation=payload.operation, - negative_keywords=merged, - previous_negative_keywords=current, - provider_response=response.get("result") if response.get("ok") else response, - ) - store.apply_results_by_key[payload.idempotency_key] = result - return result - - -@app.post( - "/yandex/campaigns/{campaign_id}/ad-groups", - response_model=LiveAdGroupCreateResult, -) -def yandex_campaign_ad_group_create( - campaign_id: str, - payload: LiveAdGroupCreateRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> LiveAdGroupCreateResult: - _require_live_write_for_apply(settings, dry_run=payload.dry_run, approved=payload.approved) - ad_group = { - "Name": payload.name, - "CampaignId": int(campaign_id) if str(campaign_id).isdigit() else campaign_id, - "RegionIds": payload.region_ids, - } - negatives = _normalize_negative_keywords(payload.negative_keywords) - if negatives: - ad_group["NegativeKeywords"] = {"Items": negatives} - preview = {"method": "adgroups.add", "params": {"AdGroups": [ad_group]}} - audit = store.append_audit( - "yandex_ad_group_create_preview" if payload.dry_run else "yandex_ad_group_create_apply", - str(campaign_id), - dry_run=payload.dry_run, - details={"name": payload.name}, - ) - if payload.dry_run: - return LiveAdGroupCreateResult( - dry_run=True, - applied=False, - source="yandex" if _is_live_read_mode(settings) else "mock", - mode=settings.directpilot_mode, - audit_id=audit.id, - campaign_id=campaign_id, - payload_preview=preview, - warnings=["Creates only an ad group; ads, keywords, and moderation are separate next steps."], - ) - if payload.idempotency_key in store.apply_results_by_key: - return store.apply_results_by_key[payload.idempotency_key] - if client is None: - raise HTTPException(status_code=409, detail="Yandex client is required for live_write apply") - try: - response = client.adgroups_add([ad_group]) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - raise _yandex_business_error_to_502(response, "ad-group create") - add_results = [] - ad_group_ids: list[int] = [] - if response.get("ok"): - add_results = (response.get("result") or {}).get("AddResults") or [] - for item in add_results: - if isinstance(item, dict) and item.get("Id") is not None: - ad_group_ids.append(int(item["Id"])) - result = LiveAdGroupCreateResult( - dry_run=False, - applied=True, - source="yandex", - mode=settings.directpilot_mode, - audit_id=audit.id, - campaign_id=campaign_id, - ad_group_ids=ad_group_ids, - add_results=add_results, - provider_response=response.get("result") if response.get("ok") else response, - warnings=["Creates only an ad group; ads, keywords, and moderation are separate next steps."], - ) - store.apply_results_by_key[payload.idempotency_key] = result - return result - - -@app.post( - "/yandex/ad-groups/{ad_group_id}/ads", - response_model=LiveAdCreateResult, -) -def yandex_ad_group_ads_add( - ad_group_id: str, - payload: LiveAdCreateRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> LiveAdCreateResult: - try: - return store.yandex_ad_group_ads_add( - ad_group_id, payload, settings=settings, client=client - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - message = str(exc) - if "Live writes require" in message or "live_readonly" in message: - raise HTTPException(status_code=409, detail=message) from exc - raise _yandex_error_to_502(exc) from exc - - -@app.post("/yandex/ads/moderate", response_model=AdsModerateResult) -def yandex_ads_moderate( - payload: AdsModerateRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> AdsModerateResult: - try: - return store.yandex_ads_moderate(payload, settings=settings, client=client) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - message = str(exc) - if "Live writes require" in message or "live_readonly" in message: - raise HTTPException(status_code=409, detail=message) from exc - raise _yandex_error_to_502(exc) from exc - - -@app.get("/yandex/ad-images", response_model=YandexRawResult) -def yandex_ad_images( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "adimages", "get", lambda c: c.adimages_get()) - - -@app.get("/yandex/creatives", response_model=YandexRawResult) -def yandex_creatives( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "creatives", "get", lambda c: c.creatives_get()) - - -@app.get("/yandex/feeds", response_model=YandexRawResult) -def yandex_feeds( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "feeds", "get", lambda c: c.feeds_get()) - - -@app.get("/yandex/businesses", response_model=YandexRawResult) -def yandex_businesses( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "businesses", "get", lambda c: c.businesses_get()) - - -@app.get("/yandex/agency-clients", response_model=YandexRawResult) -def yandex_agency_clients( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read(settings, client, "agencyclients", "get", lambda c: c.agencyclients_get()) - - -def _csv_items(value: str) -> list[str]: - return [item.strip() for item in value.split(",") if item.strip()] - - -def _csv_ints(value: str) -> list[int]: - return [int(item.strip()) for item in value.split(",") if item.strip()] - - -@app.get("/yandex/keywords-research/has-search-volume", response_model=YandexRawResult) -def yandex_keywords_has_search_volume( - keywords: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read( - settings, - client, - "keywordsresearch", - "hasSearchVolume", - lambda c: c.keywordsresearch_has_search_volume(_csv_items(keywords)), - ) - - -@app.get("/yandex/keywords-research/deduplicate", response_model=YandexRawResult) -def yandex_keywords_deduplicate( - keywords: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read( - settings, - client, - "keywordsresearch", - "deduplicate", - lambda c: c.keywordsresearch_deduplicate(_csv_items(keywords)), - ) - - -@app.get( - "/yandex/keywords-research/wordstat/create", - response_model=YandexRawResult, - responses=YANDEX_DIRECT_ERROR_RESPONSES, - deprecated=True, -) -def yandex_wordstat_create( - phrases: str, - geo_ids: str = "213", - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read( - settings, - client, - "keywordsresearch", - "createNewWordstatReport", - lambda c: c.keywordsresearch_create_wordstat_report(_csv_items(phrases), _csv_ints(geo_ids)), - ) - - -@app.get( - "/yandex/keywords-research/wordstat/{report_id}", - response_model=YandexRawResult, - responses=YANDEX_DIRECT_ERROR_RESPONSES, - deprecated=True, -) -def yandex_wordstat_get( - report_id: int, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read( - settings, - client, - "keywordsresearch", - "getWordstatReport", - lambda c: c.keywordsresearch_get_wordstat_report(report_id), - ) - - -@app.delete( - "/yandex/keywords-research/wordstat/{report_id}", - response_model=YandexRawResult, - responses=YANDEX_DIRECT_ERROR_RESPONSES, - deprecated=True, -) -def yandex_wordstat_delete( - report_id: int, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read( - settings, - client, - "keywordsresearch", - "deleteWordstatReport", - lambda c: c.keywordsresearch_delete_wordstat_report(report_id), - ) - - -@app.get("/yandex/reports/live/{report_type}", response_model=YandexRawResult) -def yandex_report( - report_type: str, - date_from: str, - date_to: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read( - settings, - client, - "reports", - report_type, - lambda c: c.report(report_type, date_from=date_from, date_to=date_to), - ) - - -@app.get("/yandex/reports/search-queries-live", response_model=YandexRawResult) -def yandex_search_queries_live( - date_from: str, - date_to: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexRawResult: - return _call_raw_read( - settings, - client, - "reports", - "SEARCH_QUERY_PERFORMANCE_REPORT", - lambda c: c.report( - "SEARCH_QUERY_PERFORMANCE_REPORT", - date_from=date_from, - date_to=date_to, - field_names=list(_SEARCH_QUERY_REPORT_FIELDS), - ), - ) - -@app.get( - "/yandex/reports/summary", - response_model=ReportSummary, - responses={ - 409: {"description": "Non-mock mode requires configured Yandex credentials/client."}, - 502: {"description": "Redacted Yandex Direct Reports API error."}, - }, -) -def yandex_reports_summary( - date_from: str | None = Query(default=None, description="ISO date (YYYY-MM-DD). Defaults to 7 days ago."), - date_to: str | None = Query(default=None, description="ISO date (YYYY-MM-DD). Defaults to today."), - campaign_id: str | None = Query(default=None, description="Optional Yandex Direct campaign id filter."), - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> ReportSummary: - """Live read-only summary from CAMPAIGN_PERFORMANCE_REPORT. - - In non-mock modes (``sandbox`` / ``live_readonly`` / ``live_write``) - with an available Yandex Direct client this calls the v5 reports - endpoint, parses the TSV response, and aggregates spend/clicks/ - impressions across all returned rows. ``period`` reflects the - requested date range (or the default 7-day window). ``source`` is - ``"yandex"`` and ``read_only`` is ``True``. - - The mock payload is the fallback ONLY for ``DIRECTPILOT_MODE=mock`` - or when no Yandex client/token is available. Live mode without a - usable client surfaces HTTP 409 (the same contract used by other - read-only endpoints), not a silent mock — marketing must not - mistake mock numbers for live numbers. - """ - # Fallback path: mock mode, or live mode but no client/token. - if settings.directpilot_mode == "mock" or client is None: - if settings.directpilot_mode != "mock": - # Live read mode without a usable client — be explicit - # rather than silently returning mock data. - raise HTTPException( - status_code=409, - detail=( - "This endpoint requires sandbox, live_readonly, or live_write " - "mode with Yandex credentials" - ), - ) - data = mock_yandex.report_summary() - return ReportSummary(**data, source="mock") - - # Live read-only path: real CAMPAIGN_PERFORMANCE_REPORT, parsed. - today = date.today() - if date_to is None: - date_to = today.isoformat() - if date_from is None: - date_from = (today - timedelta(days=6)).isoformat() - - period = f"{date_from}..{date_to}" - - try: - report_kwargs: dict[str, Any] = { - "date_from": date_from, - "date_to": date_to, - } - if campaign_id is not None: - report_kwargs["campaign_ids"] = [campaign_id] - response = client.report("CAMPAIGN_PERFORMANCE_REPORT", **report_kwargs) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"Yandex Direct rejected reports: error_code=" - f"{err.get('error_code')!r}" - ), - }, - ) - - # The client returns the raw TSV text in ``result``. NEVER log it - # (it contains customer campaign data); parse and aggregate. - tsv_text = response.get("result") or "" - totals = _aggregate_campaign_performance_tsv(tsv_text, campaign_id=campaign_id) - - impressions = totals["impressions"] - clicks = totals["clicks"] - spend = totals["spend"] - # CTR / CPC are recomputed from totals so the response is consistent - # with the v5 column values, regardless of how Yandex formatted them. - ctr = (clicks / impressions * 100.0) if impressions else 0.0 - cpc = (spend / clicks) if clicks else 0.0 - - return ReportSummary( - period=period, - spend=spend, - clicks=int(totals["clicks"]), - impressions=int(totals["impressions"]), - ctr=round(ctr, 4), - cpc=round(cpc, 4), - conversions=None, - cpa=None, - source="yandex", - read_only=True, - ) - - -def _aggregate_campaign_performance_tsv( - tsv_text: str, campaign_id: str | None = None -) -> dict[str, float | int]: - """Aggregate a CAMPAIGN_PERFORMANCE_REPORT TSV into totals. - - Expected column order (matches ``YandexDirectClient.report`` defaults): - Date, CampaignId, CampaignName, Impressions, Clicks, Cost, Ctr - - Returns a dict with ``impressions``, ``clicks``, ``spend`` summed - across the rows. Rows whose ``CampaignId`` does not match an - optional ``campaign_id`` filter are dropped. Malformed rows are - skipped silently (the endpoint surfaces 502 only on transport / - envelope errors, not on per-row parse noise). - """ - impressions = 0 - clicks = 0 - spend = 0.0 - seen = False - for raw_line in tsv_text.splitlines(): - line = raw_line.strip() - if not line: - continue - cols = line.split("\t") - # Need at least Date, CampaignId, ..., Impressions, Clicks, Cost - # i.e. index 5 (Cost) reachable. CTR is index 6 — we ignore it - # and recompute CTR/CPC from totals instead. - if len(cols) < 6: - continue - # Skip the header row (TSV first line repeats the field names). - if cols[0].lower() == "date": - continue - if campaign_id is not None and cols[1] != campaign_id: - continue - try: - impressions += int(cols[3]) - clicks += int(cols[4]) - spend += float(cols[5]) - except ValueError: - # Malformed numeric — skip the row, do not raise. - continue - seen = True - # If we got a report body but nothing matched the filter, return zeros - # rather than 502 — the report is valid, it just has no rows for the - # requested campaign / period. - if not seen and not tsv_text.strip(): - return {"impressions": 0, "clicks": 0, "spend": 0.0} - return {"impressions": impressions, "clicks": clicks, "spend": spend} - - -# Default field set for SEARCH_QUERY_PERFORMANCE_REPORT. The order matches -# what we request from Yandex and what the parser expects by default: -# Query, CampaignId, AdGroupId, Impressions, Clicks, Ctr, Cost. -_SEARCH_QUERY_REPORT_FIELDS: tuple[str, ...] = ( - "Query", - "CampaignId", - "AdGroupId", - "Impressions", - "Clicks", - "Ctr", - "Cost", -) - - -def _normalize_direct_id(value: str | None) -> str | None: - if value is None: - return None - normalized = value.strip() - try: - return str(int(normalized)) - except ValueError: - return normalized - - -def _find_search_query_column( - column_name: str, - header_map: dict[str, int] | None, - fallback_index: int, -) -> int: - if header_map and column_name in header_map: - return header_map[column_name] - return fallback_index - - -_DIRECT_REPORT_DOT_DECIMAL = re.compile(r"[0-9]+(?:\.[0-9]+)?") - - -def _parse_yandex_report_number( - value: str, - *, - max_fractional_digits: int | None = None, -) -> float: - """Parse a Direct-owned ASCII dot-decimal report token.""" - if _DIRECT_REPORT_DOT_DECIMAL.fullmatch(value) is None: - raise ValueError("Direct report number must be an ASCII dot-decimal token") - if max_fractional_digits is not None and "." in value: - fractional_digits = len(value.rsplit(".", maxsplit=1)[1]) - if fractional_digits > max_fractional_digits: - raise ValueError("Direct report number has too many fractional digits") - return float(value) - - -def _lookup_search_query_campaign_names( - client: YandexDirectClient, - campaign_ids: set[str], -) -> dict[str, str]: - """Map requested campaign ids to names using ``campaigns.get``. - - Network failures here must never fail report parsing in the happy path, - so callers should treat an empty mapping as a non-blocking fallback. - """ - try: - campaigns_result = client.campaigns_get() - except YandexDirectError: - return {} - if not campaigns_result.get("ok"): - return {} - - result_payload = campaigns_result.get("result") - if not isinstance(result_payload, dict): - return {} - - names_by_id: dict[str, str] = {} - for campaign in _extract_campaigns(result_payload): - campaign_id = campaign.get("id") - if not isinstance(campaign_id, str): - continue - campaign_name = campaign.get("name") - if campaign_id in campaign_ids and isinstance(campaign_name, str) and campaign_name: - names_by_id[campaign_id] = campaign_name - return names_by_id - - -def _aggregate_search_query_tsv( - tsv_text: str, - campaign_id: str | None = None, - campaign_name_map: dict[str, str] | None = None, -) -> list[YandexSearchQuery]: - """Parse a SEARCH_QUERY_PERFORMANCE_REPORT TSV into YandexSearchQuery items. - - Expected input is the default field order requested from Yandex - (Query, CampaignId, AdGroupId, Impressions, Clicks, Ctr, Cost), - but the parser is tolerant of legacy payloads that include CampaignName. - - Rows whose ``CampaignId`` does not match the optional ``campaign_id`` filter - are dropped. Numeric parse errors on metric columns do not fail the endpoint; - malformed rows are skipped silently, while empty input remains a valid - response with ``items=[]``. - """ - normalized_filter = _normalize_direct_id(campaign_id) - - rows = [line for line in tsv_text.splitlines() if line.strip()] - if not rows: - return [] - - header_map: dict[str, int] | None = None - first_columns = rows[0].split("\t") - if first_columns and first_columns[0].lower() == "query": - header_map = {name: idx for idx, name in enumerate(first_columns) if name} - rows = rows[1:] - - items: list[YandexSearchQuery] = [] - for cols in [row.split("\t") for row in rows]: - query_idx = _find_search_query_column("Query", header_map, 0) - campaign_id_idx = _find_search_query_column("CampaignId", header_map, 1) - ad_group_id_idx = _find_search_query_column("AdGroupId", header_map, 2) - impressions_idx = _find_search_query_column("Impressions", header_map, 3) - clicks_idx = _find_search_query_column("Clicks", header_map, 4) - ctr_idx = _find_search_query_column("Ctr", header_map, 5) - cost_idx = _find_search_query_column("Cost", header_map, 6) - campaign_name_idx = _find_search_query_column("CampaignName", header_map, -1) - - if len(cols) <= max(campaign_id_idx, ad_group_id_idx, impressions_idx, clicks_idx, ctr_idx): - continue - row_campaign_id = _normalize_direct_id(cols[campaign_id_idx]) - if row_campaign_id is None: - row_campaign_id = cols[campaign_id_idx] - if normalized_filter is not None and row_campaign_id != normalized_filter: - continue - - try: - impressions = int(cols[impressions_idx]) - clicks = int(cols[clicks_idx]) - ctr = _parse_yandex_report_number(cols[ctr_idx]) - except (IndexError, ValueError): - continue - - campaign_name = cols[campaign_name_idx] if campaign_name_idx >= 0 and len(cols) > campaign_name_idx else None - if not campaign_name and campaign_name_map is not None: - campaign_name = campaign_name_map.get(_normalize_direct_id(row_campaign_id) or row_campaign_id) - - cost: float | None = None - if len(cols) > cost_idx: - cost_text = cols[cost_idx] - if cost_text: - try: - cost = _parse_yandex_report_number(cost_text, max_fractional_digits=2) - except ValueError: - cost = None - - items.append( - YandexSearchQuery( - query=cols[query_idx], - campaign_id=row_campaign_id, - campaign_name=campaign_name, - ad_group_id=cols[ad_group_id_idx], - impressions=impressions, - clicks=clicks, - ctr=round(ctr, 4), - cost=cost, - ) - ) - return items - - -@app.get( - "/yandex/reports/search-queries", - response_model=YandexSearchQueriesReport, - responses={ - 409: {"description": "Non-mock mode requires configured Yandex credentials/client."}, - 502: {"description": "Redacted Yandex Direct Reports API error."}, - }, -) -def yandex_search_queries( - date_from: str | None = Query(default=None, description="ISO date (YYYY-MM-DD). Defaults to 7 days ago."), - date_to: str | None = Query(default=None, description="ISO date (YYYY-MM-DD). Defaults to today."), - campaign_id: str | None = Query(default=None, description="Optional Yandex Direct campaign id filter."), - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexSearchQueriesReport: - """Search query performance for the requested period. - - In ``DIRECTPILOT_MODE=mock`` the deterministic mock payload is returned - (with ``source="mock"``). In any non-mock mode (``sandbox`` / - ``live_readonly`` / ``live_write``) with a configured Yandex Direct - client, the live ``SEARCH_QUERY_PERFORMANCE_REPORT`` v5 reports - endpoint is called and the TSV is parsed into YandexSearchQuery items - with ``source="yandex"``, ``read_only=True``. An empty live report is - a valid response — it returns ``items=[]`` and ``source="yandex"``, - not a mock fallback and not a 502. - - When the live mode is selected but no client/token is available the - endpoint surfaces HTTP 409 (same contract as - ``/yandex/reports/summary`` and the other read-only endpoints), not - a silent mock — marketing must not mistake mock numbers for live - numbers. - """ - # Fallback path: mock mode, or live mode but no client/token. - if settings.directpilot_mode == "mock" or client is None: - if settings.directpilot_mode != "mock": - # Live read mode without a usable client — be explicit - # rather than silently returning mock data. - raise HTTPException( - status_code=409, - detail=( - "This endpoint requires sandbox, live_readonly, or live_write " - "mode with Yandex credentials" - ), - ) - items = [YandexSearchQuery(**q) for q in mock_yandex.search_queries()] - return YandexSearchQueriesReport( - period="last_7_days", - items=items, - source="mock", - read_only=True, - ) - - # Live read-only path: real SEARCH_QUERY_PERFORMANCE_REPORT, parsed. - today = date.today() - if date_to is None: - date_to = today.isoformat() - if date_from is None: - date_from = (today - timedelta(days=6)).isoformat() - - period = f"{date_from}..{date_to}" - - try: - report_kwargs: dict[str, Any] = { - "date_from": date_from, - "date_to": date_to, - "field_names": list(_SEARCH_QUERY_REPORT_FIELDS), - } - if campaign_id is not None: - report_kwargs["campaign_ids"] = [campaign_id] - response = client.report( - "SEARCH_QUERY_PERFORMANCE_REPORT", **report_kwargs - ) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"Yandex Direct rejected reports: error_code=" - f"{err.get('error_code')!r}" - ), - }, - ) - - # The client returns the raw TSV text in ``result``. NEVER log it - # (it contains customer search query data); parse and aggregate. - tsv_text = response.get("result") or "" - items = _aggregate_search_query_tsv(tsv_text, campaign_id=campaign_id) - missing_campaign_name_ids = { - item.campaign_id for item in items if not item.campaign_name - } - if missing_campaign_name_ids: - campaign_name_map = _lookup_search_query_campaign_names( - client, missing_campaign_name_ids - ) - if campaign_name_map: - enriched_items: list[YandexSearchQuery] = [] - for item in items: - if not item.campaign_name and item.campaign_id in campaign_name_map: - enriched_items.append( - item.model_copy(update={"campaign_name": campaign_name_map[item.campaign_id]}) - ) - else: - enriched_items.append(item) - items = enriched_items - - return YandexSearchQueriesReport( - period=period, - items=items, - source="yandex", - read_only=True, - ) - - -# --------------------------------------------------------------------------- -# Yandex Direct control facade (pause / resume) -# --------------------------------------------------------------------------- - - -@app.post( - "/yandex/campaigns/{campaign_id}/pause", - response_model=YandexControlResult, -) -def yandex_pause( - campaign_id: str, - payload: YandexControlRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexControlResult: - if not payload.approved: - raise HTTPException(status_code=409, detail="Action requires explicit approval") - if settings.directpilot_mode == "live_readonly" and not payload.dry_run: - raise HTTPException( - status_code=409, - detail="Live writes require DIRECTPILOT_MODE=live_write; live_readonly only allows dry_run", - ) - try: - return store.yandex_control( - campaign_id, "pause", payload, settings=settings, client=client - ) - except YandexDirectError as exc: - # Never include the OAuth token in the response. - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": str(exc), - }, - ) from exc - - -@app.post( - "/yandex/campaigns/{campaign_id}/resume", - response_model=YandexControlResult, -) -def yandex_resume( - campaign_id: str, - payload: YandexControlRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexControlResult: - if not payload.approved: - raise HTTPException(status_code=409, detail="Action requires explicit approval") - if settings.directpilot_mode == "live_readonly" and not payload.dry_run: - raise HTTPException( - status_code=409, - detail="Live writes require DIRECTPILOT_MODE=live_write; live_readonly only allows dry_run", - ) - try: - return store.yandex_control( - campaign_id, "resume", payload, settings=settings, client=client - ) - except YandexDirectError as exc: - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": str(exc), - }, - ) from exc - - -# --------------------------------------------------------------------------- -# Yandex AI Studio / Search API v2 — Wordstat -# -# This is the modern documented v2 path (https://yandex.cloud/en/services/ -# search-api) and is fully separate from the v5 keywordsresearch service -# used by the rest of the /yandex/* facade. Wordstat on v5 is not -# implemented, which is why the older keywordsresearch.wordstat.* helpers -# return UNSUPPORTED_IN_V5 envelopes. -# --------------------------------------------------------------------------- - - -def get_yandex_search_wordstat_client( - settings: Settings = Depends(get_settings), -) -> YandexSearchWordstatClient: - """Build a YandexSearchWordstatClient for the v2 Wordstat endpoints. - - The client is always created — even when no API key is configured. - Missing-key errors are raised inside the client's ``_post`` method so - the corresponding /wordstat/* endpoints can translate them into a 503 - "service not configured" response without crashing. - """ - return YandexSearchWordstatClient(settings=settings) - - -def _wordstat_error_to_503(exc: YandexSearchWordstatError) -> HTTPException: - """Translate a missing-config error into a 503 (service not configured).""" - return HTTPException( - status_code=503, - detail={ - "error_type": "YandexSearchWordstatError", - "message": str(exc), - }, - ) - - -def _wordstat_error_to_502(exc: YandexSearchWordstatError) -> HTTPException: - """Translate an upstream / transport error into a 502 with no key echo.""" - return HTTPException( - status_code=502, - detail={ - "error_type": "YandexSearchWordstatError", - "message": str(exc), - }, - ) - - -def _parse_int_list(raw: list[str] | None) -> list[int] | None: - """Parse repeated and/or CSV query params into ints. - - Supports both ``?regions=43®ions=213`` and ``?regions=43,213``. - ``None`` is returned for an empty list so callers can keep the "omit - when not provided" semantics intact. - """ - if not raw: - return None - values: list[int] = [] - for item in raw: - for part in item.split(","): - part = part.strip() - if part: - try: - values.append(int(part)) - except ValueError as exc: - raise HTTPException( - status_code=422, - detail=f"regions must contain integer ids; got {part!r}", - ) from exc - return values or None - - -def _raise_wordstat_http_error(exc: YandexSearchWordstatError) -> None: - if isinstance(exc, YandexSearchWordstatMissingKeyError): - raise _wordstat_error_to_503(exc) from exc - raise _wordstat_error_to_502(exc) from exc - - -@app.get( - "/wordstat/top", - response_model=YandexSearchApiResult, - responses=WORDSTAT_ERROR_RESPONSES, -) -def wordstat_top( - phrase: str, - regions: list[str] | None = Query(default=None), - limit: int | None = None, - devices: list[str] | None = Query(default=None), - client: YandexSearchWordstatClient = Depends(get_yandex_search_wordstat_client), -) -> YandexSearchApiResult: - """Top related queries for a phrase (Yandex Search API v2 topRequests).""" - try: - result = client.wordstat_top_requests( - phrase, - region_ids=_parse_int_list(regions), - limit=limit, - devices=devices or None, - ) - except YandexSearchWordstatError as exc: - _raise_wordstat_http_error(exc) - return YandexSearchApiResult( - method="topRequests", - data=result["data"], - ) - - -@app.get( - "/wordstat/dynamics", - response_model=YandexSearchApiResult, - responses=WORDSTAT_ERROR_RESPONSES, -) -def wordstat_dynamics( - phrase: str, - date_from: str, - period: str = "PERIOD_MONTHLY", - date_to: str | None = None, - regions: list[str] | None = Query(default=None), - devices: list[str] | None = Query(default=None), - client: YandexSearchWordstatClient = Depends(get_yandex_search_wordstat_client), -) -> YandexSearchApiResult: - """Show / abs show per period (Yandex Search API v2 dynamics).""" - try: - result = client.wordstat_dynamics( - phrase, - period=period, - date_from=date_from, - date_to=date_to, - region_ids=_parse_int_list(regions), - devices=devices or None, - ) - except YandexSearchWordstatError as exc: - _raise_wordstat_http_error(exc) - return YandexSearchApiResult( - method="dynamics", - data=result["data"], - ) - - -@app.get( - "/wordstat/regions", - response_model=YandexSearchApiResult, - responses=WORDSTAT_ERROR_RESPONSES, -) -def wordstat_regions( - phrase: str, - region: str = "REGION_ALL", - devices: list[str] | None = Query(default=None), - client: YandexSearchWordstatClient = Depends(get_yandex_search_wordstat_client), -) -> YandexSearchApiResult: - """Share of impressions by region (Yandex Search API v2 regions).""" - try: - result = client.wordstat_regions_distribution( - phrase, - region=region, - devices=devices or None, - ) - except YandexSearchWordstatError as exc: - _raise_wordstat_http_error(exc) - return YandexSearchApiResult( - method="regions", - data=result["data"], - ) - - -@app.get( - "/wordstat/regions-tree", - response_model=YandexSearchApiResult, - responses=WORDSTAT_ERROR_RESPONSES, -) -def wordstat_regions_tree( - client: YandexSearchWordstatClient = Depends(get_yandex_search_wordstat_client), -) -> YandexSearchApiResult: - """Region tree (Yandex Search API v2 getRegionsTree, no phrase).""" - try: - result = client.wordstat_regions_tree() - except YandexSearchWordstatError as exc: - _raise_wordstat_http_error(exc) - return YandexSearchApiResult( - method="getRegionsTree", - data=result["data"], - ) - - -# --------------------------------------------------------------------------- -# Yandex Direct Live v4 — account balance (read-only) -# -# Live v4 AccountManagement → Get is the canonical way to read the -# current account balance (Amount, AmountAvailableForTransfer, Currency, -# AccountDayBudget). The endpoint is read-only and never returns the -# token in any body. Without a configured `?login=` we fall back to -# `clients.get` to discover the login the current token is bound to. -# --------------------------------------------------------------------------- - - -def _parse_live_v4_account_block(block: Any, login: str | None) -> YandexAccountBalance: - if not isinstance(block, dict): - return YandexAccountBalance(login=login, raw={"value": block} if not isinstance(block, dict) else None) - - def _float_or_zero(value: Any) -> float: - if isinstance(value, (int, float)): - return float(value) - if isinstance(value, str): - try: - return float(value.replace(",", ".")) - except ValueError: - return 0.0 - return 0.0 - - day_budget = block.get("AccountDayBudget") - day_budget_amount: float | None = None - day_budget_mode: str | None = None - if isinstance(day_budget, dict): - raw_amount = day_budget.get("Amount") - if isinstance(raw_amount, (int, float)): - day_budget_amount = float(raw_amount) - elif isinstance(raw_amount, str): - try: - day_budget_amount = float(raw_amount) - except ValueError: - day_budget_amount = None - mode = day_budget.get("SpendMode") - if isinstance(mode, str): - day_budget_mode = mode - amount_raw = block.get("Amount") - available_raw = block.get("AmountAvailableForTransfer") - return YandexAccountBalance( - login=str(block.get("Login") or login) if block.get("Login") or login else None, - amount=_float_or_zero(amount_raw), - amount_available_for_transfer=_float_or_zero(available_raw), - currency=str(block.get("Currency")) if isinstance(block.get("Currency"), str) else None, - account_day_budget_amount=day_budget_amount, - account_day_budget_spend_mode=day_budget_mode, - raw=block, - ) - - -def _resolve_login_for_balance( - client: YandexDirectClient, -) -> str | None: - """Discover the login the current OAUTH token is bound to via clients.get. - - Returns ``None`` if the call fails or returns an unexpected envelope — - the caller then surfaces the underlying 502 to the user. - """ - try: - response = client.clients_get() - except YandexDirectError: - return None - if not response.get("ok"): - return None - result = response.get("result") - if not isinstance(result, dict): - return None - clients = result.get("Clients") or result.get("clients") or [] - if not clients: - return None - first = clients[0] - if not isinstance(first, dict): - return None - login = first.get("Login") or first.get("login") - return str(login) if isinstance(login, str) and login else None - - -def _require_direct_read_client( - settings: Settings, client: YandexDirectClient | None -) -> YandexDirectClient: - if not _is_live_read_mode(settings) or client is None: - raise HTTPException( - status_code=409, - detail="This endpoint requires sandbox, live_readonly, or live_write mode with Yandex credentials", - ) - return client - - -@app.get( - "/yandex/account/balance", - response_model=YandexAccountBalanceResult, - responses={ - 502: {"model": ApiErrorResponse, "description": "Yandex Direct upstream error"}, - 503: {"model": ApiErrorResponse, "description": "YANDEX_OAUTH_TOKEN is not configured"}, - }, -) -def yandex_account_balance( - login: str | None = None, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexAccountBalanceResult: - """Read-only Live v4 AccountManagement → Get. - - Without ``?login=`` we discover the login via ``clients.get`` and then - call Live v4. With ``?login=`` we call Live v4 directly. The token is - never echoed back in any body. - """ - direct = _require_direct_read_client(settings, client) - if not direct.settings.yandex_oauth_token: - raise HTTPException( - status_code=503, - detail={ - "error_type": "YandexDirectError", - "message": "YANDEX_OAUTH_TOKEN is required for Yandex Direct API calls", - }, - ) - if not login: - login = _resolve_login_for_balance(direct) - try: - response = direct.account_balance(login=login) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"Yandex Direct Live v4 rejected AccountManagement: " - f"error_code={err.get('error_code')!r}" - ), - }, - ) - data = response.get("data") or [] - accounts = [_parse_live_v4_account_block(block, login) for block in data] - return YandexAccountBalanceResult(accounts=accounts, source="yandex", read_only=True) - - -# --------------------------------------------------------------------------- -# Yandex Direct campaign finance (v5 campaigns.get with finance fields) -# --------------------------------------------------------------------------- - - -@app.get( - "/yandex/campaigns/finance", - response_model=YandexCampaignFinanceList, - responses=YANDEX_DIRECT_ERROR_RESPONSES, -) -def yandex_campaigns_finance( - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexCampaignFinanceList: - """v5 campaigns.get with Funds / Statistics / StartDate / EndDate. - - Surfaces the raw micro-unit values and the display floats for money - fields so the caller can pick whichever representation they need. - """ - direct = _require_direct_read_client(settings, client) - if not direct.settings.yandex_oauth_token: - raise HTTPException( - status_code=503, - detail={ - "error_type": "YandexDirectError", - "message": "YANDEX_OAUTH_TOKEN is required for Yandex Direct API calls", - }, - ) - try: - response = direct.campaigns_get_finance() - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"Yandex Direct rejected campaigns.get (finance): " - f"error_code={err.get('error_code')!r}" - ), - }, - ) - items = [YandexCampaignFinance(**row) for row in (response.get("data") or [])] - return YandexCampaignFinanceList(items=items, source="yandex", read_only=True) - - -# --------------------------------------------------------------------------- -# Semantic change package (staged / dry-run-first) -# -# Lets the user design a negative-keyword and/or positive-keyword -# change for a real Yandex Direct campaign (e.g. ``710382063``) and -# preview the exact Direct API v5 request bodies that WOULD be sent. -# Apply is gated by ``approved`` / ``idempotency_key`` / ``dry_run`` -# and the runtime mode (``live_readonly`` blocks real apply; -# ``live_write`` allows it). -# --------------------------------------------------------------------------- - - -@app.post( - "/campaigns/{campaign_id}/semantic-changes", - response_model=SemanticChangePackage, -) -def prepare_semantic_change( - campaign_id: str, - payload: SemanticChangeRequest, - settings: Settings = Depends(get_settings), -) -> SemanticChangePackage: - """Build a staged semantic-change package. - - Always pure-local: no network call, no approval required. The - response is a :class:`SemanticChangePackage` whose ``preview`` - lists the v5 ``keywords.add`` / ``adgroups.update`` operations - that *would* be sent on apply. The user (or another tool) can - inspect the proposed change before deciding to actually apply it. - - The Direct API v5 ``keywords.add`` method requires ``AdGroupId`` - per keyword and ``adgroups.update`` requires the target group - ``Id``. If the user supplies either keyword list without an - ``ad_group_id`` the request is rejected with HTTP 400 BEFORE any - package is built. - """ - try: - return store.prepare_semantic_change_package( - campaign_id, payload, settings=settings - ) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -@app.post( - "/semantic-changes/{package_id}/apply", - response_model=SemanticChangeApplyResult, -) -def apply_semantic_change( - package_id: str, - payload: SemanticChangeApplyRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> SemanticChangeApplyResult: - """Apply a previously prepared semantic change. - - Gate contract (matches the rest of the product): - - * ``approved`` must be ``True`` — otherwise 409. - * ``idempotency_key`` must be supplied (length >= 6) — same key - returns the cached result without re-sending. - * In ``live_readonly`` mode, ``dry_run=False`` is REJECTED before - any network call. - * In ``live_write`` mode with all gates satisfied, the operations - from the package are sent to Yandex via the injected client. - """ - if not payload.approved: - raise HTTPException( - status_code=409, - detail="Action requires explicit approval before apply", - ) - if settings.directpilot_mode == "live_readonly" and not payload.dry_run: - raise HTTPException( - status_code=409, - detail=( - "Live writes require DIRECTPILOT_MODE=live_write; " - "live_readonly only allows dry_run" - ), - ) - try: - return store.apply_semantic_change( - package_id, payload, settings=settings, client=client - ) - except KeyError as exc: - raise HTTPException( - status_code=404, detail="semantic change package not found" - ) from exc - except YandexDirectError as exc: - # Never include the OAuth token in the response. - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": str(exc), - }, - ) from exc - except Exception as exc: # noqa: BLE001 — safety net - # Last-resort safety net: even a non-typed exception from the - # store layer (e.g. a stale cache hit, a programming bug, or an - # unhandled httpx edge case) must be translated to 502 with a - # redacted message. The store already records a - # ``semantic_change_apply_failed`` audit event for typed errors; - # we add one here too so the operator can correlate the 502. - try: - store.append_audit( - "semantic_change_apply_failed", - str(package_id), - dry_run=False, - details={ - "package_id": package_id, - "approved": payload.approved, - "idempotency_key": payload.idempotency_key, - "endpoint_safety_net": True, - "yandex_error": ( - f"unexpected error in endpoint: " - f"{type(exc).__name__}: {exc}" - ), - "exception_type": type(exc).__name__, - }, - ) - except Exception: # noqa: BLE001 - # Audit is best-effort; never let it block the safe 502. - pass - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"unexpected error during semantic-change apply: " - f"{type(exc).__name__}" - ), - }, - ) from exc - - -# --------------------------------------------------------------------------- -# Yandex Direct live-create campaign -# -# ``POST /yandex/campaigns/live-create`` creates a real Yandex Direct -# campaign from an existing :class:`CampaignDraft` preview. The apply -# path chains ``campaigns.add`` → ``adgroups.add`` → ``ads.add`` → -# ``keywords.add``. ``negativekeywordsharedsets.add`` remains explicit -# ``not_implemented``; group-level negatives are sent through -# ``adgroups.add`` ``NegativeKeywords.Items``. Each stage is its own v5 -# call so a single failure stops the chain before the next stage. -# --------------------------------------------------------------------------- - - -@app.post( - "/yandex/campaigns/live-create", - response_model=LiveCreateCampaignResult, -) -def yandex_live_create_campaign( - payload: LiveCreateCampaignRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> LiveCreateCampaignResult: - """Create a real Yandex Direct campaign from a draft preview. - - Gates (mirrors the rest of the product): - - * ``approved`` must be ``True`` (HTTP 409 otherwise). - * ``idempotency_key`` is required. - * ``live_readonly`` + ``dry_run=False`` is REJECTED before any - network call (HTTP 409). - * ``live_write`` + ``approved`` + ``idempotency_key`` + ``dry_run=False`` - performs the real chain: ``campaigns.add`` → ``adgroups.add`` → - ``ads.add`` → ``keywords.add``. ``negativekeywordsharedsets.add`` - remains in ``not_implemented``; group-level negatives are sent via - ``adgroups.add`` ``NegativeKeywords.Items`` (block is OPTIONAL — - omitted when the draft has no negatives, included with the items - when it does). The chain does not auto-activate or call - ``campaigns.resume``; activation/moderation handoff stays a - separate approved step. - - Region / geo targeting: - - * ``adgroups.add`` items ALWAYS carry ``RegionIds`` (v5 rejects - items without a geo target). The ids are resolved from - ``draft.region`` via the explicit local map - ``_REGION_NAME_TO_V5_IDS`` in ``app/store.py`` (helper - ``_resolve_region_to_ids``). No external lookup, no network - call. Supported region names in the Beta: ``Казань`` → ``[43]``, - ``Москва`` → ``[213]``, ``Санкт-Петербург`` / ``СПб`` → ``[2]``, - ``Россия`` / ``Russia`` → ``[225]``. Trivially extensible. - * An unmapped / empty / whitespace region fails closed BEFORE any - ``campaigns.add`` network call. The chain raises - :class:`YandexDirectError` with a redacted message that names - the offending region, the public store method audits - ``live_create_campaign_failed`` (no token in the audit), and - the endpoint returns HTTP 502. The dry-run preview surfaces the - same failure so the operator sees the same mode in both paths. - * The ``adgroups.add`` payload does NOT carry a ``Status`` field — - lifecycle/moderation state is controlled by Direct and the - separate resume endpoint, not by the create chain. - """ - if not payload.approved: - raise HTTPException( - status_code=409, - detail="Action requires explicit approval before live-create", - ) - # Mode gate: only ``live_write`` may perform a real apply. The - # other three modes (``mock`` / ``sandbox`` / ``live_readonly``) - # are REJECTED before any network call so the rejection is - # guaranteed to be no-network. ``sandbox`` shares the v5 - # ``campaigns.add`` write shape with production — a real apply - # against a sandbox token would create a real campaign on the - # user's sandbox account, which is the same shape of - # misconfiguration we are protecting against. ``mock`` has no - # live client at all — silently returning ``applied=False`` (a - # dry-run shape) would lie to the operator. ``live_readonly`` is - # the documented read-only path. ``dry_run=True`` short-circuits - # all of this and is allowed in every mode. - if not payload.dry_run and settings.directpilot_mode != "live_write": - raise HTTPException( - status_code=409, - detail=( - f"Live writes require DIRECTPILOT_MODE=live_write; " - f"current mode is {settings.directpilot_mode!r}; " - f"live-create apply is not allowed in this mode " - f"(dry_run=True is the only allowed path)" - ), - ) - try: - return store.live_create_campaign( - payload, settings=settings, client=client - ) - except ValueError as exc: - # The store raises ``ValueError`` for in-store gate - # violations (e.g. unapproved apply if the endpoint gate is - # bypassed by a direct caller). Surface as 409 with the - # reason — never as the opaque FastAPI 500 default. A - # ``ValueError`` from anywhere else would also be caught by - # the generic ``Exception`` safety net below; this explicit - # branch ensures the gate-violation case is NEVER mis-coded - # as 502. - raise HTTPException(status_code=409, detail=str(exc)) from exc - except KeyError as exc: - raise HTTPException( - status_code=404, detail="Campaign draft not found" - ) from exc - except YandexDirectError as exc: - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": str(exc), - }, - ) from exc - except Exception as exc: # noqa: BLE001 — safety net - # Same last-resort contract as the semantic-change endpoint: - # any non-typed exception becomes 502 with a redacted message. - try: - store.append_audit( - "live_create_campaign_failed", - payload.draft_id, - dry_run=False, - details={ - "draft_id": payload.draft_id, - "approved": payload.approved, - "idempotency_key": payload.idempotency_key, - "endpoint_safety_net": True, - "yandex_error": ( - f"unexpected error in live-create endpoint: " - f"{type(exc).__name__}: {exc}" - ), - "exception_type": type(exc).__name__, - }, - ) - except Exception: # noqa: BLE001 - pass - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"unexpected error during live-create: " - f"{type(exc).__name__}" - ), - }, - ) from exc - - -# --------------------------------------------------------------------------- -# Yandex Direct campaign TimeTargeting read (GET) -# -# ``GET /yandex/campaigns/{campaign_id}/time-targeting`` reads the current -# ``TimeTargeting`` block from the campaign via v5 ``campaigns.get`` -# with the ``TimeTargeting`` field set. No write gate — this is a -# pure read-only endpoint available in ``mock``, ``sandbox``, -# ``live_readonly``, and ``live_write``. No ``approved``, no -# ``idempotency_key``, no network write call. -# --------------------------------------------------------------------------- - - -def _parse_v5_time_targeting_to_schedule( - time_targeting: dict, -) -> YandexTimeTargetingSchedule | None: - """Parse a v5 ``TimeTargeting`` block into a normalized schedule. - - The v5 shape is ``{Schedule: {Items: [str, ...]}, ...}`` where - each item is ``"daynum,percent0,percent1,...,percent23"``. - Returns ``None`` if the shape is unparseable. - """ - try: - schedule_block = time_targeting.get("Schedule", {}) - if not isinstance(schedule_block, dict): - return None - items = schedule_block.get("Items") - if not isinstance(items, list) or len(items) != 7: - return None - days: list[YandexTimeTargetingHourly] = [] - for item in items: - if not isinstance(item, str): - return None - parts = item.split(",") - if len(parts) != 25: # daynum + 24 percents - return None - try: - hours = [int(p) for p in parts[1:]] - except (ValueError, TypeError): - return None - if len(hours) != 24: - return None - # Validate range — out-of-range values mean unparseable. - if any(h < 0 or h > 100 for h in hours): - return None - days.append(YandexTimeTargetingHourly(hours=hours)) - return YandexTimeTargetingSchedule(days=days) - except Exception: - return None - - -@app.get( - "/yandex/campaigns/{campaign_id}/time-targeting", - response_model=YandexTimeTargetingReadResult, -) -def yandex_time_targeting_read( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexTimeTargetingReadResult: - """Read the current TimeTargeting / hourly schedule of a campaign. - - Pure read-only — no ``approved``, no ``idempotency_key``, no - network write call. Available in all modes. - - - **mock**: returns a deterministic schedule with - ``source="mock"``. - - **live** (sandbox / live_readonly / live_write): calls - ``campaigns_get_time_targeting`` (v5 ``campaigns.get`` with - ``TimeTargeting`` field) and returns the raw ``TimeTargeting`` - block plus a normalized 7×24 ``schedule``. - - Upstream Yandex errors are redacted (no token leakage) and - surfaced as 502. - """ - if _is_live_read_mode(settings) and client is not None: - try: - response = client.campaigns_get_time_targeting(campaign_id) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - "Yandex Direct rejected campaigns.get (TimeTargeting): " - f"error_code={err.get('error_code')!r}" - ), - }, - ) - result = response.get("result") or {} - campaigns = result.get("Campaigns") if isinstance(result, dict) else None - if isinstance(campaigns, list) and campaigns and isinstance(campaigns[0], dict): - camp = campaigns[0] - raw_tt = camp.get("TimeTargeting") - campaign_name = camp.get("Name") - else: - raw_tt = None - campaign_name = None - schedule = None - if isinstance(raw_tt, dict): - schedule = _parse_v5_time_targeting_to_schedule(raw_tt) - raw_tt = dict(raw_tt) # defensive copy - return YandexTimeTargetingReadResult( - campaign_id=campaign_id, - campaign_name=str(campaign_name) if campaign_name else None, - source="yandex", - read_only=True, - time_targeting=raw_tt if isinstance(raw_tt, dict) else None, - schedule=schedule, - ) - - # Mock mode (or no client): deterministic local data. - mock = mock_yandex.mock_time_targeting(campaign_id) - raw_tt = mock.get("time_targeting") - schedule = None - if isinstance(raw_tt, dict): - schedule = _parse_v5_time_targeting_to_schedule(raw_tt) - return YandexTimeTargetingReadResult( - campaign_id=campaign_id, - campaign_name=mock.get("campaign_name"), - source="mock", - read_only=True, - time_targeting=raw_tt if isinstance(raw_tt, dict) else None, - schedule=schedule, - ) - - -# --------------------------------------------------------------------------- -# Yandex Direct campaign TimeTargeting update -# -# ``POST /yandex/campaigns/{campaign_id}/time-targeting`` updates the -# hourly-bidding schedule (TimeTargeting) of an existing Yandex -# Direct campaign via v5 ``campaigns.update``. The gate contract is -# identical to the rest of the product surface: -# -# * ``approved`` must be ``True`` (HTTP 409 otherwise). -# * ``idempotency_key`` is required. -# * ``live_readonly`` + ``dry_run=False`` is REJECTED before any -# network call (HTTP 409). -# * ``live_write`` + ``approved`` + ``idempotency_key`` + -# ``dry_run=False`` performs the real apply: a v5 -# ``campaigns.update`` call with the canonical ``TimeTargeting`` -# block, followed by a read-back via ``campaigns.get`` to verify -# the schedule landed. ``sandbox`` is rejected (same write shape -# as production, so the gate is strict). -# -# The request body accepts the schedule in one of two shapes (see -# ``YandexTimeTargetingRequest``): the full 7 x 24 ``schedule`` -# matrix, or the flat ``hours`` list plus an optional ``days`` -# filter. The endpoint normalises both shapes into the canonical -# v5 day-of-week order MONDAY..SUNDAY before sending. -# -# Direct v5 ``campaigns.update`` is a REPLACE-shaped call for the -# ``TimeTargeting`` block — sending the new block atomically -# replaces the previous schedule. Other campaign fields are not -# included in the payload so the apply touches only the schedule. -# --------------------------------------------------------------------------- - - -@app.post( - "/yandex/campaigns/{campaign_id}/time-targeting", - response_model=YandexTimeTargetingResult, -) -def yandex_time_targeting( - campaign_id: str, - payload: YandexTimeTargetingRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexTimeTargetingResult: - """Update the TimeTargeting / hourly schedule of a campaign. - - Gates (mirrors the rest of the product): - - * ``approved`` must be ``True`` (HTTP 409 otherwise). - * ``idempotency_key`` is required (HTTP 422 otherwise — Pydantic). - * ``live_readonly`` + ``dry_run=False`` is REJECTED before any - network call (HTTP 409). The dry-run path is allowed in every - mode and never mutates. - * ``live_write`` + ``approved`` + ``idempotency_key`` + - ``dry_run=False`` performs the real apply: v5 - ``campaigns.update`` with the canonical ``TimeTargeting`` - block, followed by a read-back via v5 ``campaigns.get - TimeTargeting`` to verify the schedule landed. The response - surfaces the read-back so the operator can diff it against - ``schedule_applied`` without re-querying. - - The request body accepts the schedule in one of two shapes - (see :class:`YandexTimeTargetingRequest`): - - 1. ``schedule`` — the full 7 x 24 matrix (positional, in the - v5 day-of-week order MONDAY..SUNDAY). - 2. ``hours`` — a flat 24-value list (0..100) plus an optional - ``days`` filter (``["MONDAY", ..., "SUNDAY"]``). Convenience - for the common "use these hours every day" use case; the - endpoint expands it into the canonical 7 x 24 matrix. Days - not listed in ``days`` are set to all-zeros (paused) on the - apply so the operator sees an explicit zero schedule on the - missing days, not a silent carry-over of the previous - schedule. - - Either ``schedule`` or ``hours`` MUST be supplied; supplying - both is a 422 validation error. - - Audit events ``yandex_time_targeting_requested`` (every - request, dry-run or apply) and ``yandex_time_targeting_failed`` - (only on apply-path failure) record the request id, the - schedule, the timezone label, and the Yandex error (no token - in the audit). Mock mode does NOT call any client method — - the apply is a pure in-memory mirror with a deterministic - ``readback`` shape so the operator can preview the v5 payload - the apply would send. - """ - if not payload.approved: - raise HTTPException( - status_code=409, - detail="Action requires explicit approval before time-targeting update", - ) - if not payload.dry_run and settings.directpilot_mode != "live_write": - # Mode gate: only ``live_write`` may perform a real apply. - # ``live_readonly`` and ``sandbox`` are REJECTED before any - # network call. ``sandbox`` shares the v5 - # ``campaigns.update`` write shape with production; a - # sandbox-apply would mutate the user's sandbox account. - # ``mock`` has no live client — silently returning - # ``applied=False`` (a dry-run shape) would lie to the - # operator. ``dry_run=True`` short-circuits all of this - # and is allowed in every mode. - raise HTTPException( - status_code=409, - detail=( - f"Live writes require DIRECTPILOT_MODE=live_write; " - f"current mode is {settings.directpilot_mode!r}; " - f"time-targeting apply is not allowed in this mode " - f"(dry_run=True is the only allowed path)" - ), - ) - try: - return store.yandex_time_targeting( - campaign_id, payload, settings=settings, client=client - ) - except ValueError as exc: - # ``ValueError`` is the in-store gate violation signal - # (e.g. unapproved apply when the endpoint gate is - # bypassed). Surface as 409 with the reason — never as - # the opaque FastAPI 500 default. - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - # Two cases land here: - # - # 1. ``live_readonly`` / ``sandbox`` apply pre-flight gate - # (the endpoint gate is the primary; this is a - # defence-in-depth check from the store). - # 2. The apply path's v5 ``campaigns.update`` rejection. - # The store audits ``yandex_time_targeting_failed`` - # before re-raising, so the audit log already carries - # the failing stage and the redacted Yandex error. - # Both surface as 502 with a typed envelope. - diagnostics = exc.diagnostics or {} - detail: dict[str, Any] = { - "error_type": "YandexDirectError", - "message": str(exc), - } - if "error_code" in diagnostics: - detail["error_code"] = diagnostics["error_code"] - if "error_detail" in diagnostics: - detail["error_detail"] = diagnostics["error_detail"] - if "payload_preview" in diagnostics: - detail["payload_preview"] = diagnostics["payload_preview"] - raise HTTPException(status_code=502, detail=detail) from exc - except Exception as exc: # noqa: BLE001 — safety net - # Last-resort contract: any non-typed exception becomes - # 502 with a redacted message. The token is never - # included. - try: - store.append_audit( - "yandex_time_targeting_failed", - campaign_id, - dry_run=False, - details={ - "campaign_id": campaign_id, - "approved": payload.approved, - "idempotency_key": payload.idempotency_key, - "endpoint_safety_net": True, - "yandex_error": ( - f"unexpected error in time-targeting endpoint: " - f"{type(exc).__name__}: {exc}" - ), - "exception_type": type(exc).__name__, - }, - ) - except Exception: # noqa: BLE001 - pass - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"unexpected error during time-targeting: " - f"{type(exc).__name__}" - ), - }, - ) from exc - - -# --------------------------------------------------------------------------- -# Campaign strategy read (GET) -# --------------------------------------------------------------------------- - - -def _build_strategy_summary( - strategy: dict | None, - priority_goals: dict | None = None, -) -> dict | None: - """Build a human-readable strategy summary from a raw BiddingStrategy block. - - ``priority_goals`` is the optional raw ``TextCampaign.PriorityGoals`` - dict (``{\"Items\": [{GoalId, Value}, ...]}`` as returned by v5 readback). - Values are converted from Direct micros to RUBLES. - """ - import re - - if not isinstance(strategy, dict): - return None - summary: dict[str, Any] = {} - - def _to_snake(name: str) -> str: - # Convert CamelCase to snake_case: GoalId → goal_id - s1 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name) - s2 = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s1) - return s2.lower() - - search = strategy.get("Search") - if isinstance(search, dict): - search_summary: dict[str, Any] = { - "type": search.get("BiddingStrategyType", "UNKNOWN"), - } - for sub_key, sub_val in search.items(): - if isinstance(sub_val, dict): - params: dict[str, Any] = {} - for pk, pv in sub_val.items(): - snake_key = _to_snake(pk) - if pk in ("WeeklySpendLimit", "BidCeiling"): - try: - params[f"{snake_key}_rub"] = float(pv) / 1_000_000 - except (TypeError, ValueError): - params[snake_key] = pv - else: - params[snake_key] = pv - search_summary[sub_key] = params - summary["search"] = search_summary - - network = strategy.get("Network") - if isinstance(network, dict): - summary["network"] = { - "type": network.get("BiddingStrategyType", "UNKNOWN"), - } - - # Add PriorityGoals summary if present - if priority_goals is not None and isinstance(priority_goals, dict): - items = priority_goals.get("Items") - if isinstance(items, list) and items: - summary["priority_goals"] = [] - for item in items: - if isinstance(item, dict) and "GoalId" in item: - pg: dict[str, Any] = {"goal_id": item["GoalId"]} - if "Value" in item: - try: - pg["value_rub"] = float(item["Value"]) / 1_000_000 - except (TypeError, ValueError): - pg["value"] = item["Value"] - summary["priority_goals"].append(pg) - - return summary if summary else None - - -@app.get( - "/yandex/campaigns/{campaign_id}/strategy", - response_model=YandexStrategyReadResult, -) -def yandex_strategy_read( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexStrategyReadResult: - """Read the current bidding strategy of a campaign. - - Pure read-only — no write gate. Available in all modes. - """ - if _is_live_read_mode(settings) and client is not None: - try: - response = client.campaigns_get_full_strategy(campaign_id) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - if not response.get("ok"): - err = response.get("error") or {} - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - "Yandex Direct rejected campaigns.get (strategy): " - f"error_code={err.get('error_code')!r}" - ), - }, - ) - result = response.get("result") or {} - campaigns = result.get("Campaigns") if isinstance(result, dict) else None - if isinstance(campaigns, list) and campaigns and isinstance(campaigns[0], dict): - camp = campaigns[0] - campaign_name = camp.get("Name") - campaign_type = camp.get("Type") - state = camp.get("State") - status = camp.get("Status") - raw_daily_budget = camp.get("DailyBudget") - tc = camp.get("TextCampaign") - raw_counter_ids = tc.get("CounterIds") if isinstance(tc, dict) else None - raw_strategy = tc.get("BiddingStrategy") if isinstance(tc, dict) else None - raw_priority_goals = tc.get("PriorityGoals") if isinstance(tc, dict) else None - else: - campaign_name = None - campaign_type = None - state = None - status = None - raw_daily_budget = None - raw_counter_ids = None - raw_strategy = None - raw_priority_goals = None - - daily_budget = ( - dict(raw_daily_budget) - if isinstance(raw_daily_budget, dict) - else raw_daily_budget - ) - counter_ids = ( - list(raw_counter_ids) - if isinstance(raw_counter_ids, list) - else None - ) - strategy = ( - dict(raw_strategy) if isinstance(raw_strategy, dict) else None - ) - strategy_summary = _build_strategy_summary( - strategy, priority_goals=raw_priority_goals - ) - - return YandexStrategyReadResult( - campaign_id=campaign_id, - campaign_name=str(campaign_name) if campaign_name else None, - source="yandex", - read_only=True, - campaign_type=str(campaign_type) if campaign_type else None, - state=str(state) if state else None, - status=str(status) if status else None, - daily_budget=daily_budget, - counter_ids=counter_ids, - priority_goals=raw_priority_goals, - strategy=strategy, - strategy_summary=strategy_summary, - ) - - mock = mock_yandex.mock_strategy(campaign_id) - return YandexStrategyReadResult( - campaign_id=campaign_id, - campaign_name=mock.get("campaign_name"), - source="mock", - read_only=True, - campaign_type=mock.get("campaign_type"), - state=mock.get("state"), - status=mock.get("status"), - daily_budget=mock.get("daily_budget"), - counter_ids=mock.get("counter_ids"), - priority_goals=mock.get("priority_goals"), - strategy=mock.get("strategy"), - strategy_summary=mock.get("strategy_summary"), - ) - - -# --------------------------------------------------------------------------- -# Campaign strategy update (POST) -# --------------------------------------------------------------------------- - - -@app.post( - "/yandex/campaigns/{campaign_id}/strategy", - response_model=YandexStrategyResult, -) -def yandex_strategy_update( - campaign_id: str, - payload: YandexStrategyRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexStrategyResult: - """Update the bidding strategy of a campaign. - - Currently supports WB_MAXIMUM_CONVERSION_RATE. - weekly_spend_limit and bid_ceiling are in RUBLES. - """ - if not payload.approved: - raise HTTPException( - status_code=409, - detail="Action requires explicit approval before strategy update", - ) - if not payload.dry_run and settings.directpilot_mode != "live_write": - raise HTTPException( - status_code=409, - detail=( - f"Live writes require DIRECTPILOT_MODE=live_write; " - f"current mode is {settings.directpilot_mode!r}; " - f"strategy apply is not allowed in this mode " - f"(dry_run=True is the only allowed path)" - ), - ) - try: - return store.yandex_strategy_update( - campaign_id, payload, settings=settings, client=client - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - diagnostics = exc.diagnostics or {} - detail: dict[str, Any] = { - "error_type": "YandexDirectError", - "message": str(exc), - } - if "error_code" in diagnostics: - detail["error_code"] = diagnostics["error_code"] - if "error_detail" in diagnostics: - detail["error_detail"] = diagnostics["error_detail"] - if "payload_preview" in diagnostics: - detail["payload_preview"] = diagnostics["payload_preview"] - raise HTTPException(status_code=502, detail=detail) from exc - except Exception as exc: - try: - store.append_audit( - "yandex_strategy_failed", - campaign_id, - dry_run=False, - details={ - "campaign_id": campaign_id, - "approved": payload.approved, - "idempotency_key": payload.idempotency_key, - "endpoint_safety_net": True, - "yandex_error": ( - f"unexpected error in strategy endpoint: " - f"{type(exc).__name__}: {exc}" - ), - "exception_type": type(exc).__name__, - }, - ) - except Exception: - pass - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"unexpected error during strategy update: " - f"{type(exc).__name__}" - ), - }, - ) from exc - - -# --------------------------------------------------------------------------- -# Autotargeting settings read (GET) / update (POST) -# --------------------------------------------------------------------------- - - -@app.get( - "/yandex/campaigns/{campaign_id}/autotargeting", - response_model=YandexAutotargetingReadResult, -) -def yandex_autotargeting_read( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexAutotargetingReadResult: - """Read autotargeting settings for all ad groups in a campaign. - - Pure read-only — no write gate. Available in all modes. - Returns per-ad-group autotargeting categories and brand options - from the ``---autotargeting`` keyword rows. - """ - try: - return store.yandex_autotargeting_read( - campaign_id, settings=settings, client=client - ) - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - - -@app.post( - "/yandex/campaigns/{campaign_id}/autotargeting", - response_model=YandexAutotargetingResult, -) -def yandex_autotargeting_update( - campaign_id: str, - payload: YandexAutotargetingRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> YandexAutotargetingResult: - """Update autotargeting settings for ad groups in a campaign. - - Standard product gate contract: - ``dry_run=True`` (default) is preview-only and never performs a network - write; the response includes the exact v5 ``keywords.update`` payload - that WOULD be sent, with ``applied=False``. - - ``dry_run=False`` requires ``DIRECTPILOT_MODE=live_write``, - ``approved=True`` and a valid ``idempotency_key``. - - Categories are always sent with all five booleans explicitly (``YES`` - or ``NO``) to avoid the Direct API pitfall where missing categories - default to ``YES``. - - Default preset for local service-search campaigns: ``exact_narrow`` - (Exact=YES, Narrow=YES, Alternative=NO, Accessory=NO, Broader=NO). - Brand options default: WithoutBrands=YES, WithAdvertiserBrand=YES, - WithCompetitorsBrand=NO. - """ - if not payload.approved: - raise HTTPException( - status_code=409, - detail="Action requires explicit approval before autotargeting update", - ) - if not payload.dry_run and settings.directpilot_mode != "live_write": - raise HTTPException( - status_code=409, - detail=( - f"Live writes require DIRECTPILOT_MODE=live_write; " - f"current mode is {settings.directpilot_mode!r}; " - f"autotargeting apply is not allowed in this mode " - f"(dry_run=True is the only allowed path)" - ), - ) - try: - return store.yandex_autotargeting_update( - campaign_id, payload, settings=settings, client=client - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - diagnostics = exc.diagnostics or {} - detail: dict[str, Any] = { - "error_type": "YandexDirectError", - "message": str(exc), - } - if "error_code" in diagnostics: - detail["error_code"] = diagnostics["error_code"] - if "error_detail" in diagnostics: - detail["error_detail"] = diagnostics["error_detail"] - if "payload_preview" in diagnostics: - detail["payload_preview"] = diagnostics["payload_preview"] - raise HTTPException(status_code=502, detail=detail) from exc - except Exception as exc: - try: - store.append_audit( - "yandex_autotargeting_failed", - campaign_id, - dry_run=False, - details={ - "campaign_id": campaign_id, - "approved": payload.approved, - "idempotency_key": payload.idempotency_key, - "endpoint_safety_net": True, - "yandex_error": ( - f"unexpected error in autotargeting endpoint: " - f"{type(exc).__name__}: {exc}" - ), - "exception_type": type(exc).__name__, - }, - ) - except Exception: - pass - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"unexpected error during autotargeting update: " - f"{type(exc).__name__}" - ), - }, - ) from exc - - -# --------------------------------------------------------------------------- -# Keyword bids update — live-safe SearchBid / ContextBid changes -# --------------------------------------------------------------------------- - - -@app.post( - "/yandex/campaigns/{campaign_id}/bids", - response_model=KeywordBidUpdateResult, - responses={ - 409: { - "description": "Safety gate or idempotency conflict: missing approval, non-live_write apply, or replay payload mismatch.", - }, - 502: { - "description": "Upstream Yandex Direct keywordbids.set / readback failure, with redacted diagnostics only.", - }, - }, -) -def yandex_keyword_bids_update( - campaign_id: str, - payload: KeywordBidUpdateRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> KeywordBidUpdateResult: - """Update SearchBid / ContextBid for existing keywords via v5 ``keywordbids.set``. - - Standard product gate contract: - ``dry_run=True`` (default) is preview-only and never performs a network - write; the response includes the exact v5 ``keywordbids.set`` payload - that WOULD be sent, with ``applied=False``. - - ``dry_run=False`` requires ``DIRECTPILOT_MODE=live_write``, - ``approved=True`` and a valid ``idempotency_key``. - - Request items use RUBLES at the REST boundary; the store converts to - Direct micros (× 1 000 000). The minimal v5 item shape is - ``KeywordId + SearchBid`` / ``KeywordId + ContextBid`` — no - CampaignId / AdGroupId in the item. - - After apply, the endpoint reads back keyword bids for the campaign - and returns the changed keyword ids with current Bid/ContextBid. - """ - if not payload.approved: - raise HTTPException( - status_code=409, - detail="Action requires explicit approval before keyword bids update", - ) - if not payload.dry_run and settings.directpilot_mode != "live_write": - raise HTTPException( - status_code=409, - detail=( - f"Live writes require DIRECTPILOT_MODE=live_write; " - f"current mode is {settings.directpilot_mode!r}; " - f"keyword bids apply is not allowed in this mode " - f"(dry_run=True is the only allowed path)" - ), - ) - try: - return store.yandex_keyword_bids_update( - campaign_id, payload, settings=settings, client=client - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - diagnostics = exc.diagnostics or {} - detail: dict[str, Any] = { - "error_type": "YandexDirectError", - "message": str(exc), - } - if "error_code" in diagnostics: - detail["error_code"] = diagnostics["error_code"] - if "error_detail" in diagnostics: - detail["error_detail"] = diagnostics["error_detail"] - if "payload_preview" in diagnostics: - detail["payload_preview"] = diagnostics["payload_preview"] - raise HTTPException(status_code=502, detail=detail) from exc - except Exception as exc: - try: - store.append_audit( - "yandex_keyword_bids_failed", - campaign_id, - dry_run=False, - details={ - "campaign_id": campaign_id, - "approved": payload.approved, - "idempotency_key": payload.idempotency_key, - "endpoint_safety_net": True, - "yandex_error": ( - f"unexpected error in keyword bids endpoint: " - f"{type(exc).__name__}: {exc}" - ), - "exception_type": type(exc).__name__, - }, - ) - except Exception: - pass - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"unexpected error during keyword bids update: " - f"{type(exc).__name__}" - ), - }, - ) from exc - - -@app.post( - "/yandex/campaigns/{campaign_id}/keyword-bids/set-auto", - response_model=KeywordBidsSetAutoResult, - responses={ - 409: { - "description": "Safety gate or endpoint-scoped idempotency conflict.", - }, - 502: { - "description": "Upstream KeywordBids read/setAuto failure with redacted diagnostics.", - }, - }, -) -def yandex_keyword_bids_set_auto( - campaign_id: str, - payload: KeywordBidsSetAutoRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> KeywordBidsSetAutoResult: - """Preview or apply typed ``keywordbids.setAuto`` without changing strategy. - - A preview remains non-mutating. Apply is gated to ``live_write`` plus - explicit approval and a valid idempotency key; successful provider writes - are read back through ``keywordbids.get``. - """ - - if not payload.dry_run: - if not payload.approved: - raise HTTPException( - status_code=409, - detail="Action requires explicit approval before setAuto apply", - ) - if settings.directpilot_mode != "live_write": - raise HTTPException( - status_code=409, - detail=( - "Live writes require DIRECTPILOT_MODE=live_write; " - "dry_run=True is the only allowed path in this mode" - ), - ) - try: - return store.yandex_keyword_bids_set_auto( - campaign_id, payload, settings=settings, client=client - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - raise HTTPException( - status_code=502, - detail={"error_type": "YandexDirectError", "message": str(exc)}, - ) from exc - - -@app.post( - "/yandex/campaigns/{campaign_id}/bid-modifiers", - response_model=BidModifiersUpdateResult, - responses={ - 409: { - "description": "Safety gate or idempotency conflict for bid modifier update.", - }, - 502: { - "description": "Upstream Yandex Direct bidmodifiers.set / readback failure, with redacted diagnostics only.", - }, - }, -) -def yandex_bid_modifiers_update( - campaign_id: str, - payload: BidModifiersUpdateRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> BidModifiersUpdateResult: - """Preview/apply existing demographic bid modifier coefficient changes. - - Direct API v5 ``bidmodifiers.set`` updates an existing modifier by - ``Id`` and ``BidModifier``. The request keeps operator-facing - ``adjustment_percent`` semantics where ``-100`` becomes Direct - ``BidModifier=0``. Real apply still requires ``live_write``, - ``approved=True``, valid ``idempotency_key``, and ``dry_run=False``. - """ - if not payload.approved: - raise HTTPException( - status_code=409, - detail="Action requires explicit approval before bid modifiers update", - ) - if not payload.idempotency_key: - raise HTTPException( - status_code=409, - detail="idempotency_key is required before bid modifiers update", - ) - if not payload.dry_run and settings.directpilot_mode != "live_write": - raise HTTPException( - status_code=409, - detail=( - f"Live writes require DIRECTPILOT_MODE=live_write; " - f"current mode is {settings.directpilot_mode!r}; " - f"bid modifiers apply is not allowed in this mode " - f"(dry_run=True is the only allowed path)" - ), - ) - try: - return store.yandex_bid_modifiers_update( - campaign_id, payload, settings=settings, client=client - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - diagnostics = exc.diagnostics or {} - detail: dict[str, Any] = { - "error_type": "YandexDirectError", - "message": str(exc), - } - if "error_code" in diagnostics: - detail["error_code"] = diagnostics["error_code"] - if "error_detail" in diagnostics: - detail["error_detail"] = diagnostics["error_detail"] - if "payload_preview" in diagnostics: - detail["payload_preview"] = diagnostics["payload_preview"] - raise HTTPException(status_code=502, detail=detail) from exc - except Exception as exc: - try: - store.append_audit( - "yandex_bid_modifiers_failed", - campaign_id, - dry_run=payload.dry_run, - details={ - "campaign_id": campaign_id, - "approved": payload.approved, - "idempotency_key": payload.idempotency_key, - "endpoint_safety_net": True, - "yandex_error": ( - f"unexpected error in bid modifiers endpoint: " - f"{type(exc).__name__}: {exc}" - ), - "exception_type": type(exc).__name__, - }, - ) - except Exception: - pass - raise HTTPException( - status_code=502, - detail={ - "error_type": "YandexDirectError", - "message": ( - f"unexpected error during bid modifiers update: " - f"{type(exc).__name__}" - ), - }, - ) from exc - - -# --------------------------------------------------------------------------- -# Yandex Direct UTM — audit / plan / apply -# --------------------------------------------------------------------------- - - -@app.get( - "/yandex/campaigns/{campaign_id}/utm-audit", - response_model=UtmAuditResult, -) -def yandex_utm_audit( - campaign_id: str, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> UtmAuditResult: - """Read-only UTM audit for all ad and sitelink URLs in a campaign. - - In mock mode, returns deterministic mock data. In sandbox/live modes, - reads real ads and sitelinks from Yandex Direct (no writes). - """ - return store.utm_audit( - campaign_id, - settings=settings, - client=client, - ) - - -@app.post( - "/yandex/campaigns/{campaign_id}/utm-plan", - response_model=UtmPlanResult, -) -def yandex_utm_plan( - campaign_id: str, - payload: UtmPlanRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> UtmPlanResult: - """Generate UTM plan/preview — always dry_run, never writes. - - Returns the list of URL changes (old → new with UTM) and a - preview of the v5 ``ads.update`` payload that WOULD be sent on apply. - Sitelink previews are included and can be applied through ``utm-apply`` - via ``sitelinks.update`` when ``include_sitelinks=true``. - """ - return store.utm_plan( - campaign_id, - payload, - settings=settings, - client=client, - ) - - -@app.post( - "/yandex/campaigns/{campaign_id}/utm-apply", - response_model=UtmApplyResult, -) -def yandex_utm_apply( - campaign_id: str, - payload: UtmApplyRequest, - settings: Settings = Depends(get_settings), - client: YandexDirectClient | None = Depends(get_yandex_client), -) -> UtmApplyResult: - """Apply UTM URLs to live ads — write-gated. - - * ``dry_run=True`` → preview only, ``applied=False``. - * ``dry_run=False`` requires: - 1. ``DIRECTPILOT_MODE=live_write`` - 2. ``approved=true`` - 3. ``idempotency_key`` (>= 6 chars) - - Uses ``ads.update`` (REPLACE-shaped) to safely update TextAd.Href. - When requested, uses ``sitelinks.update`` to update attached sitelink Href values. - """ - if not payload.approved: - raise HTTPException( - status_code=409, - detail="Action requires explicit approval before UTM apply", - ) - if not payload.dry_run and settings.directpilot_mode != "live_write": - raise HTTPException( - status_code=409, - detail=( - f"Live writes require DIRECTPILOT_MODE=live_write; " - f"current mode is {settings.directpilot_mode!r}; " - f"UTM apply is not allowed in this mode " - f"(dry_run=True is the only allowed path)" - ), - ) - try: - return store.utm_apply( - campaign_id, - payload, - settings=settings, - client=client, - ) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except YandexDirectError as exc: - raise _yandex_error_to_502(exc) from exc - - -# --------------------------------------------------------------------------- -# Yandex Metrika — read-only (counters, goals, summary, traffic-sources) -# -# The Metrika Management API (api-metrika.yandex.net/management/v1) and -# the Stats API (api-metrika.yandex.net/stat/v1) are separate from the -# v5 Direct API. They use a service OAUTH token (NOT an Api-Key, NOT a -# v5 OAuth token) and `Authorization: OAuth ` for auth. -# --------------------------------------------------------------------------- - - -def get_yandex_metrika_client( - settings: Settings = Depends(get_settings), -) -> YandexMetrikaClient: - """Build a YandexMetrikaClient for the read-only Metrika endpoints. - - The client is always created — even when no OAUTH token is configured. - Missing-token errors are raised inside the client's request methods so - the /metrika/* endpoints can translate them into a 503 - "service not configured" response without crashing. - """ - return YandexMetrikaClient(settings=settings) - - -def _metrika_error_to_503(exc: YandexMetrikaError) -> HTTPException: - """Translate a missing-config error into a 503 (service not configured).""" - return HTTPException( - status_code=503, - detail={ - "error_type": "YandexMetrikaError", - "message": str(exc), - }, - ) - - -def _metrika_error_to_502(exc: YandexMetrikaError) -> HTTPException: - """Translate an upstream / transport error into a 502 with no token echo.""" - return HTTPException( - status_code=502, - detail={ - "error_type": "YandexMetrikaError", - "message": str(exc), - }, - ) - - -def _raise_metrika_http_error(exc: YandexMetrikaError) -> None: - if isinstance(exc, YandexMetrikaMissingTokenError): - raise _metrika_error_to_503(exc) from exc - raise _metrika_error_to_502(exc) from exc - - -@app.get( - "/metrika/counters", - response_model=YandexMetrikaResult, - responses=METRIKA_ERROR_RESPONSES, -) -def metrika_counters( - client: YandexMetrikaClient = Depends(get_yandex_metrika_client), -) -> YandexMetrikaResult: - """List Metrika counters accessible by the configured OAUTH token.""" - try: - result = client.list_counters() - except YandexMetrikaError as exc: - _raise_metrika_http_error(exc) - return YandexMetrikaResult( - service="management", - method="counters", - data=result["data"], - ) - - -@app.get( - "/metrika/counters/{counter_id}/goals", - response_model=YandexMetrikaResult, - responses=METRIKA_ERROR_RESPONSES, -) -def metrika_counter_goals( - counter_id: int, - client: YandexMetrikaClient = Depends(get_yandex_metrika_client), -) -> YandexMetrikaResult: - """List goals for one Metrika counter.""" - try: - result = client.goals(counter_id) - except YandexMetrikaError as exc: - _raise_metrika_http_error(exc) - return YandexMetrikaResult( - service="management", - method="counter_goals", - counter_id=counter_id, - data=result["data"], - ) - - -@app.get( - "/metrika/counters/{counter_id}/summary", - response_model=YandexMetrikaResult, - responses=METRIKA_ERROR_RESPONSES, -) -def metrika_counter_summary( - counter_id: int, - date1: str, - date2: str, - client: YandexMetrikaClient = Depends(get_yandex_metrika_client), -) -> YandexMetrikaResult: - """Goals-conversion summary (any-goal reaches per day) for date1..date2. - - Uses the documented ``ym:s:anyGoalReaches`` metric, NOT the per-goal - ``ym:s:goalReaches`` (the latter is per-goal and is no longer a valid - metric name in v2). - """ - try: - result = client.summary(counter_id, date1=date1, date2=date2) - except YandexMetrikaError as exc: - _raise_metrika_http_error(exc) - return YandexMetrikaResult( - service="stat", - method="summary", - counter_id=counter_id, - data=result["data"], - ) - - -@app.get( - "/metrika/counters/{counter_id}/traffic-sources", - response_model=YandexMetrikaResult, - responses=METRIKA_ERROR_RESPONSES, -) -def metrika_counter_traffic_sources( - counter_id: int, - date1: str, - date2: str, - limit: int = 10, - client: YandexMetrikaClient = Depends(get_yandex_metrika_client), -) -> YandexMetrikaResult: - """Visits split by the last-sign traffic source. - - Uses the documented ``ym:s:lastsignTrafficSource`` dimension, NOT the - older ``ym:s:TrafficSource`` (which is deprecated and breaks in v2). - """ - try: - result = client.traffic_sources( - counter_id, date1=date1, date2=date2, limit=limit - ) - except YandexMetrikaError as exc: - _raise_metrika_http_error(exc) - return YandexMetrikaResult( - service="stat", - method="traffic_sources", - counter_id=counter_id, - data=result["data"], - ) +from app.api.legacy_handlers import _aggregate_search_query_tsv +from app.api.legacy_router import ( + get_settings, + get_yandex_client, + get_yandex_metrika_client, + get_yandex_search_wordstat_client, + router, + store, +) +from app.bootstrap.application import create_app + +__all__ = [ + "app", + "_aggregate_search_query_tsv", + "get_settings", + "get_yandex_client", + "get_yandex_metrika_client", + "get_yandex_search_wordstat_client", + "store", +] + +app = create_app() +app.include_router(router) diff --git a/app/providers/__init__.py b/app/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/providers/protocols.py b/app/providers/protocols.py new file mode 100644 index 0000000..f3fe192 --- /dev/null +++ b/app/providers/protocols.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from app.config import Settings +from app.yandex_direct import YandexDirectClient +from app.yandex_metrika import YandexMetrikaClient +from app.yandex_search_wordstat import YandexSearchWordstatClient + + +@runtime_checkable +class DirectClientFactory(Protocol): + def create(self, settings: Settings) -> YandexDirectClient | None: ... + + +@runtime_checkable +class MetrikaClientFactory(Protocol): + def create(self, settings: Settings) -> YandexMetrikaClient: ... + + +@runtime_checkable +class WordstatClientFactory(Protocol): + def create(self, settings: Settings) -> YandexSearchWordstatClient: ... diff --git a/app/providers/yandex.py b/app/providers/yandex.py new file mode 100644 index 0000000..033030f --- /dev/null +++ b/app/providers/yandex.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from app.config import Settings +from app.yandex_direct import YandexDirectClient +from app.yandex_metrika import YandexMetrikaClient +from app.yandex_search_wordstat import YandexSearchWordstatClient + + +class DefaultDirectClientFactory: + def create(self, settings: Settings) -> YandexDirectClient | None: + if settings.directpilot_mode not in ("sandbox", "live_readonly", "live_write"): + return None + return YandexDirectClient(settings=settings) + + +class DefaultMetrikaClientFactory: + def create(self, settings: Settings) -> YandexMetrikaClient: + return YandexMetrikaClient(settings=settings) + + +class DefaultWordstatClientFactory: + def create(self, settings: Settings) -> YandexSearchWordstatClient: + return YandexSearchWordstatClient(settings=settings) diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/repositories/context.py b/app/repositories/context.py new file mode 100644 index 0000000..81c1f8e --- /dev/null +++ b/app/repositories/context.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from contextvars import ContextVar, Token +from typing import Any + +from app.repositories.protocols import LegacyStoreRepository + +_REQUEST_REPOSITORY: ContextVar[LegacyStoreRepository | None] = ContextVar( + "request_repository", default=None +) + + +def bind_request_repository(repository: LegacyStoreRepository) -> Token[LegacyStoreRepository | None]: + return _REQUEST_REPOSITORY.set(repository) + + +def reset_request_repository(token: Token[LegacyStoreRepository | None]) -> None: + _REQUEST_REPOSITORY.reset(token) + + +class RequestRepositoryProxy: + """Resolve legacy store access to the repository bound to this request.""" + + def __init__(self, fallback: LegacyStoreRepository) -> None: + object.__setattr__(self, "_fallback", fallback) + + def _repository(self) -> LegacyStoreRepository: + return _REQUEST_REPOSITORY.get() or self._fallback + + def __getattr__(self, name: str) -> Any: + return getattr(self._repository(), name) + + def __setattr__(self, name: str, value: Any) -> None: + setattr(self._repository(), name, value) diff --git a/app/repositories/mock_store.py b/app/repositories/mock_store.py new file mode 100644 index 0000000..e369455 --- /dev/null +++ b/app/repositories/mock_store.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from app.store import MockStore + + +class MockStoreRepositoryAdapter: + """Adapter preserving the existing MockStore behavior behind a seam.""" + + def __init__(self, store: MockStore) -> None: + self._store = store + + @property + def campaigns(self) -> Mapping[Any, Any]: + return self._store.campaigns + + @property + def drafts(self) -> Mapping[Any, Any]: + return self._store.drafts + + @property + def recommendations(self) -> Mapping[Any, Any]: + return self._store.recommendations + + @property + def audit_events(self) -> list[Any]: + return self._store.audit_events + + def create_draft(self, payload: Any) -> Any: + return self._store.create_draft(payload) + + def append_audit(self, *args: Any, **kwargs: Any) -> Any: + return self._store.append_audit(*args, **kwargs) + + def yandex_control(self, *args: Any, **kwargs: Any) -> Any: + return self._store.yandex_control(*args, **kwargs) + + def __getattr__(self, name: str) -> Any: + return getattr(self._store, name) diff --git a/app/repositories/protocols.py b/app/repositories/protocols.py new file mode 100644 index 0000000..0158f41 --- /dev/null +++ b/app/repositories/protocols.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class LegacyStoreRepository(Protocol): + """Small stable surface used by the legacy router during P1 extraction.""" + + @property + def campaigns(self) -> Mapping[Any, Any]: ... + + @property + def drafts(self) -> Mapping[Any, Any]: ... + + @property + def recommendations(self) -> Mapping[Any, Any]: ... + + @property + def audit_events(self) -> list[Any]: ... + + def create_draft(self, payload: Any) -> Any: ... + + def append_audit(self, *args: Any, **kwargs: Any) -> Any: ... + + def yandex_control(self, *args: Any, **kwargs: Any) -> Any: ... diff --git a/docs/dependency-rationale.md b/docs/dependency-rationale.md new file mode 100644 index 0000000..2868b98 --- /dev/null +++ b/docs/dependency-rationale.md @@ -0,0 +1,29 @@ +# P1 quality-tooling dependency rationale + +## Scope + +P1 adds three development-only tools to the `dev` dependency group. They are +not imported by the application runtime and do not add provider credentials, +provider writes, or a production service dependency. + +| Tool | Locked version | P1 purpose | License evidence | +| --- | --- | --- | --- | +| `respx` | 0.23.1 | Deterministic HTTPX mocking for provider-contract tests; the harness blocks an unmocked test request. | Local frozen-install wheel metadata: `License: BSD-3-Clause`. | +| `ruff` | 0.16.6 | Bounded linting of new P1 modules only; it is not used for repository-wide formatting. | Local frozen-install wheel metadata: `License-Expression: MIT`. | +| `mypy` | 2.3.1 | Strict type checks for new P1 modules only; legacy `app.store` and legacy handler support remain explicit untyped boundaries. | Local frozen-install wheel metadata: `License-Expression: MIT`. | + +The metadata above was read from the disposable Python 3.11 environment +created by `uv sync --frozen --python 3.11` for this P1 change. + +## CVE and supply-chain posture + +- The exact resolved versions are retained in `uv.lock`; CI installs only with + `uv sync --frozen --python 3.11`. +- These packages are development-only. Runtime images and the FastAPI + application dependency list are unchanged by this tooling addition. +- `respx` is used only to mock outbound HTTP in tests, so its first harness + test has no live provider path. +- This repository has no configured vulnerability scanner in the P1 scope. + Consequently, this document does not claim that a CVE scan found zero + findings. A release or dependency-governance lane must run the approved SCA + scanner against the locked dependency graph before production promotion. diff --git a/pyproject.toml b/pyproject.toml index 13e7918..3ab6531 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,11 @@ dependencies = [ [dependency-groups] dev = [ "httpx2>=0.1", # Used indirectly by Starlette/FastAPI TestClient to avoid the deprecated httpx compatibility path. + "mypy>=2.3.1", "pytest>=8.0", "pytest-asyncio>=0.23", + "respx>=0.23.1", + "ruff>=0.16.6", ] [tool.pytest.ini_options] @@ -24,3 +27,49 @@ asyncio_mode = "auto" markers = [ "live_smoke: opt-in tests that call external sandbox/live APIs and are skipped unless explicitly enabled", ] + +[tool.ruff] +target-version = "py311" +include = [ + "app/api/core_router.py", + "app/api/direct_extensions_router.py", + "app/api/direct_router.py", + "app/api/legacy_router.py", + "app/api/metrika_router.py", + "app/api/route_registry.py", + "app/api/wordstat_router.py", + "app/bootstrap/application.py", + "app/bootstrap/dependencies.py", + "app/main.py", + "app/core/**/*.py", + "app/providers/**/*.py", + "app/repositories/**/*.py", +] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] + +[tool.mypy] +python_version = "3.11" +files = [ + "app/api/core_router.py", + "app/api/direct_extensions_router.py", + "app/api/direct_router.py", + "app/api/legacy_router.py", + "app/api/metrika_router.py", + "app/api/route_registry.py", + "app/api/wordstat_router.py", + "app/bootstrap/application.py", + "app/bootstrap/dependencies.py", + "app/main.py", + "app/core", + "app/providers", + "app/repositories", +] +check_untyped_defs = true +disallow_untyped_defs = true +warn_unused_ignores = true + +[[tool.mypy.overrides]] +module = ["app.api.legacy_handlers", "app.store", "app.yandex_direct"] +follow_imports = "skip" diff --git a/tests/characterization/__init__.py b/tests/characterization/__init__.py new file mode 100644 index 0000000..86df666 --- /dev/null +++ b/tests/characterization/__init__.py @@ -0,0 +1 @@ +"""Deterministic characterization tests for the current API baseline.""" diff --git a/tests/characterization/conftest.py b/tests/characterization/conftest.py new file mode 100644 index 0000000..d5422ec --- /dev/null +++ b/tests/characterization/conftest.py @@ -0,0 +1,92 @@ +"""Shared isolated fixtures for characterization tests. + +All settings instances opt out of ``.env`` loading. Provider-facing tests use +an in-process ``httpx.MockTransport`` or a fake that raises on any attempted +provider method, so this suite never reaches an external network. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator + +import httpx +import pytest +from fastapi.testclient import TestClient + +from app.config import Settings +from app.main import app, get_settings, get_yandex_client +from app.yandex_direct import YandexDirectClient + + +class ExplodingProvider: + """Fail immediately if a route reaches a provider method.""" + + def __init__(self) -> None: + self.network_attempts: list[str] = [] + + def __getattr__(self, name: str): + if name.startswith("_"): + raise AttributeError(name) + self.network_attempts.append(name) + raise AssertionError(f"provider I/O was attempted through {name}") + + +@pytest.fixture(autouse=True) +def _restore_dependency_overrides() -> Iterator[None]: + """Keep FastAPI dependency overrides local to each characterization test.""" + + previous = dict(app.dependency_overrides) + app.dependency_overrides.clear() + try: + yield + finally: + app.dependency_overrides.clear() + app.dependency_overrides.update(previous) + + +@pytest.fixture +def client() -> Iterator[TestClient]: + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +def yandex_settings() -> Callable[..., Settings]: + def _make(mode: str, *, oauth_token: str | None = None) -> Settings: + return Settings( + _env_file=None, + directpilot_mode=mode, + yandex_oauth_token=oauth_token, + ) + + return _make + + +@pytest.fixture +def override_dependencies() -> Callable[..., None]: + def _install(settings: Settings, yandex_client: object | None = None) -> None: + app.dependency_overrides[get_settings] = lambda: settings + app.dependency_overrides[get_yandex_client] = lambda: yandex_client + + return _install + + +@pytest.fixture +def direct_client_factory() -> Callable[ + [Settings, Callable[[httpx.Request], httpx.Response]], YandexDirectClient +]: + def _make( + settings: Settings, + handler: Callable[[httpx.Request], httpx.Response], + ) -> YandexDirectClient: + return YandexDirectClient( + settings=settings, + transport=httpx.MockTransport(handler), + ) + + return _make + + +@pytest.fixture +def exploding_provider() -> ExplodingProvider: + return ExplodingProvider() diff --git a/tests/characterization/test_auction_forecast.py b/tests/characterization/test_auction_forecast.py new file mode 100644 index 0000000..82b6447 --- /dev/null +++ b/tests/characterization/test_auction_forecast.py @@ -0,0 +1,233 @@ +"""Characterize the canonical typed KeywordBids read route on current master.""" + +from __future__ import annotations + +import json +from collections.abc import Callable + +import httpx +from fastapi.testclient import TestClient + +from app.config import Settings +from app.yandex_direct import YandexDirectClient + + +CAMPAIGN_ID = "123" + + +def _strategy_response() -> dict: + return { + "result": { + "Campaigns": [ + { + "Id": int(CAMPAIGN_ID), + "TextCampaign": { + "BiddingStrategy": { + "Search": {"BiddingStrategyType": "HIGHEST_POSITION"} + } + }, + } + ] + } + } + + +def test_keyword_bids_get_maps_money_pagination_and_autotargeting( + client: TestClient, + yandex_settings: Callable[..., Settings], + override_dependencies: Callable[..., None], + direct_client_factory: Callable[ + [Settings, Callable[[httpx.Request], httpx.Response]], YandexDirectClient + ], +) -> None: + calls: list[str] = [] + keyword_bids_request: dict | None = None + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal keyword_bids_request + body = json.loads(request.content) + if request.url.path.endswith("/campaigns"): + calls.append("campaigns.get") + return httpx.Response(200, json=_strategy_response(), request=request) + if request.url.path.endswith("/keywordbids"): + calls.append("keywordbids.get") + keyword_bids_request = body + return httpx.Response( + 200, + json={ + "result": { + "KeywordBids": [ + { + "KeywordId": 10, + "CampaignId": 123, + "AdGroupId": 20, + "ServingStatus": "ELIGIBLE", + "Search": { + "Bid": 12_500_000, + "AuctionBids": { + "AuctionBidItems": [ + { + "TrafficVolume": 100, + "Bid": 83_860_000, + "Price": 13_075_000, + }, + { + "TrafficVolume": 50, + "Bid": 40_000_000, + "Price": 5_000_000, + }, + ] + }, + }, + }, + { + "KeywordId": 11, + "CampaignId": 123, + "AdGroupId": 21, + "Search": {"Bid": 5_000_000}, + }, + ], + "LimitedBy": 5, + } + }, + request=request, + ) + if request.url.path.endswith("/keywords"): + calls.append("keywords.get") + return httpx.Response( + 200, + json={ + "result": { + "Keywords": [ + { + "Id": 10, + "CampaignId": 123, + "AdGroupId": 20, + "Keyword": "characterization phrase", + }, + { + "Id": 11, + "CampaignId": 123, + "AdGroupId": 21, + "Keyword": "---autotargeting", + }, + ] + } + }, + request=request, + ) + raise AssertionError(f"unexpected provider path: {request.url.path}") + + settings = yandex_settings("live_readonly", oauth_token="x") + override_dependencies(settings, direct_client_factory(settings, handler)) + + response = client.get( + f"/yandex/campaigns/{CAMPAIGN_ID}/keyword-bids", + params=[ + ("keyword_ids", "10"), + ("keyword_ids", "11"), + ("limit", "200"), + ("offset", "4"), + ], + ) + + assert response.status_code == 200 + data = response.json() + items_by_id = {item["keyword_id"]: item for item in data["items"]} + normal = items_by_id[10] + autotargeting = items_by_id[11] + + assert calls == ["campaigns.get", "keywordbids.get", "keywords.get"] + assert keyword_bids_request is not None + assert keyword_bids_request["method"] == "get" + assert keyword_bids_request["params"]["SelectionCriteria"] == { + "CampaignIds": [123], + "KeywordIds": [10, 11], + } + assert keyword_bids_request["params"]["Page"] == {"Limit": 200, "Offset": 4} + assert data["source"] == "yandex" + assert data["read_only"] is True + assert data["limited_by"] == 5 + assert data["next_offset"] == 6 + assert normal["row_kind"] == "keyword" + assert normal["keyword"] == "characterization phrase" + assert normal["search_bid_rub"] == 12.5 + assert [bid["traffic_volume"] for bid in normal["auction_bids"]] == [100, 50] + assert normal["auction_bids"][0] == { + "traffic_volume": 100, + "bid_micros": 83_860_000, + "bid_rub": 83.86, + "price_micros": 13_075_000, + "price_rub": 13.075, + } + assert autotargeting["row_kind"] == "autotargeting" + assert autotargeting["keyword"] is None + assert autotargeting["auction_bids"] == [] + + +def test_keyword_bids_get_sends_all_requested_ids_in_one_v5_read( + client: TestClient, + yandex_settings: Callable[..., Settings], + override_dependencies: Callable[..., None], + direct_client_factory: Callable[ + [Settings, Callable[[httpx.Request], httpx.Response]], YandexDirectClient + ], +) -> None: + keyword_ids = list(range(1, 202)) + keyword_bid_batch_sizes: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + if request.url.path.endswith("/campaigns"): + return httpx.Response(200, json=_strategy_response(), request=request) + if request.url.path.endswith("/keywordbids"): + batch_ids = body["params"]["SelectionCriteria"]["KeywordIds"] + keyword_bid_batch_sizes.append(len(batch_ids)) + return httpx.Response( + 200, + json={ + "result": { + "KeywordBids": [ + { + "KeywordId": keyword_id, + "CampaignId": 123, + "AdGroupId": keyword_id + 1000, + "Search": {"Bid": 1_000_000}, + } + for keyword_id in batch_ids + ] + } + }, + request=request, + ) + if request.url.path.endswith("/keywords"): + return httpx.Response( + 200, + json={ + "result": { + "Keywords": [ + { + "Id": keyword_id, + "CampaignId": 123, + "AdGroupId": keyword_id + 1000, + "Keyword": f"phrase {keyword_id}", + } + for keyword_id in keyword_ids + ] + } + }, + request=request, + ) + raise AssertionError(f"unexpected provider path: {request.url.path}") + + settings = yandex_settings("live_readonly", oauth_token="x") + override_dependencies(settings, direct_client_factory(settings, handler)) + + response = client.get( + f"/yandex/campaigns/{CAMPAIGN_ID}/keyword-bids", + params=[("keyword_ids", str(keyword_id)) for keyword_id in keyword_ids], + ) + + assert response.status_code == 200 + assert len(response.json()["items"]) == 201 + assert keyword_bid_batch_sizes == [201] diff --git a/tests/characterization/test_audit_idempotency.py b/tests/characterization/test_audit_idempotency.py new file mode 100644 index 0000000..b0130d4 --- /dev/null +++ b/tests/characterization/test_audit_idempotency.py @@ -0,0 +1,77 @@ +"""Characterize process-local audit and idempotency behavior only.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from fastapi.testclient import TestClient + +from app.config import Settings + + +_CREDENTIAL_FIELD_NAMES = { + "access_token", + "api_key", + "authorization", + "client_secret", + "oauth_token", + "password", + "secret", + "token", +} + + +def _contains_credential_like_field(value: Any) -> bool: + if isinstance(value, dict): + return any( + str(key).lower() in _CREDENTIAL_FIELD_NAMES + or _contains_credential_like_field(nested) + for key, nested in value.items() + ) + if isinstance(value, list): + return any(_contains_credential_like_field(item) for item in value) + return False + + +def test_audit_event_shape_and_process_local_idempotency_boundary( + client: TestClient, + yandex_settings: Callable[..., Settings], + override_dependencies: Callable[..., None], + exploding_provider: object, +) -> None: + """This records only same-process in-memory replay behavior, not durability or tenancy.""" + + override_dependencies(yandex_settings("mock"), exploding_provider) + payload = { + "approved": True, + "idempotency_key": "characterization-idempotency-001", + "dry_run": True, + } + + first = client.post("/yandex/campaigns/characterization-idempotency/pause", json=payload) + repeat = client.post("/yandex/campaigns/characterization-idempotency/pause", json=payload) + conflicting_payload = {**payload, "dry_run": False} + conflicting = client.post( + "/yandex/campaigns/characterization-idempotency/pause", + json=conflicting_payload, + ) + + assert first.status_code == 200 + assert repeat.status_code == 200 + assert first.json() == repeat.json() + # The current in-memory cache replays the first result for this conflict. + assert conflicting.status_code == 200 + assert conflicting.json() == first.json() + assert exploding_provider.network_attempts == [] # type: ignore[attr-defined] + + audit_id = first.json()["audit_id"] + audit_events = client.get("/audit-log").json()["items"] + matching_events = [event for event in audit_events if event["id"] == audit_id] + + assert len(matching_events) == 1 + event = matching_events[0] + assert {"id", "actor", "action", "entity", "dry_run", "details"}.issubset(event) + assert event["action"] == "yandex_pause_requested" + assert event["entity"] == "characterization-idempotency" + assert _contains_credential_like_field(event) is False diff --git a/tests/characterization/test_campaigns_reports_queries.py b/tests/characterization/test_campaigns_reports_queries.py new file mode 100644 index 0000000..5a8cd3e --- /dev/null +++ b/tests/characterization/test_campaigns_reports_queries.py @@ -0,0 +1,191 @@ +"""Characterize campaign and report behavior with in-process provider fakes.""" + +from __future__ import annotations + +from collections.abc import Callable + +import httpx +from fastapi.testclient import TestClient + +from app.config import Settings +from app.yandex_direct import YandexDirectClient + + +def test_campaign_draft_list_and_detail_remain_available_in_mock_mode( + client: TestClient, + yandex_settings: Callable[..., Settings], + override_dependencies: Callable[..., None], +) -> None: + override_dependencies(yandex_settings("mock")) + + created = client.post( + "/campaign-drafts", + json={ + "business_type": "remont", + "region": "Kazan", + "monthly_budget": 45000, + "landing_url": "https://example.invalid/characterization", + }, + ) + assert created.status_code == 200 + draft = created.json() + + listed = client.get("/campaign-drafts") + detail = client.get(f"/campaign-drafts/{draft['id']}") + + assert listed.status_code == 200 + assert any(item["id"] == draft["id"] for item in listed.json()["items"]) + assert detail.status_code == 200 + assert detail.json()["id"] == draft["id"] + assert detail.json()["status"] == "draft" + + +def test_campaign_provider_success_and_error_mapping_use_the_fake_client( + client: TestClient, + yandex_settings: Callable[..., Settings], + override_dependencies: Callable[..., None], +) -> None: + class CampaignProvider: + def __init__(self) -> None: + self.calls: list[str] = [] + + def campaigns_get(self) -> dict: + self.calls.append("campaigns.get") + if self.calls.count("campaigns.get") == 2: + return {"ok": False, "error": {"error_code": 53}} + return { + "ok": True, + "result": { + "Campaigns": [ + { + "Id": 410, + "Name": "Characterization campaign", + "Status": "ACTIVE", + "State": "ON", + "Type": "TEXT_CAMPAIGN", + } + ] + }, + } + + def adgroups_get(self, campaign_id: str) -> dict: + self.calls.append(f"adgroups.get:{campaign_id}") + return { + "ok": True, + "result": { + "AdGroups": [ + { + "Id": 411, + "CampaignId": int(campaign_id), + "Name": "Characterization ad group", + "Status": "ACTIVE", + "Type": "TEXT_AD_GROUP", + } + ] + }, + } + + provider = CampaignProvider() + override_dependencies(yandex_settings("live_readonly"), provider) + + campaign_list = client.get("/yandex/campaigns") + related_detail = client.get("/yandex/campaigns/410/ad-groups") + provider_error = client.get("/yandex/campaigns") + + assert campaign_list.status_code == 200 + assert campaign_list.json()["source"] == "yandex" + assert campaign_list.json()["read_only"] is True + assert campaign_list.json()["items"][0]["id"] == "410" + assert related_detail.status_code == 200 + assert related_detail.json()["items"][0]["campaign_id"] == "410" + assert provider_error.status_code == 502 + assert provider_error.json()["detail"]["error_type"] == "YandexDirectError" + assert provider.calls == ["campaigns.get", "adgroups.get:410", "campaigns.get"] + + +def test_search_query_report_polls_parses_and_normalizes_direct_ids( + client: TestClient, + yandex_settings: Callable[..., Settings], + override_dependencies: Callable[..., None], + direct_client_factory: Callable[ + [Settings, Callable[[httpx.Request], httpx.Response]], YandexDirectClient + ], +) -> None: + requests: list[dict[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append({name.lower(): value for name, value in request.headers.items()}) + return httpx.Response( + 200, + content=( + "Query\tCampaignId\tCampaignName\tAdGroupId\tImpressions\tClicks\tCtr\tCost\n" + "characterization query\t000123\tExample\t700\t10\t2\t20\t31.5\n" + "malformed metric\t000123\tExample\t700\tnot-an-int\t2\t20\t31.5\n" + "different campaign\t99\tOther\t701\t9\t1\t11.1\t20\n" + ), + request=request, + ) + + settings = yandex_settings("live_readonly", oauth_token="x") + override_dependencies(settings, direct_client_factory(settings, handler)) + + response = client.get( + "/yandex/reports/search-queries", + params={ + "date_from": "2026-01-01", + "date_to": "2026-01-02", + "campaign_id": "000123", + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["source"] == "yandex" + assert body["read_only"] is True + assert body["period"] == "2026-01-01..2026-01-02" + assert body["items"] == [ + { + "query": "characterization query", + "campaign_id": "123", + "campaign_name": "Example", + "ad_group_id": "700", + "impressions": 10, + "clicks": 2, + "ctr": 20.0, + "cost": 31.5, + } + ] + assert len(requests) == 1 + assert all(headers["processingmode"] == "auto" for headers in requests) + assert all(headers["skipreportheader"] == "true" for headers in requests) + assert all(headers["skipreportsummary"] == "true" for headers in requests) + + +def test_search_query_report_returns_empty_items_for_async_response( + client: TestClient, + yandex_settings: Callable[..., Settings], + override_dependencies: Callable[..., None], + direct_client_factory: Callable[ + [Settings, Callable[[httpx.Request], httpx.Response]], YandexDirectClient + ], +) -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + return httpx.Response(202, headers={"retryIn": "0"}, request=request) + + settings = yandex_settings("live_readonly", oauth_token="x") + override_dependencies(settings, direct_client_factory(settings, handler)) + + response = client.get( + "/yandex/reports/search-queries", + params={"date_from": "2026-01-01", "date_to": "2026-01-02"}, + ) + + assert response.status_code == 200 + assert response.json()["source"] == "yandex" + assert response.json()["read_only"] is True + assert response.json()["items"] == [] + assert attempts == 1 diff --git a/tests/characterization/test_preview_write_gate.py b/tests/characterization/test_preview_write_gate.py new file mode 100644 index 0000000..9455a6b --- /dev/null +++ b/tests/characterization/test_preview_write_gate.py @@ -0,0 +1,56 @@ +"""Characterize preview behavior and the live_readonly write gate.""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest +from fastapi.testclient import TestClient + +from app.config import Settings + + +def test_live_readonly_dry_run_remains_provider_side_effect_free( + client: TestClient, + yandex_settings: Callable[..., Settings], + override_dependencies: Callable[..., None], + exploding_provider: object, +) -> None: + override_dependencies(yandex_settings("live_readonly"), exploding_provider) + + response = client.post( + "/yandex/campaigns/characterization-preview/pause", + json={ + "approved": True, + "idempotency_key": "characterization-preview-001", + "dry_run": True, + }, + ) + + assert response.status_code == 200 + assert response.json()["dry_run"] is True + assert response.json()["applied"] is False + assert exploding_provider.network_attempts == [] # type: ignore[attr-defined] + + +@pytest.mark.parametrize("operation", ("pause", "resume")) +def test_live_readonly_representative_writes_fail_before_provider_io( + operation: str, + client: TestClient, + yandex_settings: Callable[..., Settings], + override_dependencies: Callable[..., None], + exploding_provider: object, +) -> None: + override_dependencies(yandex_settings("live_readonly"), exploding_provider) + + response = client.post( + f"/yandex/campaigns/characterization-write-gate/{operation}", + json={ + "approved": True, + "idempotency_key": f"characterization-{operation}-001", + "dry_run": False, + }, + ) + + assert response.status_code == 409 + assert exploding_provider.network_attempts == [] # type: ignore[attr-defined] diff --git a/tests/characterization/test_route_inventory.py b/tests/characterization/test_route_inventory.py new file mode 100644 index 0000000..04f8193 --- /dev/null +++ b/tests/characterization/test_route_inventory.py @@ -0,0 +1,44 @@ +"""Small OpenAPI route inventory for the current-master baseline.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from app.main import app + + +_HTTP_METHODS = frozenset({"get", "post", "put", "patch", "delete", "head", "options", "trace"}) +_EXPECTED_ROUTES = { + "/campaigns": "get", + "/campaign-drafts": "post", + "/campaign-drafts/{draft_id}": "patch", + "/campaign-drafts/{draft_id}/keywords": "delete", +} + + +def _operation_count(schema: dict) -> int: + return sum( + 1 + for path_item in schema["paths"].values() + for method in path_item + if method.lower() in _HTTP_METHODS + ) + + +def test_openapi_baseline_route_inventory_matches_current_snapshot() -> None: + snapshot_path = Path(__file__).resolve().parents[2] / "docs" / "openapi.json" + snapshot = json.loads(snapshot_path.read_text(encoding="utf-8")) + generated = app.openapi() + + for schema in (snapshot, generated): + assert schema["info"]["title"] == "DirectPilot Beta API" + assert schema["info"]["version"] == "0.2.1" + assert len(schema["paths"]) == 85 + assert _operation_count(schema) == 99 + assert schema.get("components", {}).get("securitySchemes", {}) == {} + for route, method in _EXPECTED_ROUTES.items(): + assert method in schema["paths"][route] + + # This is a route manifest comparison, not a broad response-schema snapshot. + assert set(generated["paths"]) == set(snapshot["paths"]) diff --git a/tests/test_application_factory.py b/tests/test_application_factory.py new file mode 100644 index 0000000..c0ce529 --- /dev/null +++ b/tests/test_application_factory.py @@ -0,0 +1,12 @@ +from fastapi import FastAPI + +from app.bootstrap.application import create_app + + +def test_create_app_preserves_existing_openapi_metadata() -> None: + app = create_app() + + assert isinstance(app, FastAPI) + assert app.title == "DirectPilot Beta API" + assert app.version == "0.2.1" + assert app.description == "Standalone API-first beta app for safe Yandex Direct automation." diff --git a/tests/test_dependency_seams.py b/tests/test_dependency_seams.py new file mode 100644 index 0000000..70c2c65 --- /dev/null +++ b/tests/test_dependency_seams.py @@ -0,0 +1,81 @@ +from dataclasses import replace + +from fastapi import Depends +from fastapi.testclient import TestClient + +from app.bootstrap.application import create_app +from app.bootstrap.dependencies import ( + create_application_dependencies, + get_yandex_client, + legacy_store, +) +from app.repositories.mock_store import MockStoreRepositoryAdapter +from app.repositories.protocols import LegacyStoreRepository + + +class FakeStore: + campaigns = {"campaign-1": "campaign"} + drafts = {} + recommendations = {} + audit_events = [] + + def create_draft(self, payload): + return {"payload": payload} + + def append_audit(self, *args, **kwargs): + return {"args": args, "kwargs": kwargs} + + def yandex_control(self, *args, **kwargs): + return {"args": args, "kwargs": kwargs} + + +class FakeDirectClientFactory: + def __init__(self) -> None: + self.client = object() + self.settings = None + + def create(self, settings): + self.settings = settings + return self.client + + +def test_mock_store_adapter_satisfies_repository_protocol() -> None: + adapter = MockStoreRepositoryAdapter(FakeStore()) + + assert isinstance(adapter, LegacyStoreRepository) + assert adapter.campaigns == {"campaign-1": "campaign"} + assert adapter.create_draft("draft") == {"payload": "draft"} + + +def test_factory_dependency_seam_uses_injected_direct_client_factory() -> None: + direct_factory = FakeDirectClientFactory() + dependencies = replace( + create_application_dependencies(), direct_client_factory=direct_factory + ) + app = create_app(dependencies=dependencies) + + @app.get("/factory-client") + def factory_client(client=Depends(get_yandex_client)): + return {"provided": client is direct_factory.client} + + response = TestClient(app).get("/factory-client") + + assert response.status_code == 200 + assert response.json() == {"provided": True} + assert direct_factory.settings is not None + assert app.state.dependencies is dependencies + + +def test_legacy_repository_proxy_uses_request_app_dependencies() -> None: + repository = MockStoreRepositoryAdapter(FakeStore()) + dependencies = replace(create_application_dependencies(), repository=repository) + app = create_app(dependencies=dependencies) + + @app.get("/repository-from-app") + def repository_from_app(): + return {"campaign": legacy_store.campaigns["campaign-1"]} + + response = TestClient(app, raise_server_exceptions=False).get("/repository-from-app") + + assert response.status_code == 200 + assert response.json() == {"campaign": "campaign"} diff --git a/tests/test_request_context.py b/tests/test_request_context.py new file mode 100644 index 0000000..b8b4c5f --- /dev/null +++ b/tests/test_request_context.py @@ -0,0 +1,61 @@ +import logging + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.bootstrap.application import create_app +from app.core.logging import redact_value +from app.core.request_context import get_request_context + + +REDACTION_FIXTURE_VALUE = "redaction-fixture-value" +REQUEST_ID = "request-context-test-001" + + +def test_api_v1_error_uses_safe_envelope_and_request_context(caplog) -> None: + app = create_app() + observed_request_ids: list[str] = [] + + @app.get("/api/v1/unsafe") + def unsafe_api_v1_route() -> None: + observed_request_ids.append(get_request_context().request_id) + raise HTTPException(status_code=400, detail={"oauth_token": REDACTION_FIXTURE_VALUE}) + + caplog.set_level(logging.INFO, logger="directpilot.request") + response = TestClient(app).get( + "/api/v1/unsafe", headers={"X-Request-ID": REQUEST_ID} + ) + + assert response.status_code == 400 + assert response.headers["X-Request-ID"] == REQUEST_ID + assert response.json() == { + "error": { + "code": "http_error", + "message": "Request failed", + "request_id": REQUEST_ID, + } + } + assert observed_request_ids == [REQUEST_ID] + assert REDACTION_FIXTURE_VALUE not in response.text + assert REDACTION_FIXTURE_VALUE not in caplog.text + assert "request_completed" in caplog.text + + +def test_legacy_error_response_is_not_wrapped() -> None: + app = create_app() + + @app.get("/legacy-unsafe") + def unsafe_legacy_route() -> None: + raise HTTPException(status_code=400, detail={"oauth_token": REDACTION_FIXTURE_VALUE}) + + response = TestClient(app).get("/legacy-unsafe") + + assert response.status_code == 400 + assert response.json() == {"detail": {"oauth_token": REDACTION_FIXTURE_VALUE}} + + +def test_redaction_removes_sensitive_mapping_values() -> None: + assert redact_value({"oauth_token": REDACTION_FIXTURE_VALUE, "status": "ok"}) == { + "oauth_token": "[REDACTED]", + "status": "ok", + } diff --git a/tests/test_respx_harness.py b/tests/test_respx_harness.py new file mode 100644 index 0000000..6febf23 --- /dev/null +++ b/tests/test_respx_harness.py @@ -0,0 +1,17 @@ +import httpx +import pytest +import respx +from respx.models import AllMockedAssertionError + + +def test_respx_blocks_unmocked_provider_network_paths() -> None: + with respx.mock(assert_all_called=True, assert_all_mocked=True) as mock: + mock.get("https://provider.invalid/contract").respond(200, json={"ok": True}) + + response = httpx.get("https://provider.invalid/contract") + + with pytest.raises(AllMockedAssertionError): + httpx.get("https://provider.invalid/unmocked") + + assert response.status_code == 200 + assert response.json() == {"ok": True} diff --git a/tests/test_router_decomposition.py b/tests/test_router_decomposition.py new file mode 100644 index 0000000..92ab20d --- /dev/null +++ b/tests/test_router_decomposition.py @@ -0,0 +1,41 @@ +from app.api.core_router import router as core_router +from app.api.direct_extensions_router import router as direct_extensions_router +from app.api.direct_router import router as direct_router +from app.api.legacy_router import router +from app.api.metrika_router import router as metrika_router +from app.api.wordstat_router import router as wordstat_router + + +def test_existing_health_endpoint_is_declared_on_legacy_router() -> None: + assert any(route.path == "/health" for route in router.routes) + + +def test_legacy_routes_are_grouped_by_domain_router() -> None: + assert any(route.path == "/health" for route in core_router.routes) + assert any(route.path == "/yandex/campaigns" for route in direct_router.routes) + assert any(route.path == "/wordstat/top" for route in wordstat_router.routes) + assert any(route.path == "/metrika/counters" for route in metrika_router.routes) + + +def _endpoint_module(router, path: str, method: str) -> str: + route = next( + route + for route in router.routes + if route.path == path and method in route.methods + ) + return route.endpoint.__module__ + + +def test_route_handlers_are_defined_in_bounded_domain_modules() -> None: + assert _endpoint_module(core_router, "/health", "GET") == "app.api.core_router" + assert _endpoint_module(direct_router, "/yandex/campaigns", "GET") == "app.api.direct_router" + assert ( + _endpoint_module( + direct_extensions_router, + "/yandex/account/balance", + "GET", + ) + == "app.api.direct_extensions_router" + ) + assert _endpoint_module(wordstat_router, "/wordstat/top", "GET") == "app.api.wordstat_router" + assert _endpoint_module(metrika_router, "/metrika/counters", "GET") == "app.api.metrika_router" diff --git a/uv.lock b/uv.lock index 77e47f0..3ba0102 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] [[package]] name = "annotated-doc" @@ -33,6 +37,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/c0/5bb6885a9608d86ee5712c0d88bc405d3a49f3e44231576e130ea2f53d34/ast_serialize-0.9.0.tar.gz", hash = "sha256:79fe8be1c934aa572940d1811d8dbe4d1b6f22291e3f16755c9b062e9ac92fb7", size = 951293, upload-time = "2026-09-02T15:50:45.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/76/497f19d9bdb3899a1efd82e2957f455d0c6e0cb9ebbc254735acb1f74235/ast_serialize-0.9.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ae1c46eb97865823f9843c4b80145e011874923e1a4a44b45738a5309d83e9f5", size = 889442, upload-time = "2026-09-02T15:49:21.144Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c5/9fb64b7106c5534739322c74be7b743c4f2e3b5fd05d5b8e677f05c54d5f/ast_serialize-0.9.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af082cb7e6c4fa3a428aa616c13d709a944076eba84a73184de15621cc1a915d", size = 1226721, upload-time = "2026-09-02T15:49:22.612Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/b550fc81aa0133808410783c6d9a1b925e31610d226e836e21337850af55/ast_serialize-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e9f2540741ad10657a209209f7e5cc6b530eb3ed145fd77258ab43542d96ad7", size = 1207369, upload-time = "2026-09-02T15:49:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/ed9e66deb7da63e44d0c0fd3a8feef698882ed56ea521a29494d4616eb46/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95485d5e8704af2ecc7f723757b88f992ae8122028d687ccf877cad2b4c3da4", size = 1273073, upload-time = "2026-09-02T15:49:25.336Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/cd337551d7a68c982425bbf91f183943d7ccc62394002c74807a7f0e60db/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b559cffac5a71a698d9194e4295765ff2132a10fd1284860f02b30f12c1f729e", size = 1279045, upload-time = "2026-09-02T15:49:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/98dc41eb4122d5da83241e805739838ed59e1e1b9006cbed89ded635a17f/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dad8a3f7106efcf252fc289c092ee0cee5c3512c0088bcf3fffa01458323092f", size = 1539300, upload-time = "2026-09-02T15:49:28.213Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/d94cede4b3f2a4e329d8ca92218f0846cfcc9257be91c5bf1168671f4ab5/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24de2bc930b7e1ca86641136b9875a1e5f80f52b484deddc67893eeaf9077bd9", size = 1291957, upload-time = "2026-09-02T15:49:29.643Z" }, + { url = "https://files.pythonhosted.org/packages/8e/85/8ac18d754225cf13392786b23d4ba84273ceee562699362f22e61942ce64/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ac7b4cdf89ca8318aae157d824017596784982851c8a89a621973f261574696", size = 1291779, upload-time = "2026-09-02T15:49:31.199Z" }, + { url = "https://files.pythonhosted.org/packages/62/ed/cc757fec9e96e29f19a6f818e05147e4f2949356258a3243412756f22a2e/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:95dcb93f30258dcf09d9b6a302dac9323b70e9df8c18ee0bf4ea1fa7cc5f1875", size = 1299730, upload-time = "2026-09-02T15:49:32.774Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8c/a575ae0ae954f21a187b4c1d8cec28d81a009693bd13a1388eec72d9b55a/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ed5824b4b2fa37ad93fa2db23d8243a3d315b3e2d7ca70f99b2727d8788af05", size = 1344671, upload-time = "2026-09-02T15:49:34.217Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/0b2b15c0ae5f9a95f433016d1a59a3227eebbb378654d6354206c8ac8e8d/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c69f2ce565c786bd3853ce42495e8a63610516f79651c7cb4f2cd0ddfaee52", size = 1448527, upload-time = "2026-09-02T15:49:35.68Z" }, + { url = "https://files.pythonhosted.org/packages/18/be/ee89cb6a5d3427946532f0611b514befdd69564803e9a9f9ce712f9d2654/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8c824d822a2ac54ec4228b88c0d170ab0024cf285eeee57b9d4f994003fb553", size = 1554045, upload-time = "2026-09-02T15:49:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4d/eaada807f98a2f0d370fec4b46c84f0e551a62911e751a76ecb32bef4dde/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c55010ea0fafc6bc8231809d328bda781bfe41a01c43522be5eb3713fd855cda", size = 1547578, upload-time = "2026-09-02T15:49:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/046c531f4af4e1bc3314077a84b3099f38b45e2d969faa24acedb3066d92/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:322282ac5337e5e776bc416f2b5201e680fa4dacf92ea739bd35e46a28a66c41", size = 1671896, upload-time = "2026-09-02T15:49:40.273Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/8d32aa0cf4e3e566399b2079a4c47f8ad3c62155f6ee1fe63631b6d3fdde/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ded5ed06ec469407d6cd571ace7a7a25809cc388e4bac1dd35c7747469fc7fdf", size = 1472895, upload-time = "2026-09-02T15:49:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/8204c7e3d8abd4b0c7a56a8d5cce05fcacfd6a5d63ffbd812b8b94040d6d/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3ee40752ddb4fb5c6a67161d13f3ce3df7987dcb9272260542a86b0be1519ae2", size = 1492731, upload-time = "2026-09-02T15:49:43.355Z" }, + { url = "https://files.pythonhosted.org/packages/f3/72/ff5c44c19409686798feeb1fbe209f2be78b4b64948d5aa2ddeec8901591/ast_serialize-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:d9c635eacfc02b91da6796d3b5ff9086e511b8a29b19f9b3f4f978b8d170f838", size = 1112847, upload-time = "2026-09-02T15:49:45.181Z" }, + { url = "https://files.pythonhosted.org/packages/20/75/fa5be1a94d189adafadf9c5f07fffd66af7a8061c4cff92f75285ef79d10/ast_serialize-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:62a96e327e2a178d6c295b10422e95c992a9286f0ec1b2bc7cd5b4873252a38c", size = 1146846, upload-time = "2026-09-02T15:49:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ef/b2bfb331b6e379d435543f6d3be0b590e7a2f441d31ccc17d75f2d2d7cb8/ast_serialize-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:41da4332492222d56345d5e436eed4fbec76caadee959f6ffa3cd2fc1bd51895", size = 1119605, upload-time = "2026-09-02T15:49:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f9/a4af1bf8b35927814c09d90c3965dbfaa75c489ba34372bffafbc2209f40/ast_serialize-0.9.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:383f56e3ae925f154632458f01b4bfcde3dd382f3ec04f5c7f6d72f76524ff48", size = 1226344, upload-time = "2026-09-02T15:49:49.79Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6d/d3a95823a803c21f5c9df595a0bb93aada22e7aa22bf875fe00d89422d7f/ast_serialize-0.9.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9ef3d4173907bd19aa8f1683be9f06e7862e6cdf2ca6bca3633305c0df32063", size = 1207384, upload-time = "2026-09-02T15:49:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/f0/97/6e7f46c8455b738609c29d1b7655307a168c4b40ce4c7a2c678c8ed9cf2e/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9482672ca8ec09f85cd050a053fb88c30c882c8e20ce7a140d8defe19c0ef2eb", size = 1273139, upload-time = "2026-09-02T15:49:52.679Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6e/25b70733f061766865cb04d913dc5332037c595796b871d52ab5b569abb8/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6a568d1d489f0669a31aed90ca4845aa7f08e1b8cd5d05e1905dbdc3ae9b2b0", size = 1278242, upload-time = "2026-09-02T15:49:54.236Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f0/b7820399d9c5a0b7f07c239b6da93d2e21a1b3785137fa00e16528e414b3/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a4dd005d4095a13eb312dc712c943c7730f262b000a87df963925328a38ffdb", size = 1541009, upload-time = "2026-09-02T15:49:56.149Z" }, + { url = "https://files.pythonhosted.org/packages/08/56/5146f1d2a77516e697f6f42825df79137e43560675cb4605c467775f8b4a/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:207ac73afa1f4654840593853c130eac2591dd942434176eea33f730afb3359b", size = 1290898, upload-time = "2026-09-02T15:49:57.502Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/87cd16228796d703de795a369b90b0f57f0f017f90f55c4c5876e3513a03/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae01129e2cc5d57a3c434d8a990019de039350310a2e1dd3c9f61311964cf25", size = 1291742, upload-time = "2026-09-02T15:49:58.982Z" }, + { url = "https://files.pythonhosted.org/packages/cc/eb/13465c297268c5170b2bb746d75f37a8fad44a94a89b593071affc1071d0/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:4c522377f383670abfe21c94edc3032cb3bd34d8fcacd280fa9556907d4edd4b", size = 1300180, upload-time = "2026-09-02T15:50:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a7/10b84c4274b2507b0ed9cc1654058ad64bcedb6ac574753d6a461bc6e204/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4cd886f6e900f5e13f758cb8ef359652e690e4f3f9c257a7269e095940534167", size = 1345857, upload-time = "2026-09-02T15:50:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/c7/dc/2702182c9773a15de9aabfaf66da7cb87548a56a6bac24f0c9176a4a13c3/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f3f0f3359cf0f22bf096b07021ffe6bf0ec88ac8a2cf7ce5f4701af973112faa", size = 1448544, upload-time = "2026-09-02T15:50:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/59/b5/eeef2124c9563b9861707ef4db91f153f3bb37b3e0cca9543bb88a4e9e53/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:6c428444656cffbd32c1e76626c6eec5237b58c8fcb0b5d3df75941cd50c4f3c", size = 1551572, upload-time = "2026-09-02T15:50:04.982Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8c/81d18349f1dffdcfeb80671bd737e342c86348e183692d9d0d8f573d1385/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:54f0babed5e2a4eb86a0716ac612aff33f933e7572e5bc067adcdbe672a26321", size = 1548118, upload-time = "2026-09-02T15:50:06.522Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1f/339131b60d1b0df13d9f3470cfac70f858b5649188ea01b2e7b39caeb720/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b779fdaee34d19900a5ba5fd6bd4cefe225650081f9de28de256eacee5113c2", size = 1674707, upload-time = "2026-09-02T15:50:07.919Z" }, + { url = "https://files.pythonhosted.org/packages/18/0a/ca77596fa229d88f96eca180d45dbe8efa11306f8b2b4f4ee301b3fe465f/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:4db7f524eaa857fbe650cac33b9cedb5ccda14393d40f640b75dfb06aa13c98c", size = 1473618, upload-time = "2026-09-02T15:50:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/88/5c/6aebeb54dd226b480014ff4488e150aa23b1de3204e2bf3f87de27e6542a/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:d2a37795a90809da6094825e7063118b4cf723b8701b134973b4566ed8b9ea09", size = 1492025, upload-time = "2026-09-02T15:50:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/c355d470a230f778311c28b80f6d934a497d094139f07230433eea18651b/ast_serialize-0.9.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:4411d1cba9eeecb301365343a7e96813b4a44fcdb20181557867ff7e751804cf", size = 1113010, upload-time = "2026-09-02T15:50:12.337Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0d/66609ace58564727b68731293cc986c2ea1d5e6ef40e96571e7fb515f0af/ast_serialize-0.9.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:b5c3724faf780e25def89c369eb6340a15dff06ac348160c61781e2373a4cd10", size = 1146404, upload-time = "2026-09-02T15:50:13.761Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fd/da28e1c85f05fb9976f247d2a3aefce68866cb2939abcbdbddd9a5e3b835/ast_serialize-0.9.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:1de0933a4c1d104d77d6e75f053f5e628b54cf8f9fea809b8250cb04cda07bd3", size = 1118328, upload-time = "2026-09-02T15:50:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/487a158a99e4564244e000ed18475255dfea53fd34a84d8ca73633710500/ast_serialize-0.9.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5fc57f17fb4ce49b4eeccfcd2670e4a55659bd740bb8e8aedbe511ccab8b5f03", size = 889484, upload-time = "2026-09-02T15:50:16.643Z" }, + { url = "https://files.pythonhosted.org/packages/92/e4/175b0a64d6c96bc1b96598c6474ce8d1ef34e0b774bcf7183f4ce696fb10/ast_serialize-0.9.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dac690f99538d9df0d23ce0299e946add2744b007a36b480a292fe361c82553d", size = 1232635, upload-time = "2026-09-02T15:50:18.133Z" }, + { url = "https://files.pythonhosted.org/packages/28/0c/d51d8463aca43aaa833fdf1f25134d6cc1b483764896decca61306ad1f6e/ast_serialize-0.9.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:2223ead73b5a5399d39610cf9c4164ad0b2bf2025226626b87ae15226d93d3f7", size = 1219313, upload-time = "2026-09-02T15:50:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/ef/19/c88bdc64f86095a9d6ab325ae422b2a5e1395cd63cd8aa539003d4d4ae1d/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b7c5f5838408fb000d76abd14e886836412b7ec7eccd028dbb5ed5819780008", size = 1279981, upload-time = "2026-09-02T15:50:20.811Z" }, + { url = "https://files.pythonhosted.org/packages/86/58/a492075826df1753896dc8e8f6ababae4016d8883b670ee3a1c34788b154/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e05701fde79affa1cc53e391867f9da3eb03fa8501f87354292796b0f8398fd", size = 1286319, upload-time = "2026-09-02T15:50:22.203Z" }, + { url = "https://files.pythonhosted.org/packages/ae/79/3f6754eaa42fd2a6c36aac066890870cd44cbe0e25f75a67b1b99a2f4d82/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d013c36eb2f2ac0cb7d4d0e79918a92ab00fbce8f1542fe47f34a46e06168f82", size = 1551547, upload-time = "2026-09-02T15:50:23.528Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/8cfb7caadfaf28febaa6b61d31d778262f87f9366eda4dd9bd07ac940b75/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19cde5c2110f7b90ab1210a599178524f6c9f34862b20ba2b9aa7832c67bb35d", size = 1302468, upload-time = "2026-09-02T15:50:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e2/750a0b136bb02ff8e4a17d65a3a78cd478ee50724704df8215797a226ba3/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1514f4a39704e2e815f9fc675fc13f19f694f212086b520110840782cf3c5295", size = 1300563, upload-time = "2026-09-02T15:50:26.354Z" }, + { url = "https://files.pythonhosted.org/packages/ab/17/4c0aa852ff1e4f2d6723e8ce827136c1e1febf2845d7941ccc45426778de/ast_serialize-0.9.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:30651ccdec6d23c49ee4711b1a1096d8dbd3be38eecf2f09fdd98a608ce7ac24", size = 1308999, upload-time = "2026-09-02T15:50:27.901Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1b/6e73d0a29aedb0db30cc68f2557acaac06cd24c9783ccb90f84f89e4ce87/ast_serialize-0.9.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d9a46caf5e3f2cd266e8638b2f4ea8bf54cf376f015f8418397b4633fdb38e9b", size = 1358191, upload-time = "2026-09-02T15:50:29.237Z" }, + { url = "https://files.pythonhosted.org/packages/dc/38/2cf5d552de99e0e9804a16fea73e54d0a7382498adddf57c0f6dc09cbc70/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:aed6e413c6c22a23c33c47a01dd2adce01d7a7ed408748e896903f47d0a1aa47", size = 1458944, upload-time = "2026-09-02T15:50:30.77Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0b/5ef87adf955b6a027f616eb7b55f55a154c35ba600e9dd2d06ad2d30e5c2/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:871fb7c5b049897ee137b67efad7fe4545ad270f7eccd970da877833f8e63aa7", size = 1563421, upload-time = "2026-09-02T15:50:32.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/dd/9ced05a17feeb0f83e84010d80f5a1b7b7aa19e75f0376f4d3780803654c/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:bb378efb5537b43f38660e2e6d6e138a40885cf191d43443bb3ff7ff47e9cd9b", size = 1558536, upload-time = "2026-09-02T15:50:33.861Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8b/8ad486e44fc7081a2471055befc433dddc2e51c3a88dff141b3026f64602/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:b373beff65b01fcffca5aaad3269ae629f3a998b09efdd3635e48039008a5dec", size = 1682749, upload-time = "2026-09-02T15:50:35.257Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4c/7c282aba9cfb0b92d79fac45c04e4557d9a7f08d872e5a43577a50867e30/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:25c8d517c45cf2b1820fc2af6ac593783654818f79d05646d25d624360678a4e", size = 1482441, upload-time = "2026-09-02T15:50:37.319Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/e45914e8cad81b660915f3784d255460a6384183b76bfc2089fdd79ec7df/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1675dc46578298ae00936164a160997801a6ca2385913150d8d16df634296cf3", size = 1499042, upload-time = "2026-09-02T15:50:38.76Z" }, + { url = "https://files.pythonhosted.org/packages/99/09/6988921dec19c810beef53539fec2e90ae551cd93853b3303a99fe45f772/ast_serialize-0.9.0-cp39-abi3-win32.whl", hash = "sha256:20fce3885eeff05a3d6afefa845c8168016e3ea1f6fc9cdc84c8db28b863a550", size = 1116391, upload-time = "2026-09-02T15:50:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/fd/eb/839598a22a1f9af56d39e188451cad93dbcb0ce6539a45ac18fb8bf123fa/ast_serialize-0.9.0-cp39-abi3-win_amd64.whl", hash = "sha256:161914666a21d48b681982146ac0fa4086ef099d91c637cf595387f5f06aa099", size = 1156055, upload-time = "2026-09-02T15:50:42.05Z" }, + { url = "https://files.pythonhosted.org/packages/0d/45/c7cd8d36d3b506bbd02db5066fae3340284781168f0d08dac25deef5f69d/ast_serialize-0.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:74473258a5c55855d5306c864a5c799fbff03a0f0ea1197346b2b5cc5b4ea48a", size = 1128237, upload-time = "2026-09-02T15:50:43.496Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -77,8 +145,11 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "httpx2" }, + { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "respx" }, + { name = "ruff" }, ] [package.metadata] @@ -92,8 +163,11 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "httpx2", specifier = ">=0.1" }, + { name = "mypy", specifier = ">=2.3.1" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "respx", specifier = ">=0.23.1" }, + { name = "ruff", specifier = ">=0.16.6" }, ] [[package]] @@ -243,6 +317,191 @@ 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 = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -252,6 +511,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -494,6 +762,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, +] + [[package]] name = "socksio" version = "1.0.0" From 3bf4c3ab2d342ef96c2fec5522b37674ded902d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Fri, 4 Sep 2026 20:04:17 +0300 Subject: [PATCH 2/4] feat(db): add PostgreSQL persistence foundation --- .env.example | 7 + .../workflows/p2-postgresql-migrations.yml | 68 +++++ .gitignore | 1 + alembic.ini | 28 ++ app/bootstrap/dependencies.py | 30 ++ app/db/__init__.py | 1 + app/db/engine.py | 92 ++++++ app/db/migrations/__init__.py | 1 + app/db/migrations/env.py | 30 ++ app/db/migrations/runner.py | 37 +++ .../versions/20260904_0001_audit_baseline.py | 44 +++ .../versions/20260904_0002_idempotency.py | 43 +++ .../versions/20260904_0003_product_stores.py | 62 ++++ app/db/models.py | 92 ++++++ app/db/roles.py | 121 ++++++++ app/db/schema.py | 34 +++ app/modules/__init__.py | 1 + app/modules/actions/__init__.py | 3 + app/modules/actions/idempotency.py | 190 ++++++++++++ app/modules/audit/__init__.py | 3 + app/modules/audit/repository.py | 96 ++++++ app/repositories/postgres_store.py | 246 +++++++++++++++ deploy/compose.dev.yml | 29 ++ deploy/compose.test.yml | 28 ++ docs/operations/backup-restore.md | 17 ++ docs/operations/dependency-admission.md | 11 + docs/operations/migrations.md | 20 ++ pyproject.toml | 8 + tests/integration/conftest.py | 124 ++++++++ .../test_application_persistence_runtime.py | 38 +++ tests/integration/test_audit_repository.py | 81 +++++ tests/integration/test_backup_restore.py | 204 +++++++++++++ .../test_idempotency_repository.py | 105 +++++++ .../integration/test_migrations_and_roles.py | 79 +++++ .../integration/test_p1_idempotency_wiring.py | 82 +++++ .../test_persistent_product_stores.py | 85 ++++++ tests/integration/test_postgresql_runtime.py | 20 ++ .../integration/test_schema_compatibility.py | 42 +++ tests/test_application_persistence_wiring.py | 14 + tests/test_db_engine.py | 65 ++++ tests/test_p2_operations_contract.py | 51 ++++ tests/test_postgresql_compose.py | 48 +++ uv.lock | 281 ++++++++++++++++++ 43 files changed, 2662 insertions(+) create mode 100644 .github/workflows/p2-postgresql-migrations.yml create mode 100644 alembic.ini create mode 100644 app/db/__init__.py create mode 100644 app/db/engine.py create mode 100644 app/db/migrations/__init__.py create mode 100644 app/db/migrations/env.py create mode 100644 app/db/migrations/runner.py create mode 100644 app/db/migrations/versions/20260904_0001_audit_baseline.py create mode 100644 app/db/migrations/versions/20260904_0002_idempotency.py create mode 100644 app/db/migrations/versions/20260904_0003_product_stores.py create mode 100644 app/db/models.py create mode 100644 app/db/roles.py create mode 100644 app/db/schema.py create mode 100644 app/modules/__init__.py create mode 100644 app/modules/actions/__init__.py create mode 100644 app/modules/actions/idempotency.py create mode 100644 app/modules/audit/__init__.py create mode 100644 app/modules/audit/repository.py create mode 100644 app/repositories/postgres_store.py create mode 100644 deploy/compose.dev.yml create mode 100644 deploy/compose.test.yml create mode 100644 docs/operations/backup-restore.md create mode 100644 docs/operations/dependency-admission.md create mode 100644 docs/operations/migrations.md create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_application_persistence_runtime.py create mode 100644 tests/integration/test_audit_repository.py create mode 100644 tests/integration/test_backup_restore.py create mode 100644 tests/integration/test_idempotency_repository.py create mode 100644 tests/integration/test_migrations_and_roles.py create mode 100644 tests/integration/test_p1_idempotency_wiring.py create mode 100644 tests/integration/test_persistent_product_stores.py create mode 100644 tests/integration/test_postgresql_runtime.py create mode 100644 tests/integration/test_schema_compatibility.py create mode 100644 tests/test_application_persistence_wiring.py create mode 100644 tests/test_db_engine.py create mode 100644 tests/test_p2_operations_contract.py create mode 100644 tests/test_postgresql_compose.py diff --git a/.env.example b/.env.example index fc80f3d..992c98a 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,10 @@ YANDEX_SEARCH_FOLDER_ID= # Yandex Metrika read-only token YANDEX_METRIKA_OAUTH_TOKEN= + +# P2 PostgreSQL isolated development/test placeholders only; never use in production. +# Inject real database configuration through the deployment secret mechanism. +DIRECTPILOT_DATABASE_URL=postgresql+psycopg://directpilot_app:REPLACE_WITH_SYNTHETIC_TEST_PASSWORD@127.0.0.1:55432/directpilot_test +DIRECTPILOT_POSTGRES_DB=directpilot_test +DIRECTPILOT_POSTGRES_OWNER_PASSWORD=REPLACE_WITH_SYNTHETIC_TEST_PASSWORD +DIRECTPILOT_POSTGRES_PORT=55432 diff --git a/.github/workflows/p2-postgresql-migrations.yml b/.github/workflows/p2-postgresql-migrations.yml new file mode 100644 index 0000000..63d4507 --- /dev/null +++ b/.github/workflows/p2-postgresql-migrations.yml @@ -0,0 +1,68 @@ +name: P2 PostgreSQL migrations + +on: + pull_request: + paths: + - ".github/workflows/p2-postgresql-migrations.yml" + - "alembic.ini" + - "app/db/**" + - "app/modules/**" + - "app/repositories/postgres_store.py" + - "deploy/compose.test.yml" + - "pyproject.toml" + - "uv.lock" + - "tests/integration/**" + push: + branches: [main] + paths: + - ".github/workflows/p2-postgresql-migrations.yml" + - "alembic.ini" + - "app/db/**" + - "app/modules/**" + - "app/repositories/postgres_store.py" + - "deploy/compose.test.yml" + - "pyproject.toml" + - "uv.lock" + - "tests/integration/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + migration-compatibility: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + DIRECTPILOT_DOCKER_BIN: docker + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: astral-sh/setup-uv@v5 + - name: Install locked dependencies + run: uv sync --locked --all-groups + - name: Pull the isolated PostgreSQL image + run: docker pull postgres:17.7-bookworm + - name: Empty database migration and runtime-role proof + run: >- + uv run pytest + tests/integration/test_migrations_and_roles.py::test_owner_migrates_empty_database_and_runtime_role_is_nonowner + -q + - name: Prior P2 revision to head migration proof + run: >- + uv run pytest + tests/integration/test_migrations_and_roles.py::test_owner_upgrades_from_prior_p2_revision_to_current_head + -q + - name: PostgreSQL persistence and restore integration + run: >- + uv run pytest + tests/integration/test_postgresql_runtime.py + tests/integration/test_schema_compatibility.py + tests/integration/test_audit_repository.py + tests/integration/test_idempotency_repository.py + tests/integration/test_persistent_product_stores.py + tests/integration/test_application_persistence_runtime.py + tests/integration/test_backup_restore.py + -q diff --git a/.gitignore b/.gitignore index 99b96c7..f4488e4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,6 @@ __pycache__/ .ruff_cache/ .hermes/ graphify-out/ +artifacts/ *.pyc .DS_Store diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..5686391 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,28 @@ +[alembic] +script_location = app/db/migrations +prepend_sys_path = . +path_separator = os +version_path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = + +[formatters] +keys = + +[logger_root] +level = WARN +handlers = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic diff --git a/app/bootstrap/dependencies.py b/app/bootstrap/dependencies.py index 74867f2..8c46c19 100644 --- a/app/bootstrap/dependencies.py +++ b/app/bootstrap/dependencies.py @@ -1,11 +1,19 @@ from __future__ import annotations +import os from dataclasses import dataclass from typing import cast from fastapi import Depends, Request from app.config import Settings, get_settings +from app.db.engine import ( + DATABASE_URL_ENV, + DatabaseRuntime, + DatabaseSettings, + create_database_runtime, +) +from app.db.schema import check_schema_compatibility from app.providers.protocols import ( DirectClientFactory, MetrikaClientFactory, @@ -18,6 +26,7 @@ ) from app.repositories.context import RequestRepositoryProxy from app.repositories.mock_store import MockStoreRepositoryAdapter +from app.repositories.postgres_store import PostgresLegacyStoreRepository from app.repositories.protocols import LegacyStoreRepository from app.store import store as mock_store from app.yandex_direct import YandexDirectClient @@ -34,9 +43,30 @@ class ApplicationDependencies: direct_client_factory: DirectClientFactory metrika_client_factory: MetrikaClientFactory wordstat_client_factory: WordstatClientFactory + database_runtime: DatabaseRuntime | None = None def create_application_dependencies() -> ApplicationDependencies: + database_url = os.environ.get(DATABASE_URL_ENV) + app_env = os.environ.get("DIRECTPILOT_APP_ENV", "local").lower() + if database_url: + runtime = create_database_runtime( + DatabaseSettings.from_mapping({DATABASE_URL_ENV: database_url}) + ) + try: + check_schema_compatibility(runtime) + except Exception: + runtime.close() + raise + return ApplicationDependencies( + repository=PostgresLegacyStoreRepository(runtime.sessions), + direct_client_factory=DefaultDirectClientFactory(), + metrika_client_factory=DefaultMetrikaClientFactory(), + wordstat_client_factory=DefaultWordstatClientFactory(), + database_runtime=runtime, + ) + if app_env in {"production", "staging"}: + DatabaseSettings.from_mapping({}) return ApplicationDependencies( repository=legacy_store_adapter, direct_client_factory=DefaultDirectClientFactory(), diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..3c068b5 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +"""PostgreSQL persistence primitives for DirectPilot.""" diff --git a/app/db/engine.py b/app/db/engine.py new file mode 100644 index 0000000..0121653 --- /dev/null +++ b/app/db/engine.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Self + +from sqlalchemy import create_engine, text +from sqlalchemy.engine import URL, Engine, make_url +from sqlalchemy.exc import ArgumentError, SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +DATABASE_URL_ENV = "DIRECTPILOT_DATABASE_URL" + + +class DatabaseConfigurationError(ValueError): + """Raised when PostgreSQL configuration is absent or unsafe.""" + + +class DatabaseConnectionError(RuntimeError): + """Raised when a database connection cannot be established safely.""" + + +@dataclass(frozen=True, slots=True) +class DatabaseSettings: + """Validated, non-displayable configuration for the synchronous database layer.""" + + url: URL = field(repr=False) + application_name: str = "directpilot" + pool_size: int = 5 + connect_timeout_seconds: int = 5 + + @classmethod + def from_mapping(cls, values: Mapping[str, str]) -> Self: + raw_url = values.get(DATABASE_URL_ENV) + if raw_url is None or not raw_url.strip(): + raise DatabaseConfigurationError("database configuration is required") + + try: + url = make_url(raw_url) + except ArgumentError as exc: + raise DatabaseConfigurationError("database configuration is invalid") from exc + + if url.drivername != "postgresql+psycopg": + raise DatabaseConfigurationError("database configuration must use PostgreSQL psycopg") + if not url.host or not url.database: + raise DatabaseConfigurationError("database configuration is incomplete") + + return cls(url=url) + + @classmethod + def from_environment(cls) -> Self: + return cls.from_mapping(os.environ) + + +@dataclass(frozen=True, slots=True) +class DatabaseRuntime: + """Synchronous engine and session factory owned by the application lifetime.""" + + engine: Engine = field(repr=False) + sessions: sessionmaker[Session] = field(repr=False) + + def close(self) -> None: + self.engine.dispose() + + +def create_database_runtime(settings: DatabaseSettings) -> DatabaseRuntime: + """Create one pre-pinged SQLAlchemy 2 runtime without opening a connection.""" + + engine = create_engine( + settings.url, + pool_pre_ping=True, + hide_parameters=True, + pool_size=settings.pool_size, + max_overflow=0, + connect_args={ + "application_name": settings.application_name, + "connect_timeout": settings.connect_timeout_seconds, + }, + ) + return DatabaseRuntime(engine=engine, sessions=sessionmaker(bind=engine, expire_on_commit=False)) + + +def check_database_connection(runtime: DatabaseRuntime) -> int: + """Return the PostgreSQL server version or fail without rendering connection data.""" + + try: + with runtime.engine.connect() as connection: + value = connection.execute(text("SHOW server_version_num")).scalar_one() + return int(value) + except (SQLAlchemyError, TypeError, ValueError): + raise DatabaseConnectionError("database connection is unavailable") from None diff --git a/app/db/migrations/__init__.py b/app/db/migrations/__init__.py new file mode 100644 index 0000000..c785c74 --- /dev/null +++ b/app/db/migrations/__init__.py @@ -0,0 +1 @@ +# Alembic migration package for DirectPilot PostgreSQL persistence. diff --git a/app/db/migrations/env.py b/app/db/migrations/env.py new file mode 100644 index 0000000..42343db --- /dev/null +++ b/app/db/migrations/env.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from alembic import context +from sqlalchemy.engine import Connection + +from app.db.models import Base + +config = context.config +target_metadata = Base.metadata + + +def run_migrations_online() -> None: + connection = config.attributes.get("connection") + if not isinstance(connection, Connection): + raise RuntimeError("migration connection is required") + + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + raise RuntimeError("offline migrations are not supported") +run_migrations_online() diff --git a/app/db/migrations/runner.py b/app/db/migrations/runner.py new file mode 100644 index 0000000..96e4b81 --- /dev/null +++ b/app/db/migrations/runner.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import Path + +from alembic import command +from alembic.config import Config + +from app.db.engine import DatabaseRuntime +from app.db.roles import grant_runtime_schema_readiness + +_ROOT = Path(__file__).resolve().parents[3] +_MIGRATIONS = Path(__file__).resolve().parent + + +def migration_config() -> Config: + config = Config(str(_ROOT / "alembic.ini")) + config.set_main_option("script_location", str(_MIGRATIONS)) + return config + + +def upgrade_database(runtime: DatabaseRuntime, revision: str = "head") -> None: + """Upgrade with the schema-owner connection and grant only version visibility.""" + + config = migration_config() + with runtime.engine.begin() as connection: + config.attributes["connection"] = connection + command.upgrade(config, revision) + grant_runtime_schema_readiness(connection) + + +def downgrade_database(runtime: DatabaseRuntime, revision: str = "base") -> None: + """Downgrade under the owner connection for isolated migration verification.""" + + config = migration_config() + with runtime.engine.begin() as connection: + config.attributes["connection"] = connection + command.downgrade(config, revision) diff --git a/app/db/migrations/versions/20260904_0001_audit_baseline.py b/app/db/migrations/versions/20260904_0001_audit_baseline.py new file mode 100644 index 0000000..2706e61 --- /dev/null +++ b/app/db/migrations/versions/20260904_0001_audit_baseline.py @@ -0,0 +1,44 @@ +"""P2 audit baseline owned by the migration role.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "20260904_0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "audit_events", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("actor", sa.String(length=128), nullable=False), + sa.Column("action", sa.String(length=128), nullable=False), + sa.Column("entity", sa.String(length=255), nullable=False), + sa.Column("dry_run", sa.Boolean(), nullable=False), + sa.Column("details", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column( + "occurred_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_events")), + ) + op.create_index( + op.f("ix_audit_events_occurred_at"), + "audit_events", + ["occurred_at"], + unique=False, + ) + op.execute("REVOKE ALL ON TABLE audit_events FROM PUBLIC") + op.execute("GRANT SELECT, INSERT ON TABLE audit_events TO directpilot_app") + + +def downgrade() -> None: + op.drop_index(op.f("ix_audit_events_occurred_at"), table_name="audit_events") + op.drop_table("audit_events") diff --git a/app/db/migrations/versions/20260904_0002_idempotency.py b/app/db/migrations/versions/20260904_0002_idempotency.py new file mode 100644 index 0000000..777caa3 --- /dev/null +++ b/app/db/migrations/versions/20260904_0002_idempotency.py @@ -0,0 +1,43 @@ +"""P2 persistent idempotency records.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "20260904_0002" +down_revision = "20260904_0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "idempotency_records", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("namespace", sa.String(length=128), nullable=False), + sa.Column("key_hash", sa.String(length=64), nullable=False), + sa.Column("request_hash", sa.String(length=64), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("result", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_idempotency_records")), + sa.UniqueConstraint( + "namespace", + "key_hash", + name=op.f("uq_idempotency_records_namespace"), + ), + ) + op.execute("REVOKE ALL ON TABLE idempotency_records FROM PUBLIC") + op.execute("GRANT SELECT, INSERT, UPDATE ON TABLE idempotency_records TO directpilot_app") + + +def downgrade() -> None: + op.drop_table("idempotency_records") diff --git a/app/db/migrations/versions/20260904_0003_product_stores.py b/app/db/migrations/versions/20260904_0003_product_stores.py new file mode 100644 index 0000000..c7c80a8 --- /dev/null +++ b/app/db/migrations/versions/20260904_0003_product_stores.py @@ -0,0 +1,62 @@ +"""P2 durable product drafts and semantic packages.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "20260904_0003" +down_revision = "20260904_0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "campaign_drafts", + sa.Column("id", sa.String(length=64), nullable=False), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_campaign_drafts")), + ) + op.create_table( + "semantic_change_packages", + sa.Column("package_id", sa.String(length=64), nullable=False), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.PrimaryKeyConstraint("package_id", name=op.f("pk_semantic_change_packages")), + ) + op.execute("REVOKE ALL ON TABLE campaign_drafts FROM PUBLIC") + op.execute("REVOKE ALL ON TABLE semantic_change_packages FROM PUBLIC") + op.execute("GRANT SELECT, INSERT, UPDATE ON TABLE campaign_drafts TO directpilot_app") + op.execute( + "GRANT SELECT, INSERT, UPDATE ON TABLE semantic_change_packages TO directpilot_app" + ) + + +def downgrade() -> None: + op.drop_table("semantic_change_packages") + op.drop_table("campaign_drafts") diff --git a/app/db/models.py b/app/db/models.py new file mode 100644 index 0000000..2b6c310 --- /dev/null +++ b/app/db/models.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from sqlalchemy import Boolean, DateTime, MetaData, String, UniqueConstraint, text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PostgreSQLUUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + +class AuditEventRecord(Base): + __tablename__ = "audit_events" + + id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + actor: Mapped[str] = mapped_column(String(128), nullable=False) + action: Mapped[str] = mapped_column(String(128), nullable=False) + entity: Mapped[str] = mapped_column(String(255), nullable=False) + dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False) + details: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + ) + + +class IdempotencyRecord(Base): + __tablename__ = "idempotency_records" + __table_args__ = ( + UniqueConstraint("namespace", "key_hash", name="uq_idempotency_records_namespace"), + ) + + id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + namespace: Mapped[str] = mapped_column(String(128), nullable=False) + key_hash: Mapped[str] = mapped_column(String(64), nullable=False) + request_hash: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + result: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + ) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class CampaignDraftRecord(Base): + __tablename__ = "campaign_drafts" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + ) + + +class SemanticChangePackageRecord(Base): + __tablename__ = "semantic_change_packages" + + package_id: Mapped[str] = mapped_column(String(64), primary_key=True) + payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + ) diff --git a/app/db/roles.py b/app/db/roles.py new file mode 100644 index 0000000..968e900 --- /dev/null +++ b/app/db/roles.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import text +from sqlalchemy.engine import Connection + +from app.db.engine import DatabaseRuntime + +MIGRATION_ROLE = "directpilot_owner" +RUNTIME_ROLE = "directpilot_app" + + +class DatabaseRoleError(RuntimeError): + """Raised when the required non-owner runtime role is unavailable.""" + + +@dataclass(frozen=True, slots=True) +class RuntimeRoleAttributes: + name: str + is_superuser: bool + can_bypass_rls: bool + can_create_database: bool + can_create_role: bool + can_replicate: bool + + +def bootstrap_database_roles(runtime: DatabaseRuntime, *, app_password: str) -> None: + """Create or reset the restricted runtime login under the schema owner.""" + + if not app_password: + raise DatabaseRoleError("runtime role password is required") + + with runtime.engine.begin() as connection: + connection.execute( + text( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'directpilot_app' + ) THEN + CREATE ROLE directpilot_app + LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS NOINHERIT; + END IF; + END + $$; + """ + ) + ) + connection.execute( + text("SELECT set_config('directpilot.runtime_role_password', :password, true)"), + {"password": app_password}, + ) + connection.execute( + text( + """ + DO $$ + BEGIN + EXECUTE format( + 'ALTER ROLE directpilot_app WITH LOGIN NOSUPERUSER NOCREATEDB ' + 'NOCREATEROLE NOREPLICATION NOBYPASSRLS NOINHERIT PASSWORD %L', + current_setting('directpilot.runtime_role_password', true) + ); + END + $$; + """ + ) + ) + connection.execute(text("REVOKE ALL ON SCHEMA public FROM PUBLIC")) + connection.execute(text("GRANT USAGE ON SCHEMA public TO directpilot_app")) + + +def grant_runtime_schema_readiness(connection: Connection) -> None: + """Permit the app role to read the Alembic version without schema ownership.""" + + connection.execute(text("GRANT SELECT ON TABLE alembic_version TO directpilot_app")) + + +def get_role_attributes(runtime: DatabaseRuntime) -> RuntimeRoleAttributes: + with runtime.engine.connect() as connection: + row = connection.execute( + text( + """ + SELECT rolname, rolsuper, rolbypassrls, rolcreatedb, rolcreaterole, rolreplication + FROM pg_roles + WHERE rolname = :role_name + """ + ), + {"role_name": RUNTIME_ROLE}, + ).mappings().one_or_none() + + if row is None: + raise DatabaseRoleError("runtime role is missing") + + return RuntimeRoleAttributes( + name=str(row["rolname"]), + is_superuser=bool(row["rolsuper"]), + can_bypass_rls=bool(row["rolbypassrls"]), + can_create_database=bool(row["rolcreatedb"]), + can_create_role=bool(row["rolcreaterole"]), + can_replicate=bool(row["rolreplication"]), + ) + + +def get_table_owner(runtime: DatabaseRuntime, table_name: str) -> str: + with runtime.engine.connect() as connection: + owner = connection.execute( + text( + """ + SELECT tableowner + FROM pg_tables + WHERE schemaname = 'public' AND tablename = :table_name + """ + ), + {"table_name": table_name}, + ).scalar_one_or_none() + + if owner is None: + raise DatabaseRoleError("required schema table is missing") + return str(owner) diff --git a/app/db/schema.py b/app/db/schema.py new file mode 100644 index 0000000..49ceac6 --- /dev/null +++ b/app/db/schema.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from alembic.script import ScriptDirectory +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError + +from app.db.engine import DatabaseRuntime +from app.db.migrations.runner import migration_config + + +class SchemaCompatibilityError(RuntimeError): + """Raised when the runtime role cannot prove the expected schema revision.""" + + +def expected_schema_revision() -> str: + revision = ScriptDirectory.from_config(migration_config()).get_current_head() + if revision is None: + raise SchemaCompatibilityError("database schema is incompatible") + return revision + + +def check_schema_compatibility(runtime: DatabaseRuntime) -> str: + """Fail closed unless the runtime role sees exactly the current migration head.""" + + expected = expected_schema_revision() + try: + with runtime.engine.connect() as connection: + actual = connection.execute(text("SELECT version_num FROM alembic_version")).scalar_one() + except SQLAlchemyError: + raise SchemaCompatibilityError("database schema is incompatible") from None + + if actual != expected: + raise SchemaCompatibilityError("database schema is incompatible") + return expected diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..78094f9 --- /dev/null +++ b/app/modules/__init__.py @@ -0,0 +1 @@ +# Persistence modules introduced in P2. diff --git a/app/modules/actions/__init__.py b/app/modules/actions/__init__.py new file mode 100644 index 0000000..2d99a19 --- /dev/null +++ b/app/modules/actions/__init__.py @@ -0,0 +1,3 @@ +from app.modules.actions.idempotency import PostgresIdempotencyRepository + +__all__ = ["PostgresIdempotencyRepository"] diff --git a/app/modules/actions/idempotency.py b/app/modules/actions/idempotency.py new file mode 100644 index 0000000..aa0cda2 --- /dev/null +++ b/app/modules/actions/idempotency.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.orm import Session, sessionmaker + +from app.db.models import IdempotencyRecord + + +class IdempotencyError(RuntimeError): + """Base failure for the durable idempotency protocol.""" + + +class IdempotencyConflictError(IdempotencyError): + """Raised when one namespace/key is reused with a different request.""" + + +class IdempotencyStateError(IdempotencyError): + """Raised when a non-owner attempts to finalize a stored claim.""" + + +@dataclass(frozen=True, slots=True) +class IdempotencyClaim: + record_id: UUID + namespace: str + key_hash: str + request_hash: str + status: str + result: dict[str, Any] | None + is_owner: bool + + +_SENSITIVE_RESULT_KEYS = frozenset( + { + "access_token", + "api_key", + "authorization", + "client_secret", + "oauth_token", + "password", + "raw_provider_payload", + "secret", + "token", + } +) +_FINAL_STATUSES = frozenset({"failed", "succeeded"}) + + +def canonical_request_hash(payload: Mapping[str, Any]) -> str: + """Hash an order-independent JSON representation without storing the payload.""" + + try: + canonical = json.dumps( + payload, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + except (TypeError, ValueError): + raise IdempotencyError("idempotency request is not canonical JSON") from None + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _key_hash(key: str) -> str: + return hashlib.sha256(key.encode("utf-8")).hexdigest() + + +def _sanitize_result(value: Any) -> Any: + if isinstance(value, Mapping): + return { + str(key): _sanitize_result(item) + for key, item in value.items() + if str(key).lower() not in _SENSITIVE_RESULT_KEYS + } + if isinstance(value, list): + return [_sanitize_result(item) for item in value] + if value is None or isinstance(value, str | int | float | bool): + return value + raise IdempotencyError("idempotency result is not JSON") + + +class PostgresIdempotencyRepository: + """PostgreSQL uniqueness and row-locking implementation for P2 idempotency.""" + + def __init__(self, sessions: sessionmaker[Session]) -> None: + self._sessions = sessions + + def claim( + self, + namespace: str, + key: str, + payload: Mapping[str, Any], + ) -> IdempotencyClaim: + if not namespace or not key: + raise IdempotencyError("idempotency namespace and key are required") + + key_hash = _key_hash(key) + request_hash = canonical_request_hash(payload) + candidate = IdempotencyRecord( + id=uuid4(), + namespace=namespace, + key_hash=key_hash, + request_hash=request_hash, + status="pending", + result=None, + ) + with self._sessions() as session: + with session.begin(): + inserted = session.execute( + insert(IdempotencyRecord) + .values( + id=candidate.id, + namespace=candidate.namespace, + key_hash=candidate.key_hash, + request_hash=candidate.request_hash, + status=candidate.status, + ) + .on_conflict_do_nothing(index_elements=["namespace", "key_hash"]) + .returning(IdempotencyRecord) + ).scalar_one_or_none() + if inserted is not None: + return self._to_claim(inserted, is_owner=True) + + existing = session.scalar( + select(IdempotencyRecord) + .where( + IdempotencyRecord.namespace == namespace, + IdempotencyRecord.key_hash == key_hash, + ) + .with_for_update() + ) + if existing is None: + raise IdempotencyError("idempotency record is unavailable") + if existing.request_hash != request_hash: + raise IdempotencyConflictError("idempotency key conflicts with a prior request") + return self._to_claim(existing, is_owner=False) + + def complete( + self, + claim: IdempotencyClaim, + *, + status: str, + result: Mapping[str, Any], + ) -> IdempotencyClaim: + if not claim.is_owner: + raise IdempotencyStateError("only the original idempotency claimant can finalize") + if status not in _FINAL_STATUSES: + raise IdempotencyStateError("idempotency status is invalid") + + safe_result = _sanitize_result(result) + if not isinstance(safe_result, dict): + raise IdempotencyError("idempotency result is not an object") + + with self._sessions() as session: + with session.begin(): + record = session.scalar( + select(IdempotencyRecord) + .where(IdempotencyRecord.id == claim.record_id) + .with_for_update() + ) + if record is None or record.request_hash != claim.request_hash: + raise IdempotencyStateError("idempotency claim is unavailable") + if record.status != "pending": + raise IdempotencyStateError("idempotency claim was already finalized") + record.status = status + record.result = safe_result + record.completed_at = datetime.now(timezone.utc) + session.flush() + return self._to_claim(record, is_owner=True) + + @staticmethod + def _to_claim(record: IdempotencyRecord, *, is_owner: bool) -> IdempotencyClaim: + return IdempotencyClaim( + record_id=record.id, + namespace=record.namespace, + key_hash=record.key_hash, + request_hash=record.request_hash, + status=record.status, + result=record.result, + is_owner=is_owner, + ) diff --git a/app/modules/audit/__init__.py b/app/modules/audit/__init__.py new file mode 100644 index 0000000..f19c3b2 --- /dev/null +++ b/app/modules/audit/__init__.py @@ -0,0 +1,3 @@ +from app.modules.audit.repository import PostgresAuditRepository + +__all__ = ["PostgresAuditRepository"] diff --git a/app/modules/audit/repository.py b/app/modules/audit/repository.py new file mode 100644 index 0000000..389bbe4 --- /dev/null +++ b/app/modules/audit/repository.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any +from uuid import uuid4 + +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from app.db.models import AuditEventRecord +from app.models import AuditEvent + +_SAFE_METADATA_KEYS = frozenset( + { + "item_count", + "mode", + "operation", + "outcome", + "provider", + "reason_code", + "request_id", + } +) +_SAFE_METADATA_VALUE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$") + + +def sanitize_audit_metadata(details: Mapping[str, Any] | None) -> dict[str, str | int | bool]: + """Keep only bounded, non-nested metadata that belongs in an audit record.""" + + if details is None: + return {} + + sanitized: dict[str, str | int | bool] = {} + for key, value in details.items(): + if key not in _SAFE_METADATA_KEYS: + continue + if isinstance(value, bool): + sanitized[key] = value + elif isinstance(value, int) and -1_000_000 <= value <= 1_000_000: + sanitized[key] = value + elif isinstance(value, str) and _SAFE_METADATA_VALUE.fullmatch(value): + sanitized[key] = value + return sanitized + + +class PostgresAuditRepository: + """Append-only PostgreSQL implementation of the P1 audit seam.""" + + def __init__(self, sessions: sessionmaker[Session]) -> None: + self._sessions = sessions + + def append_audit( + self, + action: str, + entity: str, + *, + actor: str = "agent", + dry_run: bool = True, + details: Mapping[str, Any] | None = None, + ) -> AuditEvent: + record = AuditEventRecord( + id=uuid4(), + actor=actor, + action=action, + entity=entity, + dry_run=dry_run, + details=sanitize_audit_metadata(details) or None, + ) + with self._sessions() as session: + with session.begin(): + session.add(record) + + return self._to_model(record) + + @property + def audit_events(self) -> list[AuditEvent]: + with self._sessions() as session: + records = session.scalars( + select(AuditEventRecord).order_by( + AuditEventRecord.occurred_at, + AuditEventRecord.id, + ) + ).all() + return [self._to_model(record) for record in records] + + @staticmethod + def _to_model(record: AuditEventRecord) -> AuditEvent: + return AuditEvent( + id=str(record.id), + actor=record.actor, + action=record.action, + entity=record.entity, + dry_run=record.dry_run, + details=record.details, + ) diff --git a/app/repositories/postgres_store.py b/app/repositories/postgres_store.py new file mode 100644 index 0000000..8b14b5e --- /dev/null +++ b/app/repositories/postgres_store.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import hashlib +from collections.abc import Mapping +from datetime import datetime, timezone +from types import MappingProxyType +from typing import Any, Callable, Literal + +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from app.db.models import CampaignDraftRecord, SemanticChangePackageRecord +from app.models import ( + Campaign, + CampaignDraft, + CampaignDraftRequest, + SemanticChangePackage, + YandexControlResult, +) +from app.modules.actions.idempotency import ( + IdempotencyClaim, + IdempotencyStateError, + PostgresIdempotencyRepository, +) +from app.modules.audit.repository import PostgresAuditRepository +from app.store import MockStore + + +class PostgresLegacyStoreRepository: + """P2 persistence adapter: PostgreSQL owns drafts, packages, and audit records. + + The legacy engine is retained only to preserve existing draft/package behavior; + its process-local dictionaries are refreshed from and written back to PostgreSQL + for every persistent P2 operation. + """ + + def __init__(self, sessions: sessionmaker[Session]) -> None: + self._sessions = sessions + self._audit = PostgresAuditRepository(sessions) + self._idempotency = PostgresIdempotencyRepository(sessions) + self._legacy = MockStore() + self._legacy.append_audit = self.append_audit + + @property + def campaigns(self) -> Mapping[str, Campaign]: + return { + draft.id: Campaign( + id=draft.id, + name=draft.name or f"Draft: {draft.business_type} / {draft.region}", + business_type=draft.business_type, + status=draft.status, + ) + for draft in self.drafts.values() + } + + @property + def drafts(self) -> Mapping[str, CampaignDraft]: + self._hydrate_drafts() + return self._legacy.drafts + + @property + def recommendations(self) -> Mapping[str, Any]: + return MappingProxyType({}) + + @property + def audit_events(self) -> list[Any]: + return self._audit.audit_events + + def append_audit(self, *args: Any, **kwargs: Any) -> Any: + return self._audit.append_audit(*args, **kwargs) + + def create_draft(self, payload: CampaignDraftRequest) -> CampaignDraft: + self._hydrate_drafts() + draft = self._legacy.create_draft(payload) + self._save_draft(draft) + return draft + + def update_draft_base(self, draft_id: str, updates: dict[str, Any]) -> CampaignDraft: + return self._mutate_draft("update_draft_base", draft_id, updates) + + def replace_keywords(self, draft_id: str, keywords: list[str]) -> CampaignDraft: + return self._mutate_draft("replace_keywords", draft_id, keywords) + + def append_keywords(self, draft_id: str, keywords: list[str]) -> CampaignDraft: + return self._mutate_draft("append_keywords", draft_id, keywords) + + def remove_keywords(self, draft_id: str, keywords: list[str]) -> CampaignDraft: + return self._mutate_draft("remove_keywords", draft_id, keywords) + + def replace_negative_keywords(self, draft_id: str, payload: Any) -> CampaignDraft: + return self._mutate_draft("replace_negative_keywords", draft_id, payload) + + def create_ad_group(self, draft_id: str, payload: Any) -> CampaignDraft: + return self._mutate_draft("create_ad_group", draft_id, payload) + + def update_ad_group(self, draft_id: str, group_id: str, payload: Any) -> CampaignDraft: + return self._mutate_draft("update_ad_group", draft_id, group_id, payload) + + def delete_ad_group(self, draft_id: str, group_id: str) -> CampaignDraft: + return self._mutate_draft("delete_ad_group", draft_id, group_id) + + def create_ad(self, draft_id: str, payload: Any) -> CampaignDraft: + return self._mutate_draft("create_ad", draft_id, payload) + + def update_ad(self, draft_id: str, ad_id: str, payload: Any) -> CampaignDraft: + return self._mutate_draft("update_ad", draft_id, ad_id, payload) + + def delete_ad(self, draft_id: str, ad_id: str) -> CampaignDraft: + return self._mutate_draft("delete_ad", draft_id, ad_id) + + def generate_structure(self, draft_id: str, payload: Any) -> CampaignDraft: + return self._mutate_draft("generate_structure", draft_id, payload) + + def update_budget(self, draft_id: str, payload: Any) -> CampaignDraft: + return self._mutate_draft("update_budget", draft_id, payload) + + def update_bids(self, draft_id: str, payload: Any) -> CampaignDraft: + return self._mutate_draft("update_bids", draft_id, payload) + + def validate_draft(self, draft_id: str) -> Any: + self._hydrate_drafts() + return self._legacy.validate_draft(draft_id) + + def preview_draft(self, draft_id: str) -> Any: + self._hydrate_drafts() + return self._legacy.preview_draft(draft_id) + + def save_semantic_package(self, package: SemanticChangePackage) -> None: + payload = package.model_dump(mode="json") + with self._sessions() as session: + with session.begin(): + record = session.get(SemanticChangePackageRecord, package.package_id) + if record is None: + session.add( + SemanticChangePackageRecord(package_id=package.package_id, payload=payload) + ) + else: + record.payload = payload + record.updated_at = datetime.now(timezone.utc) + + def get_semantic_package(self, package_id: str) -> SemanticChangePackage: + with self._sessions() as session: + record = session.get(SemanticChangePackageRecord, package_id) + if record is None: + raise KeyError("semantic_change_package_not_found") + return SemanticChangePackage.model_validate(record.payload) + + def prepare_semantic_change_package(self, *args: Any, **kwargs: Any) -> SemanticChangePackage: + self._hydrate_packages() + package = self._legacy.prepare_semantic_change_package(*args, **kwargs) + self.save_semantic_package(package) + return package + + def apply_semantic_change(self, *args: Any, **kwargs: Any) -> Any: + self._hydrate_packages() + result = self._legacy.apply_semantic_change(*args, **kwargs) + for package in self._legacy.semantic_packages_by_id.values(): + self.save_semantic_package(package) + return result + + def _mutate_draft(self, method: str, *args: Any) -> CampaignDraft: + self._hydrate_drafts() + handler: Callable[..., CampaignDraft] = getattr(self._legacy, method) + draft = handler(*args) + self._save_draft(draft) + return draft + + def _hydrate_drafts(self) -> None: + with self._sessions() as session: + records = session.scalars(select(CampaignDraftRecord)).all() + self._legacy.drafts = { + record.id: CampaignDraft.model_validate(record.payload) for record in records + } + + def _save_draft(self, draft: CampaignDraft) -> None: + payload = draft.model_dump(mode="json") + with self._sessions() as session: + with session.begin(): + record = session.get(CampaignDraftRecord, draft.id) + if record is None: + session.add(CampaignDraftRecord(id=draft.id, payload=payload)) + else: + record.payload = payload + record.updated_at = datetime.now(timezone.utc) + + def _hydrate_packages(self) -> None: + with self._sessions() as session: + records = session.scalars(select(SemanticChangePackageRecord)).all() + self._legacy.semantic_packages_by_id = { + record.package_id: SemanticChangePackage.model_validate(record.payload) + for record in records + } + + def live_create_campaign(self, *args: Any, **kwargs: Any) -> Any: + self._hydrate_drafts() + return self._legacy.live_create_campaign(*args, **kwargs) + + @staticmethod + def _idempotency_namespace(operation: str, subject: str) -> str: + subject_hash = hashlib.sha256(subject.encode("utf-8")).hexdigest()[:32] + return f"{operation}:{subject_hash}" + + @staticmethod + def _replay_control(claim: IdempotencyClaim) -> YandexControlResult: + if claim.status == "succeeded" and isinstance(claim.result, dict): + return YandexControlResult.model_validate(claim.result) + raise IdempotencyStateError("idempotency request is not replayable") + + def yandex_control( + self, + campaign_id: str, + action: Literal["pause", "resume"], + payload: Any, + **kwargs: Any, + ) -> YandexControlResult: + claim = self._idempotency.claim( + self._idempotency_namespace("yandex_control", f"{action}:{campaign_id}"), + payload.idempotency_key, + { + "action": action, + "campaign_id": campaign_id, + "payload": payload.model_dump(mode="json"), + }, + ) + if not claim.is_owner: + return self._replay_control(claim) + + try: + result = self._legacy.yandex_control(campaign_id, action, payload, **kwargs) + except Exception as error: + self._idempotency.complete( + claim, + status="failed", + result={"error_type": type(error).__name__}, + ) + raise + + completed = self._idempotency.complete( + claim, + status="succeeded", + result=result.model_dump(mode="json"), + ) + return self._replay_control(completed) + + def __getattr__(self, name: str) -> Any: + return getattr(self._legacy, name) diff --git a/deploy/compose.dev.yml b/deploy/compose.dev.yml new file mode 100644 index 0000000..8b314c6 --- /dev/null +++ b/deploy/compose.dev.yml @@ -0,0 +1,29 @@ +name: directpilot-p2-dev + +services: + postgres: + image: postgres:17.7-bookworm + restart: unless-stopped + environment: + POSTGRES_DB: ${DIRECTPILOT_POSTGRES_DB:?set a development database name} + POSTGRES_USER: directpilot_owner + POSTGRES_PASSWORD: ${DIRECTPILOT_POSTGRES_OWNER_PASSWORD:?set a non-production development password} + POSTGRES_INITDB_ARGS: --auth=scram-sha-256 + ports: + - "127.0.0.1:${DIRECTPILOT_POSTGRES_PORT:-55432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U directpilot_owner -d $$POSTGRES_DB"] + interval: 3s + timeout: 3s + retries: 20 + start_period: 5s + volumes: + - directpilot_dev_postgres_data:/var/lib/postgresql/data + networks: + - directpilot_dev + +networks: + directpilot_dev: {} + +volumes: + directpilot_dev_postgres_data: diff --git a/deploy/compose.test.yml b/deploy/compose.test.yml new file mode 100644 index 0000000..af26a7f --- /dev/null +++ b/deploy/compose.test.yml @@ -0,0 +1,28 @@ +name: directpilot-p2-test + +services: + postgres: + image: postgres:17.7-bookworm + environment: + POSTGRES_DB: ${DIRECTPILOT_POSTGRES_DB:?set an isolated test database name} + POSTGRES_USER: directpilot_owner + POSTGRES_PASSWORD: ${DIRECTPILOT_POSTGRES_OWNER_PASSWORD:?set an isolated synthetic test password} + POSTGRES_INITDB_ARGS: --auth=scram-sha-256 + ports: + - "127.0.0.1:${DIRECTPILOT_POSTGRES_PORT:-55432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U directpilot_owner -d $$POSTGRES_DB"] + interval: 3s + timeout: 3s + retries: 20 + start_period: 5s + volumes: + - directpilot_test_postgres_data:/var/lib/postgresql/data + networks: + - directpilot_test + +networks: + directpilot_test: {} + +volumes: + directpilot_test_postgres_data: diff --git a/docs/operations/backup-restore.md b/docs/operations/backup-restore.md new file mode 100644 index 0000000..557a1e7 --- /dev/null +++ b/docs/operations/backup-restore.md @@ -0,0 +1,17 @@ +# P2 PostgreSQL backup and restore runbook + +Scope: P2 audit, idempotency, campaign-draft, and semantic-package tables. Run export and restore only through approved secret injection; do not place a password or connection string in this document, shell history, `.env`, source control, or a receipt. + +1. Quiesce writes for the approved maintenance window and run `pg_dump` with the schema-owner identity into encrypted approved storage. +2. Restore with `pg_restore --exit-on-error` into a separate restored database. Never restore over the source database during rehearsal. +3. Verify the restored database is distinct from the source and compare the row count plus a deterministic SHA-256 signature of allowlisted synthetic records. +4. Run schema compatibility with the runtime role before reopening the application. If any check fails, keep the source database unchanged and fail closed. +5. Keep a sanitized receipt containing only database labels, migration head, PostgreSQL server version number, record count, deterministic record signature, and status. + +The reproducible isolated rehearsal uses PostgreSQL 17.7-bookworm and writes its safe receipt to `artifacts/p2-backup-restore-rehearsal.json` (ignored by Git): + +```text +DIRECTPILOT_DOCKER_BIN=docker uv run pytest tests/integration/test_backup_restore.py -q +``` + +The rehearsal removes the dump and the separate restored database after verification. The receipt intentionally contains no credentials, connection strings, raw payloads, or audit metadata. diff --git a/docs/operations/dependency-admission.md b/docs/operations/dependency-admission.md new file mode 100644 index 0000000..6bbea8c --- /dev/null +++ b/docs/operations/dependency-admission.md @@ -0,0 +1,11 @@ +# P2 dependency admission + +The P2 runtime additions are lockfile-pinned and kept to the PostgreSQL persistence path. + +| Dependency | Locked version | Purpose | license | Admission and vulnerability control | +| --- | --- | --- | --- | --- | +| SQLAlchemy | 2.0.52 | SQLAlchemy 2 synchronous engine, sessions, and metadata | MIT | Required for the approved persistence seam. Lockfile review and the deterministic dependency audit must pass before release. | +| psycopg | 3.2.13 | PostgreSQL 17+ DBAPI driver for SQLAlchemy | LGPL-3.0-only | Required for PostgreSQL access; the binary extra is used only for the supported isolated runtime. Lockfile review and the deterministic dependency audit must pass before release. | +| Alembic | 1.17.2 | Deterministic schema versioning and upgrade checks | MIT | Required for migration ownership and compatibility checks. Lockfile review and the deterministic dependency audit must pass before release. | + +The dependency audit result is evidence, not an authorization to change versions. Any vulnerability finding or license change requires security review before updating the lockfile. No dependency in this document handles provider credentials or causes live provider writes. diff --git a/docs/operations/migrations.md b/docs/operations/migrations.md new file mode 100644 index 0000000..ad885cc --- /dev/null +++ b/docs/operations/migrations.md @@ -0,0 +1,20 @@ +# P2 PostgreSQL migration runbook + +Scope: P2 schema only. Run this procedure with the schema-owner connection supplied by the deployment secret mechanism. Do not use the runtime app role, a local `.env` file, or a connection value copied into a terminal transcript. + +1. Confirm the target is an empty database or a known P2 revision. The migration owner is `directpilot_owner`; the runtime role remains non-owner and has no schema-changing privileges. +2. Take an approved backup before changing a non-empty database. Follow `docs/operations/backup-restore.md`. +3. In the release runner, construct the owner `DatabaseRuntime` from the securely injected owner configuration and call `app.db.migrations.runner.upgrade_database(owner_runtime, "head")`. +4. Run `app.db.schema.check_schema_compatibility(app_runtime)` with the runtime app role. A missing, outdated, or incompatible revision must fail closed; do not start the application in that state. +5. Record only revision identifiers and pass/fail status. Do not record URLs, passwords, tokens, or SQL parameter values. + +Pre-release proof on an isolated PostgreSQL 17.7-bookworm container: + +```text +DIRECTPILOT_DOCKER_BIN=docker uv run pytest \ + tests/integration/test_migrations_and_roles.py::test_owner_migrates_empty_database_and_runtime_role_is_nonowner -q +DIRECTPILOT_DOCKER_BIN=docker uv run pytest \ + tests/integration/test_migrations_and_roles.py::test_owner_upgrades_from_prior_p2_revision_to_current_head -q +``` + +The first command proves an empty database install. The second proves upgrade from the prior P2 revision to head. Both commands create and remove an isolated loopback-only Compose service. diff --git a/pyproject.toml b/pyproject.toml index 3ab6531..944fb1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,9 @@ dependencies = [ "uvicorn[standard]>=0.29", "pydantic-settings>=2.2", "httpx[socks]>=0.27", + "sqlalchemy>=2.0,<2.1", + "psycopg[binary]>=3.2,<3.3", + "alembic>=1.14,<1.18", ] [dependency-groups] @@ -25,6 +28,7 @@ pythonpath = ["."] testpaths = ["tests"] asyncio_mode = "auto" markers = [ + "integration: uses an isolated real PostgreSQL Docker Compose service", "live_smoke: opt-in tests that call external sandbox/live APIs and are skipped unless explicitly enabled", ] @@ -40,6 +44,8 @@ include = [ "app/api/wordstat_router.py", "app/bootstrap/application.py", "app/bootstrap/dependencies.py", + "app/db/**/*.py", + "app/modules/**/*.py", "app/main.py", "app/core/**/*.py", "app/providers/**/*.py", @@ -61,6 +67,8 @@ files = [ "app/api/wordstat_router.py", "app/bootstrap/application.py", "app/bootstrap/dependencies.py", + "app/db", + "app/modules", "app/main.py", "app/core", "app/providers", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..0dc9826 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import os +import socket +import subprocess +from collections.abc import Callable, Generator +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + + +_WORKTREE = Path(__file__).resolve().parents[2] +_COMPOSE = _WORKTREE / "deploy" / "compose.test.yml" +_DOCKER = os.environ.get("DIRECTPILOT_DOCKER_BIN", "/tmp/directpilot-p2-bin/docker") +_OWNER_PASSWORD = "synthetic-test-owner-password" +_APP_PASSWORD = "synthetic-test-app-password" + + +@dataclass(frozen=True, slots=True) +class PostgresService: + port: int + owner_url: str = field(repr=False) + app_url: str = field(repr=False) + app_password: str = field(repr=False) + command_prefix: tuple[str, ...] = field(repr=False) + + +def _available_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + +def _run_compose(arguments: list[str], *, timeout: int = 120) -> subprocess.CompletedProcess[str]: + return subprocess.run( + arguments, + cwd=_WORKTREE, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + + +@pytest.fixture +def postgres_service( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[PostgresService, None, None]: + port = _available_loopback_port() + project_name = f"directpilot-p2-{port}" + environment_file = tmp_path_factory.mktemp("postgres-compose") / "compose.env" + environment_file.write_text( + "\n".join( + ( + "DIRECTPILOT_POSTGRES_DB=directpilot_test", + f"DIRECTPILOT_POSTGRES_OWNER_PASSWORD={_OWNER_PASSWORD}", + f"DIRECTPILOT_POSTGRES_PORT={port}", + "", + ) + ), + encoding="utf-8", + ) + command_prefix = [ + _DOCKER, + "compose", + "--project-name", + project_name, + "--env-file", + str(environment_file), + "-f", + str(_COMPOSE), + ] + start = _run_compose([*command_prefix, "up", "--detach", "--wait", "--wait-timeout", "90"]) + if start.returncode != 0: + _run_compose([*command_prefix, "down", "--volumes", "--remove-orphans"]) + pytest.fail(f"isolated PostgreSQL startup failed (exit {start.returncode})") + + service = PostgresService( + port=port, + owner_url=( + "postgresql+psycopg://directpilot_owner:" + f"{_OWNER_PASSWORD}@127.0.0.1:{port}/directpilot_test" + ), + app_url=( + "postgresql+psycopg://directpilot_app:" + f"{_APP_PASSWORD}@127.0.0.1:{port}/directpilot_test" + ), + app_password=_APP_PASSWORD, + command_prefix=tuple(command_prefix), + ) + try: + yield service + finally: + stop = _run_compose([*command_prefix, "down", "--volumes", "--remove-orphans"]) + if stop.returncode != 0: + pytest.fail(f"isolated PostgreSQL cleanup failed (exit {stop.returncode})") + + +@pytest.fixture +def postgres_url(postgres_service: PostgresService) -> str: + return postgres_service.owner_url + + +@pytest.fixture +def restart_postgres(postgres_service: PostgresService) -> Callable[[], None]: + def restart() -> None: + restarted = _run_compose([*postgres_service.command_prefix, "restart", "postgres"]) + if restarted.returncode != 0: + pytest.fail(f"isolated PostgreSQL restart failed (exit {restarted.returncode})") + ready = _run_compose( + [ + *postgres_service.command_prefix, + "up", + "--detach", + "--wait", + "--wait-timeout", + "90", + ] + ) + if ready.returncode != 0: + pytest.fail(f"isolated PostgreSQL readiness failed (exit {ready.returncode})") + + return restart diff --git a/tests/integration/test_application_persistence_runtime.py b/tests/integration/test_application_persistence_runtime.py new file mode 100644 index 0000000..3c20964 --- /dev/null +++ b/tests/integration/test_application_persistence_runtime.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import pytest + +from app.bootstrap.dependencies import create_application_dependencies +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.repositories.postgres_store import PostgresLegacyStoreRepository + + +def test_production_dependencies_use_postgresql_repository_after_schema_readiness( + monkeypatch: pytest.MonkeyPatch, + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + monkeypatch.setenv("DIRECTPILOT_APP_ENV", "production") + monkeypatch.setenv("DIRECTPILOT_DATABASE_URL", getattr(postgres_service, "app_url")) + + dependencies = create_application_dependencies() + try: + assert isinstance(dependencies.repository, PostgresLegacyStoreRepository) + assert dependencies.database_runtime is not None + finally: + if dependencies.database_runtime is not None: + dependencies.database_runtime.close() + finally: + owner_runtime.close() diff --git a/tests/integration/test_audit_repository.py b/tests/integration/test_audit_repository.py new file mode 100644 index 0000000..f1645a3 --- /dev/null +++ b/tests/integration/test_audit_repository.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from collections.abc import Callable + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.modules.audit.repository import PostgresAuditRepository + + +def test_app_role_persists_sanitized_append_only_audit_across_restart( + postgres_service: object, + restart_postgres: Callable[[], None], +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + repository = PostgresAuditRepository(app_runtime.sessions) + event = repository.append_audit( + "campaign_draft_created", + "draft-001", + actor="operator", + dry_run=True, + details={ + "request_id": "request-001", + "item_count": 3, + "mode": "live_readonly", + "authorization": "discard-this", + "raw_provider_payload": {"untrusted": "discard-this"}, + }, + ) + + assert event.details == { + "request_id": "request-001", + "item_count": 3, + "mode": "live_readonly", + } + + with app_runtime.engine.connect() as connection: + with pytest.raises(DBAPIError): + connection.execute(text("UPDATE audit_events SET action = 'changed'")) + connection.rollback() + with pytest.raises(DBAPIError): + connection.execute(text("DELETE FROM audit_events")) + connection.rollback() + + restart_postgres() + app_runtime.close() + reloaded_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + persisted = PostgresAuditRepository(reloaded_runtime.sessions).audit_events + finally: + reloaded_runtime.close() + + assert [item.id for item in persisted] == [event.id] + assert persisted[0].details == event.details + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_backup_restore.py b/tests/integration/test_backup_restore.py new file mode 100644 index 0000000..588aefc --- /dev/null +++ b/tests/integration/test_backup_restore.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import hashlib +import json +import shlex +import subprocess +from pathlib import Path + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.modules.audit.repository import PostgresAuditRepository + + +_ROOT = Path(__file__).resolve().parents[2] +_RECEIPT_PATH = _ROOT / "artifacts" / "p2-backup-restore-rehearsal.json" +_SOURCE_DATABASE = "directpilot_test" +_RESTORED_DATABASE = "directpilot_restore" + + +def test_isolated_postgresql_backup_restore_preserves_synthetic_audit_invariants( + postgres_service: object, +) -> None: + """Exercise a real isolated dump and restore without retaining connection data.""" + + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + command_prefix = list(getattr(postgres_service, "command_prefix")) + dump_path = "/tmp/directpilot-p2-backup-restore.dump" + _RECEIPT_PATH.unlink(missing_ok=True) + + def execute(stage: str, *arguments: str) -> subprocess.CompletedProcess[str]: + shell_command = ( + 'export PGPASSWORD="$POSTGRES_PASSWORD"; exec ' + f"{shlex.join(arguments)}" + ) + try: + return subprocess.run( + [ + *command_prefix, + "exec", + "-T", + "postgres", + "sh", + "-ec", + shell_command, + ], + check=False, + capture_output=True, + text=True, + timeout=45, + ) + except subprocess.TimeoutExpired as error: + raise AssertionError(f"isolated PostgreSQL {stage} exceeded 45 seconds") from error + + def signature(database: str) -> tuple[int, str]: + count = execute( + f"{database} audit count", + "psql", + "-v", + "ON_ERROR_STOP=1", + "-U", + "directpilot_owner", + "-d", + database, + "-tAc", + "SELECT count(*) FROM audit_events", + ) + assert count.returncode == 0 + rows = execute( + f"{database} audit signature", + "psql", + "-v", + "ON_ERROR_STOP=1", + "-U", + "directpilot_owner", + "-d", + database, + "-tA", + "-c", + ( + "SELECT id::text || '|' || actor || '|' || action || '|' || entity " + "|| '|' || dry_run::text || '|' || COALESCE(details::text, '') " + "FROM audit_events ORDER BY id" + ), + ) + assert rows.returncode == 0 + return int(count.stdout.strip()), hashlib.sha256(rows.stdout.encode()).hexdigest() + + try: + assert _SOURCE_DATABASE != _RESTORED_DATABASE + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + PostgresAuditRepository(app_runtime.sessions).append_audit( + "backup_restore_rehearsal", + "synthetic-record", + details={"request_id": "backup-restore-001", "mode": "mock"}, + ) + source_count, source_hash = signature(_SOURCE_DATABASE) + assert source_count == 1 + + dumped = execute( + "dump", + "pg_dump", + "-U", + "directpilot_owner", + "-d", + _SOURCE_DATABASE, + "--format=custom", + "--no-owner", + "--no-privileges", + f"--file={dump_path}", + ) + assert dumped.returncode == 0 + removed = execute( + "remove stale restore database", + "dropdb", + "-U", + "directpilot_owner", + "--if-exists", + "--force", + _RESTORED_DATABASE, + ) + assert removed.returncode == 0 + created = execute( + "create restore database", + "createdb", + "-U", + "directpilot_owner", + _RESTORED_DATABASE, + ) + assert created.returncode == 0 + restored = execute( + "restore", + "pg_restore", + "--exit-on-error", + "--no-owner", + "--no-privileges", + "-U", + "directpilot_owner", + "-d", + _RESTORED_DATABASE, + dump_path, + ) + assert restored.returncode == 0 + restored_count, restored_hash = signature(_RESTORED_DATABASE) + assert restored_count == source_count + assert restored_hash == source_hash + + version = execute( + "server version", + "psql", + "-U", + "directpilot_owner", + "-d", + _SOURCE_DATABASE, + "-tAc", + "SHOW server_version_num", + ) + assert version.returncode == 0 + _RECEIPT_PATH.parent.mkdir(exist_ok=True) + _RECEIPT_PATH.write_text( + json.dumps( + { + "database_source": _SOURCE_DATABASE, + "database_restored": _RESTORED_DATABASE, + "migration_head": "20260904_0003", + "postgresql_server_version_num": int(version.stdout.strip()), + "record_count": source_count, + "record_signature_sha256": source_hash, + "schema": "directpilot.p2.backup-restore-rehearsal.v1", + "status": "passed", + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + assert _RECEIPT_PATH.is_file() + finally: + execute( + "remove restore database", + "dropdb", + "-U", + "directpilot_owner", + "--if-exists", + "--force", + _RESTORED_DATABASE, + ) + execute("remove dump", "rm", "-f", dump_path) + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_idempotency_repository.py b/tests/integration/test_idempotency_repository.py new file mode 100644 index 0000000..281b2f4 --- /dev/null +++ b/tests/integration/test_idempotency_repository.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +import threading + +import pytest + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.modules.actions.idempotency import ( + IdempotencyConflictError, + PostgresIdempotencyRepository, +) + + +def test_idempotency_replays_conflicts_serializes_concurrency_and_survives_restart( + postgres_service: object, + restart_postgres: Callable[[], None], +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + repository = PostgresIdempotencyRepository(app_runtime.sessions) + first = repository.claim( + "campaign.pause", + "idempotency-key-001", + {"campaign_id": 101, "dry_run": True}, + ) + assert first.is_owner is True + completed = repository.complete( + first, + status="succeeded", + result={"status": "ok", "token": "discard-this"}, + ) + assert completed.result == {"status": "ok"} + + replay = repository.claim( + "campaign.pause", + "idempotency-key-001", + {"dry_run": True, "campaign_id": 101}, + ) + assert replay.is_owner is False + assert replay.status == "succeeded" + assert replay.result == completed.result + + with pytest.raises(IdempotencyConflictError, match="idempotency key conflicts"): + repository.claim( + "campaign.pause", + "idempotency-key-001", + {"campaign_id": 102, "dry_run": True}, + ) + + restart_postgres() + app_runtime.close() + reloaded_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + reloaded = PostgresIdempotencyRepository(reloaded_runtime.sessions).claim( + "campaign.pause", + "idempotency-key-001", + {"campaign_id": 101, "dry_run": True}, + ) + assert reloaded.is_owner is False + assert reloaded.result == completed.result + finally: + reloaded_runtime.close() + + barrier = threading.Barrier(2) + + def concurrent_claim() -> bool: + barrier.wait() + claim = PostgresIdempotencyRepository(app_runtime.sessions).claim( + "campaign.pause", + "idempotency-key-concurrent", + {"campaign_id": 101, "dry_run": True}, + ) + return claim.is_owner + + with ThreadPoolExecutor(max_workers=2) as executor: + owners = list(executor.map(lambda _: concurrent_claim(), range(2))) + + assert owners.count(True) == 1 + assert owners.count(False) == 1 + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_migrations_and_roles.py b/tests/integration/test_migrations_and_roles.py new file mode 100644 index 0000000..9012c2a --- /dev/null +++ b/tests/integration/test_migrations_and_roles.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import pytest + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import downgrade_database, upgrade_database +from app.db.schema import ( + SchemaCompatibilityError, + check_schema_compatibility, + expected_schema_revision, +) +from app.db.roles import ( + bootstrap_database_roles, + get_role_attributes, + get_table_owner, +) + + +def test_owner_migrates_empty_database_and_runtime_role_is_nonowner( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + runtime_role = get_role_attributes(owner_runtime) + + assert runtime_role.name == "directpilot_app" + assert runtime_role.is_superuser is False + assert runtime_role.can_bypass_rls is False + assert runtime_role.can_create_database is False + assert runtime_role.can_create_role is False + assert runtime_role.can_replicate is False + assert get_table_owner(owner_runtime, "alembic_version") == "directpilot_owner" + finally: + owner_runtime.close() + + +def test_owner_upgrades_from_prior_p2_revision_to_current_head( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + downgrade_database(owner_runtime, revision="base") + upgrade_database(owner_runtime, revision="20260904_0001") + + with pytest.raises(SchemaCompatibilityError, match="database schema is incompatible"): + check_schema_compatibility(app_runtime) + + upgrade_database(owner_runtime) + + assert check_schema_compatibility(app_runtime) == expected_schema_revision() + assert get_table_owner(owner_runtime, "idempotency_records") == "directpilot_owner" + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_p1_idempotency_wiring.py b/tests/integration/test_p1_idempotency_wiring.py new file mode 100644 index 0000000..03c1b3e --- /dev/null +++ b/tests/integration/test_p1_idempotency_wiring.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from app.config import Settings +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.models import YandexControlRequest +from app.modules.actions.idempotency import IdempotencyConflictError +from app.repositories.postgres_store import PostgresLegacyStoreRepository + + +def test_postgres_p1_control_idempotency_replays_after_restart_and_rejects_conflict( + postgres_service: object, + restart_postgres: Callable[[], None], +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + settings = Settings(_env_file=None, directpilot_mode="mock", yandex_oauth_token=None) + payload = YandexControlRequest( + approved=True, + idempotency_key="persistent-control-key-001", + dry_run=True, + reason="rehearsal", + ) + + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + first = PostgresLegacyStoreRepository(app_runtime.sessions).yandex_control( + "campaign-p1-idempotency", + "pause", + payload, + settings=settings, + client=None, + ) + + restart_postgres() + app_runtime.close() + reloaded_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + repository = PostgresLegacyStoreRepository(reloaded_runtime.sessions) + replay = repository.yandex_control( + "campaign-p1-idempotency", + "pause", + payload, + settings=settings, + client=None, + ) + assert replay == first + + with pytest.raises(IdempotencyConflictError, match="idempotency key conflicts"): + repository.yandex_control( + "campaign-p1-idempotency", + "pause", + payload.model_copy(update={"reason": "different-request"}), + settings=settings, + client=None, + ) + finally: + reloaded_runtime.close() + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_persistent_product_stores.py b/tests/integration/test_persistent_product_stores.py new file mode 100644 index 0000000..9d993b9 --- /dev/null +++ b/tests/integration/test_persistent_product_stores.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from collections.abc import Callable + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.models import ( + CampaignDraftRequest, + LiveCreateCampaignRequest, + SemanticChangePackage, + SemanticChangePreview, +) +from app.repositories.postgres_store import PostgresLegacyStoreRepository + + +def test_product_draft_and_semantic_package_survive_process_and_database_restart( + postgres_service: object, + restart_postgres: Callable[[], None], +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + repository = PostgresLegacyStoreRepository(app_runtime.sessions) + draft = repository.create_draft( + CampaignDraftRequest( + business_type="services", + region="Казань", + monthly_budget=1000, + landing_url="https://example.invalid/landing", + ) + ) + repository.append_keywords(draft.id, ["services Kazan"]) + package = SemanticChangePackage( + package_id="package-001", + campaign_id="campaign-001", + mode="mock", + created_at="2026-09-04T00:00:00Z", + preview=SemanticChangePreview(operations=[]), + ) + repository.save_semantic_package(package) + + restart_postgres() + app_runtime.close() + reloaded_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + reloaded = PostgresLegacyStoreRepository(reloaded_runtime.sessions) + assert "services Kazan" in reloaded.drafts[draft.id].keywords + assert reloaded.get_semantic_package(package.package_id) == package + operation_repository = PostgresLegacyStoreRepository(reloaded_runtime.sessions) + preview = operation_repository.live_create_campaign( + LiveCreateCampaignRequest( + draft_id=draft.id, + approved=True, + idempotency_key="persistent-draft-preview-001", + dry_run=True, + ), + settings=None, + client=None, + ) + assert preview.draft_id == draft.id + assert preview.applied is False + finally: + reloaded_runtime.close() + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_postgresql_runtime.py b/tests/integration/test_postgresql_runtime.py new file mode 100644 index 0000000..fd999a0 --- /dev/null +++ b/tests/integration/test_postgresql_runtime.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from app.db.engine import ( + DatabaseSettings, + check_database_connection, + create_database_runtime, +) + + +def test_sync_runtime_connects_to_isolated_postgresql(postgres_service: object) -> None: + runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + + try: + assert check_database_connection(runtime) >= 170000 + finally: + runtime.close() diff --git a/tests/integration/test_schema_compatibility.py b/tests/integration/test_schema_compatibility.py new file mode 100644 index 0000000..486df5b --- /dev/null +++ b/tests/integration/test_schema_compatibility.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pytest + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.db.schema import ( + SchemaCompatibilityError, + check_schema_compatibility, + expected_schema_revision, +) + + +def test_runtime_schema_readiness_fails_closed_until_owner_migrates( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + with pytest.raises(SchemaCompatibilityError, match="database schema is incompatible"): + check_schema_compatibility(app_runtime) + + upgrade_database(owner_runtime) + + assert check_schema_compatibility(app_runtime) == expected_schema_revision() + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/test_application_persistence_wiring.py b/tests/test_application_persistence_wiring.py new file mode 100644 index 0000000..1bb4101 --- /dev/null +++ b/tests/test_application_persistence_wiring.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import pytest + +from app.bootstrap.dependencies import create_application_dependencies +from app.db.engine import DatabaseConfigurationError + + +def test_production_dependencies_fail_closed_without_postgresql(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DIRECTPILOT_APP_ENV", "production") + monkeypatch.delenv("DIRECTPILOT_DATABASE_URL", raising=False) + + with pytest.raises(DatabaseConfigurationError, match="database configuration is required"): + create_application_dependencies() diff --git a/tests/test_db_engine.py b/tests/test_db_engine.py new file mode 100644 index 0000000..fc6a296 --- /dev/null +++ b/tests/test_db_engine.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import pytest + +from app.db.engine import ( + DatabaseConfigurationError, + DatabaseConnectionError, + DatabaseSettings, + check_database_connection, + create_database_runtime, +) + + +def test_database_settings_fail_closed_when_url_is_absent() -> None: + with pytest.raises(DatabaseConfigurationError, match="database configuration is required"): + DatabaseSettings.from_mapping({}) + + +def test_database_settings_accepts_only_psycopg_postgresql_urls() -> None: + settings = DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": "postgresql+psycopg://app:synthetic@127.0.0.1:5432/directpilot"} + ) + + assert settings.url.drivername == "postgresql+psycopg" + assert settings.connect_timeout_seconds == 5 + + with pytest.raises(DatabaseConfigurationError, match="psycopg"): + DatabaseSettings.from_mapping({"DIRECTPILOT_DATABASE_URL": "sqlite:///unsafe.db"}) + + +def test_database_connection_check_fails_closed_without_a_reachable_server() -> None: + runtime = create_database_runtime( + DatabaseSettings.from_mapping( + { + "DIRECTPILOT_DATABASE_URL": ( + "postgresql+psycopg://app:synthetic@127.0.0.1:1/directpilot" + ) + } + ) + ) + + try: + with pytest.raises(DatabaseConnectionError, match="database connection is unavailable") as captured: + check_database_connection(runtime) + finally: + runtime.close() + + assert captured.value.__cause__ is None + + +def test_database_runtime_hides_bound_parameters() -> None: + runtime = create_database_runtime( + DatabaseSettings.from_mapping( + { + "DIRECTPILOT_DATABASE_URL": ( + "postgresql+psycopg://app:synthetic@127.0.0.1:5432/directpilot" + ) + } + ) + ) + + try: + assert runtime.engine.hide_parameters is True + finally: + runtime.close() diff --git a/tests/test_p2_operations_contract.py b/tests/test_p2_operations_contract.py new file mode 100644 index 0000000..b70e917 --- /dev/null +++ b/tests/test_p2_operations_contract.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from pathlib import Path + + +_ROOT = Path(__file__).resolve().parents[1] + + +def _contains_all(path: Path, *markers: str) -> bool: + return path.is_file() and all(marker in path.read_text(encoding="utf-8") for marker in markers) + + +def test_p2_migration_ci_uses_real_postgresql_and_both_upgrade_paths() -> None: + workflow = _ROOT / ".github" / "workflows" / "p2-postgresql-migrations.yml" + + assert _contains_all( + workflow, + "postgres:17.7-bookworm", + "DIRECTPILOT_DOCKER_BIN: docker", + "test_owner_migrates_empty_database_and_runtime_role_is_nonowner", + "test_owner_upgrades_from_prior_p2_revision_to_current_head", + ) + + +def test_p2_operations_runbooks_reference_sanitized_rehearsal_evidence() -> None: + migration_runbook = _ROOT / "docs" / "operations" / "migrations.md" + restore_runbook = _ROOT / "docs" / "operations" / "backup-restore.md" + dependency_admission = _ROOT / "docs" / "operations" / "dependency-admission.md" + + assert _contains_all( + migration_runbook, + "schema-owner", + "empty database", + "prior P2 revision", + "head", + ) + assert _contains_all( + restore_runbook, + "pg_dump", + "pg_restore", + "separate restored database", + "artifacts/p2-backup-restore-rehearsal.json", + ) + assert _contains_all( + dependency_admission, + "SQLAlchemy", + "psycopg", + "Alembic", + "license", + "vulnerability", + ) diff --git a/tests/test_postgresql_compose.py b/tests/test_postgresql_compose.py new file mode 100644 index 0000000..46a48bf --- /dev/null +++ b/tests/test_postgresql_compose.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + + +_WORKTREE = Path(__file__).resolve().parents[1] +_COMPOSE = _WORKTREE / "deploy" / "compose.test.yml" +_DOCKER = "/tmp/directpilot-p2-bin/docker" + + +def test_test_compose_resolves_pinned_postgres_on_loopback_only(tmp_path: Path) -> None: + environment_file = tmp_path / "compose.env" + environment_file.write_text( + "\n".join( + ( + "DIRECTPILOT_POSTGRES_DB=directpilot_test", + "DIRECTPILOT_POSTGRES_OWNER_PASSWORD=synthetic-test-password", + "DIRECTPILOT_POSTGRES_PORT=55432", + "", + ) + ), + encoding="utf-8", + ) + result = subprocess.run( + [ + _DOCKER, + "compose", + "--env-file", + str(environment_file), + "-f", + str(_COMPOSE), + "config", + "--format", + "json", + ], + cwd=_WORKTREE, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + postgres = json.loads(result.stdout)["services"]["postgres"] + assert postgres["image"] == "postgres:17.7-bookworm" + assert postgres["ports"][0]["host_ip"] == "127.0.0.1" + assert str(postgres["ports"][0]["published"]) == "55432" diff --git a/uv.lock b/uv.lock index 3ba0102..e8851e5 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,20 @@ resolution-markers = [ "python_full_version < '3.15'", ] +[[package]] +name = "alembic" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -136,9 +150,12 @@ name = "directpilot-beta" version = "0.2.0" source = { virtual = "." } dependencies = [ + { name = "alembic" }, { name = "fastapi" }, { name = "httpx", extra = ["socks"] }, + { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, + { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -154,9 +171,12 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.14,<1.18" }, { name = "fastapi", specifier = ">=0.111" }, { name = "httpx", extras = ["socks"], specifier = ">=0.27" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<3.3" }, { name = "pydantic-settings", specifier = ">=2.2" }, + { name = "sqlalchemy", specifier = ">=2.0,<2.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.29" }, ] @@ -186,6 +206,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -433,6 +516,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mypy" version = "2.3.1" @@ -529,6 +698,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psycopg" +version = "3.2.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/05/d4a05988f15fcf90e0088c735b1f2fc04a30b7fc65461d6ec278f5f2f17a/psycopg-3.2.13.tar.gz", hash = "sha256:309adaeda61d44556046ec9a83a93f42bbe5310120b1995f3af49ab6d9f13c1d", size = 160626, upload-time = "2025-11-21T22:34:32.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/14/f2724bd1986158a348316e86fdd0837a838b14a711df3f00e47fba597447/psycopg-3.2.13-py3-none-any.whl", hash = "sha256:a481374514f2da627157f767a9336705ebefe93ea7a0522a6cbacba165da179a", size = 206797, upload-time = "2025-11-21T22:29:39.733Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.2.13" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/f5/fc70804a999167daf5b876107b99e8fe91c3f785a31753c0e3e7b93446ba/psycopg_binary-3.2.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cfe87749d010dfd34534ba8c71aa0674db9a3fce65232c98989f77c742c9ce7", size = 4013844, upload-time = "2025-11-21T22:30:25.985Z" }, + { url = "https://files.pythonhosted.org/packages/07/87/857639681f5dfcd567aaf199fe4e5b026a105b0462a604f4fb7eda0735d8/psycopg_binary-3.2.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8db77fac1dfe3f69c982db92a51fd78e1354fa8f523a6781a636123e5c7ffcde", size = 4077002, upload-time = "2025-11-21T22:30:29.539Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/2cb7af6a31429b9022455c966d8408a2b5a19acd3de7610402381518e8f7/psycopg_binary-3.2.13-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cbbac4cd5b0e14b91ad8244268ca3fc2f527d1a337b489af57d7669c9d2e1a24", size = 4637181, upload-time = "2025-11-21T22:30:34.126Z" }, + { url = "https://files.pythonhosted.org/packages/28/bd/ffde1ac7e6ab75646c253fbe0378772fb6f0229af8a05cd9862ee8aad0f0/psycopg_binary-3.2.13-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a146f0a59a7e3ca92996f8133b1d5e5922e668f7c656b4a9201e702f4cf25896", size = 4737775, upload-time = "2025-11-21T22:30:38.408Z" }, + { url = "https://files.pythonhosted.org/packages/c2/74/3702732d01639c97943d56ec26860357dfacda0b5a708e82e794d07f499c/psycopg_binary-3.2.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:27150515de5f709e4142429db6fd36a1d01f0b8b17d915b5f7bb095364465398", size = 4421537, upload-time = "2025-11-21T22:30:42.696Z" }, + { url = "https://files.pythonhosted.org/packages/f2/8c/915a899857c2211196aa7f1749ba85bed421afaf72f185a0eb91e64ba550/psycopg_binary-3.2.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9942255705255367d94368941e3a913b0daf74b47d191471dbe4dc0de9fbc769", size = 3877500, upload-time = "2025-11-21T22:30:47.064Z" }, + { url = "https://files.pythonhosted.org/packages/36/d9/46060c183413bf62d47df98d7e3b30ab561639bcb583c3796cca30dafa43/psycopg_binary-3.2.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:75ebc8335f48c339ec24f4c371595f6b7043147fe6d18e619c8564428ab8adaf", size = 3560186, upload-time = "2025-11-21T22:30:54.522Z" }, + { url = "https://files.pythonhosted.org/packages/56/cf/2987689614632898e4861e4122cd41937ea9b5afcbe3c3061c7265bfa6de/psycopg_binary-3.2.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6fe2982a73b2ea473c9e2b91a35a21af3b03313bed188eccbcde4972483ac60a", size = 3601117, upload-time = "2025-11-21T22:31:01.218Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ef/df7fa8a47ef47d08af8a792343811a98bc7ab48f763560fc1d5acc1f28af/psycopg_binary-3.2.13-cp311-cp311-win_amd64.whl", hash = "sha256:6a50db4661fae78779d3cc38a0a68cabc997ca9d485ec27443b109ef8ac1672a", size = 2912873, upload-time = "2025-11-21T22:31:05.473Z" }, + { url = "https://files.pythonhosted.org/packages/49/9e/f90243b3d0d007a89989b013b0eb3e78ac929fed4eb40a2b317452abafe1/psycopg_binary-3.2.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:223fc610a80bbc4355ad3c9952d468a18bb5cd7065846a8c275f100d80cd4004", size = 3996285, upload-time = "2025-11-21T22:31:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/7d55f515ee3e2ced5ff9bc493fb2308f5187686b6d9583cd6a9c880d2053/psycopg_binary-3.2.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67f06a68d68b4621b6a411f9e583df876977afa06b1ba270b1b347d40aa93fc", size = 4070567, upload-time = "2025-11-21T22:31:12.31Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a8/ead4de04d8cf5f35119a75a8dd92fa4a2ec8a309b1aa58855f64616c03d7/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:082579f2ae41bdabe20c82810810f3e290ac2206cccf0cb41cf36b3218f53b3c", size = 4616833, upload-time = "2025-11-21T22:31:16.614Z" }, + { url = "https://files.pythonhosted.org/packages/26/2e/4af6ab69ade7d67d31296f88c79c322a3522564e30b3f1458f19e74d67c3/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ff7df7bd8ec2c805f3a4896b8ade971139af0f9f8cf45d05014ac71fe54887be", size = 4711710, upload-time = "2025-11-21T22:31:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/9a/31/bdbd6b2264bb7ae5fe8b775c5524da73329d8888c6137fd8b050ff9cabbc/psycopg_binary-3.2.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f1189dc78553ef4b2e55d9e116fc74870191bc6a9a5f4442412a703c4cc6c3b", size = 4401656, upload-time = "2025-11-21T22:31:26.842Z" }, + { url = "https://files.pythonhosted.org/packages/33/c5/8fd8f96450e4ef242022c9a588305e3dc7309c34bc392a9b4c2da60854b1/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0ef8ed4a4e0f7bf5e941782478a43c14b2b585b031e2266dd3afb87be2775d95", size = 3851747, upload-time = "2025-11-21T22:31:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/406d102ae49d253f124644530f1e5b3fd2f92aea59d4f9b8dd1c71cf8e0f/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de06fc9707a49f7c081b5c950974dd6de3dc33d681f7524f0b396471f5a4a480", size = 3524796, upload-time = "2025-11-21T22:31:34.377Z" }, + { url = "https://files.pythonhosted.org/packages/45/6f/a89be8aee27a5522e97dbcb225fe429c489acdf0bb25fc0fadb329dfb39f/psycopg_binary-3.2.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:917ad1cd6e6ef8a9df2f28d7b29c7148f089be46ac56fe838f986c0227652d14", size = 3576536, upload-time = "2025-11-21T22:31:38.06Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f8/c924c7dc792c81bf6181d7d4eeb613c8b2151b3a208f95cedec3c1a25ba3/psycopg_binary-3.2.13-cp312-cp312-win_amd64.whl", hash = "sha256:b53b0d9499805b307017070492189e349256e0946f62c815e442baa01f2ea6c5", size = 2902172, upload-time = "2025-11-21T22:31:41.256Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/ef37bb44dc02fcc6c0a3eeb93f4baaac13bcb228633fe38ad3fb5a3f6449/psycopg_binary-3.2.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbae6ab1966e2b61d97e47220556c330c4608bb4cfb3a124aa0595c39995c068", size = 3995628, upload-time = "2025-11-21T22:31:45.921Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ad/4748f5f1a40248af16dba087dbec50bd335ee025cc1fb9bf64773378ceff/psycopg_binary-3.2.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fae933e4564386199fc54845d85413eedb49760e0bcd2b621fde2dd1825b99b3", size = 4069024, upload-time = "2025-11-21T22:31:50.202Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/f02ec6bbc30c7fcd3b39823d2d624b42fae480edeb6e50eb3276281d5635/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:13e2f8894d410678529ff9f1211f96c5a93ff142f992b302682b42d924428b61", size = 4615127, upload-time = "2025-11-21T22:31:56.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0d/a54fc2cdd672c84175d6869cc823d6ec2a8909318d491f3c24e6077983f2/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f26f7009375cf1e92180e5c517c52da1054f7e690dde90e0ed00fa8b5736bcd4", size = 4710267, upload-time = "2025-11-21T22:32:04.585Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b7/067de1acaf3d312253351f3af4121f972584bd36cada6378d4b0cdcebd38/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea2fdbcc9142933a47c66970e0df8b363e3bd1ea4c5ce376f2f3d94a9aeec847", size = 4400795, upload-time = "2025-11-21T22:32:08.883Z" }, + { url = "https://files.pythonhosted.org/packages/64/b5/030e6b1ebfc4d3a8fca03adc5fc827982643bad0b01a1268538d17c08ed3/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac92d6bc1d4a41c7459953a9aa727b9966e937e94c9e072527317fd2a67d488b", size = 3851239, upload-time = "2025-11-21T22:32:12.333Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/0541845364a7de9eae6807060da6a04b22a8eb2e803606d285d9250fbe93/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8b843c00478739e95c46d6d3472b13123b634685f107831a9bfc41503a06ecbd", size = 3525084, upload-time = "2025-11-21T22:32:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/83/ae/6507890dc30a4bbd9d938d4ff3a4079d009a5ad8170af51c7f762438fdbf/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2f63868cc96bc18486cebec24445affbdd7f7debf28fac466ea935a8b5a4753b", size = 3576787, upload-time = "2025-11-21T22:32:19.922Z" }, + { url = "https://files.pythonhosted.org/packages/9d/64/3d1c2f1fd09b60cdfbe68b9a810b357ba505eff6e4bdb1a2d9f6729da64c/psycopg_binary-3.2.13-cp313-cp313-win_amd64.whl", hash = "sha256:594dfbca3326e997ae738d3d339004e8416b1f7390f52ce8dc2d692393e8fa96", size = 2905584, upload-time = "2025-11-21T22:32:23.399Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b4/7656b3d67bedff2b900c8c4671cb6eb5fb99c2fc36da33579cac89779c25/psycopg_binary-3.2.13-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:502a778c3e07c6b3aabfa56ee230e8c264d2debfab42d11535513a01bdfff0d6", size = 3997201, upload-time = "2025-11-21T22:32:28.185Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2e/3b4afbd94d48df19c3931cedba464b109f89d81ac43178e6a3d654b4e8d5/psycopg_binary-3.2.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7561a71d764d6f74d66e8b7d844b0f27fa33de508f65c17b1d56a94c73644776", size = 4071631, upload-time = "2025-11-21T22:32:32.594Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/107d06d55992e2f13157eb705ba5a47d06c4cf1bed077dff0c567b10c187/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9caf14745a1930b4e03fe4072cd7154eaf6e1241d20c42130ed784408a26b24b", size = 4620918, upload-time = "2025-11-21T22:32:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/e1/47/a925620f261b115f31e813a5bfe640f316413b1864094a60162f4a6e4d67/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a6cafabdc0bfa37e11c6f365020fd5916b62d6296df581f4dceaa43a2ce680c", size = 4714494, upload-time = "2025-11-21T22:32:42.138Z" }, + { url = "https://files.pythonhosted.org/packages/46/33/bed384665356bb9ba17dd8e104884d87cc2343d16dffdfd9aaa9a159bd4d/psycopg_binary-3.2.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96cb5a27e68acac6d74b64fca38592a692de9c4b7827339190698d58027aa45", size = 4403046, upload-time = "2025-11-21T22:32:47.241Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/749d8e8102fb5df502e2ecb053b79e78e3358af01af652b5dbeb96ab7905/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:596176ae3dfbf56fc61108870bfe17c7205d33ac28d524909feb5335201daa0a", size = 3859046, upload-time = "2025-11-21T22:32:51.481Z" }, + { url = "https://files.pythonhosted.org/packages/38/7c/f492e63b517d6dcd564e8c43bc15e11a4c712a848adf8938ce33bfd4c867/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:cc3a0408435dfbb77eeca5e8050df4b19a6e9b7e5e5583edf524c4a83d6293b2", size = 3531351, upload-time = "2025-11-21T22:32:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/07/5a/d8743eb23944e5cf2a0bbfa92935c140b5beaacdb872be641065ed70ab2c/psycopg_binary-3.2.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65df0d459ffba14082d8ca4bb2f6ffbb2f8d02968f7d34a747e1031934b76b23", size = 3581034, upload-time = "2025-11-21T22:33:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/46/b2/411d4180252144f7eff024894d2d2ebb98c012c944a282fc20250870e461/psycopg_binary-3.2.13-cp314-cp314-win_amd64.whl", hash = "sha256:5c77f156c7316529ed371b5f95a51139e531328ee39c37493a2afcbc1f79d5de", size = 3000162, upload-time = "2025-11-21T22:33:07.378Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -808,6 +1038,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/08/cc5f7627b92f1456bc0b5fb7e98af4600248abe422a44da0d17a3fe6a448/sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c", size = 2172460, upload-time = "2026-08-11T20:58:22.429Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/9a2abad8bfc8fdcd38c64adc056aeefab7aaa96ecd32f5e8c140e6375f17/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608", size = 3355720, upload-time = "2026-08-11T21:00:06.746Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/e75597b5841043e3c74055d00d4feb53d9a49a5c89ba2450d2d9aab53597/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1", size = 3354394, upload-time = "2026-08-11T21:05:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/410fbc6c2f1fa8310f4ef1b6847d47d0ac1c042c7b4e81eaaca063d030a9/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43", size = 3306991, upload-time = "2026-08-11T21:00:08.603Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ba/25ffd5c24681ea4b46e62c80ceca8200ce204de1773366321306cf3f608a/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736", size = 3327454, upload-time = "2026-08-11T21:05:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f1/0f1b1d4800e51218e736a06ed55a3b2a59c257600bbaca7673bf13d2dbec/sqlalchemy-2.0.52-cp311-cp311-win32.whl", hash = "sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72", size = 2131248, upload-time = "2026-08-11T21:09:50.765Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/04d2ac5ad66f3d31278f37064ed5f5ef3fe653f7bdaa67036663f223d186/sqlalchemy-2.0.52-cp311-cp311-win_amd64.whl", hash = "sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc", size = 2156943, upload-time = "2026-08-11T21:09:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" }, + { url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" }, + { url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + [[package]] name = "starlette" version = "1.2.1" @@ -851,6 +1123,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + [[package]] name = "uvicorn" version = "0.49.0" From a0971c125204e1aa5482ebe612ef75baf2bf21fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Fri, 4 Sep 2026 20:26:11 +0300 Subject: [PATCH 3/4] fix(db): use explicit audit delegation --- app/repositories/postgres_store.py | 29 +++++++++++++- tests/test_postgres_store_composition.py | 51 ++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 tests/test_postgres_store_composition.py diff --git a/app/repositories/postgres_store.py b/app/repositories/postgres_store.py index 8b14b5e..5808066 100644 --- a/app/repositories/postgres_store.py +++ b/app/repositories/postgres_store.py @@ -11,6 +11,7 @@ from app.db.models import CampaignDraftRecord, SemanticChangePackageRecord from app.models import ( + AuditEvent, Campaign, CampaignDraft, CampaignDraftRequest, @@ -26,6 +27,31 @@ from app.store import MockStore +class _AuditDelegatingMockStore(MockStore): + """Compatibility engine with an explicit PostgreSQL audit delegate.""" + + def __init__(self, audit: PostgresAuditRepository) -> None: + super().__init__() + self._audit = audit + + def append_audit( + self, + action: str, + entity: str, + *, + actor: str = "agent", + dry_run: bool = True, + details: dict[Any, Any] | None = None, + ) -> AuditEvent: + return self._audit.append_audit( + action, + entity, + actor=actor, + dry_run=dry_run, + details=details, + ) + + class PostgresLegacyStoreRepository: """P2 persistence adapter: PostgreSQL owns drafts, packages, and audit records. @@ -38,8 +64,7 @@ def __init__(self, sessions: sessionmaker[Session]) -> None: self._sessions = sessions self._audit = PostgresAuditRepository(sessions) self._idempotency = PostgresIdempotencyRepository(sessions) - self._legacy = MockStore() - self._legacy.append_audit = self.append_audit + self._legacy = _AuditDelegatingMockStore(self._audit) @property def campaigns(self) -> Mapping[str, Campaign]: diff --git a/tests/test_postgres_store_composition.py b/tests/test_postgres_store_composition.py new file mode 100644 index 0000000..bfca6a8 --- /dev/null +++ b/tests/test_postgres_store_composition.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any, cast + +import pytest +from sqlalchemy.orm import Session, sessionmaker + +import app.repositories.postgres_store as postgres_store + + +class RecordingAuditRepository: + def __init__(self, _sessions: object) -> None: + self.calls: list[tuple[str, str, dict[str, Any]]] = [] + + def append_audit(self, action: str, entity: str, **kwargs: Any) -> str: + self.calls.append((action, entity, kwargs)) + return "recorded" + + +def test_postgres_store_legacy_audit_uses_explicit_delegate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(postgres_store, "PostgresAuditRepository", RecordingAuditRepository) + sessions = cast(sessionmaker[Session], None) + repository = postgres_store.PostgresLegacyStoreRepository(sessions) + audit = cast(RecordingAuditRepository, repository._audit) + + legacy_append_audit = repository._legacy.append_audit + + assert legacy_append_audit.__self__ is repository._legacy + assert ( + legacy_append_audit( + "campaign_draft_created", + "draft-001", + actor="operator", + dry_run=False, + details={"request_id": "request-001"}, + ) + == "recorded" + ) + assert audit.calls == [ + ( + "campaign_draft_created", + "draft-001", + { + "actor": "operator", + "dry_run": False, + "details": {"request_id": "request-001"}, + }, + ) + ] From 64aa60472eab042461f3317fe1b84ca8ae427c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=B4=D0=B5=D0=BB=D1=8C?= Date: Sat, 5 Sep 2026 17:13:50 +0300 Subject: [PATCH 4/4] fix(ci): use Docker CLI from PATH --- tests/integration/conftest.py | 3 +-- tests/test_postgresql_compose.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0dc9826..dbb7caa 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -9,10 +9,9 @@ import pytest - _WORKTREE = Path(__file__).resolve().parents[2] _COMPOSE = _WORKTREE / "deploy" / "compose.test.yml" -_DOCKER = os.environ.get("DIRECTPILOT_DOCKER_BIN", "/tmp/directpilot-p2-bin/docker") +_DOCKER = os.environ.get("DIRECTPILOT_DOCKER_BIN", "docker") _OWNER_PASSWORD = "synthetic-test-owner-password" _APP_PASSWORD = "synthetic-test-app-password" diff --git a/tests/test_postgresql_compose.py b/tests/test_postgresql_compose.py index 46a48bf..e95a8ab 100644 --- a/tests/test_postgresql_compose.py +++ b/tests/test_postgresql_compose.py @@ -1,13 +1,13 @@ from __future__ import annotations import json +import os import subprocess from pathlib import Path - _WORKTREE = Path(__file__).resolve().parents[1] _COMPOSE = _WORKTREE / "deploy" / "compose.test.yml" -_DOCKER = "/tmp/directpilot-p2-bin/docker" +_DOCKER = os.environ.get("DIRECTPILOT_DOCKER_BIN", "docker") def test_test_compose_resolves_pinned_postgres_on_loopback_only(tmp_path: Path) -> None: