"""
Provide plugin configuration.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import TypeVar, Generic, cast, Sequence, TYPE_CHECKING
from typing_extensions import override
from betty.assertion import (
RequiredField,
assert_record,
OptionalField,
assert_setattr,
assert_or,
)
from betty.assertion.error import AssertionFailed
from betty.config import Configuration, DefaultConfigurable
from betty.config.collections import ConfigurationKey
from betty.config.collections.mapping import ConfigurationMapping
from betty.locale.localizable import _
from betty.locale.localizable.config import (
OptionalStaticTranslationsLocalizableConfigurationAttr,
RequiredStaticTranslationsLocalizableConfigurationAttr,
)
from betty.machine_name import assert_machine_name, MachineName
from betty.plugin import Plugin, PluginRepository, PluginIdentifier, resolve_identifier
from betty.repr import repr_instance
from betty.typing import Void, Voidable
if TYPE_CHECKING:
from collections.abc import Iterable
from betty.locale.localizable import ShorthandStaticTranslations
from betty.serde.dump import Dump, DumpMapping
_ConfigurationT = TypeVar("_ConfigurationT", bound=Configuration)
_ConfigurationKeyT = TypeVar("_ConfigurationKeyT", bound=ConfigurationKey)
_PluginT = TypeVar("_PluginT", bound=Plugin)
_PluginCoT = TypeVar("_PluginCoT", bound=Plugin, covariant=True)
[docs]
class PluginIdentifierKeyConfigurationMapping(
ConfigurationMapping[MachineName, _ConfigurationT],
Generic[_PluginT, _ConfigurationT],
):
"""
A mapping of configuration, keyed by a plugin identifier.
"""
@override
def __getitem__(
self, configuration_key: PluginIdentifier[_PluginT]
) -> _ConfigurationT:
return super().__getitem__(resolve_identifier(configuration_key))
@override
def __contains__(self, configuration_key: PluginIdentifier[_PluginT]) -> bool:
return super().__contains__(resolve_identifier(configuration_key))
[docs]
class PluginConfiguration(Configuration):
"""
Configure a single plugin.
"""
label = RequiredStaticTranslationsLocalizableConfigurationAttr("label")
description = OptionalStaticTranslationsLocalizableConfigurationAttr("description")
[docs]
def __init__(
self,
plugin_id: MachineName,
label: ShorthandStaticTranslations,
*,
description: ShorthandStaticTranslations | None = None,
):
super().__init__()
self._id = assert_machine_name()(plugin_id)
self.label = label
if description is not None:
self.description = description
def __repr__(self) -> str:
return repr_instance(self, id=self.id, label=self.label)
@property
def id(self) -> str:
"""
The configured plugin ID.
"""
return self._id
[docs]
@override
def load(self, dump: Dump) -> None:
assert_record(
RequiredField("id", assert_machine_name() | assert_setattr(self, "_id")),
RequiredField("label", self.label.load),
OptionalField("description", self.description.load),
)(dump)
[docs]
@override
def dump(self) -> DumpMapping[Dump]:
return {
"id": self.id,
"label": self.label.dump(),
"description": self.description.dump(),
}
_PluginConfigurationT = TypeVar("_PluginConfigurationT", bound=PluginConfiguration)
[docs]
class PluginConfigurationMapping(
ConfigurationMapping[str, _PluginConfigurationT],
Generic[_PluginCoT, _PluginConfigurationT],
):
"""
Configure a collection of plugins.
"""
[docs]
def new_plugins(self) -> Sequence[type[_PluginCoT]]:
"""
Create the plugins for this configuration.
You SHOULD NOT cache the value anywhere, as it *will* change
when this configuration changes.
"""
return tuple(
self._new_plugin(plugin_configuration)
for plugin_configuration in self.values()
)
def _new_plugin(self, configuration: _PluginConfigurationT) -> type[_PluginCoT]:
"""
The plugin (class) for the given configuration.
"""
raise NotImplementedError
@override
def _get_key(self, configuration: _PluginConfigurationT) -> str:
return configuration.id
@override
def _load_key(self, item_dump: Dump, key_dump: str) -> Dump:
assert isinstance(item_dump, Mapping)
item_dump["id"] = key_dump
return item_dump
@override
def _dump_key(self, item_dump: Dump) -> tuple[Dump, str]:
assert isinstance(item_dump, Mapping)
return item_dump, cast(str, item_dump.pop("id"))
[docs]
class PluginConfigurationPluginConfigurationMapping(
PluginConfigurationMapping[_PluginCoT, PluginConfiguration], Generic[_PluginCoT]
):
"""
Configure a collection of plugins using :py:class:`betty.plugin.config.PluginConfiguration`.
"""
@override
def _load_item(self, dump: Dump) -> PluginConfiguration:
item = PluginConfiguration("-", "")
item.load(dump)
return item
@classmethod
def _create_default_item(cls, configuration_key: str) -> PluginConfiguration:
return PluginConfiguration(configuration_key, {})
[docs]
class PluginInstanceConfiguration(Configuration):
"""
Configure a single plugin instance.
Plugins that extend :py:class:`betty.config.DefaultConfigurable` may receive their configuration from
:py:attr:`betty.plugin.config.PluginInstanceConfiguration.configuration` / the `"configuration"` dump key.
"""
[docs]
def __init__(
self,
plugin_id: type[Plugin] | MachineName,
*,
configuration: Voidable[Configuration | Dump] = Void,
):
super().__init__()
self._id = (
assert_machine_name()(plugin_id)
if isinstance(plugin_id, str)
else plugin_id.plugin_id()
)
self._configuration = (
configuration.dump()
if isinstance(configuration, Configuration)
else configuration
)
def __repr__(self) -> str:
return repr_instance(self, id=self.id, configuration=self.configuration)
@property
def id(self) -> MachineName:
"""
The plugin ID.
"""
return self._id
@property
def configuration(self) -> Voidable[Dump]:
"""
Get the plugin's own configuration.
"""
return self._configuration
[docs]
async def new_plugin_instance(
self, repository: PluginRepository[_PluginT]
) -> _PluginT:
"""
Create a new plugin instance.
"""
plugin = await repository.new_target(self.id)
if self.configuration is not Void:
if not isinstance(plugin, DefaultConfigurable): # type: ignore[redundant-expr]
raise AssertionFailed(
_(
"Plugin {plugin_label} ({plugin_id}) is not configurable, but configuration was given."
).format(
plugin_id=plugin.plugin_id(), plugin_label=plugin.plugin_label()
)
)
plugin.configuration.load(self.configuration) # type: ignore[unreachable]
return plugin
[docs]
@override
def load(self, dump: Dump) -> None:
id_assertion = assert_machine_name() | assert_setattr(self, "_id")
assert_or(
id_assertion,
assert_record(
RequiredField("id", id_assertion),
OptionalField("configuration", assert_setattr(self, "_configuration")),
),
)(dump)
[docs]
@override
def dump(self) -> Dump:
configuration = self.configuration
if configuration is Void:
return self.id
return {
"id": self.id,
"configuration": configuration, # type: ignore[dict-item]
}
[docs]
class PluginInstanceConfigurationMapping(
PluginIdentifierKeyConfigurationMapping[_PluginT, PluginInstanceConfiguration],
Generic[_PluginT],
):
"""
Configure plugin instances, keyed by their plugin IDs.
"""
[docs]
def __init__(
self,
configurations: Iterable[PluginInstanceConfiguration] | None = None,
):
super().__init__(configurations)
@override
def _load_item(self, dump: Dump) -> PluginInstanceConfiguration:
configuration = PluginInstanceConfiguration("-")
configuration.load(dump)
return configuration
@override
def _get_key(self, configuration: PluginInstanceConfiguration) -> MachineName:
return configuration.id
@override
def _load_key(self, item_dump: Dump, key_dump: str) -> Dump:
if not item_dump:
return key_dump
assert isinstance(item_dump, Mapping)
item_dump["id"] = key_dump
return item_dump
@override
def _dump_key(self, item_dump: Dump) -> tuple[Dump, str]:
if isinstance(item_dump, str):
return {}, item_dump
assert isinstance(item_dump, Mapping)
return item_dump, cast(str, item_dump.pop("id"))