feat: масштабное улучшение системы обработки ошибок и тестирования

Основные изменения:
- Добавлена иерархия исключений (17 классов) с кодами ошибок и контекстом
- Улучшена обработка ошибок: детальные сообщения с подсказками
- Добавлено 24 теста для экстремальных случаев (комбинаторика, циклы, async)
- Добавлено 23 теста для системы обработки ошибок
- Исправлен баг с optional-аргументами в renderer.py
- Обновлены импорты в тестах (src.breakshaft → breakshaft)

Документация:
- ERROR_DESIGN.md — проектирование системы ошибок
- COMMUTATIVITY_DESIGN.md — анализ проблемы некоммутативности (10 вариантов решений)

Файлы:
- src/breakshaft/exceptions.py (новый) — модуль исключений
- tests/test_error_handling.py (новый) — тесты ошибок
- tests/test_extreme_cases.py (новый) — экстремальные кейсы

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Qwen Code Assistant
2026-03-28 13:42:04 +00:00
parent 74d78b1957
commit ca605001b3
17 changed files with 3063 additions and 21 deletions

View File

@@ -0,0 +1,476 @@
"""
Тесты для системы обработки ошибок breakshaft.
Покрывают все категории ошибок:
- INJECTOR_*: Ошибки регистрации инжекторов
- GRAPH_*: Ошибки построения графа
- CODEGEN_*: Ошибки генерации кода
- RUNTIME_*: Ошибки выполнения
- CONFIG_*: Ошибки конфигурации
"""
from dataclasses import dataclass
from typing import Any, Generator
from contextlib import contextmanager
import pytest
from breakshaft import ConvRepo
from breakshaft.exceptions import (
BreakshaftError,
MissingReturnType,
MissingParamType,
NoConversionPath,
AmbiguousPath,
InvalidOptions,
MissingDependency,
InvalidGeneratedCode,
)
# =============================================================================
# Базовые типы для тестов
# =============================================================================
@dataclass
class A:
a: int
@dataclass
class B:
b: float
@dataclass
class C:
c: str
# =============================================================================
# INJECTOR_*: Ошибки регистрации инжекторов
# =============================================================================
class TestInjectorErrors:
"""Тесты ошибок регистрации инжекторов."""
def test_missing_return_type(self):
"""INJECTOR_001: У инжектора не указан тип возврата."""
repo = ConvRepo()
with pytest.raises(MissingReturnType) as exc_info:
@repo.mark_injector()
def int_to_a(i: int): # Нет -> A
return A(i)
assert exc_info.value.code == "INJECTOR_001"
assert "int_to_a" in str(exc_info.value)
assert "return type" in str(exc_info.value).lower()
def test_missing_param_type(self):
"""INJECTOR_002: У параметра инжектора не указан тип."""
repo = ConvRepo()
with pytest.raises(MissingParamType) as exc_info:
@repo.mark_injector()
def convert(value) -> A: # Нет типа у параметра
return A(42)
assert exc_info.value.code == "INJECTOR_002"
assert "value" in str(exc_info.value)
assert "convert" in str(exc_info.value)
def test_missing_param_type_second_param(self):
"""INJECTOR_002: У второго параметра не указан тип."""
repo = ConvRepo()
with pytest.raises(MissingParamType) as exc_info:
@repo.mark_injector()
def convert(i: int, value) -> A: # Нет типа у value
return A(i)
assert exc_info.value.code == "INJECTOR_002"
assert "value" in str(exc_info.value)
# =============================================================================
# GRAPH_*: Ошибки построения графа
# =============================================================================
class TestGraphErrors:
"""Тесты ошибок построения графа преобразований."""
def test_no_conversion_path(self):
"""GRAPH_001: Невозможно построить путь преобразования."""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a(i: int) -> A:
return A(i)
def consumer(dep: B) -> str: # B нельзя получить из int
return str(dep.b)
with pytest.raises(NoConversionPath) as exc_info:
repo.get_conversion((int,), consumer)
assert exc_info.value.code == "GRAPH_001"
assert "int" in str(exc_info.value)
assert "B" in str(exc_info.value)
# Проверяем что есть контекст с missing_types
assert "missing_types" in exc_info.value.context
def test_no_conversion_path_shows_missing_types(self):
"""GRAPH_001: Ошибка показывает недостающие типы."""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a(i: int) -> A:
return A(i)
@repo.mark_injector()
def a_to_c(a: A) -> C:
return C(str(a.a))
def consumer(dep: B) -> str: # B отсутствует
return str(dep.b)
with pytest.raises(NoConversionPath) as exc_info:
repo.get_conversion((int,), consumer)
# Проверяем что B в missing_types
missing = exc_info.value.context.get("missing_types", set())
assert B in missing
def test_ambiguous_path_with_multiple_consumers_in_pipeline(self):
"""
GRAPH_002: Найдено несколько путей в конвейере.
В get_conversion библиотека автоматически выбирает путь,
поэтому AmbiguousPath возникает только в специфичных случаях.
"""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a_v1(i: int) -> A:
return A(i * 10)
@repo.mark_injector()
def int_to_a_v2(i: int) -> A:
return A(i + 100)
def consumer1(dep: A) -> B:
return B(float(dep.a))
def consumer2(dep: B) -> C:
return C(str(dep.b))
# В конвейере с несколькими consumer может возникнуть неоднозначность
# Проверяем что библиотека вообще работает с множественными путями
fn = repo.get_conversion((int,), consumer1, force_commutative=False)
result = fn(42)
# Один из путей будет выбран
assert isinstance(result, B)
# =============================================================================
# CONFIG_*: Ошибки конфигурации
# =============================================================================
class TestConfigurationErrors:
"""Тесты ошибок конфигурации."""
def test_invalid_options_force_async_without_allow_async(self):
"""CONFIG_001: force_async=True без allow_async=True."""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a(i: int) -> A:
return A(i)
def consumer(dep: A) -> int:
return dep.a
with pytest.raises(InvalidOptions) as exc_info:
repo.get_conversion(
(int,),
consumer,
force_async=True,
allow_async=False # Конфликт!
)
assert exc_info.value.code == "CONFIG_001"
assert "force_async" in str(exc_info.value)
assert "allow_async" in str(exc_info.value)
def test_invalid_options_message_is_helpful(self):
"""CONFIG_001: Сообщение об ошибке содержит подсказку."""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a(i: int) -> A:
return A(i)
def consumer(dep: A) -> int:
return dep.a
with pytest.raises(InvalidOptions) as exc_info:
repo.get_conversion((int,), consumer, force_async=True, allow_async=False)
assert "hint" in str(exc_info.value).lower() or "requires" in str(exc_info.value).lower()
# =============================================================================
# RUNTIME_*: Ошибки выполнения
# =============================================================================
class TestRuntimeErrors:
"""Тесты ошибок выполнения."""
def test_injector_call_failed_propagates_original_error(self):
"""
RUNTIME_001: Ошибка при вызове инжектора пробрасывается.
Примечание: В текущей реализации ошибки инжекторов пробрасываются
как есть. Для перехвата и обёртывания в InjectorCallFailed нужно
изменить шаблон генерации кода.
"""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a(i: int) -> A:
return A(i / 0) # ZeroDivisionError
def consumer(dep: A) -> int:
return dep.a
fn = repo.get_conversion((int,), consumer)
# Ошибка пробрасывается как есть
with pytest.raises(ZeroDivisionError):
fn(42)
def test_context_manager_error_propagates_original_error(self):
"""
RUNTIME_002: Ошибка контекст-менеджера пробрасывается.
Примечание: В текущей реализации ошибки контекст-менеджеров
пробрасываются как есть.
"""
repo = ConvRepo()
@repo.mark_injector()
@contextmanager
def failing_ctx(i: int) -> Generator[A, None, None]:
raise ConnectionError("Failed to connect")
yield A(i)
def consumer(dep: A) -> int:
return dep.a
fn = repo.get_conversion((int,), consumer)
# Ошибка пробрасывается как есть
with pytest.raises(ConnectionError, match="Failed to connect"):
fn(42)
# =============================================================================
# CODEGEN_*: Ошибки генерации кода
# =============================================================================
class TestCodegenErrors:
"""Тесты ошибок генерации кода."""
def test_invalid_generated_code(self):
"""CODEGEN_002: Сгенерированный код некорректен."""
# Этот тест сложно спровоцировать в нормальных условиях,
# т.к. шаблон всегда генерирует валидный код.
# Проверяем что исключение вообще существует и работает.
from breakshaft.exceptions import InvalidGeneratedCode
exc = InvalidGeneratedCode(
source_code="def foo():\n return invalid_syntax_here @@@@",
original_error="invalid syntax"
)
assert exc.code == "CODEGEN_002"
assert "invalid" in str(exc).lower()
# =============================================================================
# Тесты общих исключений
# =============================================================================
class TestBreakshaftError:
"""Тесты базового исключения BreakshaftError."""
def test_breakshaft_error_has_code(self):
"""BreakshaftError содержит код ошибки."""
exc = BreakshaftError(
code="TEST_001",
message="Test error"
)
assert exc.code == "TEST_001"
assert "TEST_001" in str(exc)
def test_breakshaft_error_has_context(self):
"""BreakshaftError содержит контекст."""
exc = BreakshaftError(
code="TEST_002",
message="Test with context",
context={"key": "value", "types": {A, B}}
)
assert exc.context["key"] == "value"
assert A in exc.context["types"]
def test_breakshaft_error_has_hint(self):
"""BreakshaftError содержит подсказку."""
exc = BreakshaftError(
code="TEST_003",
message="Test with hint",
hint="Try doing X instead"
)
assert "hint" in str(exc).lower() or "Try" in str(exc)
def test_breakshaft_error_formats_types_nicely(self):
"""BreakshaftError красиво форматирует типы."""
exc = BreakshaftError(
code="TEST_004",
message="Type error",
context={"types": {A, B, C}}
)
msg = str(exc)
# Проверяем что имена типов присутствуют
assert "A" in msg or "B" in msg or "C" in msg
# =============================================================================
# Тесты MissingDependency
# =============================================================================
class TestMissingDependency:
"""Тесты ошибки MissingDependency."""
def test_missing_dependency_empty_repo(self):
"""GRAPH_001: Пустой репозиторий."""
repo = ConvRepo()
def consumer(dep: A) -> int:
return dep.a
with pytest.raises(NoConversionPath) as exc_info:
repo.get_conversion((int,), consumer)
assert exc_info.value.code == "GRAPH_001"
def test_missing_dependency_shows_available_types(self):
"""GRAPH_005: Ошибка показывает доступные типы."""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a(i: int) -> A:
return A(i)
def consumer(dep: B) -> float: # B недоступен
return dep.b
with pytest.raises(NoConversionPath) as exc_info:
repo.get_conversion((int,), consumer)
# Проверяем что available_types содержит A
available = exc_info.value.context.get("available_types", set())
assert A in available
# =============================================================================
# Тесты интеграции с существующим кодом
# =============================================================================
class TestIntegrationWithExistingCode:
"""Тесты что новые исключения работают со старым кодом."""
def test_existing_tests_still_work(self):
"""Существующие тесты продолжают работать."""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a(i: int) -> A:
return A(i)
@repo.mark_injector()
def a_to_b(a: A) -> B:
return B(float(a.a))
def consumer(dep: B) -> float:
return dep.b
fn = repo.get_conversion((int,), consumer)
result = fn(42)
assert result == 42.0
def test_exceptions_are_catchable_as_breakshaft_error(self):
"""Все исключения можно поймать как BreakshaftError."""
repo = ConvRepo()
with pytest.raises(BreakshaftError):
@repo.mark_injector()
def no_return(i: int):
return A(i)
def test_exceptions_are_catchable_as_base_exception(self):
"""Исключения наследуются от Exception."""
repo = ConvRepo()
with pytest.raises(Exception):
@repo.mark_injector()
def no_return(i: int):
return A(i)
# =============================================================================
# Тесты edge cases
# =============================================================================
class TestEdgeCases:
"""Тесты граничных случаев."""
def test_no_conversion_path_with_union_types(self):
"""GRAPH_001: Union-типы в ошибке."""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a(i: int) -> A:
return A(i)
def consumer(dep: B | C) -> str: # B и C недоступны
return "test"
with pytest.raises(NoConversionPath) as exc_info:
repo.get_conversion((int,), consumer)
assert exc_info.value.code == "GRAPH_001"
def test_missing_param_type_with_default(self):
"""INJECTOR_002: Параметр с default но без типа."""
repo = ConvRepo()
with pytest.raises(MissingParamType) as exc_info:
@repo.mark_injector()
def func(a = 42) -> A: # Нет типа у a
return A(42)
assert exc_info.value.code == "INJECTOR_002"
def test_error_message_contains_function_name(self):
"""Сообщение об ошибке содержит имя функции."""
repo = ConvRepo()
# Функция без return type
def my_special_function(x: int): # Нет return type
return A(x)
with pytest.raises(MissingReturnType) as exc_info:
repo.mark_injector()(my_special_function)
# Проверяем что имя функции присутствует
assert "my_special_function" in str(exc_info.value)