Главная/Проекты/Решение
Эталонное решениеСначала выполните проект самостоятельно и используйте этот материал для сверки решений.

Проект 1. Эталонное решение

Открывать после собственной реализации и прохождения CHECKLIST.md. Прочитанное решение всегда кажется очевидным — это свойство чтения, а не признак понимания.

Решение — не единственно верное. Места, где обоснован другой выбор, отмечены явно.

Что проверено на самом деле

ЧтоКакРезультат
Код приложенияфактический запускработает
Тестыpytest48 пройдено
Покрытиеpytest-cov93 %
Установка пакетаpip install . в чистом окруженииработает
Все четыре кода возвратазапуск с разными входамиразличимы
Dockerfileне собиралсяDocker на машине курса не установлен

Последняя строка существенна: Dockerfile ниже написан по правилам разделов 05 и 15, но сборка не выполнялась. Проверьте её у себя — CHECKLIST.md содержит нужные команды.


Структура

text
logstat/
├── Dockerfile
├── .dockerignore
├── pyproject.toml
├── README.md
├── logstat.example.toml
├── src/logstat/
│   ├── __init__.py
│   ├── __main__.py
│   ├── errors.py
│   ├── config.py
│   ├── stats.py
│   └── cli.py
└── tests/
    ├── test_config.py
    ├── test_stats.py
    └── test_cli.py

Порядок изложения ниже — снизу вверх: от того, что ни от чего не зависит, к оболочке.


Шаг 1. Коды возврата как часть модели ошибок

Первое решение, определяющее остальные: код возврата прикреплён к исключению, а не выбирается в месте обработки.

src/logstat/errors.py:

python
"""Коды возврата и исключения.

Три различимых кода вместо двух — сознательное решение: «записей
не найдено» и «произошла ошибка» требуют разной реакции в скриптах.
"""
from __future__ import annotations

EXIT_OK = 0
EXIT_RUNTIME = 1
EXIT_USAGE = 2
EXIT_NO_MATCH = 3


class LogstatError(Exception):
    """Базовая ошибка приложения."""

    exit_code = EXIT_RUNTIME


class UsageError(LogstatError):
    """Неверное использование: аргументы или конфигурация."""

    exit_code = EXIT_USAGE


class RuntimeFailure(LogstatError):
    """Ошибка выполнения: недоступный файл, испорченные данные."""

    exit_code = EXIT_RUNTIME

Альтернатива — возвращать коды из функций — приводит к тому, что каждый вызывающий обязан их пробрасывать, и один пропущенный return превращает ошибку в успех. Здесь достаточно одного обработчика в main().

Где возможен другой выбор. Различать RuntimeFailure и базовый LogstatError строго необязательно: сейчас у них один код. Разделение оставлено как место для роста — например, для кода 4 «частичный результат».


Шаг 2. Конфигурация из трёх источников

src/logstat/config.py:

python
"""Конфигурация: файл TOML, переменные окружения, аргументы.

Приоритет — от низшего к высшему: значения по умолчанию, файл,
переменные окружения, аргументы командной строки. Источник каждого
значения запоминается: без этого невозможно ответить на вопрос
«почему применилось не то, что я задал».
"""
from __future__ import annotations

import os
import tomllib
from dataclasses import dataclass, field
from pathlib import Path

from .errors import UsageError

ENV_PREFIX = "LOGSTAT_"
DEFAULT_CONFIG_PATH = Path("/etc/logstat/config.toml")

FORMATS = ("text", "json")


@dataclass
class Config:
    """Итоговая конфигурация и происхождение каждого значения."""

    top: int = 10
    group_by: str = "status"
    output_format: str = "text"
    strict: bool = False
    sources: dict[str, str] = field(default_factory=dict)

    def describe(self) -> list[tuple[str, object, str]]:
        return [
            ("top", self.top, self.sources.get("top", "умолчание")),
            ("group_by", self.group_by, self.sources.get("group_by", "умолчание")),
            ("output_format", self.output_format,
             self.sources.get("output_format", "умолчание")),
            ("strict", self.strict, self.sources.get("strict", "умолчание")),
        ]


