diff --git a/COMMUTATIVITY_DESIGN.md b/COMMUTATIVITY_DESIGN.md new file mode 100644 index 0000000..9d29cb2 --- /dev/null +++ b/COMMUTATIVITY_DESIGN.md @@ -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* diff --git a/ERROR_DESIGN.md b/ERROR_DESIGN.md new file mode 100644 index 0000000..b8c7420 --- /dev/null +++ b/ERROR_DESIGN.md @@ -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. **Добавить документацию** по ошибкам diff --git a/src/breakshaft/__init__.py b/src/breakshaft/__init__.py index 8b13789..264627b 100644 --- a/src/breakshaft/__init__.py +++ b/src/breakshaft/__init__.py @@ -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", +] diff --git a/src/breakshaft/convertor.py b/src/breakshaft/convertor.py index 070e385..024550d 100644 --- a/src/breakshaft/convertor.py +++ b/src/breakshaft/convertor.py @@ -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: diff --git a/src/breakshaft/exceptions.py b/src/breakshaft/exceptions.py new file mode 100644 index 0000000..bf3e340 --- /dev/null +++ b/src/breakshaft/exceptions.py @@ -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", +] diff --git a/src/breakshaft/graph_walker.py b/src/breakshaft/graph_walker.py index 4666f88..70371a5 100644 --- a/src/breakshaft/graph_walker.py +++ b/src/breakshaft/graph_walker.py @@ -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] diff --git a/src/breakshaft/models.py b/src/breakshaft/models.py index c4a4c60..7c211ce 100644 --- a/src/breakshaft/models.py +++ b/src/breakshaft/models.py @@ -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) diff --git a/src/breakshaft/renderer.py b/src/breakshaft/renderer.py index 5928ab1..631b9fa 100644 --- a/src/breakshaft/renderer.py +++ b/src/breakshaft/renderer.py @@ -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) diff --git a/src/breakshaft/util.py b/src/breakshaft/util.py index b547ab8..b54f3f6 100644 --- a/src/breakshaft/util.py +++ b/src/breakshaft/util.py @@ -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 diff --git a/tests/test_basic.py b/tests/test_basic.py index e6f7972..7c3cdf5 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from src.breakshaft.convertor import ConvRepo +from breakshaft.convertor import ConvRepo @dataclass diff --git a/tests/test_ctxmanager.py b/tests/test_ctxmanager.py index 6ece635..733e9e6 100644 --- a/tests/test_ctxmanager.py +++ b/tests/test_ctxmanager.py @@ -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',) diff --git a/tests/test_default_args.py b/tests/test_default_args.py index de3ef12..1f8d0e3 100644 --- a/tests/test_default_args.py +++ b/tests/test_default_args.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from src.breakshaft.convertor import ConvRepo +from breakshaft.convertor import ConvRepo @dataclass diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..b11c45e --- /dev/null +++ b/tests/test_error_handling.py @@ -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) \ No newline at end of file diff --git a/tests/test_extreme_cases.py b/tests/test_extreme_cases.py new file mode 100644 index 0000000..503e7b5 --- /dev/null +++ b/tests/test_extreme_cases.py @@ -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 diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index aec00fc..8492a0c 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from src.breakshaft.convertor import ConvRepo +from breakshaft.convertor import ConvRepo @dataclass diff --git a/tests/test_tuple_unwrap.py b/tests/test_tuple_unwrap.py index 10db0f5..0bbc215 100644 --- a/tests/test_tuple_unwrap.py +++ b/tests/test_tuple_unwrap.py @@ -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 diff --git a/tests/test_typehints_remap.py b/tests/test_typehints_remap.py index 46d5508..3641ca9 100644 --- a/tests/test_typehints_remap.py +++ b/tests/test_typehints_remap.py @@ -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)