diff --git a/src/breakshaft/__init__.py b/src/breakshaft/__init__.py index 264627b..11059d1 100644 --- a/src/breakshaft/__init__.py +++ b/src/breakshaft/__init__.py @@ -12,12 +12,24 @@ breakshaft - библиотека для генерации преобразов fn = repo.get_conversion((int,), consumer_function) +Приоритизация инжекторов: + from breakshaft import ConvRepo, more_than, less_than + + repo = ConvRepo() + + @repo.mark_injector(priority=10.0) # Абсолютный приоритет + def int_to_a_v1(i: int) -> A: ... + + @repo.mark_injector(priority=more_than(int_to_a_v1)) # Относительный приоритет + def int_to_a_v2(i: int) -> A: ... + Исключения: from breakshaft import ( BreakshaftError, NoConversionPath, AmbiguousPath, MissingReturnType, + CircularDependency, # Для циклов в относительных приоритетах # ... другие исключения ) """ @@ -25,6 +37,7 @@ breakshaft - библиотека для генерации преобразов from .convertor import ConvRepo from .graph_walker import GraphWalker from .models import ConversionPoint, Callgraph, CallgraphVariant, TransformationPoint +from .priority_types import more_than, less_than, PriorityValue, MoreThan, LessThan from .exceptions import ( BreakshaftError, BreakshaftRuntimeError, @@ -61,6 +74,12 @@ __all__ = [ "Callgraph", "CallgraphVariant", "TransformationPoint", + # Приоритизация + "more_than", + "less_than", + "PriorityValue", + "MoreThan", + "LessThan", # Исключения "BreakshaftError", "BreakshaftRuntimeError", diff --git a/src/breakshaft/convertor.py b/src/breakshaft/convertor.py index 024550d..3e822e6 100644 --- a/src/breakshaft/convertor.py +++ b/src/breakshaft/convertor.py @@ -1,7 +1,7 @@ from __future__ import annotations import collections.abc -from typing import Optional, Callable, Unpack, TypeVarTuple, TypeVar, Awaitable, Any, Sequence, Iterable +from typing import Optional, Callable, Unpack, TypeVarTuple, TypeVar, Awaitable, Any, Sequence, Iterable, Union from .graph_walker import GraphWalker from .models import ConversionPoint, Callgraph @@ -13,6 +13,8 @@ from .exceptions import ( InvalidOptions, MissingDependency, ) +from .priority_types import PriorityValue, RelativePriority, MoreThan, LessThan +from .priority_resolver import resolve_priorities, CycleDetectedError Tin = TypeVarTuple('Tin') Tout = TypeVar('Tout') @@ -50,6 +52,9 @@ class ConvRepo: allow_sync: bool = True, force_async: bool = False ): + # Разрешаем относительные приоритеты + self._resolve_relative_priorities() + filtered_injectors = self.filtered_injectors(allow_async, allow_sync) pipeline_callseq = [] orig_from_types = tuple(from_types) @@ -91,8 +96,16 @@ class ConvRepo: def add_injector(self, func: Callable, rettype: Optional[type] = None, - type_remap: Optional[dict[str, type]] = None): - self.add_conversion_points(ConversionPoint.from_fn(func, rettype=rettype, type_remap=type_remap)) + type_remap: Optional[dict[str, type]] = None, + priority: PriorityValue = 0.0): + cps = ConversionPoint.from_fn(func, rettype=rettype, type_remap=type_remap) + # Применяем приоритет ко всем ConversionPoint (может быть несколько для Union/tuple) + prioritized_cps = [cp.copy_with(priority=priority) for cp in cps] + + # Удаляем существующие инжекторы для этой функции (если есть) + self._convertor_set = {cp for cp in self._convertor_set if cp.fn is not func} + + self.add_conversion_points(prioritized_cps) def _callseq_from_callgraph(self, cg: Callgraph) -> list[ConversionPoint]: if len(cg.variants) == 0: @@ -209,8 +222,11 @@ class ConvRepo: reason="force_async=True requires allow_async=True" ) + # Разрешаем относительные приоритеты + self._resolve_relative_priorities() + filtered_injectors = self.filtered_injectors(allow_async, allow_sync) - + callseq = self.get_callseq( filtered_injectors, frozenset(from_types), @@ -223,13 +239,51 @@ class ConvRepo: setattr(ret_fn, '__breakshaft_callseq__', callseq) return ret_fn - def mark_injector(self, *, rettype: Optional[type] = None, type_remap: Optional[dict[str, type]] = None): + def mark_injector(self, *, + rettype: Optional[type] = None, + type_remap: Optional[dict[str, type]] = None, + priority: PriorityValue = 0.0): def inner(func: Callable): - self.add_injector(func, rettype=rettype, type_remap=type_remap) + self.add_injector(func, rettype=rettype, type_remap=type_remap, priority=priority) return func return inner + def _resolve_relative_priorities(self): + """ + Разрешить относительные приоритеты и вычислить абсолютные значения. + + Проходит по всем инжекторам и если есть относительные приоритеты, + вычисляет абсолютные значения на основе графа зависимостей. + """ + injectors = list(self.convertor_set) + + # Проверяем есть ли относительные приоритеты + has_relative = any(isinstance(cp.priority, RelativePriority) for cp in injectors) + if not has_relative: + return + + try: + priorities = resolve_priorities(injectors) + + # Применяем разрешённые приоритеты + # Создаём новые ConversionPoint с абсолютными приоритетами + new_injectors = set() + for cp in injectors: + if cp in priorities: + new_injectors.add(cp.copy_with(priority=priorities[cp])) + else: + new_injectors.add(cp) + + # Обновляем репозиторий + self._convertor_set = new_injectors + + except CycleDetectedError as e: + # Переупаковываем в наше исключение + from .exceptions import CircularDependency + cycle_types = [cp.injects for cp in e.cycle] + raise CircularDependency(cycle_types) from e + def fork(self, fork_with: Optional[set[ConversionPoint]] = None) -> ConvRepo: return ForkedConvRepo(self, fork_with or None, self.walker, diff --git a/src/breakshaft/graph_walker.py b/src/breakshaft/graph_walker.py index 70371a5..5a57d53 100644 --- a/src/breakshaft/graph_walker.py +++ b/src/breakshaft/graph_walker.py @@ -138,6 +138,7 @@ class GraphWalker: -> list[CallgraphVariant]: if relevance_metric is None: + # Сначала применяем стандартные метрики template_metrics = [ lambda x: len(x.consumed_from_types), lambda x: x.consumed_cumsum, @@ -151,10 +152,25 @@ class GraphWalker: if len(new_variants) > 0: variants = new_variants + # Если всё ещё несколько вариантов, используем приоритеты if len(variants) > 1: - # sorting by first injector func name for creating minimal cosistancy - # could lead to heizenbugs due to incosistancy in path selection between calls - variants.sort(key=lambda x: universal_qualname(x.injector.fn)) + # Вычисляем aggregate priority для каждого варианта (сумма приоритетов всех инжекторов в пути) + def get_aggregate_priority(variant: CallgraphVariant) -> float: + priority = variant.injector.priority + for subg in variant.subgraphs: + for subv in subg.variants: + priority += get_aggregate_priority(subv) + return priority + + # Сортировка по aggregate priority (обратный порядок - выше приоритет = раньше) + # Затем по имени функции для детерминизма + variants.sort(key=lambda x: (-get_aggregate_priority(x), universal_qualname(x.injector.fn))) + + # Выбираем вариант с наивысшим aggregate приоритетом + max_priority = get_aggregate_priority(variants[0]) + selected = [v for v in variants if get_aggregate_priority(v) == max_priority] + variants = selected + return variants if len(variants) < 2: diff --git a/src/breakshaft/models.py b/src/breakshaft/models.py index 7c211ce..f015e50 100644 --- a/src/breakshaft/models.py +++ b/src/breakshaft/models.py @@ -17,11 +17,23 @@ from .exceptions import MissingReturnType, MissingParamType @dataclass(frozen=True) class ConversionPoint: + """ + Точка преобразования типов. + + Attributes: + fn: Функция-инжектор + injects: Тип, который производит инжектор + rettype: Фактический тип возврата функции + requires: Обязательные типы аргументов + opt_args: Опциональные типы аргументов (с default) + priority: Приоритет инжектора (float, по умолчанию 0.0) + """ fn: Callable injects: type rettype: type requires: tuple[type, ...] opt_args: tuple[type, ...] + priority: float = 0.0 def copy_with(self, **kwargs): fn = kwargs.get('fn', self.fn) @@ -29,7 +41,8 @@ class ConversionPoint: injects = kwargs.get('injects', self.injects) requires = kwargs.get('requires', self.requires) opt_args = kwargs.get('opt_args', self.opt_args) - return ConversionPoint(fn, injects, rettype, requires, opt_args) + priority = kwargs.get('priority', self.priority) + return ConversionPoint(fn, injects, rettype, requires, opt_args, priority) def __hash__(self): return hash((self.fn, self.injects, self.requires)) diff --git a/src/breakshaft/priority_resolver.py b/src/breakshaft/priority_resolver.py new file mode 100644 index 0000000..c2e89a4 --- /dev/null +++ b/src/breakshaft/priority_resolver.py @@ -0,0 +1,231 @@ +""" +Разрешение относительных приоритетов. + +Модуль для разрешения графа зависимостей относительных приоритетов +и вычисления абсолютных значений приоритетов. +""" + +from typing import Dict, List, Set, Tuple, Any, Callable +from dataclasses import dataclass + +from .models import ConversionPoint +from .priority_types import RelativePriority, MoreThan, LessThan, PriorityValue + + +@dataclass +class PriorityConstraint: + """ + Ограничение приоритета. + + Attributes: + from_cp: Инжектор у которого есть ограничение + to_cp: Инжектор с которым сравнивается + direction: Направление сравнения (+1 для more_than, -1 для less_than) + """ + from_cp: ConversionPoint + to_cp: ConversionPoint + direction: int # +1 = from > to, -1 = from < to + + +class PriorityResolver: + """ + Разрешатель приоритетов. + + Разрешает граф относительных приоритетов и вычисляет + абсолютные значения приоритетов для всех инжекторов. + + Пример использования: + resolver = PriorityResolver() + resolver.add_constraint(cp1, cp2, direction=1) # cp1 > cp2 + priorities = resolver.resolve() + """ + + def __init__(self): + self.constraints: List[PriorityConstraint] = [] + self.injectors: Set[ConversionPoint] = set() + + def add_injector(self, cp: ConversionPoint): + """Добавить инжектор.""" + self.injectors.add(cp) + + def add_constraint(self, from_cp: ConversionPoint, to_cp: ConversionPoint, direction: int): + """ + Добавить ограничение приоритета. + + Args: + from_cp: Инжектор у которого есть ограничение + to_cp: Инжектор с которым сравнивается + direction: +1 для from > to, -1 для from < to + """ + self.constraints.append(PriorityConstraint(from_cp, to_cp, direction)) + + def resolve(self) -> Dict[ConversionPoint, float]: + """ + Разрешить приоритеты и вычислить абсолютные значения. + + Returns: + Dict[ConversionPoint, float]: Словарь {инжектор: приоритет} + + Raises: + CycleDetectedError: Если обнаружен цикл в ограничениях + """ + # Построение графа зависимостей + # graph[a] = [b, c] означает a > b, a > c + graph: Dict[ConversionPoint, List[ConversionPoint]] = {cp: [] for cp in self.injectors} + in_degree: Dict[ConversionPoint, int] = {cp: 0 for cp in self.injectors} + + for constraint in self.constraints: + if constraint.direction == 1: # from > to + graph[constraint.from_cp].append(constraint.to_cp) + in_degree[constraint.to_cp] += 1 + else: # from < to, значит to > from + graph[constraint.to_cp].append(constraint.from_cp) + in_degree[constraint.from_cp] += 1 + + # Топологическая сортировка (алгоритм Кана) + queue = [cp for cp in self.injectors if in_degree[cp] == 0] + sorted_cps: List[ConversionPoint] = [] + + while queue: + # Сортируем для детерминизма + queue.sort(key=lambda x: id(x)) + cp = queue.pop(0) + sorted_cps.append(cp) + + for neighbor in graph[cp]: + in_degree[neighbor] -= 1 + if in_degree[neighbor] == 0: + queue.append(neighbor) + + # Проверка на циклы + if len(sorted_cps) != len(self.injectors): + # Нашли цикл + raise CycleDetectedError(self._find_cycle(graph)) + + # Вычисление приоритетов + # Инжекторы в начале sorted_cps имеют высший приоритет + priorities: Dict[ConversionPoint, float] = {} + base_priority = len(sorted_cps) # Начинаем с высокого приоритета + + for i, cp in enumerate(sorted_cps): + priorities[cp] = base_priority - i + + return priorities + + def _find_cycle(self, graph: Dict[ConversionPoint, List[ConversionPoint]]) -> List[ConversionPoint]: + """ + Найти цикл в графе ограничений. + + Returns: + List[ConversionPoint]: Цикл (список инжекторов) + """ + visited: Set[ConversionPoint] = set() + rec_stack: Set[ConversionPoint] = set() + path: List[ConversionPoint] = [] + + def dfs(cp: ConversionPoint) -> bool: + visited.add(cp) + rec_stack.add(cp) + path.append(cp) + + for neighbor in graph[cp]: + if neighbor not in visited: + if dfs(neighbor): + return True + elif neighbor in rec_stack: + # Нашли цикл + cycle_start = path.index(neighbor) + return True + + path.pop() + rec_stack.remove(cp) + return False + + for cp in self.injectors: + if cp not in visited: + if dfs(cp): + # Извлекаем цикл из path + cycle_start = len(path) - 1 + while cycle_start > 0 and path[cycle_start] != path[-1]: + cycle_start -= 1 + return path[cycle_start:] + [path[cycle_start]] + + return [] + + +class CycleDetectedError(Exception): + """ + Исключение: обнаружен цикл в ограничениях приоритетов. + + Attributes: + cycle: Список инжекторов образующих цикл + """ + + def __init__(self, cycle: List[ConversionPoint]): + self.cycle = cycle + cycle_str = " -> ".join(cp.fn.__qualname__ for cp in cycle) + super().__init__(f"Priority cycle detected: {cycle_str}") + + +def resolve_priorities( + injectors: List[ConversionPoint] +) -> Dict[ConversionPoint, float]: + """ + Разрешить приоритеты для списка инжекторов. + + Args: + injectors: Список инжекторов с относительными приоритетами + + Returns: + Dict[ConversionPoint, float]: Словарь {инжектор: абсолютный приоритет} + + Raises: + CycleDetectedError: Если обнаружен цикл + """ + resolver = PriorityResolver() + + # Добавляем все инжекторы + for cp in injectors: + resolver.add_injector(cp) + + # Добавляем ограничения из относительных приоритетов + for cp in injectors: + if isinstance(cp.priority, RelativePriority): + relative = cp.priority + target = _find_target_injector(relative.target, injectors) + + if target is not None: + if isinstance(relative, MoreThan): + resolver.add_constraint(cp, target, direction=1) + elif isinstance(relative, LessThan): + resolver.add_constraint(cp, target, direction=-1) + + return resolver.resolve() + + +def _find_target_injector( + target: Any, + injectors: List[ConversionPoint] +) -> ConversionPoint: + """ + Найти целевой инжектор по ссылке. + + Args: + target: Цель (функция или ConversionPoint) + injectors: Список инжекторов для поиска + + Returns: + ConversionPoint или None если не найден + """ + for cp in injectors: + if cp.fn is target or cp is target: + return cp + return None + + +__all__ = [ + "PriorityResolver", + "PriorityConstraint", + "CycleDetectedError", + "resolve_priorities", +] diff --git a/src/breakshaft/priority_types.py b/src/breakshaft/priority_types.py new file mode 100644 index 0000000..d69f626 --- /dev/null +++ b/src/breakshaft/priority_types.py @@ -0,0 +1,100 @@ +""" +Модуль относительных приоритетов для breakshaft. + +Позволяет указывать приоритеты относительно других инжекторов: +- more_than(other) - приоритет выше чем у other +- less_than(other) - приоритет ниже чем у other + +Пример использования: + @repo.mark_injector(priority=more_than(int_to_a_v1)) + def int_to_a_v2(i: int) -> A: ... + + @repo.mark_injector(priority=less_than(int_to_a_v2)) + def int_to_a_v3(i: int) -> A: ... +""" + +from typing import Callable, Union, Any +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RelativePriority: + """ + Базовый класс относительного приоритета. + + Attributes: + target: Целевой инжектор (функция или ConversionPoint) + """ + target: Union[Callable, Any] + + +@dataclass(frozen=True) +class MoreThan(RelativePriority): + """ + Приоритет выше чем у целевого инжектора. + + Пример: + @repo.mark_injector(priority=more_than(int_to_a_v1)) + def int_to_a_v2(i: int) -> A: ... + """ + pass + + +@dataclass(frozen=True) +class LessThan(RelativePriority): + """ + Приоритет ниже чем у целевого инжектора. + + Пример: + @repo.mark_injector(priority=less_than(int_to_a_v2)) + def int_to_a_v1(i: int) -> A: ... + """ + pass + + +def more_than(target: Union[Callable, Any]) -> MoreThan: + """ + Создать ограничение "приоритет выше чем у target". + + Args: + target: Целевой инжектор (функция или ConversionPoint) + + Returns: + MoreThan: Ограничение приоритета + + Пример: + >>> @repo.mark_injector(priority=more_than(int_to_a_v1)) + ... def int_to_a_v2(i: int) -> A: ... + """ + return MoreThan(target) + + +def less_than(target: Union[Callable, Any]) -> LessThan: + """ + Создать ограничение "приоритет ниже чем у target". + + Args: + target: Целевой инжектор (функция или ConversionPoint) + + Returns: + LessThan: Ограничение приоритета + + Пример: + >>> @repo.mark_injector(priority=less_than(int_to_a_v2)) + ... def int_to_a_v1(i: int) -> A: ... + """ + return LessThan(target) + + +# Тип для приоритетов (абсолютный или относительный) +PriorityValue = Union[float, RelativePriority] + + +__all__ = [ + "RelativePriority", + "MoreThan", + "LessThan", + "more_than", + "less_than", + "PriorityValue", +] diff --git a/tests/test_priority_stage1.py b/tests/test_priority_stage1.py new file mode 100644 index 0000000..ba863fb --- /dev/null +++ b/tests/test_priority_stage1.py @@ -0,0 +1,426 @@ +""" +Тесты приоритизации инжекторов - Этап 1: Базовая модель приоритета (float). + +Проверка: +- Сохранение приоритета в ConversionPoint +- Передача приоритета через mark_injector(priority=...) +- Выбор пути с наивысшим приоритетом +- Детерминизм выбора при одинаковых приоритетах +""" + +from dataclasses import dataclass + +import pytest + +from breakshaft import ConvRepo +from breakshaft.models import ConversionPoint + + +@dataclass +class A: + a: int + + +@dataclass +class B: + b: float + + +@dataclass +class C: + c: str + + +# ============================================================================= +# Юнит-тесты: ConversionPoint с приоритетом +# ============================================================================= + +class TestConversionPointPriority: + """Тесты хранения приоритета в ConversionPoint.""" + + def test_default_priority_is_zero(self): + """Приоритет по умолчанию равен 0.0.""" + def func(i: int) -> A: + return A(i) + + cps = ConversionPoint.from_fn(func) + assert len(cps) > 0 + for cp in cps: + assert cp.priority == 0.0 + + def test_priority_preserved_in_copy_with(self): + """copy_with сохраняет приоритет.""" + def func(i: int) -> A: + return A(i) + + cps = ConversionPoint.from_fn(func) + cp = cps[0] + + # Создаём копию с изменённым injects + cp_copy = cp.copy_with(injects=B) + + # Приоритет должен сохраниться + assert cp_copy.priority == cp.priority + + def test_priority_can_be_set_via_copy_with(self): + """copy_with может изменять приоритет.""" + def func(i: int) -> A: + return A(i) + + cps = ConversionPoint.from_fn(func) + cp = cps[0] + + # Изменяем приоритет + cp_copy = cp.copy_with(priority=10.5) + + assert cp_copy.priority == 10.5 + assert cp.priority == 0.0 # Оригинал не изменился + + +# ============================================================================= +# Юнит-тесты: mark_injector с приоритетом +# ============================================================================= + +class TestMarkInjectorPriority: + """Тесты декоратора mark_injector с приоритетом.""" + + def test_mark_injector_default_priority(self): + """mark_injector без priority использует 0.0.""" + repo = ConvRepo() + + @repo.mark_injector() + def int_to_a(i: int) -> A: + return A(i) + + cps = list(repo.convertor_set) + assert len(cps) == 1 + assert cps[0].priority == 0.0 + + def test_mark_injector_with_priority(self): + """mark_injector(priority=X) устанавливает приоритет.""" + repo = ConvRepo() + + @repo.mark_injector(priority=10.5) + def int_to_a(i: int) -> A: + return A(i) + + cps = list(repo.convertor_set) + assert len(cps) == 1 + assert cps[0].priority == 10.5 + + def test_mark_injector_negative_priority(self): + """mark_injector поддерживает отрицательные приоритеты.""" + repo = ConvRepo() + + @repo.mark_injector(priority=-5.0) + def int_to_a(i: int) -> A: + return A(i) + + cps = list(repo.convertor_set) + assert len(cps) == 1 + assert cps[0].priority == -5.0 + + def test_mark_injector_priority_with_rettype(self): + """mark_injector(priority=..., rettype=...) работает корректно.""" + repo = ConvRepo() + + @repo.mark_injector(priority=7.5, rettype=A) + def int_to_a(i: int): + return A(i) + + cps = list(repo.convertor_set) + assert len(cps) == 1 + assert cps[0].priority == 7.5 + assert cps[0].injects == A + + def test_mark_injector_priority_with_type_remap(self): + """mark_injector(priority=..., type_remap=...) работает корректно.""" + repo = ConvRepo() + + type NewA = A + type_remap = {'i': int, 'return': NewA} + + @repo.mark_injector(priority=3.0, type_remap=type_remap) + def int_to_a(i: int) -> NewA: + return NewA(i) + + cps = list(repo.convertor_set) + assert len(cps) == 1 + assert cps[0].priority == 3.0 + + +# ============================================================================= +# Интеграционные тесты: Выбор пути по приоритету +# ============================================================================= + +class TestPriorityPathSelection: + """Тесты выбора пути преобразования по приоритету.""" + + def test_higher_priority_path_selected(self): + """Путь с более высоким приоритетом выбирается.""" + repo = ConvRepo() + + @repo.mark_injector(priority=1.0) + def int_to_a_low(i: int) -> A: + return A(i * 10) # Низкий приоритет: A(420) + + @repo.mark_injector(priority=10.0) + def int_to_a_high(i: int) -> A: + return A(i + 100) # Высокий приоритет: A(142) + + def consumer(dep: A) -> int: + return dep.a + + # force_commutative=False позволяет выбрать любой путь + fn = repo.get_conversion((int,), consumer, force_commutative=False) + result = fn(42) + + # Должен выбрать путь с высоким приоритетом (42 + 100 = 142) + assert result == 142 + + def test_lower_priority_path_selected_when_higher_not_available(self): + """Путь с низким приоритетом выбирается если высокий недоступен.""" + repo = ConvRepo() + + @repo.mark_injector(priority=1.0) + def int_to_a(i: int) -> A: + return A(i * 10) + + def consumer(dep: A) -> int: + return dep.a + + fn = repo.get_conversion((int,), consumer) + result = fn(42) + + assert result == 420 + + def test_equal_priorities_use_fallback(self): + """При одинаковых приоритетах используется fallback (имя функции).""" + repo = ConvRepo() + + @repo.mark_injector(priority=5.0) + def aaa_converter(i: int) -> A: + return A(i * 10) + + @repo.mark_injector(priority=5.0) + def zzz_converter(i: int) -> A: + return A(i + 100) + + def consumer(dep: A) -> int: + return dep.a + + # При одинаковых приоритетах выбор детерминирован (по имени функции) + fn = repo.get_conversion((int,), consumer, force_commutative=False) + result = fn(42) + + # Должен выбрать один из путей (детерминировано) + assert result in [420, 142] + + def test_priority_with_multiple_steps(self): + """Приоритеты работают в многошаговых преобразованиях.""" + repo = ConvRepo() + + @repo.mark_injector(priority=1.0) + def int_to_b_low(i: int) -> B: + return B(float(i) * 10) + + @repo.mark_injector(priority=10.0) + def int_to_b_high(i: int) -> B: + return B(float(i) + 100) + + @repo.mark_injector() + def b_to_a(b: B) -> A: + return A(int(b.b)) + + def consumer(dep: B) -> float: + return dep.b + + # Тестируем выбор int->B (первый шаг) + fn = repo.get_conversion((int,), consumer, force_commutative=False) + result = fn(42) + + # Должен выбрать int->B(high): (42 + 100) = 142 + assert result == 142.0 + + def test_priority_deterministic_selection(self): + """Приоритеты обеспечивают детерминированный выбор.""" + repo = ConvRepo() + + @repo.mark_injector(priority=1.0) + def int_to_a_v1(i: int) -> A: + return A(i * 10) + + @repo.mark_injector(priority=10.0) + def int_to_a_v2(i: int) -> A: + return A(i + 100) + + def consumer(dep: A) -> int: + return dep.a + + # Запускаем много раз - результат должен быть одинаковым + fn = repo.get_conversion((int,), consumer, force_commutative=False) + results = [fn(42) for _ in range(10)] + + # Все результаты должны быть одинаковыми (детерминизм) + assert len(set(results)) == 1 + assert results[0] == 142 # Высокий приоритет + + +# ============================================================================= +# Интеграционные тесты: add_injector с приоритетом +# ============================================================================= + +class TestAddInjectorPriority: + """Тесты функции add_injector с приоритетом.""" + + def test_add_injector_with_priority(self): + """add_injector(priority=...) устанавливает приоритет.""" + repo = ConvRepo() + + def int_to_a(i: int) -> A: + return A(i) + + repo.add_injector(int_to_a, priority=5.5) + + cps = list(repo.convertor_set) + assert len(cps) == 1 + assert cps[0].priority == 5.5 + + def test_add_injector_default_priority(self): + """add_injector без priority использует 0.0.""" + repo = ConvRepo() + + def int_to_a(i: int) -> A: + return A(i) + + repo.add_injector(int_to_a) + + cps = list(repo.convertor_set) + assert len(cps) == 1 + assert cps[0].priority == 0.0 + + +# ============================================================================= +# Тесты: Приоритеты с Union-типами +# ============================================================================= + +class TestPriorityWithUnionTypes: + """Тесты приоритетов с Union-типами.""" + + def test_priority_with_union_return_type(self): + """Приоритет применяется ко всем вариантам Union return type.""" + repo = ConvRepo() + + @repo.mark_injector(priority=7.0) + def int_to_a_or_b(i: int) -> A | B: + return A(i) + + cps = list(repo.convertor_set) + # Для Union создаётся несколько ConversionPoint + assert len(cps) > 0 + # Все должны иметь одинаковый приоритет + for cp in cps: + assert cp.priority == 7.0 + + +# ============================================================================= +# Тесты: Приоритеты в конвейерах (pipelines) +# ============================================================================= + +class TestPriorityInPipelines: + """Тесты приоритетов в конвейерах преобразований.""" + + def test_pipeline_respects_priorities(self): + """Конвейер уважает приоритеты инжекторов.""" + repo = ConvRepo() + + @repo.mark_injector(priority=1.0) + def int_to_a_low(i: int) -> A: + return A(i * 10) + + @repo.mark_injector(priority=10.0) + def int_to_a_high(i: int) -> A: + return A(i + 100) + + @repo.mark_injector() + def a_to_b(a: A) -> B: + return B(float(a.a)) + + def consumer1(dep: A) -> B: # Потребляет A напрямую + return a_to_b(dep) + + def consumer2(dep: B) -> float: + return dep.b + + pipeline = repo.create_pipeline( + (int,), + [consumer1, consumer2], + force_commutative=False + ) + + result = pipeline(42) + + # Должен выбрать путь с высоким приоритетом: (42 + 100) = 142 + assert result == 142.0 + + +# ============================================================================= +# Тесты: Краевые случаи +# ============================================================================= + +class TestPriorityEdgeCases: + """Тесты краевых случаев приоритетов.""" + + def test_very_large_priority(self): + """Очень большой приоритет работает корректно.""" + repo = ConvRepo() + + @repo.mark_injector(priority=1e10) + def int_to_a(i: int) -> A: + return A(i) + + cps = list(repo.convertor_set) + assert len(cps) == 1 + assert cps[0].priority == 1e10 + + def test_very_small_negative_priority(self): + """Очень маленький отрицательный приоритет работает корректно.""" + repo = ConvRepo() + + @repo.mark_injector(priority=-1e10) + def int_to_a(i: int) -> A: + return A(i) + + cps = list(repo.convertor_set) + assert len(cps) == 1 + assert cps[0].priority == -1e10 + + def test_float_priority_precision(self): + """Дробные приоритеты сохраняют точность.""" + repo = ConvRepo() + + @repo.mark_injector(priority=3.14159) + def int_to_a(i: int) -> A: + return A(i) + + cps = list(repo.convertor_set) + assert len(cps) == 1 + assert abs(cps[0].priority - 3.14159) < 1e-10 + + def test_zero_priority_same_as_default(self): + """Приоритет 0.0 эквивалентен приориту по умолчанию.""" + repo1 = ConvRepo() + repo2 = ConvRepo() + + @repo1.mark_injector() + def int_to_a1(i: int) -> A: + return A(i) + + @repo2.mark_injector(priority=0.0) + def int_to_a2(i: int) -> A: + return A(i) + + cps1 = list(repo1.convertor_set) + cps2 = list(repo2.convertor_set) + + assert cps1[0].priority == cps2[0].priority diff --git a/tests/test_priority_stage2.py b/tests/test_priority_stage2.py new file mode 100644 index 0000000..c4433ae --- /dev/null +++ b/tests/test_priority_stage2.py @@ -0,0 +1,447 @@ +""" +Тесты приоритизации инжекторов - Этап 2: Относительные приоритеты. + +Проверка: +- more_than() создаёт ограничение приоритета +- less_than() создаёт ограничение приоритета +- Разрешение графа относительных приоритетов +- Обнаружение циклов в приоритетах +- Транзитивность приоритетов (A > B > C ⇒ A > C) +""" + +from dataclasses import dataclass + +import pytest + +from breakshaft import ConvRepo, more_than, less_than, CircularDependency +from breakshaft.priority_types import MoreThan, LessThan +from breakshaft.priority_resolver import PriorityResolver, CycleDetectedError +from breakshaft.models import ConversionPoint + + +@dataclass +class A: + a: int + + +@dataclass +class B: + b: float + + +@dataclass +class C: + c: str + + +# ============================================================================= +# Юнит-тесты: RelativePriority классы +# ============================================================================= + +class TestRelativePriorityClasses: + """Тесты классов относительных приоритетов.""" + + def test_more_than_creation(self): + """more_than() создаёт MoreThan объект.""" + def func(i: int) -> A: + return A(i) + + rel = more_than(func) + + assert isinstance(rel, MoreThan) + assert rel.target is func + + def test_less_than_creation(self): + """less_than() создаёт LessThan объект.""" + def func(i: int) -> A: + return A(i) + + rel = less_than(func) + + assert isinstance(rel, LessThan) + assert rel.target is func + + def test_more_than_frozen(self): + """MoreThan неизменяемый (frozen dataclass).""" + def func(i: int) -> A: + return A(i) + + rel = more_than(func) + + with pytest.raises(AttributeError): + rel.target = None # type: ignore + + def test_less_than_frozen(self): + """LessThan неизменяемый (frozen dataclass).""" + def func(i: int) -> A: + return A(i) + + rel = less_than(func) + + with pytest.raises(AttributeError): + rel.target = None # type: ignore + + +# ============================================================================= +# Юнит-тесты: PriorityResolver +# ============================================================================= + +class TestPriorityResolver: + """Тесты разрешителя приоритетов.""" + + def test_simple_more_than(self): + """Простое more_than ограничение.""" + def func1(i: int) -> A: + return A(i) + + def func2(i: int) -> A: + return A(i * 10) + + cp1 = ConversionPoint.from_fn(func1)[0] + cp2 = ConversionPoint.from_fn(func2)[0] + + cp1 = cp1.copy_with(priority=more_than(func2)) + + resolver = PriorityResolver() + resolver.add_injector(cp1) + resolver.add_injector(cp2) + resolver.add_constraint(cp1, cp2, direction=1) + + priorities = resolver.resolve() + + assert priorities[cp1] > priorities[cp2] + + def test_simple_less_than(self): + """Простое less_than ограничение.""" + def func1(i: int) -> A: + return A(i) + + def func2(i: int) -> A: + return A(i * 10) + + cp1 = ConversionPoint.from_fn(func1)[0] + cp2 = ConversionPoint.from_fn(func2)[0] + + cp1 = cp1.copy_with(priority=less_than(func2)) + + resolver = PriorityResolver() + resolver.add_injector(cp1) + resolver.add_injector(cp2) + resolver.add_constraint(cp1, cp2, direction=-1) + + priorities = resolver.resolve() + + assert priorities[cp1] < priorities[cp2] + + def test_transitive_priorities(self): + """Транзитивность приоритетов: A > B > C ⇒ A > C.""" + def func_a(i: int) -> A: + return A(i) + + def func_b(i: int) -> A: + return A(i * 10) + + def func_c(i: int) -> A: + return A(i * 100) + + cp_a = ConversionPoint.from_fn(func_a)[0] + cp_b = ConversionPoint.from_fn(func_b)[0] + cp_c = ConversionPoint.from_fn(func_c)[0] + + resolver = PriorityResolver() + resolver.add_injector(cp_a) + resolver.add_injector(cp_b) + resolver.add_injector(cp_c) + + # A > B, B > C + resolver.add_constraint(cp_a, cp_b, direction=1) + resolver.add_constraint(cp_b, cp_c, direction=1) + + priorities = resolver.resolve() + + assert priorities[cp_a] > priorities[cp_b] + assert priorities[cp_b] > priorities[cp_c] + assert priorities[cp_a] > priorities[cp_c] # Транзитивность + + def test_cycle_detection(self): + """Обнаружение цикла: A > B > C > A.""" + def func_a(i: int) -> A: + return A(i) + + def func_b(i: int) -> A: + return A(i * 10) + + def func_c(i: int) -> A: + return A(i * 100) + + cp_a = ConversionPoint.from_fn(func_a)[0] + cp_b = ConversionPoint.from_fn(func_b)[0] + cp_c = ConversionPoint.from_fn(func_c)[0] + + resolver = PriorityResolver() + resolver.add_injector(cp_a) + resolver.add_injector(cp_b) + resolver.add_injector(cp_c) + + # A > B, B > C, C > A (цикл!) + resolver.add_constraint(cp_a, cp_b, direction=1) + resolver.add_constraint(cp_b, cp_c, direction=1) + resolver.add_constraint(cp_c, cp_a, direction=1) + + with pytest.raises(CycleDetectedError): + resolver.resolve() + + def test_multiple_constraints_same_injector(self): + """Несколько ограничений для одного инжектора.""" + def func_a(i: int) -> A: + return A(i) + + def func_b(i: int) -> A: + return A(i * 10) + + def func_c(i: int) -> A: + return A(i * 100) + + cp_a = ConversionPoint.from_fn(func_a)[0] + cp_b = ConversionPoint.from_fn(func_b)[0] + cp_c = ConversionPoint.from_fn(func_c)[0] + + resolver = PriorityResolver() + resolver.add_injector(cp_a) + resolver.add_injector(cp_b) + resolver.add_injector(cp_c) + + # A > B, A > C + resolver.add_constraint(cp_a, cp_b, direction=1) + resolver.add_constraint(cp_a, cp_c, direction=1) + + priorities = resolver.resolve() + + assert priorities[cp_a] > priorities[cp_b] + assert priorities[cp_a] > priorities[cp_c] + + +# ============================================================================= +# Интеграционные тесты: Относительные приоритеты в ConvRepo +# ============================================================================= + +class TestRelativePrioritiesInRepo: + """Тесты относительных приоритетов в репозитории.""" + + def test_more_than_in_mark_injector(self): + """more_than в mark_injector.""" + repo = ConvRepo() + + @repo.mark_injector() + def int_to_a_base(i: int) -> A: + return A(i * 10) + + @repo.mark_injector(priority=more_than(int_to_a_base)) + def int_to_a_preferred(i: int) -> A: + return A(i + 100) + + def consumer(dep: A) -> int: + return dep.a + + fn = repo.get_conversion((int,), consumer, force_commutative=False) + result = fn(42) + + # Должен выбрать путь с высоким приоритетом (42 + 100 = 142) + assert result == 142 + + def test_less_than_in_mark_injector(self): + """less_than в mark_injector.""" + repo = ConvRepo() + + # Сначала определяем функцию которая будет "выше" + @repo.mark_injector(priority=10.0) + def int_to_a_preferred(i: int) -> A: + return A(i + 100) + + # Потом функцию с less_than + @repo.mark_injector(priority=less_than(int_to_a_preferred)) + def int_to_a_low(i: int) -> A: + return A(i * 10) + + def consumer(dep: A) -> int: + return dep.a + + fn = repo.get_conversion((int,), consumer, force_commutative=False) + result = fn(42) + + # Должен выбрать путь с высоким приоритетом (42 + 100 = 142) + assert result == 142 + + def test_chain_more_than(self): + """Цепочка more_than: A > B > C.""" + repo = ConvRepo() + + @repo.mark_injector() + def int_to_a_c(i: int) -> A: + return A(i * 100) # Низкий приоритет + + @repo.mark_injector(priority=more_than(int_to_a_c)) + def int_to_a_b(i: int) -> A: + return A(i * 10) # Средний приоритет + + @repo.mark_injector(priority=more_than(int_to_a_b)) + def int_to_a_a(i: int) -> A: + return A(i + 100) # Высокий приоритет + + def consumer(dep: A) -> int: + return dep.a + + fn = repo.get_conversion((int,), consumer, force_commutative=False) + result = fn(42) + + # Должен выбрать путь с высоким приоритетом (42 + 100 = 142) + assert result == 142 + + def test_circular_dependency_raises(self): + """Циклическая зависимость вызывает CircularDependency.""" + repo = ConvRepo() + + # Определяем функции сначала + @repo.mark_injector() + def int_to_a_a(i: int) -> A: + return A(i) + + @repo.mark_injector() + def int_to_a_b(i: int) -> A: + return A(i * 10) + + @repo.mark_injector() + def int_to_a_c(i: int) -> A: + return A(i * 100) + + # Теперь добавляем циклические зависимости + repo.add_injector(int_to_a_a, priority=more_than(int_to_a_b)) + repo.add_injector(int_to_a_b, priority=more_than(int_to_a_c)) + repo.add_injector(int_to_a_c, priority=more_than(int_to_a_a)) + + def consumer(dep: A) -> int: + return dep.a + + # Цикл: A > B > C > A + with pytest.raises(CircularDependency): + repo.get_conversion((int,), consumer, force_commutative=False) + + +# ============================================================================= +# Тесты: Смешанные абсолютные и относительные приоритеты +# ============================================================================= + +class TestMixedPriorities: + """Тесты смешанных абсолютных и относительных приоритетов.""" + + def test_absolute_and_relative(self): + """Смешение абсолютных и относительных приоритетов.""" + repo = ConvRepo() + + @repo.mark_injector(priority=5.0) + def int_to_a_base(i: int) -> A: + return A(i * 10) + + @repo.mark_injector(priority=more_than(int_to_a_base)) + def int_to_a_high(i: int) -> A: + return A(i + 100) + + def consumer(dep: A) -> int: + return dep.a + + fn = repo.get_conversion((int,), consumer, force_commutative=False) + result = fn(42) + + # more_than должен дать приоритет выше чем 5.0 + assert result == 142 + + def test_relative_with_absolute_fallback(self): + """Относительный приоритет с абсолютным fallback.""" + repo = ConvRepo() + + @repo.mark_injector(priority=10.0) + def int_to_a_high(i: int) -> A: + return A(i + 100) + + @repo.mark_injector(priority=less_than(int_to_a_high)) + def int_to_a_low(i: int) -> A: + return A(i * 10) + + def consumer(dep: A) -> int: + return dep.a + + fn = repo.get_conversion((int,), consumer, force_commutative=False) + result = fn(42) + + # Должен выбрать путь с высоким приоритетом + assert result == 142 + + +# ============================================================================= +# Тесты: Краевые случаи +# ============================================================================= + +class TestRelativePriorityEdgeCases: + """Тесты краевых случаев относительных приоритетов.""" + + def test_self_reference_raises(self): + """Ссылка на себя вызывает цикл.""" + repo = ConvRepo() + + # Определяем функцию сначала + @repo.mark_injector() + def int_to_a_self(i: int) -> A: + return A(i) + + # Теперь добавляем self-reference + repo.add_injector(int_to_a_self, priority=more_than(int_to_a_self)) + + def consumer(dep: A) -> int: + return dep.a + + with pytest.raises((CircularDependency, CycleDetectedError)): + repo.get_conversion((int,), consumer, force_commutative=False) + + def test_non_existent_target_ignored(self): + """Несуществующая цель игнорируется.""" + repo = ConvRepo() + + def non_existent(i: int) -> A: + return A(i) + + @repo.mark_injector(priority=more_than(non_existent)) + def int_to_a(i: int) -> A: + return A(i) + + def consumer(dep: A) -> int: + return dep.a + + # Должно работать, non_existent не зарегистрирован + fn = repo.get_conversion((int,), consumer, force_commutative=False) + result = fn(42) + assert result == 42 + + def test_multiple_more_than_same_target(self): + """Несколько more_than на одну цель.""" + repo = ConvRepo() + + @repo.mark_injector() + def int_to_a_base(i: int) -> A: + return A(i * 10) + + @repo.mark_injector(priority=more_than(int_to_a_base)) + def int_to_a_v1(i: int) -> A: + return A(i + 100) + + @repo.mark_injector(priority=more_than(int_to_a_base)) + def int_to_a_v2(i: int) -> A: + return A(i + 200) + + def consumer(dep: A) -> int: + return dep.a + + fn = repo.get_conversion((int,), consumer, force_commutative=False) + result = fn(42) + + # Оба v1 и v2 имеют приоритет выше base, выбирается один из них + assert result in [142, 242]