def _coerce(key: str, raw: object, origin: str) -> object:
    """Приведение к типу с понятным сообщением об ошибке."""
    if key == "top":
        try:
            value = int(raw)  # type: ignore[arg-type]
        except (TypeError, ValueError):
            raise UsageError(f"top: ожидалось целое число, получено {raw!r} ({origin})")
        if value < 1:
            raise UsageError(f"top: ожидалось положительное число, получено {value} ({origin})")
        return value
    if key == "output_format":
        value = str(raw)
        if value not in FORMATS:
            raise UsageError(
                f"output_format: ожидалось одно из {', '.join(FORMATS)}, "
                f"получено {value!r} ({origin})")
        return value
    if key == "strict":
        if isinstance(raw, bool):
            return raw
        value = str(raw).strip().lower()
        if value in ("1", "true", "yes", "on"):
            return True
        if value in ("0", "false", "no", "off"):
            return False
        raise UsageError(f"strict: ожидалось логическое значение, получено {raw!r} ({origin})")
    return str(raw)


def _from_file(path: Path) -> dict[str, object]:
    try:
        data = tomllib.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        raise UsageError(f"файл конфигурации не найден: {path}")
    except tomllib.TOMLDecodeError as exc:
        raise UsageError(f"файл конфигурации испорчен: {path}: {exc}")
    section = data.get("logstat", data)
    if not isinstance(section, dict):
        raise UsageError(f"файл конфигурации: ожидалась таблица [logstat] в {path}")
    return section


def _from_env(env: dict[str, str]) -> dict[str, object]:
    out: dict[str, object] = {}
    for name, value in env.items():
        if not name.startswith(ENV_PREFIX):
            continue
        key = name[len(ENV_PREFIX):].lower()
        out[key] = value
    return out


def load(
    config_path: Path | None,
    overrides: dict[str, object],
    env: dict[str, str] | None = None,
) -> Config:
    """Собрать конфигурацию из всех источников по приоритету."""
    env = os.environ if env is None else env
    cfg = Config()
    known = {"top", "group_by", "output_format", "strict"}

    layers: list[tuple[str, dict[str, object]]] = []

    path = config_path
    if path is None:
        env_path = env.get(f"{ENV_PREFIX}CONFIG")
        if env_path:
            path = Path(env_path)
        elif DEFAULT_CONFIG_PATH.exists():
            path = DEFAULT_CONFIG_PATH
    if path is not None:
        layers.append((f"файл {path}", _from_file(path)))

    layers.append(("окружение", _from_env(env)))
    layers.append(("аргумент", {k: v for k, v in overrides.items() if v is not None}))

    for origin, values in layers:
        for key, raw in values.items():
            if key == "config":
                continue
            if key not in known:
                raise UsageError(f"неизвестный параметр {key!r} ({origin})")
            setattr(cfg, key, _coerce(key, raw, origin))
            cfg.sources[key] = origin

    return cfg

Три решения, которые стоит разобрать.

Источник каждого значения запоминается. Поле sources и метод describe() существуют ради одного вопроса: «почему применилось не то, что я задал». Без них ответ ищут перебором. Это то же требование к диагностируемости, что healthcheck предъявляет к сервису.

Отсутствующий аргумент отфильтровывается явно. Строка

python
layers.append(("аргумент", {k: v for k, v in overrides.items() if v is not None}))

решает частую ошибку: argparse кладёт None в неуказанные параметры, и наивное dict.update() затрёт значение из окружения. В тестах это test_none_argument_does_not_override.

Неизвестный параметр — ошибка, а не молчаливое игнорирование. LOGSTAT_TPO=5 (опечатка) должен приводить к отказу. Молчаливое игнорирование даёт худший исход: программа работает и делает не то.

Где возможен другой выбор. Приведение типов написано вручную. Для более крупного проекта разумнее pydantic-settings (урок 6.5); здесь зависимость не добавлена намеренно, чтобы образ собирался без единого стороннего пакета.


Шаг 3. Подсчёт

src/logstat/stats.py:

python
"""Разбор записей и подсчёт статистики."""
from __future__ import annotations

import json
from collections import Counter
from dataclasses import dataclass
from typing import Iterable, Iterator

from .errors import RuntimeFailure


@dataclass
class Skipped:
    """Строки, которые не удалось разобрать."""

    count: int = 0
    first_line: int | None = None
    first_reason: str = ""

    def add(self, line_no: int, reason: str) -> None:
        self.count += 1
        if self.first_line is None:
            self.first_line = line_no
            self.first_reason = reason


