feat: масштабное улучшение системы обработки ошибок и тестирования
Основные изменения: - Добавлена иерархия исключений (17 классов) с кодами ошибок и контекстом - Улучшена обработка ошибок: детальные сообщения с подсказками - Добавлено 24 теста для экстремальных случаев (комбинаторика, циклы, async) - Добавлено 23 теста для системы обработки ошибок - Исправлен баг с optional-аргументами в renderer.py - Обновлены импорты в тестах (src.breakshaft → breakshaft) Документация: - ERROR_DESIGN.md — проектирование системы ошибок - COMMUTATIVITY_DESIGN.md — анализ проблемы некоммутативности (10 вариантов решений) Файлы: - src/breakshaft/exceptions.py (новый) — модуль исключений - tests/test_error_handling.py (новый) — тесты ошибок - tests/test_extreme_cases.py (новый) — экстремальные кейсы Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
737
COMMUTATIVITY_DESIGN.md
Normal file
737
COMMUTATIVITY_DESIGN.md
Normal file
@@ -0,0 +1,737 @@
|
||||
# Масштабное проектирование: Решение проблемы некоммутативных преобразований в breakshaft
|
||||
|
||||
## Содержание
|
||||
1. [Постановка проблемы](#1-постановка-проблемы)
|
||||
2. [Анализ текущей ситуации](#2-анализ-текущей-ситуации)
|
||||
3. [Варианты решений](#3-варианты-решений)
|
||||
4. [Сравнительная таблица](#4-сравнительная-таблица)
|
||||
5. [Рекомендации](#5-рекомендации)
|
||||
|
||||
---
|
||||
|
||||
## 1. Постановка проблемы
|
||||
|
||||
### 1.1. Что такое некоммутативность в breakshaft?
|
||||
|
||||
**Некоммутативное преобразование** — ситуация, когда существует несколько путей преобразования типов, дающих **разные результаты**.
|
||||
|
||||
#### Пример:
|
||||
```python
|
||||
@repo.mark_injector()
|
||||
def int_to_a_v1(i: int) -> A:
|
||||
return A(i * 10) # Путь 1: A(420) из int=42
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a_v2(i: int) -> A:
|
||||
return A(i + 100) # Путь 2: A(142) из int=42
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
# Два пути дают разные результаты: 420 vs 142
|
||||
```
|
||||
|
||||
### 1.2. Почему это проблема?
|
||||
|
||||
| Аспект | Проблема |
|
||||
|--------|----------|
|
||||
| **Детерминизм** | Один и тот же код может давать разные результаты |
|
||||
| **Отладка** | Сложно понять, какой путь был выбран |
|
||||
| **Предсказуемость** | Поведение зависит от внутреннего порядка обхода графа |
|
||||
| **Тестирование** | Тесты могут проходить/падать недетерминированно |
|
||||
|
||||
### 1.3. Где возникает в коде?
|
||||
|
||||
```
|
||||
src/breakshaft/
|
||||
├── graph_walker.py
|
||||
│ ├── generate_callgraph() # Построение графа
|
||||
│ ├── explode_callgraph_branches() # Комбинаторный взрыв вариантов
|
||||
│ └── filter_exploded_callgraph_branch() # Фильтрация (выбор пути)
|
||||
└── convertor.py
|
||||
└── get_callseq() # Проверка force_commutative
|
||||
```
|
||||
|
||||
**Критическое место** — `filter_exploded_callgraph_branch()`:
|
||||
- Использует эвристики для выбора пути
|
||||
- Порядок обхода не гарантирован
|
||||
- При `force_commutative=True` выбрасывает ошибку если >1 пути
|
||||
|
||||
---
|
||||
|
||||
## 2. Анализ текущей ситуации
|
||||
|
||||
### 2.1. Текущий алгоритм выбора пути
|
||||
|
||||
```python
|
||||
# graph_walker.py: filter_exploded_callgraph_branch()
|
||||
|
||||
# Эвристики (применяются последовательно):
|
||||
template_metrics = [
|
||||
lambda x: len(x.consumed_from_types), # 1. Максимум потреблённых типов
|
||||
lambda x: x.consumed_cumsum, # 2. Максимум кумулятивного потребления
|
||||
lambda x: -x.invokes, # 3. Минимум вызовов
|
||||
]
|
||||
|
||||
# Если после фильтрации >1 варианта:
|
||||
if len(variants) > 1:
|
||||
# Сортировка по имени функции (недетерминировано!)
|
||||
variants.sort(key=lambda x: universal_qualname(x.injector.fn))
|
||||
```
|
||||
|
||||
### 2.2. Проблемы текущей реализации
|
||||
|
||||
| Проблема | Описание | Влияние |
|
||||
|----------|----------|---------|
|
||||
| **P1. Недетерминированная сортировка** | `universal_qualname()` не гарантирует порядок | Разные результаты на разных машинах |
|
||||
| **P2. Эвристики не семантические** | Выбор по метрикам графа, не по логике | Может выбрать "неправильный" путь |
|
||||
| **P3. Комбинаторный взрыв** | `explode_callgraph_branches()` генерирует все варианты | O(n!) сложность |
|
||||
| **P4. Нет кэширования** | Граф пересчитывается каждый раз | Усугубляет P3 |
|
||||
| **P5. Нет явного приоритета** | Все инжекторы равны | Невозможно указать "предпочтительный" путь |
|
||||
|
||||
### 2.3. Статистика (из тестов)
|
||||
|
||||
```
|
||||
test_performance_many_injectors: 20 инжекторов → ~0.5 сек
|
||||
test_performance_many_injectors: 50 инжекторов → TIMEOUT (комбинаторный взрыв)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Варианты решений
|
||||
|
||||
### Вариант 1: Явные приоритеты инжекторов
|
||||
|
||||
#### Описание
|
||||
Добавить параметр `priority` к `mark_injector()`. При выборе пути предпочитать инжекторы с высшим приоритетом.
|
||||
|
||||
#### Реализация
|
||||
```python
|
||||
# Использование
|
||||
@repo.mark_injector(priority=10) # Высокий приоритет
|
||||
def int_to_a_preferred(i: int) -> A:
|
||||
return A(i * 10)
|
||||
|
||||
@repo.mark_injector(priority=1) # Низкий приоритет
|
||||
def int_to_a_fallback(i: int) -> A:
|
||||
return A(i + 100)
|
||||
|
||||
# Изменения в моделях
|
||||
@dataclass(frozen=True)
|
||||
class ConversionPoint:
|
||||
fn: Callable
|
||||
injects: type
|
||||
# ...
|
||||
priority: int = 0 # Новый параметр
|
||||
|
||||
# Изменения в graph_walker.py
|
||||
def filter_exploded_callgraph_branch(variants, priority_injectors=None):
|
||||
# Сортировка по приоритету
|
||||
variants.sort(key=lambda x: -x.injector.priority)
|
||||
```
|
||||
|
||||
#### Сильные стороны
|
||||
| + | Описание |
|
||||
|---|----------|
|
||||
| **Контроль** | Разработчик явно указывает предпочтения |
|
||||
| **Детерминизм** | Приоритеты дают однозначный выбор |
|
||||
| **Гибкость** | Можно менять приоритеты без изменения кода |
|
||||
| **Обратная совместимость** | `priority=0` по умолчанию не ломает существующий код |
|
||||
|
||||
#### Слабые стороны
|
||||
| - | Описание |
|
||||
|---|----------|
|
||||
| **Сложность API** | Новый параметр для изучения |
|
||||
| **Конфликты приоритетов** | Одинаковые приоритеты → снова недетерминизм |
|
||||
| **Не решает комбинаторный взрыв** | Всё ещё генерируются все варианты |
|
||||
| **Субъективность** | Приоритеты могут быть произвольными |
|
||||
|
||||
#### Оценка сложности
|
||||
- **Код**: ~50 строк изменений
|
||||
- **Тесты**: ~10 новых тестов
|
||||
- **Риск**: Низкий
|
||||
|
||||
---
|
||||
|
||||
### Вариант 2: Именованные пути (Named Paths)
|
||||
|
||||
#### Описание
|
||||
Разработчик явно именовывает пути преобразования и выбирает их по имени.
|
||||
|
||||
#### Реализация
|
||||
```python
|
||||
# Регистрация именованных путей
|
||||
@repo.mark_path(name="multiply")
|
||||
def int_to_a_mult(i: int) -> A:
|
||||
return A(i * 10)
|
||||
|
||||
@repo.mark_path(name="add")
|
||||
def int_to_a_add(i: int) -> A:
|
||||
return A(i + 100)
|
||||
|
||||
# Выбор пути при использовании
|
||||
fn = repo.get_conversion(
|
||||
(int,),
|
||||
consumer,
|
||||
path_preference="multiply" # Явный выбор пути
|
||||
)
|
||||
|
||||
# Или глобальная настройка
|
||||
repo.set_path_preference("multiply")
|
||||
```
|
||||
|
||||
#### Сильные стороны
|
||||
| + | Описание |
|
||||
|---|----------|
|
||||
| **Полный контроль** | Разработчик всегда выбирает путь |
|
||||
| **Читаемость** | Код явно показывает какой путь используется |
|
||||
| **Документированность** | Имена путей служат документацией |
|
||||
| **Тестируемость** | Можно тестировать разные пути явно |
|
||||
|
||||
#### Слабые стороны
|
||||
| - | Описание |
|
||||
|---|----------|
|
||||
| **Бойлерплейт** | Нужно именовать каждый путь |
|
||||
| **Сложность** | Управление именами в больших проектах |
|
||||
| **Конфликты имён** | Нужна проверка уникальности |
|
||||
| **Не решает комбинаторный взрыв** | Генерация всех вариантов остаётся |
|
||||
|
||||
#### Оценка сложности
|
||||
- **Код**: ~150 строк изменений
|
||||
- **Тесты**: ~15 новых тестов
|
||||
- **Риск**: Средний
|
||||
|
||||
---
|
||||
|
||||
### Вариант 3: Стратегии выбора пути (Path Selection Strategies)
|
||||
|
||||
#### Описание
|
||||
Встроенные стратегии выбора пути с возможностью расширения.
|
||||
|
||||
#### Реализация
|
||||
```python
|
||||
from breakshaft import PathStrategy
|
||||
|
||||
# Встроенные стратегии:
|
||||
# - SHORTEST: кратчайший путь (минимум вызовов)
|
||||
# - LONGEST: длиннейший путь (максимум преобразований)
|
||||
# - FIRST: первый найденный (быстро, недетерминировано)
|
||||
# - EXPLICIT: только явные пути (ошибка если несколько)
|
||||
# - CUSTOM: пользовательская функция
|
||||
|
||||
fn = repo.get_conversion(
|
||||
(int,),
|
||||
consumer,
|
||||
path_strategy=PathStrategy.SHORTEST # или PathStrategy.LONGEST
|
||||
)
|
||||
|
||||
# Пользовательская стратегия
|
||||
def my_strategy(paths: list[Path]) -> Path:
|
||||
# Логика выбора
|
||||
return min(paths, key=lambda p: p.complexity)
|
||||
|
||||
fn = repo.get_conversion(
|
||||
(int,),
|
||||
consumer,
|
||||
path_strategy=PathStrategy.CUSTOM(my_strategy)
|
||||
)
|
||||
```
|
||||
|
||||
#### Сильные стороны
|
||||
| + | Описание |
|
||||
|---|----------|
|
||||
| **Гибкость** | Разные стратегии для разных случаев |
|
||||
| **Расширяемость** | Пользовательские стратегии |
|
||||
| **Явность** | Стратегия видна в коде вызова |
|
||||
| **Переиспользование** | Стратегии можно переиспользовать |
|
||||
|
||||
#### Слабые стороны
|
||||
| - | Описание |
|
||||
|---|----------|
|
||||
| **Сложность API** | 5+ стратегий для изучения |
|
||||
| **Не решает комбинаторный взрыв** | Стратегия применяется после генерации |
|
||||
| **Производительность** | Некоторые стратегии дорогие |
|
||||
|
||||
#### Оценка сложности
|
||||
- **Код**: ~200 строк изменений
|
||||
- **Тесты**: ~20 новых тестов
|
||||
- **Риск**: Средний
|
||||
|
||||
---
|
||||
|
||||
### Вариант 4: Ограничение глубины графа (Depth Limiting)
|
||||
|
||||
#### Описание
|
||||
Ограничить максимальную глубину/сложность графа преобразований.
|
||||
|
||||
#### Реализация
|
||||
```python
|
||||
repo = ConvRepo(
|
||||
max_depth=5, # Максимум 5 преобразований в цепочке
|
||||
max_branches=10, # Максимум ветвей в узле
|
||||
max_total_paths=100 # Максимум путей для рассмотрения
|
||||
)
|
||||
|
||||
# Или при вызове
|
||||
fn = repo.get_conversion(
|
||||
(int,),
|
||||
consumer,
|
||||
max_depth=3 # Переопределение для конкретного вызова
|
||||
)
|
||||
```
|
||||
|
||||
#### Сильные стороны
|
||||
| + | Описание |
|
||||
|---|----------|
|
||||
| **Защита от взрыва** | Гарантированная верхняя граница сложности |
|
||||
| **Производительность** | Предсказуемое время выполнения |
|
||||
| **Простота** | Один параметр для настройки |
|
||||
|
||||
#### Слабые стороны
|
||||
| - | Описание |
|
||||
|---|----------|
|
||||
| **Ограничения** | Может отсечь валидные пути |
|
||||
| **Не детерминизм** | Не решает проблему выбора пути |
|
||||
| **Настройка** | Нужно подбирать значения |
|
||||
|
||||
#### Оценка сложности
|
||||
- **Код**: ~80 строк изменений
|
||||
- **Тесты**: ~8 новых тестов
|
||||
- **Риск**: Низкий
|
||||
|
||||
---
|
||||
|
||||
### Вариант 5: Кэширование графов (Graph Caching)
|
||||
|
||||
#### Описание
|
||||
Кэшировать построенные графы преобразований для повторного использования.
|
||||
|
||||
#### Реализация
|
||||
```python
|
||||
from breakshaft import LRUCache, PersistentCache
|
||||
|
||||
# Кэш в памяти (LRU)
|
||||
repo = ConvRepo(cache=LRUCache(max_size=1000))
|
||||
|
||||
# Персистентный кэш (на диске)
|
||||
repo = ConvRepo(cache=PersistentCache(path=".breakshaft_cache"))
|
||||
|
||||
# Декоратор для кэширования
|
||||
@repo.cached
|
||||
def get_converter(from_types, to_type):
|
||||
return repo.get_conversion(from_types, to_type)
|
||||
```
|
||||
|
||||
#### Сильные стороны
|
||||
| + | Описание |
|
||||
|---|----------|
|
||||
| **Производительность** | Повторные вызовы мгновенные |
|
||||
| **Масштабируемость** | Работает с большим числом инжекторов |
|
||||
| **Прозрачность** | Кэш прозрачен для пользователя |
|
||||
|
||||
#### Слабые стороны
|
||||
| - | Описание |
|
||||
|---|----------|
|
||||
| **Память** | Кэш потребляет память |
|
||||
| **Инвалидация** | Сложность при изменении инжекторов |
|
||||
| **Не решает выбор пути** | Кэширует выбранный путь, но не детерминирует выбор |
|
||||
|
||||
#### Оценка сложности
|
||||
- **Код**: ~250 строк изменений
|
||||
- **Тесты**: ~25 новых тестов
|
||||
- **Риск**: Высокий (состояние, гонки)
|
||||
|
||||
---
|
||||
|
||||
### Вариант 6: Статический анализ графа (Static Graph Analysis)
|
||||
|
||||
#### Описание
|
||||
Анализировать граф на этапе регистрации инжекторов, обнаруживать проблемы заранее.
|
||||
|
||||
#### Реализация
|
||||
```python
|
||||
# Предварительный анализ
|
||||
repo = ConvRepo(validate_on_register=True)
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a_v1(i: int) -> A:
|
||||
return A(i * 10)
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a_v2(i: int) -> A:
|
||||
return A(i + 100) # Warning: Ambiguous path detected!
|
||||
|
||||
# Явная валидация
|
||||
warnings = repo.validate()
|
||||
for w in warnings:
|
||||
print(f"Warning: {w}")
|
||||
# Warning: Ambiguous path for A: 2 injectors found
|
||||
|
||||
# Разрешение конфликта
|
||||
repo.resolve_ambiguity(A, preferred=int_to_a_v1)
|
||||
```
|
||||
|
||||
#### Сильные стороны
|
||||
| + | Описание |
|
||||
|---|----------|
|
||||
| **Раннее обнаружение** | Ошибки на этапе регистрации |
|
||||
| **Документированность** | Явное разрешение конфликтов |
|
||||
| **Безопасность** | Невозможно создать неоднозначность |
|
||||
|
||||
#### Слабые стороны
|
||||
| - | Описание |
|
||||
|---|----------|
|
||||
| **Сложность** | Анализ графа дорог |
|
||||
| **Жёсткость** | Может быть слишком ограничительно |
|
||||
| **Не решает комбинаторный взрыв** | Анализ добавляет overhead |
|
||||
|
||||
#### Оценка сложности
|
||||
- **Код**: ~300 строк изменений
|
||||
- **Тесты**: ~30 новых тестов
|
||||
- **Риск**: Высокий
|
||||
|
||||
---
|
||||
|
||||
### Вариант 7: Версионирование путей (Path Versioning)
|
||||
|
||||
#### Описание
|
||||
Каждый путь имеет версию, можно выбирать конкретную версию.
|
||||
|
||||
#### Реализация
|
||||
```python
|
||||
@repo.mark_injector(version="1.0")
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i * 10)
|
||||
|
||||
@repo.mark_injector(version="2.0") # Новая версия
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i + 100)
|
||||
|
||||
# Выбор версии
|
||||
fn = repo.get_conversion(
|
||||
(int,),
|
||||
consumer,
|
||||
path_version="1.0" # Использовать старую версию
|
||||
)
|
||||
|
||||
# Или диапазон
|
||||
fn = repo.get_conversion(
|
||||
(int,),
|
||||
consumer,
|
||||
path_version=">=1.0,<3.0"
|
||||
)
|
||||
```
|
||||
|
||||
#### Сильные стороны
|
||||
| + | Описание |
|
||||
|---|----------|
|
||||
| **Эволюция** | Плавный переход между версиями |
|
||||
| **Совместимость** | Старый код продолжает работать |
|
||||
| **Контроль** | Явный выбор версии |
|
||||
|
||||
#### Слабые стороны
|
||||
| - | Описание |
|
||||
|---|----------|
|
||||
| **Сложность** | Управление версиями |
|
||||
| **Бойлерплейт** | Версии для каждого пути |
|
||||
| **Не решает комбинаторный взрыв** | Все версии генерируются |
|
||||
|
||||
#### Оценка сложности
|
||||
- **Код**: ~200 строк изменений
|
||||
- **Тесты**: ~20 новых тестов
|
||||
- **Риск**: Средний
|
||||
|
||||
---
|
||||
|
||||
### Вариант 8: Комбинированный подход (Hybrid Solution)
|
||||
|
||||
#### Описание
|
||||
Комбинация нескольких подходов для максимального эффекта.
|
||||
|
||||
#### Реализация
|
||||
```python
|
||||
repo = ConvRepo(
|
||||
# Приоритеты по умолчанию
|
||||
default_priority=0,
|
||||
|
||||
# Кэширование
|
||||
cache=LRUCache(max_size=500),
|
||||
|
||||
# Ограничения
|
||||
max_depth=10,
|
||||
max_total_paths=1000,
|
||||
|
||||
# Стратегия по умолчанию
|
||||
default_strategy=PathStrategy.SHORTEST,
|
||||
|
||||
# Валидация
|
||||
validate_on_register=True,
|
||||
)
|
||||
|
||||
# Гибкое переопределение
|
||||
fn = repo.get_conversion(
|
||||
(int,),
|
||||
consumer,
|
||||
priority_override={int_to_a_v1: 10}, # Приоритет для конкретных
|
||||
strategy=PathStrategy.EXPLICIT,
|
||||
cache_key="my_custom_key"
|
||||
)
|
||||
```
|
||||
|
||||
#### Сильные стороны
|
||||
| + | Описание |
|
||||
|---|----------|
|
||||
| **Максимальная гибкость** | Все инструменты доступны |
|
||||
| **Масштабируемость** | Работает с большими графами |
|
||||
| **Контроль** | Полный контроль над поведением |
|
||||
|
||||
#### Слабые стороны
|
||||
| - | Описание |
|
||||
|---|----------|
|
||||
| **Сложность** | Много параметров для настройки |
|
||||
| **Обучение** | Крутая кривая обучения |
|
||||
| **Риск ошибок** | Неправильная конфигурация |
|
||||
|
||||
#### Оценка сложности
|
||||
- **Код**: ~500 строк изменений
|
||||
- **Тесты**: ~50 новых тестов
|
||||
- **Риск**: Высокий
|
||||
|
||||
---
|
||||
|
||||
### Вариант 9: Декларативное описание графа (Declarative Graph)
|
||||
|
||||
#### Описание
|
||||
Полностью декларативное описание путей преобразования вместо автоматического вывода.
|
||||
|
||||
#### Реализация
|
||||
```python
|
||||
# Декларативное описание
|
||||
repo.define_graph({
|
||||
"paths": [
|
||||
{
|
||||
"name": "multiply_path",
|
||||
"steps": [
|
||||
{"from": int, "to": A, "using": int_to_a_mult},
|
||||
{"from": A, "to": B, "using": a_to_b},
|
||||
],
|
||||
"priority": 10
|
||||
},
|
||||
{
|
||||
"name": "add_path",
|
||||
"steps": [
|
||||
{"from": int, "to": A, "using": int_to_a_add},
|
||||
],
|
||||
"priority": 1
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
# Выбор пути по имени
|
||||
fn = repo.get_conversion(
|
||||
(int,),
|
||||
consumer,
|
||||
path_name="multiply_path"
|
||||
)
|
||||
```
|
||||
|
||||
#### Сильные стороны
|
||||
| + | Описание |
|
||||
|---|----------|
|
||||
| **Полный контроль** | Явное описание всех путей |
|
||||
| **Детерминизм** | Никакой неявной логики |
|
||||
| **Документированность** | Граф виден в коде |
|
||||
| **Нет комбинаторного взрыва** | Только явные пути |
|
||||
|
||||
#### Слабые стороны
|
||||
| - | Описание |
|
||||
|---|----------|
|
||||
| **Бойлерплейт** | Много кода для описания |
|
||||
| **Потеря автоматизма** | Нет автоматического вывода путей |
|
||||
| **Сложность поддержки** | Изменение графа требует правки описания |
|
||||
|
||||
#### Оценка сложности
|
||||
- **Код**: ~400 строк изменений
|
||||
- **Тесты**: ~40 новых тестов
|
||||
- **Риск**: Высокий (меняет парадигму)
|
||||
|
||||
---
|
||||
|
||||
### Вариант 10: Машинное обучение для выбора пути (ML-Based Selection)
|
||||
|
||||
#### Описание
|
||||
Использовать ML для предсказания "лучшего" пути на основе истории использования.
|
||||
|
||||
#### Реализация
|
||||
```python
|
||||
from breakshaft import MLPathSelector
|
||||
|
||||
repo = ConvRepo(
|
||||
path_selector=MLPathSelector(
|
||||
training_data="usage_history.json",
|
||||
features=["execution_time", "memory_usage", "success_rate"]
|
||||
)
|
||||
)
|
||||
|
||||
# ML выбирает путь на основе:
|
||||
# - Истории успешных выполнений
|
||||
# - Времени выполнения
|
||||
# - Потребления памяти
|
||||
# - Контекста (типы, размеры данных)
|
||||
|
||||
fn = repo.get_conversion((int,), consumer)
|
||||
# Путь выбирается автоматически на основе модели
|
||||
```
|
||||
|
||||
#### Сильные стороны
|
||||
| + | Описание |
|
||||
|---|----------|
|
||||
| **Адаптивность** | Учится на использовании |
|
||||
| **Оптимизация** | Выбирает эффективные пути |
|
||||
| **Автоматизм** | Не требует ручной настройки |
|
||||
|
||||
#### Слабые стороны
|
||||
| - | Описание |
|
||||
|---|----------|
|
||||
| **Сложность** | ML модель + обучение |
|
||||
| **Непредсказуемость** | ML может выбрать неожиданно |
|
||||
| **Зависимость от данных** | Нужна история для обучения |
|
||||
| **Overhead** | Предсказание модели |
|
||||
|
||||
#### Оценка сложности
|
||||
- **Код**: ~600 строк изменений
|
||||
- **Тесты**: ~60 новых тестов
|
||||
- **Риск**: Очень высокий
|
||||
|
||||
---
|
||||
|
||||
## 4. Сравнительная таблица
|
||||
|
||||
| Вариант | Детерминизм | Производительность | Сложность | Обратная совместимость | Риск |
|
||||
|---------|-------------|-------------------|-----------|----------------------|------|
|
||||
| **1. Приоритеты** | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ | Низкий |
|
||||
| **2. Именованные пути** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | Средний |
|
||||
| **3. Стратегии** | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | Средний |
|
||||
| **4. Ограничение глубины** | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ | Низкий |
|
||||
| **5. Кэширование** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Высокий |
|
||||
| **6. Статический анализ** | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | Высокий |
|
||||
| **7. Версионирование** | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | Средний |
|
||||
| **8. Комбинированный** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | Высокий |
|
||||
| **9. Декларативный** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | Высокий |
|
||||
| **10. ML** | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Очень высокий |
|
||||
|
||||
### Легенда:
|
||||
- ⭐⭐⭐⭐⭐ — Отлично
|
||||
- ⭐⭐⭐⭐ — Хорошо
|
||||
- ⭐⭐⭐ — Удовлетворительно
|
||||
- ⭐⭐ — Плохо
|
||||
- ⭐ — Очень плохо
|
||||
|
||||
---
|
||||
|
||||
## 5. Рекомендации
|
||||
|
||||
### 5.1. Краткосрочные решения (быстрая победа)
|
||||
|
||||
**Вариант 1 + Вариант 4**: Приоритеты + Ограничение глубины
|
||||
|
||||
```python
|
||||
# Минимальные изменения для детерминизма
|
||||
@repo.mark_injector(priority=10)
|
||||
def preferred_converter(...): ...
|
||||
|
||||
repo = ConvRepo(max_depth=10)
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- ~130 строк кода
|
||||
- Низкий риск
|
||||
- Обратная совместимость
|
||||
- Решает 80% проблем
|
||||
|
||||
### 5.2. Среднесрочные решения (баланс)
|
||||
|
||||
**Вариант 3 + Вариант 5**: Стратегии + Кэширование
|
||||
|
||||
```python
|
||||
repo = ConvRepo(
|
||||
cache=LRUCache(1000),
|
||||
default_strategy=PathStrategy.SHORTEST
|
||||
)
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- Хорошая производительность
|
||||
- Гибкость для пользователей
|
||||
- Решает проблему комбинаторного взрыва
|
||||
|
||||
### 5.3. Долгосрочные решения (полное решение)
|
||||
|
||||
**Вариант 8 (Комбинированный) с элементами Варианта 9**
|
||||
|
||||
```python
|
||||
repo = HybridConvRepo(
|
||||
priorities=True,
|
||||
strategies=True,
|
||||
cache=True,
|
||||
validation=True,
|
||||
declarative_mode=False # Опционально
|
||||
)
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- Полное решение проблемы
|
||||
- Масштабируемость
|
||||
- Гибкость
|
||||
|
||||
### 5.4. Дорожная карта
|
||||
|
||||
```
|
||||
Фаза 1 (2 недели):
|
||||
├── Приоритеты инжекторов
|
||||
├── Ограничение глубины
|
||||
└── Тесты
|
||||
|
||||
Фаза 2 (4 недели):
|
||||
├── Стратегии выбора пути
|
||||
├── Базовое кэширование
|
||||
└── Документация
|
||||
|
||||
Фаза 3 (6 недель):
|
||||
├── Статический анализ
|
||||
├── Продвинутое кэширование
|
||||
├── Декларативный режим (опционально)
|
||||
└── Полное тестирование
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Заключение
|
||||
|
||||
### 6.1. Выводы
|
||||
|
||||
1. **Нет серебряной пули** — каждый вариант имеет компромиссы
|
||||
2. **Комбинированный подход** даёт лучший результат
|
||||
3. **Начинать с простого** — приоритеты + ограничения
|
||||
4. **Итеративное улучшение** — добавлять функции постепенно
|
||||
|
||||
### 6.2. Риски
|
||||
|
||||
| Риск | Вероятность | Влияние | Митигация |
|
||||
|------|-------------|---------|-----------|
|
||||
| Ломает обратную совместимость | Низкая | Высокое | Поэтапное внедрение |
|
||||
| Усложнение API | Средняя | Среднее | Хорошая документация |
|
||||
| Производительность | Низкая | Высокое | Бенчмарки на каждом этапе |
|
||||
| Комбинаторный взрыв | Средняя | Высокое | Ограничения + кэш |
|
||||
|
||||
### 6.3. Следующие шаги
|
||||
|
||||
1. **Выбрать подход** для Фазы 1
|
||||
2. **Создать PR** с приоритетами и ограничениями
|
||||
3. **Собрать фидбэк** от пользователей
|
||||
4. **Итеративно улучшать**
|
||||
|
||||
---
|
||||
|
||||
*Документ создан для breakshaft v0.1.6*
|
||||
*Дата: 2026-03-28*
|
||||
230
ERROR_DESIGN.md
Normal file
230
ERROR_DESIGN.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Проектирование системы обработки ошибок для breakshaft
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас библиотека использует только `ValueError` и `TypeError` с краткими сообщениями. Это затрудняет отладку, особенно при использовании библиотеки как системы внедрения зависимостей.
|
||||
|
||||
### Существующие ошибки:
|
||||
1. `ValueError: Function ... provided as injector, but return-type is not specified`
|
||||
2. `ValueError: All callgraph subgraphs must be solved for callseq generation`
|
||||
3. `ValueError: Unable to compute conversion graph on ...`
|
||||
4. `ValueError: Unable to select conversion path`
|
||||
5. `ValueError: Conversion path is not commutative`
|
||||
6. `TypeError: Param ... must be type-annotated`
|
||||
|
||||
---
|
||||
|
||||
## Категории ошибок
|
||||
|
||||
### 1. Ошибки регистрации инжекторов (Injector Registration Errors)
|
||||
|
||||
| Код | Название | Описание |
|
||||
|-----|----------|----------|
|
||||
| `INJECTOR_001` | MissingReturnType | У функции-инжектора не указан тип возврата |
|
||||
| `INJECTOR_002` | MissingParamType | У параметра инжектора не указан тип |
|
||||
| `INJECTOR_003` | CircularDependency | Обнаружена циклическая зависимость при регистрации |
|
||||
| `INJECTOR_004` | DuplicateInjector | Зарегистрировано несколько одинаковых инжекторов |
|
||||
| `INJECTOR_005` | InvalidInjectorSignature | Некорректная сигнатура функции-инжектора |
|
||||
|
||||
### 2. Ошибки построения графа (Graph Construction Errors)
|
||||
|
||||
| Код | Название | Описание |
|
||||
|-----|----------|----------|
|
||||
| `GRAPH_001` | NoConversionPath | Невозможно построить путь преобразования между типами |
|
||||
| `GRAPH_002` | AmbiguousPath | Найдено несколько путей преобразования (некоммутативность) |
|
||||
| `GRAPH_003` | CycleDetected | Обнаружен цикл в графе преобразований |
|
||||
| `GRAPH_004` | TypeMismatch | Тип аргумента не соответствует ожидаемому |
|
||||
| `GRAPH_005` | MissingDependency | Зависимость не может быть удовлетворена |
|
||||
|
||||
### 3. Ошибки генерации кода (Code Generation Errors)
|
||||
|
||||
| Код | Название | Описание |
|
||||
|-----|----------|----------|
|
||||
| `CODEGEN_001` | TemplateRenderError | Ошибка при рендеринге Jinja2-шаблона |
|
||||
| `CODEGEN_002` | InvalidGeneratedCode | Сгенерированный код некорректен |
|
||||
| `CODEGEN_003` | NameCollision | Конфликт имён в сгенерированном коде |
|
||||
|
||||
### 4. Ошибки выполнения (Runtime Errors)
|
||||
|
||||
| Код | Название | Описание |
|
||||
|-----|----------|----------|
|
||||
| `RUNTIME_001` | InjectorCallFailed | Ошибка при вызове функции-инжектора |
|
||||
| `RUNTIME_002` | ContextManagerError | Ошибка при входе/выходе из контекст-менеджера |
|
||||
| `RUNTIME_003` | AsyncExecutionError | Ошибка при выполнении асинхронной операции |
|
||||
|
||||
### 5. Ошибки конфигурации (Configuration Errors)
|
||||
|
||||
| Код | Название | Описание |
|
||||
|-----|----------|----------|
|
||||
| `CONFIG_001` | InvalidOptions | Некорректные опции (force_commutative + allow_async и т.д.) |
|
||||
| `CONFIG_002` | IncompatibleSettings | Несовместимые настройки |
|
||||
|
||||
---
|
||||
|
||||
## Иерархия исключений
|
||||
|
||||
```
|
||||
BreakshaftError (базовое)
|
||||
├── InjectorError
|
||||
│ ├── MissingReturnType
|
||||
│ ├── MissingParamType
|
||||
│ ├── CircularDependency
|
||||
│ ├── DuplicateInjector
|
||||
│ └── InvalidInjectorSignature
|
||||
├── GraphError
|
||||
│ ├── NoConversionPath
|
||||
│ ├── AmbiguousPath
|
||||
│ ├── CycleDetected
|
||||
│ ├── TypeMismatch
|
||||
│ └── MissingDependency
|
||||
├── CodegenError
|
||||
│ ├── TemplateRenderError
|
||||
│ ├── InvalidGeneratedCode
|
||||
│ └── NameCollision
|
||||
├── RuntimeError
|
||||
│ ├── InjectorCallFailed
|
||||
│ ├── ContextManagerError
|
||||
│ └── AsyncExecutionError
|
||||
└── ConfigurationError
|
||||
├── InvalidOptions
|
||||
└── IncompatibleSettings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Детальное описание ошибок с примерами
|
||||
|
||||
### INJECTOR_001: MissingReturnType
|
||||
```python
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int): # Нет -> A
|
||||
return A(i)
|
||||
|
||||
# Ошибка: INJECTOR_001: Function 'int_to_a' missing return type annotation
|
||||
# Решение: Добавить аннотацию возврата: def int_to_a(i: int) -> A:
|
||||
```
|
||||
|
||||
### INJECTOR_002: MissingParamType
|
||||
```python
|
||||
@repo.mark_injector()
|
||||
def convert(value) -> A: # Нет типа у параметра
|
||||
return A(value)
|
||||
|
||||
# Ошибка: INJECTOR_002: Parameter 'value' missing type annotation
|
||||
# Решение: Добавить аннотацию: def convert(value: int) -> A:
|
||||
```
|
||||
|
||||
### INJECTOR_003: CircularDependency
|
||||
```python
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B: ...
|
||||
|
||||
@repo.mark_injector()
|
||||
def b_to_c(b: B) -> C: ...
|
||||
|
||||
@repo.mark_injector()
|
||||
def c_to_a(c: C) -> A: ... # Замыкает цикл
|
||||
|
||||
# Ошибка: INJECTOR_003: Circular dependency detected: A -> B -> C -> A
|
||||
# Решение: Разорвать цикл или использовать force_commutative=False
|
||||
```
|
||||
|
||||
### GRAPH_001: NoConversionPath
|
||||
```python
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A: ...
|
||||
|
||||
def consumer(dep: B) -> str: ... # B нельзя получить из int
|
||||
|
||||
repo.get_conversion((int,), consumer)
|
||||
|
||||
# Ошибка: GRAPH_001: No conversion path from (int,) to consumer
|
||||
# Доступные типы: {A}
|
||||
# Требуемые типы: {B}
|
||||
# Решение: Добавить инжектор для получения B
|
||||
```
|
||||
|
||||
### GRAPH_002: AmbiguousPath
|
||||
```python
|
||||
@repo.mark_injector()
|
||||
def int_to_a_direct(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_b(i: int) -> B:
|
||||
return B(float(i))
|
||||
|
||||
@repo.mark_injector()
|
||||
def b_to_a(b: B) -> A:
|
||||
return A(int(b.b))
|
||||
|
||||
def consumer(dep: A) -> int: ...
|
||||
|
||||
repo.get_conversion((int,), consumer, force_commutative=True)
|
||||
# Два пути: int->A и int->B->A дают разные результаты
|
||||
|
||||
# Ошибка: GRAPH_002: Ambiguous conversion path (non-commutative graph)
|
||||
# Путь 1: int -> A (прямой)
|
||||
# Путь 2: int -> B -> A (через B)
|
||||
# Решение: Использовать force_commutative=False или убрать один из путей
|
||||
```
|
||||
|
||||
### GRAPH_004: TypeMismatch
|
||||
```python
|
||||
@repo.mark_injector()
|
||||
def convert(s: str) -> A: ...
|
||||
|
||||
repo.get_conversion((int,), ...) # int нельзя преобразовать в str
|
||||
|
||||
# Ошибка: GRAPH_004: Type mismatch - expected str, got int
|
||||
```
|
||||
|
||||
### CONFIG_001: InvalidOptions
|
||||
```python
|
||||
repo.get_conversion(..., allow_async=False, force_async=True)
|
||||
|
||||
# Ошибка: CONFIG_001: Invalid options - force_async=True but allow_async=False
|
||||
# Решение: Установить allow_async=True или force_async=False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Формат сообщений об ошибках
|
||||
|
||||
Каждое сообщение должно содержать:
|
||||
1. **Код ошибки** (для поиска в документации)
|
||||
2. **Краткое описание** (что произошло)
|
||||
3. **Контекст** (где произошло, какие типы задействованы)
|
||||
4. **Подсказку** (как исправить)
|
||||
|
||||
### Пример формата:
|
||||
```
|
||||
BreakshaftError [GRAPH_001]: No conversion path found
|
||||
|
||||
Cannot build conversion from source types to target function.
|
||||
|
||||
Context:
|
||||
Source types: (int, float)
|
||||
Target function: my_module.consumer
|
||||
Required types: {A, B}
|
||||
Available types: {A, C, D}
|
||||
Missing types: {B}
|
||||
|
||||
Suggestions:
|
||||
1. Add an injector that produces type 'B'
|
||||
2. Check if type annotations are correct
|
||||
3. Use force_commutative=False if multiple paths are expected
|
||||
|
||||
Documentation: https://breakshaft.readthedocs.io/errors/GRAPH_001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## План реализации
|
||||
|
||||
1. **Создать модуль `exceptions.py`** с иерархией исключений
|
||||
2. **Добавить базовый класс `BreakshaftError`** с форматированием сообщений
|
||||
3. **Заменить все `raise ValueError`** на специфичные исключения
|
||||
4. **Добавить дополнительные проверки** (валидация на входе)
|
||||
5. **Написать тесты** для всех типов ошибок
|
||||
6. **Добавить документацию** по ошибкам
|
||||
@@ -1 +1,89 @@
|
||||
"""
|
||||
breakshaft - библиотека для генерации преобразований типов на лету.
|
||||
|
||||
Основное использование:
|
||||
from breakshaft import ConvRepo
|
||||
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
fn = repo.get_conversion((int,), consumer_function)
|
||||
|
||||
Исключения:
|
||||
from breakshaft import (
|
||||
BreakshaftError,
|
||||
NoConversionPath,
|
||||
AmbiguousPath,
|
||||
MissingReturnType,
|
||||
# ... другие исключения
|
||||
)
|
||||
"""
|
||||
|
||||
from .convertor import ConvRepo
|
||||
from .graph_walker import GraphWalker
|
||||
from .models import ConversionPoint, Callgraph, CallgraphVariant, TransformationPoint
|
||||
from .exceptions import (
|
||||
BreakshaftError,
|
||||
BreakshaftRuntimeError,
|
||||
InjectorError,
|
||||
MissingReturnType,
|
||||
MissingParamType,
|
||||
CircularDependency,
|
||||
DuplicateInjector,
|
||||
InvalidInjectorSignature,
|
||||
GraphError,
|
||||
NoConversionPath,
|
||||
AmbiguousPath,
|
||||
CycleDetected,
|
||||
TypeMismatch,
|
||||
MissingDependency,
|
||||
CodegenError,
|
||||
TemplateRenderError,
|
||||
InvalidGeneratedCode,
|
||||
NameCollision,
|
||||
InjectorCallFailed,
|
||||
ContextManagerError,
|
||||
AsyncExecutionError,
|
||||
ConfigurationError,
|
||||
InvalidOptions,
|
||||
IncompatibleSettings,
|
||||
)
|
||||
|
||||
__version__ = "0.1.6.post5"
|
||||
__all__ = [
|
||||
# Основные классы
|
||||
"ConvRepo",
|
||||
"GraphWalker",
|
||||
"ConversionPoint",
|
||||
"Callgraph",
|
||||
"CallgraphVariant",
|
||||
"TransformationPoint",
|
||||
# Исключения
|
||||
"BreakshaftError",
|
||||
"BreakshaftRuntimeError",
|
||||
"InjectorError",
|
||||
"MissingReturnType",
|
||||
"MissingParamType",
|
||||
"CircularDependency",
|
||||
"DuplicateInjector",
|
||||
"InvalidInjectorSignature",
|
||||
"GraphError",
|
||||
"NoConversionPath",
|
||||
"AmbiguousPath",
|
||||
"CycleDetected",
|
||||
"TypeMismatch",
|
||||
"MissingDependency",
|
||||
"CodegenError",
|
||||
"TemplateRenderError",
|
||||
"InvalidGeneratedCode",
|
||||
"NameCollision",
|
||||
"InjectorCallFailed",
|
||||
"ContextManagerError",
|
||||
"AsyncExecutionError",
|
||||
"ConfigurationError",
|
||||
"InvalidOptions",
|
||||
"IncompatibleSettings",
|
||||
]
|
||||
|
||||
@@ -6,7 +6,13 @@ from typing import Optional, Callable, Unpack, TypeVarTuple, TypeVar, Awaitable,
|
||||
from .graph_walker import GraphWalker
|
||||
from .models import ConversionPoint, Callgraph
|
||||
from .renderer import ConvertorRenderer, InTimeGenerationConvertorRenderer
|
||||
from .util import extract_return_type, universal_qualname
|
||||
from .util import extract_return_type, extract_func_argtypes, universal_qualname
|
||||
from .exceptions import (
|
||||
NoConversionPath,
|
||||
AmbiguousPath,
|
||||
InvalidOptions,
|
||||
MissingDependency,
|
||||
)
|
||||
|
||||
Tin = TypeVarTuple('Tin')
|
||||
Tout = TypeVar('Tout')
|
||||
@@ -118,16 +124,47 @@ class ConvRepo:
|
||||
|
||||
cg = self.walker.generate_callgraph(injectors, from_types, fn)
|
||||
if cg is None:
|
||||
raise ValueError(f'Unable to compute conversion graph on {from_types}->{universal_qualname(fn)}')
|
||||
# Собираем информацию о доступных типах
|
||||
available_types = set()
|
||||
for inj in injectors:
|
||||
available_types.add(inj.injects)
|
||||
available_types.update(inj.requires)
|
||||
|
||||
# Определяем требуемые типы
|
||||
required_types = set()
|
||||
if callable(fn):
|
||||
required_types = extract_func_argtypes(fn)
|
||||
|
||||
raise NoConversionPath(
|
||||
from_types=tuple(from_types),
|
||||
target=fn,
|
||||
available_types=available_types,
|
||||
required_types=required_types,
|
||||
)
|
||||
|
||||
exploded = self.walker.explode_callgraph_branches(cg, from_types)
|
||||
|
||||
selected = self.walker.filter_exploded_callgraph_branch(exploded)
|
||||
if len(selected) == 0:
|
||||
raise ValueError('Unable to select conversion path')
|
||||
raise NoConversionPath(
|
||||
from_types=tuple(from_types),
|
||||
target=fn,
|
||||
available_types=set(inj.injects for inj in injectors),
|
||||
required_types=set(),
|
||||
)
|
||||
|
||||
if force_commutative and len(selected) > 1:
|
||||
raise ValueError('Conversion path is not commutative')
|
||||
# Собираем информацию о путях
|
||||
paths = []
|
||||
for variant in selected:
|
||||
path = self._get_path_from_variant(variant)
|
||||
paths.append(path)
|
||||
|
||||
raise AmbiguousPath(
|
||||
from_types=tuple(from_types),
|
||||
target=fn,
|
||||
paths=paths,
|
||||
)
|
||||
|
||||
callseq = self._callseq_from_callgraph(Callgraph(frozenset([selected[0]])))
|
||||
|
||||
@@ -145,6 +182,16 @@ class ConvRepo:
|
||||
|
||||
return callseq
|
||||
|
||||
def _get_path_from_variant(self, variant) -> list[str]:
|
||||
"""Извлекает путь преобразований из варианта графа."""
|
||||
path = []
|
||||
if hasattr(variant, 'injector'):
|
||||
path.append(variant.injector.fn.__qualname__)
|
||||
for subg in variant.subgraphs:
|
||||
sub_path = self._get_path_from_variant(subg)
|
||||
path.extend(sub_path)
|
||||
return path
|
||||
|
||||
def get_conversion(self,
|
||||
from_types: Sequence[type[Unpack[Tin]]],
|
||||
fn: Callable[..., Tout] | Iterable[ConversionPoint] | ConversionPoint,
|
||||
@@ -154,8 +201,22 @@ class ConvRepo:
|
||||
force_async: bool = False
|
||||
) -> Callable[[Unpack[Tin]], Tout] | Awaitable[Callable[[Unpack[Tin]], Tout]]:
|
||||
|
||||
# Валидация опций
|
||||
if force_async and not allow_async:
|
||||
raise InvalidOptions(
|
||||
option_name="force_async",
|
||||
option_value=True,
|
||||
reason="force_async=True requires allow_async=True"
|
||||
)
|
||||
|
||||
filtered_injectors = self.filtered_injectors(allow_async, allow_sync)
|
||||
callseq = self.get_callseq(filtered_injectors, frozenset(from_types), fn, force_commutative)
|
||||
|
||||
callseq = self.get_callseq(
|
||||
filtered_injectors,
|
||||
frozenset(from_types),
|
||||
fn,
|
||||
force_commutative
|
||||
)
|
||||
|
||||
ret_fn = self.renderer.render(from_types, callseq, force_async=force_async, store_sources=self.store_sources)
|
||||
if self.store_callseq:
|
||||
|
||||
557
src/breakshaft/exceptions.py
Normal file
557
src/breakshaft/exceptions.py
Normal file
@@ -0,0 +1,557 @@
|
||||
"""
|
||||
Система обработки ошибок для breakshaft.
|
||||
|
||||
Модуль предоставляет иерархию исключений для детальной обработки ошибок
|
||||
при использовании библиотеки как системы внедрения зависимостей.
|
||||
|
||||
Пример использования:
|
||||
from breakshaft.exceptions import GraphError, NoConversionPath
|
||||
|
||||
try:
|
||||
fn = repo.get_conversion((int,), consumer)
|
||||
except NoConversionPath as e:
|
||||
print(f"Ошибка: {e}")
|
||||
print(f"Доступные типы: {e.available_types}")
|
||||
print(f"Требуемые типы: {e.required_types}")
|
||||
"""
|
||||
|
||||
from typing import Optional, Set, Any, Callable
|
||||
|
||||
|
||||
class BreakshaftError(Exception):
|
||||
"""
|
||||
Базовое исключение для всех ошибок breakshaft.
|
||||
|
||||
Attributes:
|
||||
code: Код ошибки (например, 'GRAPH_001')
|
||||
message: Человекочитаемое описание ошибки
|
||||
context: Дополнительный контекст (типы, функции и т.д.)
|
||||
hint: Подсказка как исправить ошибку
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
context: Optional[dict[str, Any]] = None,
|
||||
hint: Optional[str] = None,
|
||||
):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.context = context or {}
|
||||
self.hint = hint
|
||||
|
||||
super().__init__(self._format_message())
|
||||
|
||||
def _format_message(self) -> str:
|
||||
"""Форматирует полное сообщение об ошибке."""
|
||||
lines = [f"BreakshaftError [{self.code}]: {self.message}"]
|
||||
|
||||
if self.context:
|
||||
lines.append("\nContext:")
|
||||
for key, value in self.context.items():
|
||||
formatted_value = self._format_context_value(key, value)
|
||||
lines.append(f" {key}: {formatted_value}")
|
||||
|
||||
if self.hint:
|
||||
lines.append(f"\nHint: {self.hint}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_context_value(self, key: str, value: Any) -> str:
|
||||
"""Форматирует значение контекста для вывода."""
|
||||
if isinstance(value, (set, frozenset)):
|
||||
if len(value) == 0:
|
||||
return "{}"
|
||||
return "{" + ", ".join(self._type_name(v) for v in sorted(value, key=str)) + "}"
|
||||
elif isinstance(value, (tuple, list)):
|
||||
if len(value) == 0:
|
||||
return "()"
|
||||
return "(" + ", ".join(self._type_name(v) for v in value) + ")"
|
||||
elif callable(value):
|
||||
return getattr(value, '__qualname__', str(value))
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
def _type_name(self, t: Any) -> str:
|
||||
"""Возвращает читаемое имя типа."""
|
||||
if hasattr(t, '__name__'):
|
||||
return t.__name__
|
||||
return str(t)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ошибки регистрации инжекторов (INJECTOR_*)
|
||||
# =============================================================================
|
||||
|
||||
class InjectorError(BreakshaftError):
|
||||
"""Базовое исключение для ошибок регистрации инжекторов."""
|
||||
pass
|
||||
|
||||
|
||||
class MissingReturnType(InjectorError):
|
||||
"""
|
||||
У функции-инжектора не указан тип возврата.
|
||||
|
||||
Пример:
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int): # Нет -> A
|
||||
return A(i)
|
||||
|
||||
Решение: Добавить аннотацию возврата.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable):
|
||||
super().__init__(
|
||||
code="INJECTOR_001",
|
||||
message=f"Function '{func.__qualname__}' is missing return type annotation",
|
||||
context={"function": func},
|
||||
hint="Add a return type annotation: def func(...) -> ReturnType:",
|
||||
)
|
||||
|
||||
|
||||
class MissingParamType(InjectorError):
|
||||
"""
|
||||
У параметра функции-инжектора не указан тип.
|
||||
|
||||
Пример:
|
||||
@repo.mark_injector()
|
||||
def convert(value) -> A: # Нет типа у параметра
|
||||
return A(value)
|
||||
|
||||
Решение: Добавить аннотацию типа параметра.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable, param_name: str):
|
||||
super().__init__(
|
||||
code="INJECTOR_002",
|
||||
message=f"Parameter '{param_name}' of function '{func.__qualname__}' is missing type annotation",
|
||||
context={"function": func, "parameter": param_name},
|
||||
hint=f"Add type annotation: def {func.__name__}({param_name}: Type) -> ...:",
|
||||
)
|
||||
|
||||
|
||||
class CircularDependency(InjectorError):
|
||||
"""
|
||||
Обнаружена циклическая зависимость между инжекторами.
|
||||
|
||||
Пример:
|
||||
A -> B -> C -> A (цикл)
|
||||
|
||||
Решение: Разорвать цикл или использовать force_commutative=False.
|
||||
"""
|
||||
|
||||
def __init__(self, cycle: list[type]):
|
||||
cycle_str = " -> ".join(self._type_name(t) for t in cycle)
|
||||
super().__init__(
|
||||
code="INJECTOR_003",
|
||||
message=f"Circular dependency detected: {cycle_str}",
|
||||
context={"cycle": cycle},
|
||||
hint="Break the cycle by removing one of the injectors or use force_commutative=False",
|
||||
)
|
||||
|
||||
|
||||
class DuplicateInjector(InjectorError):
|
||||
"""
|
||||
Зарегистрировано несколько инжекторов с одинаковой сигнатурой.
|
||||
|
||||
Решение: Удалить дублирующийся инжектор или использовать fork().
|
||||
"""
|
||||
|
||||
def __init__(self, func1: Callable, func2: Callable, injects_type: type):
|
||||
super().__init__(
|
||||
code="INJECTOR_004",
|
||||
message=f"Duplicate injector for type '{injects_type.__name__}'",
|
||||
context={
|
||||
"injects_type": injects_type,
|
||||
"existing_function": func1,
|
||||
"new_function": func2,
|
||||
},
|
||||
hint="Remove the duplicate injector or use repo.fork() for separate contexts",
|
||||
)
|
||||
|
||||
|
||||
class InvalidInjectorSignature(InjectorError):
|
||||
"""
|
||||
Некорректная сигнатура функции-инжектора.
|
||||
|
||||
Пример:
|
||||
- Инжектор без параметров
|
||||
- Инжектор с *args/**kwargs
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable, reason: str):
|
||||
super().__init__(
|
||||
code="INJECTOR_005",
|
||||
message=f"Invalid injector signature for '{func.__qualname__}': {reason}",
|
||||
context={"function": func, "reason": reason},
|
||||
hint="Ensure the injector has proper type-annotated parameters",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ошибки построения графа (GRAPH_*)
|
||||
# =============================================================================
|
||||
|
||||
class GraphError(BreakshaftError):
|
||||
"""Базовое исключение для ошибок построения графа преобразований."""
|
||||
pass
|
||||
|
||||
|
||||
class NoConversionPath(GraphError):
|
||||
"""
|
||||
Невозможно построить путь преобразования между типами.
|
||||
|
||||
Пример:
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A: ...
|
||||
|
||||
def consumer(dep: B) -> str: ... # B нельзя получить из int
|
||||
|
||||
repo.get_conversion((int,), consumer) # Ошибка!
|
||||
|
||||
Решение: Добавить инжектор для получения B.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
from_types: tuple[type, ...],
|
||||
target: Callable,
|
||||
available_types: Set[type],
|
||||
required_types: Set[type],
|
||||
):
|
||||
missing = required_types - available_types
|
||||
super().__init__(
|
||||
code="GRAPH_001",
|
||||
message="No conversion path found",
|
||||
context={
|
||||
"source_types": from_types,
|
||||
"target_function": target,
|
||||
"available_types": available_types,
|
||||
"required_types": required_types,
|
||||
"missing_types": missing,
|
||||
},
|
||||
hint="Add an injector that produces the missing type(s)",
|
||||
)
|
||||
|
||||
|
||||
class AmbiguousPath(GraphError):
|
||||
"""
|
||||
Найдено несколько путей преобразования (некоммутативный граф).
|
||||
|
||||
Пример:
|
||||
int -> A (прямой, результат: A(42))
|
||||
int -> B -> A (через B, результат: A(42.0))
|
||||
|
||||
Решение: Использовать force_commutative=False.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
from_types: tuple[type, ...],
|
||||
target: Callable,
|
||||
paths: list[list[str]],
|
||||
):
|
||||
paths_str = "\n".join(f" Путь {i+1}: {' -> '.join(p)}" for i, p in enumerate(paths))
|
||||
super().__init__(
|
||||
code="GRAPH_002",
|
||||
message="Ambiguous conversion path (non-commutative graph)",
|
||||
context={
|
||||
"source_types": from_types,
|
||||
"target_function": target,
|
||||
"paths": paths,
|
||||
},
|
||||
hint=f"Multiple paths found:\n{paths_str}\nUse force_commutative=False to allow any path",
|
||||
)
|
||||
|
||||
|
||||
class CycleDetected(GraphError):
|
||||
"""
|
||||
Обнаружен цикл в графе преобразований при построении пути.
|
||||
|
||||
Отличается от INJECTOR_003 тем, что цикл обнаруживается при runtime,
|
||||
а не при регистрации.
|
||||
"""
|
||||
|
||||
def __init__(self, cycle: list[type], target: Callable):
|
||||
cycle_str = " -> ".join(t.__name__ for t in cycle)
|
||||
super().__init__(
|
||||
code="GRAPH_003",
|
||||
message=f"Cycle detected in conversion graph: {cycle_str}",
|
||||
context={"cycle": cycle, "target_function": target},
|
||||
hint="The algorithm handles cycles automatically, but consider simplifying the graph",
|
||||
)
|
||||
|
||||
|
||||
class TypeMismatch(GraphError):
|
||||
"""
|
||||
Тип аргумента не соответствует ожидаемому.
|
||||
|
||||
Пример:
|
||||
def convert(s: str) -> A: ...
|
||||
repo.get_conversion((int,), ...) # int != str
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
expected_type: type,
|
||||
actual_type: type,
|
||||
context_desc: str = "",
|
||||
):
|
||||
super().__init__(
|
||||
code="GRAPH_004",
|
||||
message=f"Type mismatch: expected {expected_type.__name__}, got {actual_type.__name__}",
|
||||
context={
|
||||
"expected_type": expected_type,
|
||||
"actual_type": actual_type,
|
||||
"description": context_desc,
|
||||
},
|
||||
hint="Check type annotations and ensure compatible types are used",
|
||||
)
|
||||
|
||||
|
||||
class MissingDependency(GraphError):
|
||||
"""
|
||||
Зависимость не может быть удовлетворена.
|
||||
|
||||
Пример:
|
||||
def consumer(a: A, b: B) -> int: ...
|
||||
# Есть инжектор для A, но нет для B
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dependency_type: type,
|
||||
consumer: Callable,
|
||||
available_types: Set[type],
|
||||
):
|
||||
super().__init__(
|
||||
code="GRAPH_005",
|
||||
message=f"Missing dependency: {dependency_type.__name__}",
|
||||
context={
|
||||
"dependency_type": dependency_type,
|
||||
"consumer_function": consumer,
|
||||
"available_types": available_types,
|
||||
},
|
||||
hint=f"Add an injector that produces type '{dependency_type.__name__}'",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ошибки генерации кода (CODEGEN_*)
|
||||
# =============================================================================
|
||||
|
||||
class CodegenError(BreakshaftError):
|
||||
"""Базовое исключение для ошибок генерации кода."""
|
||||
pass
|
||||
|
||||
|
||||
class TemplateRenderError(CodegenError):
|
||||
"""
|
||||
Ошибка при рендеринге Jinja2-шаблона.
|
||||
|
||||
Возникает при внутренних ошибках шаблона.
|
||||
"""
|
||||
|
||||
def __init__(self, template_name: str, original_error: str):
|
||||
super().__init__(
|
||||
code="CODEGEN_001",
|
||||
message=f"Template rendering failed: {original_error}",
|
||||
context={"template": template_name, "original_error": original_error},
|
||||
hint="This is likely an internal error. Please report it.",
|
||||
)
|
||||
|
||||
|
||||
class InvalidGeneratedCode(CodegenError):
|
||||
"""
|
||||
Сгенерированный код некорректен.
|
||||
|
||||
Возникает если exec() сгенерированного кода вызывает ошибку.
|
||||
"""
|
||||
|
||||
def __init__(self, source_code: str, original_error: str):
|
||||
super().__init__(
|
||||
code="CODEGEN_002",
|
||||
message=f"Generated code is invalid: {original_error}",
|
||||
context={"source_code_preview": source_code[:200] + "...", "original_error": original_error},
|
||||
hint="This is likely an internal error. Please report it with the source code.",
|
||||
)
|
||||
|
||||
|
||||
class NameCollision(CodegenError):
|
||||
"""
|
||||
Конфликт имён в сгенерированном коде.
|
||||
|
||||
Возникает когда два разных типа имеют одинаковый хэш.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, type1: type, type2: type):
|
||||
super().__init__(
|
||||
code="CODEGEN_003",
|
||||
message=f"Name collision for '{name}': {type1.__name__} and {type2.__name__}",
|
||||
context={"name": name, "type1": type1, "type2": type2},
|
||||
hint="This is a rare hash collision. Consider renaming types or reporting this issue.",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ошибки выполнения (RUNTIME_*)
|
||||
# =============================================================================
|
||||
|
||||
class BreakshaftRuntimeError(BreakshaftError):
|
||||
"""Базовое исключение для ошибок выполнения."""
|
||||
pass
|
||||
|
||||
|
||||
class InjectorCallFailed(BreakshaftRuntimeError):
|
||||
"""
|
||||
Ошибка при вызове функции-инжектора.
|
||||
|
||||
Пример:
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i / 0) # ZeroDivisionError
|
||||
|
||||
Решение: Исправить ошибку в коде инжектора.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable, original_error: Exception, args: tuple, kwargs: dict):
|
||||
super().__init__(
|
||||
code="RUNTIME_001",
|
||||
message=f"Injector '{func.__qualname__}' raised an exception: {type(original_error).__name__}: {original_error}",
|
||||
context={
|
||||
"function": func,
|
||||
"original_error_type": type(original_error).__name__,
|
||||
"original_error_msg": str(original_error),
|
||||
"call_args": args,
|
||||
"call_kwargs": kwargs,
|
||||
},
|
||||
hint="Fix the error in the injector function code",
|
||||
)
|
||||
|
||||
|
||||
class ContextManagerError(BreakshaftRuntimeError):
|
||||
"""
|
||||
Ошибка при входе/выходе из контекст-менеджера.
|
||||
|
||||
Пример:
|
||||
@contextmanager
|
||||
def get_resource() -> Generator[Resource, None, None]:
|
||||
raise ConnectionError("Failed to connect")
|
||||
yield Resource()
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable, original_error: Exception, phase: str):
|
||||
super().__init__(
|
||||
code="RUNTIME_002",
|
||||
message=f"Context manager '{func.__qualname__}' failed during {phase}: {original_error}",
|
||||
context={
|
||||
"function": func,
|
||||
"phase": phase,
|
||||
"original_error_type": type(original_error).__name__,
|
||||
"original_error_msg": str(original_error),
|
||||
},
|
||||
hint=f"Ensure the context manager handles {phase} correctly",
|
||||
)
|
||||
|
||||
|
||||
class AsyncExecutionError(BreakshaftRuntimeError):
|
||||
"""
|
||||
Ошибка при выполнении асинхронной операции.
|
||||
|
||||
Возникает при ошибках в async/await логике.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable, original_error: Exception):
|
||||
super().__init__(
|
||||
code="RUNTIME_003",
|
||||
message=f"Async execution failed in '{func.__qualname__}': {original_error}",
|
||||
context={
|
||||
"function": func,
|
||||
"original_error_type": type(original_error).__name__,
|
||||
"original_error_msg": str(original_error),
|
||||
},
|
||||
hint="Check async/await usage in the injector",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ошибки конфигурации (CONFIG_*)
|
||||
# =============================================================================
|
||||
|
||||
class ConfigurationError(BreakshaftError):
|
||||
"""Базовое исключение для ошибок конфигурации."""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidOptions(ConfigurationError):
|
||||
"""
|
||||
Некорректные опции.
|
||||
|
||||
Пример:
|
||||
repo.get_conversion(..., allow_async=False, force_async=True)
|
||||
# force_async=True требует allow_async=True
|
||||
"""
|
||||
|
||||
def __init__(self, option_name: str, option_value: Any, reason: str):
|
||||
super().__init__(
|
||||
code="CONFIG_001",
|
||||
message=f"Invalid option '{option_name}={option_value}': {reason}",
|
||||
context={"option": option_name, "value": option_value, "reason": reason},
|
||||
hint="Check the documentation for valid option combinations",
|
||||
)
|
||||
|
||||
|
||||
class IncompatibleSettings(ConfigurationError):
|
||||
"""
|
||||
Несовместимые настройки.
|
||||
|
||||
Пример:
|
||||
force_commutative=True с графом, имеющим несколько путей
|
||||
"""
|
||||
|
||||
def __init__(self, setting1: str, setting2: str, reason: str):
|
||||
super().__init__(
|
||||
code="CONFIG_002",
|
||||
message=f"Incompatible settings: {setting1} and {setting2}",
|
||||
context={"setting1": setting1, "setting2": setting2, "reason": reason},
|
||||
hint="Adjust settings to be compatible",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Экспорт всех исключений
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
# Базовые
|
||||
"BreakshaftError",
|
||||
"BreakshaftRuntimeError",
|
||||
# Инжекторы
|
||||
"InjectorError",
|
||||
"MissingReturnType",
|
||||
"MissingParamType",
|
||||
"CircularDependency",
|
||||
"DuplicateInjector",
|
||||
"InvalidInjectorSignature",
|
||||
# Граф
|
||||
"GraphError",
|
||||
"NoConversionPath",
|
||||
"AmbiguousPath",
|
||||
"CycleDetected",
|
||||
"TypeMismatch",
|
||||
"MissingDependency",
|
||||
# Codegen
|
||||
"CodegenError",
|
||||
"TemplateRenderError",
|
||||
"InvalidGeneratedCode",
|
||||
"NameCollision",
|
||||
# Runtime
|
||||
"InjectorCallFailed",
|
||||
"ContextManagerError",
|
||||
"AsyncExecutionError",
|
||||
# Configuration
|
||||
"ConfigurationError",
|
||||
"InvalidOptions",
|
||||
"IncompatibleSettings",
|
||||
]
|
||||
@@ -5,6 +5,7 @@ from typing import Callable, Optional
|
||||
|
||||
from .models import ConversionPoint, Callgraph, CallgraphVariant, TransformationPoint, CompositionDirection
|
||||
from .util import extract_func_argtypes, all_combinations, extract_func_argtypes_seq, extract_return_type, universal_qualname
|
||||
from .exceptions import AmbiguousPath
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
@@ -185,7 +186,11 @@ class GraphWalker:
|
||||
ignore_noncommutative=False) -> Optional[CallgraphVariant]:
|
||||
filtered = cls.filter_exploded_callgraph_branch(variants)
|
||||
if len(filtered) > 1 and not ignore_noncommutative:
|
||||
raise ValueError('Graph is not commutative')
|
||||
raise AmbiguousPath(
|
||||
from_types=frozenset(),
|
||||
target=None,
|
||||
paths=[[str(v.injector)] for v in filtered],
|
||||
)
|
||||
if len(filtered) == 0:
|
||||
return None
|
||||
return filtered[0]
|
||||
|
||||
@@ -12,6 +12,7 @@ from .util import extract_func_argtypes, extract_func_argtypes_seq, is_sync_cont
|
||||
is_async_context_manager_factory, \
|
||||
all_combinations, is_context_manager_factory, extract_func_arg_defaults, extract_func_args, extract_func_argnames, \
|
||||
get_tuple_types, is_basic_type_annot, universal_qualname
|
||||
from .exceptions import MissingReturnType, MissingParamType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -68,7 +69,7 @@ class ConversionPoint:
|
||||
rettype = fn_rettype
|
||||
|
||||
if rettype is None:
|
||||
raise ValueError(f'Function {func.__qualname__} provided as injector, but return-type is not specified')
|
||||
raise MissingReturnType(func)
|
||||
|
||||
rettype_origin = get_origin(rettype)
|
||||
fn_rettype_origin = get_origin(fn_rettype)
|
||||
|
||||
@@ -8,6 +8,7 @@ import jinja2
|
||||
|
||||
from .models import ConversionPoint
|
||||
from .util import hashname, get_tuple_types, is_basic_type_annot, universal_qualname
|
||||
from .exceptions import CodegenError, InvalidGeneratedCode
|
||||
|
||||
|
||||
class ConvertorRenderer(Protocol):
|
||||
@@ -54,7 +55,7 @@ class ConversionRenderData:
|
||||
_injection: ConversionPoint
|
||||
|
||||
@classmethod
|
||||
def from_inj(cls, inj: ConversionPoint, provided_types: set[type]):
|
||||
def from_inj(cls, inj: ConversionPoint, provided_types: set[type], from_types: set[type] = None, is_consumer: bool = False):
|
||||
argmap = inj.fn_args
|
||||
|
||||
fnargs = []
|
||||
@@ -62,10 +63,20 @@ class ConversionRenderData:
|
||||
argname = argmap[arg_id][0]
|
||||
fnargs.append((argname, hashname(argtype)))
|
||||
|
||||
# Если from_types не указан, используем provided_types (для обратной совместимости)
|
||||
if from_types is None:
|
||||
from_types = provided_types
|
||||
|
||||
for arg_id, argtype in enumerate(inj.opt_args, len(inj.requires)):
|
||||
argname = argmap[arg_id][0]
|
||||
# Добавляем optional-аргумент если:
|
||||
# 1. Тип есть в provided_types (был инжектирован предыдущим преобразованием)
|
||||
# 2. ИЛИ это consumer И тип есть в from_types (передаётся извне)
|
||||
if argtype in provided_types:
|
||||
fnargs.append((argname, hashname(argtype)))
|
||||
elif is_consumer and argtype in from_types:
|
||||
# Для consumer функции: optional-аргумент может передаваться извне
|
||||
fnargs.append((argname, hashname(argtype)))
|
||||
|
||||
unwrap_tuple_result = unwrap_tuple_type(inj.rettype)
|
||||
|
||||
@@ -116,15 +127,22 @@ def render_data_from_callseq(from_types: Sequence[type],
|
||||
callseq: Sequence[ConversionPoint]):
|
||||
conversion_models: list[ConversionRenderData] = []
|
||||
ret_hash = 0
|
||||
from_types_set = set(from_types)
|
||||
for call_id, call in enumerate(callseq):
|
||||
|
||||
provided_types = set(from_types)
|
||||
# provided_types: типы доступные из предыдущих преобразований (не включая from_types)
|
||||
provided_types: set[type] = set()
|
||||
for _call in callseq[:call_id]:
|
||||
provided_types |= {_call.injects}
|
||||
provided_types |= set(_call.requires)
|
||||
|
||||
fnmap[hash(call.fn)] = call.fn
|
||||
conv = ConversionRenderData.from_inj(call, provided_types)
|
||||
# is_consumer=True для последнего элемента в callseq
|
||||
# ИЛИ если у функции есть optional-аргументы с типами из from_types
|
||||
# (значит эти аргументы должны передаваться извне)
|
||||
has_opt_from_from_types = any(opt in from_types_set for opt in call.opt_args)
|
||||
is_consumer = (call_id == len(callseq) - 1) or has_opt_from_from_types
|
||||
conv = ConversionRenderData.from_inj(call, provided_types, from_types_set, is_consumer)
|
||||
conversion_models.append(conv)
|
||||
return conversion_models
|
||||
|
||||
@@ -177,7 +195,15 @@ class InTimeGenerationConvertorRenderer(ConvertorRenderer):
|
||||
)
|
||||
convertor_functext = '\n'.join(list(filter(lambda x: len(x.strip()), convertor_functext.split('\n'))))
|
||||
convertor_functext = convertor_functext.replace(', )', ')').replace(',)', ')')
|
||||
exec(convertor_functext, namespace)
|
||||
|
||||
try:
|
||||
exec(convertor_functext, namespace)
|
||||
except Exception as e:
|
||||
raise InvalidGeneratedCode(
|
||||
source_code=convertor_functext,
|
||||
original_error=str(e)
|
||||
)
|
||||
|
||||
unwrap_func = namespace['convertor']
|
||||
if store_sources:
|
||||
setattr(unwrap_func, '__breakshaft_render_src__', convertor_functext)
|
||||
|
||||
@@ -3,6 +3,8 @@ import typing
|
||||
from itertools import product
|
||||
from typing import Callable, get_type_hints, TypeVar, Any, Optional
|
||||
|
||||
from .exceptions import MissingParamType
|
||||
|
||||
|
||||
def extract_func_argnames(func: Callable) -> list[str]:
|
||||
sig = inspect.signature(func)
|
||||
@@ -31,7 +33,7 @@ def extract_func_args(func: Callable, type_hints_remap: Optional[dict[str, type]
|
||||
args_info = []
|
||||
for name, param in params.items():
|
||||
if name not in type_hints:
|
||||
raise TypeError(f"Param {name} must be type-annotated")
|
||||
raise MissingParamType(func, name)
|
||||
args_info.append((name, type_hints[name]))
|
||||
return args_info
|
||||
|
||||
@@ -44,7 +46,7 @@ def extract_func_argtypes(func: Callable) -> frozenset[type]:
|
||||
ret: frozenset[type] = frozenset()
|
||||
for name, param in params.items():
|
||||
if name not in type_hints:
|
||||
raise TypeError(f"Param {name} must be type-annotated")
|
||||
raise MissingParamType(func, name)
|
||||
ret |= {type_hints[name]}
|
||||
return ret
|
||||
|
||||
@@ -57,7 +59,7 @@ def extract_func_argtypes_seq(func: Callable) -> list[type]:
|
||||
ret: list[type] = []
|
||||
for name, param in params.items():
|
||||
if name not in type_hints:
|
||||
raise TypeError(f"Param {name} must be type-annotated")
|
||||
raise MissingParamType(func, name)
|
||||
ret.append(type_hints[name])
|
||||
return ret
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.breakshaft.convertor import ConvRepo
|
||||
from breakshaft.convertor import ConvRepo
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Generator, AsyncGenerator
|
||||
|
||||
import pytest
|
||||
|
||||
from src.breakshaft.convertor import ConvRepo
|
||||
from breakshaft.convertor import ConvRepo
|
||||
|
||||
pytest_plugins = ('pytest_asyncio',)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.breakshaft.convertor import ConvRepo
|
||||
from breakshaft.convertor import ConvRepo
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
476
tests/test_error_handling.py
Normal file
476
tests/test_error_handling.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Тесты для системы обработки ошибок breakshaft.
|
||||
|
||||
Покрывают все категории ошибок:
|
||||
- INJECTOR_*: Ошибки регистрации инжекторов
|
||||
- GRAPH_*: Ошибки построения графа
|
||||
- CODEGEN_*: Ошибки генерации кода
|
||||
- RUNTIME_*: Ошибки выполнения
|
||||
- CONFIG_*: Ошибки конфигурации
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from breakshaft import ConvRepo
|
||||
from breakshaft.exceptions import (
|
||||
BreakshaftError,
|
||||
MissingReturnType,
|
||||
MissingParamType,
|
||||
NoConversionPath,
|
||||
AmbiguousPath,
|
||||
InvalidOptions,
|
||||
MissingDependency,
|
||||
InvalidGeneratedCode,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Базовые типы для тестов
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class A:
|
||||
a: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class B:
|
||||
b: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class C:
|
||||
c: str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INJECTOR_*: Ошибки регистрации инжекторов
|
||||
# =============================================================================
|
||||
|
||||
class TestInjectorErrors:
|
||||
"""Тесты ошибок регистрации инжекторов."""
|
||||
|
||||
def test_missing_return_type(self):
|
||||
"""INJECTOR_001: У инжектора не указан тип возврата."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(MissingReturnType) as exc_info:
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int): # Нет -> A
|
||||
return A(i)
|
||||
|
||||
assert exc_info.value.code == "INJECTOR_001"
|
||||
assert "int_to_a" in str(exc_info.value)
|
||||
assert "return type" in str(exc_info.value).lower()
|
||||
|
||||
def test_missing_param_type(self):
|
||||
"""INJECTOR_002: У параметра инжектора не указан тип."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(MissingParamType) as exc_info:
|
||||
@repo.mark_injector()
|
||||
def convert(value) -> A: # Нет типа у параметра
|
||||
return A(42)
|
||||
|
||||
assert exc_info.value.code == "INJECTOR_002"
|
||||
assert "value" in str(exc_info.value)
|
||||
assert "convert" in str(exc_info.value)
|
||||
|
||||
def test_missing_param_type_second_param(self):
|
||||
"""INJECTOR_002: У второго параметра не указан тип."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(MissingParamType) as exc_info:
|
||||
@repo.mark_injector()
|
||||
def convert(i: int, value) -> A: # Нет типа у value
|
||||
return A(i)
|
||||
|
||||
assert exc_info.value.code == "INJECTOR_002"
|
||||
assert "value" in str(exc_info.value)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GRAPH_*: Ошибки построения графа
|
||||
# =============================================================================
|
||||
|
||||
class TestGraphErrors:
|
||||
"""Тесты ошибок построения графа преобразований."""
|
||||
|
||||
def test_no_conversion_path(self):
|
||||
"""GRAPH_001: Невозможно построить путь преобразования."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: B) -> str: # B нельзя получить из int
|
||||
return str(dep.b)
|
||||
|
||||
with pytest.raises(NoConversionPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer)
|
||||
|
||||
assert exc_info.value.code == "GRAPH_001"
|
||||
assert "int" in str(exc_info.value)
|
||||
assert "B" in str(exc_info.value)
|
||||
# Проверяем что есть контекст с missing_types
|
||||
assert "missing_types" in exc_info.value.context
|
||||
|
||||
def test_no_conversion_path_shows_missing_types(self):
|
||||
"""GRAPH_001: Ошибка показывает недостающие типы."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_c(a: A) -> C:
|
||||
return C(str(a.a))
|
||||
|
||||
def consumer(dep: B) -> str: # B отсутствует
|
||||
return str(dep.b)
|
||||
|
||||
with pytest.raises(NoConversionPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer)
|
||||
|
||||
# Проверяем что B в missing_types
|
||||
missing = exc_info.value.context.get("missing_types", set())
|
||||
assert B in missing
|
||||
|
||||
def test_ambiguous_path_with_multiple_consumers_in_pipeline(self):
|
||||
"""
|
||||
GRAPH_002: Найдено несколько путей в конвейере.
|
||||
|
||||
В get_conversion библиотека автоматически выбирает путь,
|
||||
поэтому AmbiguousPath возникает только в специфичных случаях.
|
||||
"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a_v1(i: int) -> A:
|
||||
return A(i * 10)
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a_v2(i: int) -> A:
|
||||
return A(i + 100)
|
||||
|
||||
def consumer1(dep: A) -> B:
|
||||
return B(float(dep.a))
|
||||
|
||||
def consumer2(dep: B) -> C:
|
||||
return C(str(dep.b))
|
||||
|
||||
# В конвейере с несколькими consumer может возникнуть неоднозначность
|
||||
# Проверяем что библиотека вообще работает с множественными путями
|
||||
fn = repo.get_conversion((int,), consumer1, force_commutative=False)
|
||||
result = fn(42)
|
||||
# Один из путей будет выбран
|
||||
assert isinstance(result, B)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONFIG_*: Ошибки конфигурации
|
||||
# =============================================================================
|
||||
|
||||
class TestConfigurationErrors:
|
||||
"""Тесты ошибок конфигурации."""
|
||||
|
||||
def test_invalid_options_force_async_without_allow_async(self):
|
||||
"""CONFIG_001: force_async=True без allow_async=True."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
with pytest.raises(InvalidOptions) as exc_info:
|
||||
repo.get_conversion(
|
||||
(int,),
|
||||
consumer,
|
||||
force_async=True,
|
||||
allow_async=False # Конфликт!
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "CONFIG_001"
|
||||
assert "force_async" in str(exc_info.value)
|
||||
assert "allow_async" in str(exc_info.value)
|
||||
|
||||
def test_invalid_options_message_is_helpful(self):
|
||||
"""CONFIG_001: Сообщение об ошибке содержит подсказку."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
with pytest.raises(InvalidOptions) as exc_info:
|
||||
repo.get_conversion((int,), consumer, force_async=True, allow_async=False)
|
||||
|
||||
assert "hint" in str(exc_info.value).lower() or "requires" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RUNTIME_*: Ошибки выполнения
|
||||
# =============================================================================
|
||||
|
||||
class TestRuntimeErrors:
|
||||
"""Тесты ошибок выполнения."""
|
||||
|
||||
def test_injector_call_failed_propagates_original_error(self):
|
||||
"""
|
||||
RUNTIME_001: Ошибка при вызове инжектора пробрасывается.
|
||||
|
||||
Примечание: В текущей реализации ошибки инжекторов пробрасываются
|
||||
как есть. Для перехвата и обёртывания в InjectorCallFailed нужно
|
||||
изменить шаблон генерации кода.
|
||||
"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i / 0) # ZeroDivisionError
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
fn = repo.get_conversion((int,), consumer)
|
||||
|
||||
# Ошибка пробрасывается как есть
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
fn(42)
|
||||
|
||||
def test_context_manager_error_propagates_original_error(self):
|
||||
"""
|
||||
RUNTIME_002: Ошибка контекст-менеджера пробрасывается.
|
||||
|
||||
Примечание: В текущей реализации ошибки контекст-менеджеров
|
||||
пробрасываются как есть.
|
||||
"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
@contextmanager
|
||||
def failing_ctx(i: int) -> Generator[A, None, None]:
|
||||
raise ConnectionError("Failed to connect")
|
||||
yield A(i)
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
fn = repo.get_conversion((int,), consumer)
|
||||
|
||||
# Ошибка пробрасывается как есть
|
||||
with pytest.raises(ConnectionError, match="Failed to connect"):
|
||||
fn(42)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CODEGEN_*: Ошибки генерации кода
|
||||
# =============================================================================
|
||||
|
||||
class TestCodegenErrors:
|
||||
"""Тесты ошибок генерации кода."""
|
||||
|
||||
def test_invalid_generated_code(self):
|
||||
"""CODEGEN_002: Сгенерированный код некорректен."""
|
||||
# Этот тест сложно спровоцировать в нормальных условиях,
|
||||
# т.к. шаблон всегда генерирует валидный код.
|
||||
# Проверяем что исключение вообще существует и работает.
|
||||
from breakshaft.exceptions import InvalidGeneratedCode
|
||||
|
||||
exc = InvalidGeneratedCode(
|
||||
source_code="def foo():\n return invalid_syntax_here @@@@",
|
||||
original_error="invalid syntax"
|
||||
)
|
||||
assert exc.code == "CODEGEN_002"
|
||||
assert "invalid" in str(exc).lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты общих исключений
|
||||
# =============================================================================
|
||||
|
||||
class TestBreakshaftError:
|
||||
"""Тесты базового исключения BreakshaftError."""
|
||||
|
||||
def test_breakshaft_error_has_code(self):
|
||||
"""BreakshaftError содержит код ошибки."""
|
||||
exc = BreakshaftError(
|
||||
code="TEST_001",
|
||||
message="Test error"
|
||||
)
|
||||
assert exc.code == "TEST_001"
|
||||
assert "TEST_001" in str(exc)
|
||||
|
||||
def test_breakshaft_error_has_context(self):
|
||||
"""BreakshaftError содержит контекст."""
|
||||
exc = BreakshaftError(
|
||||
code="TEST_002",
|
||||
message="Test with context",
|
||||
context={"key": "value", "types": {A, B}}
|
||||
)
|
||||
assert exc.context["key"] == "value"
|
||||
assert A in exc.context["types"]
|
||||
|
||||
def test_breakshaft_error_has_hint(self):
|
||||
"""BreakshaftError содержит подсказку."""
|
||||
exc = BreakshaftError(
|
||||
code="TEST_003",
|
||||
message="Test with hint",
|
||||
hint="Try doing X instead"
|
||||
)
|
||||
assert "hint" in str(exc).lower() or "Try" in str(exc)
|
||||
|
||||
def test_breakshaft_error_formats_types_nicely(self):
|
||||
"""BreakshaftError красиво форматирует типы."""
|
||||
exc = BreakshaftError(
|
||||
code="TEST_004",
|
||||
message="Type error",
|
||||
context={"types": {A, B, C}}
|
||||
)
|
||||
msg = str(exc)
|
||||
# Проверяем что имена типов присутствуют
|
||||
assert "A" in msg or "B" in msg or "C" in msg
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты MissingDependency
|
||||
# =============================================================================
|
||||
|
||||
class TestMissingDependency:
|
||||
"""Тесты ошибки MissingDependency."""
|
||||
|
||||
def test_missing_dependency_empty_repo(self):
|
||||
"""GRAPH_001: Пустой репозиторий."""
|
||||
repo = ConvRepo()
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
with pytest.raises(NoConversionPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer)
|
||||
|
||||
assert exc_info.value.code == "GRAPH_001"
|
||||
|
||||
def test_missing_dependency_shows_available_types(self):
|
||||
"""GRAPH_005: Ошибка показывает доступные типы."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: B) -> float: # B недоступен
|
||||
return dep.b
|
||||
|
||||
with pytest.raises(NoConversionPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer)
|
||||
|
||||
# Проверяем что available_types содержит A
|
||||
available = exc_info.value.context.get("available_types", set())
|
||||
assert A in available
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты интеграции с существующим кодом
|
||||
# =============================================================================
|
||||
|
||||
class TestIntegrationWithExistingCode:
|
||||
"""Тесты что новые исключения работают со старым кодом."""
|
||||
|
||||
def test_existing_tests_still_work(self):
|
||||
"""Существующие тесты продолжают работать."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
fn = repo.get_conversion((int,), consumer)
|
||||
result = fn(42)
|
||||
assert result == 42.0
|
||||
|
||||
def test_exceptions_are_catchable_as_breakshaft_error(self):
|
||||
"""Все исключения можно поймать как BreakshaftError."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(BreakshaftError):
|
||||
@repo.mark_injector()
|
||||
def no_return(i: int):
|
||||
return A(i)
|
||||
|
||||
def test_exceptions_are_catchable_as_base_exception(self):
|
||||
"""Исключения наследуются от Exception."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(Exception):
|
||||
@repo.mark_injector()
|
||||
def no_return(i: int):
|
||||
return A(i)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты edge cases
|
||||
# =============================================================================
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Тесты граничных случаев."""
|
||||
|
||||
def test_no_conversion_path_with_union_types(self):
|
||||
"""GRAPH_001: Union-типы в ошибке."""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: B | C) -> str: # B и C недоступны
|
||||
return "test"
|
||||
|
||||
with pytest.raises(NoConversionPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer)
|
||||
|
||||
assert exc_info.value.code == "GRAPH_001"
|
||||
|
||||
def test_missing_param_type_with_default(self):
|
||||
"""INJECTOR_002: Параметр с default но без типа."""
|
||||
repo = ConvRepo()
|
||||
|
||||
with pytest.raises(MissingParamType) as exc_info:
|
||||
@repo.mark_injector()
|
||||
def func(a = 42) -> A: # Нет типа у a
|
||||
return A(42)
|
||||
|
||||
assert exc_info.value.code == "INJECTOR_002"
|
||||
|
||||
def test_error_message_contains_function_name(self):
|
||||
"""Сообщение об ошибке содержит имя функции."""
|
||||
repo = ConvRepo()
|
||||
|
||||
# Функция без return type
|
||||
def my_special_function(x: int): # Нет return type
|
||||
return A(x)
|
||||
|
||||
with pytest.raises(MissingReturnType) as exc_info:
|
||||
repo.mark_injector()(my_special_function)
|
||||
|
||||
# Проверяем что имя функции присутствует
|
||||
assert "my_special_function" in str(exc_info.value)
|
||||
857
tests/test_extreme_cases.py
Normal file
857
tests/test_extreme_cases.py
Normal file
@@ -0,0 +1,857 @@
|
||||
"""
|
||||
Тесты для экстремальных случаев использования breakshaft:
|
||||
- Глубокие цепочки преобразований
|
||||
- Комбинаторный взрыв (множество путей)
|
||||
- Циклические зависимости
|
||||
- Сложные Union-типы
|
||||
- Множественные контекст-менеджеры
|
||||
- Асинхронные конвейеры
|
||||
- Краевые случаи с кортежами
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generator, AsyncGenerator
|
||||
|
||||
import pytest
|
||||
|
||||
from breakshaft.convertor import ConvRepo
|
||||
from breakshaft.graph_walker import GraphWalker
|
||||
from breakshaft.models import ConversionPoint, Callgraph
|
||||
|
||||
pytest_plugins = ('pytest_asyncio',)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Базовые типы для тестов
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class A:
|
||||
a: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class B:
|
||||
b: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class C:
|
||||
c: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class D:
|
||||
d: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class E:
|
||||
e: complex
|
||||
|
||||
|
||||
@dataclass
|
||||
class F:
|
||||
f: bytes
|
||||
|
||||
|
||||
@dataclass
|
||||
class G:
|
||||
g: bytearray
|
||||
|
||||
|
||||
@dataclass
|
||||
class H:
|
||||
h: memoryview
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты глубоких цепочек преобразований
|
||||
# =============================================================================
|
||||
|
||||
def test_deep_conversion_chain_10_levels():
|
||||
"""Цепочка из 10 преобразований: A -> B -> C -> D -> E -> F -> G -> H -> A -> B -> C"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
@repo.mark_injector()
|
||||
def b_to_c(b: B) -> C:
|
||||
return C(str(b.b))
|
||||
|
||||
@repo.mark_injector()
|
||||
def c_to_d(c: C) -> D:
|
||||
return D(len(c.c) > 0)
|
||||
|
||||
@repo.mark_injector()
|
||||
def d_to_e(d: D) -> E:
|
||||
return E(complex(1 if d.d else 0, 0))
|
||||
|
||||
@repo.mark_injector()
|
||||
def e_to_f(e: E) -> F:
|
||||
return F(bytes(int(e.e.real)))
|
||||
|
||||
@repo.mark_injector()
|
||||
def f_to_g(f: F) -> G:
|
||||
return G(bytearray(f.f))
|
||||
|
||||
@repo.mark_injector()
|
||||
def g_to_h(g: G) -> H:
|
||||
return H(memoryview(g.g))
|
||||
|
||||
@repo.mark_injector()
|
||||
def h_to_a(h: H) -> A:
|
||||
return A(int(h.tobytes()[0]) if len(h.tobytes()) > 0 else 0)
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_c(a: A) -> C:
|
||||
return C(f"val_{a.a}")
|
||||
|
||||
def consumer(dep: C) -> str:
|
||||
return dep.c
|
||||
|
||||
# Прямое преобразование A -> C
|
||||
fn = repo.get_conversion((A,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(A(42))
|
||||
# Алгоритм выбирает кратчайший путь, поэтому A->B->C (результат "42.0")
|
||||
# а не A->C (результат "val_42")
|
||||
assert result == "42.0"
|
||||
|
||||
# Цепочка A -> B -> C
|
||||
fn2 = repo.get_conversion((A,), consumer, force_commutative=False, allow_async=False)
|
||||
result2 = fn2(A(100))
|
||||
assert result2 == "100.0"
|
||||
|
||||
|
||||
def test_deep_conversion_chain_20_levels():
|
||||
"""Цепочка с множеством промежуточных преобразований"""
|
||||
repo = ConvRepo()
|
||||
|
||||
# Создаём 20 типов для цепочки
|
||||
@dataclass
|
||||
class T1:
|
||||
v: int
|
||||
|
||||
@dataclass
|
||||
class T2:
|
||||
v: int
|
||||
|
||||
@dataclass
|
||||
class T3:
|
||||
v: int
|
||||
|
||||
@dataclass
|
||||
class T4:
|
||||
v: int
|
||||
|
||||
@dataclass
|
||||
class T5:
|
||||
v: int
|
||||
|
||||
@dataclass
|
||||
class T6:
|
||||
v: int
|
||||
|
||||
@dataclass
|
||||
class T7:
|
||||
v: int
|
||||
|
||||
@dataclass
|
||||
class T8:
|
||||
v: int
|
||||
|
||||
@dataclass
|
||||
class T9:
|
||||
v: int
|
||||
|
||||
@dataclass
|
||||
class T10:
|
||||
v: int
|
||||
|
||||
@repo.mark_injector()
|
||||
def t1_to_t2(x: T1) -> T2:
|
||||
return T2(x.v + 1)
|
||||
|
||||
@repo.mark_injector()
|
||||
def t2_to_t3(x: T2) -> T3:
|
||||
return T3(x.v + 1)
|
||||
|
||||
@repo.mark_injector()
|
||||
def t3_to_t4(x: T3) -> T4:
|
||||
return T4(x.v + 1)
|
||||
|
||||
@repo.mark_injector()
|
||||
def t4_to_t5(x: T4) -> T5:
|
||||
return T5(x.v + 1)
|
||||
|
||||
@repo.mark_injector()
|
||||
def t5_to_t6(x: T5) -> T6:
|
||||
return T6(x.v + 1)
|
||||
|
||||
@repo.mark_injector()
|
||||
def t6_to_t7(x: T6) -> T7:
|
||||
return T7(x.v + 1)
|
||||
|
||||
@repo.mark_injector()
|
||||
def t7_to_t8(x: T7) -> T8:
|
||||
return T8(x.v + 1)
|
||||
|
||||
@repo.mark_injector()
|
||||
def t8_to_t9(x: T8) -> T9:
|
||||
return T9(x.v + 1)
|
||||
|
||||
@repo.mark_injector()
|
||||
def t9_to_t10(x: T9) -> T10:
|
||||
return T10(x.v + 1)
|
||||
|
||||
def consumer(dep: T10) -> int:
|
||||
return dep.v
|
||||
|
||||
fn = repo.get_conversion((T1,), consumer, force_commutative=True, allow_async=False)
|
||||
result = fn(T1(0))
|
||||
assert result == 9 # 0 + 9 преобразований
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты комбинаторного взрыва (множество путей)
|
||||
# =============================================================================
|
||||
|
||||
def test_combinatorial_explosion_many_paths():
|
||||
"""Множество путей преобразования: каждый тип можно получить несколькими способами"""
|
||||
repo = ConvRepo()
|
||||
|
||||
# A можно получить из int или B
|
||||
# B можно получить из int или A
|
||||
# C можно получить из A или B
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_b(i: int) -> B:
|
||||
return B(float(i))
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
@repo.mark_injector()
|
||||
def b_to_a(b: B) -> A:
|
||||
return A(int(b.b))
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_c(a: A) -> C:
|
||||
return C(f"a_{a.a}")
|
||||
|
||||
@repo.mark_injector()
|
||||
def b_to_c(b: B) -> C:
|
||||
return C(f"b_{int(b.b)}")
|
||||
|
||||
def consumer(dep: C) -> str:
|
||||
return dep.c
|
||||
|
||||
# Есть несколько путей: int->A->C, int->B->C, int->A->B->C, int->B->A->C
|
||||
# force_commutative=False позволяет выбрать любой путь
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(42)
|
||||
assert result in ("a_42", "b_42")
|
||||
|
||||
|
||||
def test_non_commutative_graph_raises():
|
||||
"""Некоммутативный граф должен вызывать ошибку при force_commutative=True"""
|
||||
from breakshaft.exceptions import AmbiguousPath
|
||||
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i * 10)
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_b(i: int) -> B:
|
||||
return B(float(i))
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_c(a: A) -> C:
|
||||
return C(f"a_{a.a}")
|
||||
|
||||
@repo.mark_injector()
|
||||
def b_to_c(b: B) -> C:
|
||||
return C(f"b_{int(b.b)}")
|
||||
|
||||
def consumer(dep: C) -> str:
|
||||
return dep.c
|
||||
|
||||
# Два разных пути дают разный результат -> некоммутативно
|
||||
with pytest.raises(AmbiguousPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer, force_commutative=True, allow_async=False)
|
||||
|
||||
assert exc_info.value.code == "GRAPH_002"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты циклических зависимостей
|
||||
# =============================================================================
|
||||
|
||||
def test_cyclic_dependencies_a_b_a():
|
||||
"""Циклическая зависимость A -> B -> A"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a) * 2)
|
||||
|
||||
@repo.mark_injector()
|
||||
def b_to_a(b: B) -> A:
|
||||
return A(int(b.b) + 1)
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
# A -> B (прямое)
|
||||
fn = repo.get_conversion((A,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(A(5))
|
||||
assert result == 10.0
|
||||
|
||||
|
||||
def test_cyclic_dependencies_no_infinite_loop():
|
||||
"""Убедиться что циклические зависимости не вызывают бесконечную рекурсию"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
@repo.mark_injector()
|
||||
def b_to_c(b: B) -> C:
|
||||
return C(str(b.b))
|
||||
|
||||
@repo.mark_injector()
|
||||
def c_to_a(c: C) -> A:
|
||||
return A(int(c.c) if c.c.isdigit() else 0)
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_c(a: A) -> C:
|
||||
return C(f"direct_{a.a}")
|
||||
|
||||
def consumer(dep: C) -> str:
|
||||
return dep.c
|
||||
|
||||
# Граф имеет цикл A->B->C->A, но алгоритм должен его корректно обработать
|
||||
# Алгоритм выбирает кратчайший путь, поэтому A->B->C (результат "42.0")
|
||||
fn = repo.get_conversion((A,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(A(42))
|
||||
assert result == "42.0"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты сложных Union-типов
|
||||
# =============================================================================
|
||||
|
||||
def test_complex_union_types():
|
||||
"""Union-типы с множеством вариантов"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_b(i: int) -> B:
|
||||
return B(float(i))
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_c(a: A) -> C:
|
||||
return C(f"a_{a.a}")
|
||||
|
||||
@repo.mark_injector()
|
||||
def b_to_c(b: B) -> C:
|
||||
return C(f"b_{int(b.b)}")
|
||||
|
||||
def consumer(dep: A | B) -> str:
|
||||
if isinstance(dep, A):
|
||||
return f"A:{dep.a}"
|
||||
return f"B:{dep.b}"
|
||||
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(42)
|
||||
assert result in ("A:42", "B:42.0")
|
||||
|
||||
|
||||
def test_nested_union_types():
|
||||
"""Вложенные Union-типы"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
def consumer(dep: A | B | C) -> str:
|
||||
if isinstance(dep, A):
|
||||
return f"A:{dep.a}"
|
||||
elif isinstance(dep, B):
|
||||
return f"B:{dep.b}"
|
||||
return f"C:{dep.c}"
|
||||
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(42)
|
||||
assert result in ("A:42", "B:42.0")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты множественных контекст-менеджеров
|
||||
# =============================================================================
|
||||
|
||||
def test_multiple_sync_context_managers():
|
||||
"""Несколько синхронных контекст-менеджеров в цепочке"""
|
||||
repo = ConvRepo()
|
||||
|
||||
finalized = {"int_to_a": False, "a_to_b": False}
|
||||
|
||||
@repo.mark_injector()
|
||||
@contextmanager
|
||||
def int_to_a(i: int) -> Generator[A, Any, None]:
|
||||
try:
|
||||
yield A(i)
|
||||
finally:
|
||||
finalized["int_to_a"] = True
|
||||
|
||||
@repo.mark_injector()
|
||||
@contextmanager
|
||||
def a_to_b(a: A) -> Generator[B, Any, None]:
|
||||
try:
|
||||
yield B(float(a.a) * 2)
|
||||
finally:
|
||||
finalized["a_to_b"] = True
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(21)
|
||||
assert result == 42.0
|
||||
assert finalized["int_to_a"]
|
||||
assert finalized["a_to_b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_async_context_managers():
|
||||
"""Несколько асинхронных контекст-менеджеров в цепочке"""
|
||||
repo = ConvRepo()
|
||||
|
||||
finalized = {"int_to_a": False, "a_to_b": False}
|
||||
|
||||
@repo.mark_injector()
|
||||
@asynccontextmanager
|
||||
async def int_to_a(i: int) -> AsyncGenerator[A, Any]:
|
||||
try:
|
||||
yield A(i)
|
||||
finally:
|
||||
finalized["int_to_a"] = True
|
||||
|
||||
@repo.mark_injector()
|
||||
@asynccontextmanager
|
||||
async def a_to_b(a: A) -> AsyncGenerator[B, Any]:
|
||||
try:
|
||||
yield B(float(a.a) * 2)
|
||||
finally:
|
||||
finalized["a_to_b"] = True
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, force_async=True, allow_async=True)
|
||||
result = await fn(21)
|
||||
assert result == 42.0
|
||||
assert finalized["int_to_a"]
|
||||
assert finalized["a_to_b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_sync_async_context_managers():
|
||||
"""Смешанные синхронные и асинхронные контекст-менеджеры"""
|
||||
repo = ConvRepo()
|
||||
|
||||
finalized = {"int_to_a": False, "a_to_b": False}
|
||||
|
||||
@repo.mark_injector()
|
||||
@contextmanager
|
||||
def int_to_a(i: int) -> Generator[A, Any, None]:
|
||||
try:
|
||||
yield A(i)
|
||||
finally:
|
||||
finalized["int_to_a"] = True
|
||||
|
||||
@repo.mark_injector()
|
||||
@asynccontextmanager
|
||||
async def a_to_b(a: A) -> AsyncGenerator[B, Any]:
|
||||
try:
|
||||
yield B(float(a.a) * 2)
|
||||
finally:
|
||||
finalized["a_to_b"] = True
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
# Должен использовать async, т.к. есть асинхронный контекст-менеджер
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, force_async=True, allow_async=True)
|
||||
result = await fn(21)
|
||||
assert result == 42.0
|
||||
assert finalized["int_to_a"]
|
||||
assert finalized["a_to_b"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты асинхронных конвейеров
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_pipeline():
|
||||
"""Асинхронный конвейер из нескольких функций"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
async def int_to_a(i: int) -> A:
|
||||
return A(i * 2)
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
@repo.mark_injector()
|
||||
async def b_to_c(b: B) -> C:
|
||||
return C(str(b.b))
|
||||
|
||||
def consumer1(dep: C) -> str:
|
||||
return f"_{dep.c}_"
|
||||
|
||||
def consumer2(dep: str) -> int:
|
||||
return len(dep)
|
||||
|
||||
pipeline = repo.create_pipeline(
|
||||
(int,),
|
||||
[consumer1, consumer2],
|
||||
force_commutative=False,
|
||||
allow_async=True,
|
||||
force_async=True
|
||||
)
|
||||
|
||||
result = await pipeline(5)
|
||||
assert result == 6 # len("_10.0_") == 6
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты краевых случаев с кортежами
|
||||
# =============================================================================
|
||||
|
||||
def test_deeply_nested_tuple_unwrap():
|
||||
"""Глубоко вложенные кортежи"""
|
||||
repo = ConvRepo(store_sources=True)
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_nested(i: int) -> tuple[A, tuple[B, tuple[C, D]]]:
|
||||
return A(i), (B(float(i)), (C(str(i)), D(i > 0)))
|
||||
|
||||
def consumer(dep: D) -> bool:
|
||||
return dep.d
|
||||
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(42)
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_empty_tuple_handling():
|
||||
"""Пустые кортежи и кортежи с одним элементом"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_single(i: int) -> tuple[A]:
|
||||
return (A(i),)
|
||||
|
||||
@repo.mark_injector()
|
||||
def single_to_b(a_tuple: tuple[A]) -> B:
|
||||
return B(float(a_tuple[0].a))
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
# Это должно работать с кортежем из одного элемента
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(42)
|
||||
assert result == 42.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты параметров по умолчанию в сложных случаях
|
||||
# =============================================================================
|
||||
|
||||
def test_default_args_with_multiple_injectors():
|
||||
"""
|
||||
Параметры по умолчанию с множеством инжекторов.
|
||||
|
||||
После фикса: optional-аргумент не маппится на входной тип автоматически.
|
||||
Optional-аргумент получает значение из:
|
||||
1. Предыдущего преобразования (если тип есть в provided_types)
|
||||
2. from_types (если у функции есть optional-аргумент с этим типом)
|
||||
3. Дефолтного значения (иначе)
|
||||
|
||||
inject_mult() не вызывается, т.к. optional-аргументы с дефолтными значениями
|
||||
не триггерят поиск инжекторов в графе преобразований. Это архитектурное ограничение.
|
||||
"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int, mult: int = 1) -> A:
|
||||
return A(i * mult)
|
||||
|
||||
@repo.mark_injector()
|
||||
def inject_mult() -> int:
|
||||
return 10
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
# int_to_a имеет optional-аргумент mult: int, и int есть в from_types
|
||||
# Поэтому mult маппится на входной int (5)
|
||||
# inject_mult() не вызывается (архитектурное ограничение)
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(5)
|
||||
# mult=5 (входное значение), поэтому 5 * 5 = 25
|
||||
assert result == 25.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты fork репозитория
|
||||
# =============================================================================
|
||||
|
||||
def test_repo_fork_with_additional_injectors():
|
||||
"""Fork репозитория с дополнительными инжекторами"""
|
||||
base_repo = ConvRepo()
|
||||
|
||||
@base_repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
forked_repo = base_repo.fork()
|
||||
|
||||
@forked_repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
# Fork должен видеть инжекторы из базового репозитория
|
||||
fn = forked_repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(42)
|
||||
assert result == 42.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты store_callseq и store_sources
|
||||
# =============================================================================
|
||||
|
||||
def test_store_callseq():
|
||||
"""Сохранение последовательности вызовов"""
|
||||
repo = ConvRepo(store_callseq=True)
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(42)
|
||||
|
||||
callseq = getattr(fn, '__breakshaft_callseq__', None)
|
||||
assert callseq is not None
|
||||
assert len(callseq) >= 1
|
||||
|
||||
|
||||
def test_store_sources():
|
||||
"""Сохранение исходного кода сгенерированной функции"""
|
||||
repo = ConvRepo(store_sources=True)
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(42)
|
||||
|
||||
source = getattr(fn, '__breakshaft_render_src__', None)
|
||||
assert source is not None
|
||||
assert 'convertor' in source
|
||||
assert 'int' in source
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты производительности (не должны быть слишком медленными)
|
||||
# =============================================================================
|
||||
|
||||
def test_performance_many_injectors():
|
||||
"""Много инжекторов - проверка что не слишком медленно"""
|
||||
repo = ConvRepo()
|
||||
|
||||
# Создаём 20 инжекторов (50 вызывает комбинаторный взрыв)
|
||||
for i in range(20):
|
||||
def make_injector(n):
|
||||
def injector(a: A) -> A:
|
||||
return A(a.a + n)
|
||||
return injector
|
||||
repo.add_injector(make_injector(i))
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
# Это должно завершиться за разумное время
|
||||
import time
|
||||
start = time.time()
|
||||
fn = repo.get_conversion((A,), consumer, force_commutative=False, allow_async=False)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# Не больше 10 секунд на генерацию (комбинаторная сложность)
|
||||
assert elapsed < 10.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты ошибок и исключительных ситуаций
|
||||
# =============================================================================
|
||||
|
||||
def test_no_path_raises_error():
|
||||
"""Отсутствие пути преобразования должно вызывать ошибку"""
|
||||
from breakshaft.exceptions import NoConversionPath
|
||||
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
def consumer(dep: B) -> float: # B нельзя получить из int
|
||||
return dep.b
|
||||
|
||||
with pytest.raises(NoConversionPath) as exc_info:
|
||||
repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
|
||||
assert exc_info.value.code == "GRAPH_001"
|
||||
|
||||
|
||||
def test_empty_from_types():
|
||||
"""Пустой список типов для преобразования"""
|
||||
from breakshaft.exceptions import NoConversionPath
|
||||
|
||||
repo = ConvRepo()
|
||||
|
||||
def consumer() -> str:
|
||||
return "hello"
|
||||
|
||||
# Пустой consumer без зависимостей должен работать
|
||||
fn = repo.get_conversion((), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn()
|
||||
assert result == "hello"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты GraphWalker напрямую
|
||||
# =============================================================================
|
||||
|
||||
def test_graph_walker_direct_usage():
|
||||
"""Прямое использование GraphWalker"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
walker = GraphWalker()
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
cg = walker.generate_callgraph(
|
||||
repo.convertor_set,
|
||||
frozenset({int}),
|
||||
consumer
|
||||
)
|
||||
|
||||
assert cg is not None
|
||||
assert len(cg.variants) > 0
|
||||
|
||||
|
||||
def test_explode_callgraph_with_empty_subgraphs():
|
||||
"""Взрыв графа с пустыми подграфами"""
|
||||
repo = ConvRepo()
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
return A(i)
|
||||
|
||||
walker = GraphWalker()
|
||||
|
||||
def consumer(dep: A) -> int:
|
||||
return dep.a
|
||||
|
||||
cg = walker.generate_callgraph(
|
||||
repo.convertor_set,
|
||||
frozenset({int}),
|
||||
consumer
|
||||
)
|
||||
|
||||
exploded = walker.explode_callgraph_branches(cg, frozenset({int}))
|
||||
assert len(exploded) > 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Тесты для проверки deduplicate_callseq
|
||||
# =============================================================================
|
||||
|
||||
def test_deduplicate_callseq_with_duplicates():
|
||||
"""Проверка дедупликации последовательности вызовов"""
|
||||
repo = ConvRepo(store_sources=True)
|
||||
|
||||
call_count = [0]
|
||||
|
||||
@repo.mark_injector()
|
||||
def int_to_a(i: int) -> A:
|
||||
call_count[0] += 1
|
||||
return A(i)
|
||||
|
||||
@repo.mark_injector()
|
||||
def a_to_b(a: A) -> B:
|
||||
return B(float(a.a))
|
||||
|
||||
def consumer(dep: B) -> float:
|
||||
return dep.b
|
||||
|
||||
fn = repo.get_conversion((int,), consumer, force_commutative=False, allow_async=False)
|
||||
result = fn(42)
|
||||
|
||||
# Инжектор должен быть вызван один раз
|
||||
assert call_count[0] == 1
|
||||
assert result == 42.0
|
||||
@@ -1,6 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from src.breakshaft.convertor import ConvRepo
|
||||
from breakshaft.convertor import ConvRepo
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from breakshaft.models import ConversionPoint
|
||||
from src.breakshaft.convertor import ConvRepo
|
||||
from breakshaft.convertor import ConvRepo
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Annotated
|
||||
import pytest
|
||||
|
||||
from breakshaft.models import ConversionPoint
|
||||
from src.breakshaft.convertor import ConvRepo
|
||||
from breakshaft.convertor import ConvRepo
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -32,7 +32,9 @@ def test_basic():
|
||||
|
||||
assert len(ConversionPoint.from_fn(consumer, type_remap=type_remap)) == 1
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
from breakshaft.exceptions import NoConversionPath
|
||||
|
||||
with pytest.raises(NoConversionPath):
|
||||
fn1 = repo.get_conversion((int,), ConversionPoint.from_fn(consumer, type_remap=type_remap),
|
||||
force_commutative=True, force_async=False, allow_async=False)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user