Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make Configurable require Configuration #2146

Merged
merged 1 commit into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions betty/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,7 @@ def __init__(
cache_factory: Callable[[Self], Cache[Any]],
fetcher: Fetcher | None = None,
):
super().__init__()
self._configuration = configuration
super().__init__(configuration=configuration)
self._assets: AssetRepository | None = None
self._localization_initialized = False
self._localizer: Localizer | None = None
Expand Down
22 changes: 15 additions & 7 deletions betty/assertion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from betty.error import FileNotFound, UserFacingError
from betty.locale import get_data, UNDETERMINED_LOCALE
from betty.locale.localizable import _, Localizable, plain, join, do_you_mean
from betty.typing import Void, Voidable
from betty.typing import Void, Voidable, internal

Number: TypeAlias = int | float

Expand Down Expand Up @@ -90,8 +90,16 @@ def __call__(self, value: _AssertionValueT) -> _AssertionReturnT:
return self._assertion(value)


@internal
@dataclass(frozen=True)
class _Field(Generic[_AssertionValueT, _AssertionReturnT]):
class Field(Generic[_AssertionValueT, _AssertionReturnT]):
"""
A key-value mapping field.

Do not instantiate this class directly. Use :py:class:`betty.assertion.RequiredField` or
:py:class:`betty.assertion.OptionalField` instead.
"""

name: str
assertion: Assertion[_AssertionValueT, _AssertionReturnT] | None = None

Expand All @@ -100,7 +108,7 @@ class _Field(Generic[_AssertionValueT, _AssertionReturnT]):
@dataclass(frozen=True)
class RequiredField(
Generic[_AssertionValueT, _AssertionReturnT],
_Field[_AssertionValueT, _AssertionReturnT],
Field[_AssertionValueT, _AssertionReturnT],
):
"""
A required key-value mapping field.
Expand All @@ -113,7 +121,7 @@ class RequiredField(
@dataclass(frozen=True)
class OptionalField(
Generic[_AssertionValueT, _AssertionReturnT],
_Field[_AssertionValueT, _AssertionReturnT],
Field[_AssertionValueT, _AssertionReturnT],
):
"""
An optional key-value mapping field.
Expand Down Expand Up @@ -370,7 +378,7 @@ def _assert_mapping(


def assert_fields(
*fields: _Field[Any, Any],
*fields: Field[Any, Any],
) -> AssertionChain[Any, MutableMapping[str, Any]]:
"""
Assert that a value is a key-value mapping of arbitrary value types, and assert several of its values.
Expand Down Expand Up @@ -409,7 +417,7 @@ def assert_field(


def assert_field(
field: _Field[_AssertionValueT, _AssertionReturnT],
field: Field[_AssertionValueT, _AssertionReturnT],
) -> (
AssertionChain[_AssertionValueT, _AssertionReturnT]
| AssertionChain[_AssertionValueT, Voidable[_AssertionReturnT]]
Expand All @@ -430,7 +438,7 @@ def _assert_field(


def assert_record(
*fields: _Field[Any, Any],
*fields: Field[Any, Any],
) -> AssertionChain[Any, MutableMapping[str, Any]]:
"""
Assert that a value is a record: a key-value mapping of arbitrary value types, with a known structure.
Expand Down
5 changes: 1 addition & 4 deletions betty/assets/locale/betty.pot
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Betty VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-10-12 23:15+0100\n"
"POT-Creation-Date: 2024-10-21 00:53+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
Expand Down Expand Up @@ -1005,9 +1005,6 @@ msgstr ""
msgid "{event_type} of {subjects}"
msgstr ""

msgid "{extension_type} is not configurable."
msgstr ""

msgid "{extension} does not have an assets directory."
msgstr ""

Expand Down
5 changes: 1 addition & 4 deletions betty/assets/locale/de-DE/betty.po
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Betty VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-10-12 23:15+0100\n"
"POT-Creation-Date: 2024-10-21 00:53+0100\n"
"PO-Revision-Date: 2024-02-08 13:24+0000\n"
"Last-Translator: Bart Feenstra <bart@bartfeenstra.com>\n"
"Language: de\n"
Expand Down Expand Up @@ -1198,9 +1198,6 @@ msgstr "{event_type} ({event_description}) von {subjects}"
msgid "{event_type} of {subjects}"
msgstr "{event_type} von {subjects}"

msgid "{extension_type} is not configurable."
msgstr "{extension_type} ist nicht konfigurierbar."

msgid "{extension} does not have an assets directory."
msgstr ""

Expand Down
5 changes: 1 addition & 4 deletions betty/assets/locale/fr-FR/betty.po
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-10-12 23:15+0100\n"
"POT-Creation-Date: 2024-10-21 00:53+0100\n"
"PO-Revision-Date: 2024-02-08 13:24+0000\n"
"Last-Translator: Bart Feenstra <bart@bartfeenstra.com>\n"
"Language: fr\n"
Expand Down Expand Up @@ -1141,9 +1141,6 @@ msgstr "{event_type} ({event_description}) de {subjects}"
msgid "{event_type} of {subjects}"
msgstr "{event_type} de {subjects}"

msgid "{extension_type} is not configurable."
msgstr ""

msgid "{extension} does not have an assets directory."
msgstr ""

Expand Down
5 changes: 1 addition & 4 deletions betty/assets/locale/nl-NL/betty.po
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-10-12 23:15+0100\n"
"POT-Creation-Date: 2024-10-21 00:53+0100\n"
"PO-Revision-Date: 2024-02-11 15:31+0000\n"
"Last-Translator: Bart Feenstra <bart@bartfeenstra.com>\n"
"Language: nl\n"
Expand Down Expand Up @@ -1233,9 +1233,6 @@ msgstr "{event_type} ({event_description}) van {subjects}"
msgid "{event_type} of {subjects}"
msgstr "{event_type} van {subjects}"

msgid "{extension_type} is not configurable."
msgstr "\"{extension_type}\" kan niet ingesteld worden."

msgid "{extension} does not have an assets directory."
msgstr "{extension} heeft geen assets-directory."

Expand Down
5 changes: 1 addition & 4 deletions betty/assets/locale/uk/betty.po
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: Betty VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-10-12 23:15+0100\n"
"POT-Creation-Date: 2024-10-21 00:53+0100\n"
"PO-Revision-Date: 2024-02-08 13:08+0000\n"
"Last-Translator: Rainer Thieringer <rainerthi@gmail.com>\n"
"Language: uk\n"
Expand Down Expand Up @@ -1119,9 +1119,6 @@ msgstr ""
msgid "{event_type} of {subjects}"
msgstr "{event_type} {subjects}"

msgid "{extension_type} is not configurable."
msgstr ""

msgid "{extension} does not have an assets directory."
msgstr ""

Expand Down
11 changes: 4 additions & 7 deletions betty/cli/commands/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,12 @@ async def new() -> None:
configuration_file_path,
)

