✅ Решения: все 15 задач практикума

Решения на актуальном API (Pydantic v2 / SQLAlchemy 2.x) с объяснением логики

⚡ Ключевые моменты решений

  • Pydantic валидаторы: @field_validator + @classmethod — всегда вместе
  • Decimal с ограничением: Annotated[Decimal, Field(gt=0)]
  • Строка с паттерном: Annotated[str, Field(pattern=r"^(debit|credit)$")]
  • SQLAlchemy Base: class Base(DeclarativeBase): pass
  • Сессия: with Session(engine) as session: — без sessionmaker и bind=
  • Ошибки задачи 15: нет Base, классы не наследуются, sessionmaker без engine, String без длины

Блок 1: Pydantic — моделирование

Задача 1: Event — дата не в прошлом

Логика решения: @field_validator('date') с @classmethod получает значение поля date после приведения типа. Сравниваем с datetime.now() и выбрасываем ValueError если дата в прошлом.

from pydantic import BaseModel, field_validator
from datetime import datetime, timedelta

class Event(BaseModel):
    title: str
    date: datetime
    location: str

    @field_validator('date')
    @classmethod
    def date_must_be_future(cls, v: datetime) -> datetime:
        if v < datetime.now():
            raise ValueError("Event date must be in the future")
        return v

# Пример использования
try:
    future_event = Event(
        title="New Year Party",
        date=datetime.now() + timedelta(days=30),
        location="New York"
    )
    print(future_event)
except ValueError as e:
    print(e)

Почему @classmethod? В Pydantic v2 валидаторы полей — это методы класса. cls — это класс модели, v — значение поля. Без @classmethod Pydantic v2 выбросит ошибку конфигурации.

Задача 2: UserProfile — Field, EmailStr, ConfigDict

Логика решения: Field(min_length=8) добавляет ограничение на длину пароля. EmailStr — специальный тип Pydantic для автоматической валидации email. model_config = ConfigDict(json_schema_extra=...) — замена class Config: schema_extra из v1.

from pydantic import BaseModel, EmailStr, Field, ConfigDict

class UserProfile(BaseModel):
    username: str
    password: str = Field(
        min_length=8,
        description="Password must be at least 8 characters long"
    )
    email: EmailStr

    model_config = ConfigDict(
        json_schema_extra={
            "example": {
                "username": "john_doe",
                "password": "securePassword123",
                "email": "john.doe@example.com"
            }
        }
    )

# Пример создания пользователя
user_profile = UserProfile(
    username="john_doe",
    password="securePassword123",
    email="john.doe@example.com"
)
print(user_profile)

Задача 3: Transaction — Annotated[Decimal, Field], str_strip_whitespace

Логика решения: в Pydantic v2 нет condecimal и constr — вместо них используется Annotated[тип, Field(...)]. anystr_strip_whitespace из v1 заменяется на ConfigDict(str_strip_whitespace=True).

from pydantic import BaseModel, Field, ConfigDict
from typing import Annotated
from decimal import Decimal

class Transaction(BaseModel):
    amount: Annotated[Decimal, Field(gt=0)]
    transaction_type: Annotated[str, Field(pattern=r"^(debit|credit)$")]
    currency: Annotated[str, Field(min_length=3, max_length=3)]

    model_config = ConfigDict(str_strip_whitespace=True)

# Пример транзакции
transaction = Transaction(
    amount=Decimal("150.50"),
    transaction_type="debit",
    currency="USD"
)
print(transaction)

Почему Decimal, а не float? Для финансовых операций важна точность. float имеет проблемы с представлением: 0.1 + 0.2 != 0.3. Decimal обеспечивает точные вычисления.

Задача 4: Appointment — ≥24ч вперёд

Логика решения: проверяем не просто что дата в будущем, а что она как минимум на 24 часа вперёд. timedelta(hours=24) точнее, чем timedelta(days=1) при учёте перехода суток.

from pydantic import BaseModel, field_validator
from datetime import datetime, timedelta

class Appointment(BaseModel):
    patient_name: str
    appointment_date: datetime

    @field_validator('appointment_date')
    @classmethod
    def check_appointment_date(cls, v: datetime) -> datetime:
        if v < datetime.now() + timedelta(hours=24):
            raise ValueError(
                "Appointment must be scheduled at least 24 hours in advance."
            )
        return v

# Пример использования
try:
    appointment = Appointment(
        patient_name="Alice Smith",
        appointment_date=datetime.now() + timedelta(hours=25)
    )
    print(appointment)
