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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions pristan/common_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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: ...


Expand Down
50 changes: 49 additions & 1 deletion pristan/components/plugins_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}')
31 changes: 31 additions & 0 deletions pristan/components/slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
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.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"
Expand Down
1 change: 1 addition & 0 deletions requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
72 changes: 71 additions & 1 deletion tests/typing/decorators/test_slot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand Down
Loading
Loading