configuration.extensions.enable(CottonCandy)
configuration.extensions.enable(Deriver)
configuration.extensions.enable(Privatizer)
configuration.extensions.enable(Wikipedia)
await configuration.extensions.enable(
CottonCandy, Deriver, Privatizer, Wikipedia
)
webpack_requirement = await Webpack.requirement()
if webpack_requirement.is_met():
configuration.extensions.enable(HttpApiDoc)
configuration.extensions.enable(Maps)
configuration.extensions.enable(Trees)
await configuration.extensions.enable(HttpApiDoc, Maps, Trees)

configuration.locales.replace(
LocaleConfiguration(
Expand Down
25 changes: 19 additions & 6 deletions betty/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

from __future__ import annotations

from abc import abstractmethod
from collections.abc import Callable
from contextlib import chdir
from typing import Generic, TypeVar, TypeAlias, TYPE_CHECKING, Self
from typing import Generic, TypeVar, TypeAlias, TYPE_CHECKING, Self, Any

import aiofiles
from aiofiles.os import makedirs
Expand Down Expand Up @@ -46,20 +47,32 @@ class Configurable(Generic[_ConfigurationT]):
Any configurable object.
"""

_configuration: _ConfigurationT
def __init__(self, *args: Any, configuration: _ConfigurationT, **kwargs: Any):
super().__init__(*args, **kwargs)
self._configuration = configuration

@property
def configuration(self) -> _ConfigurationT:
"""
The object's configuration.
"""
if not hasattr(self, "_configuration"):
raise RuntimeError(
f"{self} has no configuration. {type(self)}.__init__() must ensure it is set."
)
return self._configuration


class DefaultConfigurable(Configurable[_ConfigurationT], Generic[_ConfigurationT]):
"""
A configurable type that can provide its own default configuration.
"""

@classmethod
@abstractmethod
def new_default_configuration(cls) -> _ConfigurationT:
"""
Create this extension's default configuration.
"""
pass


async def assert_configuration_file(
configuration: _ConfigurationT,
) -> AssertionChain[Path, _ConfigurationT]:
Expand Down
87 changes: 81 additions & 6 deletions betty/plugin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
Provide plugin configuration.
"""

from collections.abc import Sequence
from typing import TypeVar, Generic, cast
from __future__ import annotations

from typing import TypeVar, Generic, cast, Sequence, Any, TYPE_CHECKING

from typing_extensions import override

Expand All @@ -12,18 +13,24 @@
assert_record,
OptionalField,
assert_setattr,
Field,
assert_field,
)
from betty.config import Configuration
from betty.config import Configuration, DefaultConfigurable
from betty.config.collections.mapping import ConfigurationMapping
from betty.locale.localizable import ShorthandStaticTranslations
from betty.locale.localizable.config import (
OptionalStaticTranslationsLocalizableConfigurationAttr,
RequiredStaticTranslationsLocalizableConfigurationAttr,
)
from betty.machine_name import assert_machine_name, MachineName
from betty.plugin import Plugin
from betty.serde.dump import Dump, DumpMapping
from betty.plugin import Plugin, PluginRepository
from betty.plugin.assertion import assert_plugin

if TYPE_CHECKING:
from betty.locale.localizable import ShorthandStaticTranslations
from betty.serde.dump import Dump, DumpMapping

_PluginT = TypeVar("_PluginT", bound=Plugin)
_PluginCoT = TypeVar("_PluginCoT", bound=Plugin, covariant=True)


Expand Down Expand Up @@ -131,3 +138,71 @@ def load_item(self, dump: Dump) -> PluginConfiguration:
@classmethod
def _create_default_item(cls, configuration_key: str) -> PluginConfiguration:
return PluginConfiguration(configuration_key, {})


class PluginInstanceConfiguration(Configuration, Generic[_PluginT]):
"""
Configure a single plugin instance.

Plugins that extend :py:class:`betty.config.DefaultConfigurable` may receive their configuration from
:py:attr:`betty.plugin.config.PluginInstanceConfiguration.plugin_configuration` / the `"configuration"` dump key.
"""

def __init__(
self,
plugin: type[_PluginT],
*,
plugin_repository: PluginRepository[_PluginT],
plugin_configuration: Configuration | None = None,
):
if plugin_configuration and not issubclass(plugin, DefaultConfigurable):
raise ValueError(
f"{plugin} is not configurable (it must extend {DefaultConfigurable}), but configuration was given."
)
if (
issubclass(plugin, DefaultConfigurable) # type: ignore[redundant-expr]
and not plugin_configuration # type: ignore[unreachable]
):
plugin_configuration = plugin.new_default_configuration() # type: ignore[unreachable]
super().__init__()
self._plugin = plugin
self._plugin_configuration = plugin_configuration
self._plugin_repository = plugin_repository

@property
def plugin(self) -> type[_PluginT]:
"""
The plugin.
"""
return self._plugin

@property
def plugin_configuration(self) -> Configuration | None:
"""
Get the plugin's own configuration.
"""
return self._plugin_configuration

@override
def load(self, dump: Dump) -> None:
id_field = RequiredField(
"id",
assert_plugin(self._plugin_repository) | assert_setattr(self, "_plugin"),
)
plugin = assert_field(id_field)(dump)
fields = [id_field, *self._fields()]
if issubclass(plugin, DefaultConfigurable):
configuration = plugin.new_default_configuration()
self._plugin_configuration = configuration
fields.append(RequiredField("configuration", configuration.load))
assert_record(*fields)(dump)

def _fields(self) -> Sequence[Field[Any, Any]]:
return []

@override
def dump(self) -> DumpMapping[Dump]:
dump: DumpMapping[Dump] = {"id": self.plugin.plugin_id()}
if issubclass(self.plugin, DefaultConfigurable): # type: ignore[redundant-expr]
dump["configuration"] = self.plugin_configuration.dump() # type: ignore[unreachable]
return dump
Loading