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

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