@dataclass
class Report:
    """Результат подсчёта."""

    group_by: str
    total: int
    matched: int
    counts: Counter[str]
    skipped: Skipped

    def top(self, n: int) -> list[tuple[str, int]]:
        # most_common сортирует по убыванию счётчика; при равенстве
        # порядок вставки не воспроизводим между запусками, поэтому
        # добавлена вторая ось сортировки — по ключу.
        return sorted(self.counts.items(), key=lambda kv: (-kv[1], kv[0]))[:n]


def parse_filter(expr: str | None) -> tuple[str, str] | None:
    """Разобрать выражение вида поле=значение."""
    if expr is None:
        return None
    if "=" not in expr:
        raise RuntimeFailure(f"фильтр должен иметь вид поле=значение, получено {expr!r}")
    field, _, value = expr.partition("=")
    field = field.strip()
    if not field:
        raise RuntimeFailure(f"фильтр без имени поля: {expr!r}")
    return field, value.strip()


def read_records(lines: Iterable[str], strict: bool,
                 skipped: Skipped) -> Iterator[dict[str, object]]:
    """Разобрать JSON Lines, пропуская или отвергая испорченные строки."""
    for line_no, raw in enumerate(lines, 1):
        line = raw.strip()
        if not line:
            continue
        try:
            record = json.loads(line)
        except json.JSONDecodeError as exc:
            reason = f"строка {line_no}: не JSON ({exc.msg})"
            if strict:
                raise RuntimeFailure(reason)
            skipped.add(line_no, reason)
            continue
        if not isinstance(record, dict):
            reason = f"строка {line_no}: ожидался объект, получен {type(record).__name__}"
            if strict:
                raise RuntimeFailure(reason)
            skipped.add(line_no, reason)
            continue
        yield record


def build_report(lines: Iterable[str], group_by: str,
                 filter_expr: str | None, strict: bool) -> Report:
    """Посчитать статистику по потоку строк."""
    condition = parse_filter(filter_expr)
    skipped = Skipped()
    counts: Counter[str] = Counter()
    total = matched = 0

    for record in read_records(lines, strict, skipped):
        total += 1
        if condition is not None:
            field, expected = condition
            if str(record.get(field, "")) != expected:
                continue
        matched += 1
        counts[str(record.get(group_by, "—"))] += 1

    return Report(group_by=group_by, total=total, matched=matched,
                  counts=counts, skipped=skipped)

Решение о порядке сортировки. Counter.most_common() при равных счётчиках возвращает элементы в порядке вставки — то есть результат зависит от порядка строк во входном файле. Для инструмента, чей вывод сравнивают между запусками, это плохо. Вторая ось сортировки по ключу делает вывод воспроизводимым; тест test_top_is_deterministic_on_ties это фиксирует.

Решение о неразобранных строках. По умолчанию строка пропускается, но считается, и число попадает в диагностику. Молчаливый пропуск дал бы правдоподобный неверный ответ — исход хуже отказа. Режим --strict переключает поведение на отказ, что нужно в конвейере, где данные обязаны быть чистыми.


Шаг 4. Оболочка командной строки

src/logstat/cli.py:

python
"""Точка входа командной строки."""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import IO, Sequence

from . import __version__
from .config import Config, load
from .errors import EXIT_NO_MATCH, EXIT_OK, LogstatError, RuntimeFailure, UsageError
from .stats import Report, build_report


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="logstat",
        description="Статистика по журналам в формате JSON Lines.",
        epilog="Коды возврата: 0 успех, 1 ошибка выполнения, "
               "2 ошибка использования, 3 записей не найдено.",
    )
    parser.add_argument("path", nargs="?", default="-",
                        help="файл журнала; «-» или пропуск — стандартный ввод")
    parser.add_argument("--group-by", dest="group_by", metavar="ПОЛЕ",
                        help="поле для группировки")
    parser.add_argument("--top", type=int, metavar="N",
                        help="сколько строк показать")
    parser.add_argument("--filter", dest="filter_expr", metavar="ПОЛЕ=ЗНАЧЕНИЕ",
                        help="оставить записи с указанным значением поля")
    parser.add_argument("--format", dest="output_format", choices=("text", "json"),
                        help="формат вывода")
    parser.add_argument("--strict", action="store_true", default=None,
                        help="прерываться на первой неразобранной строке")
    parser.add_argument("--config", type=Path, metavar="ПУТЬ",
                        help="файл конфигурации TOML")
    parser.add_argument("--show-config", action="store_true",
                        help="показать итоговую конфигурацию и источник каждого значения")
    parser.add_argument("--version", action="version",
                        version=f"logstat {__version__}")
    return parser


