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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ def some_slot(a, b):
...
```

> ⓘ You can also pass several call descriptions, like `signature=['..', '.']`. In that case, the slot and every plugin must support all of them. This is useful when host code intentionally calls the same slot in more than one way.

In this case, even functions that in principle share a common calling convention with the slot but do not match the expected one will be filtered out:

```python
Expand Down
1 change: 1 addition & 0 deletions pristan/common_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
DefaultType = TypeVar('DefaultType')

SlotResult = Optional[Union[List[PluginResult], Dict[str, PluginResult]]]
SlotSignature = Union[str, List[str]]
SlotFunction = Callable[SlotParameters, SlotCallResult]
PluginFunction = Callable[SlotParameters, PluginResult]

Expand Down
26 changes: 22 additions & 4 deletions pristan/components/slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
SlotFunction,
SlotParameters,
SlotResult,
SlotSignature,
)
from pristan.components.plugin import Plugin
from pristan.components.plugins_group import PluginsGroup
Expand Down Expand Up @@ -65,17 +66,18 @@
},
)
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, unique: bool) -> None: # noqa: PLR0913, A002
def __init__(self, slot_function: SlotFunction[SlotParameters, SlotResult[PluginResult]], signature: Optional[SlotSignature], 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.')

self.signature_matchers: Tuple[PossibleCallMatcher, ...] = self._get_signature_matchers(signature)
self.signature = list(signature) if isinstance(signature, list) else signature
self.slot_function = slot_function
self.code_representation = SlotCodeRepresenter(self.slot_function)

if not self.code_representation.returns_list and not self.code_representation.returns_dict and self.code_representation.returning_type is not return_type_sentinel:
raise StrangeTypeAnnotationError('The return type annotation for a slot must be either a list or a dict, or remain empty.')

self.signature = signature
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
Expand Down Expand Up @@ -201,8 +203,24 @@ def _add_plugin(self, name: str, function: PluginFunction[SlotParameters, Plugin
self.plugins.delete_last_by_name(name)
raise PrimadonnaPluginError(f'Plugin "{other_plugin.name}" claims to be unique, but there are other plugins with the same name.')

@staticmethod
def _get_signature_matchers(signature: Optional[SlotSignature]) -> Tuple[PossibleCallMatcher, ...]:
if signature is None:
return ()

if isinstance(signature, str):
return (PossibleCallMatcher(signature),)

if isinstance(signature, list):
if not signature:
raise ValueError('The slot signature may be omitted, specified as a string, or specified as a non-empty list of strings; an empty list was provided.')
return tuple(PossibleCallMatcher(item) for item in signature)

raise TypeError('The slot signature must be either a string or a list of strings.')

def _compare_signatures(self, slot_function: SlotFunction[SlotParameters, SlotResult[PluginResult]], plugin_function: PluginFunction[SlotParameters, PluginResult]) -> None:
if self.signature is not None:
PossibleCallMatcher(self.signature).match(plugin_function, raise_exception=True)
if self.signature_matchers:
for matcher in self.signature_matchers:
matcher.match(plugin_function, raise_exception=True)
elif not PossibleCallMatcher.from_callable(slot_function) & PossibleCallMatcher.from_callable(plugin_function):
raise SignatureMismatchError('No common calling method has been found between the slot and the plugin.')
11 changes: 6 additions & 5 deletions pristan/decorators/slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,24 @@
SlotDecoratorProtocol,
SlotParameters,
SlotProtocol,
SlotSignature,
)
from pristan.components.slot import Slot


@overload
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
def slot(function: Callable[SlotParameters, List[PluginResult]], /, *, signature: Optional[SlotSignature] = 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]], /, *, 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
def slot(function: Callable[SlotParameters, Dict[str, PluginResult]], /, *, signature: Optional[SlotSignature] = 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], /, *, 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
def slot(function: Callable[SlotParameters, None], /, *, signature: Optional[SlotSignature] = 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(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
def slot(function: str = ..., /, *, signature: Optional[SlotSignature] = 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

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
def slot(function: Optional[object] = None, /, *, signature: Optional[SlotSignature] = 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, unique))

Expand Down
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.16"
version = "0.0.17"
authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }]
description = "Function-based plugin system with respect to typing"
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion tests/smokes/demo/plugins_another_name.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from tests.smokes.demo.simple_slots import simple_slot_2


@simple_slot_2.plugin('name2') # type: ignore[attr-defined]
@simple_slot_2.plugin('name2')
def plugin_2():
return 2
8 changes: 4 additions & 4 deletions tests/smokes/demo/simple_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@
)


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


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


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


@simple_slot_5.plugin('name') # type: ignore[attr-defined]
@simple_slot_5.plugin('name')
def plugin_4():
return 1
4 changes: 2 additions & 2 deletions tests/smokes/demo/simple_unique_plugins.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from tests.smokes.demo.simple_slots import simple_slot_6


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


