Python

Модуль 9: Типизация, тестирование и качество

В этом модуле мы систематизируем инженерные практики: статическая типизация, исключения и контракты, логирование, создание CLI, тестирование и мокирование. Также рассмотрим ключевые модули стандартной библиотеки, которые встречаются в большинстве проектов.

9.1 Типизация и mypy

Аннотации типов улучшают читаемость и надёжность кода, позволяют инструментам (mypy, pyright) проверять ошибки на этапе разработки.

from typing import Optional, Union, Iterable, Sequence, Mapping, Callable, TypeVar, Generic, Literal

T = TypeVar("T")

# Базовые аннотации
age: int = 42
name: str = "Alice"
ratio: float = 0.75

# Optional/Union
user_id: Optional[int] = None
value: Union[int, float] = 3.14

# Коллекции
nums: list[int] = [1, 2, 3]
users: dict[str, int] = {"alice": 1}

# Параметры функций и возвращаемые типы
def add(a: int, b: int) -> int:
    return a + b

# Callable
def apply(f: Callable[[int], int], x: int) -> int:
    return f(x)

# Обобщения
class Box(Generic[T]):
    def __init__(self, value: T) -> None:
        self.value = value

    def get(self) -> T:
        return self.value

# Literal полезен для ограниченных наборов значений
Mode = Literal["r", "w", "a"]

def open_mode(mode: Mode) -> None:
    ...
# Протоколы и структурная типизация
from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

class Resource:
    def close(self) -> None:
        print("closed")

# TypedDict для словарей с фиксированной схемой
from typing import TypedDict

class User(TypedDict):
    id: int
    name: str
    is_active: bool

u: User = {"id": 1, "name": "Alice", "is_active": True}

Настройка mypy

Проверка типов выполняется вне рантайма. Добавьте конфиг и запустите проверку.

# mypy.ini
[mypy]
python_version = 3.11
warn_unused_ignores = True
warn_return_any = True
warn_redundant_casts = True
no_implicit_optional = True
strict = True

[mypy-tests.*]
ignore_errors = True
# Пример запуска
mypy src/

Типизация не влияет на выполнение кода, но помогает IDE, CI и код-ревью. В сочетании с dataclasses/pydantic повышает надёжность.

9.2 Исключения и контракты

Исключения — стандартный механизм сигнализации об ошибках. Контракты формализуют предпосылки и постусловия функций.

class DomainError(Exception):
    pass

class ValidationError(DomainError):
    pass

def divide(a: float, b: float) -> float:
    # Предусловие (contract): b != 0
    assert b != 0, "b must be non-zero"
    return a / b

try:
    result = divide(10, 0)
except AssertionError as e:
    # Контракт нарушен
    print(e)

# Контекстные менеджеры для гарантированного освобождения ресурсов
from contextlib import contextmanager

@contextmanager
def open_safely(path: str):
    f = open(path, "w", encoding="utf-8")
    try:
        yield f
    finally:
        f.close()

with open_safely("file.txt") as f:
    f.write("hello")

Лучшие практики

  • Поднимайте специфичные исключения (не глотайте их без логирования).
  • Используйте контракты через assert для инвариантов и предусловий; в проде — валидацию входных данных.
  • Для сложной валидации используйте pydantic или attrs.
  • Оберните границы I/O в адаптеры и пробрасывайте доменные ошибки.

9.3 Логирование

logging — стандартный фреймворк логирования. Настройте формат, уровни и хендлеры.

import logging
from pathlib import Path

LOG_DIR = Path("logs")
LOG_DIR.mkdir(exist_ok=True)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
    handlers=[
        logging.StreamHandler(),
        logging.FileHandler(LOG_DIR / "app.log", encoding="utf-8")
    ]
)
logger = logging.getLogger("app")

logger.info("started")
logger.warning("something odd")
# dictConfig для продвинутой конфигурации
import logging.config

LOGGING = {
    "version": 1,
    "handlers": {
        "console": {"class": "logging.StreamHandler", "level": "DEBUG"},
    },
    "loggers": {
        "app": {"handlers": ["console"], "level": "INFO"}
    }
}