def open_source(path: str, stdin: IO[str]) -> tuple[IO[str], bool]:
    """Вернуть поток строк и признак, нужно ли его закрывать."""
    if path == "-":
        return stdin, False
    p = Path(path)
    try:
        return p.open(encoding="utf-8"), True
    except FileNotFoundError:
        raise RuntimeFailure(f"файл не найден: {path}")
    except IsADirectoryError:
        raise RuntimeFailure(f"это каталог, а не файл: {path}")
    except PermissionError:
        raise RuntimeFailure(f"нет прав на чтение: {path}")


def render_text(report: Report, top: int, out: IO[str]) -> None:
    rows = report.top(top)
    width = max((len(k) for k, _ in rows), default=len(report.group_by))
    width = max(width, len(report.group_by))
    print(f"{report.group_by:<{width}}  {'записей':>8}  {'доля':>7}", file=out)
    print("─" * (width + 19), file=out)
    for key, count in rows:
        share = count / report.matched * 100 if report.matched else 0.0
        print(f"{key:<{width}}  {count:>8}  {share:>6.1f}%", file=out)


def render_json(report: Report, top: int, out: IO[str]) -> None:
    payload = {
        "группировка": report.group_by,
        "всего": report.total,
        "отобрано": report.matched,
        "пропущено": report.skipped.count,
        "строки": [{"ключ": k, "записей": n} for k, n in report.top(top)],
    }
    json.dump(payload, out, ensure_ascii=False, indent=2)
    out.write("\n")


def report_diagnostics(report: Report, cfg: Config, err: IO[str]) -> None:
    """Диагностика идёт в stderr, чтобы не смешиваться с результатом."""
    print(f"прочитано записей: {report.total}, отобрано: {report.matched}", file=err)
    if report.skipped.count:
        print(f"пропущено неразобранных строк: {report.skipped.count} "
              f"(первая — {report.skipped.first_reason})", file=err)
        print("подсказка: --strict прерывает работу на первой такой строке", file=err)


def show_config(cfg: Config, out: IO[str]) -> None:
    print(f"{'параметр':<16} {'значение':<12} источник", file=out)
    print("─" * 48, file=out)
    for name, value, origin in cfg.describe():
        print(f"{name:<16} {str(value):<12} {origin}", file=out)


