diff --git a/README.md b/README.md index f722d34..4937c68 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pristan/common_types.py b/pristan/common_types.py index c7e97b6..f1e85b2 100644 --- a/pristan/common_types.py +++ b/pristan/common_types.py @@ -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] diff --git a/pristan/components/slot.py b/pristan/components/slot.py index 71222bb..3246dd6 100644 --- a/pristan/components/slot.py +++ b/pristan/components/slot.py @@ -33,6 +33,7 @@ SlotFunction, SlotParameters, SlotResult, + SlotSignature, ) from pristan.components.plugin import Plugin from pristan.components.plugins_group import PluginsGroup @@ -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 @@ -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.') diff --git a/pristan/decorators/slot.py b/pristan/decorators/slot.py index 24b7281..222c91c 100644 --- a/pristan/decorators/slot.py +++ b/pristan/decorators/slot.py @@ -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)) diff --git a/pyproject.toml b/pyproject.toml index cb2f7f6..46f3308 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/smokes/demo/plugins_another_name.py b/tests/smokes/demo/plugins_another_name.py index 35fddcf..8372eac 100644 --- a/tests/smokes/demo/plugins_another_name.py +++ b/tests/smokes/demo/plugins_another_name.py @@ -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 diff --git a/tests/smokes/demo/simple_plugins.py b/tests/smokes/demo/simple_plugins.py index 9f56781..945e6ce 100644 --- a/tests/smokes/demo/simple_plugins.py +++ b/tests/smokes/demo/simple_plugins.py @@ -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 diff --git a/tests/smokes/demo/simple_unique_plugins.py b/tests/smokes/demo/simple_unique_plugins.py index 65918a0..384a324 100644 --- a/tests/smokes/demo/simple_unique_plugins.py +++ b/tests/smokes/demo/simple_unique_plugins.py @@ -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 diff --git a/tests/typing/decorators/test_slot.py b/tests/typing/decorators/test_slot.py index f8bcecb..7132992 100644 --- a/tests/typing/decorators/test_slot.py +++ b/tests/typing/decorators/test_slot.py @@ -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 @@ -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 [] @@ -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 @@ -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] @@ -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 diff --git a/tests/units/decorators/test_slot.py b/tests/units/decorators/test_slot.py index f09a7e3..a89986b 100644 --- a/tests/units/decorators/test_slot.py +++ b/tests/units/decorators/test_slot.py @@ -1,5 +1,6 @@ from sys import version_info from threading import RLock +from typing import List import pytest from full_match import match @@ -60,6 +61,223 @@ def plugin(): ... +def test_slot_and_plugin_support_all_passed_signatures(folder_plugin, list_type): + """A slot and its plugins must support every call shape in the list. + + The optional second argument allows both declared positional calls, and + the assertions prove that the registered plugin handles each one. + """ + @slot(signature=['..', '.']) + def on_event(event, context=None) -> list_type: # noqa: ARG001 + return [] + + @folder_plugin(on_event) + def plugin(event, context=None): + if context is None: + return event + return f'{event}:{context}' + + assert on_event('event') == ['event'] + assert on_event('event', 'context') == ['event:context'] + + +def test_direct_call_slot_enforces_signature_list(list_type): + """Direct-call slot construction enforces every listed call shape.""" + def on_event(event, context=None) -> list_type: # noqa: ARG001 + return [] + + event_slot = slot(on_event, signature=['..', '.']) + + @event_slot.plugin + def plugin(event, context=None): + return event, context + + with pytest.raises(SignatureMismatchError, match=match('This is a difficult situation, there is no guarantee that a call with a variable number of positional arguments will fill all the slots of positional arguments.')): + @event_slot.plugin + def invalid_plugin(event, context): + return event, context + + assert len(event_slot) == 1 + assert event_slot('event') == [('event', None)] + assert event_slot('event', 'context') == [('event', 'context')] + + +def test_plugin_with_required_second_argument_does_not_match_all_passed_signatures(): + """A plugin requiring two arguments fails the one-argument call shape.""" + @slot(signature=['..', '.']) + def on_event(event, context=None): + ... + + with pytest.raises(SignatureMismatchError, match=match('This is a difficult situation, there is no guarantee that a call with a variable number of positional arguments will fill all the slots of positional arguments.')): + @on_event.plugin + def invalid_plugin(event, context): + return event, context + + assert len(on_event) == 0 + + +def test_plugin_with_only_one_argument_does_not_match_all_passed_signatures(): + """A plugin accepting one argument fails the two-argument call shape.""" + @slot(signature=['..', '.']) + def on_event(event, context=None): + ... + + with pytest.raises(SignatureMismatchError, match=match('The signature of the callable object does not match the expected one.')): + @on_event.plugin + def invalid_plugin(event): + return event + + assert len(on_event) == 0 + + +def test_slot_has_to_match_all_passed_signatures(list_type): + """A slot must support every listed call shape, not only one of them. + + The same required-two-argument function remains valid with the equivalent + single string signature, isolating the additional list requirement. + """ + with pytest.raises(SignatureMismatchError, match=match('This is a difficult situation, there is no guarantee that a call with a variable number of positional arguments will fill all the slots of positional arguments.')): + @slot(signature=['..', '.']) + def on_event(event, context) -> list_type: + return [(event, context)] + + @slot(signature='..') + def on_event_with_single_signature(event, context) -> list_type: + return [(event, context)] + + assert on_event_with_single_signature('event', 'context') == [('event', 'context')] + + +def test_one_signature_in_list_enforces_its_call_shape(list_type): + """A one-element signature list enforces its single call shape.""" + @slot(signature=['..']) + def collect(a, b) -> list_type: # noqa: ARG001 + return [] + + @collect.plugin + def plugin(a, b): + return a + b + + with pytest.raises(SignatureMismatchError, match=match('The signature of the callable object does not match the expected one.')): + @collect.plugin + def invalid_plugin(a): + return a + + assert collect(1, 2) == [3] + + +def test_empty_signature_list_is_not_allowed(): + """An empty signature list is rejected because it declares no calls.""" + with pytest.raises(ValueError, match=match('The slot signature may be omitted, specified as a string, or specified as a non-empty list of strings; an empty list was provided.')): + @slot(signature=[]) + def on_event(): + ... + + +def test_empty_signature_list_is_checked_before_return_annotation(): + """Signature-list validation takes priority over return annotations.""" + with pytest.raises(ValueError, match=match('The slot signature may be omitted, specified as a string, or specified as a non-empty list of strings; an empty list was provided.')): + @slot(signature=[]) + def on_event() -> int: + return 1 + + +@pytest.mark.parametrize( + 'signature', + [ + ('.',), + ('..', '.'), + ('invalid!',), + ('invalid!', 'bad-name'), + (1,), + (1, False), + 1, + True, + ], + ids=( + 'tuple_with_one_valid_string', + 'tuple_with_several_valid_strings', + 'tuple_with_one_invalid_string', + 'tuple_with_several_invalid_strings', + 'tuple_with_one_non_string', + 'tuple_with_several_non_strings', + 'integer_scalar', + 'boolean_scalar', + ), +) +def test_unsupported_signature_containers_and_scalars_are_rejected(signature: object): + """Only a string or a list may declare slot signature constraints. + + Tuple contents do not matter: tuples and scalar values are rejected before + the unrelated return annotation is inspected. + """ + with pytest.raises(TypeError, match=match('The slot signature must be either a string or a list of strings.')): + @slot(signature=signature) # type: ignore[call-overload] + def on_event() -> int: + return 1 + + +@pytest.mark.parametrize( + 'signature', + [ + pytest.param([1], id='one_non_string'), + pytest.param(['.', 1], id='valid_string_then_non_string'), + ], +) +def test_signature_list_can_contain_only_strings(signature: object): + """Every item in an accepted signature list must itself be a string.""" + with pytest.raises(TypeError, match=match('Only strings can be used as symbolic representation of function parameters. You used "1" (int).')): + @slot(signature=signature) # type: ignore[call-overload] + def on_event(): + ... + + +@pytest.mark.parametrize( + 'signature', + [ + pytest.param(['invalid!'], id='one_invalid_description'), + pytest.param(['.', 'invalid!'], id='valid_then_invalid_description'), + ], +) +def test_signature_list_rejects_invalid_call_descriptions(signature: List[str]): + """Each string in a signature list must be valid sigmatch syntax.""" + with pytest.raises(ValueError, match=match('Only strings of a certain format can be used as symbols for function arguments: arbitrary variable names, and ".", "*", "**" strings. You used "invalid!".')): + @slot(signature=signature) + def on_event(): + ... + + +def test_signature_matchers_use_declaration_snapshot(list_type): + """Later list mutations do not add constraints to installed matchers.""" + signatures = ['..', '.'] + + @slot(signature=signatures) + def on_event(event, context=None) -> list_type: # noqa: ARG001 + return [] + + signatures.append('...') + + @on_event.plugin + def plugin(event, context=None): + return event, context + + assert on_event('event') == [('event', None)] + assert on_event('event', 'context') == [('event', 'context')] + + +def test_signature_list_repr_uses_declaration_snapshot(): + """The slot repr keeps the signature list as it was when declared.""" + signatures = ['..', '.'] + + @slot(signature=signatures) + def on_event(event, context=None): + ... + + signatures.append('...') + + assert repr(on_event) == 'Slot(on_event, signature=[\'..\', \'.\'])' + + def test_run_1_plugin_without_hints(folder_slot, folder_plugin, slot_unique_options): bread_crumbs = [] @@ -1225,6 +1443,10 @@ def some_slot_6(a, b=3): def some_slot_7(a, b=3): ... + @slot(name='name7', signature=['..', '.']) + def some_slot_8(a, b=3): + ... + assert repr(some_slot) == 'Slot(some_slot)' assert repr(some_slot_2) == 'Slot(some_slot_2, slot_name=\'name\')' assert repr(some_slot_3) == 'Slot(some_slot_3, signature=\'..\', slot_name=\'name2\')' @@ -1232,6 +1454,7 @@ def some_slot_7(a, b=3): assert repr(some_slot_5) == 'Slot(some_slot_5, signature=\'..\', slot_name=\'name4\', max=3, type_check=False)' assert repr(some_slot_6) == 'Slot(some_slot_6, signature=\'..\', slot_name=\'name5\', max=3, type_check=False)' assert repr(some_slot_7) == 'Slot(some_slot_7, signature=\'..\', slot_name=\'name6\', max=3, type_check=False, unique=True)' + assert repr(some_slot_8) == 'Slot(some_slot_8, signature=[\'..\', \'.\'], slot_name=\'name7\')' def test_getitem_repr(folder_slot, folder_plugin):