except ValueError as e:
    print(e)

Блок 2: Pydantic — найди ошибку

Задача 5: Product price=-100

Ошибка: значение price=-100 нарушает условие валидатора check_price, который требует value > 0. При попытке создать Product с такой ценой будет выброшен ValidationError с сообщением «Price must be positive».

Как исправить: передать положительное значение цены. Если нужно разрешить нулевую цену — изменить условие на value < 0.

# Ошибочный вызов:
product = Product(name="Laptop", price=-100)
# -> ValidationError: price - Price must be positive

# Исправление:
product = Product(name="Laptop", price=1500.00)
print(product)  # name='Laptop' price=1500.0

Дополнительно: в Pydantic v2 рекомендуется добавить @classmethod к валидатору — в данном коде он отсутствует (это также ошибка стиля для v2).

Задача 6: User email="aliceexample.com" без @

Ошибка: строка "aliceexample.com" не является валидным email — в ней отсутствует символ @. Тип EmailStr в Pydantic проверяет формат email по стандарту RFC 5321. При создании объекта будет выброшен ValidationError.

Как исправить: передать корректный email с символом @.

# Ошибочный вызов:
user = User(name="Alice", email="aliceexample.com")
# -> ValidationError: email - value is not a valid email address

# Исправление:
user = User(name="Alice", email="alice@example.com")
print(user)  # name='Alice' email='alice@example.com'

Задача 7: Account balance="100" при validate_assignment

Ошибка: поле balance ожидает float, но передана строка "100". Флаг validate_assignment=True (или его аналог в v2: ConfigDict(validate_assignment=True)) заставляет Pydantic строго проверять типы при присвоении. Строка не приводится к float автоматически в этом режиме.

Примечание: без validate_assignment=True Pydantic v2 может принять строку "100" и автоматически преобразовать её в 100.0. С этой настройкой — не принимает.

# Ошибочный вызов:
acc = Account(username="john_doe", balance="100")
# -> ValidationError: balance - Input should be a valid number

# Исправление (передать float или число):
acc = Account(username="john_doe", balance=100.0)
print(acc)  # username='john_doe' balance=100.0

# Также стоит обновить Config до ConfigDict (Pydantic v2):
# model_config = ConfigDict(validate_assignment=True)

Задача 8: Item() без обязательного price

Ошибка: при создании Item() не передано значение для поля price. Поле price: float = Field(gt=0) — обязательное (нет дефолтного значения, только ограничение). Поле name имеет default=None, поэтому для него ошибки нет.

# Ошибочный вызов:
item = Item()
# -> ValidationError: price - Field required

# Исправление (передать price):
item = Item(price=9.99)
print(item)  # name=None price=9.99

# Или:
item = Item(name="Widget", price=9.99)
print(item)  # name='Widget' price=9.99

Блок 3: SQLAlchemy

Задача 9: Engine для MySQL

Логика: строка подключения для MySQL строится как mysql+pymysql://пользователь:пароль@хост/база. Диалект mysql+pymysql говорит SQLAlchemy использовать PyMySQL как DBAPI.

from sqlalchemy import create_engine

engine = create_engine('mysql+pymysql://user:password@localhost/mydatabase')

# Установка pymysql: pip install pymysql

Задача 10: Engine SQLite :memory: с логированием

Логика: sqlite:///:memory: — специальный URL для SQLite в памяти (база исчезает при закрытии соединения). echo=True — удобный способ включить логирование SQL-запросов в stdout. Альтернатива — модуль logging.

from sqlalchemy import create_engine
import logging

logging.basicConfig(level=logging.INFO)
engine = create_engine('sqlite:///:memory:', echo=True)

Задача 11: Модель User на SQLAlchemy 2.x

Логика: в 2.x базовый класс создаётся наследованием от DeclarativeBase, поля — через Mapped[тип] = mapped_column(...) с явными аннотациями типов.

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, Integer

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = 'users'
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))
    age: Mapped[int] = mapped_column(Integer)

Задача 12: User + Post — one-to-many

Логика: ForeignKey('users.id') в Post создаёт ссылку на родительскую запись. relationship с back_populates связывает оба класса. back_populates создаёт двустороннюю связь — при обращении к user.posts получаем список постов.

from sqlalchemy import String, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import List

