diff --git a/README.md b/README.md index 1b1ecbc..f4adb47 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,31 @@ print(len(some_slot['name'])) #> 2 ``` +Plugins can also be removed by key, using the `del` keyword or the `pop` method. The difference is that `pop` returns a collection of removed plugins: + +```python +del some_slot['name'] + +# or... + +some_slot.pop('name') +``` + +If there is no such key, a [`KeyError`](https://docs.python.org/3/library/exceptions.html#KeyError) will be raised: + +```python +some_slot.pop('unknown') +#> KeyError: 'unknown' +``` + +Like [`dict.pop()`](https://docs.python.org/3/library/stdtypes.html#dict.pop), `pop` can receive a default value: + +```python +some_slot.pop('unknown', None) +``` + +> ⓘ If you use the base plugin name, all plugins with that declared name will be removed. If you use a name with a numeric suffix, only that specific plugin will be removed. The suffix `-1` refers to the first plugin, whose actual name has no suffix. + ## Additional restrictions diff --git a/pristan/common_types.py b/pristan/common_types.py index 4a93ca8..c7e97b6 100644 --- a/pristan/common_types.py +++ b/pristan/common_types.py @@ -24,6 +24,7 @@ SlotCallResult = TypeVar('SlotCallResult') SlotCallResultCovariant = TypeVar('SlotCallResultCovariant', covariant=True) # noqa: PLC0105 PluginResultCovariant = TypeVar('PluginResultCovariant', covariant=True) # noqa: PLC0105 +DefaultType = TypeVar('DefaultType') SlotResult = Optional[Union[List[PluginResult], Dict[str, PluginResult]]] SlotFunction = Callable[SlotParameters, SlotCallResult] @@ -62,6 +63,14 @@ def keys(self) -> Tuple[str, ...]: ... def __getitem__(self, key: str) -> SlotSelectionProtocol[SlotParameters, SlotCallResultCovariant, PluginResult]: ... + def __delitem__(self, key: str) -> None: ... + + @overload + def pop(self, key: str) -> SlotSelectionProtocol[SlotParameters, SlotCallResultCovariant, PluginResult]: ... + + @overload + def pop(self, key: str, default: DefaultType) -> Union[SlotSelectionProtocol[SlotParameters, SlotCallResultCovariant, PluginResult], DefaultType]: ... + def __contains__(self, item: object) -> bool: ... diff --git a/pristan/components/plugins_group.py b/pristan/components/plugins_group.py index 7a3cf74..4f0989d 100644 --- a/pristan/components/plugins_group.py +++ b/pristan/components/plugins_group.py @@ -50,7 +50,7 @@ def __contains__(self, item: Any) -> bool: if plugin.name == item: return True return False - raise ValueError(f'The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, “name-22”. "{item}" is not a valid name for a plugin.') + raise ValueError(f"The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, 'name-22'. {item!r} is not a valid name for a plugin.") if isinstance(item, Plugin): return item.requested_name in self.plugins_by_requested_names and any(x.name == item.name for x in self.plugins_by_requested_names[item.requested_name]) # pragma: no branch @@ -91,3 +91,51 @@ def add(self, *plugins: 'Plugin[PluginResult]') -> None: def delete_last_by_name(self, name: str) -> None: self.plugins_by_requested_names[name].pop() self.plugins.pop() + + def pop(self, key: str) -> List[Plugin[PluginResult]]: + if not isinstance(key, str): + raise KeyError('You have used an invalid key. Strings that are suitable as keys are valid Python identifiers, or the same strings with a number separated by a hyphen (e.g., "a", "a-5").') + + if key.isidentifier(): + return self._pop_group(key) + + if not self._is_identifier_with_number(key): + raise KeyError('You have used an invalid key. Strings that are suitable as keys are valid Python identifiers, or the same strings with a number separated by a hyphen (e.g., "a", "a-5").') + + return self._pop_exact_plugin(key) + + def _pop_group(self, requested_name: str) -> List[Plugin[PluginResult]]: + if requested_name not in self.plugins_by_requested_names: + raise KeyError(requested_name) + + removed_plugins = self.plugins_by_requested_names.pop(requested_name) + self.plugins = [plugin for plugin in self.plugins if plugin.requested_name != requested_name] + return removed_plugins + + def _pop_exact_plugin(self, key: str) -> List[Plugin[PluginResult]]: + requested_name, number = key.split('-') + plugin = self._get_plugin_by_exact_key(requested_name, number) + requested_plugins = self.plugins_by_requested_names[requested_name] + + self.plugins.remove(plugin) + requested_plugins.remove(plugin) + + if requested_plugins: + self._rename_duplicates(requested_name) + else: + del self.plugins_by_requested_names[requested_name] + + return [plugin] + + def _get_plugin_by_exact_key(self, requested_name: str, number: str) -> Plugin[PluginResult]: + exact_name = requested_name if number == '1' else f'{requested_name}-{number}' + + for plugin in self.plugins_by_requested_names.get(requested_name, ()): + if plugin.name == exact_name: + return plugin + + raise KeyError(f'{requested_name}-{number}') + + def _rename_duplicates(self, requested_name: str) -> None: + for index, plugin in enumerate(self.plugins_by_requested_names[requested_name], start=1): + plugin.set_name(requested_name if index == 1 else f'{requested_name}-{index}') diff --git a/pristan/components/slot.py b/pristan/components/slot.py index db30481..ee6c566 100644 --- a/pristan/components/slot.py +++ b/pristan/components/slot.py @@ -16,10 +16,12 @@ List, Optional, Tuple, + TypeVar, Union, overload, ) +from denial import InnerNoneType from printo import not_none, repred from sigmatch import PossibleCallMatcher from sigmatch.errors import SignatureMismatchError @@ -43,6 +45,9 @@ TooManyPluginsError, ) +DefaultType = TypeVar('DefaultType') +pop_default_sentinel = InnerNoneType() + # TODO: consider to delete all the "type: ignore"d comments if python 3.9 deleted from the matrix @repred( @@ -95,12 +100,38 @@ def __getitem__(self, key: str) -> CallerWithPlugins[PluginResult]: self._load_entrypoints() return self.plugins[key] # type: ignore[no-any-return] + def __delitem__(self, key: str) -> None: + self._pop_plugins(key) + def __contains__(self, item: Any) -> bool: return item in self.plugins def __len__(self) -> int: return len(self.plugins) + @overload + def pop(self, key: str) -> CallerWithPlugins[PluginResult]: + ... # pragma: no cover + + @overload + def pop(self, key: str, default: DefaultType) -> Union[CallerWithPlugins[PluginResult], DefaultType]: + ... # pragma: no cover + + def pop(self, key: str, default: Any = pop_default_sentinel) -> Any: + try: + removed_plugins = self._pop_plugins(key) + except KeyError: + if default is pop_default_sentinel: + raise + return default + + return CallerWithPlugins(self.caller, removed_plugins) + + def _pop_plugins(self, key: str) -> List[Plugin[PluginResult]]: + self._load_entrypoints() + with self.lock: + return self.plugins.pop(key) + @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]]: ... # pragma: no cover diff --git a/pyproject.toml b/pyproject.toml index 1492d41..a42c1fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pristan" -version = "0.0.13" +version = "0.0.14" authors = [{ name = "Evgeniy Blinov", email = "zheni-b@yandex.ru" }] description = "Function-based plugin system with respect to typing" readme = "README.md" diff --git a/requirements_dev.txt b/requirements_dev.txt index 0fce963..7eee7c6 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -8,3 +8,4 @@ ruff==0.14.6 mutmut==3.2.3 full_match==0.0.3 transtests==0.0.1 +locklib==0.0.21 diff --git a/tests/typing/decorators/test_slot.py b/tests/typing/decorators/test_slot.py index caeaec1..8ba06b5 100644 --- a/tests/typing/decorators/test_slot.py +++ b/tests/typing/decorators/test_slot.py @@ -4,7 +4,7 @@ __test__ = False import sys -from typing import Any, Callable, Dict, List +from typing import Any, Callable, Dict, List, Union import pytest @@ -223,6 +223,42 @@ def consume(payload: Dict[str, int]): callable_view(1) +@pytest.mark.mypy_testing +def test_slot_pop_returns_selection_type(): + @slot + def collect_list(value: int) -> List[int]: + return [] + + @slot + def collect_dict(value: int) -> Dict[str, int]: + return {} + + popped_list = collect_list.pop('name') + popped_dict = collect_dict.pop('name') + + popped_list_view: SlotSelectionProtocol[[int], List[int], int] = popped_list + popped_dict_view: SlotSelectionProtocol[[int], Dict[str, int], int] = popped_dict + + reveal_type(popped_list(1)) # R: builtins.list[builtins.int] + reveal_type(popped_dict(1)) # R: builtins.dict[builtins.str, builtins.int] + + del collect_list['name'] + popped_list_view(1) + popped_dict_view(1) + + +@pytest.mark.mypy_testing +def test_slot_pop_with_default_returns_union(): + @slot + def collect(value: int) -> List[int]: + return [] + + popped_or_text: Union[SlotSelectionProtocol[[int], List[int], int], str] = collect.pop('name', 'fallback') + popped_or_number: Union[SlotSelectionProtocol[[int], List[int], int], int] = collect.pop('name', 1) + + 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.mypy_testing def test_iterated_plugins_preserve_slot_parameter_types(): @slot @@ -376,6 +412,20 @@ def collect(value: int) -> List[int]: 'name' in selection # E: [operator] +@pytest.mark.mypy_testing +def test_popped_selection_does_not_expose_full_slot_api(): + @slot + def collect(value: int) -> List[int]: + return [] + + popped = collect.pop('name') + + popped.plugin('name') # E: [attr-defined] + popped.keys() # E: [attr-defined] + popped['nested'] # E: [index] + 'name' in popped # E: [operator] + + @pytest.mark.mypy_testing def test_collection_api_reports_wrong_argument_types(): @slot @@ -384,9 +434,14 @@ def collect(value: int) -> List[int]: collect.keys(1) # E: [call-arg] collect[1] # E: [index] + collect.pop(1) # type: ignore[call-overload] + collect.pop() # type: ignore[call-overload] + collect.pop('name', 1, 2) # type: ignore[call-overload] + del collect[1] # E: [arg-type] keys_as_list: List[str] = collect.keys() # E: [assignment] wrong_selection: int = collect['name'] # E: [assignment] + wrong_popped_selection: int = collect.pop('name') # E: [assignment] @pytest.mark.mypy_testing @@ -508,6 +563,21 @@ def consume_dict(payload: dict[str, int]): consume_dict(collect_list['name'](1)) # E: [arg-type] +@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(): + @slot + def collect(value: int) -> list[int]: + return [] + + popped = collect.pop('name') + popped_or_text: Union[SlotSelectionProtocol[[int], list[int], int], str] = collect.pop('name', 'fallback') + + reveal_type(popped(1)) # R: builtins.list[builtins.int] + + del collect['name'] + + @pytest.mark.skipif(sys.version_info < (3, 9), reason='built-in generics require Python 3.9+') @pytest.mark.mypy_testing def test_plugin_return_type_mismatch_is_reported_for_built_in_generics(): diff --git a/tests/units/components/test_plugins_group.py b/tests/units/components/test_plugins_group.py index 4a30c4a..32bbc6f 100644 --- a/tests/units/components/test_plugins_group.py +++ b/tests/units/components/test_plugins_group.py @@ -7,6 +7,21 @@ from pristan.components.slot_code_representer import SlotCodeRepresenter +@pytest.fixture +def group_with_named_duplicates(): + """Build a group with duplicate plugin names for deletion and renumbering tests.""" + caller = SlotCaller(SlotCodeRepresenter(lambda x: x), 'kek', lambda x: x, False) + plugins = [ + Plugin('name', lambda x: x, int, False, False), + Plugin('name', lambda x: x, int, False, False), + Plugin('name', lambda x: x, int, False, False), + Plugin('name2', lambda x: x, int, False, False), + ] + plugins[1].set_name('name-2') + plugins[2].set_name('name-3') + return PluginsGroup(caller, plugins=plugins), plugins + + def test_bool(): caller = SlotCaller(SlotCodeRepresenter(lambda x: x), 'kek', lambda x: x, False) @@ -137,19 +152,19 @@ def test_contains_with_not_valid_names(): ] group = PluginsGroup(caller, plugins=plugins) - with pytest.raises(ValueError, match=match('The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, “name-22”. "kek-kek" is not a valid name for a plugin.')): + with pytest.raises(ValueError, match=match("The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, 'name-22'. 'kek-kek' is not a valid name for a plugin.")): 'kek-kek' in group # noqa: B015 - with pytest.raises(ValueError, match=match('The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, “name-22”. "kek-2-2" is not a valid name for a plugin.')): + with pytest.raises(ValueError, match=match("The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, 'name-22'. 'kek-2-2' is not a valid name for a plugin.")): 'kek-2-2' in group # noqa: B015 - with pytest.raises(ValueError, match=match('The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, “name-22”. "kek--" is not a valid name for a plugin.')): + with pytest.raises(ValueError, match=match("The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, 'name-22'. 'kek--' is not a valid name for a plugin.")): 'kek--' in group # noqa: B015 - with pytest.raises(ValueError, match=match('The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, “name-22”. "@" is not a valid name for a plugin.')): + with pytest.raises(ValueError, match=match("The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, 'name-22'. '@' is not a valid name for a plugin.")): '@' in group # noqa: B015 - with pytest.raises(ValueError, match=match('The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, “name-22”. "kek-0" is not a valid name for a plugin.')): + with pytest.raises(ValueError, match=match("The plugin name string must look like either a valid Python identifier or an identifier plus one or more digits separated by a hyphen, for example, 'name-22'. 'kek-0' is not a valid name for a plugin.")): 'kek-0' in group # noqa: B015 with pytest.raises(TypeError, match=match('Checking for inclusion is only possible for strings of a valid format or for plugin objects.')): @@ -229,3 +244,77 @@ def test_getitem_good_key(): assert not group['kek-2'] assert len(group['kek-2']) == 0 assert [x.name for x in group['kek-2']] == [] + + +def test_pop_by_base_name(group_with_named_duplicates): + group, plugins = group_with_named_duplicates + removed_plugins = group.pop('name') + + assert removed_plugins == plugins[:3] + assert group.plugins == [plugins[3]] + assert group.plugins_by_requested_names == { + 'name2': [plugins[3]], + } + + +def test_pop_first_plugin_by_name_1(group_with_named_duplicates): + group, plugins = group_with_named_duplicates + removed_plugins = group.pop('name-1') + + assert removed_plugins == [plugins[0]] + assert [x.name for x in group.plugins] == ['name', 'name-2', 'name2'] + assert group.plugins_by_requested_names == { + 'name': [plugins[1], plugins[2]], + 'name2': [plugins[3]], + } + + +def test_pop_middle_plugin_renumbers_remaining_duplicates(group_with_named_duplicates): + group, plugins = group_with_named_duplicates + removed_plugins = group.pop('name-2') + + assert removed_plugins == [plugins[1]] + assert [x.name for x in group.plugins] == ['name', 'name-2', 'name2'] + assert group.plugins_by_requested_names == { + 'name': [plugins[0], plugins[2]], + 'name2': [plugins[3]], + } + + +def test_pop_last_plugin_keeps_compact_numbering(group_with_named_duplicates): + group, plugins = group_with_named_duplicates + removed_plugins = group.pop('name-3') + + assert removed_plugins == [plugins[2]] + assert [x.name for x in group.plugins] == ['name', 'name-2', 'name2'] + assert group.plugins_by_requested_names == { + 'name': [plugins[0], plugins[1]], + 'name2': [plugins[3]], + } + + +def test_pop_only_plugin_by_name_1_removes_requested_name_bucket(group_with_named_duplicates): + group, plugins = group_with_named_duplicates + + removed_plugins = group.pop('name2-1') + + assert removed_plugins == [plugins[3]] + assert group.plugins == plugins[:3] + assert group.plugins_by_requested_names == { + 'name': plugins[:3], + } + + +def test_pop_missing_valid_key(group_with_named_duplicates): + group, _ = group_with_named_duplicates + with pytest.raises(KeyError, match=match("'name3'")): + group.pop('name3') + + with pytest.raises(KeyError, match=match("'name-4'")): + group.pop('name-4') + + +def test_pop_invalid_key(group_with_named_duplicates): + group, _ = group_with_named_duplicates + with pytest.raises(KeyError, match=match('\'You have used an invalid key. Strings that are suitable as keys are valid Python identifiers, or the same strings with a number separated by a hyphen (e.g., "a", "a-5").\'')): + group.pop('name--') diff --git a/tests/units/decorators/test_slot.py b/tests/units/decorators/test_slot.py index 76a4072..210265b 100644 --- a/tests/units/decorators/test_slot.py +++ b/tests/units/decorators/test_slot.py @@ -1,7 +1,9 @@ from sys import version_info +from threading import RLock import pytest from full_match import match +from locklib import LockTraceWrapper from packaging.version import Version from sigmatch.errors import SignatureMismatchError @@ -1078,6 +1080,240 @@ def plugin(): # noqa: F811 assert some_slot.loaded +def test_delitem_removes_plugins_from_slot(folder_slot, folder_plugin): + @folder_slot(slot) + def some_slot(): + ... + + @folder_plugin(some_slot) + def plugin(): + ... + + @folder_plugin(some_slot) + def plugin(): # noqa: F811 + ... + + @folder_plugin(some_slot) + def plugin2(): + ... + + del some_slot['plugin'] + + assert [x.name for x in some_slot.plugins.plugins] == ['plugin2'] + assert some_slot.plugins.plugins_by_requested_names == { + 'plugin2': [some_slot.plugins.plugins[0]], + } + assert some_slot.keys() == ('plugin2',) + assert len(some_slot) == 1 + assert 'plugin' not in some_slot + + +def test_pop_removes_plugin_and_returns_detached_selection(folder_slot): + bread_crumbs = [] + + @folder_slot(slot) + def some_slot(a): + bread_crumbs.append(f'slot_{a}') + + @some_slot.plugin('plugin') + def plugin_1(a): + bread_crumbs.append(f'plugin_1_{a}') + + @some_slot.plugin('plugin') + def plugin_2(a): + bread_crumbs.append(f'plugin_2_{a}') + + @some_slot.plugin('plugin') + def plugin_3(a): + bread_crumbs.append(f'plugin_3_{a}') + + removed_plugins = some_slot.pop('plugin-2') + + assert [x.name for x in removed_plugins] == ['plugin-2'] + assert [x.name for x in some_slot.plugins.plugins] == ['plugin', 'plugin-2'] + assert some_slot.keys() == ('plugin',) + + removed_plugins(1) + + assert bread_crumbs == ['plugin_2_1'] + + +def test_pop_returns_default_for_missing_key(folder_slot): + @folder_slot(slot) + def some_slot(): + ... + + sentinel = object() + + assert some_slot.pop('missing', sentinel) is sentinel + assert some_slot.pop('missing', None) is None + + +def test_pop_and_delitem_raise_key_error_for_empty_slot(folder_slot): + @folder_slot(slot) + def some_slot(): + ... + + with pytest.raises(KeyError, match=match("'missing'")): + some_slot.pop('missing') + + with pytest.raises(KeyError, match=match("'missing'")): + del some_slot['missing'] + + +def test_pop_and_delitem_raise_key_error_for_non_string_keys(folder_slot): + @folder_slot(slot) + def some_slot(): + ... + + with pytest.raises(KeyError, match=match('\'You have used an invalid key. Strings that are suitable as keys are valid Python identifiers, or the same strings with a number separated by a hyphen (e.g., "a", "a-5").\'')): + some_slot.pop(None) # type: ignore[arg-type] + + with pytest.raises(KeyError, match=match('\'You have used an invalid key. Strings that are suitable as keys are valid Python identifiers, or the same strings with a number separated by a hyphen (e.g., "a", "a-5").\'')): + del some_slot[None] # type: ignore[index] + + +def test_deleting_plugin_prevents_it_from_running(folder_slot): + bread_crumbs = [] + + @folder_slot(slot) + def some_slot(a, b=3): + bread_crumbs.append(f'some_slot_{a}_{b}') + + @some_slot.plugin('plugin') + def plugin_1(a, b=4): + bread_crumbs.append(f'plugin_1_{a}_{b}') + + @some_slot.plugin('plugin') + def plugin_2(a, b=5): + bread_crumbs.append(f'plugin_2_{a}_{b}') + + @some_slot.plugin('plugin') + def plugin_3(a, b=6): + bread_crumbs.append(f'plugin_3_{a}_{b}') + + del some_slot['plugin-2'] + some_slot(1) + + assert bread_crumbs == ['plugin_1_1_4', 'plugin_3_1_6'] + + +def test_delitem_and_pop_support_exact_duplicate_keys(folder_slot): + @folder_slot(slot) + def some_slot(): + ... + + @some_slot.plugin('plugin') + def plugin_1(): + ... + + @some_slot.plugin('plugin') + def plugin_2(): + ... + + @some_slot.plugin('plugin') + def plugin_3(): + ... + + del some_slot['plugin-1'] + + assert [x.name for x in some_slot.plugins.plugins] == ['plugin', 'plugin-2'] + + removed_plugins = some_slot.pop('plugin-2') + + assert [x.name for x in removed_plugins] == ['plugin-2'] + assert [x.name for x in some_slot.plugins.plugins] == ['plugin'] + + +def test_delitem_with_name_1_removes_first_plugin(folder_slot): + bread_crumbs = [] + + @folder_slot(slot) + def some_slot(): + ... + + @some_slot.plugin('plugin') + def plugin_1(): + bread_crumbs.append('plugin_1') + + @some_slot.plugin('plugin') + def plugin_2(): + bread_crumbs.append('plugin_2') + + @some_slot.plugin('plugin') + def plugin_3(): + bread_crumbs.append('plugin_3') + + del some_slot['plugin-1'] + some_slot() + + assert bread_crumbs == ['plugin_2', 'plugin_3'] + + +def test_delitem_is_loading_entry_points(folder_slot): + @folder_slot(slot) + def some_slot(): + ... + + assert not some_slot.loaded + + with pytest.raises(KeyError, match=match("'kek'")): + del some_slot['kek'] + + assert some_slot.loaded + + +def test_pop_is_loading_entry_points(folder_slot): + @folder_slot(slot) + def some_slot(): + ... + + assert not some_slot.loaded + + with pytest.raises(KeyError, match=match("'kek'")): + some_slot.pop('kek') + + assert some_slot.loaded + + +def test_deleting_plugins_is_protected_by_slot_lock(folder_slot): + @folder_slot(slot) + def some_slot(): + ... + + @some_slot.plugin('plugin') + def plugin_1(): + ... + + @some_slot.plugin('plugin') + def plugin_2(): + ... + + @some_slot.plugin('plugin') + def plugin_3(): + ... + + some_slot.lock = LockTraceWrapper(RLock()) + original_pop = some_slot.plugins.pop + original_rename = some_slot.plugins._rename_duplicates + + def traced_pop(key): + some_slot.lock.notify('delete') + return original_pop(key) + + def traced_rename(name): + some_slot.lock.notify('renumber') + return original_rename(name) + + some_slot.plugins.pop = traced_pop + some_slot.plugins._rename_duplicates = traced_rename + + del some_slot['plugin-2'] + + assert some_slot.lock.was_event_locked('delete') + assert some_slot.lock.was_event_locked('renumber') + + def test_pass_to_plugin_decorator_something_wrong(folder_slot): @folder_slot(slot) def some_slot():