Основные изменения: - Добавлена иерархия исключений (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>
476 lines
16 KiB
Python
476 lines
16 KiB
Python
"""
|
||
Тесты для системы обработки ошибок 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) |