logging.config.dictConfig(LOGGING)
log = logging.getLogger("app")
log.info("configured via dictConfig")

Для структурированных логов рассмотрите structlog или logging.LoggerAdapter с добавлением контекста.

9.4 CLI и argparse

Создавайте удобные консольные интерфейсы с argparse. Для сложных CLI обратите внимание на click и typer.

# cli.py
import argparse
from pathlib import Path

parser = argparse.ArgumentParser(prog="photo-tool", description="Resize photos")
parser.add_argument("input", type=Path, help="Папка с изображениями")
parser.add_argument("--width", type=int, default=800)
parser.add_argument("--height", type=int, default=600)
parser.add_argument("--dry-run", action="store_true")

args = parser.parse_args()
print(args.input, args.width, args.height, args.dry_run)
# Подкоманды
sub = argparse.ArgumentParser(prog="tool")
cmd = sub.add_subparsers(dest="command", required=True)

build = cmd.add_parser("build")
build.add_argument("--prod", action="store_true")

serve = cmd.add_parser("serve")
serve.add_argument("--port", type=int, default=8000)

9.5 Тестирование pytest

pytest — де-факто стандарт в Python. Чистый синтаксис, мощные фикстуры и плагин-экосистема.

# test_math.py
import pytest

@pytest.mark.parametrize("a,b,expected", [
    (1, 2, 3),
    (0, 0, 0),
    (-1, 1, 0),
])
def test_add(a, b, expected):
    assert a + b == expected

# Фикстуры
@pytest.fixture
def tmp_user(tmp_path):
    p = tmp_path / "user.json"
    p.write_text("{}", encoding="utf-8")
    return p
# pytest.ini
[pytest]
addopts = -q -ra
filterwarnings = ignore::DeprecationWarning
python_files = tests.py test_*.py *_tests.py
markers =
    slow: медленные тесты
    integration: интеграционные

Покрывайте тестами бизнес-правила и границы систем. В CI включайте отчёт о покрытии (coverage.py).

9.6 Мокирование

Изолируйте тестируемый код от внешних зависимостей: сети, БД, времени.

# unittest.mock
from unittest.mock import patch, MagicMock
import requests

@patch("requests.get")
def test_fetch(mock_get: MagicMock):
    mock_get.return_value.status_code = 200
    mock_get.return_value.json.return_value = {"ok": True}
    resp = requests.get("https://api.example.com")
    assert resp.json()["ok"] is True
# monkeypatch (pytest)
def test_env(monkeypatch):
    monkeypatch.setenv("APP_ENV", "test")
    import os
    assert os.getenv("APP_ENV") == "test"

Для времени используйте freezegun, для HTTP — responses или pytest-httpserver.

9.7 Широко используемая стандартная библиотека

itertools

from itertools import islice, chain, combinations, groupby

nums = range(100)
first_ten = list(islice(nums, 10))

pairs = list(combinations([1,2,3], 2))  # [(1,2), (1,3), (2,3)]

# Группировка
animals = ["ant", "bear", "cat", "cow"]
key = lambda s: s[0]
for k, g in groupby(sorted(animals, key=key), key=key):
    print(k, list(g))

functools

from functools import lru_cache, partial, singledispatch

@lru_cache(maxsize=1024)
def fib(n: int) -> int:
    return n if n < 2 else fib(n-1) + fib(n-2)

mul_by_10 = partial(lambda x, y: x * y, 10)

@singledispatch
def dump(obj):
    return str(obj)

@dump.register
def _(obj: list):
    return f"list({len(obj)})"

pathlib

from pathlib import Path

p = Path("data") / "file.txt"
if not p.exists():
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text("hello", encoding="utf-8")
print(p.read_text(encoding="utf-8"))

datetime и zoneinfo

from datetime import datetime, timezone
from zoneinfo import ZoneInfo

now_utc = datetime.now(timezone.utc)
now_msk = now_utc.astimezone(ZoneInfo("Europe/Moscow"))
print(now_msk.isoformat())

Настройки

Цветовая схема

Тема