@simple_slot_6.plugin('name') # type: ignore[attr-defined]
@simple_slot_6.plugin('name')
def plugin_6():
return 2
57 changes: 49 additions & 8 deletions tests/typing/decorators/test_slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,18 @@ def unique_slot(value: int) -> List[int]:
def configured_slot(value: int) -> List[int]:
return []

@slot(signature=['..', '.'])
def configured_slot_with_signature_list(value: int, context: str = '') -> 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]
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]
configured_slot_with_signature_list(1, 2) # E: [arg-type]


@pytest.mark.mypy_testing
Expand All @@ -141,8 +148,11 @@ def test_slot_direct_call_configuration_arguments_are_typed():
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.
direct calls keep precise list and dict result types, including when a
signature list is passed. They also distinguish the documented `Any` result
for an unannotated slot from `None` for an explicitly annotated one.
Iteration over slots with signature lists proves that plugin result types
are kept as well.
"""
def collect_list(value: int) -> List[int]:
return []
Expand All @@ -153,25 +163,53 @@ def collect_dict(value: int) -> Dict[str, int]:
def notify(value: int):
return None

def typed_notify(value: int) -> None:
return None

default_list_slot = slot(collect_list)
list_slot = slot(collect_list, unique=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)
signature_list_dict_slot = slot(collect_dict, signature=['.'])
notify_slot = slot(notify, unique=True)
signature_list_notify_slot = slot(typed_notify, signature=['.'])

default_list_view: SlotProtocol[[int], List[int], int] = default_list_slot
list_view: SlotProtocol[[int], List[int], int] = list_slot
signature_list_view: SlotProtocol[[int], List[int], int] = signature_list_slot
dict_view: SlotProtocol[[int], Dict[str, int], int] = dict_slot
signature_list_dict_view: SlotProtocol[[int], Dict[str, int], int] = signature_list_dict_slot
notify_view: SlotProtocol[[int], None, Any] = notify_slot
signature_list_notify_view: SlotProtocol[[int], None, Any] = signature_list_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(signature_list_slot(1)) # R: builtins.list[builtins.int]
reveal_type(dict_slot(1)) # R: builtins.dict[builtins.str, builtins.int]
reveal_type(signature_list_dict_slot(1)) # R: builtins.dict[builtins.str, builtins.int]
reveal_type(notify_slot(1)) # R: Any
reveal_type(signature_list_notify_slot(1)) # R: None
signature_list_slot('value') # E: [arg-type]
signature_list_slot() # E: [call-arg]

default_list_view(1)
list_view(1)
signature_list_view(1)
dict_view(1)
signature_list_dict_view(1)
notify_view(1)
signature_list_notify_view(1)

for signature_list_plugin in signature_list_slot:
reveal_type(signature_list_plugin(1)) # R: builtins.int
signature_list_plugin('value') # E: [arg-type]
signature_list_plugin() # E: [call-arg]

for signature_list_dict_plugin in signature_list_dict_slot:
reveal_type(signature_list_dict_plugin(1)) # R: builtins.int

for signature_list_notify_plugin in signature_list_notify_slot:
reveal_type(signature_list_notify_plugin(1)) # R: Any


@pytest.mark.mypy_testing
Expand Down Expand Up @@ -340,17 +378,18 @@ def collect(value: int) -> List[int]:

@pytest.mark.mypy_testing
def test_slot_bad_factory_arguments_stay_type_errors():
"""Pin invalid slot(...) calls via code-specific ignores.
"""Pin invalid slot(...) calls via code-specific expectations.

These scenarios produce call-overload plus several overload notes on the
same physical line. pytest-mypy-testing cannot express that message bundle
precisely in a .py test file, so this test relies on warn-unused-ignores:
if any bad call ever becomes valid, mypy will report unused-ignore and the
test will fail.
Calls producing bundled overload diagnostics use targeted ignores together
with warn-unused-ignores. Calls with a single precise diagnostic use inline
expected-error annotations. Either form fails if an invalid call becomes
accepted.
"""
slot(1) # type: ignore[call-overload]
slot(name=1) # type: ignore[call-overload]
slot(signature=1) # type: ignore[call-overload]
slot(signature=[1]) # E: [list-item]
slot(signature=('.',)) # type: ignore[call-overload]
slot(max='1') # type: ignore[call-overload]
slot(type_check='yes') # type: ignore[call-overload]
slot(entrypoint_group=None) # type: ignore[call-overload]
Expand All @@ -360,6 +399,8 @@ def collect(value: int) -> List[int]:
return []

slot(collect, unique='yes') # type: ignore[call-overload]
slot(collect, signature=[1]) # E: [list-item]
slot(collect, signature=('.',)) # type: ignore[call-overload]


@pytest.mark.mypy_testing
Expand Down
Loading
Loading