Skip to content

Commit 19f5f17

Browse files
authored
Merge pull request #26 from mutating/develop
0.0.22
2 parents 179413a + e72f3e9 commit 19f5f17

17 files changed

Lines changed: 1872 additions & 252 deletions

File tree

README.md

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ But there are already other plugin libraries! How is this one different? Here ar
3434
- [**Plugins and finding them**](#plugins-and-finding-them)
3535
- [**Type safety**](#type-safety)
3636
- [**Slot as a collection**](#slot-as-a-collection)
37+
- [**Quick selection**](#quick-selection)
3738
- [**Additional restrictions**](#additional-restrictions)
3839

3940

@@ -366,28 +367,6 @@ some_slot['non_existent_key']()
366367
#> run the slot default function
367368
```
368369

369-
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()`:
370-
371-
```python
372-
@slot
373-
def sum_slot(a, b) -> list[int]:
374-
...
375-
376-
@sum_slot.plugin
377-
def sum_plugin(a, b) -> int:
378-
return a + b
379-
380-
selected_from_slot = sum_slot.one
381-
selected_by_name = sum_slot['sum_plugin'].one
382-
383-
print(selected_from_slot(1, 2))
384-
#> [3]
385-
print(selected_by_name(1, 2))
386-
#> [3]
387-
```
388-
389-
`.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.
390-
391370
You can use the [`len()`](https://docs.python.org/3/library/functions.html#len) function to find out how many plugins you have:
392371

393372
```python
@@ -397,6 +376,16 @@ print(len(some_slot['name']))
397376
#> 2
398377
```
399378

379+
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.
380+
381+
```python
382+
for plugin in some_slot:
383+
print(plugin.name)
384+
#> name
385+
#> name-2
386+
#> name2
387+
```
388+
400389
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:
401390

402391
```python
@@ -436,6 +425,31 @@ some_slot.pop('unknown', None)
436425
> ⓘ 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.
437426
438427

428+
## Quick selection
429+
430+
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()`:
431+
432+
```python
433+
@slot
434+
def sum_slot(a, b) -> list[int]:
435+
...
436+
437+
@sum_slot.plugin
438+
def sum_plugin(a, b) -> int:
439+
return a + b
440+
441+
selected_from_slot = sum_slot.one
442+
selected_by_name = sum_slot['sum_plugin'].one
443+
444+
print(selected_from_slot(1, 2))
445+
#> [3]
446+
print(selected_by_name(1, 2))
447+
#> [3]
448+
```
449+
450+
`.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.
451+
452+
439453
## Additional restrictions
440454

441455
You can impose some additional restrictions on slots or individual plugins.

docs/plans/2.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Рефакторинг `SlotCaller` на `Slot`
2+
3+
## Summary
4+
5+
- `SlotCaller` будет хранить ссылку на `Slot`, а не отдельные копии `code_representation`, `slot_name`, `slot_function`, `type_check`.
6+
- Публичное поведение `@slot`, вызова слотов, `.one`, `PluginsGroup` и entry point loading не меняем.
7+
- Старый внутренний конструктор `SlotCaller(code_representation, slot_name, slot_function, type_check)` не сохраняем.
8+
9+
## Key Changes
10+
11+
- В `pristan/components/slot_caller.py` заменить конструктор на:
12+
13+
```python
14+
def __init__(self, slot: 'Slot[PluginResult]') -> None: # type: ignore[name-defined] # noqa: F821
15+
self.slot = slot
16+
```
17+
18+
- Не добавлять `Protocol` и не добавлять `TYPE_CHECKING`-импорт.
19+
- В `Slot.__init__` заменить создание caller на `SlotCaller(self)`.
20+
- В `SlotCaller.__call__` в начале вызова снять локальный per-call snapshot:
21+
22+
```python
23+
slot = self.slot
24+
code_representation = slot.code_representation
25+
slot_name = slot.slot_name
26+
slot_function = slot.slot_function
27+
type_check = slot.type_check
28+
```
29+
30+
- Дальше внутри одного вызова использовать эти локальные переменные.
31+
- В `CallerWithPlugins.one` обращаться к имени через `self.caller.slot.slot_name`.
32+
- Убрать ставшие неиспользуемыми импорты из `slot_caller.py`.
33+
- Принять новый `repr`: `SlotCaller(slot=Slot(...))`.
34+
35+
## Tests
36+
37+
- Обновить все прямые создания `SlotCaller(...)` в unit/typing tests на создание `Slot` и использование `slot.caller` или `SlotCaller(slot)`.
38+
- Заменить тестовые monkeypatches вида `slot.caller.code_representation = ...` на `slot.code_representation = ...`.
39+
- Обновить точные `repr`-ожидания для `SlotCaller`, `CallerWithPlugins` и `PluginsGroup`.
40+
- Добавить/обновить тест, фиксирующий, что `SlotCaller` читает актуальный `slot.code_representation`.
41+
- Прогнать `pytest`, обе coverage-команды, `ruff check pristan`, `ruff check tests`, `mypy --strict pristan`, `mypy tests --exclude tests/typing`.
42+
43+
## Assumptions
44+
45+
- Риски live-read приняты: ручная мутация metadata `Slot` после создания может менять поведение уже существующих caller/selection.
46+
- Консистентность одного вызова защищаем локальным snapshot внутри `__call__`, но snapshot между вызовами больше не сохраняем.
47+
- `SlotCaller` считается внутренним API; внешнюю совместимость старого конструктора не поддерживаем.

docs/plans/3.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# План: `SyntaxWarning` для `CallerWithPlugins.one`
2+
3+
## Общая идея
4+
5+
При любом доступе к `CallerWithPlugins.one` предупреждать пользователя, если слот не требует уникальных имен плагинов (`unique=False`). Это нужно и для успешного выбора одного плагина, и для ошибок разрешения: сам факт использования `.one` показывает, что код ожидает единичный плагин, поэтому слот лучше сделать строгим через `unique=True`.
6+
7+
## Кратко
8+
9+
- Перед началом реализации сохранить этот план в `docs/plans/3.md`.
10+
- Добавить `SyntaxWarning` только в `CallerWithPlugins.one`.
11+
- `Slot.one` не менять.
12+
- Warning выпускать через `warnings.warn(..., SyntaxWarning, stacklevel=2)`.
13+
- Сообщение warning: `Consider setting unique=True for slot "{slot_name}", because this code uses .one to work with a single plugin.`
14+
15+
## Изменения API и реализации
16+
17+
- В `pristan/components/slot_caller.py` импортировать `warnings`.
18+
- В начале `CallerWithPlugins.one`, до проверок `not self` и `len(self) > 1`, если `not self.caller.slot.unique`, выпустить `SyntaxWarning`.
19+
- После warning оставить текущую resolution-логику без изменений:
20+
- пустая selection с пустым body поднимает текущий `OneResolutionError`;
21+
- selection с несколькими плагинами поднимает текущий `OneResolutionError`;
22+
- разрешимая selection возвращает `self`.
23+
- Сигнатуры, типы, read-only контракт `.one`, вызов плагинов и fallback-поведение не менять.
24+
25+
## План тестирования
26+
27+
### Общие требования
28+
29+
- Каждый новый или измененный тест получает докстринг в стиле существующих тестов.
30+
- В многострочных докстрингах делать перенос строки сразу после открывающих `"""`.
31+
- Позитивные warning-проверки делать через `pytest.warns(SyntaxWarning, match=match(...))`.
32+
- Проверки отсутствия warning делать через `warnings.catch_warnings(record=True)` и assert, что среди записей нет `SyntaxWarning`.
33+
34+
### Рантайм-тесты
35+
36+
1. `test_caller_with_plugins_one_warns_when_slot_is_not_unique`
37+
- Идея: `.one` у выборки предупреждает для non-unique слота.
38+
- Фиксирует: успешный доступ к `CallerWithPlugins.one` при `unique=False` выпускает `SyntaxWarning` с рекомендацией рассмотреть `unique=True`.
39+
- Сценарий: создать слот с `unique=False`, зарегистрировать один плагин, получить selection, прочитать `selection.one` внутри `pytest.warns(...)`, проверить, что возвращен тот же объект.
40+
41+
2. `test_caller_with_plugins_one_does_not_warn_when_slot_is_unique`
42+
- Идея: strict-слот не должен получать предупреждение.
43+
- Фиксирует: при `unique=True` успешный доступ к `CallerWithPlugins.one` не выпускает `SyntaxWarning`.
44+
- Сценарий: создать слот с `unique=True`, зарегистрировать один плагин, получить selection, прочитать `selection.one` внутри `warnings.catch_warnings(record=True)`, проверить identity результата и отсутствие `SyntaxWarning`.
45+
46+
3. `test_caller_with_plugins_one_resolution_errors_warn_when_slot_is_not_unique`
47+
- Идея: warning появляется до ошибок разрешения `.one`.
48+
- Фиксирует: пустая selection с пустым body и selection с несколькими плагинами при `unique=False` выпускают `SyntaxWarning`, а затем сохраняют текущий `OneResolutionError`.
49+
- Сценарий: в одном тесте проверить обе ошибочные ветки через вложенные `pytest.warns(...)` и `pytest.raises(..., match=match(...))`.
50+
- Докстринга должна явно объяснять мотивацию:
51+
```python
52+
"""
53+
Non-unique selections warn even when .one cannot resolve one plugin.
54+
55+
A resolution error still means user code tried to work with one plugin
56+
through .one, so the warning should recommend unique=True before raising
57+
the existing OneResolutionError.
58+
"""
59+
```
60+
61+
4. Обновить существующие тесты с успешным или ошибочным `CallerWithPlugins.one` на non-unique слотах
62+
- Идея: новая диагностика не должна засорять warning summary в тестовом прогоне.
63+
- Фиксирует: старые проверки identity, snapshots, pop/getitem, fallback и resolution errors сохраняют поведение, но теперь явно ожидают warning там, где читают `selection.one` у `unique=False`.
64+
- Сценарий: локально обернуть чтения `selection.one`/`selection.one()` в `pytest.warns(...)` или создать слот с `unique=True`, если уникальность не влияет на смысл теста.
65+
66+
## Verification
67+
68+
Запускать из активированного venv:
69+
70+
- `pytest tests/units/components/test_slot_caller.py tests/units/components/test_slot.py --cache-clear --assert=plain`
71+
- `ruff check pristan`
72+
- `ruff check tests`
73+
- `mypy --strict pristan`
74+
- `mypy tests --exclude tests/typing`
75+
- `coverage run --source=pristan --omit="*tests*" -m pytest --cache-clear --assert=plain && coverage report -m --fail-under=100`
76+
- `coverage run --branch --source=pristan --omit="*tests*" -m pytest --cache-clear --assert=plain && coverage report -m --fail-under=100`
77+
78+
## Допущения
79+
80+
- Предупреждение нужно только для `CallerWithPlugins.one`; прямой `Slot.one` остается без нового warning.
81+
- Warning не меняет control flow: после него `.one` либо возвращает `self`, либо поднимает тот же `OneResolutionError`, что и раньше.

0 commit comments

Comments
 (0)