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:
@@ -1 +1,89 @@
|
||||
"""
|
||||
breakshaft - библиотека для генерации преобразований типов на лету.
|
||||
|
||||
Основное использование:
|
||||
from breakshaft import ConvRepo
|
||||
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
fn = repo.get_conversion((int,), consumer_function)
|
||||
|
||||
Исключения:
|
||||
from breakshaft import (
|
||||
BreakshaftError,
|
||||
NoConversionPath,
|
||||
AmbiguousPath,
|
||||
MissingReturnType,
|
||||
# ... другие исключения
|
||||
)
|
||||
"""
|
||||
|
||||
from .convertor import ConvRepo
|
||||
from .graph_walker import GraphWalker
|
||||
from .models import ConversionPoint, Callgraph, CallgraphVariant, TransformationPoint
|
||||
from .exceptions import (
|
||||
BreakshaftError,
|
||||
BreakshaftRuntimeError,
|
||||
InjectorError,
|
||||
MissingReturnType,
|
||||
MissingParamType,
|
||||
CircularDependency,
|
||||
DuplicateInjector,
|
||||
InvalidInjectorSignature,
|
||||
GraphError,
|
||||
NoConversionPath,
|
||||
AmbiguousPath,
|
||||
CycleDetected,
|
||||
TypeMismatch,
|
||||
MissingDependency,
|
||||
CodegenError,
|
||||
TemplateRenderError,
|
||||
InvalidGeneratedCode,
|
||||
NameCollision,
|
||||
InjectorCallFailed,
|
||||
ContextManagerError,
|
||||
AsyncExecutionError,
|
||||
ConfigurationError,
|
||||
InvalidOptions,
|
||||
IncompatibleSettings,
|
||||
)
|
||||
|
||||
__version__ = "0.1.6.post5"
|
||||
__all__ = [
|
||||
# Основные классы
|
||||
"ConvRepo",
|
||||
"GraphWalker",
|
||||
"ConversionPoint",
|
||||
"Callgraph",
|
||||
"CallgraphVariant",
|
||||
"TransformationPoint",
|
||||
# Исключения
|
||||
"BreakshaftError",
|
||||
"BreakshaftRuntimeError",
|
||||
"InjectorError",
|
||||
"MissingReturnType",
|
||||
"MissingParamType",
|
||||
"CircularDependency",
|
||||
"DuplicateInjector",
|
||||
"InvalidInjectorSignature",
|
||||
"GraphError",
|
||||
"NoConversionPath",
|
||||
"AmbiguousPath",
|
||||
"CycleDetected",
|
||||
"TypeMismatch",
|
||||
"MissingDependency",
|
||||
"CodegenError",
|
||||
"TemplateRenderError",
|
||||
"InvalidGeneratedCode",
|
||||
"NameCollision",
|
||||
"InjectorCallFailed",
|
||||
"ContextManagerError",
|
||||
"AsyncExecutionError",
|
||||
"ConfigurationError",
|
||||
"InvalidOptions",
|
||||
"IncompatibleSettings",
|
||||
]
|
||||
|
||||
@@ -6,7 +6,13 @@ from typing import Optional, Callable, Unpack, TypeVarTuple, TypeVar, Awaitable,
|
||||
from .graph_walker import GraphWalker
|
||||
from .models import ConversionPoint, Callgraph
|
||||
from .renderer import ConvertorRenderer, InTimeGenerationConvertorRenderer
|
||||
from .util import extract_return_type, universal_qualname
|
||||
from .util import extract_return_type, extract_func_argtypes, universal_qualname
|
||||
from .exceptions import (
|
||||
NoConversionPath,
|
||||
AmbiguousPath,
|
||||
InvalidOptions,
|
||||
MissingDependency,
|
||||
)
|
||||
|
||||
Tin = TypeVarTuple('Tin')
|
||||
Tout = TypeVar('Tout')
|
||||
@@ -118,16 +124,47 @@ class ConvRepo:
|
||||
|
||||
cg = self.walker.generate_callgraph(injectors, from_types, fn)
|
||||
if cg is None:
|
||||
raise ValueError(f'Unable to compute conversion graph on {from_types}->{universal_qualname(fn)}')
|
||||
# Собираем информацию о доступных типах
|
||||
available_types = set()
|
||||
for inj in injectors:
|
||||
available_types.add(inj.injects)
|
||||
available_types.update(inj.requires)
|
||||
|
||||
# Определяем требуемые типы
|
||||
required_types = set()
|
||||
if callable(fn):
|
||||
required_types = extract_func_argtypes(fn)
|
||||
|
||||
raise NoConversionPath(
|
||||
from_types=tuple(from_types),
|
||||
target=fn,
|
||||
available_types=available_types,
|
||||
required_types=required_types,
|
||||
)
|
||||
|
||||
exploded = self.walker.explode_callgraph_branches(cg, from_types)
|
||||
|
||||
selected = self.walker.filter_exploded_callgraph_branch(exploded)
|
||||
if len(selected) == 0:
|
||||
raise ValueError('Unable to select conversion path')
|
||||
raise NoConversionPath(
|
||||
from_types=tuple(from_types),
|
||||
target=fn,
|
||||
available_types=set(inj.injects for inj in injectors),
|
||||
required_types=set(),
|
||||
)
|
||||
|
||||
if force_commutative and len(selected) > 1:
|
||||
raise ValueError('Conversion path is not commutative')
|
||||
# Собираем информацию о путях
|
||||
paths = []
|
||||
for variant in selected:
|
||||
path = self._get_path_from_variant(variant)
|
||||
paths.append(path)
|
||||
|
||||
raise AmbiguousPath(
|
||||
from_types=tuple(from_types),
|
||||
target=fn,
|
||||
paths=paths,
|
||||
)
|
||||
|
||||
callseq = self._callseq_from_callgraph(Callgraph(frozenset([selected[0]])))
|
||||
|
||||
@@ -145,6 +182,16 @@ class ConvRepo:
|
||||
|
||||
return callseq
|
||||
|
||||
def _get_path_from_variant(self, variant) -> list[str]:
|
||||
"""Извлекает путь преобразований из варианта графа."""
|
||||
path = []
|
||||
if hasattr(variant, 'injector'):
|
||||
path.append(variant.injector.fn.__qualname__)
|
||||
for subg in variant.subgraphs:
|
||||
sub_path = self._get_path_from_variant(subg)
|
||||
path.extend(sub_path)
|
||||
return path
|
||||
|
||||
def get_conversion(self,
|
||||
from_types: Sequence[type[Unpack[Tin]]],
|
||||
fn: Callable[..., Tout] | Iterable[ConversionPoint] | ConversionPoint,
|
||||
@@ -154,8 +201,22 @@ class ConvRepo:
|
||||
force_async: bool = False
|
||||
) -> Callable[[Unpack[Tin]], Tout] | Awaitable[Callable[[Unpack[Tin]], Tout]]:
|
||||
|
||||
# Валидация опций
|
||||
if force_async and not allow_async:
|
||||
raise InvalidOptions(
|
||||
option_name="force_async",
|
||||
option_value=True,
|
||||
reason="force_async=True requires allow_async=True"
|
||||
)
|
||||
|
||||
filtered_injectors = self.filtered_injectors(allow_async, allow_sync)
|
||||
callseq = self.get_callseq(filtered_injectors, frozenset(from_types), fn, force_commutative)
|
||||
|
||||
callseq = self.get_callseq(
|
||||
filtered_injectors,
|
||||
frozenset(from_types),
|
||||
fn,
|
||||
force_commutative
|
||||
)
|
||||
|
||||
ret_fn = self.renderer.render(from_types, callseq, force_async=force_async, store_sources=self.store_sources)
|
||||
if self.store_callseq:
|
||||
|
||||
557
src/breakshaft/exceptions.py
Normal file
557
src/breakshaft/exceptions.py
Normal file
@@ -0,0 +1,557 @@
|
||||
"""
|
||||
Система обработки ошибок для breakshaft.
|
||||
|
||||
Модуль предоставляет иерархию исключений для детальной обработки ошибок
|
||||
при использовании библиотеки как системы внедрения зависимостей.
|
||||
|
||||
Пример использования:
|
||||
from breakshaft.exceptions import GraphError, NoConversionPath
|
||||
|
||||
try:
|
||||
fn = repo.get_conversion((int,), consumer)
|
||||
except NoConversionPath as e:
|
||||
print(f"Ошибка: {e}")
|
||||
print(f"Доступные типы: {e.available_types}")
|
||||
print(f"Требуемые типы: {e.required_types}")
|
||||
"""
|
||||
|
||||
from typing import Optional, Set, Any, Callable
|
||||
|
||||
|
||||
class BreakshaftError(Exception):
|
||||
"""
|
||||
Базовое исключение для всех ошибок breakshaft.
|
||||
|
||||
Attributes:
|
||||
code: Код ошибки (например, 'GRAPH_001')
|
||||
message: Человекочитаемое описание ошибки
|
||||
context: Дополнительный контекст (типы, функции и т.д.)
|
||||
hint: Подсказка как исправить ошибку
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
context: Optional[dict[str, Any]] = None,
|
||||
hint: Optional[str] = None,
|
||||
):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.context = context or {}
|
||||
self.hint = hint
|
||||
|
||||
super().__init__(self._format_message())
|
||||
|
||||
def _format_message(self) -> str:
|
||||
"""Форматирует полное сообщение об ошибке."""
|
||||
lines = [f"BreakshaftError [{self.code}]: {self.message}"]
|
||||
|
||||
if self.context:
|
||||
lines.append("\nContext:")
|
||||
for key, value in self.context.items():
|
||||
formatted_value = self._format_context_value(key, value)
|
||||
lines.append(f" {key}: {formatted_value}")
|
||||
|
||||
if self.hint:
|
||||
lines.append(f"\nHint: {self.hint}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_context_value(self, key: str, value: Any) -> str:
|
||||
"""Форматирует значение контекста для вывода."""
|
||||
if isinstance(value, (set, frozenset)):
|
||||
if len(value) == 0:
|
||||
return "{}"
|
||||
return "{" + ", ".join(self._type_name(v) for v in sorted(value, key=str)) + "}"
|
||||
elif isinstance(value, (tuple, list)):
|
||||
if len(value) == 0:
|
||||
return "()"
|
||||
return "(" + ", ".join(self._type_name(v) for v in value) + ")"
|
||||
elif callable(value):
|
||||
return getattr(value, '__qualname__', str(value))
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
def _type_name(self, t: Any) -> str:
|
||||
"""Возвращает читаемое имя типа."""
|
||||
if hasattr(t, '__name__'):
|
||||
return t.__name__
|
||||
return str(t)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ошибки регистрации инжекторов (INJECTOR_*)
|
||||
# =============================================================================
|
||||
|
||||
class InjectorError(BreakshaftError):
|
||||
"""Базовое исключение для ошибок регистрации инжекторов."""
|
||||
pass
|
||||
|
||||
|
||||
class MissingReturnType(InjectorError):
|
||||
"""
|
||||
У функции-инжектора не указан тип возврата.
|
||||
|
||||
Пример:
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int): # Нет -> A
|
||||
return A(i)
|
||||
|
||||
Решение: Добавить аннотацию возврата.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable):
|
||||
super().__init__(
|
||||
code="INJECTOR_001",
|
||||
message=f"Function '{func.__qualname__}' is missing return type annotation",
|
||||
context={"function": func},
|
||||
hint="Add a return type annotation: def func(...) -> ReturnType:",
|
||||
)
|
||||
|
||||
|
||||
class MissingParamType(InjectorError):
|
||||
"""
|
||||
У параметра функции-инжектора не указан тип.
|
||||
|
||||
Пример:
|
||||
@repo.mark_injector()
|
||||
def convert(value) -> A: # Нет типа у параметра
|
||||
return A(value)
|
||||
|
||||
Решение: Добавить аннотацию типа параметра.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable, param_name: str):
|
||||
super().__init__(
|
||||
code="INJECTOR_002",
|
||||
message=f"Parameter '{param_name}' of function '{func.__qualname__}' is missing type annotation",
|
||||
context={"function": func, "parameter": param_name},
|
||||
hint=f"Add type annotation: def {func.__name__}({param_name}: Type) -> ...:",
|
||||
)
|
||||
|
||||
|
||||
class CircularDependency(InjectorError):
|
||||
"""
|
||||
Обнаружена циклическая зависимость между инжекторами.
|
||||
|
||||
Пример:
|
||||
A -> B -> C -> A (цикл)
|
||||
|
||||
Решение: Разорвать цикл или использовать force_commutative=False.
|
||||
"""
|
||||
|
||||
def __init__(self, cycle: list[type]):
|
||||
cycle_str = " -> ".join(self._type_name(t) for t in cycle)
|
||||
super().__init__(
|
||||
code="INJECTOR_003",
|
||||
message=f"Circular dependency detected: {cycle_str}",
|
||||
context={"cycle": cycle},
|
||||
hint="Break the cycle by removing one of the injectors or use force_commutative=False",
|
||||
)
|
||||
|
||||
|
||||
class DuplicateInjector(InjectorError):
|
||||
"""
|
||||
Зарегистрировано несколько инжекторов с одинаковой сигнатурой.
|
||||
|
||||
Решение: Удалить дублирующийся инжектор или использовать fork().
|
||||
"""
|
||||
|
||||
def __init__(self, func1: Callable, func2: Callable, injects_type: type):
|
||||
super().__init__(
|
||||
code="INJECTOR_004",
|
||||
message=f"Duplicate injector for type '{injects_type.__name__}'",
|
||||
context={
|
||||
"injects_type": injects_type,
|
||||
"existing_function": func1,
|
||||
"new_function": func2,
|
||||
},
|
||||
hint="Remove the duplicate injector or use repo.fork() for separate contexts",
|
||||
)
|
||||
|
||||
|
||||
class InvalidInjectorSignature(InjectorError):
|
||||
"""
|
||||
Некорректная сигнатура функции-инжектора.
|
||||
|
||||
Пример:
|
||||
- Инжектор без параметров
|
||||
- Инжектор с *args/**kwargs
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable, reason: str):
|
||||
super().__init__(
|
||||
code="INJECTOR_005",
|
||||
message=f"Invalid injector signature for '{func.__qualname__}': {reason}",
|
||||
context={"function": func, "reason": reason},
|
||||
hint="Ensure the injector has proper type-annotated parameters",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ошибки построения графа (GRAPH_*)
|
||||
# =============================================================================
|
||||
|
||||
class GraphError(BreakshaftError):
|
||||
"""Базовое исключение для ошибок построения графа преобразований."""
|
||||
pass
|
||||
|
||||
|
||||
class NoConversionPath(GraphError):
|
||||
"""
|
||||
Невозможно построить путь преобразования между типами.
|
||||
|
||||
Пример:
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A: ...
|
||||
|
||||
def consumer(dep: B) -> str: ... # B нельзя получить из int
|
||||
|
||||
repo.get_conversion((int,), consumer) # Ошибка!
|
||||
|
||||
Решение: Добавить инжектор для получения B.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
from_types: tuple[type, ...],
|
||||
target: Callable,
|
||||
available_types: Set[type],
|
||||
required_types: Set[type],
|
||||
):
|
||||
missing = required_types - available_types
|
||||
super().__init__(
|
||||
code="GRAPH_001",
|
||||
message="No conversion path found",
|
||||
context={
|
||||
"source_types": from_types,
|
||||
"target_function": target,
|
||||
"available_types": available_types,
|
||||
"required_types": required_types,
|
||||
"missing_types": missing,
|
||||
},
|
||||
hint="Add an injector that produces the missing type(s)",
|
||||
)
|
||||
|
||||
|
||||
class AmbiguousPath(GraphError):
|
||||
"""
|
||||
Найдено несколько путей преобразования (некоммутативный граф).
|
||||
|
||||
Пример:
|
||||
int -> A (прямой, результат: A(42))
|
||||
int -> B -> A (через B, результат: A(42.0))
|
||||
|
||||
Решение: Использовать force_commutative=False.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
from_types: tuple[type, ...],
|
||||
target: Callable,
|
||||
paths: list[list[str]],
|
||||
):
|
||||
paths_str = "\n".join(f" Путь {i+1}: {' -> '.join(p)}" for i, p in enumerate(paths))
|
||||
super().__init__(
|
||||
code="GRAPH_002",
|
||||
message="Ambiguous conversion path (non-commutative graph)",
|
||||
context={
|
||||
"source_types": from_types,
|
||||
"target_function": target,
|
||||
"paths": paths,
|
||||
},
|
||||
hint=f"Multiple paths found:\n{paths_str}\nUse force_commutative=False to allow any path",
|
||||
)
|
||||
|
||||
|
||||
class CycleDetected(GraphError):
|
||||
"""
|
||||
Обнаружен цикл в графе преобразований при построении пути.
|
||||
|
||||
Отличается от INJECTOR_003 тем, что цикл обнаруживается при runtime,
|
||||
а не при регистрации.
|
||||
"""
|
||||
|
||||
def __init__(self, cycle: list[type], target: Callable):
|
||||
cycle_str = " -> ".join(t.__name__ for t in cycle)
|
||||
super().__init__(
|
||||
code="GRAPH_003",
|
||||
message=f"Cycle detected in conversion graph: {cycle_str}",
|
||||
context={"cycle": cycle, "target_function": target},
|
||||
hint="The algorithm handles cycles automatically, but consider simplifying the graph",
|
||||
)
|
||||
|
||||
|
||||
class TypeMismatch(GraphError):
|
||||
"""
|
||||
Тип аргумента не соответствует ожидаемому.
|
||||
|
||||
Пример:
|
||||
def convert(s: str) -> A: ...
|
||||
repo.get_conversion((int,), ...) # int != str
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
expected_type: type,
|
||||
actual_type: type,
|
||||
context_desc: str = "",
|
||||
):
|
||||
super().__init__(
|
||||
code="GRAPH_004",
|
||||
message=f"Type mismatch: expected {expected_type.__name__}, got {actual_type.__name__}",
|
||||
context={
|
||||
"expected_type": expected_type,
|
||||
"actual_type": actual_type,
|
||||
"description": context_desc,
|
||||
},
|
||||
hint="Check type annotations and ensure compatible types are used",
|
||||
)
|
||||
|
||||
|
||||
class MissingDependency(GraphError):
|
||||
"""
|
||||
Зависимость не может быть удовлетворена.
|
||||
|
||||
Пример:
|
||||
def consumer(a: A, b: B) -> int: ...
|
||||
# Есть инжектор для A, но нет для B
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dependency_type: type,
|
||||
consumer: Callable,
|
||||
available_types: Set[type],
|
||||
):
|
||||
super().__init__(
|
||||
code="GRAPH_005",
|
||||
message=f"Missing dependency: {dependency_type.__name__}",
|
||||
context={
|
||||
"dependency_type": dependency_type,
|
||||
"consumer_function": consumer,
|
||||
"available_types": available_types,
|
||||
},
|
||||
hint=f"Add an injector that produces type '{dependency_type.__name__}'",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ошибки генерации кода (CODEGEN_*)
|
||||
# =============================================================================
|
||||
|
||||
class CodegenError(BreakshaftError):
|
||||
"""Базовое исключение для ошибок генерации кода."""
|
||||
pass
|
||||
|
||||
|
||||
class TemplateRenderError(CodegenError):
|
||||
"""
|
||||
Ошибка при рендеринге Jinja2-шаблона.
|
||||
|
||||
Возникает при внутренних ошибках шаблона.
|
||||
"""
|
||||
|
||||
def __init__(self, template_name: str, original_error: str):
|
||||
super().__init__(
|
||||
code="CODEGEN_001",
|
||||
message=f"Template rendering failed: {original_error}",
|
||||
context={"template": template_name, "original_error": original_error},
|
||||
hint="This is likely an internal error. Please report it.",
|
||||
)
|
||||
|
||||
|
||||
class InvalidGeneratedCode(CodegenError):
|
||||
"""
|
||||
Сгенерированный код некорректен.
|
||||
|
||||
Возникает если exec() сгенерированного кода вызывает ошибку.
|
||||
"""
|
||||
|
||||
def __init__(self, source_code: str, original_error: str):
|
||||
super().__init__(
|
||||
code="CODEGEN_002",
|
||||
message=f"Generated code is invalid: {original_error}",
|
||||
context={"source_code_preview": source_code[:200] + "...", "original_error": original_error},
|
||||
hint="This is likely an internal error. Please report it with the source code.",
|
||||
)
|
||||
|
||||
|
||||
class NameCollision(CodegenError):
|
||||
"""
|
||||
Конфликт имён в сгенерированном коде.
|
||||
|
||||
Возникает когда два разных типа имеют одинаковый хэш.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, type1: type, type2: type):
|
||||
super().__init__(
|
||||
code="CODEGEN_003",
|
||||
message=f"Name collision for '{name}': {type1.__name__} and {type2.__name__}",
|
||||
context={"name": name, "type1": type1, "type2": type2},
|
||||
hint="This is a rare hash collision. Consider renaming types or reporting this issue.",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ошибки выполнения (RUNTIME_*)
|
||||
# =============================================================================
|
||||
|
||||
class BreakshaftRuntimeError(BreakshaftError):
|
||||
"""Базовое исключение для ошибок выполнения."""
|
||||
pass
|
||||
|
||||
|
||||
class InjectorCallFailed(BreakshaftRuntimeError):
|
||||
"""
|
||||
Ошибка при вызове функции-инжектора.
|
||||
|
||||
Пример:
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i / 0) # ZeroDivisionError
|
||||
|
||||
Решение: Исправить ошибку в коде инжектора.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable, original_error: Exception, args: tuple, kwargs: dict):
|
||||
super().__init__(
|
||||
code="RUNTIME_001",
|
||||
message=f"Injector '{func.__qualname__}' raised an exception: {type(original_error).__name__}: {original_error}",
|
||||
context={
|
||||
"function": func,
|
||||
"original_error_type": type(original_error).__name__,
|
||||
"original_error_msg": str(original_error),
|
||||
"call_args": args,
|
||||
"call_kwargs": kwargs,
|
||||
},
|
||||
hint="Fix the error in the injector function code",
|
||||
)
|
||||
|
||||
|
||||
class ContextManagerError(BreakshaftRuntimeError):
|
||||
"""
|
||||
Ошибка при входе/выходе из контекст-менеджера.
|
||||
|
||||
Пример:
|
||||
@contextmanager
|
||||
def get_resource() -> Generator[Resource, None, None]:
|
||||
raise ConnectionError("Failed to connect")
|
||||
yield Resource()
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable, original_error: Exception, phase: str):
|
||||
super().__init__(
|
||||
code="RUNTIME_002",
|
||||
message=f"Context manager '{func.__qualname__}' failed during {phase}: {original_error}",
|
||||
context={
|
||||
"function": func,
|
||||
"phase": phase,
|
||||
"original_error_type": type(original_error).__name__,
|
||||
"original_error_msg": str(original_error),
|
||||
},
|
||||
hint=f"Ensure the context manager handles {phase} correctly",
|
||||
)
|
||||
|
||||
|
||||
class AsyncExecutionError(BreakshaftRuntimeError):
|
||||
"""
|
||||
Ошибка при выполнении асинхронной операции.
|
||||
|
||||
Возникает при ошибках в async/await логике.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable, original_error: Exception):
|
||||
super().__init__(
|
||||
code="RUNTIME_003",
|
||||
message=f"Async execution failed in '{func.__qualname__}': {original_error}",
|
||||
context={
|
||||
"function": func,
|
||||
"original_error_type": type(original_error).__name__,
|
||||
"original_error_msg": str(original_error),
|
||||
},
|
||||
hint="Check async/await usage in the injector",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ошибки конфигурации (CONFIG_*)
|
||||
# =============================================================================
|
||||
|
||||
class ConfigurationError(BreakshaftError):
|
||||
"""Базовое исключение для ошибок конфигурации."""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidOptions(ConfigurationError):
|
||||
"""
|
||||
Некорректные опции.
|
||||
|
||||
Пример:
|
||||
repo.get_conversion(..., allow_async=False, force_async=True)
|
||||
# force_async=True требует allow_async=True
|
||||
"""
|
||||
|
||||
def __init__(self, option_name: str, option_value: Any, reason: str):
|
||||
super().__init__(
|
||||
code="CONFIG_001",
|
||||
message=f"Invalid option '{option_name}={option_value}': {reason}",
|
||||
context={"option": option_name, "value": option_value, "reason": reason},
|
||||
hint="Check the documentation for valid option combinations",
|
||||
)
|
||||
|
||||
|
||||
class IncompatibleSettings(ConfigurationError):
|
||||
"""
|
||||
Несовместимые настройки.
|
||||
|
||||
Пример:
|
||||
force_commutative=True с графом, имеющим несколько путей
|
||||
"""
|
||||
|
||||
def __init__(self, setting1: str, setting2: str, reason: str):
|
||||
super().__init__(
|
||||
code="CONFIG_002",
|
||||
message=f"Incompatible settings: {setting1} and {setting2}",
|
||||
context={"setting1": setting1, "setting2": setting2, "reason": reason},
|
||||
hint="Adjust settings to be compatible",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Экспорт всех исключений
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
# Базовые
|
||||
"BreakshaftError",
|
||||
"BreakshaftRuntimeError",
|
||||
# Инжекторы
|
||||
"InjectorError",
|
||||
"MissingReturnType",
|
||||
"MissingParamType",
|
||||
"CircularDependency",
|
||||
"DuplicateInjector",
|
||||
"InvalidInjectorSignature",
|
||||
# Граф
|
||||
"GraphError",
|
||||
"NoConversionPath",
|
||||
"AmbiguousPath",
|
||||
"CycleDetected",
|
||||
"TypeMismatch",
|
||||
"MissingDependency",
|
||||
# Codegen
|
||||
"CodegenError",
|
||||
"TemplateRenderError",
|
||||
"InvalidGeneratedCode",
|
||||
"NameCollision",
|
||||
# Runtime
|
||||
"InjectorCallFailed",
|
||||
"ContextManagerError",
|
||||
"AsyncExecutionError",
|
||||
# Configuration
|
||||
"ConfigurationError",
|
||||
"InvalidOptions",
|
||||
"IncompatibleSettings",
|
||||
]
|
||||
@@ -5,6 +5,7 @@ from typing import Callable, Optional
|
||||
|
||||
from .models import ConversionPoint, Callgraph, CallgraphVariant, TransformationPoint, CompositionDirection
|
||||
from .util import extract_func_argtypes, all_combinations, extract_func_argtypes_seq, extract_return_type, universal_qualname
|
||||
from .exceptions import AmbiguousPath
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
@@ -185,7 +186,11 @@ class GraphWalker:
|
||||
ignore_noncommutative=False) -> Optional[CallgraphVariant]:
|
||||
filtered = cls.filter_exploded_callgraph_branch(variants)
|
||||
if len(filtered) > 1 and not ignore_noncommutative:
|
||||
raise ValueError('Graph is not commutative')
|
||||
raise AmbiguousPath(
|
||||
from_types=frozenset(),
|
||||
target=None,
|
||||
paths=[[str(v.injector)] for v in filtered],
|
||||
)
|
||||
if len(filtered) == 0:
|
||||
return None
|
||||
return filtered[0]
|
||||
|
||||
@@ -12,6 +12,7 @@ from .util import extract_func_argtypes, extract_func_argtypes_seq, is_sync_cont
|
||||
is_async_context_manager_factory, \
|
||||
all_combinations, is_context_manager_factory, extract_func_arg_defaults, extract_func_args, extract_func_argnames, \
|
||||
get_tuple_types, is_basic_type_annot, universal_qualname
|
||||
from .exceptions import MissingReturnType, MissingParamType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -68,7 +69,7 @@ class ConversionPoint:
|
||||
rettype = fn_rettype
|
||||
|
||||
if rettype is None:
|
||||
raise ValueError(f'Function {func.__qualname__} provided as injector, but return-type is not specified')
|
||||
raise MissingReturnType(func)
|
||||
|
||||
rettype_origin = get_origin(rettype)
|
||||
fn_rettype_origin = get_origin(fn_rettype)
|
||||
|
||||
@@ -8,6 +8,7 @@ import jinja2
|
||||
|
||||
from .models import ConversionPoint
|
||||
from .util import hashname, get_tuple_types, is_basic_type_annot, universal_qualname
|
||||
from .exceptions import CodegenError, InvalidGeneratedCode
|
||||
|
||||
|
||||
class ConvertorRenderer(Protocol):
|
||||
@@ -54,7 +55,7 @@ class ConversionRenderData:
|
||||
_injection: ConversionPoint
|
||||
|
||||
@classmethod
|
||||
def from_inj(cls, inj: ConversionPoint, provided_types: set[type]):
|
||||
def from_inj(cls, inj: ConversionPoint, provided_types: set[type], from_types: set[type] = None, is_consumer: bool = False):
|
||||
argmap = inj.fn_args
|
||||
|
||||
fnargs = []
|
||||
@@ -62,10 +63,20 @@ class ConversionRenderData:
|
||||
argname = argmap[arg_id][0]
|
||||
fnargs.append((argname, hashname(argtype)))
|
||||
|
||||
# Если from_types не указан, используем provided_types (для обратной совместимости)
|
||||
if from_types is None:
|
||||
from_types = provided_types
|
||||
|
||||
for arg_id, argtype in enumerate(inj.opt_args, len(inj.requires)):
|
||||
argname = argmap[arg_id][0]
|
||||
# Добавляем optional-аргумент если:
|
||||
# 1. Тип есть в provided_types (был инжектирован предыдущим преобразованием)
|
||||
# 2. ИЛИ это consumer И тип есть в from_types (передаётся извне)
|
||||
if argtype in provided_types:
|
||||
fnargs.append((argname, hashname(argtype)))
|
||||
elif is_consumer and argtype in from_types:
|
||||
# Для consumer функции: optional-аргумент может передаваться извне
|
||||
fnargs.append((argname, hashname(argtype)))
|
||||
|
||||
unwrap_tuple_result = unwrap_tuple_type(inj.rettype)
|
||||
|
||||
@@ -116,15 +127,22 @@ def render_data_from_callseq(from_types: Sequence[type],
|
||||
callseq: Sequence[ConversionPoint]):
|
||||
conversion_models: list[ConversionRenderData] = []
|
||||
ret_hash = 0
|
||||
from_types_set = set(from_types)
|
||||
for call_id, call in enumerate(callseq):
|
||||
|
||||
provided_types = set(from_types)
|
||||
# provided_types: типы доступные из предыдущих преобразований (не включая from_types)
|
||||
provided_types: set[type] = set()
|
||||
for _call in callseq[:call_id]:
|
||||
provided_types |= {_call.injects}
|
||||
provided_types |= set(_call.requires)
|
||||
|
||||
fnmap[hash(call.fn)] = call.fn
|
||||
conv = ConversionRenderData.from_inj(call, provided_types)
|
||||
# is_consumer=True для последнего элемента в callseq
|
||||
# ИЛИ если у функции есть optional-аргументы с типами из from_types
|
||||
# (значит эти аргументы должны передаваться извне)
|
||||
has_opt_from_from_types = any(opt in from_types_set for opt in call.opt_args)
|
||||
is_consumer = (call_id == len(callseq) - 1) or has_opt_from_from_types
|
||||
conv = ConversionRenderData.from_inj(call, provided_types, from_types_set, is_consumer)
|
||||
conversion_models.append(conv)
|
||||
return conversion_models
|
||||
|
||||
@@ -177,7 +195,15 @@ class InTimeGenerationConvertorRenderer(ConvertorRenderer):
|
||||
)
|
||||
convertor_functext = '\n'.join(list(filter(lambda x: len(x.strip()), convertor_functext.split('\n'))))
|
||||
convertor_functext = convertor_functext.replace(', )', ')').replace(',)', ')')
|
||||
exec(convertor_functext, namespace)
|
||||
|
||||
try:
|
||||
exec(convertor_functext, namespace)
|
||||
except Exception as e:
|
||||
raise InvalidGeneratedCode(
|
||||
source_code=convertor_functext,
|
||||
original_error=str(e)
|
||||
)
|
||||
|
||||
unwrap_func = namespace['convertor']
|
||||
if store_sources:
|
||||
setattr(unwrap_func, '__breakshaft_render_src__', convertor_functext)
|
||||
|
||||
@@ -3,6 +3,8 @@ import typing
|
||||
from itertools import product
|
||||
from typing import Callable, get_type_hints, TypeVar, Any, Optional
|
||||
|
||||
from .exceptions import MissingParamType
|
||||
|
||||
|
||||
def extract_func_argnames(func: Callable) -> list[str]:
|
||||
sig = inspect.signature(func)
|
||||
@@ -31,7 +33,7 @@ def extract_func_args(func: Callable, type_hints_remap: Optional[dict[str, type]
|
||||
args_info = []
|
||||
for name, param in params.items():
|
||||
if name not in type_hints:
|
||||
raise TypeError(f"Param {name} must be type-annotated")
|
||||
raise MissingParamType(func, name)
|
||||
args_info.append((name, type_hints[name]))
|
||||
return args_info
|
||||
|
||||
@@ -44,7 +46,7 @@ def extract_func_argtypes(func: Callable) -> frozenset[type]:
|
||||
ret: frozenset[type] = frozenset()
|
||||
for name, param in params.items():
|
||||
if name not in type_hints:
|
||||
raise TypeError(f"Param {name} must be type-annotated")
|
||||
raise MissingParamType(func, name)
|
||||
ret |= {type_hints[name]}
|
||||
return ret
|
||||
|
||||
@@ -57,7 +59,7 @@ def extract_func_argtypes_seq(func: Callable) -> list[type]:
|
||||
ret: list[type] = []
|
||||
for name, param in params.items():
|
||||
if name not in type_hints:
|
||||
raise TypeError(f"Param {name} must be type-annotated")
|
||||
raise MissingParamType(func, name)
|
||||
ret.append(type_hints[name])
|
||||
return ret
|
||||
|
||||
|
||||
Reference in New Issue
Block a user