def main(argv: Sequence[str] | None = None,
         stdin: IO[str] | None = None,
         stdout: IO[str] | None = None,
         stderr: IO[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    stdin = stdin or sys.stdin
    out = stdout or sys.stdout
    err = stderr or sys.stderr

    try:
        cfg = load(args.config, {
            "top": args.top,
            "group_by": args.group_by,
            "output_format": args.output_format,
            "strict": args.strict,
        })

        if args.show_config:
            show_config(cfg, out)
            return EXIT_OK

        source, need_close = open_source(args.path, stdin)
        try:
            report = build_report(source, cfg.group_by, args.filter_expr, cfg.strict)
        finally:
            if need_close:
                source.close()

        if cfg.output_format == "json":
            render_json(report, cfg.top, out)
        else:
            render_text(report, cfg.top, out)

        report_diagnostics(report, cfg, err)

        if report.matched == 0:
            print("записей, удовлетворяющих условию, не найдено", file=err)
            return EXIT_NO_MATCH
        return EXIT_OK

    except LogstatError as exc:
        print(f"logstat: {exc}", file=err)
        return exc.exit_code
    except BrokenPipeError:
        # Обычная ситуация при `logstat ... | head`: получатель закрыл поток.
        return EXIT_OK
    except KeyboardInterrupt:
        print("logstat: прервано", file=err)
        return 130

Четыре решения.

Потоки передаются параметрами, а не берутся из sys. Сигнатура main(argv, stdin, stdout, stderr) позволяет тестировать разделение потоков без подмены глобального состояния. Это сделало возможными тесты test_result_goes_to_stdout_diagnostics_to_stderr и test_json_output_contains_only_json — самые ценные в наборе.

BrokenPipeError перехватывается и даёт код 0. logstat ... | head -2 закрывает поток на середине. Без обработки Python печатает трассировку — то есть штатное использование выглядит как отказ.

Диагностика печатается после результата, но это не гарантирует порядок. При перенаправлении stdout буферизуется блоками, stderr — нет. Смешанный вывод может идти в любом порядке; это отмечено в CHECKLIST.md и не является ошибкой.

--show-config печатает в stdout и завершает работу. Это результат работы, а не диагностика: его разбирают скриптами.

Точка входа:

src/logstat/__init__.py:

python
"""logstat — статистика по журналам в формате JSON Lines."""

__version__ = "1.0.0"

src/logstat/__main__.py:

python
"""Запуск через `python -m logstat`."""
from __future__ import annotations

import sys

from .cli import main

if __name__ == "__main__":
    sys.exit(main())

Наличие __main__.py даёт python -m logstat — форма, работающая без установки консольного скрипта. Полезно при отладке внутри container'а.


Шаг 5. Тесты

Набор разделён по слоям: конфигурация, подсчёт, оболочка. Тесты оболочки — самые содержательные, потому что проверяют именно то, что ломается чаще всего.

tests/test_config.py:

python
"""Конфигурация: приоритет источников и сообщения об ошибках."""
from __future__ import annotations

import pytest

from logstat.config import load
from logstat.errors import UsageError


def test_defaults_when_nothing_given():
    cfg = load(None, {}, env={})
    assert cfg.top == 10
    assert cfg.group_by == "status"
    assert cfg.output_format == "text"
    assert cfg.strict is False


def test_env_overrides_defaults():
    cfg = load(None, {}, env={"LOGSTAT_TOP": "3"})
    assert cfg.top == 3
    assert cfg.sources["top"] == "окружение"


def test_argument_overrides_env():
    cfg = load(None, {"top": 5}, env={"LOGSTAT_TOP": "3"})
    assert cfg.top == 5
    assert cfg.sources["top"] == "аргумент"


def test_file_is_lowest_of_the_three(tmp_path):
    path = tmp_path / "c.toml"
    path.write_text('[logstat]\ntop = 7\ngroup_by = "path"\n', encoding="utf-8")
    cfg = load(path, {}, env={"LOGSTAT_TOP": "3"})
    assert cfg.top == 3, "окружение должно перекрывать файл"
    assert cfg.group_by == "path", "файл применяется там, где нет других источников"
    assert cfg.sources["group_by"].startswith("файл")


def test_none_argument_does_not_override():
    """Отсутствующий аргумент — не то же самое, что заданный пустым."""
    cfg = load(None, {"top": None}, env={"LOGSTAT_TOP": "3"})
    assert cfg.top == 3


@pytest.mark.parametrize("value", ["zero", "-1", "0", ""])
def test_bad_top_is_usage_error(value):
    with pytest.raises(UsageError) as exc:
        load(None, {}, env={"LOGSTAT_TOP": value})
    assert "top" in str(exc.value)


def test_bad_format_names_allowed_values():
    with pytest.raises(UsageError) as exc:
        load(None, {}, env={"LOGSTAT_OUTPUT_FORMAT": "xml"})
    assert "text" in str(exc.value) and "json" in str(exc.value)


def test_unknown_key_is_rejected():
    with pytest.raises(UsageError) as exc:
        load(None, {}, env={"LOGSTAT_NOSUCH": "1"})
    assert "nosuch" in str(exc.value)


def test_missing_config_file_is_usage_error(tmp_path):
    with pytest.raises(UsageError):
        load(tmp_path / "нет.toml", {}, env={})


def test_broken_config_file_names_the_path(tmp_path):
    path = tmp_path / "c.toml"
    path.write_text("[logstat\ntop = 1", encoding="utf-8")
    with pytest.raises(UsageError) as exc:
        load(path, {}, env={})
    assert str(path) in str(exc.value)


@pytest.mark.parametrize("raw,expected", [
    ("1", True), ("true", True), ("yes", True), ("on", True),
    ("0", False), ("false", False), ("no", False), ("off", False),
])
def test_strict_accepts_usual_spellings(raw, expected):
    cfg = load(None, {}, env={"LOGSTAT_STRICT": raw})
    assert cfg.strict is expected

tests/test_stats.py:

python
"""Разбор записей и подсчёт."""
from __future__ import annotations

import pytest

from logstat.errors import RuntimeFailure
from logstat.stats import build_report, parse_filter

LINES = [
    '{"status": 200, "path": "/a"}',
    '{"status": 200, "path": "/b"}',
    '{"status": 404, "path": "/a"}',
    "",
    "не json",
    "[1, 2]",
]


def test_counts_by_field():
    r = build_report(LINES, "status", None, strict=False)
    assert r.total == 3
    assert r.matched == 3
    assert dict(r.counts) == {"200": 2, "404": 1}


def test_skipped_lines_are_counted_not_hidden():
    r = build_report(LINES, "status", None, strict=False)
    assert r.skipped.count == 2
    assert r.skipped.first_line == 5


def test_empty_lines_are_not_errors():
    r = build_report(["", "   ", '{"status": 1}'], "status", None, strict=False)
    assert r.skipped.count == 0
    assert r.total == 1


def test_strict_stops_at_first_bad_line():
    with pytest.raises(RuntimeFailure) as exc:
        build_report(LINES, "status", None, strict=True)
    assert "строка 5" in str(exc.value)


def test_filter_reduces_matched_but_not_total():
    r = build_report(LINES, "status", "path=/a", strict=False)
    assert r.total == 3
    assert r.matched == 2


def test_missing_field_becomes_placeholder():
    r = build_report(['{"status": 200}'], "нет_такого", None, strict=False)
    assert dict(r.counts) == {"—": 1}


def test_top_is_deterministic_on_ties():
    """При равных счётчиках порядок задаётся ключом, а не порядком вставки."""
    lines = ['{"k": "b"}', '{"k": "a"}', '{"k": "c"}']
    r = build_report(lines, "k", None, strict=False)
    assert [k for k, _ in r.top(3)] == ["a", "b", "c"]


def test_top_limits_rows():
    lines = [f'{{"k": "{c}"}}' for c in "abcde"]
    r = build_report(lines, "k", None, strict=False)
    assert len(r.top(2)) == 2


@pytest.mark.parametrize("expr", ["без-равно", "=значение"])
def test_bad_filter_is_runtime_failure(expr):
    with pytest.raises(RuntimeFailure):
        parse_filter(expr)


def test_filter_value_may_be_empty():
    assert parse_filter("field=") == ("field", "")

tests/test_cli.py:

python
"""Поведение командной строки: коды возврата и разделение потоков."""
from __future__ import annotations

import io
import json

import pytest

from logstat.cli import main
from logstat.errors import EXIT_NO_MATCH, EXIT_OK, EXIT_RUNTIME, EXIT_USAGE

SAMPLE = "\n".join([
    '{"status": 200, "path": "/a", "method": "GET"}',
    '{"status": 200, "path": "/b", "method": "GET"}',
    '{"status": 500, "path": "/a", "method": "POST"}',
    "испорченная строка",
])


def run(argv, stdin_text="", env=None, monkeypatch=None):
    out, err = io.StringIO(), io.StringIO()
    if env is not None and monkeypatch is not None:
        for k, v in env.items():
            monkeypatch.setenv(k, v)
    code = main(argv, stdin=io.StringIO(stdin_text), stdout=out, stderr=err)
    return code, out.getvalue(), err.getvalue()


def test_reads_stdin_by_default():
    code, out, _ = run([], SAMPLE)
    assert code == EXIT_OK
    assert "200" in out


def test_result_goes_to_stdout_diagnostics_to_stderr():
    _, out, err = run([], SAMPLE)
    assert "200" in out
    assert "прочитано записей" in err
    assert "прочитано записей" not in out, "диагностика не должна попадать в stdout"


def test_skipped_lines_reported_to_stderr():
    _, out, err = run([], SAMPLE)
    assert "пропущено" in err
    assert "пропущено" not in out


def test_no_matches_returns_dedicated_code():
    code, _, err = run(["--filter", "method=DELETE"], SAMPLE)
    assert code == EXIT_NO_MATCH
    assert "не найдено" in err


def test_missing_file_is_runtime_error():
    code, out, err = run(["/нет/такого/файла.jsonl"])
    assert code == EXIT_RUNTIME
    assert out == "", "при ошибке stdout остаётся пустым"
    assert "не найден" in err


def test_bad_env_value_is_usage_error(monkeypatch):
    code, _, err = run([], SAMPLE, env={"LOGSTAT_TOP": "много"}, monkeypatch=monkeypatch)
    assert code == EXIT_USAGE
    assert "top" in err


def test_strict_turns_skips_into_failure():
    code, out, err = run(["--strict"], SAMPLE)
    assert code == EXIT_RUNTIME
    assert out == ""


def test_json_output_is_valid_json():
    _, out, _ = run(["--format", "json"], SAMPLE)
    payload = json.loads(out)
    assert payload["всего"] == 3
    assert payload["пропущено"] == 1


def test_json_output_contains_only_json():
    """Диагностика в stdout сделала бы вывод неразбираемым."""
    _, out, _ = run(["--format", "json"], SAMPLE)
    json.loads(out)  # упадёт, если в stdout попало что-то ещё


def test_top_limits_rows_in_output():
    lines = "\n".join(f'{{"k": "{c}"}}' for c in "abcdef")
    _, out, _ = run(["--group-by", "k", "--top", "2"], lines)
    body = [l for l in out.splitlines() if l and not l.startswith(("k ", "─"))]
    assert len(body) == 2


def test_show_config_reports_source_of_each_value(monkeypatch):
    code, out, _ = run(["--show-config", "--top", "4"],
                       env={"LOGSTAT_GROUP_BY": "path"}, monkeypatch=monkeypatch)
    assert code == EXIT_OK
    assert "аргумент" in out
    assert "окружение" in out


def test_file_argument_is_read(tmp_path):
    path = tmp_path / "log.jsonl"
    path.write_text(SAMPLE, encoding="utf-8")
    code, out, _ = run([str(path)])
    assert code == EXIT_OK
    assert "200" in out


def test_directory_instead_of_file_is_runtime_error(tmp_path):
    code, _, err = run([str(tmp_path)])
    assert code == EXIT_RUNTIME
    assert "каталог" in err


def test_bad_filter_expression_is_runtime_error():
    code, _, err = run(["--filter", "безравно"], SAMPLE)
    assert code == EXIT_RUNTIME
    assert "поле=значение" in err


def test_version_exits_zero():
    with pytest.raises(SystemExit) as exc:
        main(["--version"])
    assert exc.value.code == 0


def test_unknown_argument_exits_two():
    """argparse сам возвращает 2 — тот же код, что и наша UsageError."""
    with pytest.raises(SystemExit) as exc:
        main(["--нет-такого"])
    assert exc.value.code == EXIT_USAGE

Что в этом наборе главное.

Тест test_result_goes_to_stdout_diagnostics_to_stderr содержит утверждение

python
assert "прочитано записей" not in out

Именно оно ловит самую частую ошибку — print() без file=sys.stderr. Тест, читающий объединённый вывод, эту ошибку пропустит: строка ведь напечатана.

Тест test_json_output_contains_only_json — то же требование, выраженное сильнее: json.loads() упадёт, если в stdout попало хоть что-то лишнее.

Фактический результат:

text
48 passed in 0.15s

Name                      Stmts   Miss  Cover   Missing
-------------------------------------------------------
src/logstat/__init__.py       1      0   100%
src/logstat/__main__.py       5      5     0%   2-9
src/logstat/cli.py           89      7    92%   55-56, 142-147
src/logstat/config.py        86      5    94%   68, 81, 111, 113, 123
src/logstat/errors.py        11      0   100%
src/logstat/stats.py         69      1    99%   75
-------------------------------------------------------
TOTAL                       261     18    93%

Что не покрыто и почему это оставлено так. Непокрытые строки — PermissionError при открытии файла, KeyboardInterrupt, BrokenPipeError и несколько ветвей проверки типов в конфигурации. Все они воспроизводятся только через подмену системного поведения, и тест получился бы проверкой заглушки, а не кода. __main__.py не покрыт по устройству: он выполняется при запуске модуля, а не при импорте.

Порог 90 % выбран так, чтобы эти строки укладывались в остаток. Порог 100 % заставил бы писать тесты ради числа — и это отдельный антипаттерн (урок 15.2).


Шаг 6. Упаковка

pyproject.toml:

toml
[project]
name = "logstat"
version = "1.0.0"
description = "Статистика по журналам в формате JSON Lines"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []

[project.scripts]
logstat = "logstat.cli:main"

[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-q"

pythonpath = ["src"] в настройках pytest позволяет запускать тесты без установки пакета — удобно локально. В образе пакет именно устанавливается, поэтому тесты в стадии test проверяют установленный код, а не исходники рядом.


Шаг 7. Dockerfile

dockerfile
# syntax=docker/dockerfile:1

# ── Общая основа: одна и та же для сборки и для запуска ──────────────
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PATH=/opt/venv/bin:$PATH
WORKDIR /app

# ── Зависимости и установка пакета ───────────────────────────────────
FROM base AS deps
RUN python -m venv /opt/venv
# Файлы, влияющие на установку, копируются ДО исходников:
# изменение кода не сбрасывает кэш этого слоя.
COPY pyproject.toml README.md ./
COPY src/ ./src/
# Кэш pip живёт в mount, а не в слое: --no-cache-dir здесь был бы
# прямым противоречием — он отключает ровно то, что кэшируется.
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install .

# ── Тесты: отдельная стадия, выполняется только при --target test ────
FROM deps AS test
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install pytest==9.1.1 pytest-cov==7.1.0
COPY tests/ ./tests/
RUN python -m pytest -q --cov=logstat --cov-report=term-missing --cov-fail-under=90

# ── Итоговый образ ───────────────────────────────────────────────────
FROM base AS runtime
COPY --from=deps /opt/venv /opt/venv
USER 10001:10001
ENTRYPOINT ["logstat"]
CMD ["--help"]

Четыре решения.

Виртуальное окружение в /opt/venv и копирование его целиком. Альтернатива — копировать site-packages — требует знать точный путь с версией Python (/usr/local/lib/python3.13/...) и ломается при обновлении базового образа. Фиксированный путь надёжнее.

--mount=type=cache без --no-cache-dir. Их сочетание встречается часто и бессмысленно: --no-cache-dir отключает ровно тот кэш, который монтируется. Ошибка безобидная, но показывает, что строку скопировали не читая.

Стадия test наследует deps, а не base. Тесты выполняются против установленного пакета. Если бы стадия начиналась с base, пришлось бы устанавливать всё заново, и проверялось бы другое дерево зависимостей.

USER 10001:10001 без создания пользователя в /etc/passwd. Числовой идентификатор работает и без записи в passwd; для Kubernetes он обязателен (урок 18.2). Ограничение: у процесса нет $HOME. Для этого приложения безразлично; приложению, пишущему в домашний каталог, пользователя пришлось бы создать.

.dockerignore:

text
.git
.gitignore
.venv
.v
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
htmlcov
.coverage
*.jsonl

*.jsonl в списке — не мелочь: без него тестовые журналы попадают в контекст сборки и сбрасывают кэш при каждом изменении данных.

logstat.example.toml:

toml
# Пример конфигурации logstat.
# Значения перекрываются переменными окружения LOGSTAT_* и аргументами.

[logstat]
top = 5
group_by = "path"
output_format = "text"
strict = false

Чего решение не делает

Образ не собран. Docker на машине, где готовился курс, отсутствует. Dockerfile написан по правилам разделов 05, 06 и 15, но ни docker build, ни docker run не выполнялись. Всё остальное — код, тесты, покрытие, коды возврата, приоритет конфигурации — проверено фактическим запуском.

Размер образа не измерен. Дополнительное задание 5 (distroless или alpine) не выполнено. Приблизительная оценка была бы выдумкой: размер зависит от базового образа и версии Python.

Воспроизводимость сборки не проверена. Дополнительное задание 6 требует двух сборок подряд и сравнения digest — без Docker это невозможно. Заметим, что решение к воспроизводимости и не стремится: базовый образ взят по тегу python:3.13-slim, а не по digest. Для проекта 1 это осознанное упрощение; в проекте 4 так делать уже нельзя.

Зависимости не зафиксированы файлом блокировки. У приложения их нет вовсе — только стандартная библиотека. Как только появится первая, понадобится uv.lock или requirements.lock (урок 6.4).

Обработка сигналов не добавлена. Для инструмента, живущего доли секунды, SIGTERM не наступает: процесс завершается раньше. KeyboardInterrupt обработан, потому что он как раз случается — при Ctrl+C на большом файле. В проекте 2, где сервис живёт долго, обработка сигналов становится обязательной.


Сравнение с вашей реализацией

Стоит сравнить по пяти вопросам — они дают больше, чем чтение кода:

  1. Что происходит с неразобранной строкой? Пропуск, отказ или молчание. Молчание — единственный неверный ответ.
  2. Сколько у вас кодов возврата и различают ли они «пусто» и «ошибка»?
  3. Можно ли по выводу понять, откуда взялось значение параметра?
  4. Проверяют ли ваши тесты stdout и stderr раздельно?
  5. Что произойдёт при | head?

Если по какому-то вопросу ваше решение обоснованнее — так и есть. Пятый вопрос, например, для инструмента, никогда не попадающего в конвейер, можно счесть несущественным.


Навигация

← Список проверок
← Техническое задание
Следующий проект: FastAPI service →
Вернуться к проектам
Главное оглавление

Markdown на GitHub ↗