class User(Base):
    __tablename__ = 'users'
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))
    age: Mapped[int] = mapped_column(Integer)
    posts: Mapped[List["Post"]] = relationship("Post", back_populates="user")

class Post(Base):
    __tablename__ = 'posts'
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(255))
    user_id: Mapped[int] = mapped_column(ForeignKey('users.id'))
    user: Mapped["User"] = relationship("User", back_populates="posts")

Base.metadata.create_all(engine)

Задача 13: User + Address — one-to-many

Логика: аналогична задаче 12. Один User имеет много Address. Важно: в исходном коде лекции модель Address не имеет поля description — оно нужно для задачи 14. Добавим его как необязательное.

from sqlalchemy import String, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import List, Optional

class User(Base):
    __tablename__ = 'users'
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))
    age: Mapped[int] = mapped_column(Integer)
    addresses: Mapped[List["Address"]] = relationship(
        "Address", back_populates="user"
    )

class Address(Base):
    __tablename__ = 'addresses'
    id: Mapped[int] = mapped_column(primary_key=True)
    description: Mapped[Optional[str]] = mapped_column(String(255))
    user_id: Mapped[int] = mapped_column(ForeignKey('users.id'))
    user: Mapped["User"] = relationship("User", back_populates="addresses")

Base.metadata.create_all(engine)

Задача 14: Сессия — add/commit/delete/query

Логика: в SQLAlchemy 2.x используем контекстный менеджер with Session(engine) as session:. После commit() транзакция зафиксирована в БД. После delete(obj) + commit() запись удалена — повторный query вернёт None.

from sqlalchemy.orm import Session

# Создать таблицы
Base.metadata.create_all(engine)

with Session(engine) as session:
    # Создание нового пользователя и адреса
    new_user = User(name="John Doe", age=28)
    new_address = Address(user=new_user, description="123 Elm Street")

    # Добавление в базу данных
    session.add(new_user)
    session.add(new_address)
    session.commit()

    # Удаление пользователя и проверка
    session.delete(new_user)
    session.commit()

    # Проверка, что пользователь удалён
    result = session.query(User).filter_by(name="John Doe").first()
    print(result)  # None

Задача 15: Анализ ошибок — 4 проблемы

Найденные ошибки из конспекта:

  1. Нет Base = declarative_base() (в 1.x) или класса class Base(DeclarativeBase) (в 2.x) — Base используется, но не определён.
  2. Классы Person и Pet не наследуются от Base — написано class Person(): вместо class Person(Base):. SQLAlchemy не узнает эти классы как ORM-модели.
  3. В sessionmaker() отсутствует параметр движка — написано sessionmaker() вместо привязки к engine. В 2.x используем with Session(engine).
  4. Не указан размер строки в StringString без аргумента может вызвать проблемы в некоторых СУБД (MySQL требует явную длину). Нужно String(100) или другой размер.

Исправленный код на SQLAlchemy 2.x:

from sqlalchemy import Column, ForeignKey, Integer, String, create_engine
from sqlalchemy.orm import (
    DeclarativeBase, Mapped, mapped_column,
    relationship, Session
)
from typing import List, Optional

engine = create_engine('sqlite:///example.db')

# ИСПРАВЛЕНИЕ 1: определить базовый класс
class Base(DeclarativeBase):
    pass

# ИСПРАВЛЕНИЕ 2: наследоваться от Base
class Person(Base):
    __tablename__ = 'persons'
    id: Mapped[int] = mapped_column(primary_key=True)
    # ИСПРАВЛЕНИЕ 4: указать длину строки
    name: Mapped[str] = mapped_column(String(100), nullable=False)
    pets: Mapped[List["Pet"]] = relationship("Pet", back_populates="owner")

class Pet(Base):
    __tablename__ = 'pets'
    id: Mapped[int] = mapped_column(primary_key=True)
    # ИСПРАВЛЕНИЕ 4: указать длину строки
    name: Mapped[str] = mapped_column(String(100), nullable=False)
    owner_id: Mapped[int] = mapped_column(ForeignKey('persons.id'))
    owner: Mapped["Person"] = relationship("Person", back_populates="pets")

Base.metadata.create_all(engine)

# ИСПРАВЛЕНИЕ 3: использовать with Session(engine) вместо sessionmaker()
with Session(engine) as session:
    new_person = Person(name='Alice')
    new_pet = Pet(name='Fido', owner=new_person)
    session.add(new_person)
    session.add(new_pet)
    session.commit()
# Сессия закрывается автоматически