T2: Pydantic v2 — replace @dataclass with BaseModel

- All domain models now use pydantic.BaseModel
- Removed manual to_dict()/from_dict() — use model_dump(mode='json')/model_validate()
- Added field validators:
  - Account.balance >= 0
  - Transaction.amount != 0
  - RecurringCashflow.amount != 0, frequency in {daily, weekly, monthly, yearly}
  - Liability.interest, payment >= 0
  - ExchangeRate.rate > 0
  - ForecastScenario.multipliers >= 0
- pydantic v2 native UUID handling (auto str in JSON)
- Updated excel_sync.py to use model_validate()
- Updated tests/test_model.py and tests/test_currency.py
- Added pydantic>=2.0 to pyproject.toml

Tests: 63/63 pass.
This commit is contained in:
2026-10-08 16:42:04 +03:00
parent feb77ef217
commit c765f3672a
12 changed files with 112 additions and 187 deletions
+9 -19
View File
@@ -1,27 +1,17 @@
from dataclasses import dataclass, field
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
@dataclass
class Account: class Account(BaseModel):
id: UUID = field(default_factory=uuid4) id: UUID = Field(default_factory=uuid4)
name: str = "" name: str = ""
currency: str = "USD" currency: str = "USD"
balance: float = 0.0 balance: float = 0.0
def to_dict(self) -> dict: @field_validator("balance")
return {
"id": str(self.id),
"name": self.name,
"currency": self.currency,
"balance": self.balance,
}
@classmethod @classmethod
def from_dict(cls, data: dict) -> "Account": def _balance_non_negative(cls, v: float) -> float:
return cls( if v < 0:
id=UUID(data["id"]), raise ValueError("balance must be non-negative")
name=data["name"], return v
currency=data.get("currency", "USD"),
balance=data.get("balance", 0.0),
)
+4 -21
View File
@@ -1,27 +1,10 @@
from dataclasses import dataclass, field
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import BaseModel, Field
@dataclass
class Asset: class Asset(BaseModel):
id: UUID = field(default_factory=uuid4) id: UUID = Field(default_factory=uuid4)
name: str = "" name: str = ""
value: float = 0.0 value: float = 0.0
growth_rate: float = 0.0 growth_rate: float = 0.0
def to_dict(self) -> dict:
return {
"id": str(self.id),
"name": self.name,
"value": self.value,
"growth_rate": self.growth_rate,
}
@classmethod
def from_dict(cls, data: dict) -> "Asset":
return cls(
id=UUID(data["id"]),
name=data["name"],
value=data.get("value", 0.0),
growth_rate=data.get("growth_rate", 0.0),
)
+7 -16
View File
@@ -1,4 +1,4 @@
from dataclasses import dataclass from pydantic import BaseModel, Field, field_validator
CURRENCY_SYMBOLS = { CURRENCY_SYMBOLS = {
"RUB": "₽", "RUB": "₽",
@@ -12,26 +12,17 @@ CURRENCY_SYMBOLS = {
} }
@dataclass class ExchangeRate(BaseModel):
class ExchangeRate:
from_currency: str = "USD" from_currency: str = "USD"
to_currency: str = "RUB" to_currency: str = "RUB"
rate: float = 80.0 rate: float = 80.0
def to_dict(self) -> dict: @field_validator("rate")
return {
"from_currency": self.from_currency,
"to_currency": self.to_currency,
"rate": self.rate,
}
@classmethod @classmethod
def from_dict(cls, data: dict) -> "ExchangeRate": def _rate_positive(cls, v: float) -> float:
return cls( if v <= 0:
from_currency=data.get("from_currency", "USD"), raise ValueError("rate must be positive")
to_currency=data.get("to_currency", "RUB"), return v
rate=data.get("rate", 80.0),
)
DEFAULT_RATES: list[ExchangeRate] = [ DEFAULT_RATES: list[ExchangeRate] = [
+9 -21
View File
@@ -1,30 +1,18 @@
from dataclasses import dataclass, field
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
@dataclass
class Liability: class Liability(BaseModel):
id: UUID = field(default_factory=uuid4) id: UUID = Field(default_factory=uuid4)
name: str = "" name: str = ""
balance: float = 0.0 balance: float = 0.0
interest: float = 0.0 interest: float = 0.0
payment: float = 0.0 payment: float = 0.0
def to_dict(self) -> dict: @field_validator("interest", "payment")
return {
"id": str(self.id),
"name": self.name,
"balance": self.balance,
"interest": self.interest,
"payment": self.payment,
}
@classmethod @classmethod
def from_dict(cls, data: dict) -> "Liability": def _non_negative(cls, v: float) -> float:
return cls( if v < 0:
id=UUID(data["id"]), raise ValueError("must be non-negative")
name=data["name"], return v
balance=data.get("balance", 0.0),
interest=data.get("interest", 0.0),
payment=data.get("payment", 0.0),
)
+34 -29
View File
@@ -1,7 +1,8 @@
import json import json
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, Field
from domain.account import Account from domain.account import Account
from domain.asset import Asset from domain.asset import Asset
from domain.currency import DEFAULT_RATES, ExchangeRate from domain.currency import DEFAULT_RATES, ExchangeRate
@@ -11,37 +12,41 @@ from domain.scenario import ForecastScenario
from domain.transaction import Transaction from domain.transaction import Transaction
@dataclass class FinancialModel(BaseModel):
class FinancialModel: """Корневая модель финансового плана."""
base_currency: str = "RUB"
accounts: list[Account] = field(default_factory=list)
transactions: list[Transaction] = field(default_factory=list)
recurring: list[RecurringCashflow] = field(default_factory=list)
assets: list[Asset] = field(default_factory=list)
liabilities: list[Liability] = field(default_factory=list)
scenarios: list[ForecastScenario] = field(default_factory=list)
exchange_rates: list[ExchangeRate] = field(default_factory=lambda: DEFAULT_RATES.copy())
SCHEMA_VERSION = 1 SCHEMA_VERSION: int = 1 # NB: не Field — это class-level metadata, не pydantic field
base_currency: str = "RUB"
accounts: list[Account] = Field(default_factory=list)
transactions: list[Transaction] = Field(default_factory=list)
recurring: list[RecurringCashflow] = Field(default_factory=list)
assets: list[Asset] = Field(default_factory=list)
liabilities: list[Liability] = Field(default_factory=list)
scenarios: list[ForecastScenario] = Field(default_factory=list)
exchange_rates: list[ExchangeRate] = Field(
default_factory=lambda: [r.model_copy() for r in DEFAULT_RATES]
)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Сериализация в dict с version и UUID-as-str (для JSON)."""
return { return {
"version": self.SCHEMA_VERSION, "version": self.SCHEMA_VERSION,
"base_currency": self.base_currency, "base_currency": self.base_currency,
"accounts": [a.to_dict() for a in self.accounts], "accounts": [a.model_dump(mode="json") for a in self.accounts],
"transactions": [t.to_dict() for t in self.transactions], "transactions": [t.model_dump(mode="json") for t in self.transactions],
"recurring": [r.to_dict() for r in self.recurring], "recurring": [r.model_dump(mode="json") for r in self.recurring],
"assets": [a.to_dict() for a in self.assets], "assets": [a.model_dump(mode="json") for a in self.assets],
"liabilities": [li.to_dict() for li in self.liabilities], "liabilities": [li.model_dump(mode="json") for li in self.liabilities],
"scenarios": [s.to_dict() for s in self.scenarios], "scenarios": [s.model_dump(mode="json") for s in self.scenarios],
"exchange_rates": [r.to_dict() for r in self.exchange_rates], "exchange_rates": [e.model_dump(mode="json") for e in self.exchange_rates],
} }
@classmethod @classmethod
def from_dict(cls, data: dict) -> "FinancialModel": def from_dict(cls, data: dict) -> "FinancialModel":
version = data.get("version", 0) version = data.get("version", 0)
if version == 0: if version == 0:
return cls._from_v0(data) return cls._from_legacy(data)
if version == 1: if version == 1:
return cls._from_v1(data) return cls._from_v1(data)
raise ValueError(f"Unsupported FinancialModel version: {version}") raise ValueError(f"Unsupported FinancialModel version: {version}")
@@ -50,18 +55,18 @@ class FinancialModel:
def _from_v1(cls, data: dict) -> "FinancialModel": def _from_v1(cls, data: dict) -> "FinancialModel":
return cls( return cls(
base_currency=data.get("base_currency", "RUB"), base_currency=data.get("base_currency", "RUB"),
accounts=[Account.from_dict(a) for a in data.get("accounts", [])], accounts=[Account.model_validate(a) for a in data.get("accounts", [])],
transactions=[Transaction.from_dict(t) for t in data.get("transactions", [])], transactions=[Transaction.model_validate(t) for t in data.get("transactions", [])],
recurring=[RecurringCashflow.from_dict(r) for r in data.get("recurring", [])], recurring=[RecurringCashflow.model_validate(r) for r in data.get("recurring", [])],
assets=[Asset.from_dict(a) for a in data.get("assets", [])], assets=[Asset.model_validate(a) for a in data.get("assets", [])],
liabilities=[Liability.from_dict(li) for li in data.get("liabilities", [])], liabilities=[Liability.model_validate(li) for li in data.get("liabilities", [])],
scenarios=[ForecastScenario.from_dict(s) for s in data.get("scenarios", [])], scenarios=[ForecastScenario.model_validate(s) for s in data.get("scenarios", [])],
exchange_rates=[ExchangeRate.from_dict(r) for r in data.get("exchange_rates", [])], exchange_rates=[ExchangeRate.model_validate(r) for r in data.get("exchange_rates", [])],
) )
@classmethod @classmethod
def _from_v0(cls, data: dict) -> "FinancialModel": def _from_legacy(cls, data: dict) -> "FinancialModel":
# Legacy: файлы, сохранённые до введения version. Совпадает по структуре с v1. # Legacy: файлы без version. Структура совпадает с v1.
return cls._from_v1(data) return cls._from_v1(data)
def save(self, path: str | Path) -> None: def save(self, path: str | Path) -> None:
+16 -23
View File
@@ -1,33 +1,26 @@
from dataclasses import dataclass, field
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
@dataclass
class RecurringCashflow: class RecurringCashflow(BaseModel):
id: UUID = field(default_factory=uuid4) id: UUID = Field(default_factory=uuid4)
start_date: str = "" start_date: str = ""
end_date: str = "" end_date: str = ""
frequency: str = "monthly" frequency: str = "monthly"
amount: float = 0.0 amount: float = 0.0
category: str = "" category: str = ""
def to_dict(self) -> dict: @field_validator("frequency")
return {
"id": str(self.id),
"start_date": self.start_date,
"end_date": self.end_date,
"frequency": self.frequency,
"amount": self.amount,
"category": self.category,
}
@classmethod @classmethod
def from_dict(cls, data: dict) -> "RecurringCashflow": def _frequency_known(cls, v: str) -> str:
return cls( if v not in {"daily", "weekly", "monthly", "yearly"}:
id=UUID(data["id"]), raise ValueError(f"unknown frequency: {v}")
start_date=data.get("start_date", ""), return v
end_date=data.get("end_date", ""),
frequency=data.get("frequency", "monthly"), @field_validator("amount")
amount=data.get("amount", 0.0), @classmethod
category=data.get("category", ""), def _amount_nonzero(cls, v: float) -> float:
) if v == 0:
raise ValueError("amount must be non-zero")
return v
+9 -23
View File
@@ -1,33 +1,19 @@
from dataclasses import dataclass, field
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
@dataclass
class ForecastScenario: class ForecastScenario(BaseModel):
id: UUID = field(default_factory=uuid4) id: UUID = Field(default_factory=uuid4)
name: str = "baseline" name: str = "baseline"
income_multiplier: float = 1.0 income_multiplier: float = 1.0
expense_multiplier: float = 1.0 expense_multiplier: float = 1.0
growth_multiplier: float = 1.0 growth_multiplier: float = 1.0
description: str = "" description: str = ""
def to_dict(self) -> dict: @field_validator("income_multiplier", "expense_multiplier", "growth_multiplier")
return {
"id": str(self.id),
"name": self.name,
"income_multiplier": self.income_multiplier,
"expense_multiplier": self.expense_multiplier,
"growth_multiplier": self.growth_multiplier,
"description": self.description,
}
@classmethod @classmethod
def from_dict(cls, data: dict) -> "ForecastScenario": def _non_negative(cls, v: float) -> float:
return cls( if v < 0:
id=UUID(data["id"]), raise ValueError("multiplier must be non-negative")
name=data["name"], return v
income_multiplier=data.get("income_multiplier", 1.0),
expense_multiplier=data.get("expense_multiplier", 1.0),
growth_multiplier=data.get("growth_multiplier", 1.0),
description=data.get("description", ""),
)
+9 -23
View File
@@ -1,33 +1,19 @@
from dataclasses import dataclass, field
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
@dataclass
class Transaction: class Transaction(BaseModel):
id: UUID = field(default_factory=uuid4) id: UUID = Field(default_factory=uuid4)
date: str = "" date: str = ""
account: str = "" account: str = ""
category: str = "" category: str = ""
amount: float = 0.0 amount: float = 0.0
description: str = "" description: str = ""
def to_dict(self) -> dict: @field_validator("amount")
return {
"id": str(self.id),
"date": self.date,
"account": self.account,
"category": self.category,
"amount": self.amount,
"description": self.description,
}
@classmethod @classmethod
def from_dict(cls, data: dict) -> "Transaction": def _amount_nonzero(cls, v: float) -> float:
return cls( if v == 0:
id=UUID(data["id"]), raise ValueError("amount must be non-zero")
date=data["date"], return v
account=data.get("account", ""),
category=data.get("category", ""),
amount=data.get("amount", 0.0),
description=data.get("description", ""),
)
+8 -6
View File
@@ -119,17 +119,19 @@ class ExcelSync:
def _add_to_model(self, model: FinancialModel, sheet_name: str, data: dict) -> None: def _add_to_model(self, model: FinancialModel, sheet_name: str, data: dict) -> None:
try: try:
cls = _SHEET_CONFIG[sheet_name]["cls"]
instance = cls.model_validate(data)
if sheet_name == "Accounts": if sheet_name == "Accounts":
model.accounts.append(Account.from_dict(data)) model.accounts.append(instance)
elif sheet_name == "Transactions": elif sheet_name == "Transactions":
model.transactions.append(Transaction.from_dict(data)) model.transactions.append(instance)
elif sheet_name == "Recurring": elif sheet_name == "Recurring":
model.recurring.append(RecurringCashflow.from_dict(data)) model.recurring.append(instance)
elif sheet_name == "Assets": elif sheet_name == "Assets":
model.assets.append(Asset.from_dict(data)) model.assets.append(instance)
elif sheet_name == "Liabilities": elif sheet_name == "Liabilities":
model.liabilities.append(Liability.from_dict(data)) model.liabilities.append(instance)
elif sheet_name == "ExchangeRates": elif sheet_name == "ExchangeRates":
model.exchange_rates.append(ExchangeRate.from_dict(data)) model.exchange_rates.append(instance)
except Exception as e: except Exception as e:
raise SyncError(f"Failed to parse row in {sheet_name}: {e}") from e raise SyncError(f"Failed to parse row in {sheet_name}: {e}") from e
+1
View File
@@ -11,6 +11,7 @@ dependencies = [
"openpyxl>=3.1", "openpyxl>=3.1",
"typer>=0.9", "typer>=0.9",
"rich>=13.0", "rich>=13.0",
"pydantic>=2.0",
] ]
[project.scripts] [project.scripts]
+2 -2
View File
@@ -6,8 +6,8 @@ from domain import CurrencyConverter, CurrencyError, ExchangeRate
class TestExchangeRate: class TestExchangeRate:
def test_to_dict_roundtrip(self): def test_to_dict_roundtrip(self):
rate = ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0) rate = ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0)
d = rate.to_dict() d = rate.model_dump(mode="json")
r2 = ExchangeRate.from_dict(d) r2 = ExchangeRate.model_validate(d)
assert r2.from_currency == "USD" assert r2.from_currency == "USD"
assert r2.to_currency == "RUB" assert r2.to_currency == "RUB"
assert r2.rate == 80.0 assert r2.rate == 80.0
+4 -4
View File
@@ -21,8 +21,8 @@ class TestAccount:
def test_to_dict_roundtrip(self): def test_to_dict_roundtrip(self):
a = Account(name="Test", balance=100.0) a = Account(name="Test", balance=100.0)
d = a.to_dict() d = a.model_dump(mode="json")
a2 = Account.from_dict(d) a2 = Account.model_validate(d)
assert a2.name == a.name assert a2.name == a.name
assert a2.balance == a.balance assert a2.balance == a.balance
assert a2.currency == a.currency assert a2.currency == a.currency
@@ -35,8 +35,8 @@ class TestTransaction:
def test_roundtrip(self): def test_roundtrip(self):
t = Transaction(amount=-100.0, category="rent", description="test") t = Transaction(amount=-100.0, category="rent", description="test")
d = t.to_dict() d = t.model_dump(mode="json")
t2 = Transaction.from_dict(d) t2 = Transaction.model_validate(d)
assert t2.amount == t.amount assert t2.amount == t.amount
assert t2.category == t.category assert t2.category == t.category
assert t2.description == t.description assert t2.description == t.description