feat: приоритизация инжекторов (Этапы 1-2)

Реализована система приоритизации инжекторов:

Этап 1 - Базовая модель приоритета (float):
- Добавлено поле priority: float в ConversionPoint
- mark_injector(priority=10.5) для установки приоритета
- Интеграция в graph_walker для выбора пути по приоритету
- Aggregate priority для многошаговых путей

Этап 2 - Относительные приоритеты:
- more_than(target) - приоритет выше чем у target
- less_than(target) - приоритет ниже чем у target
- PriorityResolver для разрешения графа зависимостей
- Топологическая сортировка для вычисления приоритетов
- Обнаружение циклов в приоритетах (CircularDependency)

Файлы:
- priority_types.py - классы MoreThan, LessThan, more_than(), less_than()
- priority_resolver.py - PriorityResolver, CycleDetectedError
- test_priority_stage1.py - 21 тест базовых приоритетов
- test_priority_stage2.py - 18 тестов относительных приоритетов

Пример использования:
    @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: ...

    @repo.mark_injector(priority=less_than(int_to_a_v2))
    def int_to_a_v3(i: int) -> A: ...

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Qwen Code Assistant
2026-03-28 14:09:25 +00:00
parent ca605001b3
commit 4c1568fd47
8 changed files with 1316 additions and 10 deletions

View File

@@ -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",

View File

@@ -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,6 +222,9 @@ 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(
@@ -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,

View File

@@ -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:

View File

@@ -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))

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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]