From bed12e04b44b652f418ba40c49e5ac4a7688b3f0 Mon Sep 17 00:00:00 2001 From: Lucy Date: Mon, 5 Aug 2024 20:23:28 +0100 Subject: [PATCH 01/27] Initial mockup --- alluka/_client.py | 6 + alluka/managed/__init__.py | 40 +++++++ alluka/managed/_config.py | 96 ++++++++++++++++ alluka/managed/_index.py | 209 +++++++++++++++++++++++++++++++++++ alluka/managed/_manager.py | 221 +++++++++++++++++++++++++++++++++++++ 5 files changed, 572 insertions(+) create mode 100644 alluka/managed/__init__.py create mode 100644 alluka/managed/_config.py create mode 100644 alluka/managed/_index.py create mode 100644 alluka/managed/_manager.py diff --git a/alluka/_client.py b/alluka/_client.py index eb3fd018..11ae5846 100644 --- a/alluka/_client.py +++ b/alluka/_client.py @@ -51,6 +51,7 @@ from . import _types # pyright: ignore[reportPrivateUsage] from . import _visitor from . import abc as alluka +from .managed import _index if typing.TYPE_CHECKING: from typing import Self @@ -180,6 +181,11 @@ def _build_descriptors(self, callback: alluka.CallbackSig[typing.Any], /) -> dic except KeyError: pass + descriptors = _index.GLOBAL_INDEX.get_descriptors(callback) + if descriptors is not None: + self._descriptors[callback] = descriptors + return descriptors + # TODO: introspect_annotations=self._introspect_annotations descriptors = self._descriptors[callback] = _visitor.Callback(callback).accept(_visitor.ParameterVisitor()) return descriptors diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py new file mode 100644 index 00000000..77c07a62 --- /dev/null +++ b/alluka/managed/__init__.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# BSD 3-Clause License +# +# Copyright (c) 2020-2024, Faster Speeding +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from __future__ import annotations + +from . import _index +from ._config import BaseConfig as BaseConfig +from ._manager import Manager as Manager + +_GLOBAL_INDEX = _index.GLOBAL_INDEX + +register_config = _GLOBAL_INDEX.register_config +register_type = _GLOBAL_INDEX.register_type diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py new file mode 100644 index 00000000..d437b07c --- /dev/null +++ b/alluka/managed/_config.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# BSD 3-Clause License +# +# Copyright (c) 2020-2024, Faster Speeding +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from __future__ import annotations + +import abc +import typing +from collections import abc as collections + +if typing.TYPE_CHECKING: + from typing_extensions import Self + + +_DictKeyT = typing.Union[str, int, float, bool, None] +_DictValueT = typing.Union[ + collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT +] + + +class BaseConfig(abc.ABC): + __slots__ = () + + @classmethod + def config_types(cls) -> collections.Sequence[type[BaseConfig]]: + return [cls] + + @classmethod + @abc.abstractmethod + def config_id(cls) -> str: + raise NotImplementedError + + @classmethod + @abc.abstractmethod + def from_mapping(cls, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> Self: + raise NotImplementedError + + +class TypeLoader(BaseConfig): + __slots__ = ("_load_types",) + + def __init__(self, load_types: collections.Sequence[str], /) -> None: + self._load_types = load_types + + @property + def load_types(self) -> collections.Sequence[str]: + return self._load_types + + @classmethod + def config_id(cls) -> str: + return "alluka.TypeLoader" + + @classmethod + def from_mapping(cls, data: collections.Mapping[_DictKeyT, _DictValueT]) -> Self: + raw_load_types = data.get("load_types") + if not isinstance(raw_load_types, collections.Sequence): + raise RuntimeError(f"Expected a list of strings at `'load_types', found {type(raw_load_types)}") + + load_types: list[str] = [] + for index, type_id in enumerate(raw_load_types): + if not isinstance(type_id, str): + raise RuntimeError(f"Expected a string at `'load_types'.{index}`, found {type(type_id)}") + + load_types.append(type_id) + + return cls(tuple(load_types)) + + +class ConfigFile(typing.NamedTuple): # TODO: hide + configs: collections.Sequence[BaseConfig] diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py new file mode 100644 index 00000000..a57e8c83 --- /dev/null +++ b/alluka/managed/_index.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +# BSD 3-Clause License +# +# Copyright (c) 2020-2024, Faster Speeding +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from __future__ import annotations + +import threading +import typing +import weakref +from collections import abc as collections + +from .. import _visitor +from . import _config + +if typing.TYPE_CHECKING: + import types + + from typing_extensions import Self + + from .. import _types + from .. import abc as alluka + + +_T = typing.TypeVar("_T") +_CoroT = collections.Coroutine[typing.Any, typing.Any, _T] +_DictKeyT = typing.Union[str, int, float, bool, None] +_DictValueT = typing.Union[ + collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT +] + + +class TypeConfig(typing.NamedTuple, typing.Generic[_T]): + async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] + async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] + cleanup: typing.Optional[collections.Callable[[_T], None]] + create: typing.Optional[collections.Callable[..., _T]] + dep_type: type[_T] + dependencies: collections.Sequence[type[typing.Any]] + name: str + + +class Index: + __slots__ = ("_descriptors", "_config_index", "_lock", "_name_index", "_type_index") + + def __init__(self) -> None: + # TODO: this forces objects to have a __weakref__ attribute, + # and also hashability (so hash and eq or neither), do we want to + # keep with this behaviour or document it? + self._descriptors: weakref.WeakKeyDictionary[ + alluka.CallbackSig[typing.Any], dict[str, _types.InjectedTuple] + ] = weakref.WeakKeyDictionary() + self._config_index: dict[str, type[_config.BaseConfig]] = {} + self._lock = threading.Lock() + self._name_index: dict[str, TypeConfig[typing.Any]] = {} + self._type_index: dict[type[typing.Any], TypeConfig[typing.Any]] = {} + + def __enter__(self) -> None: + self._lock.__enter__() + + def __exit__( + self, + exc_cls: type[BaseException] | None, + exc: BaseException | None, + traceback_value: types.TracebackType | None, + ) -> None: + return self._lock.__exit__(exc_cls, exc, traceback_value) + + def register_config(self, config_cls: type[_config.BaseConfig], /) -> Self: + config_id = config_cls.config_id() + if config_id in self._config_index: + raise RuntimeError(f"Config ID {config_id!r} already registered") + + self._config_index[config_id] = config_cls + return self + + @typing.overload + def register_type( + self, + dep_type: type[_T], + name: str, + /, + *, + async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, + async_create: collections.Callable[..., _CoroT[_T]], + cleanup: typing.Optional[collections.Callable[[_T], None]] = None, + create: typing.Optional[collections.Callable[..., _T]] = None, + dependencies: collections.Sequence[type[typing.Any]] = (), + ) -> Self: ... + + @typing.overload + def register_type( + self, + dep_type: type[_T], + name: str, + /, + *, + async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, + async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, + cleanup: typing.Optional[collections.Callable[[_T], None]] = None, + create: collections.Callable[..., _T], + dependencies: collections.Sequence[type[typing.Any]] = (), + ) -> Self: ... + + def register_type( + self, + dep_type: type[_T], + name: str, + /, + *, + async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, + async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, + cleanup: typing.Optional[collections.Callable[[_T], None]] = None, + create: typing.Optional[collections.Callable[..., _T]] = None, + dependencies: collections.Sequence[type[typing.Any]] = (), + ) -> Self: + if not create and not async_create: + raise RuntimeError("Either create or async_create has to be passed") + + config = TypeConfig( + async_cleanup=async_cleanup, + async_create=async_create, + cleanup=cleanup, + create=create, + dep_type=dep_type, + dependencies=dependencies, + name=name, + ) + + if config.dep_type in self._type_index: + raise RuntimeError(f"Dependency type `{config.dep_type}` already registered") + + if config.name in self._name_index: + raise RuntimeError(f"Dependency name {config.name!r} already registered") + + self._type_index[config.dep_type] = self._name_index[config.name] = config + return self + + def set_descriptors( + self, callback: alluka.CallbackSig[typing.Any], descriptors: dict[str, _types.InjectedTuple], / + ) -> Self: + self._descriptors[callback] = _visitor.Callback(callback).accept(_visitor.ParameterVisitor()) + return self + + def get_descriptors( + self, callback: alluka.CallbackSig[typing.Any], / + ) -> typing.Optional[dict[str, _types.InjectedTuple]]: + return self._descriptors.get(callback) + + def get_type(self, dep_type: type[_T], /) -> TypeConfig[_T]: + try: + return self._type_index[dep_type] + + except KeyError: + raise RuntimeError(f"Unknown dependency type {dep_type}") from None + + def get_type_by_name(self, name: str, /) -> TypeConfig[typing.Any]: + try: + return self._name_index[name] + + except KeyError: + raise RuntimeError(f"Unknown dependency ID {name!r}") from None + + def _parse_config(self, key: _DictKeyT, config: _DictValueT, /) -> _config.BaseConfig: + if not isinstance(key, str): + raise RuntimeError(f"Expected string keys in `'configs'`, found {key!r}") + + if not isinstance(config, collections.Mapping): + raise RuntimeError(f"Expected a dictionary at `'configs'.{key!r}`, found {type(config)}") + + if config_type := self._config_index.get(key): + return config_type.from_mapping(config) + + raise RuntimeError(f"Unknown config ID `{key!r}`") + + def parse_config(self, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> _config.ConfigFile: + raw_configs = data["configs"] + if not isinstance(raw_configs, collections.Mapping): + raise RuntimeError(f"Expected a dictionaries at `'configs'`, found {type(raw_configs)}") + + return _config.ConfigFile(configs=[self._parse_config(*args) for args in raw_configs.items()]) + + +GLOBAL_INDEX = Index().register_config(_config.TypeLoader) diff --git a/alluka/managed/_manager.py b/alluka/managed/_manager.py new file mode 100644 index 00000000..d4334b18 --- /dev/null +++ b/alluka/managed/_manager.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# BSD 3-Clause License +# +# Copyright (c) 2020-2024, Faster Speeding +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from __future__ import annotations + +import itertools +import json +import logging +import pathlib +import typing +import weakref +from collections import abc as collections + +from .. import _types +from .. import _visitor +from .. import abc +from . import _config +from . import _index + +if typing.TYPE_CHECKING: + from typing_extensions import Self + + +_DictKeyT = typing.Union[str, int, float, bool, None] +_DictValueT = typing.Union[ + collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT +] +_LOGGER = logging.getLogger("alluka.managed") +_PARSERS: dict[str, collections.Callable[[typing.BinaryIO], collections.Mapping[_DictKeyT, _DictValueT]]] = { + "json": json.load +} +_T = typing.TypeVar("_T") + + +try: + import tomllib # pyright: ignore[reportMissingTypeStubs] + +except ModuleNotFoundError: + pass + +else: + _PARSERS["toml"] = tomllib.load # type: ignore + + +class Manager: + __slots__ = ("_callback_types", "_client", "_loaded", "_processed_callbacks") + + def __init__(self, client: abc.Client, /) -> None: + self._callback_types: set[str] = set() + self._client = client + self._loaded: typing.Optional[list[_index.TypeConfig[typing.Any]]] = None + self._processed_callbacks: weakref.WeakSet[collections.Callable[..., typing.Any]] = weakref.WeakSet() + + def load_config(self, path: pathlib.Path, /) -> Self: + extension = path.name.rsplit(".", 1)[-1].lower() + parser = _PARSERS.get(extension) + if not parser: + raise RuntimeError(f"Unsupported file type {extension!r}") + + with path.open("rb") as file: + raw_config = parser(file) + + if not isinstance(raw_config, dict): + raise RuntimeError(f"Unexpected top level type found in `{path!s}`, expected a dictionary") + + config = _index.GLOBAL_INDEX.parse_config(raw_config) + for sub_config in config.configs: + for config_type in sub_config.config_types(): + self._client.set_type_dependency(config_type, sub_config) + + return self + + def _to_resolvers( + self, type_id: typing.Union[str, type[typing.Any]], /, *, mimo: typing.Optional[set[type[typing.Any]]] = None + ) -> collections.Iterator[_index.TypeConfig[typing.Any]]: + if mimo is None: + mimo = set() + + if isinstance(type_id, str): + type_config = _index.GLOBAL_INDEX.get_type_by_name(type_id) + + else: + type_config = _index.GLOBAL_INDEX.get_type(type_id) + + for dep in type_config.dependencies: + if dep in mimo: + continue + + mimo.add(dep) + yield from self._to_resolvers(dep, mimo=mimo) + + yield type_config + + def _calculate_loaders(self) -> list[_index.TypeConfig[typing.Any]]: + if self._loaded is not None: + raise RuntimeError("Dependencies already loaded") + + ids_to_load = self._client.get_type_dependency(_config.TypeLoader, default=None) + if not ids_to_load: + return [] + + self._loaded = list( + itertools.chain.from_iterable( + self._to_resolvers(type_id) for type_id in itertools.chain(ids_to_load.load_types, self._callback_types) + ) + ) + return self._loaded + + def load_deps(self) -> None: + to_load = self._calculate_loaders() + + for type_info in to_load: + if not type_info.create: + raise RuntimeError(f"Type dependency {type_info.name!r} can only be created in an async context") + + value = self._client.call_with_di(type_info.create) + self._client.set_type_dependency(type_info.dep_type, value) + + async def load_deps_async(self) -> None: + to_load = self._calculate_loaders() + + for type_info in to_load: + callback = type_info.async_create or type_info.create + assert callback + value = await self._client.call_with_async_di(callback) + self._client.set_type_dependency(type_info.dep_type, value) + + def _iter_unload(self) -> collections.Iterator[tuple[_index.TypeConfig[typing.Any], typing.Any]]: + if self._loaded is None: + raise RuntimeError("Dependencies not loaded") + + for type_info in self._loaded: + try: + value = self._client.get_type_dependency(type_info.dep_type) + + except KeyError: + pass + + else: + self._client.remove_type_dependency(type_info.dep_type) + yield (type_info, value) + + def unload_deps(self) -> None: + for type_info, value in self._iter_unload(): + if type_info.cleanup: + type_info.cleanup(value) + + elif type_info.async_cleanup: + _LOGGER.warning( + "Dependency %r might have not been cleaned up properly;" + "cannot run asynchronous cleanup function in a synchronous runtime", + type_info.dep_type, + ) + + async def unload_deps_async(self) -> None: + for type_info, value in self._iter_unload(): + if type_info.async_cleanup: + await type_info.async_cleanup(value) + + elif type_info.cleanup: + type_info.cleanup(value) + + def pre_process_function(self, callback: collections.Callable[..., typing.Any], /) -> Self: + types: list[str] = [] + for param in _visitor.Callback(callback).accept(_visitor.ParameterVisitor()).values(): + if param[0] is _types.InjectedTypes.CALLBACK: + self.pre_process_function(param[1].callback) + continue + + dep_info = param[1] + # For this initial implementation only required arguments with no + # union are supported. + if dep_info.default is _types.UNDEFINED or len(dep_info.types) != 1: + continue + + try: + self._client.get_type_dependency(dep_info.types[0]) + + except KeyError: + continue + + try: + type_info = _index.GLOBAL_INDEX.get_type(dep_info.types[0]) + + except KeyError: + # TODO: raise or mark as missing? + pass + + else: + types.append(type_info.name) + + self._processed_callbacks.add(callback) + self._callback_types.update(types) + return self From fd4d1a1e726452eefe6894bbd2fff033c74283ed Mon Sep 17 00:00:00 2001 From: Lucy Date: Tue, 6 Aug 2024 19:04:01 +0100 Subject: [PATCH 02/27] Allow configs to define loaders and unloaders --- alluka/managed/__init__.py | 43 ++++++++++++++++++++- alluka/managed/_config.py | 63 ++++++++++++++++++++---------- alluka/managed/_index.py | 43 +++++++-------------- alluka/managed/_manager.py | 79 ++++++++++++++++++++------------------ 4 files changed, 138 insertions(+), 90 deletions(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index 77c07a62..4e8b1d26 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -30,11 +30,50 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations +import typing +from collections import abc as collections + from . import _index from ._config import BaseConfig as BaseConfig +from ._config import ConfigFile as ConfigFile from ._manager import Manager as Manager +if typing.TYPE_CHECKING: + _T = typing.TypeVar("_T") + + _CoroT = collections.Coroutine[typing.Any, typing.Any, _T] + + class _RegiserTypeSig(typing.Protocol): + @typing.overload + def __call__( + self, + dep_type: type[_T], + name: str, + /, + *, + async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, + async_create: collections.Callable[..., _CoroT[_T]], + cleanup: typing.Optional[collections.Callable[[_T], None]] = None, + create: typing.Optional[collections.Callable[..., _T]] = None, + dependencies: collections.Sequence[type[typing.Any]] = (), + ) -> None: ... + + @typing.overload + def __call__( + self, + dep_type: type[_T], + name: str, + /, + *, + async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, + async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, + cleanup: typing.Optional[collections.Callable[[_T], None]] = None, + create: collections.Callable[..., _T], + dependencies: collections.Sequence[type[typing.Any]] = (), + ) -> None: ... + + _GLOBAL_INDEX = _index.GLOBAL_INDEX -register_config = _GLOBAL_INDEX.register_config -register_type = _GLOBAL_INDEX.register_type +register_config: collections.Callable[[type[BaseConfig]], None] = _GLOBAL_INDEX.register_config +register_type: _RegiserTypeSig = _GLOBAL_INDEX.register_type diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index d437b07c..50400675 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -37,7 +37,10 @@ if typing.TYPE_CHECKING: from typing_extensions import Self + from .. import abc as alluka + +_CoroT = collections.Coroutine[typing.Any, typing.Any, None] _DictKeyT = typing.Union[str, int, float, bool, None] _DictValueT = typing.Union[ collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT @@ -61,36 +64,56 @@ def config_id(cls) -> str: def from_mapping(cls, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> Self: raise NotImplementedError + @property + def async_cleanup(self) -> typing.Optional[collections.Callable[[Self, alluka.Client], _CoroT]]: ... -class TypeLoader(BaseConfig): - __slots__ = ("_load_types",) + @property + def async_create(self) -> typing.Optional[collections.Callable[[Self, alluka.Client], _CoroT]]: ... - def __init__(self, load_types: collections.Sequence[str], /) -> None: - self._load_types = load_types + @property + def cleanup(self) -> typing.Optional[collections.Callable[[Self, alluka.Client], None]]: ... @property - def load_types(self) -> collections.Sequence[str]: - return self._load_types + def create(self) -> typing.Optional[collections.Callable[[Self, alluka.Client], None]]: ... - @classmethod - def config_id(cls) -> str: - return "alluka.TypeLoader" - @classmethod - def from_mapping(cls, data: collections.Mapping[_DictKeyT, _DictValueT]) -> Self: - raw_load_types = data.get("load_types") - if not isinstance(raw_load_types, collections.Sequence): - raise RuntimeError(f"Expected a list of strings at `'load_types', found {type(raw_load_types)}") +def _parse_config(key: _DictKeyT, config: _DictValueT, /) -> BaseConfig: + if not isinstance(key, str): + raise RuntimeError(f"Expected string keys in `'configs'`, found {key!r}") - load_types: list[str] = [] - for index, type_id in enumerate(raw_load_types): - if not isinstance(type_id, str): - raise RuntimeError(f"Expected a string at `'load_types'.{index}`, found {type(type_id)}") + if not isinstance(config, collections.Mapping): + raise RuntimeError(f"Expected a dictionary at `'configs'.{key!r}`, found {type(config)}") - load_types.append(type_id) + from . import _index - return cls(tuple(load_types)) + return _index.GLOBAL_INDEX.get_config(key).from_mapping(config) class ConfigFile(typing.NamedTuple): # TODO: hide configs: collections.Sequence[BaseConfig] + load_types: collections.Sequence[str] + + @classmethod + def parse(cls, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> Self: + raw_configs = data["configs"] + if not isinstance(raw_configs, collections.Mapping): + raise RuntimeError(f"Expected a dictionaries at `'configs'`, found {type(raw_configs)}") + + try: + raw_load_types = data["load_types"] + + except KeyError: + load_types: list[str] = [] + + else: + if not isinstance(raw_load_types, collections.Sequence): + raise RuntimeError(f"Expected a list of strings at `'load_types'`, found {type(raw_load_types)}") + + load_types = [] + for index, type_id in enumerate(raw_load_types): + if not isinstance(type_id, str): + raise RuntimeError(f"Expected a string at `'load_types'.{index}`, found {type(type_id)}") + + load_types.append(type_id) + + return cls(configs=[_parse_config(*args) for args in raw_configs.items()], load_types=load_types) diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index a57e8c83..3c828bad 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -36,15 +36,13 @@ from collections import abc as collections from .. import _visitor -from . import _config if typing.TYPE_CHECKING: import types - from typing_extensions import Self - - from .. import _types + from .. import _types # pyright: ignore[reportPrivateUsage] from .. import abc as alluka + from . import _config # pyright: ignore[reportPrivateUsage] _T = typing.TypeVar("_T") @@ -91,13 +89,12 @@ def __exit__( ) -> None: return self._lock.__exit__(exc_cls, exc, traceback_value) - def register_config(self, config_cls: type[_config.BaseConfig], /) -> Self: + def register_config(self, config_cls: type[_config.BaseConfig], /) -> None: config_id = config_cls.config_id() if config_id in self._config_index: raise RuntimeError(f"Config ID {config_id!r} already registered") self._config_index[config_id] = config_cls - return self @typing.overload def register_type( @@ -111,7 +108,7 @@ def register_type( cleanup: typing.Optional[collections.Callable[[_T], None]] = None, create: typing.Optional[collections.Callable[..., _T]] = None, dependencies: collections.Sequence[type[typing.Any]] = (), - ) -> Self: ... + ) -> None: ... @typing.overload def register_type( @@ -125,7 +122,7 @@ def register_type( cleanup: typing.Optional[collections.Callable[[_T], None]] = None, create: collections.Callable[..., _T], dependencies: collections.Sequence[type[typing.Any]] = (), - ) -> Self: ... + ) -> None: ... def register_type( self, @@ -138,7 +135,7 @@ def register_type( cleanup: typing.Optional[collections.Callable[[_T], None]] = None, create: typing.Optional[collections.Callable[..., _T]] = None, dependencies: collections.Sequence[type[typing.Any]] = (), - ) -> Self: + ) -> None: if not create and not async_create: raise RuntimeError("Either create or async_create has to be passed") @@ -159,13 +156,11 @@ def register_type( raise RuntimeError(f"Dependency name {config.name!r} already registered") self._type_index[config.dep_type] = self._name_index[config.name] = config - return self def set_descriptors( self, callback: alluka.CallbackSig[typing.Any], descriptors: dict[str, _types.InjectedTuple], / - ) -> Self: + ) -> None: self._descriptors[callback] = _visitor.Callback(callback).accept(_visitor.ParameterVisitor()) - return self def get_descriptors( self, callback: alluka.CallbackSig[typing.Any], / @@ -186,24 +181,12 @@ def get_type_by_name(self, name: str, /) -> TypeConfig[typing.Any]: except KeyError: raise RuntimeError(f"Unknown dependency ID {name!r}") from None - def _parse_config(self, key: _DictKeyT, config: _DictValueT, /) -> _config.BaseConfig: - if not isinstance(key, str): - raise RuntimeError(f"Expected string keys in `'configs'`, found {key!r}") - - if not isinstance(config, collections.Mapping): - raise RuntimeError(f"Expected a dictionary at `'configs'.{key!r}`, found {type(config)}") - - if config_type := self._config_index.get(key): - return config_type.from_mapping(config) - - raise RuntimeError(f"Unknown config ID `{key!r}`") - - def parse_config(self, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> _config.ConfigFile: - raw_configs = data["configs"] - if not isinstance(raw_configs, collections.Mapping): - raise RuntimeError(f"Expected a dictionaries at `'configs'`, found {type(raw_configs)}") + def get_config(self, config_id: str, /) -> type[_config.BaseConfig]: + try: + return self._config_index[config_id] - return _config.ConfigFile(configs=[self._parse_config(*args) for args in raw_configs.items()]) + except KeyError: + raise RuntimeError(f"Unknown config ID {config_id!r}") from None -GLOBAL_INDEX = Index().register_config(_config.TypeLoader) +GLOBAL_INDEX = Index() diff --git a/alluka/managed/_manager.py b/alluka/managed/_manager.py index d4334b18..67c453d9 100644 --- a/alluka/managed/_manager.py +++ b/alluka/managed/_manager.py @@ -38,10 +38,10 @@ import weakref from collections import abc as collections -from .. import _types +from .. import _types # pyright: ignore[reportPrivateUsage] from .. import _visitor from .. import abc -from . import _config +from . import _config # pyright: ignore[reportPrivateUsage] from . import _index if typing.TYPE_CHECKING: @@ -70,31 +70,41 @@ class Manager: - __slots__ = ("_callback_types", "_client", "_loaded", "_processed_callbacks") + __slots__ = ("_client", "_is_loaded", "_load_configs", "_load_types", "_processed_callbacks") def __init__(self, client: abc.Client, /) -> None: - self._callback_types: set[str] = set() self._client = client - self._loaded: typing.Optional[list[_index.TypeConfig[typing.Any]]] = None + self._is_loaded = False + self._load_configs: list[_config.BaseConfig] = [] + self._load_types: dict[type[typing.Any], _index.TypeConfig[typing.Any]] = {} self._processed_callbacks: weakref.WeakSet[collections.Callable[..., typing.Any]] = weakref.WeakSet() - def load_config(self, path: pathlib.Path, /) -> Self: - extension = path.name.rsplit(".", 1)[-1].lower() - parser = _PARSERS.get(extension) - if not parser: - raise RuntimeError(f"Unsupported file type {extension!r}") + def load_config(self, config: typing.Union[pathlib.Path, _config.ConfigFile], /) -> Self: + if isinstance(config, pathlib.Path): + extension = config.name.rsplit(".", 1)[-1].lower() + parser = _PARSERS.get(extension) + if not parser: + raise RuntimeError(f"Unsupported file type {extension!r}") - with path.open("rb") as file: - raw_config = parser(file) + with config.open("rb") as file: + raw_config = parser(file) - if not isinstance(raw_config, dict): - raise RuntimeError(f"Unexpected top level type found in `{path!s}`, expected a dictionary") + if not isinstance(raw_config, dict): + raise RuntimeError(f"Unexpected top level type found in `{config!s}`, expected a dictionary") + + config = _config.ConfigFile.parse(raw_config) + return self.load_config(config) - config = _index.GLOBAL_INDEX.parse_config(raw_config) for sub_config in config.configs: for config_type in sub_config.config_types(): self._client.set_type_dependency(config_type, sub_config) + mimo: set[type[typing.Any]] = set() + for type_info in itertools.chain.from_iterable( + self._to_resolvers(type_id, mimo=mimo) for type_id in config.load_types + ): + self._load_types[type_info.dep_type] = type_info + return self def _to_resolvers( @@ -118,25 +128,11 @@ def _to_resolvers( yield type_config - def _calculate_loaders(self) -> list[_index.TypeConfig[typing.Any]]: - if self._loaded is not None: - raise RuntimeError("Dependencies already loaded") - - ids_to_load = self._client.get_type_dependency(_config.TypeLoader, default=None) - if not ids_to_load: - return [] - - self._loaded = list( - itertools.chain.from_iterable( - self._to_resolvers(type_id) for type_id in itertools.chain(ids_to_load.load_types, self._callback_types) - ) - ) - return self._loaded - def load_deps(self) -> None: - to_load = self._calculate_loaders() + if self._is_loaded: + raise RuntimeError("Dependencies already loaded") - for type_info in to_load: + for type_info in self._load_types.values(): if not type_info.create: raise RuntimeError(f"Type dependency {type_info.name!r} can only be created in an async context") @@ -144,19 +140,21 @@ def load_deps(self) -> None: self._client.set_type_dependency(type_info.dep_type, value) async def load_deps_async(self) -> None: - to_load = self._calculate_loaders() + if self._is_loaded: + raise RuntimeError("Dependencies already loaded") - for type_info in to_load: + for type_info in self._load_types.values(): callback = type_info.async_create or type_info.create assert callback value = await self._client.call_with_async_di(callback) self._client.set_type_dependency(type_info.dep_type, value) def _iter_unload(self) -> collections.Iterator[tuple[_index.TypeConfig[typing.Any], typing.Any]]: - if self._loaded is None: + if not self._is_loaded: raise RuntimeError("Dependencies not loaded") - for type_info in self._loaded: + self._is_loaded = False + for type_info in self._load_types.values(): try: value = self._client.get_type_dependency(type_info.dep_type) @@ -189,7 +187,9 @@ async def unload_deps_async(self) -> None: def pre_process_function(self, callback: collections.Callable[..., typing.Any], /) -> Self: types: list[str] = [] - for param in _visitor.Callback(callback).accept(_visitor.ParameterVisitor()).values(): + descriptors = _visitor.Callback(callback).accept(_visitor.ParameterVisitor()) + _index.GLOBAL_INDEX.set_descriptors(callback, descriptors) + for param in descriptors.values(): if param[0] is _types.InjectedTypes.CALLBACK: self.pre_process_function(param[1].callback) continue @@ -217,5 +217,8 @@ def pre_process_function(self, callback: collections.Callable[..., typing.Any], types.append(type_info.name) self._processed_callbacks.add(callback) - self._callback_types.update(types) + mimo: set[type[typing.Any]] = set() + for type_info in itertools.chain.from_iterable(self._to_resolvers(type_id, mimo=mimo) for type_id in types): + self._load_types[type_info.dep_type] = type_info + return self From 1fce99dcab5c4879f42048f12c1a45934167dd19 Mon Sep 17 00:00:00 2001 From: Lucy Date: Tue, 6 Aug 2024 19:29:53 +0100 Subject: [PATCH 03/27] Switch to just allowing configs to add type IDs to load --- alluka/managed/_config.py | 14 ++------------ alluka/managed/_manager.py | 4 +++- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index 50400675..076dd2de 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -37,10 +37,7 @@ if typing.TYPE_CHECKING: from typing_extensions import Self - from .. import abc as alluka - -_CoroT = collections.Coroutine[typing.Any, typing.Any, None] _DictKeyT = typing.Union[str, int, float, bool, None] _DictValueT = typing.Union[ collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT @@ -65,16 +62,9 @@ def from_mapping(cls, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> S raise NotImplementedError @property - def async_cleanup(self) -> typing.Optional[collections.Callable[[Self, alluka.Client], _CoroT]]: ... - - @property - def async_create(self) -> typing.Optional[collections.Callable[[Self, alluka.Client], _CoroT]]: ... + def load_types(self) -> collections.Sequence[str]: + return [] - @property - def cleanup(self) -> typing.Optional[collections.Callable[[Self, alluka.Client], None]]: ... - - @property - def create(self) -> typing.Optional[collections.Callable[[Self, alluka.Client], None]]: ... def _parse_config(key: _DictKeyT, config: _DictValueT, /) -> BaseConfig: diff --git a/alluka/managed/_manager.py b/alluka/managed/_manager.py index 67c453d9..62533e22 100644 --- a/alluka/managed/_manager.py +++ b/alluka/managed/_manager.py @@ -95,13 +95,15 @@ def load_config(self, config: typing.Union[pathlib.Path, _config.ConfigFile], /) config = _config.ConfigFile.parse(raw_config) return self.load_config(config) + load_types = set(config.load_types) for sub_config in config.configs: + load_types.update(sub_config.load_types) for config_type in sub_config.config_types(): self._client.set_type_dependency(config_type, sub_config) mimo: set[type[typing.Any]] = set() for type_info in itertools.chain.from_iterable( - self._to_resolvers(type_id, mimo=mimo) for type_id in config.load_types + self._to_resolvers(type_id, mimo=mimo) for type_id in iter(load_types) ): self._load_types[type_info.dep_type] = type_info From 70b978c70f7828dce6c86096368446ba664a00c1 Mon Sep 17 00:00:00 2001 From: "always-on-duty[bot]" <120557446+always-on-duty[bot]@users.noreply.github.com> Date: Tue, 6 Aug 2024 18:30:34 +0000 Subject: [PATCH 04/27] Reformat PR code --- alluka/managed/_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index 076dd2de..9fa614b8 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -66,7 +66,6 @@ def load_types(self) -> collections.Sequence[str]: return [] - def _parse_config(key: _DictKeyT, config: _DictValueT, /) -> BaseConfig: if not isinstance(key, str): raise RuntimeError(f"Expected string keys in `'configs'`, found {key!r}") From c0771145c5e9cff0d89643ab95d7e7645714114b Mon Sep 17 00:00:00 2001 From: Lucy Date: Wed, 7 Aug 2024 20:25:35 +0100 Subject: [PATCH 05/27] Load config classes from package metadata --- alluka/_client.py | 3 +-- alluka/managed/_index.py | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/alluka/_client.py b/alluka/_client.py index 11ae5846..bae52903 100644 --- a/alluka/_client.py +++ b/alluka/_client.py @@ -169,7 +169,7 @@ def __init__(self, *, introspect_annotations: bool = True) -> None: self._descriptors: weakref.WeakKeyDictionary[ alluka.CallbackSig[typing.Any], dict[str, _types.InjectedTuple] ] = weakref.WeakKeyDictionary() - self._introspect_annotations = introspect_annotations + self._introspect_annotations = introspect_annotations # TODO: deprecate static_context = _context.Context(self) self._make_context: collections.Callable[[Self], alluka.Context] = lambda _: static_context self._type_dependencies: dict[type[typing.Any], typing.Any] = {alluka.Client: self, Client: self} @@ -186,7 +186,6 @@ def _build_descriptors(self, callback: alluka.CallbackSig[typing.Any], /) -> dic self._descriptors[callback] = descriptors return descriptors - # TODO: introspect_annotations=self._introspect_annotations descriptors = self._descriptors[callback] = _visitor.Callback(callback).accept(_visitor.ParameterVisitor()) return descriptors diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index 3c828bad..a3ccd369 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -30,6 +30,9 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations +import importlib.metadata +import logging +import sys import threading import typing import weakref @@ -45,6 +48,8 @@ from . import _config # pyright: ignore[reportPrivateUsage] +_LOGGER = logging.getLogger("alluka.managed") + _T = typing.TypeVar("_T") _CoroT = collections.Coroutine[typing.Any, typing.Any, _T] _DictKeyT = typing.Union[str, int, float, bool, None] @@ -64,7 +69,7 @@ class TypeConfig(typing.NamedTuple, typing.Generic[_T]): class Index: - __slots__ = ("_descriptors", "_config_index", "_lock", "_name_index", "_type_index") + __slots__ = ("_descriptors", "_config_index", "_lock", "_metadata_scanned", "_name_index", "_type_index") def __init__(self) -> None: # TODO: this forces objects to have a __weakref__ attribute, @@ -75,8 +80,10 @@ def __init__(self) -> None: ] = weakref.WeakKeyDictionary() self._config_index: dict[str, type[_config.BaseConfig]] = {} self._lock = threading.Lock() + self._metadata_scanned = False self._name_index: dict[str, TypeConfig[typing.Any]] = {} self._type_index: dict[type[typing.Any], TypeConfig[typing.Any]] = {} + self.scan_libraries() def __enter__(self) -> None: self._lock.__enter__() @@ -90,6 +97,7 @@ def __exit__( return self._lock.__exit__(exc_cls, exc, traceback_value) def register_config(self, config_cls: type[_config.BaseConfig], /) -> None: + # TODO: Note that libraries should use package metadata! config_id = config_cls.config_id() if config_id in self._config_index: raise RuntimeError(f"Config ID {config_id!r} already registered") @@ -188,5 +196,32 @@ def get_config(self, config_id: str, /) -> type[_config.BaseConfig]: except KeyError: raise RuntimeError(f"Unknown config ID {config_id!r}") from None + def scan_libraries(self) -> None: + if self._metadata_scanned: + return + + self._metadata_scanned = True + if sys.version_info >= (3, 10): + entry_points = importlib.metadata.entry_points(group="alluka") + + else: + entry_points = importlib.metadata.entry_points()["alluka"] + + for entry_point in entry_points: + if not entry_point.name.startswith("config"): + continue + + value = entry_point.load() + if isinstance(value, type) and issubclass(value, _config.BaseConfig): + self.register_config(value) + + else: + _LOGGER.warn( + "Unexpected value found at %, expected a BaseConfig class but found %r. " + "An alluka entry point is misconfigured.", + entry_point.value, + value, + ) + GLOBAL_INDEX = Index() From fb774ae1f1d7eb48f8cc3b84a5f4832b1a76fbea Mon Sep 17 00:00:00 2001 From: Lucy Date: Thu, 8 Aug 2024 00:11:34 +0100 Subject: [PATCH 06/27] Some cleanup/improvements --- alluka/managed/__init__.py | 53 ++++++++++++- alluka/managed/_config.py | 33 ++++---- alluka/managed/_index.py | 151 ++++++++++++++++++++++++++++++++++--- alluka/managed/_manager.py | 22 +++++- 4 files changed, 229 insertions(+), 30 deletions(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index 4e8b1d26..c7062965 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -30,11 +30,13 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations +__all__: list[str] = ["register_config", "register_type"] + import typing from collections import abc as collections from . import _index -from ._config import BaseConfig as BaseConfig +from ._config import PluginConfig as PluginConfig from ._config import ConfigFile as ConfigFile from ._manager import Manager as Manager @@ -75,5 +77,52 @@ def __call__( _GLOBAL_INDEX = _index.GLOBAL_INDEX -register_config: collections.Callable[[type[BaseConfig]], None] = _GLOBAL_INDEX.register_config +register_config: collections.Callable[[type[PluginConfig]], None] = _GLOBAL_INDEX.register_config +"""Register a plugin configuration class. + +!!! warning + Libraries should register entry-points under the `"alluka"` group + with `"config"` prefixed names to register custom configuration + classes. + +Parameters +---------- +config_cls + The plugin configuration class to register. + +Raises +------ +RuntimeError + If the configuration class' ID is already registered. +""" + register_type: _RegiserTypeSig = _GLOBAL_INDEX.register_type +"""Register the procedures for creating and destorying a type dependency. + +!!! note + Either `create` or `async_create` must be passed, but if only + `async_create` is passed then this will fail to be created in + a synchronous runtime. + +Parameters +---------- +dep_type + Type of the dep this should be registered for. +name + Name used to identify this type dependency in configuration files. +async_cleanup + Callback used to use to destroy the dependency in an async runtime. +async_create + Callback used to use to create the dependency in an async runtime. +cleanup + Callback used to use to destroy the dependency in a sync runtime. +create + Callback used to use to create the dependency in a sync runtime. +dependencies + Sequence of type dependencies that are required to create this dependency. + +Raises +------ +TypeError + If neither `create` nor `async_create` is passed. +""" diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index 9fa614b8..0e7ff63c 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -30,6 +30,8 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations +__all__: list[str] = ["ConfigFile", "PluginConfig"] + import abc import typing from collections import abc as collections @@ -44,49 +46,54 @@ ] -class BaseConfig(abc.ABC): +class PluginConfig(abc.ABC): + """Base class used for configuring plugins loaded via Alluka's manager.""" __slots__ = () @classmethod - def config_types(cls) -> collections.Sequence[type[BaseConfig]]: + def config_types(cls) -> collections.Sequence[type[PluginConfig]]: + """The types to use when registering this configuration as a type dependency.""" return [cls] @classmethod @abc.abstractmethod def config_id(cls) -> str: + """ID used to identify the plugin configuration.""" raise NotImplementedError @classmethod @abc.abstractmethod def from_mapping(cls, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> Self: + """Create this configuration object from a dictionary.""" raise NotImplementedError @property def load_types(self) -> collections.Sequence[str]: + """Sequence of string types to load when this config is present.""" return [] -def _parse_config(key: _DictKeyT, config: _DictValueT, /) -> BaseConfig: +def _parse_config(key: _DictKeyT, config: _DictValueT, /) -> PluginConfig: if not isinstance(key, str): - raise RuntimeError(f"Expected string keys in `'configs'`, found {key!r}") + raise TypeError(f"Expected string keys in `'plugins'`, found {key!r}") if not isinstance(config, collections.Mapping): - raise RuntimeError(f"Expected a dictionary at `'configs'.{key!r}`, found {type(config)}") + raise TypeError(f"Expected a dictionary at `'plugins'.{key!r}`, found {type(config)}") from . import _index return _index.GLOBAL_INDEX.get_config(key).from_mapping(config) -class ConfigFile(typing.NamedTuple): # TODO: hide - configs: collections.Sequence[BaseConfig] +class ConfigFile(typing.NamedTuple): + plugins: collections.Sequence[PluginConfig] load_types: collections.Sequence[str] @classmethod def parse(cls, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> Self: - raw_configs = data["configs"] - if not isinstance(raw_configs, collections.Mapping): - raise RuntimeError(f"Expected a dictionaries at `'configs'`, found {type(raw_configs)}") + raw_plugins = data["plugins"] + if not isinstance(raw_plugins, collections.Mapping): + raise TypeError(f"Expected a dictionaries at `'plugins'`, found {type(raw_plugins)}") try: raw_load_types = data["load_types"] @@ -96,13 +103,13 @@ def parse(cls, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> Self: else: if not isinstance(raw_load_types, collections.Sequence): - raise RuntimeError(f"Expected a list of strings at `'load_types'`, found {type(raw_load_types)}") + raise TypeError(f"Expected a list of strings at `'load_types'`, found {type(raw_load_types)}") load_types = [] for index, type_id in enumerate(raw_load_types): if not isinstance(type_id, str): - raise RuntimeError(f"Expected a string at `'load_types'.{index}`, found {type(type_id)}") + raise TypeError(f"Expected a string at `'load_types'.{index}`, found {type(type_id)}") load_types.append(type_id) - return cls(configs=[_parse_config(*args) for args in raw_configs.items()], load_types=load_types) + return cls(plugins=[_parse_config(*args) for args in raw_plugins.items()], load_types=load_types) diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index a3ccd369..470da58e 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -30,6 +30,8 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations +__all__: list[str] = ["Index", "TypeConfig"] + import importlib.metadata import logging import sys @@ -38,8 +40,6 @@ import weakref from collections import abc as collections -from .. import _visitor - if typing.TYPE_CHECKING: import types @@ -59,31 +59,52 @@ class TypeConfig(typing.NamedTuple, typing.Generic[_T]): + """Represents the procedures and metadata for creating and destorying a type dependency.""" + async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] + """Callback used to use to cleanup the dependency in an async runtime.""" + async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] + """Callback used to use to create the dependency in an async runtime.""" + cleanup: typing.Optional[collections.Callable[[_T], None]] + """Callback used to use to cleanup the dependency in a sync runtime.""" + create: typing.Optional[collections.Callable[..., _T]] + """Callback used to use to create the dependency in an async runtime.""" + dep_type: type[_T] + """The type created values should be registered as a type dependency for.""" + dependencies: collections.Sequence[type[typing.Any]] + """Sequence of type dependencies that are required to create this dependency.""" + name: str + """Name used to identify this type dependency in configuration files.""" class Index: + """Index used to internally track the register global custom configuration. + + This is used by the manager to parse plugin configuration and initialise types. + """ + __slots__ = ("_descriptors", "_config_index", "_lock", "_metadata_scanned", "_name_index", "_type_index") def __init__(self) -> None: + """Initialise an Index.""" # TODO: this forces objects to have a __weakref__ attribute, # and also hashability (so hash and eq or neither), do we want to # keep with this behaviour or document it? self._descriptors: weakref.WeakKeyDictionary[ alluka.CallbackSig[typing.Any], dict[str, _types.InjectedTuple] ] = weakref.WeakKeyDictionary() - self._config_index: dict[str, type[_config.BaseConfig]] = {} + self._config_index: dict[str, type[_config.PluginConfig]] = {} self._lock = threading.Lock() self._metadata_scanned = False self._name_index: dict[str, TypeConfig[typing.Any]] = {} self._type_index: dict[type[typing.Any], TypeConfig[typing.Any]] = {} - self.scan_libraries() + self._scan_libraries() def __enter__(self) -> None: self._lock.__enter__() @@ -96,7 +117,24 @@ def __exit__( ) -> None: return self._lock.__exit__(exc_cls, exc, traceback_value) - def register_config(self, config_cls: type[_config.BaseConfig], /) -> None: + def register_config(self, config_cls: type[_config.PluginConfig], /) -> None: + """Register a plugin configuration class. + + !!! warning + Libraries should register entry-points under the `"alluka"` group + with `"config"` prefixed names to register custom configuration + classes. + + Parameters + ---------- + config_cls + The plugin configuration class to register. + + Raises + ------ + RuntimeError + If the configuration class' ID is already registered. + """ # TODO: Note that libraries should use package metadata! config_id = config_cls.config_id() if config_id in self._config_index: @@ -144,8 +182,37 @@ def register_type( create: typing.Optional[collections.Callable[..., _T]] = None, dependencies: collections.Sequence[type[typing.Any]] = (), ) -> None: + """Register the procedures for creating and destorying a type dependency. + + !!! note + Either `create` or `async_create` must be passed, but if only + `async_create` is passed then this will fail to be created in + a synchronous runtime. + + Parameters + ---------- + dep_type + Type of the dep this should be registered for. + name + Name used to identify this type dependency in configuration files. + async_cleanup + Callback used to use to destroy the dependency in an async runtime. + async_create + Callback used to use to create the dependency in an async runtime. + cleanup + Callback used to use to destroy the dependency in a sync runtime. + create + Callback used to use to create the dependency in a sync runtime. + dependencies + Sequence of type dependencies that are required to create this dependency. + + Raises + ------ + TypeError + If neither `create` nor `async_create` is passed. + """ if not create and not async_create: - raise RuntimeError("Either create or async_create has to be passed") + raise TypeError("Either create or async_create has to be passed") config = TypeConfig( async_cleanup=async_cleanup, @@ -168,14 +235,49 @@ def register_type( def set_descriptors( self, callback: alluka.CallbackSig[typing.Any], descriptors: dict[str, _types.InjectedTuple], / ) -> None: - self._descriptors[callback] = _visitor.Callback(callback).accept(_visitor.ParameterVisitor()) + """Cache the parsed dependency injection descriptors for a callback. + + Parameters + ---------- + callback + The callback to cache the injection descriptors for. + descriptors + The descriptors to cache. + """ + self._descriptors[callback] = descriptors def get_descriptors( self, callback: alluka.CallbackSig[typing.Any], / ) -> typing.Optional[dict[str, _types.InjectedTuple]]: + """Get the dependency injection descriptors cached for a callback. + + Parameters + ---------- + + Returns + ------- + dict[str, alluka._types.InjectedTuple] | None + A dictionary of parameter names to injection metadata. + + This will be [None][] if the descriptors are not cached for + the callback. + """ return self._descriptors.get(callback) def get_type(self, dep_type: type[_T], /) -> TypeConfig[_T]: + """Get the configuration for a type dependency. + + Parameters + ---------- + dep_type + Type of the dependency to get the configuration for. + + Returns + ------- + TypeConfig[_T] + Configuration which represents the procedures and metadata + for creating and destorying the type dependency. + """ try: return self._type_index[dep_type] @@ -183,20 +285,46 @@ def get_type(self, dep_type: type[_T], /) -> TypeConfig[_T]: raise RuntimeError(f"Unknown dependency type {dep_type}") from None def get_type_by_name(self, name: str, /) -> TypeConfig[typing.Any]: + """Get the configuration for a type dependency by its configured name. + + Parameters + ---------- + name + Name of the type dependency to get the configuration for. + + Returns + ------- + TypeConfig[_T] + Configuration which represents the procedures and metadata + for creating and destorying the type dependency. + """ try: return self._name_index[name] except KeyError: raise RuntimeError(f"Unknown dependency ID {name!r}") from None - def get_config(self, config_id: str, /) -> type[_config.BaseConfig]: + def get_config(self, config_id: str, /) -> type[_config.PluginConfig]: + """Get the custom plugin configuration class for a config ID. + + Parameters + ---------- + config_id + ID used to identify the plugin configuration. + + Returns + ------- + type[alluka.managed.PluginConfig] + The custom plugin configuration class. + """ try: return self._config_index[config_id] except KeyError: raise RuntimeError(f"Unknown config ID {config_id!r}") from None - def scan_libraries(self) -> None: + def _scan_libraries(self) -> None: + """Load config clases from installed libraries based on their entry points.""" if self._metadata_scanned: return @@ -212,12 +340,12 @@ def scan_libraries(self) -> None: continue value = entry_point.load() - if isinstance(value, type) and issubclass(value, _config.BaseConfig): + if isinstance(value, type) and issubclass(value, _config.PluginConfig): self.register_config(value) else: _LOGGER.warn( - "Unexpected value found at %, expected a BaseConfig class but found %r. " + "Unexpected value found at %, expected a PluginConfig class but found %r. " "An alluka entry point is misconfigured.", entry_point.value, value, @@ -225,3 +353,4 @@ def scan_libraries(self) -> None: GLOBAL_INDEX = Index() +"""Global instance of [Index][].""" diff --git a/alluka/managed/_manager.py b/alluka/managed/_manager.py index 62533e22..97d0e311 100644 --- a/alluka/managed/_manager.py +++ b/alluka/managed/_manager.py @@ -30,6 +30,8 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations +__all__: list[str] = ["Manager"] + import itertools import json import logging @@ -48,11 +50,12 @@ from typing_extensions import Self +_LOGGER = logging.getLogger("alluka.managed") + _DictKeyT = typing.Union[str, int, float, bool, None] _DictValueT = typing.Union[ collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT ] -_LOGGER = logging.getLogger("alluka.managed") _PARSERS: dict[str, collections.Callable[[typing.BinaryIO], collections.Mapping[_DictKeyT, _DictValueT]]] = { "json": json.load } @@ -60,7 +63,7 @@ try: - import tomllib # pyright: ignore[reportMissingTypeStubs] + import tomllib # pyright: ignore[reportMissingImports] except ModuleNotFoundError: pass @@ -70,12 +73,23 @@ class Manager: + """A type dependency lifetime manager implementation. + + This class manages creating and destroying type dependencies. + """ __slots__ = ("_client", "_is_loaded", "_load_configs", "_load_types", "_processed_callbacks") def __init__(self, client: abc.Client, /) -> None: + """Create a manager. + + Parameters + ---------- + client + The alluka client to bind this to. + """ self._client = client self._is_loaded = False - self._load_configs: list[_config.BaseConfig] = [] + self._load_configs: list[_config.PluginConfig] = [] self._load_types: dict[type[typing.Any], _index.TypeConfig[typing.Any]] = {} self._processed_callbacks: weakref.WeakSet[collections.Callable[..., typing.Any]] = weakref.WeakSet() @@ -96,7 +110,7 @@ def load_config(self, config: typing.Union[pathlib.Path, _config.ConfigFile], /) return self.load_config(config) load_types = set(config.load_types) - for sub_config in config.configs: + for sub_config in config.plugins: load_types.update(sub_config.load_types) for config_type in sub_config.config_types(): self._client.set_type_dependency(config_type, sub_config) From 5817730438fc0e6581bb5e0416c0e066dc8ada88 Mon Sep 17 00:00:00 2001 From: "always-on-duty[bot]" <120557446+always-on-duty[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2024 23:12:14 +0000 Subject: [PATCH 07/27] Reformat PR code --- alluka/managed/__init__.py | 2 +- alluka/managed/_config.py | 1 + alluka/managed/_manager.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index c7062965..3e51932c 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -36,8 +36,8 @@ from collections import abc as collections from . import _index -from ._config import PluginConfig as PluginConfig from ._config import ConfigFile as ConfigFile +from ._config import PluginConfig as PluginConfig from ._manager import Manager as Manager if typing.TYPE_CHECKING: diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index 0e7ff63c..cc80af92 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -48,6 +48,7 @@ class PluginConfig(abc.ABC): """Base class used for configuring plugins loaded via Alluka's manager.""" + __slots__ = () @classmethod diff --git a/alluka/managed/_manager.py b/alluka/managed/_manager.py index 97d0e311..d495e693 100644 --- a/alluka/managed/_manager.py +++ b/alluka/managed/_manager.py @@ -77,6 +77,7 @@ class Manager: This class manages creating and destroying type dependencies. """ + __slots__ = ("_client", "_is_loaded", "_load_configs", "_load_types", "_processed_callbacks") def __init__(self, client: abc.Client, /) -> None: From 6c4f1720a101d70bbc4661d764b363f81256e08f Mon Sep 17 00:00:00 2001 From: Lucy Date: Thu, 8 Aug 2024 00:21:06 +0100 Subject: [PATCH 08/27] Improve entrypoint naming --- alluka/managed/__init__.py | 5 ++--- alluka/managed/_index.py | 15 +++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index 3e51932c..246a059d 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -81,9 +81,8 @@ def __call__( """Register a plugin configuration class. !!! warning - Libraries should register entry-points under the `"alluka"` group - with `"config"` prefixed names to register custom configuration - classes. + Libraries should register entry-points under the `"alluka.plugins"` group + to register custom configuration classes. Parameters ---------- diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index 470da58e..da196606 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -83,6 +83,9 @@ class TypeConfig(typing.NamedTuple, typing.Generic[_T]): """Name used to identify this type dependency in configuration files.""" +_ENTRY_POINT_GROUP_NAME = "alluka.plugins" + + class Index: """Index used to internally track the register global custom configuration. @@ -121,9 +124,8 @@ def register_config(self, config_cls: type[_config.PluginConfig], /) -> None: """Register a plugin configuration class. !!! warning - Libraries should register entry-points under the `"alluka"` group - with `"config"` prefixed names to register custom configuration - classes. + Libraries should register entry-points under the `"alluka.plugins"` group + to register custom configuration classes. Parameters ---------- @@ -330,15 +332,12 @@ def _scan_libraries(self) -> None: self._metadata_scanned = True if sys.version_info >= (3, 10): - entry_points = importlib.metadata.entry_points(group="alluka") + entry_points = importlib.metadata.entry_points(group=_ENTRY_POINT_GROUP_NAME) else: - entry_points = importlib.metadata.entry_points()["alluka"] + entry_points = importlib.metadata.entry_points()[_ENTRY_POINT_GROUP_NAME] for entry_point in entry_points: - if not entry_point.name.startswith("config"): - continue - value = entry_point.load() if isinstance(value, type) and issubclass(value, _config.PluginConfig): self.register_config(value) From bb7b2372dae3937ba79ca1ba2efd4296c09e1a01 Mon Sep 17 00:00:00 2001 From: Lucy Date: Thu, 8 Aug 2024 00:30:09 +0100 Subject: [PATCH 09/27] More documentation --- alluka/managed/_manager.py | 72 +++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/alluka/managed/_manager.py b/alluka/managed/_manager.py index d495e693..cf8cccdc 100644 --- a/alluka/managed/_manager.py +++ b/alluka/managed/_manager.py @@ -95,6 +95,25 @@ def __init__(self, client: abc.Client, /) -> None: self._processed_callbacks: weakref.WeakSet[collections.Callable[..., typing.Any]] = weakref.WeakSet() def load_config(self, config: typing.Union[pathlib.Path, _config.ConfigFile], /) -> Self: + """Load plugin and dependency configuration into this manager. + + Parameters + ---------- + config + Either the parsed configuration or a path to a file to parsed. + + Only paths to JSON and TOML (3.10+) files are supported. + + Raises + ------ + RuntimeError + If the path passed is an unsupported file type or does not match + the expected structure. + TypeError + If the configuration passed does not match the expected structure. + KeyError + If the configuration passed does not match the expected structure. + """ if isinstance(config, pathlib.Path): extension = config.name.rsplit(".", 1)[-1].lower() parser = _PARSERS.get(extension) @@ -146,17 +165,36 @@ def _to_resolvers( yield type_config def load_deps(self) -> None: + """Initialise the configured dependencies. + + !!! note + This will skip over any dependencies which can only be created + asynchronously. + + Raises + ------ + RuntimeError + If the dependencies are already loaded. + """ if self._is_loaded: raise RuntimeError("Dependencies already loaded") for type_info in self._load_types.values(): - if not type_info.create: - raise RuntimeError(f"Type dependency {type_info.name!r} can only be created in an async context") + if type_info.create: + value = self._client.call_with_di(type_info.create) + self._client.set_type_dependency(type_info.dep_type, value) - value = self._client.call_with_di(type_info.create) - self._client.set_type_dependency(type_info.dep_type, value) + else: + _LOGGER.warn("Type dependency %r skipped as it can only be created in an async context", type_info.name) async def load_deps_async(self) -> None: + """Initialise the configured dependencies asynchronously. + + Raises + ------ + RuntimeError + If a dependencies are already loaded. + """ if self._is_loaded: raise RuntimeError("Dependencies already loaded") @@ -183,6 +221,18 @@ def _iter_unload(self) -> collections.Iterator[tuple[_index.TypeConfig[typing.An yield (type_info, value) def unload_deps(self) -> None: + """Unload the configured dependencies. + + !!! warning + If you have any dependencies which were loaded asynchronously, + you probably want + [Manager.unload_deps_async][alluka.managed.Manager.unload_deps_async]. + + Raises + ------ + RuntimeError + If the dependencies aren't loaded. + """ for type_info, value in self._iter_unload(): if type_info.cleanup: type_info.cleanup(value) @@ -195,6 +245,13 @@ def unload_deps(self) -> None: ) async def unload_deps_async(self) -> None: + """Unload the configured dependencies asynchronously + + Raises + ------ + RuntimeError + If the dependencies aren't loaded. + """ for type_info, value in self._iter_unload(): if type_info.async_cleanup: await type_info.async_cleanup(value) @@ -203,6 +260,13 @@ async def unload_deps_async(self) -> None: type_info.cleanup(value) def pre_process_function(self, callback: collections.Callable[..., typing.Any], /) -> Self: + """Register the required type dependencies found in a callback's signature. + + Parameters + ---------- + callback + The callback to register the required type dependencies for. + """ types: list[str] = [] descriptors = _visitor.Callback(callback).accept(_visitor.ParameterVisitor()) _index.GLOBAL_INDEX.set_descriptors(callback, descriptors) From 2cedc634d33a7585b4e40f19c779c2eea7d4470d Mon Sep 17 00:00:00 2001 From: Lucy Date: Thu, 8 Aug 2024 22:28:44 +0100 Subject: [PATCH 10/27] Small fixes --- alluka/managed/__init__.py | 18 +++++++++--------- alluka/managed/_config.py | 2 +- alluka/managed/_index.py | 18 ++++++++++++------ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index 246a059d..fcf05a7f 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -86,7 +86,7 @@ def __call__( Parameters ---------- -config_cls +config_cls: type[PluginConfig] The plugin configuration class to register. Raises @@ -96,7 +96,7 @@ def __call__( """ register_type: _RegiserTypeSig = _GLOBAL_INDEX.register_type -"""Register the procedures for creating and destorying a type dependency. +"""Register the procedures for creating and destroying a type dependency. !!! note Either `create` or `async_create` must be passed, but if only @@ -105,19 +105,19 @@ def __call__( Parameters ---------- -dep_type +dep_type: type[T] Type of the dep this should be registered for. -name +name : str Name used to identify this type dependency in configuration files. -async_cleanup +async_cleanup : collections.abc.Callable[[T], collections.abc.Coroutine[typing.Any, typing.Any, None]] | None Callback used to use to destroy the dependency in an async runtime. -async_create +async_create : collections.abc.Callable[..., collections.abc.Coroutine[typing.Any, typing.Any, T]] | None Callback used to use to create the dependency in an async runtime. -cleanup +cleanup : collections.abc.Callable[[T], None] | None Callback used to use to destroy the dependency in a sync runtime. -create +create : collections.abc.Callable[..., T] | None Callback used to use to create the dependency in a sync runtime. -dependencies +dependencies : collections.abc.Sequence[type[typing.Any]] Sequence of type dependencies that are required to create this dependency. Raises diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index cc80af92..c0485aca 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -94,7 +94,7 @@ class ConfigFile(typing.NamedTuple): def parse(cls, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> Self: raw_plugins = data["plugins"] if not isinstance(raw_plugins, collections.Mapping): - raise TypeError(f"Expected a dictionaries at `'plugins'`, found {type(raw_plugins)}") + raise TypeError(f"Expected a dictionary at `'plugins'`, found {type(raw_plugins)}") try: raw_load_types = data["load_types"] diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index da196606..9ea53106 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -32,6 +32,7 @@ __all__: list[str] = ["Index", "TypeConfig"] +import dataclasses import importlib.metadata import logging import sys @@ -58,8 +59,13 @@ ] -class TypeConfig(typing.NamedTuple, typing.Generic[_T]): - """Represents the procedures and metadata for creating and destorying a type dependency.""" +@dataclasses.dataclass(frozen=True) +class TypeConfig(typing.Generic[_T]): + """Represents the procedures and metadata for creating and destroying a type dependency.""" + + __slots__ = ( + "async_cleanup", "async_create", "cleanup", "create", "dep_type", "dependencies", "name" + ) async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] """Callback used to use to cleanup the dependency in an async runtime.""" @@ -184,7 +190,7 @@ def register_type( create: typing.Optional[collections.Callable[..., _T]] = None, dependencies: collections.Sequence[type[typing.Any]] = (), ) -> None: - """Register the procedures for creating and destorying a type dependency. + """Register the procedures for creating and destroying a type dependency. !!! note Either `create` or `async_create` must be passed, but if only @@ -278,7 +284,7 @@ def get_type(self, dep_type: type[_T], /) -> TypeConfig[_T]: ------- TypeConfig[_T] Configuration which represents the procedures and metadata - for creating and destorying the type dependency. + for creating and destroying the type dependency. """ try: return self._type_index[dep_type] @@ -298,7 +304,7 @@ def get_type_by_name(self, name: str, /) -> TypeConfig[typing.Any]: ------- TypeConfig[_T] Configuration which represents the procedures and metadata - for creating and destorying the type dependency. + for creating and destroying the type dependency. """ try: return self._name_index[name] @@ -326,7 +332,7 @@ def get_config(self, config_id: str, /) -> type[_config.PluginConfig]: raise RuntimeError(f"Unknown config ID {config_id!r}") from None def _scan_libraries(self) -> None: - """Load config clases from installed libraries based on their entry points.""" + """Load config classes from installed libraries based on their entry points.""" if self._metadata_scanned: return From fa0e17fd14dd99acd18a26c1fc04a38f73938295 Mon Sep 17 00:00:00 2001 From: FasterSpeeding Date: Thu, 8 Aug 2024 07:46:47 +0100 Subject: [PATCH 11/27] Create manager.md --- docs/reference/manager.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/reference/manager.md diff --git a/docs/reference/manager.md b/docs/reference/manager.md new file mode 100644 index 00000000..242613e4 --- /dev/null +++ b/docs/reference/manager.md @@ -0,0 +1,3 @@ +# alluka.manager + +::: alluka.manager From 198b499259a82fc1e8879d0c14d775edef6d7045 Mon Sep 17 00:00:00 2001 From: FasterSpeeding Date: Thu, 8 Aug 2024 07:47:31 +0100 Subject: [PATCH 12/27] Update and rename manager.md to managed.md --- docs/reference/managed.md | 3 +++ docs/reference/manager.md | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 docs/reference/managed.md delete mode 100644 docs/reference/manager.md diff --git a/docs/reference/managed.md b/docs/reference/managed.md new file mode 100644 index 00000000..7a95d8a6 --- /dev/null +++ b/docs/reference/managed.md @@ -0,0 +1,3 @@ +# alluka.manages + +::: alluka.managed diff --git a/docs/reference/manager.md b/docs/reference/manager.md deleted file mode 100644 index 242613e4..00000000 --- a/docs/reference/manager.md +++ /dev/null @@ -1,3 +0,0 @@ -# alluka.manager - -::: alluka.manager From 7521521665d0f7eb3330321e966fbe84c797e413 Mon Sep 17 00:00:00 2001 From: FasterSpeeding Date: Thu, 8 Aug 2024 07:51:33 +0100 Subject: [PATCH 13/27] Update managed.md --- docs/reference/managed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/managed.md b/docs/reference/managed.md index 7a95d8a6..1474116d 100644 --- a/docs/reference/managed.md +++ b/docs/reference/managed.md @@ -1,3 +1,3 @@ -# alluka.manages +# alluka.managed ::: alluka.managed From c8c5517c582d00fb5e5c7c52c6f7715c093b1f85 Mon Sep 17 00:00:00 2001 From: "always-on-duty[bot]" <120557446+always-on-duty[bot]@users.noreply.github.com> Date: Thu, 8 Aug 2024 21:29:35 +0000 Subject: [PATCH 14/27] Reformat PR code --- alluka/managed/_index.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index 9ea53106..ea598c97 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -63,9 +63,7 @@ class TypeConfig(typing.Generic[_T]): """Represents the procedures and metadata for creating and destroying a type dependency.""" - __slots__ = ( - "async_cleanup", "async_create", "cleanup", "create", "dep_type", "dependencies", "name" - ) + __slots__ = ("async_cleanup", "async_create", "cleanup", "create", "dep_type", "dependencies", "name") async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] """Callback used to use to cleanup the dependency in an async runtime.""" From 726795ae7efa63f620c252b5be843df5cfe8adc1 Mon Sep 17 00:00:00 2001 From: Lucy Date: Thu, 8 Aug 2024 22:39:57 +0100 Subject: [PATCH 15/27] Small fixes --- alluka/managed/__init__.py | 8 +++++--- alluka/managed/_config.py | 6 ++++++ alluka/managed/_index.py | 6 ++++-- alluka/managed/_manager.py | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index fcf05a7f..1d6b0b5c 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -28,9 +28,11 @@ # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +"""Utility class for loading type dependencies based on configuration.""" + from __future__ import annotations -__all__: list[str] = ["register_config", "register_type"] +__all__: list[str] = ["ConfigFile", "PluginConfig", "Manager"] import typing from collections import abc as collections @@ -86,7 +88,7 @@ def __call__( Parameters ---------- -config_cls: type[PluginConfig] +config_cls : type[PluginConfig] The plugin configuration class to register. Raises @@ -105,7 +107,7 @@ def __call__( Parameters ---------- -dep_type: type[T] +dep_type : type[T] Type of the dep this should be registered for. name : str Name used to identify this type dependency in configuration files. diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index c0485aca..5ac652e6 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -87,11 +87,17 @@ def _parse_config(key: _DictKeyT, config: _DictValueT, /) -> PluginConfig: class ConfigFile(typing.NamedTuple): + """Represents the configuration file used to configure Alluka's Manager.""" + plugins: collections.Sequence[PluginConfig] + """Sequence of the loaded plugin configurations.""" + load_types: collections.Sequence[str] + """Sequence of the IDs of type dependencies to load alongside configured plugin types.""" @classmethod def parse(cls, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> Self: + """Parse [ConfigFile][alluka.managed.ConfigFile] from a JSON style dictionary.""" raw_plugins = data["plugins"] if not isinstance(raw_plugins, collections.Mapping): raise TypeError(f"Expected a dictionary at `'plugins'`, found {type(raw_plugins)}") diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index ea598c97..5c2527ac 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -40,13 +40,13 @@ import typing import weakref from collections import abc as collections +from . import _config # pyright: ignore[reportPrivateUsage] if typing.TYPE_CHECKING: import types from .. import _types # pyright: ignore[reportPrivateUsage] from .. import abc as alluka - from . import _config # pyright: ignore[reportPrivateUsage] _LOGGER = logging.getLogger("alluka.managed") @@ -259,6 +259,8 @@ def get_descriptors( Parameters ---------- + callback + The callback to get the descriptors for. Returns ------- @@ -339,7 +341,7 @@ def _scan_libraries(self) -> None: entry_points = importlib.metadata.entry_points(group=_ENTRY_POINT_GROUP_NAME) else: - entry_points = importlib.metadata.entry_points()[_ENTRY_POINT_GROUP_NAME] + entry_points = importlib.metadata.entry_points().get(_ENTRY_POINT_GROUP_NAME) or [] for entry_point in entry_points: value = entry_point.load() diff --git a/alluka/managed/_manager.py b/alluka/managed/_manager.py index cf8cccdc..3b078e4f 100644 --- a/alluka/managed/_manager.py +++ b/alluka/managed/_manager.py @@ -245,7 +245,7 @@ def unload_deps(self) -> None: ) async def unload_deps_async(self) -> None: - """Unload the configured dependencies asynchronously + """Unload the configured dependencies asynchronously. Raises ------ From eb3ee165058374bd6ce3d2c7d75592bea6d4202b Mon Sep 17 00:00:00 2001 From: "always-on-duty[bot]" <120557446+always-on-duty[bot]@users.noreply.github.com> Date: Thu, 8 Aug 2024 21:40:45 +0000 Subject: [PATCH 16/27] Reformat PR code --- alluka/managed/__init__.py | 2 +- alluka/managed/_index.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index 1d6b0b5c..57f57df4 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -32,7 +32,7 @@ from __future__ import annotations -__all__: list[str] = ["ConfigFile", "PluginConfig", "Manager"] +__all__: list[str] = ["ConfigFile", "Manager", "PluginConfig"] import typing from collections import abc as collections diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index 5c2527ac..2c1fc12f 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -40,6 +40,7 @@ import typing import weakref from collections import abc as collections + from . import _config # pyright: ignore[reportPrivateUsage] if typing.TYPE_CHECKING: From 02b478cac5cfc63ed932ecfc4686417fd97815db Mon Sep 17 00:00:00 2001 From: Lucy Date: Thu, 8 Aug 2024 22:43:06 +0100 Subject: [PATCH 17/27] Linting fixes --- alluka/managed/__init__.py | 5 +++-- alluka/managed/_manager.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index 57f57df4..43affcc5 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -35,14 +35,15 @@ __all__: list[str] = ["ConfigFile", "Manager", "PluginConfig"] import typing -from collections import abc as collections from . import _index from ._config import ConfigFile as ConfigFile -from ._config import PluginConfig as PluginConfig +from ._config import PluginConfig as PluginConfig # noqa: TC002 from ._manager import Manager as Manager if typing.TYPE_CHECKING: + from collections import abc as collections + _T = typing.TypeVar("_T") _CoroT = collections.Coroutine[typing.Any, typing.Any, _T] diff --git a/alluka/managed/_manager.py b/alluka/managed/_manager.py index 3b078e4f..a50aaf01 100644 --- a/alluka/managed/_manager.py +++ b/alluka/managed/_manager.py @@ -187,7 +187,7 @@ def load_deps(self) -> None: else: _LOGGER.warn("Type dependency %r skipped as it can only be created in an async context", type_info.name) - async def load_deps_async(self) -> None: + async def load_deps_async(self) -> None: # noqa: ASYNC910 """Initialise the configured dependencies asynchronously. Raises @@ -244,7 +244,7 @@ def unload_deps(self) -> None: type_info.dep_type, ) - async def unload_deps_async(self) -> None: + async def unload_deps_async(self) -> None: # noqa: ASYNC910 """Unload the configured dependencies asynchronously. Raises From cfda5539d700641c5619114aca042ef48c826f00 Mon Sep 17 00:00:00 2001 From: Lucy Date: Sun, 11 Aug 2024 09:44:16 +0100 Subject: [PATCH 18/27] Introduce TypeConfig class for libraries --- alluka/managed/__init__.py | 65 ++------------- alluka/managed/_config.py | 149 +++++++++++++++++++++++++++++++++- alluka/managed/_index.py | 162 ++++++++----------------------------- alluka/managed/_manager.py | 7 +- 4 files changed, 191 insertions(+), 192 deletions(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index 43affcc5..2be4869d 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -44,39 +44,6 @@ if typing.TYPE_CHECKING: from collections import abc as collections - _T = typing.TypeVar("_T") - - _CoroT = collections.Coroutine[typing.Any, typing.Any, _T] - - class _RegiserTypeSig(typing.Protocol): - @typing.overload - def __call__( - self, - dep_type: type[_T], - name: str, - /, - *, - async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, - async_create: collections.Callable[..., _CoroT[_T]], - cleanup: typing.Optional[collections.Callable[[_T], None]] = None, - create: typing.Optional[collections.Callable[..., _T]] = None, - dependencies: collections.Sequence[type[typing.Any]] = (), - ) -> None: ... - - @typing.overload - def __call__( - self, - dep_type: type[_T], - name: str, - /, - *, - async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, - async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, - cleanup: typing.Optional[collections.Callable[[_T], None]] = None, - create: collections.Callable[..., _T], - dependencies: collections.Sequence[type[typing.Any]] = (), - ) -> None: ... - _GLOBAL_INDEX = _index.GLOBAL_INDEX @@ -84,7 +51,7 @@ def __call__( """Register a plugin configuration class. !!! warning - Libraries should register entry-points under the `"alluka.plugins"` group + Libraries should register entry-points under the `"alluka.managed"` group to register custom configuration classes. Parameters @@ -98,33 +65,15 @@ def __call__( If the configuration class' ID is already registered. """ -register_type: _RegiserTypeSig = _GLOBAL_INDEX.register_type +register_type: collections.Callable[[_config.TypeConfig[typing.Any]], None] = _GLOBAL_INDEX.register_type """Register the procedures for creating and destroying a type dependency. -!!! note - Either `create` or `async_create` must be passed, but if only - `async_create` is passed then this will fail to be created in - a synchronous runtime. +!!! warning + Libraries should register entry-points under the `"alluka.managed"` group + to register type procedure classes. Parameters ---------- -dep_type : type[T] - Type of the dep this should be registered for. -name : str - Name used to identify this type dependency in configuration files. -async_cleanup : collections.abc.Callable[[T], collections.abc.Coroutine[typing.Any, typing.Any, None]] | None - Callback used to use to destroy the dependency in an async runtime. -async_create : collections.abc.Callable[..., collections.abc.Coroutine[typing.Any, typing.Any, T]] | None - Callback used to use to create the dependency in an async runtime. -cleanup : collections.abc.Callable[[T], None] | None - Callback used to use to destroy the dependency in a sync runtime. -create : collections.abc.Callable[..., T] | None - Callback used to use to create the dependency in a sync runtime. -dependencies : collections.abc.Sequence[type[typing.Any]] - Sequence of type dependencies that are required to create this dependency. - -Raises ------- -TypeError - If neither `create` nor `async_create` is passed. +type_info + The type dependency's runtime procedures. """ diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index 5ac652e6..0f14d996 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -30,7 +30,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations -__all__: list[str] = ["ConfigFile", "PluginConfig"] +__all__: list[str] = ["ConfigFile", "PluginConfig", "TypeConfig"] import abc import typing @@ -39,15 +39,147 @@ if typing.TYPE_CHECKING: from typing_extensions import Self + _CoroT = collections.Coroutine[typing.Any, typing.Any, "_T"] + +_T = typing.TypeVar("_T") _DictKeyT = typing.Union[str, int, float, bool, None] _DictValueT = typing.Union[ collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT ] +class TypeConfig(typing.Generic[_T]): + """Base class used for to declare the creation logic used by Alluka's manager for type dependencies. + + Libraries should use entry points in the group "alluka.managed" to register + these. + """ + + __slots__ = ("_async_cleanup", "_async_create", "_cleanup", "_create", "_dep_type", "_dependencies", "_name") + + @typing.overload + def __init__( + self, + dep_type: type[_T], + name: str, + /, + *, + async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, + async_create: collections.Callable[..., _CoroT[_T]], + cleanup: typing.Optional[collections.Callable[[_T], None]] = None, + create: typing.Optional[collections.Callable[..., _T]] = None, + dependencies: collections.Sequence[type[typing.Any]] = (), + ) -> None: ... + + @typing.overload + def __init__( + self, + dep_type: type[_T], + name: str, + /, + *, + async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, + async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, + cleanup: typing.Optional[collections.Callable[[_T], None]] = None, + create: collections.Callable[..., _T], + dependencies: collections.Sequence[type[typing.Any]] = (), + ) -> None: ... + + def __init__( + self, + dep_type: type[_T], + name: str, + /, + *, + async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, + async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, + cleanup: typing.Optional[collections.Callable[[_T], None]] = None, + create: typing.Optional[collections.Callable[..., _T]] = None, + dependencies: collections.Sequence[type[typing.Any]] = (), + ) -> None: + """Initialise a type config. + + !!! note + Either `create` or `async_create` must be passed, but if only + `async_create` is passed then this will fail to be created in + a synchronous runtime. + + Parameters + ---------- + dep_type + Type of the dep this should be registered for. + name + Name used to identify this type dependency in configuration files. + async_cleanup + Callback used to use to destroy the dependency in an async runtime. + async_create + Callback used to use to create the dependency in an async runtime. + cleanup + Callback used to use to destroy the dependency in a sync runtime. + create + Callback used to use to create the dependency in a sync runtime. + dependencies + Sequence of type dependencies that are required to create this dependency. + + Raises + ------ + TypeError + If neither `create` nor `async_create` is passed. + """ + if not create and not async_create: + raise TypeError("Either `create` or `async_create` must be passed") + + self._async_cleanup = async_cleanup + self._async_create = async_create + self._cleanup = cleanup + self._create = create + self._dep_type = dep_type + self._dependencies = dependencies + self._name = name + + @property + def async_cleanup(self) -> typing.Optional[collections.Callable[[_T], _CoroT[None]]]: + """Callback used to use to cleanup the dependency in an async runtime.""" + return self._async_cleanup + + @property + def async_create(self) -> typing.Optional[collections.Callable[..., _CoroT[_T]]]: + """Callback used to use to create the dependency in an async runtime.""" + return self._async_create + + @property + def cleanup(self) -> typing.Optional[collections.Callable[[_T], None]]: + """Callback used to use to cleanup the dependency in a sync runtime.""" + return self._cleanup + + @property + def create(self) -> typing.Optional[collections.Callable[..., _T]]: + """Callback used to use to create the dependency in an async runtime.""" + return self._create + + @property + def dep_type(self) -> type[_T]: + """The type created values should be registered as a type dependency for.""" + return self._dep_type + + @property + def dependencies(self) -> collections.Sequence[type[typing.Any]]: + """Sequence of type dependencies that are required to create this dependency.""" + return self._dependencies + + @property + def name(self) -> str: + """Name used to identify this type dependency in configuration files.""" + return self._name + + class PluginConfig(abc.ABC): - """Base class used for configuring plugins loaded via Alluka's manager.""" + """Base class used for configuring plugins loaded via Alluka's manager. + + Libraries should use entry points in the group "alluka.managed" to register + these. + """ __slots__ = () @@ -97,7 +229,18 @@ class ConfigFile(typing.NamedTuple): @classmethod def parse(cls, data: collections.Mapping[_DictKeyT, _DictValueT], /) -> Self: - """Parse [ConfigFile][alluka.managed.ConfigFile] from a JSON style dictionary.""" + """Parse [ConfigFile][alluka.managed.ConfigFile] from a JSON style dictionary. + + Parameters + ---------- + data + The mapping of data to parse. + + Returns + ------- + ConfigFile + The parsed configuration. + """ raw_plugins = data["plugins"] if not isinstance(raw_plugins, collections.Mapping): raise TypeError(f"Expected a dictionary at `'plugins'`, found {type(raw_plugins)}") diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index 2c1fc12f..915a6ffc 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -30,9 +30,8 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations -__all__: list[str] = ["Index", "TypeConfig"] +__all__: list[str] = ["Index"] -import dataclasses import importlib.metadata import logging import sys @@ -53,42 +52,13 @@ _LOGGER = logging.getLogger("alluka.managed") _T = typing.TypeVar("_T") -_CoroT = collections.Coroutine[typing.Any, typing.Any, _T] _DictKeyT = typing.Union[str, int, float, bool, None] _DictValueT = typing.Union[ collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT ] -@dataclasses.dataclass(frozen=True) -class TypeConfig(typing.Generic[_T]): - """Represents the procedures and metadata for creating and destroying a type dependency.""" - - __slots__ = ("async_cleanup", "async_create", "cleanup", "create", "dep_type", "dependencies", "name") - - async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] - """Callback used to use to cleanup the dependency in an async runtime.""" - - async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] - """Callback used to use to create the dependency in an async runtime.""" - - cleanup: typing.Optional[collections.Callable[[_T], None]] - """Callback used to use to cleanup the dependency in a sync runtime.""" - - create: typing.Optional[collections.Callable[..., _T]] - """Callback used to use to create the dependency in an async runtime.""" - - dep_type: type[_T] - """The type created values should be registered as a type dependency for.""" - - dependencies: collections.Sequence[type[typing.Any]] - """Sequence of type dependencies that are required to create this dependency.""" - - name: str - """Name used to identify this type dependency in configuration files.""" - - -_ENTRY_POINT_GROUP_NAME = "alluka.plugins" +_ENTRY_POINT_GROUP_NAME = "alluka.managed" class Index: @@ -110,8 +80,8 @@ def __init__(self) -> None: self._config_index: dict[str, type[_config.PluginConfig]] = {} self._lock = threading.Lock() self._metadata_scanned = False - self._name_index: dict[str, TypeConfig[typing.Any]] = {} - self._type_index: dict[type[typing.Any], TypeConfig[typing.Any]] = {} + self._name_index: dict[str, _config.TypeConfig[typing.Any]] = {} + self._type_index: dict[type[typing.Any], _config.TypeConfig[typing.Any]] = {} self._scan_libraries() def __enter__(self) -> None: @@ -129,7 +99,7 @@ def register_config(self, config_cls: type[_config.PluginConfig], /) -> None: """Register a plugin configuration class. !!! warning - Libraries should register entry-points under the `"alluka.plugins"` group + Libraries should register entry-points under the `"alluka.managed"` group to register custom configuration classes. Parameters @@ -149,95 +119,25 @@ def register_config(self, config_cls: type[_config.PluginConfig], /) -> None: self._config_index[config_id] = config_cls - @typing.overload - def register_type( - self, - dep_type: type[_T], - name: str, - /, - *, - async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, - async_create: collections.Callable[..., _CoroT[_T]], - cleanup: typing.Optional[collections.Callable[[_T], None]] = None, - create: typing.Optional[collections.Callable[..., _T]] = None, - dependencies: collections.Sequence[type[typing.Any]] = (), - ) -> None: ... - - @typing.overload - def register_type( - self, - dep_type: type[_T], - name: str, - /, - *, - async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, - async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, - cleanup: typing.Optional[collections.Callable[[_T], None]] = None, - create: collections.Callable[..., _T], - dependencies: collections.Sequence[type[typing.Any]] = (), - ) -> None: ... - - def register_type( - self, - dep_type: type[_T], - name: str, - /, - *, - async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, - async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, - cleanup: typing.Optional[collections.Callable[[_T], None]] = None, - create: typing.Optional[collections.Callable[..., _T]] = None, - dependencies: collections.Sequence[type[typing.Any]] = (), - ) -> None: + def register_type(self, type_info: _config.TypeConfig[typing.Any], /) -> None: """Register the procedures for creating and destroying a type dependency. - !!! note - Either `create` or `async_create` must be passed, but if only - `async_create` is passed then this will fail to be created in - a synchronous runtime. + !!! warning + Libraries should register entry-points under the `"alluka.managed"` group + to register type procedure classes. Parameters ---------- - dep_type - Type of the dep this should be registered for. - name - Name used to identify this type dependency in configuration files. - async_cleanup - Callback used to use to destroy the dependency in an async runtime. - async_create - Callback used to use to create the dependency in an async runtime. - cleanup - Callback used to use to destroy the dependency in a sync runtime. - create - Callback used to use to create the dependency in a sync runtime. - dependencies - Sequence of type dependencies that are required to create this dependency. - - Raises - ------ - TypeError - If neither `create` nor `async_create` is passed. + type_info + The type dependency's runtime procedures. """ - if not create and not async_create: - raise TypeError("Either create or async_create has to be passed") - - config = TypeConfig( - async_cleanup=async_cleanup, - async_create=async_create, - cleanup=cleanup, - create=create, - dep_type=dep_type, - dependencies=dependencies, - name=name, - ) + if type_info.dep_type in self._type_index: + raise RuntimeError(f"Dependency type `{type_info.dep_type}` already registered") - if config.dep_type in self._type_index: - raise RuntimeError(f"Dependency type `{config.dep_type}` already registered") + if type_info.name in self._name_index: + raise RuntimeError(f"Dependency name {type_info.name!r} already registered") - if config.name in self._name_index: - raise RuntimeError(f"Dependency name {config.name!r} already registered") - - self._type_index[config.dep_type] = self._name_index[config.name] = config + self._type_index[type_info.dep_type] = self._name_index[type_info.name] = type_info def set_descriptors( self, callback: alluka.CallbackSig[typing.Any], descriptors: dict[str, _types.InjectedTuple], / @@ -273,7 +173,7 @@ def get_descriptors( """ return self._descriptors.get(callback) - def get_type(self, dep_type: type[_T], /) -> TypeConfig[_T]: + def get_type(self, dep_type: type[_T], /) -> _config.TypeConfig[_T]: """Get the configuration for a type dependency. Parameters @@ -293,7 +193,7 @@ def get_type(self, dep_type: type[_T], /) -> TypeConfig[_T]: except KeyError: raise RuntimeError(f"Unknown dependency type {dep_type}") from None - def get_type_by_name(self, name: str, /) -> TypeConfig[typing.Any]: + def get_type_by_name(self, name: str, /) -> _config.TypeConfig[typing.Any]: """Get the configuration for a type dependency by its configured name. Parameters @@ -346,16 +246,24 @@ def _scan_libraries(self) -> None: for entry_point in entry_points: value = entry_point.load() - if isinstance(value, type) and issubclass(value, _config.PluginConfig): - self.register_config(value) + if not isinstance(value, type): + pass - else: - _LOGGER.warn( - "Unexpected value found at %, expected a PluginConfig class but found %r. " - "An alluka entry point is misconfigured.", - entry_point.value, - value, - ) + elif issubclass(value, _config.PluginConfig): + _LOGGER.debug("Registered PluginConfig from %r", entry_point) + self.register_config(value) + continue + + elif issubclass(value, _config.TypeConfig): + _LOGGER.debug("Registering TypeConfig from %r", entry_point) + continue + + _LOGGER.warn( + "Unexpected value found at %, expected a PluginConfig class but found %r. " + "An alluka entry point is misconfigured.", + entry_point.value, + value, + ) GLOBAL_INDEX = Index() diff --git a/alluka/managed/_manager.py b/alluka/managed/_manager.py index a50aaf01..c431488b 100644 --- a/alluka/managed/_manager.py +++ b/alluka/managed/_manager.py @@ -59,7 +59,6 @@ _PARSERS: dict[str, collections.Callable[[typing.BinaryIO], collections.Mapping[_DictKeyT, _DictValueT]]] = { "json": json.load } -_T = typing.TypeVar("_T") try: @@ -91,7 +90,7 @@ def __init__(self, client: abc.Client, /) -> None: self._client = client self._is_loaded = False self._load_configs: list[_config.PluginConfig] = [] - self._load_types: dict[type[typing.Any], _index.TypeConfig[typing.Any]] = {} + self._load_types: dict[type[typing.Any], _config.TypeConfig[typing.Any]] = {} self._processed_callbacks: weakref.WeakSet[collections.Callable[..., typing.Any]] = weakref.WeakSet() def load_config(self, config: typing.Union[pathlib.Path, _config.ConfigFile], /) -> Self: @@ -145,7 +144,7 @@ def load_config(self, config: typing.Union[pathlib.Path, _config.ConfigFile], /) def _to_resolvers( self, type_id: typing.Union[str, type[typing.Any]], /, *, mimo: typing.Optional[set[type[typing.Any]]] = None - ) -> collections.Iterator[_index.TypeConfig[typing.Any]]: + ) -> collections.Iterator[_config.TypeConfig[typing.Any]]: if mimo is None: mimo = set() @@ -204,7 +203,7 @@ async def load_deps_async(self) -> None: # noqa: ASYNC910 value = await self._client.call_with_async_di(callback) self._client.set_type_dependency(type_info.dep_type, value) - def _iter_unload(self) -> collections.Iterator[tuple[_index.TypeConfig[typing.Any], typing.Any]]: + def _iter_unload(self) -> collections.Iterator[tuple[_config.TypeConfig[typing.Any], typing.Any]]: if not self._is_loaded: raise RuntimeError("Dependencies not loaded") From 783fcecc2f590116d399b8858e0b17de4ccf22b1 Mon Sep 17 00:00:00 2001 From: Lucy Date: Sun, 11 Aug 2024 14:18:40 +0100 Subject: [PATCH 19/27] Set Manager as a type dep --- alluka/managed/_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alluka/managed/_manager.py b/alluka/managed/_manager.py index c431488b..e6e17bf9 100644 --- a/alluka/managed/_manager.py +++ b/alluka/managed/_manager.py @@ -87,7 +87,7 @@ def __init__(self, client: abc.Client, /) -> None: client The alluka client to bind this to. """ - self._client = client + self._client = client.set_type_dependency(Manager, self) self._is_loaded = False self._load_configs: list[_config.PluginConfig] = [] self._load_types: dict[type[typing.Any], _config.TypeConfig[typing.Any]] = {} From d5134215c7553f980f4aef4a9dec3ccae7bf90d7 Mon Sep 17 00:00:00 2001 From: Lucy Date: Sun, 11 Aug 2024 16:21:20 +0100 Subject: [PATCH 20/27] Add decorator utility methods --- alluka/managed/__init__.py | 3 +- alluka/managed/_config.py | 112 +++++++++++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 13 deletions(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index 2be4869d..5e526bc4 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -32,13 +32,14 @@ from __future__ import annotations -__all__: list[str] = ["ConfigFile", "Manager", "PluginConfig"] +__all__: list[str] = ["ConfigFile", "Manager", "PluginConfig", "TypeConfig"] import typing from . import _index from ._config import ConfigFile as ConfigFile from ._config import PluginConfig as PluginConfig # noqa: TC002 +from ._config import TypeConfig as TypeConfig from ._manager import Manager as Manager if typing.TYPE_CHECKING: diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index 0f14d996..d55d85c6 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -37,12 +37,15 @@ from collections import abc as collections if typing.TYPE_CHECKING: + import typing_extensions from typing_extensions import Self + _P = typing_extensions.ParamSpec("_P") _CoroT = collections.Coroutine[typing.Any, typing.Any, "_T"] _T = typing.TypeVar("_T") +_OtherT = typing.TypeVar("_OtherT") _DictKeyT = typing.Union[str, int, float, bool, None] _DictValueT = typing.Union[ collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT @@ -56,7 +59,7 @@ class TypeConfig(typing.Generic[_T]): these. """ - __slots__ = ("_async_cleanup", "_async_create", "_cleanup", "_create", "_dep_type", "_dependencies", "_name") + __slots__ = ("_async_cleanup", "_async_create", "_cleanup", "_create", "_dep_type", "_name") @typing.overload def __init__( @@ -69,7 +72,6 @@ def __init__( async_create: collections.Callable[..., _CoroT[_T]], cleanup: typing.Optional[collections.Callable[[_T], None]] = None, create: typing.Optional[collections.Callable[..., _T]] = None, - dependencies: collections.Sequence[type[typing.Any]] = (), ) -> None: ... @typing.overload @@ -83,7 +85,6 @@ def __init__( async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, cleanup: typing.Optional[collections.Callable[[_T], None]] = None, create: collections.Callable[..., _T], - dependencies: collections.Sequence[type[typing.Any]] = (), ) -> None: ... def __init__( @@ -96,7 +97,6 @@ def __init__( async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, cleanup: typing.Optional[collections.Callable[[_T], None]] = None, create: typing.Optional[collections.Callable[..., _T]] = None, - dependencies: collections.Sequence[type[typing.Any]] = (), ) -> None: """Initialise a type config. @@ -119,8 +119,6 @@ def __init__( Callback used to use to destroy the dependency in a sync runtime. create Callback used to use to create the dependency in a sync runtime. - dependencies - Sequence of type dependencies that are required to create this dependency. Raises ------ @@ -135,9 +133,56 @@ def __init__( self._cleanup = cleanup self._create = create self._dep_type = dep_type - self._dependencies = dependencies self._name = name + @classmethod + def from_create( + cls, dep_type: type[_OtherT], name: str, / + ) -> collections.Callable[[collections.Callable[..., _OtherT]], TypeConfig[_OtherT]]: + """Initialise a type config by decorating a sync create callback. + + Parameters + ---------- + dep_type + Type of the dep this should be registered for. + name + Name used to identify this type dependency in configuration files. + + Returns + ------- + TypeConfig + The created type config. + """ + + def decorator(callback: collections.Callable[..., _OtherT], /) -> TypeConfig[_OtherT]: + return cls(dep_type, name, create=callback) + + return decorator + + @classmethod + def from_async_create( + cls, dep_type: type[_OtherT], name: str, / + ) -> collections.Callable[[collections.Callable[..., _CoroT[_OtherT]]], TypeConfig[_OtherT]]: + """Initialise a type config by decorating an async create callback. + + Parameters + ---------- + dep_type + Type of the dep this should be registered for. + name + Name used to identify this type dependency in configuration files. + + Returns + ------- + TypeConfig + The created type config. + """ + + def decorator(callback: collections.Callable[..., _CoroT[_OtherT]], /) -> TypeConfig[_OtherT]: + return cls(dep_type, name, async_create=callback) + + return decorator + @property def async_cleanup(self) -> typing.Optional[collections.Callable[[_T], _CoroT[None]]]: """Callback used to use to cleanup the dependency in an async runtime.""" @@ -163,16 +208,59 @@ def dep_type(self) -> type[_T]: """The type created values should be registered as a type dependency for.""" return self._dep_type - @property - def dependencies(self) -> collections.Sequence[type[typing.Any]]: - """Sequence of type dependencies that are required to create this dependency.""" - return self._dependencies - @property def name(self) -> str: """Name used to identify this type dependency in configuration files.""" return self._name + def with_create(self, callback: collections.Callable[_P, _T], /) -> collections.Callable[_P, _T]: + """Set the synchronous create callback through a decorator call. + + Parameters + ---------- + callback + The callback to set as the synchronous create callback. + """ + self._create = callback + return callback + + def with_async_create( + self, callback: collections.Callable[_P, _CoroT[_T]], / + ) -> collections.Callable[_P, _CoroT[_T]]: + """Set the asynchronous create callback through a decorator call. + + Parameters + ---------- + callback + The callback to set as the asynchronous create callback. + """ + self._async_create = callback + return callback + + def with_cleanup(self, callback: collections.Callable[[_T], None], /) -> collections.Callable[[_T], None]: + """Set the synchronous cleanup callback through a decorator call. + + Parameters + ---------- + callback + The callback to set as the asynchronous cleanup callback. + """ + self._cleanup = callback + return callback + + def with_async_cleanup( + self, callback: collections.Callable[[_T], _CoroT[None]], / + ) -> collections.Callable[[_T], _CoroT[None]]: + """Set the asynchronous cleanup callback through a decorator call. + + Parameters + ---------- + callback + The callback to set as the synchronous cleanup callback. + """ + self._async_cleanup = callback + return callback + class PluginConfig(abc.ABC): """Base class used for configuring plugins loaded via Alluka's manager. From 6d01c50ec65af8a82c9f5464dcd19179f488fee2 Mon Sep 17 00:00:00 2001 From: Lucy Date: Sun, 11 Aug 2024 16:27:51 +0100 Subject: [PATCH 21/27] Small doc improvements --- alluka/managed/__init__.py | 10 +++++----- alluka/managed/_config.py | 8 ++++---- alluka/managed/_index.py | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index 5e526bc4..c345a7fb 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -52,8 +52,8 @@ """Register a plugin configuration class. !!! warning - Libraries should register entry-points under the `"alluka.managed"` group - to register custom configuration classes. + Libraries should register custom configuration classes using package + entry-points tagged with the `"alluka.managed"` group. Parameters ---------- @@ -70,11 +70,11 @@ """Register the procedures for creating and destroying a type dependency. !!! warning - Libraries should register entry-points under the `"alluka.managed"` group - to register type procedure classes. + Libraries should register custom type procedure objects using package + entry-points tagged with the `"alluka.managed"` group. Parameters ---------- -type_info +type_info : yuyo.managed.TypeConfig[typing.Any] The type dependency's runtime procedures. """ diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index d55d85c6..ea6fa1d0 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -55,8 +55,8 @@ class TypeConfig(typing.Generic[_T]): """Base class used for to declare the creation logic used by Alluka's manager for type dependencies. - Libraries should use entry points in the group "alluka.managed" to register - these. + Libraries should register custom type procedure objects using package + entry-points tagged with the `"alluka.managed"` group. """ __slots__ = ("_async_cleanup", "_async_create", "_cleanup", "_create", "_dep_type", "_name") @@ -265,8 +265,8 @@ def with_async_cleanup( class PluginConfig(abc.ABC): """Base class used for configuring plugins loaded via Alluka's manager. - Libraries should use entry points in the group "alluka.managed" to register - these. + Libraries should register custom configuration classes using package + entry-points tagged with the `"alluka.managed"` group. """ __slots__ = () diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index 915a6ffc..fcdfe51e 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -99,8 +99,8 @@ def register_config(self, config_cls: type[_config.PluginConfig], /) -> None: """Register a plugin configuration class. !!! warning - Libraries should register entry-points under the `"alluka.managed"` group - to register custom configuration classes. + Libraries should register custom configuration classes using package + entry-points tagged with the `"alluka.managed"` group. Parameters ---------- @@ -123,8 +123,8 @@ def register_type(self, type_info: _config.TypeConfig[typing.Any], /) -> None: """Register the procedures for creating and destroying a type dependency. !!! warning - Libraries should register entry-points under the `"alluka.managed"` group - to register type procedure classes. + Libraries should register custom type procedure objects using package + entry-points tagged with the `"alluka.managed"` group. Parameters ---------- From b9afb888fb8b5918a9293fef01d409e7830f0d34 Mon Sep 17 00:00:00 2001 From: Lucy Date: Sun, 11 Aug 2024 17:54:23 +0100 Subject: [PATCH 22/27] Fixes --- alluka/managed/__init__.py | 2 +- alluka/managed/_config.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index c345a7fb..196cc498 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -66,7 +66,7 @@ If the configuration class' ID is already registered. """ -register_type: collections.Callable[[_config.TypeConfig[typing.Any]], None] = _GLOBAL_INDEX.register_type +register_type: collections.Callable[[TypeConfig[typing.Any]], None] = _GLOBAL_INDEX.register_type """Register the procedures for creating and destroying a type dependency. !!! warning diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index ea6fa1d0..4f4fba55 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -155,7 +155,7 @@ def from_create( """ def decorator(callback: collections.Callable[..., _OtherT], /) -> TypeConfig[_OtherT]: - return cls(dep_type, name, create=callback) + return TypeConfig(dep_type, name, create=callback) return decorator @@ -179,7 +179,7 @@ def from_async_create( """ def decorator(callback: collections.Callable[..., _CoroT[_OtherT]], /) -> TypeConfig[_OtherT]: - return cls(dep_type, name, async_create=callback) + return TypeConfig(dep_type, name, async_create=callback) return decorator From ef6788651b8f60b49b36c3f0d1acb0969f6a77cc Mon Sep 17 00:00:00 2001 From: Lucy Date: Tue, 13 Aug 2024 11:44:20 +0100 Subject: [PATCH 23/27] Some small improvementsw --- alluka/managed/__init__.py | 2 +- alluka/managed/_config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/alluka/managed/__init__.py b/alluka/managed/__init__.py index 196cc498..4ca3d528 100644 --- a/alluka/managed/__init__.py +++ b/alluka/managed/__init__.py @@ -39,7 +39,7 @@ from . import _index from ._config import ConfigFile as ConfigFile from ._config import PluginConfig as PluginConfig # noqa: TC002 -from ._config import TypeConfig as TypeConfig +from ._config import TypeConfig as TypeConfig # noqa: TC002 from ._manager import Manager as Manager if typing.TYPE_CHECKING: diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index 4f4fba55..b8162ec0 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -272,7 +272,7 @@ class PluginConfig(abc.ABC): __slots__ = () @classmethod - def config_types(cls) -> collections.Sequence[type[PluginConfig]]: + def config_types(cls) -> collections.Sequence[type[typing.Any]]: """The types to use when registering this configuration as a type dependency.""" return [cls] From c83f986b4c710ce0595d445e356bc61538372b65 Mon Sep 17 00:00:00 2001 From: Faster Speeding Date: Mon, 18 Nov 2024 23:58:01 +0000 Subject: [PATCH 24/27] post-rebase fixes --- alluka/_types.py | 8 ++++---- alluka/managed/_config.py | 39 ++++++++++++++++++-------------------- alluka/managed/_index.py | 12 ++++-------- alluka/managed/_manager.py | 30 +++++++++++------------------ 4 files changed, 37 insertions(+), 52 deletions(-) diff --git a/alluka/_types.py b/alluka/_types.py index f6db2043..b2ea4499 100644 --- a/alluka/_types.py +++ b/alluka/_types.py @@ -198,10 +198,10 @@ class InjectedTypes(int, enum.Enum): """ -InjectedTuple = typing.Union[ - tuple[typing.Literal[InjectedTypes.CALLBACK], InjectedCallback], - tuple[typing.Literal[InjectedTypes.TYPE], InjectedType], -] +InjectedTuple = ( + tuple[typing.Literal[InjectedTypes.CALLBACK], InjectedCallback] + | tuple[typing.Literal[InjectedTypes.TYPE], InjectedType] +) """Type of the tuple used to describe an injected value.""" _TypeT = type[_T] diff --git a/alluka/managed/_config.py b/alluka/managed/_config.py index b8162ec0..21c7a215 100644 --- a/alluka/managed/_config.py +++ b/alluka/managed/_config.py @@ -37,19 +37,16 @@ from collections import abc as collections if typing.TYPE_CHECKING: - import typing_extensions - from typing_extensions import Self + from typing import Self - _P = typing_extensions.ParamSpec("_P") + _P = typing.ParamSpec("_P") _CoroT = collections.Coroutine[typing.Any, typing.Any, "_T"] _T = typing.TypeVar("_T") _OtherT = typing.TypeVar("_OtherT") -_DictKeyT = typing.Union[str, int, float, bool, None] -_DictValueT = typing.Union[ - collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT -] +_DictKeyT = str | int | float | bool | None +_DictValueT = collections.Mapping[_DictKeyT, "_DictValueT"] | collections.Sequence["_DictValueT"] | _DictKeyT class TypeConfig(typing.Generic[_T]): @@ -68,10 +65,10 @@ def __init__( name: str, /, *, - async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, + async_cleanup: collections.Callable[[_T], _CoroT[None]] | None = None, async_create: collections.Callable[..., _CoroT[_T]], - cleanup: typing.Optional[collections.Callable[[_T], None]] = None, - create: typing.Optional[collections.Callable[..., _T]] = None, + cleanup: collections.Callable[[_T], None] | None = None, + create: collections.Callable[..., _T] | None = None, ) -> None: ... @typing.overload @@ -81,9 +78,9 @@ def __init__( name: str, /, *, - async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, - async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, - cleanup: typing.Optional[collections.Callable[[_T], None]] = None, + async_cleanup: collections.Callable[[_T], _CoroT[None]] | None = None, + async_create: collections.Callable[..., _CoroT[_T]] | None = None, + cleanup: collections.Callable[[_T], None] | None = None, create: collections.Callable[..., _T], ) -> None: ... @@ -93,10 +90,10 @@ def __init__( name: str, /, *, - async_cleanup: typing.Optional[collections.Callable[[_T], _CoroT[None]]] = None, - async_create: typing.Optional[collections.Callable[..., _CoroT[_T]]] = None, - cleanup: typing.Optional[collections.Callable[[_T], None]] = None, - create: typing.Optional[collections.Callable[..., _T]] = None, + async_cleanup: collections.Callable[[_T], _CoroT[None]] | None = None, + async_create: collections.Callable[..., _CoroT[_T]] | None = None, + cleanup: collections.Callable[[_T], None] | None = None, + create: collections.Callable[..., _T] | None = None, ) -> None: """Initialise a type config. @@ -184,22 +181,22 @@ def decorator(callback: collections.Callable[..., _CoroT[_OtherT]], /) -> TypeCo return decorator @property - def async_cleanup(self) -> typing.Optional[collections.Callable[[_T], _CoroT[None]]]: + def async_cleanup(self) -> collections.Callable[[_T], _CoroT[None]] | None: """Callback used to use to cleanup the dependency in an async runtime.""" return self._async_cleanup @property - def async_create(self) -> typing.Optional[collections.Callable[..., _CoroT[_T]]]: + def async_create(self) -> collections.Callable[..., _CoroT[_T]] | None: """Callback used to use to create the dependency in an async runtime.""" return self._async_create @property - def cleanup(self) -> typing.Optional[collections.Callable[[_T], None]]: + def cleanup(self) -> collections.Callable[[_T], None] | None: """Callback used to use to cleanup the dependency in a sync runtime.""" return self._cleanup @property - def create(self) -> typing.Optional[collections.Callable[..., _T]]: + def create(self) -> collections.Callable[..., _T] | None: """Callback used to use to create the dependency in an async runtime.""" return self._create diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index fcdfe51e..2c6574b8 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -52,10 +52,8 @@ _LOGGER = logging.getLogger("alluka.managed") _T = typing.TypeVar("_T") -_DictKeyT = typing.Union[str, int, float, bool, None] -_DictValueT = typing.Union[ - collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT -] +_DictKeyT = str | int | float | bool | None +_DictValueT = collections.Mapping[_DictKeyT, "_DictValueT"] | collections.Sequence["_DictValueT"] | _DictKeyT _ENTRY_POINT_GROUP_NAME = "alluka.managed" @@ -153,9 +151,7 @@ def set_descriptors( """ self._descriptors[callback] = descriptors - def get_descriptors( - self, callback: alluka.CallbackSig[typing.Any], / - ) -> typing.Optional[dict[str, _types.InjectedTuple]]: + def get_descriptors(self, callback: alluka.CallbackSig[typing.Any], /) -> dict[str, _types.InjectedTuple] | None: """Get the dependency injection descriptors cached for a callback. Parameters @@ -258,7 +254,7 @@ def _scan_libraries(self) -> None: _LOGGER.debug("Registering TypeConfig from %r", entry_point) continue - _LOGGER.warn( + _LOGGER.warning( "Unexpected value found at %, expected a PluginConfig class but found %r. " "An alluka entry point is misconfigured.", entry_point.value, diff --git a/alluka/managed/_manager.py b/alluka/managed/_manager.py index e6e17bf9..fa5ea7d1 100644 --- a/alluka/managed/_manager.py +++ b/alluka/managed/_manager.py @@ -36,6 +36,7 @@ import json import logging import pathlib +import tomllib import typing import weakref from collections import abc as collections @@ -47,30 +48,19 @@ from . import _index if typing.TYPE_CHECKING: - from typing_extensions import Self + from typing import Self _LOGGER = logging.getLogger("alluka.managed") -_DictKeyT = typing.Union[str, int, float, bool, None] -_DictValueT = typing.Union[ - collections.Mapping[_DictKeyT, "_DictValueT"], collections.Sequence["_DictValueT"], _DictKeyT -] +_DictKeyT = str | int | float | bool | None +_DictValueT = collections.Mapping[_DictKeyT, "_DictValueT"] | collections.Sequence["_DictValueT"] | _DictKeyT _PARSERS: dict[str, collections.Callable[[typing.BinaryIO], collections.Mapping[_DictKeyT, _DictValueT]]] = { - "json": json.load + "json": json.load, + "toml": tomllib.load, } -try: - import tomllib # pyright: ignore[reportMissingImports] - -except ModuleNotFoundError: - pass - -else: - _PARSERS["toml"] = tomllib.load # type: ignore - - class Manager: """A type dependency lifetime manager implementation. @@ -93,7 +83,7 @@ def __init__(self, client: abc.Client, /) -> None: self._load_types: dict[type[typing.Any], _config.TypeConfig[typing.Any]] = {} self._processed_callbacks: weakref.WeakSet[collections.Callable[..., typing.Any]] = weakref.WeakSet() - def load_config(self, config: typing.Union[pathlib.Path, _config.ConfigFile], /) -> Self: + def load_config(self, config: pathlib.Path | _config.ConfigFile, /) -> Self: """Load plugin and dependency configuration into this manager. Parameters @@ -143,7 +133,7 @@ def load_config(self, config: typing.Union[pathlib.Path, _config.ConfigFile], /) return self def _to_resolvers( - self, type_id: typing.Union[str, type[typing.Any]], /, *, mimo: typing.Optional[set[type[typing.Any]]] = None + self, type_id: str | type[typing.Any], /, *, mimo: set[type[typing.Any]] | None = None ) -> collections.Iterator[_config.TypeConfig[typing.Any]]: if mimo is None: mimo = set() @@ -184,7 +174,9 @@ def load_deps(self) -> None: self._client.set_type_dependency(type_info.dep_type, value) else: - _LOGGER.warn("Type dependency %r skipped as it can only be created in an async context", type_info.name) + _LOGGER.warning( + "Type dependency %r skipped as it can only be created in an async context", type_info.name + ) async def load_deps_async(self) -> None: # noqa: ASYNC910 """Initialise the configured dependencies asynchronously. From 4258316f21dc7dd18427fdb9877fcc869297306d Mon Sep 17 00:00:00 2001 From: Faster Speeding Date: Sun, 24 Nov 2024 05:23:39 +0000 Subject: [PATCH 25/27] Remove locking and u[grade to 3.11+ --- alluka/managed/_index.py | 28 ++-------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index 2c6574b8..c893d100 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -34,7 +34,6 @@ import importlib.metadata import logging -import sys import threading import typing import weakref @@ -65,7 +64,7 @@ class Index: This is used by the manager to parse plugin configuration and initialise types. """ - __slots__ = ("_descriptors", "_config_index", "_lock", "_metadata_scanned", "_name_index", "_type_index") + __slots__ = ("_descriptors", "_config_index", "_name_index", "_type_index") def __init__(self) -> None: """Initialise an Index.""" @@ -76,23 +75,10 @@ def __init__(self) -> None: alluka.CallbackSig[typing.Any], dict[str, _types.InjectedTuple] ] = weakref.WeakKeyDictionary() self._config_index: dict[str, type[_config.PluginConfig]] = {} - self._lock = threading.Lock() - self._metadata_scanned = False self._name_index: dict[str, _config.TypeConfig[typing.Any]] = {} self._type_index: dict[type[typing.Any], _config.TypeConfig[typing.Any]] = {} self._scan_libraries() - def __enter__(self) -> None: - self._lock.__enter__() - - def __exit__( - self, - exc_cls: type[BaseException] | None, - exc: BaseException | None, - traceback_value: types.TracebackType | None, - ) -> None: - return self._lock.__exit__(exc_cls, exc, traceback_value) - def register_config(self, config_cls: type[_config.PluginConfig], /) -> None: """Register a plugin configuration class. @@ -230,17 +216,7 @@ def get_config(self, config_id: str, /) -> type[_config.PluginConfig]: def _scan_libraries(self) -> None: """Load config classes from installed libraries based on their entry points.""" - if self._metadata_scanned: - return - - self._metadata_scanned = True - if sys.version_info >= (3, 10): - entry_points = importlib.metadata.entry_points(group=_ENTRY_POINT_GROUP_NAME) - - else: - entry_points = importlib.metadata.entry_points().get(_ENTRY_POINT_GROUP_NAME) or [] - - for entry_point in entry_points: + for entry_point in importlib.metadata.entry_points(group=_ENTRY_POINT_GROUP_NAME): value = entry_point.load() if not isinstance(value, type): pass From cdfee36fe2330a4890458450314f7967a8b9a9fe Mon Sep 17 00:00:00 2001 From: "always-on-duty[bot]" <120557446+always-on-duty[bot]@users.noreply.github.com> Date: Sun, 24 Nov 2024 05:24:55 +0000 Subject: [PATCH 26/27] Reformat PR code --- alluka/managed/_index.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/alluka/managed/_index.py b/alluka/managed/_index.py index c893d100..a5febd92 100644 --- a/alluka/managed/_index.py +++ b/alluka/managed/_index.py @@ -34,7 +34,6 @@ import importlib.metadata import logging -import threading import typing import weakref from collections import abc as collections @@ -42,7 +41,6 @@ from . import _config # pyright: ignore[reportPrivateUsage] if typing.TYPE_CHECKING: - import types from .. import _types # pyright: ignore[reportPrivateUsage] from .. import abc as alluka From 7f4cbd0eb697ee83367189b655e73387b6a2bed5 Mon Sep 17 00:00:00 2001 From: Faster Speeding Date: Sun, 24 Nov 2024 18:16:37 +0000 Subject: [PATCH 27/27] Doc heading fixes --- docs/usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 8c1c5afa..911b54c7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -145,7 +145,7 @@ their signatures do not need to match and async callbacks can be overridden with sync with vice versa also working (although overriding a sync callback with an async callback will prevent the callback from being used in a sync context). -# Local client +## Local client Alluka provides a system in [alluka.local][] which lets you associate an Alluka client with the local scope. This can make dependency injection easier for application code as it avoids the need to @@ -189,7 +189,7 @@ As such `auto_inject` and `auto_inject_async` can be used to make an auto-inject before a local client has been set but any calls to the returned auto-injecting callbacks will only work within a scope where `initialise` or `scope_client` is in effect. -# Custom injection contexts +## Custom injection contexts Under the hood Alluka builds a [alluka.abc.Context][] for each call to a `call_with_{async}_di` method.