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
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ def plugin_name():
...
```

The plugin name must be a valid Python identifier. However, if more than one plugin with the same name is attached to a single slot, the system will automatically change their names to remain unique by appending a numeric suffix, starting with the second plugin (`plugin_name`, `plugin_name-2`, and so on).
The plugin name must be a valid Python identifier. By default, if more than one plugin with the same name is attached to a single slot, the system will automatically change their names to remain unique by appending a numeric suffix, starting with the second plugin (`plugin_name`, `plugin_name-2`, and so on).

Now that we know what plugin names are, let's look at basic operations with the slot as a collection.

Expand Down Expand Up @@ -422,6 +422,25 @@ def plugin_2():
#> pristan.errors.TooManyPluginsError: The maximum number of plugins for this slot is 1.
```

If every plugin in a slot must have a unique declared name, pass `unique=True` to the slot decorator:

```python
@slot(unique=True)
def some_slot():
...

@some_slot.plugin('plugin')
def plugin_1():
...

@some_slot.plugin('plugin')
def plugin_2():
...

#> ...
#> pristan.errors.PrimadonnaPluginError: Slot "some_slot" requires unique plugin names, but "plugin" is already registered.
```

You can also restrict a plugin to a specific version of the library that declares the slot. To do this, pass a version expression (or a `list` of them) as the `engine` argument:

