Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
202 changes: 202 additions & 0 deletions docs/plans/6.md

Large diffs are not rendered by default.

17 changes: 15 additions & 2 deletions pristan/common_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]: ...
Expand Down
5 changes: 3 additions & 2 deletions pristan/components/slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
44 changes: 41 additions & 3 deletions pristan/components/slot_caller.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions tests/smokes/demo/simple_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}


Expand All @@ -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 {}


Expand Down
18 changes: 10 additions & 8 deletions tests/smokes/test_entry_points.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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']


Expand Down Expand Up @@ -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):
Expand All @@ -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']


Expand Down
6 changes: 3 additions & 3 deletions tests/typing/components/test_slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []

Expand All @@ -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]
15 changes: 7 additions & 8 deletions tests/typing/components/test_slot_caller.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,17 @@ 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]

slot = Slot(collect, signature=None, slot_name=None, max=None, type_check=True, entrypoint_group='pristan', unique=False)
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]
Loading
Loading