Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 36 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ But there are already other plugin libraries! How is this one different? Here ar
- [**Plugins and finding them**](#plugins-and-finding-them)
- [**Type safety**](#type-safety)
- [**Slot as a collection**](#slot-as-a-collection)
- [**Quick selection**](#quick-selection)
- [**Additional restrictions**](#additional-restrictions)


Expand Down Expand Up @@ -366,28 +367,6 @@ some_slot['non_existent_key']()
#> run the slot default function
```

When a slot or selection should resolve to exactly one callable candidate, prefer `.one` to manual collection checks. It works on slots and on selections returned by `[...]` or `pop()`:

```python
@slot
def sum_slot(a, b) -> list[int]:
...

@sum_slot.plugin
def sum_plugin(a, b) -> int:
return a + b

selected_from_slot = sum_slot.one
selected_by_name = sum_slot['sum_plugin'].one

print(selected_from_slot(1, 2))
#> [3]
print(selected_by_name(1, 2))
#> [3]
```

`.one` returns a callable selection; it does not call it. The arguments above are passed to that returned selection. For `sum_slot.one`, the selection contains the only plugin registered in the slot; for `sum_slot['sum_plugin'].one`, the only plugin in that selection. If no plugin matches but the slot body is non-empty, that body is used as fallback. Otherwise, or if there is more than one candidate, `pristan.errors.OneResolutionError` is raised.

You can use the [`len()`](https://docs.python.org/3/library/functions.html#len) function to find out how many plugins you have:

```python
Expand All @@ -397,6 +376,16 @@ print(len(some_slot['name']))
#> 2
```

You can iterate over a slot to inspect the currently registered plugins. Iteration uses a snapshot of the plugin list that is fixed before the first item is yielded. After iteration has started, this snapshot is not checked against the slot again, so changes made from another thread are not attached or synchronized to that already-started iteration.

```python
for plugin in some_slot:
print(plugin.name)
#> name
#> name-2
#> name2
```

You can also convert a slot, a plugin selection, or a found result of `pop()` to [`bool`](https://docs.python.org/3/library/functions.html#bool). The result is `True` when it contains plugins or when the slot has a non-empty default function body:

```python
Expand Down Expand Up @@ -436,6 +425,31 @@ some_slot.pop('unknown', None)
> ⓘ If you use the base plugin name, all plugins with that declared name will be removed. If you use a name with a numeric suffix, only that specific plugin will be removed. The suffix `-1` refers to the first plugin, whose actual name has no suffix.


## Quick selection

When a slot or selection should resolve to exactly one callable candidate, prefer `.one` to manual collection checks. It works on slots and on selections returned by `[...]` or `pop()`:

```python
@slot
def sum_slot(a, b) -> list[int]:
...

@sum_slot.plugin
def sum_plugin(a, b) -> int:
return a + b

selected_from_slot = sum_slot.one
selected_by_name = sum_slot['sum_plugin'].one

print(selected_from_slot(1, 2))
#> [3]
print(selected_by_name(1, 2))
#> [3]
```

`.one` returns a callable selection; it does not call it. The arguments above are passed to that returned selection. For `sum_slot.one`, the selection contains the only plugin registered in the slot; for `sum_slot['sum_plugin'].one`, the only plugin in that selection. If no plugin matches but the slot body is non-empty, that body is used as fallback. Otherwise, or if there is more than one candidate, `pristan.errors.OneResolutionError` is raised.


## Additional restrictions

You can impose some additional restrictions on slots or individual plugins.
Expand Down
47 changes: 47 additions & 0 deletions docs/plans/2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Рефакторинг `SlotCaller` на `Slot`

## Summary

- `SlotCaller` будет хранить ссылку на `Slot`, а не отдельные копии `code_representation`, `slot_name`, `slot_function`, `type_check`.
- Публичное поведение `@slot`, вызова слотов, `.one`, `PluginsGroup` и entry point loading не меняем.
- Старый внутренний конструктор `SlotCaller(code_representation, slot_name, slot_function, type_check)` не сохраняем.

## Key Changes

- В `pristan/components/slot_caller.py` заменить конструктор на:

```python
def __init__(self, slot: 'Slot[PluginResult]') -> None: # type: ignore[name-defined] # noqa: F821
self.slot = slot
```

- Не добавлять `Protocol` и не добавлять `TYPE_CHECKING`-импорт.
- В `Slot.__init__` заменить создание caller на `SlotCaller(self)`.
- В `SlotCaller.__call__` в начале вызова снять локальный per-call snapshot:

```python
slot = self.slot
code_representation = slot.code_representation
slot_name = slot.slot_name
slot_function = slot.slot_function
type_check = slot.type_check
```

- Дальше внутри одного вызова использовать эти локальные переменные.
- В `CallerWithPlugins.one` обращаться к имени через `self.caller.slot.slot_name`.
- Убрать ставшие неиспользуемыми импорты из `slot_caller.py`.
- Принять новый `repr`: `SlotCaller(slot=Slot(...))`.

## Tests

- Обновить все прямые создания `SlotCaller(...)` в unit/typing tests на создание `Slot` и использование `slot.caller` или `SlotCaller(slot)`.
- Заменить тестовые monkeypatches вида `slot.caller.code_representation = ...` на `slot.code_representation = ...`.
- Обновить точные `repr`-ожидания для `SlotCaller`, `CallerWithPlugins` и `PluginsGroup`.
- Добавить/обновить тест, фиксирующий, что `SlotCaller` читает актуальный `slot.code_representation`.
- Прогнать `pytest`, обе coverage-команды, `ruff check pristan`, `ruff check tests`, `mypy --strict pristan`, `mypy tests --exclude tests/typing`.

## Assumptions

- Риски live-read приняты: ручная мутация metadata `Slot` после создания может менять поведение уже существующих caller/selection.
- Консистентность одного вызова защищаем локальным snapshot внутри `__call__`, но snapshot между вызовами больше не сохраняем.
- `SlotCaller` считается внутренним API; внешнюю совместимость старого конструктора не поддерживаем.
81 changes: 81 additions & 0 deletions docs/plans/3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# План: `SyntaxWarning` для `CallerWithPlugins.one`

## Общая идея

При любом доступе к `CallerWithPlugins.one` предупреждать пользователя, если слот не требует уникальных имен плагинов (`unique=False`). Это нужно и для успешного выбора одного плагина, и для ошибок разрешения: сам факт использования `.one` показывает, что код ожидает единичный плагин, поэтому слот лучше сделать строгим через `unique=True`.

## Кратко

- Перед началом реализации сохранить этот план в `docs/plans/3.md`.
- Добавить `SyntaxWarning` только в `CallerWithPlugins.one`.
- `Slot.one` не менять.
- Warning выпускать через `warnings.warn(..., SyntaxWarning, stacklevel=2)`.
- Сообщение warning: `Consider setting unique=True for slot "{slot_name}", because this code uses .one to work with a single plugin.`

## Изменения API и реализации

- В `pristan/components/slot_caller.py` импортировать `warnings`.
- В начале `CallerWithPlugins.one`, до проверок `not self` и `len(self) > 1`, если `not self.caller.slot.unique`, выпустить `SyntaxWarning`.
- После warning оставить текущую resolution-логику без изменений:
- пустая selection с пустым body поднимает текущий `OneResolutionError`;
- selection с несколькими плагинами поднимает текущий `OneResolutionError`;
- разрешимая selection возвращает `self`.
- Сигнатуры, типы, read-only контракт `.one`, вызов плагинов и fallback-поведение не менять.

## План тестирования

### Общие требования

- Каждый новый или измененный тест получает докстринг в стиле существующих тестов.
- В многострочных докстрингах делать перенос строки сразу после открывающих `"""`.
- Позитивные warning-проверки делать через `pytest.warns(SyntaxWarning, match=match(...))`.
- Проверки отсутствия warning делать через `warnings.catch_warnings(record=True)` и assert, что среди записей нет `SyntaxWarning`.

### Рантайм-тесты

1. `test_caller_with_plugins_one_warns_when_slot_is_not_unique`
- Идея: `.one` у выборки предупреждает для non-unique слота.
- Фиксирует: успешный доступ к `CallerWithPlugins.one` при `unique=False` выпускает `SyntaxWarning` с рекомендацией рассмотреть `unique=True`.
- Сценарий: создать слот с `unique=False`, зарегистрировать один плагин, получить selection, прочитать `selection.one` внутри `pytest.warns(...)`, проверить, что возвращен тот же объект.

2. `test_caller_with_plugins_one_does_not_warn_when_slot_is_unique`
- Идея: strict-слот не должен получать предупреждение.
- Фиксирует: при `unique=True` успешный доступ к `CallerWithPlugins.one` не выпускает `SyntaxWarning`.
- Сценарий: создать слот с `unique=True`, зарегистрировать один плагин, получить selection, прочитать `selection.one` внутри `warnings.catch_warnings(record=True)`, проверить identity результата и отсутствие `SyntaxWarning`.

3. `test_caller_with_plugins_one_resolution_errors_warn_when_slot_is_not_unique`
- Идея: warning появляется до ошибок разрешения `.one`.
- Фиксирует: пустая selection с пустым body и selection с несколькими плагинами при `unique=False` выпускают `SyntaxWarning`, а затем сохраняют текущий `OneResolutionError`.
- Сценарий: в одном тесте проверить обе ошибочные ветки через вложенные `pytest.warns(...)` и `pytest.raises(..., match=match(...))`.
- Докстринга должна явно объяснять мотивацию:
```python
"""
Non-unique selections warn even when .one cannot resolve one plugin.

A resolution error still means user code tried to work with one plugin
through .one, so the warning should recommend unique=True before raising
the existing OneResolutionError.
"""
```

4. Обновить существующие тесты с успешным или ошибочным `CallerWithPlugins.one` на non-unique слотах
- Идея: новая диагностика не должна засорять warning summary в тестовом прогоне.
- Фиксирует: старые проверки identity, snapshots, pop/getitem, fallback и resolution errors сохраняют поведение, но теперь явно ожидают warning там, где читают `selection.one` у `unique=False`.
- Сценарий: локально обернуть чтения `selection.one`/`selection.one()` в `pytest.warns(...)` или создать слот с `unique=True`, если уникальность не влияет на смысл теста.

## Verification

Запускать из активированного venv:

- `pytest tests/units/components/test_slot_caller.py tests/units/components/test_slot.py --cache-clear --assert=plain`
- `ruff check pristan`
- `ruff check tests`
- `mypy --strict pristan`
- `mypy tests --exclude tests/typing`
- `coverage run --source=pristan --omit="*tests*" -m pytest --cache-clear --assert=plain && coverage report -m --fail-under=100`
- `coverage run --branch --source=pristan --omit="*tests*" -m pytest --cache-clear --assert=plain && coverage report -m --fail-under=100`

## Допущения

- Предупреждение нужно только для `CallerWithPlugins.one`; прямой `Slot.one` остается без нового warning.
- Warning не меняет control flow: после него `.one` либо возвращает `self`, либо поднимает тот же `OneResolutionError`, что и раньше.
Loading
Loading