"""Pydantic models for per-city cost-of-living data. Every category figure is monthly GBP for a single person — the denomination the simulator expects when scaling `spending_gbp`. The source object retains the original currency, FX rate, and snapshot date so we can re-validate or update a stale baseline. """ from __future__ import annotations from datetime import date from decimal import Decimal from typing import Literal from pydantic import BaseModel, ConfigDict, Field SourceName = Literal["numbeo", "expatistan", "baseline", "manual"] class ColSource(BaseModel): """Provenance for a CityCostIndex entry — where did the numbers come from and when. The simulator surfaces this in the SimulateResult so the user can audit which baseline was applied.""" model_config = ConfigDict(frozen=True) name: SourceName url: str | None = None snapshot_date: date raw_currency: str = "GBP" gbp_per_unit: Decimal = Decimal("1") class CategoryBreakdown(BaseModel): """Per-category monthly costs in GBP for a single person.""" model_config = ConfigDict(frozen=True) rent_1bed_center: Decimal rent_1bed_outside: Decimal | None = None groceries: Decimal restaurants: Decimal transport: Decimal utilities: Decimal leisure: Decimal class CityCostIndex(BaseModel): """One city's headline cost-of-living snapshot.""" model_config = ConfigDict(frozen=True) city: str city_slug: str = Field(min_length=1) country: str total_single_no_rent_gbp: Decimal total_single_with_rent_gbp: Decimal breakdown: CategoryBreakdown source: ColSource @property def total_monthly_gbp(self) -> Decimal: """The number the simulator uses for ratios — `with rent` is the right anchor because moving location changes rent too.""" return self.total_single_with_rent_gbp