diff --git a/README.md b/README.md index 8e741b9..5ca7095 100644 --- a/README.md +++ b/README.md @@ -442,12 +442,12 @@ selected_from_slot = sum_slot.one selected_by_name = sum_slot['sum_plugin'].one print(selected_from_slot(1, 2)) -#> [3] +#> 3 print(selected_by_name(1, 2)) -#> [3] +#> 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. +`.one` returns a callable selection without calling it. For parameterized list/dict slots like this one, calling that selection forwards the arguments and returns the sole plugin or fallback result contained in the aggregate. `sum_slot.one` selects the only registered plugin; `sum_slot['sum_plugin'].one` selects the only plugin in that named selection. If no plugin matches, a non-empty slot body is used as fallback. `pristan.errors.OneResolutionError` is raised when there is no matching plugin or fallback, more than one candidate matches, or a parameterized list/dict aggregate yields zero or multiple results. ## Additional restrictions diff --git a/docs/plans/6.md b/docs/plans/6.md new file mode 100644 index 0000000..c055399 --- /dev/null +++ b/docs/plans/6.md @@ -0,0 +1,202 @@ +# План: прямой результат для `.one()` и строгая cardinality-проверка + +## Summary + +- Перед имплементацией сохранить этот план в [docs/plans/6.md](/Users/pomponchik/Desktop/Projects/symplug/docs/plans/6.md). +- По TDD: сначала добавить/обновить runtime- и typing-тесты, убедиться, что новые проверки падают на текущей реализации, затем менять код. +- Новое поведение: `slot.one() == value`, а не `slot.one() == [value]` / `{'name': value}` для параметризованных `List[T]/list[T]` и `Dict[str, T]/dict[str, T]`. +- Если параметризованный `.one()` получает aggregate длины `0` или `>1`, он кидает `OneResolutionError`. +- Для слотов без return annotation и для bare `list/List/dict/Dict` без параметров `.one()` не делает cardinality-check и возвращает `None`. +- README обновить как часть реализации: заменить примеры старого `.one()` aggregate-результата на прямой payload. README не тестировать. + +## Public APIs / Interfaces / Types + +- Добавить `OneCallerWithPlugins[PluginResult]` как наследника `CallerWithPlugins[PluginResult]`. +- `OneCallerWithPlugins.__call__` вызывает `super().__call__()`, затем: + - если `caller.slot.code_representation.returning_type is sentinel`, возвращает `None`; + - если результат `list`/`dict`, требует длину `1`, иначе кидает `OneResolutionError`; + - для `list` возвращает единственный элемент, для `dict` возвращает единственное значение; + - если результат не `list`/`dict`, возвращает его как есть. +- У `OneCallerWithPlugins` добавить докстринг: класс нужен для runtime/typing-контракта `.one()`, в отличие от `CallerWithPlugins` он возвращает один payload, а не aggregate `SlotResult`. +- `OneCallerWithPlugins.__call__` аннотировать как `-> PluginResult`; из-за несовместимости override с `CallerWithPlugins.__call__ -> SlotResult[PluginResult]` поставить локальный `# type: ignore[override]` на метод, без module-level suppressions. +- `Slot.one` и `CallerWithPlugins.one` должны возвращать новый `OneCallerWithPlugins`, а не `self`. +- Добавить `OneSlotSelectionProtocol`: `__call__` возвращает `PluginResult`; обновить `.one` в `SlotProtocol` и `SlotSelectionProtocol`. +- Для `List[T]/list[T]` и `Dict[str, T]/dict[str, T]` `.one(...)` типизируется как `T`; для отсутствующего хинта и bare `List/list/Dict/dict` тип результата `.one(...)` остается `Any`. + +## Изменения реализации + +- В [pristan/components/slot_caller.py](/Users/pomponchik/Desktop/Projects/symplug/pristan/components/slot_caller.py) добавить `OneCallerWithPlugins`. +- В `OneCallerWithPlugins.__call__` порядок строго такой: сначала `super().__call__(*args, **kwargs)`, затем проверка `returning_type is sentinel`, затем `list/dict` cardinality и unwrap. +- В `CallerWithPlugins.one` оставить существующие warning/error правила выбора кандидатов, но возвращать `OneCallerWithPlugins(self.caller, list(self.plugins))`. +- В `Slot.one` после lazy loading и проверки числа зарегистрированных плагинов возвращать `OneCallerWithPlugins(self.caller, list(self.plugins.plugins))`. +- Текст новой call-time cardinality ошибки: `Slot "name" .one returned N results, so .one cannot choose one.` +- В [pristan/common_types.py](/Users/pomponchik/Desktop/Projects/symplug/pristan/common_types.py) добавить one-протокол и обновить существующие протоколы. +- Не менять `SlotCaller.__call__`, `SlotCodeRepresenter`, регистрацию плагинов, lazy loading, empty-body правила, обычные aggregate-вызовы `slot()` / `selection()` и текущую нормализацию unannotated/loose веток. + +## План тестирования + +### Общие требования + +- Фикстуру параметризации `CallerWithPlugins` / `OneCallerWithPlugins` вынести в [tests/units/components/conftest.py](/Users/pomponchik/Desktop/Projects/symplug/tests/units/components/conftest.py). +- Runtime-тесты размещать в [tests/units/components/test_slot.py](/Users/pomponchik/Desktop/Projects/symplug/tests/units/components/test_slot.py) и [tests/units/components/test_slot_caller.py](/Users/pomponchik/Desktop/Projects/symplug/tests/units/components/test_slot_caller.py). +- Использовать существующие фикстуры `subscribable_list_type`, `subscribable_dict_type`, `list_type`, `dict_type`. +- Схлопывать проверки через `pytest.mark.parametrize` по access path, container kind, result size и payload. +- Typing-тесты обновлять в существующих файлах `tests/typing/...`; не добавлять README-тесты. + +### Runtime-Тесты + +1. Обновить базовые тесты `CallerWithPlugins.one` через `caller_class` fixture. + - Non-unique selection с одним плагином предупреждает. + - Unique selection с одним плагином не предупреждает. + - Пустая selection с empty body кидает старый `OneResolutionError`. + - Multi-plugin selection кидает старый `OneResolutionError`. + - `.one` возвращает `OneCallerWithPlugins`, это не тот же объект selection, но внутри лежат те же `Plugin`-объекты. + +2. Добавить singleton plugin unwrap matrix для параметризованных контейнеров. + - `List[T]/list[T]`: один плагин возвращает `T`, `.one()` возвращает `T`. + - `Dict[str, T]/dict[str, T]`: один плагин возвращает `T`, `.one()` возвращает `T`. + - Payload rows: `int`, `str`, `tuple`, `List[int]`, `Dict[str, int]`, `None`. + - Access paths: `slot.one()`, `slot['name'].one()`, `slot.pop('name').one()`, direct `OneCallerWithPlugins(...)()`. + +3. Добавить singleton fallback unwrap matrix для параметризованных контейнеров. + - `List[T]/list[T]` fallback body возвращает `[value]`, `.one()` возвращает `value`. + - `Dict[str, T]/dict[str, T]` fallback body возвращает `{'only': value}`, `.one()` возвращает `value`. + - Payload rows: `int`, `str`, `tuple`, `List[int]`, `Dict[str, int]`, `None`. + - Access paths: `slot.one()` и empty `selection.one()`. + - Отдельная row: у родительского слота есть другие плагины, но empty selection вызывает fallback и возвращает единственный payload. + - Для non-unique empty selection с non-empty fallback проверить `SyntaxWarning`, после которого вызов успешно возвращает payload. + +4. Добавить fallback cardinality error matrix для параметризованных контейнеров. + - `List[T]/list[T]`: non-empty body с docstring + `[]` и `[first, second]` кидает `OneResolutionError`. + - `Dict[str, T]/dict[str, T]`: non-empty body с docstring + `{}` и `{'first': first, 'second': second}` кидает `OneResolutionError`. + - Access paths: `slot.one()` и empty `selection.one()`. + - Чтение `.one` должно успешно вернуть `OneCallerWithPlugins`; исключение возникает только при вызове returned one-caller. + +5. Добавить direct `OneCallerWithPlugins` multi-plugin cardinality test. + - Создать `OneCallerWithPlugins` напрямую с двумя плагинами для list/dict слота. + - Вызов должен получить aggregate от `super().__call__()` и кинуть call-time `OneResolutionError`. + +6. Добавить unannotated fallback no-cardinality matrix. + - Слот без return annotation. + - Fallback возвращает `None`, scalar, `[]`, `[value]`, `[first, second]`, `{}`, `{'only': value}`, `{'first': first, 'second': second}`. + - `.one()` возвращает `None` и не кидает cardinality error. + - Access paths: `slot.one()` и empty `selection.one()`. + +7. Добавить bare `list/List` fallback no-cardinality matrix. + - Слот с return annotation `list` или `List`. + - Fallback возвращает `[]`, `[value]`, `[first, second]`. + - `.one()` возвращает `None` и не кидает cardinality error. + - Access paths: `slot.one()` и empty `selection.one()`. + +8. Добавить bare `dict/Dict` fallback no-cardinality matrix. + - Слот с return annotation `dict` или `Dict`. + - Fallback возвращает `{}`, `{'only': value}`, `{'first': first, 'second': second}`. + - `.one()` возвращает `None` и не кидает cardinality error. + - Access paths: `slot.one()` и empty `selection.one()`. + +9. Добавить unannotated/loose plugin path no-cardinality test. + - Для no annotation, bare `list/List`, bare `dict/Dict` зарегистрировать один плагин. + - Плагин возвращает scalar/list/dict и записывает side effect. + - Проверить, что плагин выполняется, `.one()` возвращает `None`, cardinality error не возникает. + +10. Добавить plugin-over-bad-fallback test. + - Для параметризованного list/dict слота есть один выбранный плагин. + - Fallback body либо кидает исключение, либо вернул бы `[]`, `[a, b]`, `{}`, `{'a': a, 'b': b}`. + - `slot.one()` и `selection.one()` возвращают plugin payload; fallback не выполняется. + +11. Обновить существующие public `.one` behavior tests. + - Entry point smoke tests ожидают `7`/`8`, а не `{'name': 7}` / `{'name2': 8}`. + - `slot.one`, `slot['name'].one`, `slot.pop('name').one`, duplicate exact keys, popped selections и run-once ожидают direct payload. + - Fallback body success для `slot.one()` ожидает direct payload. + - Старые plugin-count ошибки на property access сохраняются. + +12. Обновить operational surface tests. + - `Slot.one` на non-unique slot не предупреждает. + - `CallerWithPlugins.one` предупреждает для non-unique selection. + - `.one` read-only для `Slot`, `CallerWithPlugins` и возвращенного `OneCallerWithPlugins`. + - Property access `.one` не выполняет plugin body и fallback body. + - Lazy loading, snapshot и run-once state сохраняют существующую семантику с новым `OneCallerWithPlugins`. + +13. Сохранить проверки неизменного aggregate-контракта и exception passthrough. + - `SlotCaller` и обычный `CallerWithPlugins` без `.one` продолжают возвращать `[]`/`{}` для empty list/dict defaults. + - Обычные `slot()` / `selection()` продолжают возвращать aggregate `list`/`dict`. + - Исключение из plugin body под `.one()` пробрасывается как есть. + - Исключение из fallback body под `.one()` пробрасывается как есть. + - Существующий TypeError от type-check под `.one()` не заменяется на `OneResolutionError`. + +### Тесты Типизации + +1. Обновить concrete component typing. + - `Slot[int].one -> OneCallerWithPlugins[int]`. + - `CallerWithPlugins[int].one -> OneCallerWithPlugins[int]`. + - `OneCallerWithPlugins[int].__call__(...) -> int`. + - `OneCallerWithPlugins[int].one` остается payload-typed callable. + +2. Обновить generic result matrix для `@slot`, `@slot()`, configured decorators и direct-call `slot(function)`. + - `List[int]/list[int]`: обычный вызов `-> list[int]`, `.one(...) -> int`. + - `Dict[str, int]/dict[str, int]`: обычный вызов `-> dict[str, int]`, `.one(...) -> int`. + - Встроенные generics проверять под существующим `skipif(sys.version_info < (3, 9))`. + +3. Обновить selection/narrowing typing. + - `slot['name']` и `slot.pop('name')`: обычный вызов selection остается aggregate. + - `selection.one(...)` и `popped_selection.one(...)` возвращают payload. + - `selection.one.one(...)` и `popped_selection.one.one(...)` возвращают payload. + - `pop(..., default)` после narrowing в selection branch сохраняет `.one(...) -> payload`. + +4. Обновить protocol typing. + - `SlotProtocol.one -> OneSlotSelectionProtocol`. + - `SlotSelectionProtocol.one -> OneSlotSelectionProtocol`. + - `OneSlotSelectionProtocol.__call__ -> PluginResult`. + - Обычные `SlotProtocol.__call__` и `SlotSelectionProtocol.__call__` остаются aggregate. + +5. Добавить assignment checks. + - Результат `.one(...)` можно присвоить в `PluginResult`. + - Результат `.one(...)` нельзя присвоить в `List[PluginResult]` / `Dict[str, PluginResult]`. + - Обычный `slot(...)` / `selection(...)` по-прежнему нельзя присвоить в scalar payload. + - `.one.__call__` совместим с `Callable[..., PluginResult]` и несовместим с aggregate callable. + +6. Добавить call-shape checks. + - `slot.one(...)`, `selection.one(...)`, `slot.one.one(...)`, `selection.one.one(...)` принимают те же валидные аргументы, что слот. + - Missing args, wrong arg types и unknown kwargs остаются mypy/runtime errors в существующем стиле typing tests. + +7. Обновить loose built-in typing. + - Bare `list` и `dict`: обычный вызов остается `list[Any]` / `dict[str, Any]`. + - `.one(...) -> Any`. + - Selection `.one(...) -> Any`. + +8. Обновить loose typing containers и unannotated typing. + - Bare `List` и `Dict`: обычный вызов остается `list[Any]` / `dict[str, Any]`. + - Bare `List` / `Dict` `.one(...) -> Any`. + - No annotation `.one(...) -> Any`. + - Selection `.one(...) -> Any`. + +9. Добавить advanced payload typing. + - `List[List[int]]` / `list[list[int]]` `.one(...) -> list[int]`. + - `Dict[str, Dict[str, int]]` / `dict[str, dict[str, int]]` `.one(...) -> dict[str, int]`. + - `List[None]` / `list[None]` и `Dict[str, None]` / `dict[str, None]` `.one(...) -> None`. + - `List[Any]`, `Dict[str, Any]`, `list[Any]`, `dict[str, Any]` `.one(...) -> Any`. + +10. Добавить read-only typing surface. + - Assignment/deletion `.one` запрещены на `SlotProtocol`. + - Assignment/deletion `.one` запрещены на `SlotSelectionProtocol`. + - Assignment/deletion `.one` запрещены на `OneSlotSelectionProtocol` / returned one-caller typing surface. + +## Проверка + +- Запустить из активированного виртуального окружения: + - `pytest tests/units/components/test_slot_caller.py` + - `pytest tests/units/components/test_slot.py` + - `pytest tests/smokes/test_entry_points.py` + - `mypy tests --exclude tests/typing` + - typing test workflow проекта для `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` + - `ruff check pristan` + - `ruff check tests` + - `mypy --strict pristan` + +## Предположения + +- Для `returning_type is sentinel` (`no annotation`, bare `list/List/dict/Dict`) `.one()` намеренно возвращает `None` во всех runtime-вариантах, включая singleton, чтобы не интерпретировать untyped/loose контейнер как строгий aggregate. +- `dict` singleton unwrap возвращает единственное значение, а не ключ и не пару `(key, value)`. +- `OneCallerWithPlugins` копирует только список ссылок на `Plugin`-объекты; сами `Plugin`-объекты не копируются, поэтому `run_once` и состояние плагинов сохраняются. diff --git a/pristan/common_types.py b/pristan/common_types.py index a149df4..11702a6 100644 --- a/pristan/common_types.py +++ b/pristan/common_types.py @@ -51,12 +51,25 @@ def __len__(self) -> int: ... class SlotSelectionProtocol(BaseSlotViewProtocol[SlotParameters, SlotCallResultCovariant, PluginResultCovariant], Protocol[SlotParameters, SlotCallResultCovariant, PluginResultCovariant]): # pragma: no cover @property - def one(self) -> 'SlotSelectionProtocol[SlotParameters, SlotCallResultCovariant, PluginResultCovariant]': ... + def one(self) -> 'OneSlotSelectionProtocol[SlotParameters, PluginResultCovariant]': ... + + +class OneSlotSelectionProtocol(Protocol[SlotParameters, PluginResultCovariant]): # pragma: no cover + def __call__(self, *args: SlotParameters.args, **kwargs: SlotParameters.kwargs) -> PluginResultCovariant: ... + + def __iter__(self) -> Iterator[PluginProtocol[SlotParameters, PluginResultCovariant]]: ... + + def __bool__(self) -> bool: ... + + def __len__(self) -> int: ... + + @property + def one(self) -> 'OneSlotSelectionProtocol[SlotParameters, PluginResultCovariant]': ... class SlotProtocol(BaseSlotViewProtocol[SlotParameters, SlotCallResultCovariant, PluginResult], Protocol[SlotParameters, SlotCallResultCovariant, PluginResult]): # pragma: no cover @property - def one(self) -> SlotSelectionProtocol[SlotParameters, SlotCallResultCovariant, PluginResult]: ... + def one(self) -> OneSlotSelectionProtocol[SlotParameters, PluginResult]: ... @overload def plugin(self, plugin_function_or_name: Optional[str] = None, unique: bool = False, engine: Optional[Union[List[str], str]] = None, run_once: bool = False) -> Callable[[Callable[SlotParameters, PluginResult]], Callable[SlotParameters, PluginResult]]: ... diff --git a/pristan/components/slot.py b/pristan/components/slot.py index 7cc0459..22e60e7 100644 --- a/pristan/components/slot.py +++ b/pristan/components/slot.py @@ -40,6 +40,7 @@ from pristan.components.plugins_group import PluginsGroup from pristan.components.slot_caller import ( CallerWithPlugins, + OneCallerWithPlugins, SlotCaller, ) from pristan.components.slot_code_representer import SlotCodeRepresenter @@ -119,10 +120,10 @@ def __bool__(self) -> bool: return bool(self.backed_caller) @property - def one(self) -> CallerWithPlugins[PluginResult]: + def one(self) -> OneCallerWithPlugins[PluginResult]: with self.lock: self._load_entrypoints() - snapshot = CallerWithPlugins(self.caller, list(self.plugins.plugins)) + snapshot = OneCallerWithPlugins(self.caller, list(self.plugins.plugins)) if not snapshot: raise OneResolutionError(f'Slot "{self.slot_name}" has no registered plugins and its body is empty.') if len(snapshot) > 1: diff --git a/pristan/components/slot_caller.py b/pristan/components/slot_caller.py index 0b0849d..5a5370c 100644 --- a/pristan/components/slot_caller.py +++ b/pristan/components/slot_caller.py @@ -1,5 +1,5 @@ import warnings -from typing import Any, Dict, Generator, Generic, List, NoReturn, Type, Union +from typing import Any, Dict, Generator, Generic, List, NoReturn, Type, Union, cast from denial import InnerNoneType from printo import repred @@ -65,6 +65,15 @@ def __call__(self, plugins: Union[PluginsGroup[PluginResult], List[Plugin[Plugin @repred class CallerWithPlugins(Generic[PluginResult]): + """ + Callable plugin selection returned by regular slot filtering. + + `CallerWithPlugins` preserves the normal slot contract: dispatch returns + the full aggregate `SlotResult`. Its `.one` property creates a + `OneCallerWithPlugins`, which keeps the same selected plugins but unwraps a + single payload after dispatch. + """ + def __init__(self, caller: SlotCaller[PluginResult], plugins: List[Plugin[PluginResult]]) -> None: self.caller = caller self.plugins = plugins @@ -82,14 +91,14 @@ def __len__(self) -> int: return len(self.plugins) @property - def one(self) -> 'CallerWithPlugins[PluginResult]': + def one(self) -> 'OneCallerWithPlugins[PluginResult]': if not self.caller.slot.unique: warnings.warn(f'Consider setting unique=True for slot "{self.caller.slot.slot_name}", because this code uses .one to work with a single plugin.', SyntaxWarning, stacklevel=2) if not self: raise OneResolutionError(f'Selection from slot "{self.caller.slot.slot_name}" has no selected plugins and the slot body is empty.') if len(self) > 1: raise OneResolutionError(f'Selection from slot "{self.caller.slot.slot_name}" has {len(self)} selected plugins, so .one cannot choose one.') - return self + return OneCallerWithPlugins(self.caller, list(self.plugins)) @one.setter def one(self, value: Any) -> NoReturn: # noqa: ARG002 @@ -98,3 +107,32 @@ def one(self, value: Any) -> NoReturn: # noqa: ARG002 @one.deleter def one(self) -> NoReturn: raise AttributeError('Attribute ".one" is read-only.') + + +class OneCallerWithPlugins(CallerWithPlugins[PluginResult]): + """ + Callable selection returned by `.one`. + + `CallerWithPlugins` keeps the regular slot contract and returns the full + aggregate `SlotResult`. This subclass is used only for `.one`: it delegates + dispatch to `CallerWithPlugins`, then verifies that an aggregate has exactly + one result and unwraps that result to the plugin payload. + """ + + def __call__(self, *args: SlotParameters.args, **kwargs: SlotParameters.kwargs) -> PluginResult: # type: ignore[override] + result = super().__call__(*args, **kwargs) + + if self.caller.slot.code_representation.returning_type is return_type_sentinel: + return None # type: ignore[return-value] + + if isinstance(result, (list, dict)): + result_count = len(result) + if result_count != 1: + raise OneResolutionError(f'Slot "{self.caller.slot.slot_name}" .one returned {result_count} results, so .one cannot choose one.') + + payload = result[0] if isinstance(result, list) else next(iter(result.values())) + + else: + payload = cast(PluginResult, result) + + return payload diff --git a/pyproject.toml b/pyproject.toml index a830958..948e6f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pristan" -version = "0.0.23" +version = "0.0.24" authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }] description = "Function-based plugin system with respect to typing" readme = "README.md" diff --git a/tests/smokes/demo/simple_slots.py b/tests/smokes/demo/simple_slots.py index c5c3a16..6ead068 100644 --- a/tests/smokes/demo/simple_slots.py +++ b/tests/smokes/demo/simple_slots.py @@ -47,7 +47,7 @@ def simple_bool_slot() -> Dict[str, int]: @slot def simple_one_slot() -> Dict[str, int]: - """For test_slot_one_loads_plugin_from_real_entrypoint_and_calls_result only; do not reuse.""" + """For test_slot_one_loads_plugin_from_real_entrypoint_once_and_returns_payload only; do not reuse.""" return {} @@ -65,7 +65,7 @@ def simple_contains_slot() -> Dict[str, int]: @slot(entrypoint_group='another_name') def simple_custom_one_slot() -> Dict[str, int]: - """For test_slot_one_loads_plugin_from_custom_entrypoint_group only; do not reuse.""" + """For test_slot_one_loads_plugin_from_custom_entrypoint_group_once_and_returns_payload only; do not reuse.""" return {} diff --git a/tests/smokes/test_entry_points.py b/tests/smokes/test_entry_points.py index bf23a7a..a2a0450 100644 --- a/tests/smokes/test_entry_points.py +++ b/tests/smokes/test_entry_points.py @@ -121,8 +121,8 @@ def get_entries(group=None): assert requested_groups == ['pristan'] -def test_slot_one_loads_plugin_from_real_entrypoint_and_calls_result(monkeypatch): - """`Slot.one` loads a real entry point once and returns a callable selection.""" +def test_slot_one_loads_plugin_from_real_entrypoint_once_and_returns_payload(monkeypatch): + """`Slot.one` loads and registers a real entry point once, then returns its payload on repeated calls.""" requested_groups = [] def get_entries(group=None): @@ -132,10 +132,11 @@ def get_entries(group=None): monkeypatch.setattr(slot_module, 'entry_points', get_entries) assert not simple_one_slot.loaded - assert simple_one_slot.one() == {'name': 7} + assert simple_one_slot.one() == 7 assert simple_one_slot.loaded + assert simple_one_slot.keys() == ('name',) - assert simple_one_slot.one() == {'name': 7} + assert simple_one_slot.one() == 7 assert requested_groups == ['pristan'] @@ -179,8 +180,8 @@ def get_entries(group=None): assert requested_groups == ['pristan'] -def test_slot_one_loads_plugin_from_custom_entrypoint_group(monkeypatch): - """`Slot.one` loads a custom-group entry point once into a callable selection.""" +def test_slot_one_loads_plugin_from_custom_entrypoint_group_once_and_returns_payload(monkeypatch): + """`Slot.one` loads and registers a custom-group entry point once, then returns its payload on repeated calls.""" requested_groups = [] def get_entries(group=None): @@ -190,10 +191,11 @@ def get_entries(group=None): monkeypatch.setattr(slot_module, 'entry_points', get_entries) assert not simple_custom_one_slot.loaded - assert simple_custom_one_slot.one() == {'name2': 8} + assert simple_custom_one_slot.one() == 8 assert simple_custom_one_slot.loaded + assert simple_custom_one_slot.keys() == ('name2',) - assert simple_custom_one_slot.one() == {'name2': 8} + assert simple_custom_one_slot.one() == 8 assert requested_groups == ['another_name'] diff --git a/tests/typing/components/test_slot.py b/tests/typing/components/test_slot.py index b648423..49f63bd 100644 --- a/tests/typing/components/test_slot.py +++ b/tests/typing/components/test_slot.py @@ -24,8 +24,8 @@ def get_entries(group=None): @pytest.mark.mypy_testing -def test_concrete_slot_one_is_typed_as_caller_with_plugins(monkeypatch): - """Concrete `Slot.one` returns `CallerWithPlugins` with the same plugin result generic.""" +def test_concrete_slot_one_is_typed_as_one_caller_with_plugins(monkeypatch): + """Concrete `Slot.one` returns `OneCallerWithPlugins` with the same plugin result generic.""" def collect() -> List[int]: return [] @@ -40,4 +40,4 @@ def get_entries(group=None): def plugin() -> int: return 1 - reveal_type(slot_view.one) # R: pristan.components.slot_caller.CallerWithPlugins[builtins.int] + reveal_type(slot_view.one) # R: pristan.components.slot_caller.OneCallerWithPlugins[builtins.int] diff --git a/tests/typing/components/test_slot_caller.py b/tests/typing/components/test_slot_caller.py index 960e4b2..b605ef2 100644 --- a/tests/typing/components/test_slot_caller.py +++ b/tests/typing/components/test_slot_caller.py @@ -20,13 +20,8 @@ def collect() -> list: @pytest.mark.mypy_testing -def test_concrete_caller_with_plugins_one_keeps_selection_type(): - """ - `CallerWithPlugins.one` returns the same selection type. - - Catching the warning keeps this check on a non-unique slot; changing it to - unique=True would bias coverage, while leaving it uncaught would add warning noise. - """ +def test_concrete_caller_with_plugins_one_returns_one_caller_type(): + """`CallerWithPlugins.one` and nested `.one` stay typed as `OneCallerWithPlugins`; calling it returns the payload type.""" def collect() -> List[int]: return [1] @@ -34,4 +29,8 @@ def collect() -> List[int]: selection: CallerWithPlugins[int] = CallerWithPlugins(slot.caller, []) with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')): - reveal_type(selection.one) # R: pristan.components.slot_caller.CallerWithPlugins[builtins.int] + one_selection = selection.one + reveal_type(one_selection) # R: pristan.components.slot_caller.OneCallerWithPlugins[builtins.int] + reveal_type(one_selection()) # R: builtins.int + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')): + reveal_type(one_selection.one) # R: pristan.components.slot_caller.OneCallerWithPlugins[builtins.int] diff --git a/tests/typing/decorators/test_slot.py b/tests/typing/decorators/test_slot.py index 7b33476..97ac626 100644 --- a/tests/typing/decorators/test_slot.py +++ b/tests/typing/decorators/test_slot.py @@ -10,6 +10,7 @@ import pristan.components.slot as slot_module from pristan import slot from pristan.common_types import ( + OneSlotSelectionProtocol, SlotDecoratorProtocol, SlotProtocol, SlotSelectionProtocol, @@ -25,7 +26,8 @@ def clean_entrypoints(monkeypatch): @pytest.mark.mypy_testing def test_typing_collection_result_matrix_preserves_exact_slot_types(): - """Cover decorator, `.one`, and typing collection combinations. + """ + Cover decorator and typing collection combinations. Mypy sees this file statically, so the matrix keeps normal calls, `@slot`/`@slot()`, and `List`/`Dict` variants explicit. @@ -66,14 +68,51 @@ def factory_dictionary_plugin(value: int) -> int: reveal_type(bare_dictionary_slot(1)) # R: builtins.dict[builtins.str, builtins.int] reveal_type(factory_list_slot(1)) # R: builtins.list[builtins.int] reveal_type(factory_dictionary_slot(1)) # R: builtins.dict[builtins.str, builtins.int] - reveal_type(bare_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(bare_dictionary_slot.one(1)) # R: builtins.dict[builtins.str, builtins.int] - reveal_type(factory_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(factory_dictionary_slot.one(1)) # R: builtins.dict[builtins.str, builtins.int] reveal_type(bare_dictionary_slot.keys()) # R: builtins.tuple[builtins.str, ...] reveal_type(factory_dictionary_slot.keys()) # R: builtins.tuple[builtins.str, ...] +@pytest.mark.mypy_testing +def test_typing_collection_one_unwraps_payload_types(): + """Typing List/Dict `.one` calls unwrap list items and dict values.""" + @slot + def bare_list_slot(value: int) -> List[int]: # noqa: ARG001 + return [] + + @slot + def bare_dictionary_slot(value: int) -> Dict[str, int]: # noqa: ARG001 + return {} + + @slot() + def factory_list_slot(value: int) -> List[int]: # noqa: ARG001 + return [] + + @slot() + def factory_dictionary_slot(value: int) -> Dict[str, int]: # noqa: ARG001 + return {} + + @bare_list_slot.plugin('bare_list') + def bare_list_plugin(value: int) -> int: + return value + + @bare_dictionary_slot.plugin('bare_dictionary') + def bare_dictionary_plugin(value: int) -> int: + return value + + @factory_list_slot.plugin('factory_list') + def factory_list_plugin(value: int) -> int: + return value + + @factory_dictionary_slot.plugin('factory_dictionary') + def factory_dictionary_plugin(value: int) -> int: + return value + + reveal_type(bare_list_slot.one(1)) # R: builtins.int + reveal_type(bare_dictionary_slot.one(1)) # R: builtins.int + reveal_type(factory_list_slot.one(1)) # R: builtins.int + reveal_type(factory_dictionary_slot.one(1)) # R: builtins.int + + @pytest.mark.mypy_testing def test_slot_without_return_annotation_is_typed_as_any_in_both_forms(): """ @@ -114,7 +153,7 @@ def plugin_with_parentheses(value: int) -> int: @pytest.mark.mypy_testing def test_slot_configuration_arguments_include_explicit_plugin_names(): - """Configured decorators keep normal and `.one` calls typed under strict naming.""" + """Configured decorators keep normal calls typed under strict naming.""" @slot('some_another_slot_name') def slot_with_positional_name(value: int) -> List[int]: # noqa: ARG001 return [] @@ -179,20 +218,82 @@ def signature_list_plugin(value: int, context: str = '') -> int: # noqa: ARG001 reveal_type(configured_slot(1)) # R: builtins.list[builtins.int] reveal_type(configured_slot_with_signature_list(1)) # R: builtins.list[builtins.int] reveal_type(configured_slot_with_signature_list(1, 'context')) # R: builtins.list[builtins.int] - reveal_type(slot_with_positional_name.one(1)) # R: builtins.list[builtins.int] - reveal_type(unique_slot_with_positional_name.one(1)) # R: builtins.list[builtins.int] - reveal_type(slot_with_keyword_name.one(1)) # R: builtins.dict[builtins.str, builtins.int] - reveal_type(unique_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(explicit_plugin_names_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(configured_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(configured_slot_with_signature_list.one(1)) # R: builtins.list[builtins.int] - reveal_type(configured_slot_with_signature_list.one(1, 'context')) # R: builtins.list[builtins.int] configured_slot_with_signature_list(1, 2) # E: [arg-type] +@pytest.mark.mypy_testing +def test_slot_configuration_arguments_expose_one_payload_types(): + """Configured decorators type `.one` calls as payload values.""" + @slot('some_another_slot_name') + def slot_with_positional_name(value: int) -> List[int]: # noqa: ARG001 + return [] + + @slot('some_unique_slot_name', unique=True) + def unique_slot_with_positional_name(value: int) -> List[int]: # noqa: ARG001 + return [] + + @slot(name='some_named_slot') + def slot_with_keyword_name(value: int) -> Dict[str, int]: # noqa: ARG001 + return {} + + @slot(unique=True) + def unique_slot(value: int) -> List[int]: # noqa: ARG001 + return [] + + @slot(explicit_plugin_names=True) + def explicit_plugin_names_slot(value: int) -> List[int]: # noqa: ARG001 + return [] + + @slot(signature='.', max=1, type_check=False, entrypoint_group='new_namespace', unique=True) + def configured_slot(value: int) -> List[int]: # noqa: ARG001 + return [] + + @slot(signature=['..', '.']) + def configured_slot_with_signature_list(value: int, context: str = '') -> List[int]: # noqa: ARG001 + return [] + + @slot_with_positional_name.plugin('positional_plugin') + def positional_plugin(value: int) -> int: + return value + + @unique_slot_with_positional_name.plugin('unique_positional_plugin') + def unique_positional_plugin(value: int) -> int: + return value + + @slot_with_keyword_name.plugin('keyword_plugin') + def keyword_plugin(value: int) -> int: + return value + + @unique_slot.plugin('unique_plugin') + def unique_plugin(value: int) -> int: + return value + + @explicit_plugin_names_slot.plugin('explicit_plugin') + def explicit_plugin(value: int) -> int: + return value + + @configured_slot.plugin('configured_plugin') + def configured_plugin(value: int) -> int: + return value + + @configured_slot_with_signature_list.plugin('signature_list_plugin') + def signature_list_plugin(value: int, context: str = '') -> int: # noqa: ARG001 + return value + + reveal_type(slot_with_positional_name.one(1)) # R: builtins.int + reveal_type(unique_slot_with_positional_name.one(1)) # R: builtins.int + reveal_type(slot_with_keyword_name.one(1)) # R: builtins.int + reveal_type(unique_slot.one(1)) # R: builtins.int + reveal_type(explicit_plugin_names_slot.one(1)) # R: builtins.int + reveal_type(configured_slot.one(1)) # R: builtins.int + reveal_type(configured_slot_with_signature_list.one(1)) # R: builtins.int + reveal_type(configured_slot_with_signature_list.one(1, 'context')) # R: builtins.int + + @pytest.mark.mypy_testing def test_slot_direct_call_configuration_arguments_include_explicit_plugin_names(): - """Direct-call overloads preserve call, plugin, and `.one` result types. + """ + Direct-call overloads preserve call, plugin, and iteration types. Covers factory options, signatures, result shapes, and plugin iteration. """ @@ -219,8 +320,8 @@ def typed_notify(value: int) -> None: # noqa: ARG001 signature_list_notify_slot = slot(typed_notify, signature=['.']) signature_list_notify_view: SlotProtocol[[int], None, Any] = signature_list_notify_slot reveal_type(signature_list_notify_slot(1)) # R: None - reveal_type(signature_list_notify_slot.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], None, Any] - reveal_type(signature_list_notify_slot.one(1)) # R: None + reveal_type(signature_list_notify_slot.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], Any] + reveal_type(signature_list_notify_slot.one(1)) # R: Any signature_list_notify_view(1) for signature_list_notify_plugin in signature_list_notify_slot: @@ -263,7 +364,7 @@ def typed_notify(value: int) -> None: # noqa: ARG001 @pytest.mark.mypy_testing def test_slot_direct_call_one_preserves_result_types(): - """Direct-call slot forms expose `.one` with the same result types.""" + """Direct-call slot forms expose `.one` payload result types.""" def collect_list(value: int) -> List[int]: # noqa: ARG001 return [] @@ -274,6 +375,7 @@ def notify(value: int): # noqa: ARG001 return None default_list_slot = slot(collect_list) + default_dict_slot = slot(collect_dict) list_slot = slot(collect_list, unique=True, explicit_plugin_names=True) signature_list_slot = slot(collect_list, signature=['.']) dict_slot = slot(collect_dict, signature='.', name='collect', max=2, type_check=False, entrypoint_group='custom', unique=True) @@ -284,6 +386,10 @@ def notify(value: int): # noqa: ARG001 def default_list_plugin(value: int) -> int: return value + @default_dict_slot.plugin('default_dict') + def default_dict_plugin(value: int) -> int: + return value + @list_slot.plugin('list_plugin') def list_plugin(value: int) -> int: return value @@ -304,18 +410,19 @@ def signature_list_dict_plugin(value: int) -> int: def notify_plugin(value: int) -> str: return str(value) - reveal_type(default_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(signature_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(dict_slot.one(1)) # R: builtins.dict[builtins.str, builtins.int] - reveal_type(signature_list_dict_slot.one(1)) # R: builtins.dict[builtins.str, builtins.int] + reveal_type(default_list_slot.one(1)) # R: builtins.int + reveal_type(default_dict_slot.one(1)) # R: builtins.int + reveal_type(list_slot.one(1)) # R: builtins.int + reveal_type(signature_list_slot.one(1)) # R: builtins.int + reveal_type(dict_slot.one(1)) # R: builtins.int + reveal_type(signature_list_dict_slot.one(1)) # R: builtins.int reveal_type(notify_slot.one(1)) # R: Any @pytest.mark.skipif(sys.version_info < (3, 9), reason='built-in generics require Python 3.9+') @pytest.mark.mypy_testing def test_slot_direct_call_one_preserves_built_in_result_types(): - """Direct-call slot forms expose `.one` with built-in generic result types.""" + """Direct-call slot forms expose `.one` built-in generic payload types.""" def collect_list(value: int) -> list[int]: # noqa: ARG001 return [] @@ -323,6 +430,7 @@ def collect_dict(value: int) -> dict[str, int]: # noqa: ARG001 return {} default_list_slot = slot(collect_list) + default_dict_slot = slot(collect_dict) list_slot = slot(collect_list, unique=True, explicit_plugin_names=True) signature_list_slot = slot(collect_list, signature=['.']) dict_slot = slot(collect_dict, signature='.', name='collect', max=2, type_check=False, entrypoint_group='custom', unique=True) @@ -332,6 +440,10 @@ def collect_dict(value: int) -> dict[str, int]: # noqa: ARG001 def default_list_plugin(value: int) -> int: return value + @default_dict_slot.plugin('default_dict') + def default_dict_plugin(value: int) -> int: + return value + @list_slot.plugin('list_plugin') def list_plugin(value: int) -> int: return value @@ -348,11 +460,12 @@ def dict_plugin(value: int) -> int: def signature_list_dict_plugin(value: int) -> int: return value - reveal_type(default_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(signature_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(dict_slot.one(1)) # R: builtins.dict[builtins.str, builtins.int] - reveal_type(signature_list_dict_slot.one(1)) # R: builtins.dict[builtins.str, builtins.int] + reveal_type(default_list_slot.one(1)) # R: builtins.int + reveal_type(default_dict_slot.one(1)) # R: builtins.int + reveal_type(list_slot.one(1)) # R: builtins.int + reveal_type(signature_list_slot.one(1)) # R: builtins.int + reveal_type(dict_slot.one(1)) # R: builtins.int + reveal_type(signature_list_dict_slot.one(1)) # R: builtins.int @pytest.mark.mypy_testing @@ -448,12 +561,7 @@ def collect(value: int) -> Dict[str, int]: # noqa: ARG001 @pytest.mark.mypy_testing def test_slot_pop_returns_selection_type(): - """ - Popped selections keep their call and `.one` result types. - - Catching selection warnings keeps these slots non-unique; changing them to - unique=True would bias coverage, while leaving them uncaught would add warning noise. - """ + """Popped selections keep aggregate call types.""" @slot def collect_list(value: int) -> List[int]: # noqa: ARG001 return [] @@ -476,14 +584,6 @@ def dict_plugin(value: int) -> int: popped_list_selection_view: SlotSelectionProtocol[[int], List[int], int] = popped_list_selection popped_dict_selection_view: SlotSelectionProtocol[[int], Dict[str, int], int] = popped_dict_selection - with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_list", because this code uses .one to work with a single plugin.')): # noqa: PT031 - reveal_type(popped_list_selection.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int] - reveal_type(popped_list_selection.one.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int] - reveal_type(popped_list_selection.one(1)) # R: builtins.list[builtins.int] - with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_dict", because this code uses .one to work with a single plugin.')): # noqa: PT031 - reveal_type(popped_dict_selection.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.dict[builtins.str, builtins.int], builtins.int] - reveal_type(popped_dict_selection.one.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.dict[builtins.str, builtins.int], builtins.int] - reveal_type(popped_dict_selection.one(1)) # R: builtins.dict[builtins.str, builtins.int] reveal_type(popped_list_selection(1)) # R: builtins.list[builtins.int] reveal_type(popped_dict_selection(1)) # R: builtins.dict[builtins.str, builtins.int] @@ -491,6 +591,43 @@ def dict_plugin(value: int) -> int: popped_dict_selection_view(1) +@pytest.mark.mypy_testing +def test_slot_pop_selection_exposes_one_payload_types(): + """ + Popped selections expose `.one` payload types. + + Catching selection warnings keeps these slots non-unique; changing them to + unique=True would bias coverage, while leaving them uncaught would add warning noise. + """ + @slot + def collect_list(value: int) -> List[int]: # noqa: ARG001 + return [] + + @slot + def collect_dict(value: int) -> Dict[str, int]: # noqa: ARG001 + return {} + + @collect_list.plugin('name') + def list_plugin(value: int) -> int: + return value + + @collect_dict.plugin('name') + def dict_plugin(value: int) -> int: + return value + + popped_list_selection = collect_list.pop('name') + popped_dict_selection = collect_dict.pop('name') + + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_list", because this code uses .one to work with a single plugin.')): # noqa: PT031 + reveal_type(popped_list_selection.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(popped_list_selection.one.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(popped_list_selection.one(1)) # R: builtins.int + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_dict", because this code uses .one to work with a single plugin.')): # noqa: PT031 + reveal_type(popped_dict_selection.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(popped_dict_selection.one.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(popped_dict_selection.one(1)) # R: builtins.int + + @pytest.mark.mypy_testing def test_slot_pop_with_default_returns_union(): """Defaulted Slot.pop on a typing.List slot is typed as the removed selection or the exact supplied default type.""" @@ -846,12 +983,12 @@ def dict_plugin() -> str: reveal_type(collect_list()) # R: builtins.list[Any] reveal_type(collect_dict()) # R: builtins.dict[builtins.str, Any] - reveal_type(collect_list.one()) # R: builtins.list[Any] - reveal_type(collect_dict.one()) # R: builtins.dict[builtins.str, Any] + reveal_type(collect_list.one()) # R: Any + reveal_type(collect_dict.one()) # R: Any with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_list", because this code uses .one to work with a single plugin.')): - reveal_type(collect_list['list_plugin'].one()) # R: builtins.list[Any] + reveal_type(collect_list['list_plugin'].one()) # R: Any with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_dict", because this code uses .one to work with a single plugin.')): - reveal_type(collect_dict['dict_plugin'].one()) # R: builtins.dict[builtins.str, Any] + reveal_type(collect_dict['dict_plugin'].one()) # R: Any @pytest.mark.mypy_testing @@ -880,12 +1017,83 @@ def dict_plugin() -> str: reveal_type(collect_list()) # R: builtins.list[Any] reveal_type(collect_dict()) # R: builtins.dict[builtins.str, Any] - reveal_type(collect_list.one()) # R: builtins.list[Any] - reveal_type(collect_dict.one()) # R: builtins.dict[builtins.str, Any] + reveal_type(collect_list.one()) # R: Any + reveal_type(collect_dict.one()) # R: Any with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_list", because this code uses .one to work with a single plugin.')): - reveal_type(collect_list['list_plugin'].one()) # R: builtins.list[Any] + reveal_type(collect_list['list_plugin'].one()) # R: Any with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_dict", because this code uses .one to work with a single plugin.')): - reveal_type(collect_dict['dict_plugin'].one()) # R: builtins.dict[builtins.str, Any] + reveal_type(collect_dict['dict_plugin'].one()) # R: Any + + +@pytest.mark.mypy_testing +def test_nested_none_and_any_typing_payloads_are_unwrapped_by_one(): + """Typing List/Dict `.one` unwraps only the outer aggregate, preserving nested, None, and explicit Any payloads.""" + @slot + def nested_list_slot(value: int) -> List[List[int]]: + return [[value]] + + @slot + def nested_dict_slot(value: int) -> Dict[str, Dict[str, int]]: + return {'only': {'value': value}} + + @slot + def none_list_slot(value: int) -> List[None]: # noqa: ARG001 + return [None] + + @slot + def none_dict_slot(value: int) -> Dict[str, None]: # noqa: ARG001 + return {'only': None} + + @slot + def any_list_slot(value: int) -> List[Any]: + return [str(value)] + + @slot + def any_dict_slot(value: int) -> Dict[str, Any]: + return {'only': str(value)} + + reveal_type(nested_list_slot.one(1)) # R: builtins.list[builtins.int] + reveal_type(nested_dict_slot.one(1)) # R: builtins.dict[builtins.str, builtins.int] + reveal_type(none_list_slot.one(1)) # R: None + reveal_type(none_dict_slot.one(1)) # R: None + reveal_type(any_list_slot.one(1)) # R: Any + reveal_type(any_dict_slot.one(1)) # R: Any + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason='built-in generics require Python 3.9+') +@pytest.mark.mypy_testing +def test_nested_none_and_any_built_in_payloads_are_unwrapped_by_one(): + """Built-in list/dict `.one` unwraps only the outer aggregate, preserving nested, None, and explicit Any payloads.""" + @slot + def nested_list_slot(value: int) -> list[list[int]]: + return [[value]] + + @slot + def nested_dict_slot(value: int) -> dict[str, dict[str, int]]: + return {'only': {'value': value}} + + @slot + def none_list_slot(value: int) -> list[None]: # noqa: ARG001 + return [None] + + @slot + def none_dict_slot(value: int) -> dict[str, None]: # noqa: ARG001 + return {'only': None} + + @slot + def any_list_slot(value: int) -> list[Any]: + return [str(value)] + + @slot + def any_dict_slot(value: int) -> dict[str, Any]: + return {'only': str(value)} + + reveal_type(nested_list_slot.one(1)) # R: builtins.list[builtins.int] + reveal_type(nested_dict_slot.one(1)) # R: builtins.dict[builtins.str, builtins.int] + reveal_type(none_list_slot.one(1)) # R: None + reveal_type(none_dict_slot.one(1)) # R: None + reveal_type(any_list_slot.one(1)) # R: Any + reveal_type(any_dict_slot.one(1)) # R: Any @pytest.mark.mypy_testing @@ -914,7 +1122,8 @@ def plugin_with_parentheses(value: int) -> int: @pytest.mark.skipif(sys.version_info < (3, 9), reason='built-in generics require Python 3.9+') @pytest.mark.mypy_testing def test_built_in_generic_result_matrix_preserves_exact_slot_types(): - """Cover decorator, `.one`, and built-in collection combinations. + """ + Covers decorator forms and built-in collection combinations. Mypy sees this file statically, so the matrix keeps built-in list/dict and configured variants explicit. @@ -955,6 +1164,10 @@ def explicit_plugin_names_slot(value: int) -> list[int]: # noqa: ARG001 def configured_list_slot(value: int) -> list[int]: # noqa: ARG001 return [] + @slot(name='builtin_configured_dictionary', max=1, type_check=False) + def configured_dictionary_slot(value: int) -> dict[str, int]: # noqa: ARG001 + return {} + @slot(signature=['..', '.']) def configured_list_slot_with_signature_list(value: int, context: str = '') -> list[int]: # noqa: ARG001 return [] @@ -995,6 +1208,10 @@ def explicit_plugin(value: int) -> int: def configured_plugin(value: int) -> int: return value + @configured_dictionary_slot.plugin('configured_dictionary') + def configured_dictionary_plugin(value: int) -> int: + return value + @configured_list_slot_with_signature_list.plugin('signature_list') def signature_list_plugin(value: int, context: str = '') -> int: # noqa: ARG001 return value @@ -1008,23 +1225,119 @@ def signature_list_plugin(value: int, context: str = '') -> int: # noqa: ARG001 reveal_type(unique_list_slot(1)) # R: builtins.list[builtins.int] reveal_type(explicit_plugin_names_slot(1)) # R: builtins.list[builtins.int] reveal_type(configured_list_slot(1)) # R: builtins.list[builtins.int] + reveal_type(configured_dictionary_slot(1)) # R: builtins.dict[builtins.str, builtins.int] reveal_type(configured_list_slot_with_signature_list(1)) # R: builtins.list[builtins.int] reveal_type(configured_list_slot_with_signature_list(1, 'context')) # R: builtins.list[builtins.int] - reveal_type(bare_dictionary_slot.one(1)) # R: builtins.dict[builtins.str, builtins.int] - reveal_type(bare_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(factory_dictionary_slot.one(1)) # R: builtins.dict[builtins.str, builtins.int] - reveal_type(factory_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(keyword_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(positional_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(unique_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(explicit_plugin_names_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(configured_list_slot.one(1)) # R: builtins.list[builtins.int] - reveal_type(configured_list_slot_with_signature_list.one(1)) # R: builtins.list[builtins.int] - reveal_type(configured_list_slot_with_signature_list.one(1, 'context')) # R: builtins.list[builtins.int] reveal_type(bare_list_slot['name'](1)) # R: builtins.list[builtins.int] reveal_type(factory_list_slot['name'](1)) # R: builtins.list[builtins.int] +@pytest.mark.skipif(sys.version_info < (3, 9), reason='built-in generics require Python 3.9+') +@pytest.mark.mypy_testing +def test_built_in_generic_one_unwraps_payload_types(): + """Built-in generic `.one` calls unwrap list items and dict values.""" + @slot + def bare_dictionary_slot(value: int) -> dict[str, int]: # noqa: ARG001 + return {} + + @slot + def bare_list_slot(value: int) -> list[int]: # noqa: ARG001 + return [] + + @slot() + def factory_dictionary_slot(value: int) -> dict[str, int]: # noqa: ARG001 + return {} + + @slot() + def factory_list_slot(value: int) -> list[int]: # noqa: ARG001 + return [] + + @slot(name='builtin_keyword') + def keyword_list_slot(value: int) -> list[int]: # noqa: ARG001 + return [] + + @slot('builtin_positional') + def positional_list_slot(value: int) -> list[int]: # noqa: ARG001 + return [] + + @slot(unique=True) + def unique_list_slot(value: int) -> list[int]: # noqa: ARG001 + return [] + + @slot(explicit_plugin_names=True) + def explicit_plugin_names_slot(value: int) -> list[int]: # noqa: ARG001 + return [] + + @slot(signature='.', max=1, type_check=False) + def configured_list_slot(value: int) -> list[int]: # noqa: ARG001 + return [] + + @slot(name='builtin_configured_dictionary', max=1, type_check=False) + def configured_dictionary_slot(value: int) -> dict[str, int]: # noqa: ARG001 + return {} + + @slot(signature=['..', '.']) + def configured_list_slot_with_signature_list(value: int, context: str = '') -> list[int]: # noqa: ARG001 + return [] + + @bare_dictionary_slot.plugin('bare_dictionary') + def bare_dictionary_plugin(value: int) -> int: + return value + + @bare_list_slot.plugin('bare_list') + def bare_list_plugin(value: int) -> int: + return value + + @factory_dictionary_slot.plugin('factory_dictionary') + def factory_dictionary_plugin(value: int) -> int: + return value + + @factory_list_slot.plugin('factory_list') + def factory_list_plugin(value: int) -> int: + return value + + @keyword_list_slot.plugin('keyword') + def keyword_plugin(value: int) -> int: + return value + + @positional_list_slot.plugin('positional') + def positional_plugin(value: int) -> int: + return value + + @unique_list_slot.plugin('unique') + def unique_plugin(value: int) -> int: + return value + + @explicit_plugin_names_slot.plugin('explicit') + def explicit_plugin(value: int) -> int: + return value + + @configured_list_slot.plugin('configured') + def configured_plugin(value: int) -> int: + return value + + @configured_dictionary_slot.plugin('configured_dictionary') + def configured_dictionary_plugin(value: int) -> int: + return value + + @configured_list_slot_with_signature_list.plugin('signature_list') + def signature_list_plugin(value: int, context: str = '') -> int: # noqa: ARG001 + return value + + reveal_type(bare_dictionary_slot.one(1)) # R: builtins.int + reveal_type(bare_list_slot.one(1)) # R: builtins.int + reveal_type(factory_dictionary_slot.one(1)) # R: builtins.int + reveal_type(factory_list_slot.one(1)) # R: builtins.int + reveal_type(keyword_list_slot.one(1)) # R: builtins.int + reveal_type(positional_list_slot.one(1)) # R: builtins.int + reveal_type(unique_list_slot.one(1)) # R: builtins.int + reveal_type(explicit_plugin_names_slot.one(1)) # R: builtins.int + reveal_type(configured_list_slot.one(1)) # R: builtins.int + reveal_type(configured_dictionary_slot.one(1)) # R: builtins.int + reveal_type(configured_list_slot_with_signature_list.one(1)) # R: builtins.int + reveal_type(configured_list_slot_with_signature_list.one(1, 'context')) # R: builtins.int + + @pytest.mark.skipif(sys.version_info < (3, 9), reason='built-in generics require Python 3.9+') @pytest.mark.mypy_testing def test_built_in_generic_results_are_not_widened(): @@ -1051,8 +1364,25 @@ def consume_dict(payload: dict[str, int]): @pytest.mark.skipif(sys.version_info < (3, 9), reason='built-in generics require Python 3.9+') @pytest.mark.mypy_testing def test_slot_pop_returns_selection_type_for_built_in_generics(): + """Popped built-in generic selections expose aggregate and default-pop types.""" + @slot + def collect(value: int) -> list[int]: # noqa: ARG001 + return [] + + @collect.plugin('name') + def plugin(value: int) -> int: + return value + + popped_selection = collect.pop('name') + reveal_type(popped_selection(1)) # R: builtins.list[builtins.int] + reveal_type(collect.pop('name', 'fallback')) # R: Union[pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int], builtins.str] + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason='built-in generics require Python 3.9+') +@pytest.mark.mypy_testing +def test_slot_pop_built_in_list_exposes_one_payload_types(): """ - Popped built-in generic selections keep call, `.one`, and default-pop types. + Popped built-in generic selections expose `.one` payload types. Catching the selection warning keeps this slot non-unique; changing it to unique=True would bias coverage, while leaving it uncaught would add warning noise. @@ -1067,11 +1397,9 @@ def plugin(value: int) -> int: popped_selection = collect.pop('name') with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')): # noqa: PT031 - reveal_type(popped_selection.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int] - reveal_type(popped_selection.one.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int] - reveal_type(popped_selection.one(1)) # R: builtins.list[builtins.int] - reveal_type(popped_selection(1)) # R: builtins.list[builtins.int] - reveal_type(collect.pop('name', 'fallback')) # R: Union[pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int], builtins.str] + reveal_type(popped_selection.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(popped_selection.one.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(popped_selection.one(1)) # R: builtins.int @pytest.mark.skipif(sys.version_info < (3, 9), reason='built-in generics require Python 3.9+') @@ -1098,7 +1426,7 @@ def bad_dict_plugin(value: int) -> str: @pytest.mark.mypy_testing def test_typing_generics_one_preserves_result_types(): """ - `.one` keeps typing-generic call result types for slots and selections. + `.one` preserves typing-generic payload result types for slots and selections. Catching selection warnings keeps these slots non-unique; changing them to unique=True would bias coverage, while leaving them uncaught would add warning noise. @@ -1122,29 +1450,36 @@ def dict_plugin(value: int) -> int: list_selection = collect_list['list_plugin'] dict_selection = collect_dict['dict_plugin'] - reveal_type(collect_list.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int] - reveal_type(collect_dict.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.dict[builtins.str, builtins.int], builtins.int] + reveal_type(collect_list.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(collect_dict.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_list", because this code uses .one to work with a single plugin.')): # noqa: PT031 - reveal_type(list_selection.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int] - reveal_type(list_selection.one.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int] - reveal_type(list_selection.one(1)) # R: builtins.list[builtins.int] + reveal_type(list_selection.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(list_selection.one.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + selected_list_payload: int = reveal_type(list_selection.one(1)) # R: builtins.int with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_dict", because this code uses .one to work with a single plugin.')): # noqa: PT031 - reveal_type(dict_selection.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.dict[builtins.str, builtins.int], builtins.int] - reveal_type(dict_selection.one.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.dict[builtins.str, builtins.int], builtins.int] - reveal_type(dict_selection.one(1)) # R: builtins.dict[builtins.str, builtins.int] - reveal_type(collect_list.one(1)) # R: builtins.list[builtins.int] - reveal_type(collect_dict.one(1)) # R: builtins.dict[builtins.str, builtins.int] - - wrong_list_selection_call: Callable[[str], List[int]] = collect_list.one.__call__ # E: [assignment] # noqa: F841 - wrong_dict_selection_call: Callable[[str], Dict[str, int]] = collect_dict.one.__call__ # E: [assignment] # noqa: F841 + reveal_type(dict_selection.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(dict_selection.one.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + selected_dict_payload: int = reveal_type(dict_selection.one(1)) # R: builtins.int + list_payload: int = reveal_type(collect_list.one(1)) # R: builtins.int + dict_payload: int = reveal_type(collect_dict.one(1)) # R: builtins.int + list_payload_call: Callable[[int], int] = collect_list.one.__call__ + dict_payload_call: Callable[[int], int] = collect_dict.one.__call__ + wrong_slot_list_one_call_shape: Callable[[str], int] = collect_list.one.__call__ # E: [assignment] # noqa: F841 + wrong_slot_dict_one_call_shape: Callable[[str], int] = collect_dict.one.__call__ # E: [assignment] # noqa: F841 + wrong_list_one_as_aggregate_call: Callable[[int], List[int]] = collect_list.one.__call__ # E: [assignment] # noqa: F841 + wrong_dict_one_as_aggregate_call: Callable[[int], Dict[str, int]] = collect_dict.one.__call__ # E: [assignment] # noqa: F841 with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_list", because this code uses .one to work with a single plugin.')): # noqa: PT031 - wrong_selected_list_selection_call: Callable[[str], List[int]] = list_selection.one.__call__ # E: [assignment] # noqa: F841 - wrong_selection_list_result: Dict[str, int] = list_selection.one(1) # E: [assignment] # noqa: F841 + wrong_selected_list_one_call_shape: Callable[[str], int] = list_selection.one.__call__ # E: [assignment] # noqa: F841 + wrong_selection_list_aggregate_result: List[int] = list_selection.one(1) # E: [assignment] # noqa: F841 with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_dict", because this code uses .one to work with a single plugin.')): # noqa: PT031 - wrong_selected_dict_selection_call: Callable[[str], Dict[str, int]] = dict_selection.one.__call__ # E: [assignment] # noqa: F841 - wrong_selection_dict_result: List[int] = dict_selection.one(1) # E: [assignment] # noqa: F841 - wrong_list_result: Dict[str, int] = collect_list.one(1) # E: [assignment] # noqa: F841 - wrong_dict_result: List[int] = collect_dict.one(1) # E: [assignment] # noqa: F841 + wrong_selected_dict_one_call_shape: Callable[[str], int] = dict_selection.one.__call__ # E: [assignment] # noqa: F841 + wrong_selection_dict_aggregate_result: Dict[str, int] = dict_selection.one(1) # E: [assignment] # noqa: F841 + wrong_list_aggregate_result: List[int] = collect_list.one(1) # E: [assignment] # noqa: F841 + wrong_dict_aggregate_result: Dict[str, int] = collect_dict.one(1) # E: [assignment] # noqa: F841 + wrong_scalar_from_list_slot_call: int = collect_list(1) # E: [assignment] # noqa: F841 + wrong_scalar_from_list_selection_call: int = list_selection(1) # E: [assignment] # noqa: F841 + + assert (list_payload, dict_payload, selected_list_payload, selected_dict_payload, list_payload_call(1), dict_payload_call(1)) == (1, 1, 1, 1, 1, 1) @pytest.mark.mypy_testing @@ -1165,11 +1500,16 @@ def plugin(value: int, label: str = 'default', *, enabled: bool = True) -> str: selection = collect['name'] - reveal_type(collect.one(1)) # R: builtins.list[builtins.str] - reveal_type(collect.one(1, 'label', enabled=False)) # R: builtins.list[builtins.str] + reveal_type(collect.one(1)) # R: builtins.str + reveal_type(collect.one(1, 'label', enabled=False)) # R: builtins.str with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')): # noqa: PT031 - reveal_type(selection.one(1)) # R: builtins.list[builtins.str] - reveal_type(selection.one(1, 'label', enabled=False)) # R: builtins.list[builtins.str] + reveal_type(collect.one.one(1)) # R: builtins.str + reveal_type(collect.one.one(1, 'label', enabled=False)) # R: builtins.str + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')): # noqa: PT031 + reveal_type(selection.one(1)) # R: builtins.str + reveal_type(selection.one(1, 'label', enabled=False)) # R: builtins.str + reveal_type(selection.one.one(1)) # R: builtins.str + reveal_type(selection.one.one(1, 'label', enabled=False)) # R: builtins.str with pytest.raises(TypeError): collect.one('value') # E: [arg-type] @@ -1177,19 +1517,31 @@ def plugin(value: int, label: str = 'default', *, enabled: bool = True) -> str: collect.one() # E: [call-arg] with pytest.raises(TypeError): collect.one(1, unknown=True) # E: [call-arg] + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')), pytest.raises(TypeError): + collect.one.one('value') # E: [arg-type] + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')), pytest.raises(TypeError): + collect.one.one() # E: [call-arg] + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')), pytest.raises(TypeError): + collect.one.one(1, unknown=True) # E: [call-arg] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')), pytest.raises(TypeError): selection.one('value') # E: [arg-type] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')), pytest.raises(TypeError): selection.one() # E: [call-arg] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')), pytest.raises(TypeError): selection.one(1, unknown=True) # E: [call-arg] + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')), pytest.raises(TypeError): + selection.one.one('value') # E: [arg-type] + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')), pytest.raises(TypeError): + selection.one.one() # E: [call-arg] + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')), pytest.raises(TypeError): + selection.one.one(1, unknown=True) # E: [call-arg] @pytest.mark.skipif(sys.version_info < (3, 9), reason='built-in generics require Python 3.9+') @pytest.mark.mypy_testing def test_built_in_generics_one_preserves_result_types(): """ - `.one` keeps built-in generic call result types for slots and selections. + `.one` preserves built-in generic payload result types for slots and selections. Catching selection warnings keeps these slots non-unique; changing them to unique=True would bias coverage, while leaving them uncaught would add warning noise. @@ -1213,35 +1565,38 @@ def dict_plugin(value: int) -> int: list_selection = collect_list['list_plugin'] dict_selection = collect_dict['dict_plugin'] - reveal_type(collect_list.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int] - reveal_type(collect_dict.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.dict[builtins.str, builtins.int], builtins.int] + reveal_type(collect_list.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(collect_dict.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_list", because this code uses .one to work with a single plugin.')): # noqa: PT031 - reveal_type(list_selection.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int] - reveal_type(list_selection.one.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.list[builtins.int], builtins.int] - reveal_type(list_selection.one(1)) # R: builtins.list[builtins.int] + reveal_type(list_selection.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(list_selection.one.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + selected_list_payload: int = reveal_type(list_selection.one(1)) # R: builtins.int with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_dict", because this code uses .one to work with a single plugin.')): # noqa: PT031 - reveal_type(dict_selection.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.dict[builtins.str, builtins.int], builtins.int] - reveal_type(dict_selection.one.one) # R: pristan.common_types.SlotSelectionProtocol[[value: builtins.int], builtins.dict[builtins.str, builtins.int], builtins.int] - reveal_type(dict_selection.one(1)) # R: builtins.dict[builtins.str, builtins.int] - reveal_type(collect_list.one(1)) # R: builtins.list[builtins.int] - reveal_type(collect_dict.one(1)) # R: builtins.dict[builtins.str, builtins.int] - - wrong_list_selection_call: Callable[[str], list[int]] = collect_list.one.__call__ # E: [assignment] # noqa: F841 - wrong_dict_selection_call: Callable[[str], dict[str, int]] = collect_dict.one.__call__ # E: [assignment] # noqa: F841 + reveal_type(dict_selection.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + reveal_type(dict_selection.one.one) # R: pristan.common_types.OneSlotSelectionProtocol[[value: builtins.int], builtins.int] + selected_dict_payload: int = reveal_type(dict_selection.one(1)) # R: builtins.int + list_payload: int = reveal_type(collect_list.one(1)) # R: builtins.int + dict_payload: int = reveal_type(collect_dict.one(1)) # R: builtins.int + wrong_slot_list_one_call_shape: Callable[[str], int] = collect_list.one.__call__ # E: [assignment] # noqa: F841 + wrong_slot_dict_one_call_shape: Callable[[str], int] = collect_dict.one.__call__ # E: [assignment] # noqa: F841 + wrong_list_one_as_aggregate_call: Callable[[int], list[int]] = collect_list.one.__call__ # E: [assignment] # noqa: F841 + wrong_dict_one_as_aggregate_call: Callable[[int], dict[str, int]] = collect_dict.one.__call__ # E: [assignment] # noqa: F841 with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_list", because this code uses .one to work with a single plugin.')): # noqa: PT031 - wrong_selected_list_selection_call: Callable[[str], list[int]] = list_selection.one.__call__ # E: [assignment] # noqa: F841 - wrong_selection_list_result: Dict[str, int] = list_selection.one(1) # E: [assignment] # noqa: F841 + wrong_selected_list_one_call_shape: Callable[[str], int] = list_selection.one.__call__ # E: [assignment] # noqa: F841 + wrong_selection_list_aggregate_result: list[int] = list_selection.one(1) # E: [assignment] # noqa: F841 with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect_dict", because this code uses .one to work with a single plugin.')): # noqa: PT031 - wrong_selected_dict_selection_call: Callable[[str], dict[str, int]] = dict_selection.one.__call__ # E: [assignment] # noqa: F841 - wrong_selection_dict_result: List[int] = dict_selection.one(1) # E: [assignment] # noqa: F841 - wrong_list_result: Dict[str, int] = collect_list.one(1) # E: [assignment] # noqa: F841 - wrong_dict_result: List[int] = collect_dict.one(1) # E: [assignment] # noqa: F841 + wrong_selected_dict_one_call_shape: Callable[[str], int] = dict_selection.one.__call__ # E: [assignment] # noqa: F841 + wrong_selection_dict_aggregate_result: dict[str, int] = dict_selection.one(1) # E: [assignment] # noqa: F841 + wrong_list_aggregate_result: list[int] = collect_list.one(1) # E: [assignment] # noqa: F841 + wrong_dict_aggregate_result: dict[str, int] = collect_dict.one(1) # E: [assignment] # noqa: F841 + + assert (list_payload, dict_payload, selected_list_payload, selected_dict_payload) == (1, 1, 1, 1) @pytest.mark.mypy_testing def test_one_protocols_accept_slot_and_selection(): """ - Protocols expose `.one` with the same callable surface as concrete values. + Protocols accept slots and selections while `.one` returns payload values. Catching the selection warning keeps this slot non-unique; changing it to unique=True would bias coverage, while leaving it uncaught would add warning noise. @@ -1254,15 +1609,40 @@ def collect(value: int) -> List[int]: # noqa: ARG001 def plugin(value: int) -> int: return value - def call_slot(target: 'SlotProtocol[[int], List[int], int]') -> List[int]: + def call_slot(target: 'SlotProtocol[[int], List[int], int]') -> int: return target.one(1) - def call_selection(target: 'SlotSelectionProtocol[[int], List[int], int]') -> List[int]: + def call_selection(target: 'SlotSelectionProtocol[[int], List[int], int]') -> int: return target.one(1) - reveal_type(call_slot(collect)) # R: builtins.list[builtins.int] + reveal_type(call_slot(collect)) # R: builtins.int with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')): - reveal_type(call_selection(collect['name'])) # R: builtins.list[builtins.int] + reveal_type(call_selection(collect['name'])) # R: builtins.int + + +@pytest.mark.mypy_testing +def test_slot_and_selection_protocols_expose_aggregate_and_one_payload_calls(): + """Protocols preserve aggregate calls and expose `.one` payload calls for slots and selections.""" + @slot + def collect(value: int) -> List[int]: # noqa: ARG001 + return [] + + @collect.plugin('name') + def plugin(value: int) -> int: + return value + + selection = collect['name'] + collect_view: SlotProtocol[[int], List[int], int] = collect + selection_view: SlotSelectionProtocol[[int], List[int], int] = selection + + reveal_type(collect_view(1)) # R: builtins.list[builtins.int] + reveal_type(collect_view.one(1)) # R: builtins.int + reveal_type(collect_view.one) # R: pristan.common_types.OneSlotSelectionProtocol[[builtins.int], builtins.int] + reveal_type(selection_view(1)) # R: builtins.list[builtins.int] + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')): + reveal_type(selection_view.one(1)) # R: builtins.int + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')): + reveal_type(selection_view.one) # R: pristan.common_types.OneSlotSelectionProtocol[[builtins.int], builtins.int] @pytest.mark.mypy_testing @@ -1272,24 +1652,80 @@ def test_one_read_only_surface_rejects_assignment_and_deletion(): def collect(value: int) -> List[int]: # noqa: ARG001 return [] + @collect.plugin('name') + def plugin(value: int) -> int: + return value + selection = collect['name'] collect_view: SlotProtocol[[int], List[int], int] = collect selection_view: SlotSelectionProtocol[[int], List[int], int] = selection + one_selection = collect.one with pytest.raises(AttributeError): - collect.one = selection # E: [misc] + collect.one = one_selection # E: [misc] + with pytest.raises(AttributeError): + selection.one = one_selection # E: [misc] with pytest.raises(AttributeError): - selection.one = selection # E: [misc] + collect_view.one = one_selection # E: [misc] + with pytest.raises(AttributeError): + selection_view.one = one_selection # E: [misc] with pytest.raises(AttributeError): del collect_view.one with pytest.raises(AttributeError): del selection_view.one +@pytest.mark.mypy_testing +def test_one_selection_read_only_surface_rejects_assignment_and_deletion(): + """`.one` rejects assignment and deletion on one-selections.""" + @slot + def collect(value: int) -> List[int]: # noqa: ARG001 + return [] + + @collect.plugin('name') + def plugin(value: int) -> int: + return value + + one_selection_view: OneSlotSelectionProtocol[[int], int] = collect.one + + with pytest.raises(AttributeError): + one_selection_view.one = one_selection_view # E: [misc] + with pytest.raises(AttributeError): + del one_selection_view.one + + +@pytest.mark.mypy_testing +def test_one_selection_protocol_exposes_bool_len_and_iteration_only(): + """One-selections expose bool, len, and iteration, but not plugin, keys, pop, or indexing.""" + @slot + def collect(value: int) -> List[int]: # noqa: ARG001 + return [] + + @collect.plugin + def plugin(value: int) -> int: + return value + + one_selection_view: OneSlotSelectionProtocol[[int], int] = collect.one + + reveal_type(one_selection_view.__bool__()) # R: builtins.bool + reveal_type(len(one_selection_view)) # R: builtins.int + for loaded_plugin in one_selection_view: + reveal_type(loaded_plugin(1)) # R: builtins.int + + with pytest.raises(AttributeError): + one_selection_view.plugin('name') # E: [attr-defined] + with pytest.raises(AttributeError): + one_selection_view.keys() # E: [attr-defined] + with pytest.raises(AttributeError): + one_selection_view.pop('name') # E: [attr-defined] + with pytest.raises(TypeError): + one_selection_view['nested'] # E: [index] + + @pytest.mark.mypy_testing def test_pop_with_default_exposes_one_after_selection_narrowing(): """ - Pop with a default exposes `.one` in the narrowed selection branch. + Pop with a default exposes `.one` payload calls in the narrowed selection branch. Catching the selection warning keeps this slot non-unique; changing it to unique=True would bias coverage, while leaving it uncaught would add warning noise. @@ -1308,4 +1744,4 @@ def plugin(value: int) -> int: reveal_type(popped_or_default) # R: builtins.str else: with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "collect", because this code uses .one to work with a single plugin.')): - reveal_type(popped_or_default.one(1)) # R: builtins.list[builtins.int] + reveal_type(popped_or_default.one(1)) # R: builtins.int diff --git a/tests/units/components/conftest.py b/tests/units/components/conftest.py new file mode 100644 index 0000000..503c3b1 --- /dev/null +++ b/tests/units/components/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from pristan.components.slot_caller import CallerWithPlugins, OneCallerWithPlugins + + +@pytest.fixture(params=(CallerWithPlugins, OneCallerWithPlugins)) +def caller_class(request): + return request.param diff --git a/tests/units/components/test_slot.py b/tests/units/components/test_slot.py index 5f0d815..9e5b298 100644 --- a/tests/units/components/test_slot.py +++ b/tests/units/components/test_slot.py @@ -1,3 +1,4 @@ +import warnings from typing import Dict, List import pytest @@ -8,7 +9,7 @@ import pristan.components.slot as slot_module from pristan import slot as public_slot from pristan.components.slot import Slot -from pristan.components.slot_caller import CallerWithPlugins +from pristan.components.slot_caller import CallerWithPlugins, OneCallerWithPlugins from pristan.errors import ( CannotGetVersionsError, EntrypointLoadingError, @@ -161,7 +162,7 @@ def __iter__(self): slot.lock.notify('read-plugins') yield object() - class TracedCallerWithPlugins: + class TracedOneCallerWithPlugins: def __init__(self, _caller, plugins): slot.lock.notify('snapshot') assert isinstance(plugins, list) @@ -176,7 +177,7 @@ def __len__(self): slot._load_entrypoints = lambda: slot.lock.notify('load') # type: ignore[method-assign] slot.plugins.plugins = PluginsList() # type: ignore[assignment] - monkeypatch.setattr(slot_module, 'CallerWithPlugins', TracedCallerWithPlugins) + monkeypatch.setattr(slot_module, 'OneCallerWithPlugins', TracedOneCallerWithPlugins) _ = slot.one @@ -1233,7 +1234,7 @@ def get_entries(group=None): def test_saved_selection_is_snapshot_and_does_not_load_again(monkeypatch): """ - Saved selections keep their plugin snapshots for `.one` after parent mutation. + Saved selections resolve `.one` from original plugin snapshots after parent mutation without reloading entry points. Catching selection warnings keeps the slot non-unique; changing it to unique=True would bias coverage, while leaving them uncaught would add warning noise. @@ -1280,7 +1281,10 @@ def later_group_plugin(): assert bool(singleton_selection) assert len(singleton_selection) == 1 with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): - assert singleton_selection.one is singleton_selection + resolved_singleton_selection = singleton_selection.one + assert isinstance(resolved_singleton_selection, OneCallerWithPlugins) + assert resolved_singleton_selection is not singleton_selection + assert resolved_singleton_selection.plugins[0] is singleton_selection.plugins[0] assert len(slot['plugin']) == 0 with pytest.raises(OneResolutionError, match=match('Slot "empty_body" has 2 registered plugins, so .one cannot choose one.')): _ = slot.one @@ -1527,7 +1531,8 @@ def plugin(): selection = slot['plugin'] assert [plugin.name for plugin in slot.one] == ['plugin'] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): - assert selection.one is selection + resolved_selection = selection.one + assert isinstance(resolved_selection, OneCallerWithPlugins) with pytest.raises(AttributeError, match=match('Attribute ".one" is read-only.')): slot.one = object() # type: ignore[misc] @@ -1540,11 +1545,36 @@ def plugin(): assert [plugin.name for plugin in slot.one] == ['plugin'] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): - assert selection.one is selection + assert isinstance(selection.one, OneCallerWithPlugins) + + +def test_one_caller_with_plugins_one_is_read_only(monkeypatch): + """`.one` is read-only on one-caller snapshots.""" + @public_slot + def empty_body(): + pass + + def get_entries(group=None): + assert group == 'pristan' + return [] + + slot = empty_body + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + @slot.plugin('plugin') + def plugin(): + return None + + resolved_selection = slot.one + + with pytest.raises(AttributeError, match=match('Attribute ".one" is read-only.')): + resolved_selection.one = object() # type: ignore[misc] + with pytest.raises(AttributeError, match=match('Attribute ".one" is read-only.')): + del resolved_selection.one # type: ignore[misc] def test_slot_one_returns_caller_with_plugins_for_singleton_and_fallback(monkeypatch, subscribable_list_type, subscribable_dict_type): - """`Slot.one` returns callable selections for singleton plugins and non-empty fallback bodies across public forms.""" + """`Slot.one` returns one-caller snapshots for singleton plugins and non-empty fallback bodies across public forms.""" def get_entries(group=None): assert group == 'pristan' return [] @@ -1633,31 +1663,41 @@ def direct_named_fallback_body() -> subscribable_dict_type[str, int]: direct_named_fallback_slot = public_slot(direct_named_fallback_body, name='direct_named_fallback_slot') - for current_slot, call_arguments, expected_plugin_count, expected_result in ( - (bare_slot, (), 1, ['bare']), - (factory_slot, (), 1, {'factory': 1}), - (named_slot, (), 1, [3]), - (configured_slot, (1,), 1, [1]), - (direct_slot, (), 1, {'direct': 2}), - (direct_named_slot, (), 1, {'direct_named': 4}), - (bare_fallback_slot, (), 0, []), - (factory_fallback_slot, (), 0, {}), - (named_fallback_slot, (), 0, []), - (configured_fallback_slot, (1,), 0, []), - (direct_fallback_slot, (), 0, []), - (direct_named_fallback_slot, (), 0, {}), + for current_slot, call_arguments, expected_aggregate, expected_result in ( + (bare_slot, (), ['bare'], 'bare'), + (factory_slot, (), {'factory': 1}, 1), + (named_slot, (), [3], 3), + (configured_slot, (1,), [1], 1), + (direct_slot, (), {'direct': 2}, 2), + (direct_named_slot, (), {'direct_named': 4}, 4), ): + assert current_slot(*call_arguments) == expected_aggregate resolved_selection = current_slot.one - assert isinstance(resolved_selection, CallerWithPlugins) + assert isinstance(resolved_selection, OneCallerWithPlugins) assert bool(resolved_selection) - assert len(resolved_selection) == expected_plugin_count + assert len(resolved_selection) == 1 assert resolved_selection(*call_arguments) == expected_result + for current_slot, call_arguments, expected_aggregate in ( + (bare_fallback_slot, (), []), + (factory_fallback_slot, (), {}), + (named_fallback_slot, (), []), + (configured_fallback_slot, (1,), []), + (direct_fallback_slot, (), []), + (direct_named_fallback_slot, (), {}), + ): + assert current_slot(*call_arguments) == expected_aggregate + resolved_selection = current_slot.one + + assert isinstance(resolved_selection, OneCallerWithPlugins) + assert bool(resolved_selection) + assert len(resolved_selection) == 0 + def test_caller_with_plugins_one_resolves_by_count_and_fallback(): """ - Selections return themselves for one plugin or fallback, else raise selection-specific errors. + Selections return one-callers for singleton plugin or fallback, else raise selection-specific errors. Catching selection warnings keeps these slots non-unique; changing them to unique=True would bias coverage, while leaving them uncaught would add warning noise. @@ -1695,11 +1735,17 @@ def second_group_plugin(): fallback_selection = CallerWithPlugins(fallback_slot.caller, []) with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "fallback_slot", because this code uses .one to work with a single plugin.')): - assert fallback_selection.one is fallback_selection + resolved_fallback_selection = fallback_selection.one + assert isinstance(resolved_fallback_selection, OneCallerWithPlugins) + assert resolved_fallback_selection is not fallback_selection + assert resolved_fallback_selection() is None singleton_selection = plugin_slot.plugins['plugin'] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "plugin_slot", because this code uses .one to work with a single plugin.')): - assert singleton_selection.one is singleton_selection + resolved_singleton_selection = singleton_selection.one + assert isinstance(resolved_singleton_selection, OneCallerWithPlugins) + assert resolved_singleton_selection is not singleton_selection + assert resolved_singleton_selection.plugins[0] is singleton_selection.plugins[0] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "plugin_slot", because this code uses .one to work with a single plugin.')), pytest.raises(OneResolutionError, match=match('Selection from slot "plugin_slot" has 2 selected plugins, so .one cannot choose one.')): _ = plugin_slot.plugins['group'].one @@ -1735,7 +1781,7 @@ def raise_loading_error(): slot._load_entrypoints = raise_loading_error # type: ignore[method-assign] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): - assert selection.one is selection + assert isinstance(selection.one, OneCallerWithPlugins) @pytest.mark.parametrize(('operation_name', 'remaining_plugin_count'), [('getitem', 1), ('pop', 0)]) @@ -1770,8 +1816,9 @@ def get_entries(group=None): } selection = operations[operation_name]() + assert selection() == [1] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): - assert selection.one() == [1] + assert selection.one() == 1 assert slot.loaded assert len(slot) == remaining_plugin_count @@ -1821,7 +1868,7 @@ def get_entries(group=None): ) def test_public_one_single_plugin_access_paths_return_callable_selections(monkeypatch, subscribable_list_type, access_selection, remaining_plugin_count, selection_access_warns): """ - Public singleton access paths return callable selections. + Public singleton paths return callable one-selections that unwrap plugin payloads. The pop row proves parent mutation does not affect the returned selection. Catching selection warnings keeps the slot non-unique; changing it to @@ -1842,17 +1889,74 @@ def get_entries(group=None): def plugin(): return 1 + assert slot() == [1] if selection_access_warns: with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): selection = access_selection(slot) else: selection = access_selection(slot) - assert selection() == [1] + assert selection() == 1 assert len(slot) == remaining_plugin_count assert slot.loaded +@pytest.mark.parametrize( + 'get_selection', + [ + lambda slot: slot['name'], + lambda slot: slot.pop('name'), + ], + ids=('getitem', 'pop'), +) +def test_public_list_selection_calls_without_one_keep_aggregate_result(monkeypatch, subscribable_list_type, get_selection): + """Public list selections called without `.one` keep returning list aggregates.""" + @public_slot + def empty_body() -> subscribable_list_type[int]: + return [] + + def get_entries(group=None): + assert group == 'pristan' + return [] + + slot = empty_body + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + @slot.plugin('name') + def plugin(): + return 1 + + assert get_selection(slot)() == [1] + + +@pytest.mark.parametrize( + 'get_selection', + [ + lambda slot: slot['name'], + lambda slot: slot.pop('name'), + ], + ids=('getitem', 'pop'), +) +def test_public_dict_selection_calls_without_one_keep_aggregate_result(monkeypatch, subscribable_dict_type, get_selection): + """Public dict selections called without `.one` keep returning dict aggregates.""" + @public_slot + def empty_body() -> subscribable_dict_type[str, int]: + return {} + + def get_entries(group=None): + assert group == 'pristan' + return [] + + slot = empty_body + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + @slot.plugin('name') + def plugin(): + return 1 + + assert get_selection(slot)() == {'name': 1} + + def test_slot_one_calls_load_entrypoints_once_per_access(): """Every `Slot.one` access calls the lazy entry-point loader hook once.""" @public_slot @@ -1898,7 +2002,7 @@ def plugin(): def test_slot_one_load_error_dominates_local_singleton(): - """Entry point loading failures dominate a locally resolvable singleton.""" + """Entry point loading failures are raised before `Slot.one` resolves a local singleton.""" @public_slot def empty_body(): pass @@ -2105,7 +2209,8 @@ def get_entries(group=None): def test_slot_one_resolves_non_empty_fallback_bodies(monkeypatch, subscribable_list_type, subscribable_dict_type): - """`Slot.one` treats non-empty fallback bodies as one candidate. + """ + `Slot.one` treats non-empty fallback bodies as one candidate. Rows cover unannotated None normalization and list/dict results of any size. """ @@ -2160,12 +2265,475 @@ def get_entries(group=None): (docstring_with_annotated_dict_body, {}), ): slot = public_slot(body_function) + assert slot() == expected_result resolved_selection = slot.one - assert isinstance(resolved_selection, CallerWithPlugins) + assert isinstance(resolved_selection, OneCallerWithPlugins) assert bool(resolved_selection) assert len(resolved_selection) == 0 - assert resolved_selection() == expected_result + + +@pytest.mark.parametrize( + ('payload_value', 'payload_annotation'), + [ + (1, int), + ('value', str), + ((1, 2), tuple), + ([1, 2], List[int]), + ({'value': 1}, Dict[str, int]), + (None, None), + ], + ids=('int', 'str', 'tuple', 'list', 'dict', 'none'), +) +@pytest.mark.parametrize('access_path', ['slot', 'getitem', 'pop', 'direct']) +def test_slot_one_unwraps_parameterized_list_plugin_payloads(monkeypatch, subscribable_list_type, access_path, payload_value, payload_annotation): + """Parameterized list slot, selection, pop, and direct one-caller paths return the selected plugin payload.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + @public_slot + def empty_body() -> subscribable_list_type[payload_annotation]: + return [] + + slot = empty_body + + @slot.plugin('name') + def plugin(): + return payload_value + + if access_path == 'slot': + assert slot.one() == payload_value + elif access_path == 'direct': + assert OneCallerWithPlugins(slot.caller, slot.plugins.plugins)() == payload_value + else: + selection = slot['name'] if access_path == 'getitem' else slot.pop('name') + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): + assert selection.one() == payload_value + + +@pytest.mark.parametrize( + ('payload_value', 'payload_annotation'), + [ + (1, int), + ('value', str), + ((1, 2), tuple), + ([1, 2], List[int]), + ({'value': 1}, Dict[str, int]), + (None, None), + ], + ids=('int', 'str', 'tuple', 'list', 'dict', 'none'), +) +@pytest.mark.parametrize('access_path', ['slot', 'getitem', 'pop', 'direct']) +def test_slot_one_unwraps_parameterized_dict_plugin_payloads(monkeypatch, subscribable_dict_type, access_path, payload_value, payload_annotation): + """Parameterized dict slot, selection, pop, and direct one-caller paths return the selected plugin payload.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + @public_slot + def empty_body() -> subscribable_dict_type[str, payload_annotation]: + return {} + + slot = empty_body + + @slot.plugin('name') + def plugin(): + return payload_value + + if access_path == 'slot': + assert slot.one() == payload_value + elif access_path == 'direct': + assert OneCallerWithPlugins(slot.caller, slot.plugins.plugins)() == payload_value + else: + selection = slot['name'] if access_path == 'getitem' else slot.pop('name') + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): + assert selection.one() == payload_value + + +@pytest.mark.parametrize( + ('payload_value', 'payload_annotation'), + [ + (1, int), + ('value', str), + ((1, 2), tuple), + ([1, 2], List[int]), + ({'value': 1}, Dict[str, int]), + (None, None), + ], + ids=('int', 'str', 'tuple', 'list', 'dict', 'none'), +) +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +def test_slot_one_unwraps_parameterized_list_fallback_singletons(monkeypatch, subscribable_list_type, access_path, payload_value, payload_annotation): + """Parameterized list `.one` unwraps singleton fallback results for slots and empty selections.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + @public_slot + def fallback_body() -> subscribable_list_type[payload_annotation]: + return [payload_value] + + slot = fallback_body + + if access_path == 'slot': + assert slot.one() == payload_value + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "fallback_body", because this code uses .one to work with a single plugin.')): + assert slot['missing'].one() == payload_value + + +@pytest.mark.parametrize( + ('payload_value', 'payload_annotation'), + [ + (1, int), + ('value', str), + ((1, 2), tuple), + ([1, 2], List[int]), + ({'value': 1}, Dict[str, int]), + (None, None), + ], + ids=('int', 'str', 'tuple', 'list', 'dict', 'none'), +) +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +def test_slot_one_unwraps_parameterized_dict_fallback_singletons(monkeypatch, subscribable_dict_type, access_path, payload_value, payload_annotation): + """Parameterized dict `.one` unwraps singleton fallback results for slots and empty selections.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + @public_slot + def fallback_body() -> subscribable_dict_type[str, payload_annotation]: + return {'only': payload_value} + + slot = fallback_body + + if access_path == 'slot': + assert slot.one() == payload_value + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "fallback_body", because this code uses .one to work with a single plugin.')): + assert slot['missing'].one() == payload_value + + +def test_empty_selection_one_uses_fallback_even_when_parent_slot_has_plugins(monkeypatch, subscribable_list_type): + """An empty selection uses the parent fallback body even when other plugins are registered on the slot.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + @public_slot + def fallback_body() -> subscribable_list_type[int]: + return [1] + + slot = fallback_body + + @slot.plugin + def plugin(): + raise AssertionError('plugin was executed') + + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "fallback_body", because this code uses .one to work with a single plugin.')): + assert slot['missing'].one() == 1 + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +@pytest.mark.parametrize('fallback_result', [[], [1, 2]]) +def test_slot_one_call_raises_when_parameterized_list_fallback_returns_non_singleton(monkeypatch, subscribable_list_type, access_path, fallback_result): + """Parameterized list `.one` creates a one-caller, then rejects non-singleton fallback results on call.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + def fallback_body() -> subscribable_list_type[int]: + return fallback_result + + slot = public_slot(fallback_body, name='list_fallback_body') + + if access_path == 'slot': + resolved_selection = slot.one + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "list_fallback_body", because this code uses .one to work with a single plugin.')): + resolved_selection = slot['missing'].one + + assert isinstance(resolved_selection, OneCallerWithPlugins) + with pytest.raises(OneResolutionError, match=match(f'Slot "list_fallback_body" .one returned {len(fallback_result)} results, so .one cannot choose one.')): + resolved_selection() + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +@pytest.mark.parametrize('fallback_result', [{}, {'first': 1, 'second': 2}]) +def test_slot_one_call_raises_when_parameterized_dict_fallback_returns_non_singleton(monkeypatch, subscribable_dict_type, access_path, fallback_result): + """Parameterized dict `.one` creates a one-caller, then rejects non-singleton fallback results on call.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + def fallback_body() -> subscribable_dict_type[str, int]: + return fallback_result + + slot = public_slot(fallback_body, name='dict_fallback_body') + + if access_path == 'slot': + resolved_selection = slot.one + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "dict_fallback_body", because this code uses .one to work with a single plugin.')): + resolved_selection = slot['missing'].one + + assert isinstance(resolved_selection, OneCallerWithPlugins) + with pytest.raises(OneResolutionError, match=match(f'Slot "dict_fallback_body" .one returned {len(fallback_result)} results, so .one cannot choose one.')): + resolved_selection() + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +def test_slot_one_raises_on_call_when_list_fallback_with_docstring_returns_empty_list(monkeypatch, subscribable_list_type, access_path): + """Annotated list docstring fallbacks resolve through slot/selection `.one`, then reject empty results on call.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + def fallback_body() -> subscribable_list_type[int]: + """Docstring keeps the body non-empty while returning empty.""" + return [] + + slot = public_slot(fallback_body, name='list_docstring_fallback_body') + if access_path == 'slot': + resolved_selection = slot.one + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "list_docstring_fallback_body", because this code uses .one to work with a single plugin.')): + resolved_selection = slot['missing'].one + + assert isinstance(resolved_selection, OneCallerWithPlugins) + with pytest.raises(OneResolutionError, match=match('Slot "list_docstring_fallback_body" .one returned 0 results, so .one cannot choose one.')): + resolved_selection() + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +def test_slot_one_raises_on_call_when_dict_fallback_with_docstring_returns_empty_dict(monkeypatch, subscribable_dict_type, access_path): + """Annotated dict docstring fallbacks resolve through slot/selection `.one`, then reject empty results on call.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + def fallback_body() -> subscribable_dict_type[str, int]: + """Docstring keeps the body non-empty while returning empty.""" + return {} + + slot = public_slot(fallback_body, name='dict_docstring_fallback_body') + if access_path == 'slot': + resolved_selection = slot.one + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "dict_docstring_fallback_body", because this code uses .one to work with a single plugin.')): + resolved_selection = slot['missing'].one + + assert isinstance(resolved_selection, OneCallerWithPlugins) + with pytest.raises(OneResolutionError, match=match('Slot "dict_docstring_fallback_body" .one returned 0 results, so .one cannot choose one.')): + resolved_selection() + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +@pytest.mark.parametrize('fallback_result', [None, 1, [], [1], [1, 2], {}, {'only': 1}, {'first': 1, 'second': 2}]) +def test_slot_one_normalizes_unannotated_fallback_results_to_none(monkeypatch, access_path, fallback_result): + """Unannotated fallbacks normalize `.one` to None for any value or container size.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + fallback_calls = [] + + def fallback_body(): + fallback_calls.append('fallback') + return fallback_result + + slot = public_slot(fallback_body, name='unannotated_fallback_body') + + if access_path == 'slot': + assert slot.one() is None + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "unannotated_fallback_body", because this code uses .one to work with a single plugin.')): + assert slot['missing'].one() is None + assert fallback_calls == ['fallback'] + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +def test_slot_one_normalizes_bare_return_fallback_result_to_none(monkeypatch, access_path): + """Bare-return unannotated fallbacks run under `.one` but normalize to None.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + fallback_calls = [] + + def fallback_body(): + fallback_calls.append('fallback') + return # noqa: PLR1711 + + slot = public_slot(fallback_body, name='bare_return_fallback_body') + + if access_path == 'slot': + assert slot.one() is None + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "bare_return_fallback_body", because this code uses .one to work with a single plugin.')): + assert slot['missing'].one() is None + assert fallback_calls == ['fallback'] + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +@pytest.mark.parametrize('fallback_result', [[], [1], [1, 2]]) +def test_slot_one_normalizes_bare_list_fallback_results_to_none(monkeypatch, list_type, access_path, fallback_result): + """Bare list/List fallbacks normalize `.one` to None for any list size.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + fallback_calls = [] + + def fallback_body() -> list_type: + fallback_calls.append('fallback') + return fallback_result + + slot = public_slot(fallback_body, name='bare_list_fallback_body') + + if access_path == 'slot': + assert slot.one() is None + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "bare_list_fallback_body", because this code uses .one to work with a single plugin.')): + assert slot['missing'].one() is None + assert fallback_calls == ['fallback'] + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +@pytest.mark.parametrize('fallback_result', [{}, {'only': 1}, {'first': 1, 'second': 2}]) +def test_slot_one_normalizes_bare_dict_fallback_results_to_none(monkeypatch, dict_type, access_path, fallback_result): + """Bare dict/Dict fallbacks normalize `.one` to None for any dict size.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + fallback_calls = [] + + def fallback_body() -> dict_type: + fallback_calls.append('fallback') + return fallback_result + + slot = public_slot(fallback_body, name='bare_dict_fallback_body') + + if access_path == 'slot': + assert slot.one() is None + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "bare_dict_fallback_body", because this code uses .one to work with a single plugin.')): + assert slot['missing'].one() is None + assert fallback_calls == ['fallback'] + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +@pytest.mark.parametrize('plugin_result', [1, [1, 2], {'value': 1}]) +def test_slot_one_normalizes_unannotated_plugin_results_to_none(monkeypatch, access_path, plugin_result): + """On an unannotated slot, `.one` runs the selected plugin but normalizes its result to None.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + plugin_calls = [] + + @public_slot + def empty_body(): + pass + + slot = empty_body + + @slot.plugin('name') + def plugin(): + plugin_calls.append('plugin') + return plugin_result + + if access_path == 'slot': + assert slot.one() is None + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): + assert slot['name'].one() is None + assert plugin_calls == ['plugin'] + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +@pytest.mark.parametrize('plugin_result', [1, [1, 2], {'value': 1}]) +def test_slot_one_normalizes_plugin_results_to_none_for_bare_list_slots(monkeypatch, list_type, access_path, plugin_result): + """Bare list/List slots dispatch the selected plugin, but `.one` returns None.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + plugin_calls = [] + + @public_slot + def empty_body() -> list_type: + return [] + + slot = empty_body + + @slot.plugin('name') + def plugin(): + plugin_calls.append('plugin') + return plugin_result + + if access_path == 'slot': + assert slot.one() is None + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): + assert slot['name'].one() is None + assert plugin_calls == ['plugin'] + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +@pytest.mark.parametrize('plugin_result', [1, [1, 2], {'value': 1}]) +def test_slot_one_normalizes_plugin_results_to_none_for_bare_dict_slots(monkeypatch, dict_type, access_path, plugin_result): + """Bare dict/Dict slots dispatch the selected plugin, but `.one` returns None.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + plugin_calls = [] + + @public_slot + def empty_body() -> dict_type: + return {} + + slot = empty_body + + @slot.plugin('name') + def plugin(): + plugin_calls.append('plugin') + return plugin_result + + if access_path == 'slot': + assert slot.one() is None + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): + assert slot['name'].one() is None + assert plugin_calls == ['plugin'] def test_slot_one_prefers_single_plugin_over_non_empty_fallback_body(monkeypatch, subscribable_list_type): @@ -2185,7 +2753,78 @@ def get_entries(group=None): def plugin(): return 'plugin' - assert slot.one() == ['plugin'] + assert slot() == ['plugin'] + assert slot.one() == 'plugin' + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +@pytest.mark.parametrize('fallback_behavior', ['raises', 'empty', 'multi']) +def test_slot_one_prefers_single_list_plugin_over_failing_or_non_singleton_fallback(monkeypatch, subscribable_list_type, access_path, fallback_behavior): + """A single plugin on a parameterized list slot resolves without running a failing or non-singleton fallback body.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + fallback_events = [] + + @public_slot + def fallback_body() -> subscribable_list_type[str]: + fallback_events.append('fallback') + if fallback_behavior == 'raises': + raise AssertionError('fallback was executed') + if fallback_behavior == 'empty': + return [] + return ['first', 'second'] + + slot = fallback_body + + @slot.plugin('plugin') + def plugin(): + return 'plugin' + + if access_path == 'slot': + assert slot.one() == 'plugin' + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "fallback_body", because this code uses .one to work with a single plugin.')): + assert slot['plugin'].one() == 'plugin' + + assert not fallback_events + + +@pytest.mark.parametrize('access_path', ['slot', 'selection']) +@pytest.mark.parametrize('fallback_behavior', ['raises', 'empty', 'multi']) +def test_slot_one_prefers_single_dict_plugin_over_failing_or_non_singleton_fallback(monkeypatch, subscribable_dict_type, access_path, fallback_behavior): + """A single plugin on a parameterized dict slot resolves without running a failing or non-singleton fallback body.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + fallback_events = [] + + @public_slot + def fallback_body() -> subscribable_dict_type[str, str]: + fallback_events.append('fallback') + if fallback_behavior == 'raises': + raise AssertionError('fallback was executed') + if fallback_behavior == 'empty': + return {} + return {'first': 'first', 'second': 'second'} + + slot = fallback_body + + @slot.plugin('plugin') + def plugin(): + return 'plugin' + + if access_path == 'slot': + assert slot.one() == 'plugin' + else: + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "fallback_body", because this code uses .one to work with a single plugin.')): + assert slot['plugin'].one() == 'plugin' + + assert not fallback_events def test_slot_one_plugin_count_resolution_skips_fallback_body(): @@ -2199,7 +2838,7 @@ def default_body(): raise AssertionError('default body was executed') for plugin_count in (1, 2): - slot = public_slot(default_body, name='default_body') + slot = public_slot(default_body) for index in range(plugin_count): @slot.plugin(f'plugin_{index}') @@ -2233,6 +2872,10 @@ def fallback_body(): fallback_slot = fallback_body fallback_selection = fallback_slot.one + assert not fallback_events + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "fallback_body", because this code uses .one to work with a single plugin.')): + _ = fallback_slot['missing'].one + assert not fallback_events fallback_selection() assert fallback_events == ['fallback-called'] @@ -2251,6 +2894,10 @@ def plugin(): plugin_selection = plugin_slot.one + assert not plugin_events + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): + _ = plugin_slot['plugin'].one + assert not plugin_events plugin_selection() assert plugin_events == ['plugin-called'] @@ -2301,6 +2948,52 @@ def get_entries(group=None): assert slot.loaded +def test_slot_level_one_does_not_warn_for_non_unique_slots(monkeypatch, subscribable_list_type): + """Slot-level `.one` does not warn on non-unique slots for successful calls or count-resolution errors.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + @public_slot + def plugin_backed_slot() -> subscribable_list_type[int]: + return [] + + @public_slot + def fallback_body() -> subscribable_list_type[int]: + return [2] + + @public_slot + def empty_slot(): + pass + + @public_slot + def multiple_plugins_slot() -> subscribable_list_type[int]: + return [] + + @plugin_backed_slot.plugin + def plugin(): + return 1 + + @multiple_plugins_slot.plugin + def first_plugin(): + return 1 + + @multiple_plugins_slot.plugin + def second_plugin(): + return 2 + + with warnings.catch_warnings(): + warnings.simplefilter('error', SyntaxWarning) + assert plugin_backed_slot.one() == 1 + assert fallback_body.one() == 2 + with pytest.raises(OneResolutionError, match=match('Slot "empty_slot" has no registered plugins and its body is empty.')): + _ = empty_slot.one + with pytest.raises(OneResolutionError, match=match('Slot "multiple_plugins_slot" has 2 registered plugins, so .one cannot choose one.')): + _ = multiple_plugins_slot.one + + def test_one_result_type_checks_happen_on_call_for_plugins_and_fallback(monkeypatch, subscribable_list_type): """Plugin and fallback result checks happen when the resolved selection is called.""" def get_entries(group=None): @@ -2338,6 +3031,40 @@ def fallback_body() -> subscribable_list_type[str]: fallback_selection() +@pytest.mark.parametrize('error_source', ['plugin', 'fallback']) +def test_slot_one_call_propagates_plugin_and_fallback_body_errors(monkeypatch, subscribable_list_type, error_source): + """Plugin or fallback body exceptions are not replaced by OneResolutionError.""" + def get_entries(group=None): + assert group == 'pristan' + return [] + + monkeypatch.setattr(slot_module, 'entry_points', get_entries) + + expected_error = RuntimeError('failed') + + if error_source == 'plugin': + @public_slot + def empty_body() -> subscribable_list_type[int]: + return [] + + slot = empty_body + + @slot.plugin + def plugin(): + raise expected_error + + else: + @public_slot + def fallback_body() -> subscribable_list_type[int]: + raise expected_error + + slot = fallback_body + + with pytest.raises(RuntimeError) as exception_info: + slot.one() + assert exception_info.value is expected_error + + def test_slot_one_preserves_run_once_plugin_state(monkeypatch, subscribable_list_type): """`Slot.one` snapshots share plugin objects, so run-once state is preserved.""" @public_slot @@ -2355,7 +3082,7 @@ def get_entries(group=None): def plugin(): return 1 - assert slot.one() == [1] + assert slot.one() == 1 with pytest.raises(NumberOfCallsError, match=match('A limit of 1 has been set on the number of calls for plugin "plugin". And this plugin has already been called previously.')): slot.one() @@ -2428,8 +3155,9 @@ def second(): def third(): return 3 - with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): # noqa: PT031 - for key, expected_result in (('name-1', [1]), ('name-2', [2]), ('name-3', [3])): + for key, expected_result in (('name-1', 1), ('name-2', 2), ('name-3', 3)): + assert slot[key]() == [expected_result] + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): assert slot[key].one() == expected_result with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')), pytest.raises(OneResolutionError, match=match('Selection from slot "empty_body" has 3 selected plugins, so .one cannot choose one.')): @@ -2437,11 +3165,13 @@ def third(): popped_selection = slot.pop('name-2') + assert popped_selection() == [2] with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): - assert popped_selection.one() == [2] + assert popped_selection.one() == 2 assert [plugin.name for plugin in slot.plugins.plugins] == ['name', 'name-2'] - with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): # noqa: PT031 - for key, expected_result in (('name-1', [1]), ('name-2', [3])): + for key, expected_result in (('name-1', 1), ('name-2', 3)): + assert slot[key]() == [expected_result] + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): assert slot[key].one() == expected_result diff --git a/tests/units/components/test_slot_caller.py b/tests/units/components/test_slot_caller.py index 21a5ddf..ebf4192 100644 --- a/tests/units/components/test_slot_caller.py +++ b/tests/units/components/test_slot_caller.py @@ -6,7 +6,7 @@ from pristan.components.plugin import Plugin from pristan.components.slot import Slot -from pristan.components.slot_caller import CallerWithPlugins +from pristan.components.slot_caller import CallerWithPlugins, OneCallerWithPlugins from pristan.errors import OneResolutionError @@ -93,40 +93,44 @@ def plugin_function(): assert CallerWithPlugins(slot.caller, [Plugin('plugin', plugin_function, int, True, False)]) -def test_caller_with_plugins_one_warns_when_slot_is_not_unique(): - """Non-unique slot selection `.one` warns on success.""" +def test_caller_with_plugins_one_warns_when_slot_is_not_unique(caller_class): + """Non-unique selections warn when `.one` returns an independent one-caller snapshot.""" def empty_body(): pass - def plugin_function(): - return None - slot = Slot(empty_body, signature=None, slot_name=None, max=None, type_check=True, entrypoint_group='pristan', unique=False) - selection = CallerWithPlugins(slot.caller, [Plugin('plugin', plugin_function, int, True, False)]) + plugin = Plugin('plugin', empty_body, int, True, False) + selection = caller_class(slot.caller, [plugin]) with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_body", because this code uses .one to work with a single plugin.')): - assert selection.one is selection + resolved_selection = selection.one + assert isinstance(resolved_selection, OneCallerWithPlugins) + assert resolved_selection is not selection + assert resolved_selection.plugins is not selection.plugins + assert resolved_selection.plugins == [plugin] -def test_caller_with_plugins_one_does_not_warn_when_slot_is_unique(): - """Unique slot selection `.one` does not warn on success.""" + +def test_caller_with_plugins_one_does_not_warn_when_slot_is_unique(caller_class): + """Unique selections return an independent one-caller snapshot without warning.""" def empty_body(): pass - def plugin_function(): - return None - slot = Slot(empty_body, signature=None, slot_name=None, max=None, type_check=True, entrypoint_group='pristan', unique=True) - selection = CallerWithPlugins(slot.caller, [Plugin('plugin', plugin_function, int, True, False)]) + plugin = Plugin('plugin', empty_body, int, True, False) + selection = caller_class(slot.caller, [plugin]) - with warnings.catch_warnings(record=True) as caught_warnings: - warnings.simplefilter('always') - assert selection.one is selection + with warnings.catch_warnings(): + warnings.simplefilter('error', SyntaxWarning) + resolved_selection = selection.one - assert not any(issubclass(warning.category, SyntaxWarning) for warning in caught_warnings) + assert isinstance(resolved_selection, OneCallerWithPlugins) + assert resolved_selection is not selection + assert resolved_selection.plugins is not selection.plugins + assert resolved_selection.plugins == [plugin] -def test_caller_with_plugins_one_resolution_errors_warn_when_slot_is_not_unique(): +def test_caller_with_plugins_one_resolution_errors_warn_when_slot_is_not_unique(caller_class): """ Selections from non-unique slots warn before `.one` resolution errors. @@ -136,32 +140,83 @@ def test_caller_with_plugins_one_resolution_errors_warn_when_slot_is_not_unique( def empty_body(): pass - def plugin_function(): - return None - empty_slot = Slot(empty_body, signature=None, slot_name='empty_slot', max=None, type_check=True, entrypoint_group='pristan', unique=False) multi_plugin_slot = Slot(empty_body, signature=None, slot_name='multi_plugin_slot', max=None, type_check=True, entrypoint_group='pristan', unique=False) - cases = ( - ( - CallerWithPlugins(empty_slot.caller, []), - 'empty_slot', - 'Selection from slot "empty_slot" has no selected plugins and the slot body is empty.', - ), - ( - CallerWithPlugins(multi_plugin_slot.caller, [Plugin('first', plugin_function, int, True, False), Plugin('second', plugin_function, int, True, False)]), - 'multi_plugin_slot', - 'Selection from slot "multi_plugin_slot" has 2 selected plugins, so .one cannot choose one.', - ), - ) + empty_selection = caller_class(empty_slot.caller, []) + multi_plugin_selection = caller_class(multi_plugin_slot.caller, [Plugin('first', empty_body, int, True, False), Plugin('second', empty_body, int, True, False)]) + + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "empty_slot", because this code uses .one to work with a single plugin.')), pytest.raises(OneResolutionError, match=match('Selection from slot "empty_slot" has no selected plugins and the slot body is empty.')): + _ = empty_selection.one + with pytest.warns(SyntaxWarning, match=match('Consider setting unique=True for slot "multi_plugin_slot", because this code uses .one to work with a single plugin.')), pytest.raises(OneResolutionError, match=match('Selection from slot "multi_plugin_slot" has 2 selected plugins, so .one cannot choose one.')): + _ = multi_plugin_selection.one + + +def test_one_caller_with_plugins_rejects_multiple_list_aggregate_results(subscribable_list_type): + """Direct OneCallerWithPlugins calls reject multi-result list aggregates.""" + def empty_list_body() -> subscribable_list_type[int]: + return [] + + plugin_calls = [] + + def first_plugin(): + plugin_calls.append('first') + return 1 + + def second_plugin(): + plugin_calls.append('second') + return 2 + + slot = Slot(empty_list_body, signature=None, slot_name='list_slot', max=None, type_check=True, entrypoint_group='pristan', unique=False) + one_selection = OneCallerWithPlugins(slot.caller, [Plugin('first', first_plugin, int, True, False), Plugin('second', second_plugin, int, True, False)]) + + with pytest.raises(OneResolutionError, match=match('Slot "list_slot" .one returned 2 results, so .one cannot choose one.')): + one_selection() + assert plugin_calls == ['first', 'second'] + + +def test_one_caller_with_plugins_rejects_multiple_dict_aggregate_results(subscribable_dict_type): + """Direct OneCallerWithPlugins calls reject multi-result dict aggregates.""" + def empty_dict_body() -> subscribable_dict_type[str, int]: + return {} + + plugin_calls = [] + + def first_plugin(): + plugin_calls.append('first') + return 1 + + def second_plugin(): + plugin_calls.append('second') + return 2 + + slot = Slot(empty_dict_body, signature=None, slot_name='dict_slot', max=None, type_check=True, entrypoint_group='pristan', unique=False) + one_selection = OneCallerWithPlugins(slot.caller, [Plugin('first', first_plugin, int, True, False), Plugin('second', second_plugin, int, True, False)]) + + with pytest.raises(OneResolutionError, match=match('Slot "dict_slot" .one returned 2 results, so .one cannot choose one.')): + one_selection() + assert plugin_calls == ['first', 'second'] + + +def test_one_caller_with_plugins_passes_through_non_aggregate_fallback_result(): + """OneCallerWithPlugins passes through non-aggregate fallback results.""" + class ScalarCodeRepresentation: + is_empty = False + returns_list = False + returns_dict = False + returning_type = int + + def fallback_body(value, *, multiplier): + return value * multiplier + + slot = Slot(fallback_body, signature=None, slot_name=None, max=None, type_check=True, entrypoint_group='pristan', unique=False) + slot.code_representation = ScalarCodeRepresentation() - for selection, slot_name, error_message in cases: - with pytest.warns(SyntaxWarning, match=match(f'Consider setting unique=True for slot "{slot_name}", because this code uses .one to work with a single plugin.')), pytest.raises(OneResolutionError, match=match(error_message)): - _ = selection.one + assert OneCallerWithPlugins(slot.caller, [])(6, multiplier=7) == 42 def test_empty_list_and_dict_defaults_still_call_to_empty_containers(): - """SlotCaller.__call__ still returns empty list and dict defaults without plugins.""" + """SlotCaller.__call__ and empty CallerWithPlugins selections return empty containers without plugins.""" def empty_list_body() -> List[int]: return [] @@ -175,6 +230,7 @@ def empty_dict_body() -> Dict[str, int]: slot = Slot(function, signature=None, slot_name=None, max=None, type_check=True, entrypoint_group='pristan', unique=False) assert slot.caller([]) == expected_result + assert CallerWithPlugins(slot.caller, [])() == expected_result def test_repr():