```python
Expand Down
13 changes: 11 additions & 2 deletions pristan/components/slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,20 @@
# TODO: consider to delete all the "type: ignore"d comments if python 3.9 deleted from the matrix
@repred(
positionals=['slot_function'],
getters={
'slot_name': lambda x: x.declared_slot_name,
},
filters={
'signature': not_none,
'slot_name': not_none,
'max': not_none,
'type_check': lambda x: x != True,
'entrypoint_group': lambda x: x != 'pristan',
'unique': lambda x: x,
},
)
class Slot(Generic[PluginResult]):
def __init__(self, slot_function: SlotFunction[SlotParameters, SlotResult[PluginResult]], signature: Optional[str], slot_name: Optional[str], max: Optional[int], type_check: bool, entrypoint_group: str) -> None: # noqa: PLR0913, A002
def __init__(self, slot_function: SlotFunction[SlotParameters, SlotResult[PluginResult]], signature: Optional[str], slot_name: Optional[str], max: Optional[int], type_check: bool, entrypoint_group: str, unique: bool) -> None: # noqa: PLR0913, A002
if max is not None and max < 0:
raise ValueError('The maximum number of plugins cannot be less than zero.')

Expand All @@ -72,11 +76,13 @@ def __init__(self, slot_function: SlotFunction[SlotParameters, SlotResult[Plugin
raise StrangeTypeAnnotationError('The return type annotation for a slot must be either a list or a dict, or remain empty.')

self.signature = signature
self.slot_name = slot_name
self.declared_slot_name = slot_name
self.slot_name = slot_name if slot_name is not None else slot_function.__name__
self.slot_function = slot_function
self.max_number_of_plugins = max
self.type_check = type_check
self.entrypoint_group = entrypoint_group
self.unique = unique

self.lock = RLock()

Expand Down Expand Up @@ -184,6 +190,9 @@ def _add_plugin(self, name: str, function: PluginFunction[SlotParameters, Plugin
raise TooManyPluginsError(f'The maximum number of plugins for this slot is {self.max_number_of_plugins}.')

if self.code_representation.check_package_version(engine):
if self.unique and name in self.plugins.plugins_by_requested_names:
raise PrimadonnaPluginError(f'Slot "{self.slot_name}" requires unique plugin names, but "{name}" is already registered.')

self.plugins.add(plugin)
if len(self.plugins.plugins_by_requested_names[name]) > 1:
plugin.set_name(f'{name}-{len(self.plugins.plugins_by_requested_names[name])}')
Expand Down
6 changes: 3 additions & 3 deletions pristan/components/slot_caller.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Dict, Generator, Generic, List, Optional, Type, Union
from typing import Any, Dict, Generator, Generic, List, Type, Union

from denial import InnerNoneType
from printo import repred
Expand All @@ -18,7 +18,7 @@
@repred
class SlotCaller(Generic[PluginResult]):
# TODO: consider to delete this "type: ignore" if python 3.8 deleted from the matrix
def __init__(self, code_representation: SlotCodeRepresenter, slot_name: Optional[str], slot_function: SlotFunction[SlotParameters, SlotResult[PluginResult]], type_check: bool) -> None:
def __init__(self, code_representation: SlotCodeRepresenter, slot_name: str, slot_function: SlotFunction[SlotParameters, SlotResult[PluginResult]], type_check: bool) -> None:
self.code_representation = code_representation
self.slot_name = slot_name
self.slot_function = slot_function
Expand All @@ -40,7 +40,7 @@ def __call__(self, plugins: Union[PluginsGroup[PluginResult], List[Plugin[Plugin
returns_type = self.code_representation.returning_type

# TODO: consider to delete this "type: ignore" if python 3.9 deleted from the matrix
result: SlotResult[PluginResult] = Plugin(self.slot_name if self.slot_name is not None else self.slot_function.__name__, self.slot_function, returns_type, self.type_check, False)(*args, **kwargs)
result: SlotResult[PluginResult] = Plugin(self.slot_name, self.slot_function, returns_type, self.type_check, False)(*args, **kwargs)

if self.code_representation.returning_type is return_type_sentinel and not self.code_representation.returns_dict and not self.code_representation.returns_list:
result = None
Expand Down
17 changes: 7 additions & 10 deletions pristan/decorators/slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,24 @@


@overload
def slot(function: Callable[SlotParameters, List[PluginResult]], /) -> SlotProtocol[SlotParameters, List[PluginResult], PluginResult]: ... # pragma: no branch
def slot(function: Callable[SlotParameters, List[PluginResult]], /, *, signature: Optional[str] = None, name: Optional[str] = None, max: Optional[int] = None, type_check: bool = True, entrypoint_group: str = 'pristan', unique: bool = False) -> SlotProtocol[SlotParameters, List[PluginResult], PluginResult]: ... # pragma: no branch, PLR0913, A002

@overload
def slot(function: Callable[SlotParameters, Dict[str, PluginResult]], /) -> SlotProtocol[SlotParameters, Dict[str, PluginResult], PluginResult]: ... # pragma: no branch
def slot(function: Callable[SlotParameters, Dict[str, PluginResult]], /, *, signature: Optional[str] = None, name: Optional[str] = None, max: Optional[int] = None, type_check: bool = True, entrypoint_group: str = 'pristan', unique: bool = False) -> SlotProtocol[SlotParameters, Dict[str, PluginResult], PluginResult]: ... # pragma: no branch, PLR0913, A002

@overload
def slot(function: Callable[SlotParameters, None], /) -> SlotProtocol[SlotParameters, None, Any]: ... # pragma: no branch
def slot(function: Callable[SlotParameters, None], /, *, signature: Optional[str] = None, name: Optional[str] = None, max: Optional[int] = None, type_check: bool = True, entrypoint_group: str = 'pristan', unique: bool = False) -> SlotProtocol[SlotParameters, None, Any]: ... # pragma: no branch, PLR0913, A002

@overload
def slot(*, signature: Optional[str] = None, name: Optional[str] = None, max: Optional[int] = None, type_check: bool = True, entrypoint_group: str = 'pristan') -> SlotDecoratorProtocol: ... # pragma: no branch, PLR0913, A002
def slot(function: str = ..., /, *, signature: Optional[str] = None, name: Optional[str] = None, max: Optional[int] = None, type_check: bool = True, entrypoint_group: str = 'pristan', unique: bool = False) -> SlotDecoratorProtocol: ... # pragma: no branch, PLR0913, A002

@overload
def slot(function: str, /, *, signature: Optional[str] = None, name: Optional[str] = None, max: Optional[int] = None, type_check: bool = True, entrypoint_group: str = 'pristan') -> SlotDecoratorProtocol: ... # pragma: no branch, PLR0913, A002

def slot(function: Optional[object] = None, /, *, signature: Optional[str] = None, name: Optional[str] = None, max: Optional[int] = None, type_check: bool = True, entrypoint_group: str = 'pristan') -> Any: # noqa: PLR0913, A002
def slot(function: Optional[object] = None, /, *, signature: Optional[str] = None, name: Optional[str] = None, max: Optional[int] = None, type_check: bool = True, entrypoint_group: str = 'pristan', unique: bool = False) -> Any: # noqa: PLR0913, A002
if callable(function):
return wraps(function)(Slot(function, signature, name, max, type_check, entrypoint_group))
return wraps(function)(Slot(function, signature, name, max, type_check, entrypoint_group, unique))

if isinstance(function, str):
if name is not None and name != function:
raise ValueError('You have specified two different names for the slot.')
name = function

return partial(slot, signature=signature, name=name, max=max, type_check=type_check, entrypoint_group=entrypoint_group)
return partial(slot, signature=signature, name=name, max=max, type_check=type_check, entrypoint_group=entrypoint_group, unique=unique)
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.15"
version = "0.0.16"
authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }]
description = "Function-based plugin system with respect to typing"
readme = "README.md"
Expand Down
5 changes: 5 additions & 0 deletions tests/smokes/demo/simple_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,8 @@ def simple_slot_4() -> Dict[str, int]:
@slot
def simple_slot_5() -> Dict[str, int]:
return {}


@slot(unique=True)
def simple_slot_6() -> Dict[str, int]:
return {}
11 changes: 11 additions & 0 deletions tests/smokes/demo/simple_unique_plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from tests.smokes.demo.simple_slots import simple_slot_6


@simple_slot_6.plugin('name') # type: ignore[attr-defined]
def plugin_5():
return 1


@simple_slot_6.plugin('name') # type: ignore[attr-defined]
def plugin_6():
return 2
34 changes: 34 additions & 0 deletions tests/smokes/test_entry_points.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from importlib.metadata import EntryPoint

import pytest
from full_match import match

import pristan.components.slot as slot_module
from pristan.errors import PrimadonnaPluginError
from tests.smokes.demo.simple_slots import (
simple_slot_1,
simple_slot_2,
simple_slot_3,
simple_slot_4,
simple_slot_5,
simple_slot_6,
)


Expand Down Expand Up @@ -71,3 +76,32 @@ def get_entries(group=None): # noqa: ARG001
assert len(simple_slot_5['name']) == 1

assert simple_slot_5.loaded


def test_unique_slot_rejects_duplicate_plugins_loaded_from_entrypoints(monkeypatch):
"""Lazy entry point imports apply the unique-slot policy on first resolution.

Loading the demo plugin module through a real `EntryPoint` registers two
plugins with the same requested name. The first public slot call must raise
the slot-level uniqueness error during lazy resolution, after the first
plugin has been installed and before the duplicate is kept.

A failed resolution does not mark the slot as loaded, so another public
call would try to load the same failing entry point again. The test replaces
entry points with an empty provider before the second call; that call then
proves through public behavior that only the first plugin participates.
"""
def get_entries(group=None): # noqa: ARG001
return [EntryPoint(name='name', value='tests.smokes.demo.simple_unique_plugins', group='pristan')]

monkeypatch.setattr(slot_module, 'entry_points', get_entries)

try:
with pytest.raises(PrimadonnaPluginError, match=match('Slot "simple_slot_6" requires unique plugin names, but "name" is already registered.')):
simple_slot_6()

monkeypatch.setattr(slot_module, 'entry_points', lambda group=None: []) # noqa: ARG005

assert simple_slot_6() == {'name': 1}
finally:
simple_slot_6.pop('name', None)
58 changes: 57 additions & 1 deletion tests/typing/decorators/test_slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,19 +111,69 @@ def test_slot_configuration_arguments_are_typed():
def slot_with_positional_name(value: int) -> List[int]:
return []

@slot('some_unique_slot_name', unique=True)
def unique_slot_with_positional_name(value: int) -> List[int]:
return []

@slot(name='some_named_slot')
def slot_with_keyword_name(value: int) -> Dict[str, int]:
return {}

@slot(signature='..', max=1, type_check=False, entrypoint_group='new_namespace')
@slot(unique=True)
def unique_slot(value: int) -> List[int]:
return []

@slot(signature='..', max=1, type_check=False, entrypoint_group='new_namespace', unique=True)
def configured_slot(value: int) -> List[int]:
return []

reveal_type(slot_with_positional_name(1)) # R: builtins.list[builtins.int]
reveal_type(unique_slot_with_positional_name(1)) # R: builtins.list[builtins.int]
reveal_type(slot_with_keyword_name(1)) # R: builtins.dict[builtins.str, builtins.int]
reveal_type(unique_slot(1)) # R: builtins.list[builtins.int]
reveal_type(configured_slot(1)) # R: builtins.list[builtins.int]


@pytest.mark.mypy_testing
def test_slot_direct_call_configuration_arguments_are_typed():
"""Direct-call slot overloads preserve call and plugin result types.

The configured direct-call form accepts the same keyword options as the
decorator-factory form, including `unique`. The assignments to
`SlotProtocol` and the reveal checks prove that default and configured
direct calls keep precise list and dict result types, plus the documented
`Any` result for unannotated slots.
"""
def collect_list(value: int) -> List[int]:
return []

def collect_dict(value: int) -> Dict[str, int]:
return {}

def notify(value: int):
return None

default_list_slot = slot(collect_list)
list_slot = slot(collect_list, unique=True)
dict_slot = slot(collect_dict, signature='.', name='collect', max=2, type_check=False, entrypoint_group='custom', unique=True)
notify_slot = slot(notify, unique=True)

default_list_view: SlotProtocol[[int], List[int], int] = default_list_slot
list_view: SlotProtocol[[int], List[int], int] = list_slot
dict_view: SlotProtocol[[int], Dict[str, int], int] = dict_slot
notify_view: SlotProtocol[[int], None, Any] = notify_slot

reveal_type(default_list_slot(1)) # R: builtins.list[builtins.int]
reveal_type(list_slot(1)) # R: builtins.list[builtins.int]
reveal_type(dict_slot(1)) # R: builtins.dict[builtins.str, builtins.int]
reveal_type(notify_slot(1)) # R: Any

default_list_view(1)
list_view(1)
dict_view(1)
notify_view(1)


@pytest.mark.mypy_testing
def test_plugin_decorator_variants_preserve_callable_types():
@slot
Expand Down Expand Up @@ -304,6 +354,12 @@ def test_slot_bad_factory_arguments_stay_type_errors():
slot(max='1') # type: ignore[call-overload]
slot(type_check='yes') # type: ignore[call-overload]
slot(entrypoint_group=None) # type: ignore[call-overload]
slot(unique='yes') # type: ignore[call-overload]

def collect(value: int) -> List[int]:
return []

slot(collect, unique='yes') # type: ignore[call-overload]


@pytest.mark.mypy_testing
Expand Down
2 changes: 1 addition & 1 deletion tests/units/components/test_slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@

def test_set_max_less_than_zero():
with pytest.raises(ValueError, match=match('The maximum number of plugins cannot be less than zero.')):
Slot(lambda x: x, '.', 'slot_name', -1, False, 'pristan')
Slot(lambda x: x, '.', 'slot_name', -1, False, 'pristan', False)
12 changes: 12 additions & 0 deletions tests/units/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ def folder_slot(request):
return request.param


@pytest.fixture(
params=(
{},
{'unique': True},
{'unique': False},
),
ids=('default_unique', 'unique=True', 'unique=False'),
)
def slot_unique_options(request):
return request.param


@pytest.fixture(params=('with_name', 'without_name'))
def folder_plugin(request):
def folder(slot):
Expand Down
Loading
Loading