diff --git a/.coveragerc b/.coveragerc index 22b0c1382..9a1d7737f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -6,6 +6,7 @@ omit = [report] exclude_lines = + pass # pragma: no cover def __repr__ raise NotImplementedError diff --git a/README.md b/README.md index 8ce371a41..4e7d604b4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ + # Betty 👵 ![Test status](https://github.com/bartfeenstra/betty/workflows/Test/badge.svg) [![Code coverage](https://codecov.io/gh/bartfeenstra/betty/branch/master/graph/badge.svg)](https://codecov.io/gh/bartfeenstra/betty) [![PyPI releases](https://badge.fury.io/py/betty.svg)](https://pypi.org/project/betty/) [![Supported Python versions](https://img.shields.io/pypi/pyversions/betty.svg?logo=python&logoColor=FBE072)](https://pypi.org/project/betty/) [![Recent downloads](https://img.shields.io/pypi/dm/betty.svg)](https://pypi.org/project/betty/) [![Follow Betty on Twitter](https://img.shields.io/twitter/follow/Betty_Project.svg?label=Betty_Project&style=flat&logo=twitter&logoColor=4FADFF)](https://twitter.com/Betty_Project) @@ -88,6 +89,11 @@ locales: - locale: en-US alias: en - locale: nl +entity_types: + Person: + generate_html_list: true + File: + generate_html_list: false extensions: betty.anonymizer.Anonymizer: {} betty.cleaner.Cleaner: {} @@ -114,16 +120,16 @@ extensions: betty.wikipedia.Wikipedia: {} ``` -- `base_url` (required); The absolute, public URL at which the site will be published. +- `base_url` (required): The absolute, public URL at which the site will be published. - `debug` (optional): `true` to output more detailed logs and disable optimizations that make debugging harder. Defaults to `false`. -- `root_path` (optional); The relative path under the public URL at which the site will be published. -- `clean_urls` (optional); A boolean indicating whether to use clean URLs, e.g. `/path` instead of `/path/index.html`. +- `root_path` (optional): The relative path under the public URL at which the site will be published. +- `clean_urls` (optional): A boolean indicating whether to use clean URLs, e.g. `/path` instead of `/path/index.html`. Defaults to `false`. - `content_negotiation` (optional): Enables dynamic content negotiation, but requires a web server that supports it. This implies `clean_urls`. Defaults to `false` -- `title` (optional); The site's title. -- `author` (optional); The site's author and copyright holder. +- `title` (optional): The site's title. +- `author` (optional): The site's author and copyright holder. - `lifetime_threshold` (optional); The number of years people are expected to live at most, e.g. after which they're presumed to have died. Defaults to `125`. - `locales` (optional); An array of locales, each of which is an object with the following keys: @@ -131,10 +137,13 @@ extensions: - `alias` (optional): A shorthand alias to use instead of the full language tag, such as when rendering URLs. If no locales are defined, Betty defaults to US English. +- `entity_types` (optional): Keys are entity type names, and values are objects containing the following keys: + - `generate_html_list` (optional): Whether to generate the HTML page to list entities of this type. Defaults to + `false`. - `extensions` (optional): The extensions to enable. Keys are extension names, and values are objects containing the following keys: - - `enabled` (optional). A boolean indicating whether the extension is enabled. Defaults to `true`. - - `configuration` (optional). An object containing the extension's own configuration, if it provides any configuration + - `enabled` (optional): A boolean indicating whether the extension is enabled. Defaults to `true`. + - `configuration` (optional): An object containing the extension's own configuration, if it provides any configuration options. Both keys may be omitted to quickly enable an extension using its default configuration. diff --git a/betty/_package/pyinstaller/__init__.py b/betty/_package/pyinstaller/__init__.py index 018a6d877..4d50948c6 100644 --- a/betty/_package/pyinstaller/__init__.py +++ b/betty/_package/pyinstaller/__init__.py @@ -15,7 +15,7 @@ from betty.http_api_doc import HttpApiDoc from betty.maps import Maps from betty.npm import _Npm, build_assets -from betty.project import ProjectExtensionConfiguration +from betty.project import ExtensionConfiguration from betty.trees import Trees @@ -34,9 +34,9 @@ def _filter_submodule(submodule: str) -> bool: async def _build_assets() -> None: npm_builder_extension_types = {HttpApiDoc, Maps, Trees} with App() as app: - app.project.configuration.extensions.add(ProjectExtensionConfiguration(_Npm)) + app.project.configuration.extensions.add(ExtensionConfiguration(_Npm)) for extension_type in npm_builder_extension_types: - app.project.configuration.extensions.add(ProjectExtensionConfiguration(extension_type)) + app.project.configuration.extensions.add(ExtensionConfiguration(extension_type)) await asyncio.gather(*[ build_assets(app.extensions[extension_type]) for extension_type diff --git a/betty/app/__init__.py b/betty/app/__init__.py index fc8d55224..10ff26696 100644 --- a/betty/app/__init__.py +++ b/betty/app/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -import locale import weakref from concurrent.futures._base import Executor from concurrent.futures.thread import ThreadPoolExecutor @@ -9,45 +8,41 @@ from pathlib import Path from typing import List, Type, TYPE_CHECKING, Set, Iterator, Optional +import aiohttp from babel.core import parse_locale from babel.localedata import locale_identifiers - -try: - from typing import Self # type: ignore -except ImportError: - from typing_extensions import Self - +from jinja2 import Environment as Jinja2Environment +from reactives import reactive from reactives.factory.type import ReactiveInstance from betty.app.extension import ListExtensions, Extension, Extensions, build_extension_type_graph, \ CyclicDependencyError, ExtensionDispatcher, ConfigurableExtension, discover_extension_types from betty.asyncio import sync +from betty.concurrent import ExceptionRaisingAwaitableExecutor +from betty.config import FileBasedConfiguration, DumpedConfigurationImport, Configurable, DumpedConfigurationExport +from betty.config.load import ConfigurationValidationError, Loader, Field +from betty.dispatch import Dispatcher +from betty.fs import FileSystem, ASSETS_DIRECTORY_PATH, HOME_DIRECTORY_PATH +from betty.locale import negotiate_locale, TranslationsRepository, Translations, rfc_1766_to_bcp_47, bcp_47_to_rfc_1766 +from betty.lock import Locks from betty.model import Entity, EntityTypeProvider +from betty.model.ancestry import Citation, Event, File, Person, PersonName, Presence, Place, Enclosure, \ + Source, Note, EventType from betty.model.event_type import EventTypeProvider, Birth, Baptism, Adoption, Death, Funeral, Cremation, Burial, Will, \ Engagement, Marriage, MarriageAnnouncement, Divorce, DivorceAnnouncement, Residence, Immigration, Emigration, \ Occupation, Retirement, Correspondence, Confirmation from betty.project import Project +from betty.render import Renderer, SequentialRenderer try: + from graphlib_backport import TopologicalSorter, CycleError +except ModuleNotFoundError: from graphlib import TopologicalSorter, CycleError -except ImportError: - from graphlib_backport import TopologicalSorter # type: ignore - -import aiohttp -from jinja2 import Environment as Jinja2Environment -from reactives import reactive -from betty.concurrent import ExceptionRaisingAwaitableExecutor -from betty.config import ConfigurationError, FileBasedConfiguration, DumpedConfiguration -from betty.dispatch import Dispatcher -from betty.lock import Locks -from betty.render import Renderer, SequentialRenderer - -from betty.model.ancestry import Citation, Event, File, Person, PersonName, Presence, Place, Enclosure, \ - Source, Note, EventType -from betty.config import Configurable -from betty.fs import FileSystem, ASSETS_DIRECTORY_PATH, HOME_DIRECTORY_PATH -from betty.locale import negotiate_locale, TranslationsRepository, Translations, rfc_1766_to_bcp_47, bcp_47_to_rfc_1766 +try: + from typing_extensions import Self +except ModuleNotFoundError: + from typing import Self # type: ignore if TYPE_CHECKING: from betty.builtins import _ @@ -90,22 +85,22 @@ def locale(self) -> Optional[str]: @locale.setter def locale(self, locale: str) -> None: + try: + parse_locale(bcp_47_to_rfc_1766(locale)) + except ValueError: + raise ConfigurationValidationError(_('{locale} is not a valid IETF BCP 47 language tag.').format(locale=locale)) self._locale = locale - def load(self, dumped_configuration: DumpedConfiguration) -> None: - if not isinstance(dumped_configuration, dict): - raise ConfigurationError(_('Betty application configuration must be a mapping (dictionary).')) - - if 'locale' in dumped_configuration: - if not isinstance(dumped_configuration['locale'], str): - raise ConfigurationError(_('The locale must be a string.'), contexts=['`title`']) - try: - parse_locale(bcp_47_to_rfc_1766(dumped_configuration['locale'])) - except ValueError: - raise ConfigurationError(_('{locale} is not a valid IETF BCP 47 language tag.').format(locale=locale)) - self.locale = dumped_configuration['locale'] + def load(self, dumped_configuration: DumpedConfigurationImport, loader: Loader) -> None: + loader.assert_record(dumped_configuration, { + 'locale': Field( + True, + loader.assert_str, # type: ignore + lambda x: loader.assert_setattr(self, 'locale', x), + ), + }) - def dump(self) -> DumpedConfiguration: + def dump(self) -> DumpedConfigurationExport: dumped_configuration = {} if self._locale is not None: dumped_configuration['locale'] = self.locale @@ -121,7 +116,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._configuration = AppConfiguration() with suppress(FileNotFoundError): - self.configuration.read() + with Translations(): + self.configuration.read() self._acquired = False self._extensions = _AppExtensions() diff --git a/betty/app/extension/__init__.py b/betty/app/extension/__init__.py index d1a02a62b..b16df5f0c 100644 --- a/betty/app/extension/__init__.py +++ b/betty/app/extension/__init__.py @@ -9,9 +9,9 @@ Iterator, Sequence try: - from typing import Self # type: ignore -except ImportError: from typing_extensions import Self +except ModuleNotFoundError: + from typing import Self # type: ignore from reactives import reactive, scope from reactives.factory.type import ReactiveInstance diff --git a/betty/assets/betty.pot b/betty/assets/betty.pot index fb0d232fe..cdf3dbf1a 100644 --- a/betty/assets/betty.pot +++ b/betty/assets/betty.pot @@ -44,12 +44,15 @@ msgid "" "" msgstr "" -msgid "\"{extension_type_name}\" is not a Betty extension." +msgid "\"{entity_type}\" is not a valid Betty entity type." msgstr "" msgid "\"{hex_value}\" is not a valid hexadecimal color, such as #ffc0cb." msgstr "" +msgid "\"{path}\" is not a directory." +msgstr "" + #, python-format msgid "%(date)s" msgstr "" @@ -168,9 +171,6 @@ msgstr "" msgid "Anonymizer" msgstr "" -msgid "App extensions configuration must be a mapping (dictionary)." -msgstr "" - msgid "Appearances" msgstr "" @@ -186,18 +186,12 @@ msgstr "" msgid "Beneficiary" msgstr "" -msgid "Betty application configuration must be a mapping (dictionary)." -msgstr "" - msgid "Betty helps you visualize and publish your family history by building interactive genealogy websites out of your Gramps and GEDCOM family trees." msgstr "" msgid "Betty project configuration ({extensions})" msgstr "" -msgid "Betty project configuration must be a mapping (dictionary)." -msgstr "" - msgid "Birth" msgstr "" @@ -207,16 +201,22 @@ msgstr "" msgid "Cancel" msgstr "" +msgid "Cannot determine the entity type for \"{entity_type}\". Did you perhaps make a typo, or could it be that the entity type comes from another package that is not yet installed?" +msgstr "" + +msgid "Cannot find and import \"{entity_type}\"." +msgstr "" + msgid "Cannot remove the last remaining locale {locale}" msgstr "" msgid "Citation" msgstr "" -msgid "Clean URLs" +msgid "Citations" msgstr "" -msgid "Clean URLs must be enabled (true) or disabled (false) with a boolean." +msgid "Clean URLs" msgstr "" msgid "Cleaner" @@ -228,9 +228,6 @@ msgstr "" msgid "Close" msgstr "" -msgid "Color configuration must be a string." -msgstr "" - msgid "Configuration" msgstr "" @@ -246,9 +243,6 @@ msgstr "" msgid "Content negotiation" msgstr "" -msgid "Content negotiation must be enabled (true) or disabled (false) with a boolean." -msgstr "" - #, python-format msgid "Continue reading on Wikipedia." msgstr "" @@ -277,9 +271,6 @@ msgstr "" msgid "Debugging mode" msgstr "" -msgid "Debugging must be enabled (true) or disabled (false) with a boolean." -msgstr "" - msgid "Decide the correct page variety to serve users depending on their own preferences. This requires a web server that supports it." msgstr "" @@ -314,18 +305,12 @@ msgstr "" msgid "Enable {extension}" msgstr "" -msgid "Enclosure" -msgstr "" - msgid "Engagement" msgstr "" msgid "Entity ID" msgstr "" -msgid "Entity references must be a list." -msgstr "" - msgid "Error" msgstr "" @@ -350,15 +335,6 @@ msgstr "" msgid "Family" msgstr "" -msgid "Family tree configuration must be a mapping (dictionary)." -msgstr "" - -msgid "Family tree configuration requires a Gramps file to be set." -msgstr "" - -msgid "Family trees configuration is required and must must be a list." -msgstr "" - msgid "Featured entities" msgstr "" @@ -368,6 +344,9 @@ msgstr "" msgid "File path" msgstr "" +msgid "Files" +msgstr "" + msgid "Follow Betty on Twitter and Github." msgstr "" @@ -380,39 +359,21 @@ msgstr "" msgid "General" msgstr "" -msgid "Generate site" -msgstr "" - -msgid "Generated OpenAPI documentation in {locale}." -msgstr "" - -msgid "Generated pages for {citation_count} citations in {locale}." +msgid "Generate entity listing pages" msgstr "" -msgid "Generated pages for {event_count} events in {locale}." -msgstr "" - -msgid "Generated pages for {file_count} files in {locale}." -msgstr "" - -msgid "Generated pages for {note_count} notes in {locale}." -msgstr "" - -msgid "Generated pages for {person_count} people in {locale}." +msgid "Generate site" msgstr "" -msgid "Generated pages for {place_count} places in {locale}." +msgid "Generated pages for {count} {entity_type} in {locale}." msgstr "" -msgid "Generated pages for {source_count} sources in {locale}." +msgid "Generated the listing HTML page for {entity_type} entities in {locale}." msgstr "" msgid "Generating your site..." msgstr "" -msgid "Gramps configuration must be a mapping (dictionary)." -msgstr "" - msgid "Help" msgstr "" @@ -479,9 +440,6 @@ msgstr "" msgid "Locale aliases must not contain slashes." msgstr "" -msgid "Locales configuration must be a list." -msgstr "" - msgid "Localization" msgstr "" @@ -560,6 +518,9 @@ msgstr "" msgid "Person name" msgstr "" +msgid "Person names" +msgstr "" + msgid "Place" msgstr "" @@ -572,9 +533,6 @@ msgstr "" msgid "Pre-built assets are unavailable for {extension_names}." msgstr "" -msgid "Presence" -msgstr "" - msgid "Primary color (active)" msgstr "" @@ -701,12 +659,6 @@ msgstr "" msgid "The age at which people are presumed dead." msgstr "" -msgid "The author must be a string." -msgstr "" - -msgid "The base URL is required and must be a string." -msgstr "" - msgid "The base URL must include a path." msgstr "" @@ -734,60 +686,30 @@ msgstr "" msgid "The collection of sources." msgstr "" -msgid "The configuration must be a mapping (dictionary)." -msgstr "" - -msgid "The entity ID is required." -msgstr "" - -msgid "The entity ID must be a string." -msgstr "" - -msgid "The entity reference must be a mapping (dictionary)." -msgstr "" - msgid "The entity reference must be for an entity of type {expected_entity_type_name} ({expected_entity_type_label}), but instead is for an entity of type {actual_entity_type_name} ({actual_entity_type_label})" msgstr "" -msgid "The entity type is required." -msgstr "" - msgid "The event." msgstr "" msgid "The file." msgstr "" -msgid "The lifetime threshold must be a positive number." -msgstr "" - -msgid "The lifetime threshold must be an integer." +msgid "The key \"{configuration_key}\" is required." msgstr "" msgid "The lifetime threshold must consist of digits only." msgstr "" -msgid "The locale must be a string." -msgstr "" - msgid "The person." msgstr "" msgid "The place." msgstr "" -msgid "The root path must be a string." -msgstr "" - msgid "The source." msgstr "" -msgid "The theme configuration must be a mapping (dictionary)." -msgstr "" - -msgid "The title must be a string." -msgstr "" - #, python-format msgid "The translations for {locale_name} are {coverage_percentage}% complete." msgstr "" @@ -820,6 +742,27 @@ msgid_plural "They had %(child_count)s children." msgstr[0] "" msgstr[1] "" +msgid "This must be a boolean." +msgstr "" + +msgid "This must be a decimal number." +msgstr "" + +msgid "This must be a key-value mapping." +msgstr "" + +msgid "This must be a list." +msgstr "" + +msgid "This must be a positive number." +msgstr "" + +msgid "This must be a string." +msgstr "" + +msgid "This must be an integer." +msgstr "" + msgid "This person's details are unavailable to protect their privacy." msgstr "" @@ -847,6 +790,9 @@ msgstr "" msgid "Unknown" msgstr "" +msgid "Unknown key: {unknown_configuration_key}. Did you mean {known_configuration_keys}?" +msgstr "" + #, python-format msgid "Use and to navigate results, or esc to exit the search. Search again with %(shortcut)s." msgstr "" @@ -1051,6 +997,15 @@ msgstr "" msgid "{entity_type_label} ID" msgstr "" +msgid "{entity_type} {entity_id}" +msgstr "" + +msgid "{extension_type} is not a valid Betty extension." +msgstr "" + +msgid "{extension_type} is not configurable." +msgstr "" + msgid "{individual_name} {affiliation_name}" msgstr "" diff --git a/betty/assets/locale/fr_FR/LC_MESSAGES/betty.po b/betty/assets/locale/fr_FR/LC_MESSAGES/betty.po index 94627675c..1a358454a 100644 --- a/betty/assets/locale/fr_FR/LC_MESSAGES/betty.po +++ b/betty/assets/locale/fr_FR/LC_MESSAGES/betty.po @@ -53,12 +53,15 @@ msgid "" "" msgstr "" -msgid "\"{extension_type_name}\" is not a Betty extension." +msgid "\"{entity_type}\" is not a valid Betty entity type." msgstr "" msgid "\"{hex_value}\" is not a valid hexadecimal color, such as #ffc0cb." msgstr "" +msgid "\"{path}\" is not a directory." +msgstr "" + #, python-format msgid "%(date)s" msgstr "le %(date)s" @@ -217,9 +220,6 @@ msgstr "" msgid "Anonymizer" msgstr "" -msgid "App extensions configuration must be a mapping (dictionary)." -msgstr "" - msgid "Appearances" msgstr "Caractéristiques" @@ -235,9 +235,6 @@ msgstr "Baptême" msgid "Beneficiary" msgstr "Bénéficiaire" -msgid "Betty application configuration must be a mapping (dictionary)." -msgstr "" - msgid "" "Betty helps you visualize and publish your family history by building " "interactive genealogy websites out of your Wikipedia." msgstr "Poursuivez votre lecture sur Wikipedia." @@ -338,9 +333,6 @@ msgstr "Décès" msgid "Debugging mode" msgstr "" -msgid "Debugging must be enabled (true) or disabled (false) with a boolean." -msgstr "" - msgid "" "Decide the correct page variety to serve users depending on their own " "preferences. This requires a web server that supports it." @@ -387,18 +379,12 @@ msgstr "Emigration" msgid "Enable {extension}" msgstr "" -msgid "Enclosure" -msgstr "" - msgid "Engagement" msgstr "Fiançailles" msgid "Entity ID" msgstr "" -msgid "Entity references must be a list." -msgstr "" - msgid "Error" msgstr "" @@ -423,15 +409,6 @@ msgstr "Faits" msgid "Family" msgstr "Famille" -msgid "Family tree configuration must be a mapping (dictionary)." -msgstr "" - -msgid "Family tree configuration requires a Gramps file to be set." -msgstr "" - -msgid "Family trees configuration is required and must must be a list." -msgstr "" - msgid "Featured entities" msgstr "" @@ -441,6 +418,9 @@ msgstr "" msgid "File path" msgstr "" +msgid "Files" +msgstr "" + msgid "" "Follow Betty on Twitter" " and Github." @@ -455,39 +435,21 @@ msgstr "Funérailles" msgid "General" msgstr "" -msgid "Generate site" -msgstr "" - -msgid "Generated OpenAPI documentation in {locale}." -msgstr "" - -msgid "Generated pages for {citation_count} citations in {locale}." +msgid "Generate entity listing pages" msgstr "" -msgid "Generated pages for {event_count} events in {locale}." -msgstr "" - -msgid "Generated pages for {file_count} files in {locale}." -msgstr "" - -msgid "Generated pages for {note_count} notes in {locale}." -msgstr "" - -msgid "Generated pages for {person_count} people in {locale}." +msgid "Generate site" msgstr "" -msgid "Generated pages for {place_count} places in {locale}." +msgid "Generated pages for {count} {entity_type} in {locale}." msgstr "" -msgid "Generated pages for {source_count} sources in {locale}." +msgid "Generated the listing HTML page for {entity_type} entities in {locale}." msgstr "" msgid "Generating your site..." msgstr "" -msgid "Gramps configuration must be a mapping (dictionary)." -msgstr "" - msgid "Help" msgstr "" @@ -556,9 +518,6 @@ msgstr "" msgid "Locale aliases must not contain slashes." msgstr "" -msgid "Locales configuration must be a list." -msgstr "" - msgid "Localization" msgstr "" @@ -639,6 +598,9 @@ msgstr "" msgid "Person name" msgstr "" +msgid "Person names" +msgstr "" + msgid "Place" msgstr "Lieu" @@ -651,9 +613,6 @@ msgstr "" msgid "Pre-built assets are unavailable for {extension_names}." msgstr "" -msgid "Presence" -msgstr "" - msgid "Primary color (active)" msgstr "" @@ -783,12 +742,6 @@ msgstr "L'ID de la ressource à récupérer." msgid "The age at which people are presumed dead." msgstr "" -msgid "The author must be a string." -msgstr "" - -msgid "The base URL is required and must be a string." -msgstr "" - msgid "The base URL must include a path." msgstr "" @@ -818,18 +771,6 @@ msgstr "L'ensemble des lieux." msgid "The collection of sources." msgstr "L'ensemble des sources." -msgid "The configuration must be a mapping (dictionary)." -msgstr "" - -msgid "The entity ID is required." -msgstr "" - -msgid "The entity ID must be a string." -msgstr "" - -msgid "The entity reference must be a mapping (dictionary)." -msgstr "" - msgid "" "The entity reference must be for an entity of type " "{expected_entity_type_name} ({expected_entity_type_label}), but instead " @@ -837,45 +778,27 @@ msgid "" "({actual_entity_type_label})" msgstr "" -msgid "The entity type is required." -msgstr "" - msgid "The event." msgstr "L'événement." msgid "The file." msgstr "Le fichier." -msgid "The lifetime threshold must be a positive number." -msgstr "" - -msgid "The lifetime threshold must be an integer." +msgid "The key \"{configuration_key}\" is required." msgstr "" msgid "The lifetime threshold must consist of digits only." msgstr "" -msgid "The locale must be a string." -msgstr "" - msgid "The person." msgstr "L'individu." msgid "The place." msgstr "Le lieu." -msgid "The root path must be a string." -msgstr "" - msgid "The source." msgstr "La source." -msgid "The theme configuration must be a mapping (dictionary)." -msgstr "" - -msgid "The title must be a string." -msgstr "" - #, python-format msgid "The translations for {locale_name} are {coverage_percentage}% complete." msgstr "" @@ -908,6 +831,27 @@ msgid_plural "They had %(child_count)s children." msgstr[0] "Ils ont eu un enfant." msgstr[1] "Ils ont eu %(child_count)s enfants." +msgid "This must be a boolean." +msgstr "" + +msgid "This must be a decimal number." +msgstr "" + +msgid "This must be a key-value mapping." +msgstr "" + +msgid "This must be a list." +msgstr "" + +msgid "This must be a positive number." +msgstr "" + +msgid "This must be a string." +msgstr "" + +msgid "This must be an integer." +msgstr "" + msgid "This person's details are unavailable to protect their privacy." msgstr "" "Les détails concernant cette personne ne sont pas disponibles afin de " @@ -940,6 +884,11 @@ msgstr "Non autorisé" msgid "Unknown" msgstr "Inconnu" +msgid "" +"Unknown key: {unknown_configuration_key}. Did you mean " +"{known_configuration_keys}?" +msgstr "" + #, python-format msgid "" "Use and to navigate results, or esc " @@ -1178,6 +1127,15 @@ msgstr "" msgid "{entity_type_label} ID" msgstr "" +msgid "{entity_type} {entity_id}" +msgstr "" + +msgid "{extension_type} is not a valid Betty extension." +msgstr "" + +msgid "{extension_type} is not configurable." +msgstr "" + msgid "{individual_name} {affiliation_name}" msgstr "{individual_name} {affiliation_name}" diff --git a/betty/assets/locale/nl_NL/LC_MESSAGES/betty.po b/betty/assets/locale/nl_NL/LC_MESSAGES/betty.po index 3fb60668a..51eec3313 100644 --- a/betty/assets/locale/nl_NL/LC_MESSAGES/betty.po +++ b/betty/assets/locale/nl_NL/LC_MESSAGES/betty.po @@ -63,12 +63,15 @@ msgid "" "" msgstr "" -msgid "\"{extension_type_name}\" is not a Betty extension." -msgstr "\"{extension_type_name}\" is geen Betty-extensie." +msgid "\"{entity_type}\" is not a valid Betty entity type." +msgstr "\"{entity_type}\" is geen geldig Betty-entiteitstype." msgid "\"{hex_value}\" is not a valid hexadecimal color, such as #ffc0cb." msgstr "" +msgid "\"{path}\" is not a directory." +msgstr "\"{path}\" is geen directory." + #, python-format msgid "%(date)s" msgstr "op %(date)s" @@ -234,9 +237,6 @@ msgstr "" msgid "Anonymizer" msgstr "Anonimiseerder" -msgid "App extensions configuration must be a mapping (dictionary)." -msgstr "" - msgid "Appearances" msgstr "Verschijningen" @@ -252,9 +252,6 @@ msgstr "Doping" msgid "Beneficiary" msgstr "Begunstigde" -msgid "Betty application configuration must be a mapping (dictionary)." -msgstr "Betty-applicatieconfiguratie moet een mapping (dictionary) zijn." - msgid "" "Betty helps you visualize and publish your family history by building " "interactive genealogy websites out of your Wikipedia." msgstr "Lees verder op Wikipedia." @@ -369,9 +363,6 @@ msgstr "Overlijden" msgid "Debugging mode" msgstr "" -msgid "Debugging must be enabled (true) or disabled (false) with a boolean." -msgstr "" - msgid "" "Decide the correct page variety to serve users depending on their own " "preferences. This requires a web server that supports it." @@ -418,18 +409,12 @@ msgstr "Emigratie" msgid "Enable {extension}" msgstr "Schakel {extension} in" -msgid "Enclosure" -msgstr "Omsluiting" - msgid "Engagement" msgstr "Verloving" msgid "Entity ID" msgstr "Entiteits-ID" -msgid "Entity references must be a list." -msgstr "Entiteitsverwijzingen moeten een lijst zijn." - msgid "Error" msgstr "Fout" @@ -454,15 +439,6 @@ msgstr "Feiten" msgid "Family" msgstr "Familie" -msgid "Family tree configuration must be a mapping (dictionary)." -msgstr "De stamboomconfiguratie moet een mapping (dictionary) zijn." - -msgid "Family tree configuration requires a Gramps file to be set." -msgstr "Stamboomconfiguratie vereist een Gramps-bestand." - -msgid "Family trees configuration is required and must must be a list." -msgstr "Stamboomconfiguratie is vereist en moet een lijst zijn." - msgid "Featured entities" msgstr "Uitgelichte entiteiten" @@ -472,6 +448,9 @@ msgstr "Bestand" msgid "File path" msgstr "Bestandspad" +msgid "Files" +msgstr "Bestanden" + msgid "" "Follow Betty on Twitter" " and Github." @@ -488,39 +467,22 @@ msgstr "Uitvaart" msgid "General" msgstr "Algemeen" +msgid "Generate entity listing pages" +msgstr "Genereer pagina's met entiteitsoverzichten" + msgid "Generate site" msgstr "Genereer site" -msgid "Generated OpenAPI documentation in {locale}." -msgstr "" +msgid "Generated pages for {count} {entity_type} in {locale}." +msgstr "Pagina's voor {count} {entity_type} in het {locale} gegenereerd." -msgid "Generated pages for {citation_count} citations in {locale}." -msgstr "" - -msgid "Generated pages for {event_count} events in {locale}." -msgstr "" - -msgid "Generated pages for {file_count} files in {locale}." -msgstr "" - -msgid "Generated pages for {note_count} notes in {locale}." -msgstr "" - -msgid "Generated pages for {person_count} people in {locale}." -msgstr "" - -msgid "Generated pages for {place_count} places in {locale}." -msgstr "" - -msgid "Generated pages for {source_count} sources in {locale}." -msgstr "" +#, fuzzy +msgid "Generated the listing HTML page for {entity_type} entities in {locale}." +msgstr "Pagina's voor {count} {entity_type} in het {locale} gegenereerd." msgid "Generating your site..." msgstr "Je site aan het genereren..." -msgid "Gramps configuration must be a mapping (dictionary)." -msgstr "De Gramps-configuratie moet een mapping (dictionary) zijn." - msgid "Help" msgstr "Hulp" @@ -587,9 +549,6 @@ msgstr "Taalregio" msgid "Locale aliases must not contain slashes." msgstr "Taalregioaliassen mogen geen schuine streep bevatten." -msgid "Locales configuration must be a list." -msgstr "Taalregioconfiguratie moet een lijst zijn." - msgid "Localization" msgstr "Taalregio" @@ -670,6 +629,9 @@ msgstr "Persoon" msgid "Person name" msgstr "Persoonsnaam" +msgid "Person names" +msgstr "Persoonsnamen" + msgid "Place" msgstr "Plaats" @@ -682,9 +644,6 @@ msgstr "" msgid "Pre-built assets are unavailable for {extension_names}." msgstr "" -msgid "Presence" -msgstr "Aanwezigheid" - msgid "Primary color (active)" msgstr "Primaire kleur (actief)" @@ -818,12 +777,6 @@ msgstr "Het ID van het op te halen document." msgid "The age at which people are presumed dead." msgstr "De leeftijd waarop mensen overleden verondersteld worden" -msgid "The author must be a string." -msgstr "De auteur moet een tekenreeks zijn." - -msgid "The base URL is required and must be a string." -msgstr "De basis-URL is vereist en moet een tekenreeks zijn." - msgid "The base URL must include a path." msgstr "De basis-URL moet een pad bevatten." @@ -855,18 +808,6 @@ msgstr "De verzameling van plaatsen." msgid "The collection of sources." msgstr "De verzameling van bronnen." -msgid "The configuration must be a mapping (dictionary)." -msgstr "De configuratie moet een mapping (dictionary) zijn." - -msgid "The entity ID is required." -msgstr "Het entiteits-ID is vereist." - -msgid "The entity ID must be a string." -msgstr "Het entiteits-ID moet een tekenreeks zijn." - -msgid "The entity reference must be a mapping (dictionary)." -msgstr "De entiteitsverwijzing moet een mapping (dictionary) zijn." - msgid "" "The entity reference must be for an entity of type " "{expected_entity_type_name} ({expected_entity_type_label}), but instead " @@ -878,45 +819,27 @@ msgstr "" " echter naar een entiteit van het type {actual_entity_type_name} " "({actual_entity_type_label})" -msgid "The entity type is required." -msgstr "Het entiteitstype is vereist." - msgid "The event." msgstr "De gebeurtenis." msgid "The file." msgstr "Het bestand." -msgid "The lifetime threshold must be a positive number." -msgstr "De levensgrens moet een positief getal zijn." - -msgid "The lifetime threshold must be an integer." -msgstr "De levensgrens moet een geheel getal zijn." +msgid "The key \"{configuration_key}\" is required." +msgstr "De sleutel \"{configuration_key}\" is vereist." msgid "The lifetime threshold must consist of digits only." msgstr "De levensgrens mag alleen uit cijfers bestaan." -msgid "The locale must be a string." -msgstr "De taalregio moet een tekenreeks zijn." - msgid "The person." msgstr "De persoon." msgid "The place." msgstr "De plaats." -msgid "The root path must be a string." -msgstr "Het bronpad moet een tekenreeks zijn." - msgid "The source." msgstr "De bron." -msgid "The theme configuration must be a mapping (dictionary)." -msgstr "De themaconfiguratie moet een mapping (dictionary) zijn." - -msgid "The title must be a string." -msgstr "De titel moet een tekenreeks zijn." - #, python-format msgid "The translations for {locale_name} are {coverage_percentage}% complete." msgstr "De vertalingen voor {locale_name} zijn {coverage_percentage}% compleet." @@ -949,6 +872,27 @@ msgid_plural "They had %(child_count)s children." msgstr[0] "Men had een kind." msgstr[1] "Men had %(child_count)skinderen." +msgid "This must be a boolean." +msgstr "Dit moet een booleaan zijn." + +msgid "This must be a decimal number." +msgstr "Dit moet een decimaal getal zijn." + +msgid "This must be a key-value mapping." +msgstr "Dit moet een sleutel-waardetoewijzing zijn." + +msgid "This must be a list." +msgstr "Dit moet een lijst zijn." + +msgid "This must be a positive number." +msgstr "Dit moet een positief getal zijn." + +msgid "This must be a string." +msgstr "Dit moet een tekenreeks zijn." + +msgid "This must be an integer." +msgstr "Dit moet een geheel getal zijn." + msgid "This person's details are unavailable to protect their privacy." msgstr "De gegevens van deze persoon zijn niet beschikbaar vanwege privacyredenen." @@ -982,6 +926,13 @@ msgstr "Onbevoegd" msgid "Unknown" msgstr "Onbekend" +msgid "" +"Unknown key: {unknown_configuration_key}. Did you mean " +"{known_configuration_keys}?" +msgstr "" +"Onbekende sleutel: {unknown_configuration_key}. " +"Bedoel je {known_configuration_keys}?" + #, python-format msgid "" "Use and to navigate results, or esc " @@ -1201,6 +1152,15 @@ msgstr "{dependent_label} vereist {dependency_labels}." msgid "{entity_type_label} ID" msgstr "{entity_type_label}-ID" +msgid "{entity_type} {entity_id}" +msgstr "" + +msgid "{extension_type} is not a valid Betty extension." +msgstr "\"{extension_type}\" is geen geldige Betty-extensie." + +msgid "{extension_type} is not configurable." +msgstr "\"{extension_type}\" kan niet ingesteld worden." + msgid "{individual_name} {affiliation_name}" msgstr "{individual_name} {affiliation_name}" diff --git a/betty/assets/locale/uk/LC_MESSAGES/betty.po b/betty/assets/locale/uk/LC_MESSAGES/betty.po index afad1de62..e641af068 100644 --- a/betty/assets/locale/uk/LC_MESSAGES/betty.po +++ b/betty/assets/locale/uk/LC_MESSAGES/betty.po @@ -54,12 +54,15 @@ msgid "" "" msgstr "" -msgid "\"{extension_type_name}\" is not a Betty extension." +msgid "\"{entity_type}\" is not a valid Betty entity type." msgstr "" msgid "\"{hex_value}\" is not a valid hexadecimal color, such as #ffc0cb." msgstr "" +msgid "\"{path}\" is not a directory." +msgstr "" + #, python-format msgid "%(date)s" msgstr "%(date)s" @@ -211,9 +214,6 @@ msgstr "" msgid "Anonymizer" msgstr "" -msgid "App extensions configuration must be a mapping (dictionary)." -msgstr "" - msgid "Appearances" msgstr "Знайдено в" @@ -229,9 +229,6 @@ msgstr "Хрещення" msgid "Beneficiary" msgstr "Бенефіціар" -msgid "Betty application configuration must be a mapping (dictionary)." -msgstr "" - msgid "" "Betty helps you visualize and publish your family history by building " "interactive genealogy websites out of your Wikipedia." msgstr "Продовжуйте читати у Вікіпедії." @@ -332,9 +327,6 @@ msgstr "Смерть" msgid "Debugging mode" msgstr "" -msgid "Debugging must be enabled (true) or disabled (false) with a boolean." -msgstr "" - msgid "" "Decide the correct page variety to serve users depending on their own " "preferences. This requires a web server that supports it." @@ -381,18 +373,12 @@ msgstr "Еміграція" msgid "Enable {extension}" msgstr "" -msgid "Enclosure" -msgstr "" - msgid "Engagement" msgstr "Заручини" msgid "Entity ID" msgstr "" -msgid "Entity references must be a list." -msgstr "" - msgid "Error" msgstr "" @@ -417,15 +403,6 @@ msgstr "Факти" msgid "Family" msgstr "" -msgid "Family tree configuration must be a mapping (dictionary)." -msgstr "" - -msgid "Family tree configuration requires a Gramps file to be set." -msgstr "" - -msgid "Family trees configuration is required and must must be a list." -msgstr "" - msgid "Featured entities" msgstr "" @@ -435,6 +412,9 @@ msgstr "" msgid "File path" msgstr "" +msgid "Files" +msgstr "" + msgid "" "Follow Betty on Twitter" " and Github." @@ -449,39 +429,21 @@ msgstr "Похорон" msgid "General" msgstr "" -msgid "Generate site" -msgstr "" - -msgid "Generated OpenAPI documentation in {locale}." -msgstr "" - -msgid "Generated pages for {citation_count} citations in {locale}." +msgid "Generate entity listing pages" msgstr "" -msgid "Generated pages for {event_count} events in {locale}." -msgstr "" - -msgid "Generated pages for {file_count} files in {locale}." -msgstr "" - -msgid "Generated pages for {note_count} notes in {locale}." -msgstr "" - -msgid "Generated pages for {person_count} people in {locale}." +msgid "Generate site" msgstr "" -msgid "Generated pages for {place_count} places in {locale}." +msgid "Generated pages for {count} {entity_type} in {locale}." msgstr "" -msgid "Generated pages for {source_count} sources in {locale}." +msgid "Generated the listing HTML page for {entity_type} entities in {locale}." msgstr "" msgid "Generating your site..." msgstr "" -msgid "Gramps configuration must be a mapping (dictionary)." -msgstr "" - msgid "Help" msgstr "Допомога" @@ -548,9 +510,6 @@ msgstr "" msgid "Locale aliases must not contain slashes." msgstr "" -msgid "Locales configuration must be a list." -msgstr "" - msgid "Localization" msgstr "" @@ -631,6 +590,9 @@ msgstr "" msgid "Person name" msgstr "" +msgid "Person names" +msgstr "" + msgid "Place" msgstr "Місце" @@ -643,9 +605,6 @@ msgstr "" msgid "Pre-built assets are unavailable for {extension_names}." msgstr "" -msgid "Presence" -msgstr "" - msgid "Primary color (active)" msgstr "" @@ -775,12 +734,6 @@ msgstr "" msgid "The age at which people are presumed dead." msgstr "" -msgid "The author must be a string." -msgstr "" - -msgid "The base URL is required and must be a string." -msgstr "" - msgid "The base URL must include a path." msgstr "" @@ -810,18 +763,6 @@ msgstr "" msgid "The collection of sources." msgstr "" -msgid "The configuration must be a mapping (dictionary)." -msgstr "" - -msgid "The entity ID is required." -msgstr "" - -msgid "The entity ID must be a string." -msgstr "" - -msgid "The entity reference must be a mapping (dictionary)." -msgstr "" - msgid "" "The entity reference must be for an entity of type " "{expected_entity_type_name} ({expected_entity_type_label}), but instead " @@ -829,45 +770,27 @@ msgid "" "({actual_entity_type_label})" msgstr "" -msgid "The entity type is required." -msgstr "" - msgid "The event." msgstr "" msgid "The file." msgstr "" -msgid "The lifetime threshold must be a positive number." -msgstr "" - -msgid "The lifetime threshold must be an integer." +msgid "The key \"{configuration_key}\" is required." msgstr "" msgid "The lifetime threshold must consist of digits only." msgstr "" -msgid "The locale must be a string." -msgstr "" - msgid "The person." msgstr "" msgid "The place." msgstr "" -msgid "The root path must be a string." -msgstr "" - msgid "The source." msgstr "" -msgid "The theme configuration must be a mapping (dictionary)." -msgstr "" - -msgid "The title must be a string." -msgstr "" - #, python-format msgid "The translations for {locale_name} are {coverage_percentage}% complete." msgstr "" @@ -903,6 +826,27 @@ msgstr[0] "У нього була дитина." msgstr[1] "У нього було %(child_count)s дитини." msgstr[2] "У нього було %(child_count)s дитини." +msgid "This must be a boolean." +msgstr "" + +msgid "This must be a decimal number." +msgstr "" + +msgid "This must be a key-value mapping." +msgstr "" + +msgid "This must be a list." +msgstr "" + +msgid "This must be a positive number." +msgstr "" + +msgid "This must be a string." +msgstr "" + +msgid "This must be an integer." +msgstr "" + msgid "This person's details are unavailable to protect their privacy." msgstr "Дані цієї особи недоступні для захисту їх конфіденційності." @@ -933,6 +877,11 @@ msgstr "Несанкціонована" msgid "Unknown" msgstr "Невідомо" +msgid "" +"Unknown key: {unknown_configuration_key}. Did you mean " +"{known_configuration_keys}?" +msgstr "" + #, python-format msgid "" "Use and to navigate results, or esc " @@ -1150,6 +1099,15 @@ msgstr "" msgid "{entity_type_label} ID" msgstr "" +msgid "{entity_type} {entity_id}" +msgstr "" + +msgid "{extension_type} is not a valid Betty extension." +msgstr "" + +msgid "{extension_type} is not configurable." +msgstr "" + msgid "{individual_name} {affiliation_name}" msgstr "{individual_name} {affiliation_name}" diff --git a/betty/cleaner/__init__.py b/betty/cleaner/__init__.py index 4aabd74bf..e9acbf300 100644 --- a/betty/cleaner/__init__.py +++ b/betty/cleaner/__init__.py @@ -1,18 +1,18 @@ +from typing import Set, Type, Dict, TYPE_CHECKING + from betty.anonymizer import Anonymizer from betty.app.extension import Extension, UserFacingExtension +from betty.load import PostLoader +from betty.model.ancestry import Ancestry, Place, File, Person, Event, Source, Citation try: + from graphlib_backport import TopologicalSorter +except ModuleNotFoundError: from graphlib import TopologicalSorter -except ImportError: - from graphlib_backport import TopologicalSorter # type: ignore -from typing import Set, Type, Dict, TYPE_CHECKING if TYPE_CHECKING: from betty.builtins import _ -from betty.model.ancestry import Ancestry, Place, File, Person, Event, Source, Citation -from betty.load import PostLoader - def clean(ancestry: Ancestry) -> None: _clean_people(ancestry) diff --git a/betty/cli.py b/betty/cli.py index 176629da2..394b1e797 100644 --- a/betty/cli.py +++ b/betty/cli.py @@ -9,6 +9,8 @@ from PyQt6.QtWidgets import QMainWindow +from betty.config.load import ConfigurationValidationError + if TYPE_CHECKING: from betty.builtins import _ @@ -18,7 +20,6 @@ from betty import about, cache, demo, generate, load, serve from betty.app import App from betty.asyncio import sync -from betty.config import ConfigurationError from betty.error import UserFacingError from betty.gui import BettyApplication from betty.gui.app import WelcomeWindow @@ -109,7 +110,7 @@ async def _init_ctx(ctx: Context, __: Optional[Option] = None, configuration_fil return if configuration_file_path is not None: - raise ConfigurationError(_('Configuration file "{configuration_file_path}" does not exist.').format(configuration_file_path=configuration_file_path)) + raise ConfigurationValidationError(_('Configuration file "{configuration_file_path}" does not exist.').format(configuration_file_path=configuration_file_path)) class _BettyCommands(click.MultiCommand): diff --git a/betty/config.py b/betty/config.py deleted file mode 100644 index 97b2f4cba..000000000 --- a/betty/config.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations - -import json -import os -from os import path -from pathlib import Path -from tempfile import TemporaryDirectory -from typing import Dict, Callable, TypeVar, Any, Generic, Optional, TYPE_CHECKING, Union, List, Hashable, \ - Iterable, overload, SupportsIndex - -import yaml -from reactives import reactive -from reactives.factory.type import ReactiveInstance - -from betty.classtools import Repr -from betty.error import UserFacingError, ContextError, ensure_context -from betty.os import PathLike, ChDir -from betty.typing import Void - -if TYPE_CHECKING: - from betty.builtins import _ - - -class ConfigurationError(UserFacingError, ContextError, ValueError): - pass - - -DumpedConfiguration = Union[Any, Void] - - -@overload -def _minimize_dumped_configuration_collection(dumped_configuration: List, keys: Iterable[SupportsIndex]) -> DumpedConfiguration: - pass - - -@overload -def _minimize_dumped_configuration_collection(dumped_configuration: Dict, keys: Iterable[Hashable]) -> DumpedConfiguration: - pass - - -def _minimize_dumped_configuration_collection(dumped_configuration, keys) -> DumpedConfiguration: - for key in keys: - dumped_configuration[key] = minimize_dumped_configuration(dumped_configuration[key]) - if dumped_configuration[key] is Void: - del dumped_configuration[key] - if len(dumped_configuration) > 0: - return dumped_configuration - return Void - - -def minimize_dumped_configuration(configuration: DumpedConfiguration) -> DumpedConfiguration: - if isinstance(configuration, list): - return _minimize_dumped_configuration_collection(configuration, reversed(range(len(configuration)))) - if isinstance(configuration, dict): - return _minimize_dumped_configuration_collection(configuration, list(configuration.keys())) - return configuration - - -@reactive -class Configuration(ReactiveInstance, Repr): - def load(self, dumped_configuration: DumpedConfiguration) -> None: - """ - Validate the dumped configuration and load it into self. - - Raises - ------ - betty.config.ConfigurationError - """ - - raise NotImplementedError - - def dump(self) -> DumpedConfiguration: - """ - Dump this configuration to a portable format. - """ - - raise NotImplementedError - - -ConfigurationT = TypeVar('ConfigurationT', bound=Configuration) - - -class FileBasedConfiguration(Configuration): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._project_directory: Optional[TemporaryDirectory] = None - self._configuration_file_path = None - self._autowrite = False - - def _assert_configuration_file_path(self) -> None: - if self.configuration_file_path is None: - raise RuntimeError('The configuration must have a configuration file path.') - - @property - def autowrite(self) -> bool: - return self._autowrite - - @autowrite.setter - def autowrite(self, autowrite: bool) -> None: - if autowrite: - self._assert_configuration_file_path() - if not self._autowrite: - self.react.react_weakref(self.write) - else: - self.react.shutdown(self.write) - self._autowrite = autowrite - - def write(self, configuration_file_path: Optional[PathLike] = None) -> None: - if configuration_file_path is None: - self._assert_configuration_file_path() - else: - self.configuration_file_path = configuration_file_path - - self._write(self.configuration_file_path) - - def _write(self, configuration_file_path: Path) -> None: - # Change the working directory to allow absolute paths to be turned relative to the configuration file's directory - # path. - with ChDir(configuration_file_path.parent): - dumped_configuration = _APP_CONFIGURATION_FORMATS[configuration_file_path.suffix].dumper(self) - try: - with open(configuration_file_path, mode='w') as f: - f.write(dumped_configuration) - except FileNotFoundError: - os.makedirs(configuration_file_path.parent) - self.write() - self._configuration_file_path = configuration_file_path - - def read(self, configuration_file_path: Optional[PathLike] = None) -> None: - if configuration_file_path is None: - self._assert_configuration_file_path() - else: - self.configuration_file_path = configuration_file_path - - # Change the working directory to allow relative paths to be resolved against the configuration file's directory - # path. - with ChDir(self.configuration_file_path.parent): - with ensure_context('in %s' % self.configuration_file_path.resolve()): - with open(self.configuration_file_path) as f: - read_configuration = f.read() - self.load(_APP_CONFIGURATION_FORMATS[self.configuration_file_path.suffix].loader(read_configuration)) - - def __del__(self): - if hasattr(self, '_project_directory') and self._project_directory is not None: - self._project_directory.cleanup() - - @reactive # type: ignore - @property - def configuration_file_path(self) -> Path: - if self._configuration_file_path is None: - if self._project_directory is None: - self._project_directory = TemporaryDirectory() - self._write(Path(self._project_directory.name) / f'{type(self).__name__}.json') - return self._configuration_file_path # type: ignore - - @configuration_file_path.setter - def configuration_file_path(self, configuration_file_path: PathLike) -> None: - configuration_file_path = Path(configuration_file_path) - if configuration_file_path == self._configuration_file_path: - return - if configuration_file_path.suffix not in _APP_CONFIGURATION_FORMATS: - raise ConfigurationError(f"Unknown file format \"{configuration_file_path.suffix}\". Supported formats are: {', '.join(APP_CONFIGURATION_FORMATS)}.") - self._configuration_file_path = configuration_file_path - - @configuration_file_path.deleter - def configuration_file_path(self) -> None: - if self._autowrite: - raise RuntimeError('Cannot remove the configuration file path while autowrite is enabled.') - self._configuration_file_path = None - - -class Configurable(Generic[ConfigurationT]): - def __init__(self, /, configuration: Optional[ConfigurationT] = None, *args, **kwargs): - super().__init__(*args, **kwargs) - self._configuration = configuration - - @property - def configuration(self) -> ConfigurationT: - if self._configuration is None: - raise RuntimeError(f'{self} has no configuration. {type(self)}.__init__() must ensure it is set.') - return self._configuration - - -def _from_json(configuration_json: str) -> Any: - try: - return json.loads(configuration_json) - except json.JSONDecodeError as e: - raise ConfigurationError(_('Invalid JSON: {error}.').format(error=e)) - - -def _from_yaml(configuration_yaml: str) -> Any: - try: - return yaml.safe_load(configuration_yaml) - except yaml.YAMLError as e: - raise ConfigurationError(_('Invalid YAML: {error}.').format(error=e)) - - -def _to_json(configuration: Configuration) -> str: - return json.dumps(configuration.dump()) - - -def _to_yaml(configuration: Configuration) -> str: - return yaml.safe_dump(configuration.dump()) - - -class _Format: - # These loaders must take a single argument, which is the configuration in its dumped format, as a string. They must - # return Configuration or raise ConfigurationError. - Loader = Callable[[str], Any] - # These dumpers must take a single argument, which is Configuration. They must return a single string. - Dumper = Callable[[Configuration], str] - - def __init__(self, loader: Loader, dumper: Dumper): - self.loader = loader - self.dumper = dumper - - -_APP_CONFIGURATION_FORMATS: Dict[str, _Format] = { - '.json': _Format(_from_json, _to_json), - '.yaml': _Format(_from_yaml, _to_yaml), - '.yml': _Format(_from_yaml, _to_yaml), -} - -APP_CONFIGURATION_FORMATS = set(_APP_CONFIGURATION_FORMATS.keys()) - - -def ensure_path(path_configuration: str) -> Path: - try: - return Path(path_configuration).expanduser().resolve() - except TypeError as e: - raise ConfigurationError(e) - - -def ensure_directory_path(path_configuration: str) -> Path: - ensured_path = ensure_path(path_configuration) - if not path.isdir(ensured_path): - raise ConfigurationError(f'"{ensured_path}" is not a directory.') - return ensured_path diff --git a/betty/config/__init__.py b/betty/config/__init__.py new file mode 100644 index 000000000..b1e0f40ab --- /dev/null +++ b/betty/config/__init__.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import os +from contextlib import suppress +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Dict, TypeVar, Generic, Optional, Iterable + +from reactives import reactive, scope +from reactives.factory.type import ReactiveInstance + +from betty.classtools import Repr, repr_instance +from betty.config.dump import DumpedConfigurationImport, DumpedConfigurationExport, \ + DumpedConfigurationDict, minimize_dict +from betty.config.format import FORMATS_BY_EXTENSION, EXTENSIONS +from betty.config.load import ConfigurationFormatError, Loader, ConfigurationLoadError +from betty.os import PathLike, ChDir + +try: + from typing_extensions import TypeGuard +except ModuleNotFoundError: + from typing import TypeGuard # type: ignore + + +@reactive +class Configuration(ReactiveInstance, Repr): + def load(self, dumped_configuration: DumpedConfigurationImport, loader: Loader) -> None: + """ + Validate the dumped configuration and prepare to load it into self. + + Implementations MUST: + - Use the loader to set configuration errors + - Use the loader to register callbacks that make the actual configuration updates + + Implementations MUST NOT: + - Raise configuration errors + - Update their own state as a direct result of this method being called + """ + + raise NotImplementedError + + def dump(self) -> DumpedConfigurationExport: + """ + Dump this configuration to a portable format. + """ + + raise NotImplementedError + + +ConfigurationT = TypeVar('ConfigurationT', bound=Configuration) + + +class FileBasedConfiguration(Configuration): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._project_directory: Optional[TemporaryDirectory] = None + self._configuration_file_path = None + self._autowrite = False + + def _assert_configuration_file_path(self) -> None: + if self.configuration_file_path is None: + raise ConfigurationLoadError('The configuration must have a configuration file path.') + + @property + def autowrite(self) -> bool: + return self._autowrite + + @autowrite.setter + def autowrite(self, autowrite: bool) -> None: + if autowrite: + self._assert_configuration_file_path() + if not self._autowrite: + self.react.react_weakref(self.write) + else: + self.react.shutdown(self.write) + self._autowrite = autowrite + + def write(self, configuration_file_path: Optional[PathLike] = None) -> None: + if configuration_file_path is None: + self._assert_configuration_file_path() + else: + self.configuration_file_path = configuration_file_path + + self._write(self.configuration_file_path) + + def _write(self, configuration_file_path: Path) -> None: + # Change the working directory to allow absolute paths to be turned relative to the configuration file's directory + # path. + with ChDir(configuration_file_path.parent): + dumped_configuration = FORMATS_BY_EXTENSION[configuration_file_path.suffix[1:]].dump(self.dump()) + try: + with open(configuration_file_path, mode='w') as f: + f.write(dumped_configuration) + except FileNotFoundError: + os.makedirs(configuration_file_path.parent) + self.write() + self._configuration_file_path = configuration_file_path + + def read(self, configuration_file_path: Optional[PathLike] = None) -> None: + if configuration_file_path is None: + self._assert_configuration_file_path() + else: + self.configuration_file_path = configuration_file_path + + # Change the working directory to allow relative paths to be resolved against the configuration file's directory + # path. + with ChDir(self.configuration_file_path.parent): + with open(self.configuration_file_path) as f: + read_configuration = f.read() + loader = Loader() + with loader.context('in %s' % self.configuration_file_path.resolve()): + self.load(FORMATS_BY_EXTENSION[self.configuration_file_path.suffix[1:]].load(read_configuration), loader) + loader.commit() + + def __del__(self): + if hasattr(self, '_project_directory') and self._project_directory is not None: + self._project_directory.cleanup() + + @reactive # type: ignore + @property + def configuration_file_path(self) -> Path: + if self._configuration_file_path is None: + if self._project_directory is None: + self._project_directory = TemporaryDirectory() + self._write(Path(self._project_directory.name) / f'{type(self).__name__}.json') + return self._configuration_file_path # type: ignore + + @configuration_file_path.setter + def configuration_file_path(self, configuration_file_path: PathLike) -> None: + configuration_file_path = Path(configuration_file_path) + if configuration_file_path == self._configuration_file_path: + return + if configuration_file_path.suffix[1:] not in EXTENSIONS: + raise ConfigurationFormatError(f"Unknown file format \"{configuration_file_path.suffix}\". Supported formats are: {', '.join(map(lambda x: f'.{x}', EXTENSIONS))}.") + self._configuration_file_path = configuration_file_path + + @configuration_file_path.deleter + def configuration_file_path(self) -> None: + if self._autowrite: + raise RuntimeError('Cannot remove the configuration file path while autowrite is enabled.') + self._configuration_file_path = None + + +ConfigurationKeyT = TypeVar('ConfigurationKeyT') + + +class ConfigurationMapping(Configuration, Generic[ConfigurationKeyT, ConfigurationT]): + def __init__(self, configurations: Optional[Iterable[ConfigurationT]] = None): + super().__init__() + self._configurations: Dict[ConfigurationKeyT, ConfigurationT] = {} + if configurations is not None: + for configuration in configurations: + self.add(configuration) + + def __contains__(self, item) -> bool: + return item in self._configurations + + @scope.register_self + def __getitem__(self, configuration_key: ConfigurationKeyT) -> ConfigurationT: + try: + return self._configurations[configuration_key] + except KeyError: + item = self._default_configuration_item(configuration_key) + self.add(item) + return item + + def __delitem__(self, configuration_key: ConfigurationKeyT) -> None: + self.remove(configuration_key) + + @scope.register_self + def __iter__(self) -> Iterable[ConfigurationT]: + return (configuration for configuration in self._configurations.values()) + + @scope.register_self + def __len__(self) -> int: + return len(self._configurations) + + def __repr__(self): + return repr_instance(self, configurations=list(self._configurations.values())) + + @scope.register_self + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return self._configurations == other._configurations + + def remove(self, *configuration_keys: ConfigurationKeyT) -> None: + for configuration_key in configuration_keys: + with suppress(KeyError): + self._configurations[configuration_key].react.shutdown(self) + del self._configurations[configuration_key] + self.react.trigger() + + def clear(self) -> None: + self.remove(*self._configurations.keys()) + + def add(self, *configurations: ConfigurationT) -> None: + for configuration in configurations: + if self._get_key(configuration) not in self._configurations: + self._configurations[self._get_key(configuration)] = configuration + configuration.react(self) + self.react.trigger() + + def load(self, dumped_configuration: DumpedConfigurationImport, loader: Loader) -> None: + if loader.assert_dict(dumped_configuration): + loader.on_commit(self.clear) + loader.assert_mapping( + dumped_configuration, + self._load_configuration, # type: ignore + ) + + def _load_configuration(self, dumped_configuration: DumpedConfigurationImport, loader: Loader, dumped_configuration_key: str) -> TypeGuard[DumpedConfigurationDict[DumpedConfigurationImport]]: + with loader.context() as errors: + with loader.catch(): + configuration_key = self._load_key(dumped_configuration_key) + configuration = self._default_configuration_item(configuration_key) + configuration.load(dumped_configuration, loader) + loader.on_commit(lambda: self.add(configuration)) + return errors.valid + + def dump(self) -> DumpedConfigurationExport: + return minimize_dict({ + self._dump_key(self._get_key(configuration)): configuration.dump() + for configuration in self._configurations.values() + }, self._is_void_empty()) + + def _get_key(self, configuration: ConfigurationT) -> ConfigurationKeyT: + raise NotImplementedError + + def _load_key(self, dumped_configuration_key: str) -> ConfigurationKeyT: + raise NotImplementedError + + def _dump_key(self, configuration_key: ConfigurationKeyT) -> str: + raise NotImplementedError + + def _default_configuration_item(self, configuration_key: ConfigurationKeyT) -> ConfigurationT: + raise NotImplementedError + + def _is_void_empty(self) -> bool: + return False + + +class Configurable(Generic[ConfigurationT]): + def __init__(self, /, configuration: Optional[ConfigurationT] = None, *args, **kwargs): + super().__init__(*args, **kwargs) + self._configuration = configuration + + @property + def configuration(self) -> ConfigurationT: + if self._configuration is None: + raise RuntimeError(f'{self} has no configuration. {type(self)}.__init__() must ensure it is set.') + return self._configuration diff --git a/betty/config/dump.py b/betty/config/dump.py new file mode 100644 index 000000000..3d7442806 --- /dev/null +++ b/betty/config/dump.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import Union, TypeVar, Mapping, Sequence, Type, Any + +from betty.typing import Void + +try: + from typing_extensions import TypeAlias +except ModuleNotFoundError: + from typing import TypeAlias # type: ignore + + +DumpedConfigurationType: TypeAlias = Union[ + bool, + int, + float, + str, + None, + list, + dict, +] +DumpedConfigurationTypeT = TypeVar('DumpedConfigurationTypeT', bound=DumpedConfigurationType) + + +DumpedConfigurationImport: TypeAlias = Union[ + bool, + int, + float, + str, + None, + Sequence['DumpedConfigurationImport'], + Mapping[str, 'DumpedConfigurationImport'], +] +DumpedConfigurationImportT = TypeVar('DumpedConfigurationImportT', bound=DumpedConfigurationImport) +DumpedConfigurationImportU = TypeVar('DumpedConfigurationImportU', bound=DumpedConfigurationImport) + + +DumpedConfigurationExport: TypeAlias = Union[ + bool, + int, + float, + str, + None, + Sequence['DumpedConfigurationExport'], + Mapping[str, 'DumpedConfigurationExport'], + Type[Void], +] +DumpedConfigurationExportT = TypeVar('DumpedConfigurationExportT', bound=DumpedConfigurationExport) +DumpedConfigurationExportU = TypeVar('DumpedConfigurationExportU', bound=DumpedConfigurationExport) + + +DumpedConfiguration: TypeAlias = Union[DumpedConfigurationImport, DumpedConfigurationExport] +DumpedConfigurationT = TypeVar('DumpedConfigurationT', bound=DumpedConfiguration) + + +DumpedConfigurationList: TypeAlias = Sequence[DumpedConfigurationT] + + +DumpedConfigurationDict: TypeAlias = Mapping[str, DumpedConfigurationT] + + +DumpedConfigurationCollection: TypeAlias = Union[DumpedConfigurationList, DumpedConfigurationDict] + + +def _minimize_collection(dumped_configuration: Any, keys: Any) -> None: + for key in keys: + dumped_configuration[key] = minimize(dumped_configuration[key]) + if dumped_configuration[key] is Void: + del dumped_configuration[key] + + +def minimize_list(dumped_configuration: DumpedConfigurationList[DumpedConfigurationExportT]) -> Union[DumpedConfigurationList[DumpedConfigurationExportT], Type[Void]]: + _minimize_collection(dumped_configuration, reversed(range(len(dumped_configuration)))) + if not len(dumped_configuration): + return Void + return dumped_configuration + + +def minimize_dict(dumped_configuration: DumpedConfigurationDict[DumpedConfigurationExportT], void_empty: bool = False) -> Union[DumpedConfigurationDict[DumpedConfigurationExportT], Type[Void]]: + _minimize_collection(dumped_configuration, list(dumped_configuration.keys())) + if not void_empty or len(dumped_configuration) > 0: + return dumped_configuration + return Void + + +def minimize(dumped_configuration: DumpedConfigurationExport) -> Union[DumpedConfigurationExport, Type[Void]]: + if isinstance(dumped_configuration, list): + return minimize_list(dumped_configuration) + if isinstance(dumped_configuration, dict): + return minimize_dict(dumped_configuration) + return dumped_configuration diff --git a/betty/config/error.py b/betty/config/error.py new file mode 100644 index 000000000..ca47a50e9 --- /dev/null +++ b/betty/config/error.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import copy +from textwrap import indent +from typing import Iterator, Generic, TypeVar, Tuple, cast + +from betty.classtools import Repr +from betty.error import UserFacingError + + +class ConfigurationError(UserFacingError, ValueError): + """ + A configuration error. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._contexts: Tuple[str, ...] = () + + def __str__(self): + return (super().__str__() + '\n' + indent('\n'.join(self._contexts), '- ')).strip() + + @property + def contexts(self) -> Tuple[str, ...]: + return self._contexts + + def with_context(self, *contexts: str) -> ConfigurationError: + """ + Add a message describing the error's context. + """ + self_copy = copy.copy(self) + self_copy._contexts = (*self._contexts, *contexts) + return self_copy + + +ConfigurationErrorT = TypeVar('ConfigurationErrorT', bound=ConfigurationError) + + +class ConfigurationErrorCollection(ConfigurationError, Generic[ConfigurationErrorT], Repr): + """ + A collection of zero or more configuration errors. + """ + + def __init__(self): + super().__init__() + self._errors = [] + + def __iter__(self) -> Iterator[ConfigurationErrorT]: + yield from self._errors + + def flatten(self) -> Iterator[ConfigurationError]: + for error in self._errors: + if isinstance(error, ConfigurationErrorCollection): + yield from error.flatten() + else: + yield error + + def __str__(self) -> str: + return '\n'.join(map(str, self._errors)) + + def __len__(self) -> int: + return len(self._errors) + + @property + def valid(self) -> bool: + return len(self._errors) == 0 + + def with_context(self, *contexts: str) -> ConfigurationErrorCollection: + self_copy = cast(ConfigurationErrorCollection, super().with_context(*contexts)) + self_copy._errors = [error.with_context(*contexts) for error in self._errors] + return self_copy + + def append(self, *errors: ConfigurationErrorT) -> None: + for error in errors: + self._errors.append(error.with_context(*self._contexts)) diff --git a/betty/config/format.py b/betty/config/format.py new file mode 100644 index 000000000..ff33a591c --- /dev/null +++ b/betty/config/format.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import json +from typing import Set, List, Dict, TYPE_CHECKING + +import yaml + +from betty.config.dump import DumpedConfigurationImport, DumpedConfigurationExport +from betty.config.load import ConfigurationFormatError + +if TYPE_CHECKING: + from betty.builtins import _ + + +class Format: + @property + def extensions(self) -> Set[str]: + raise NotImplementedError + + def load(self, dumped_configuration: str) -> DumpedConfigurationImport: + raise NotImplementedError + + def dump(self, dumped_configuration: DumpedConfigurationExport) -> str: + raise NotImplementedError + + +class Json(Format): + @property + def extensions(self) -> Set[str]: + return {'json'} + + def load(self, dumped_configuration: str) -> DumpedConfigurationImport: + try: + return json.loads(dumped_configuration) + except json.JSONDecodeError as e: + raise ConfigurationFormatError(_('Invalid JSON: {error}.').format(error=e)) + + def dump(self, dumped_configuration: DumpedConfigurationExport) -> str: + return json.dumps(dumped_configuration) + + +class Yaml(Format): + @property + def extensions(self) -> Set[str]: + return {'yaml', 'yml'} + + def load(self, dumped_configuration: str) -> DumpedConfigurationImport: + try: + return yaml.safe_load(dumped_configuration) + except yaml.YAMLError as e: + raise ConfigurationFormatError(_('Invalid YAML: {error}.').format(error=e)) + + def dump(self, dumped_configuration: DumpedConfigurationExport) -> str: + return yaml.safe_dump(dumped_configuration) + + +FORMATS: List[Format] = [ + Json(), + Yaml(), +] + +FORMATS_BY_EXTENSION: Dict[str, Format] = { + extension: _format + for _format in FORMATS + for extension in _format.extensions +} + +EXTENSIONS: List[str] = [ + extension + for _format in FORMATS + for extension in _format.extensions +] diff --git a/betty/config/load.py b/betty/config/load.py new file mode 100644 index 000000000..ded029c1c --- /dev/null +++ b/betty/config/load.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import inspect +from contextlib import contextmanager +from os import path +from pathlib import Path +from typing import TYPE_CHECKING, Iterator, Type, Dict, Union, Callable, Optional, Tuple, Any, ContextManager, List, \ + Generic + +from reactives.factory.property import _ReactiveProperty + +from betty.config.dump import DumpedConfigurationImport, DumpedConfigurationImportT, DumpedConfigurationImportU, \ + DumpedConfigurationTypeT, DumpedConfigurationType, DumpedConfigurationDict, DumpedConfigurationList +from betty.config.error import ConfigurationError, ConfigurationErrorCollection + +try: + from typing_extensions import TypeAlias, TypeGuard +except ModuleNotFoundError: + from typing import TypeAlias, TypeGuard # type: ignore + +if TYPE_CHECKING: + from betty.builtins import _ + + +class ConfigurationLoadError(ConfigurationError): + pass + + +class ConfigurationValidationError(ConfigurationLoadError): + pass + + +class ConfigurationFormatError(ConfigurationLoadError): + pass + + +_TYPE_VIOLATION_ERROR_MESSAGE_BUILDERS: Dict[Type, Callable[[], str]] = { + bool: lambda: _('This must be a boolean.'), + int: lambda: _('This must be an integer.'), + float: lambda: _('This must be a decimal number.'), + str: lambda: _('This must be a string.'), + list: lambda: _('This must be a list.'), + dict: lambda: _('This must be a key-value mapping.'), +} + + +ConfigurationValueAssertFunction: TypeAlias = Callable[ + [ + DumpedConfigurationImport, + 'Loader', + ], + TypeGuard[DumpedConfigurationImportT], +] + + +ConfigurationValueAssertLoaderMethod: TypeAlias = Callable[ + [ + DumpedConfigurationImport, + ], + TypeGuard[DumpedConfigurationImportT], +] + + +ConfigurationValueAssert: TypeAlias = Union[ + ConfigurationValueAssertFunction[DumpedConfigurationImportT], + ConfigurationValueAssertLoaderMethod[DumpedConfigurationImportT], +] + + +ConfigurationKeyAndValueAssert: TypeAlias = Callable[ + [ + DumpedConfigurationImport, + 'Loader', + str, + ], + TypeGuard[DumpedConfigurationImportT], +] + + +ConfigurationAssert: TypeAlias = Union[ + ConfigurationValueAssert[DumpedConfigurationImportT], + ConfigurationKeyAndValueAssert[DumpedConfigurationImportT], +] + + +Committer = Callable[[], None] + + +FieldCommitter = Callable[[DumpedConfigurationImportT], None] + + +class Field(Generic[DumpedConfigurationImportT]): + def __init__( + self, + required: bool, + configuration_assert: ConfigurationAssert[DumpedConfigurationImportT], + committer: Optional[FieldCommitter[DumpedConfigurationImportT]] = None, + ): + self._required = required + self._configuration_assert = configuration_assert + self._committer = committer + + @property + def required(self) -> bool: + return self._required + + @property + def configuration_assert(self) -> ConfigurationAssert[DumpedConfigurationImportT]: + return self._configuration_assert + + def commit(self, dumped_configuration: DumpedConfigurationImportT) -> None: + if self._committer: + self._committer(dumped_configuration) + + +class Loader: + def __init__(self): + self._errors: ConfigurationErrorCollection[ConfigurationLoadError] = ConfigurationErrorCollection() + self._committers = [] + self._committed = False + + @property + def errors(self) -> ConfigurationErrorCollection: + return self._errors + + def _assert_uncommitted(self) -> None: + if self._committed: + raise RuntimeError('This load was committed already.') + + def on_commit(self, committer: Committer) -> None: + self._assert_uncommitted() + self._committers.append(committer) + + def error(self, *errors: ConfigurationLoadError) -> None: + self._assert_uncommitted() + self._errors.append(*errors) + + @contextmanager + def catch(self) -> Iterator[None]: + try: + yield + except ConfigurationLoadError as e: + self.error(e) + + @contextmanager + def context(self, context: Optional[str] = None) -> Iterator[ConfigurationErrorCollection]: + context_errors: ConfigurationErrorCollection = ConfigurationErrorCollection() + if context: + context_errors = context_errors.with_context(context) + previous_errors = self._errors + self._errors = context_errors + yield context_errors + self._errors = previous_errors + self.error(*context_errors) + + def commit(self) -> None: + if not self._errors.valid: + raise self._errors + if not self._committed: + self._committed = True + for committer in self._committers: + committer() + + def _assert(self, dumped_configuration: DumpedConfigurationImport, configuration_assert: ConfigurationAssert[DumpedConfigurationImportT], configuration_key: Optional[str] = None) -> TypeGuard[DumpedConfigurationImportT]: + args: List[Any] = [dumped_configuration] + if configuration_assert not in map(lambda x: x[1], inspect.getmembers(self)): + args.append(self) + if configuration_key and len(inspect.signature(configuration_assert).parameters) > len(args): + args.append(configuration_key) + return configuration_assert(*args) + + def _assert_type(self, dumped_configuration: DumpedConfigurationImportU, configuration_value_required_type: Type[DumpedConfigurationTypeT], configuration_value_disallowed_type: Optional[Type[DumpedConfigurationType]] = None) -> TypeGuard[DumpedConfigurationTypeT]: + if isinstance(dumped_configuration, configuration_value_required_type) and (not configuration_value_disallowed_type or not isinstance(dumped_configuration, configuration_value_disallowed_type)): + return True + self.error(ConfigurationValidationError(_TYPE_VIOLATION_ERROR_MESSAGE_BUILDERS[configuration_value_required_type]())) + return False + + def assert_bool(self, dumped_configuration: DumpedConfigurationImport) -> TypeGuard[bool]: + return self._assert_type(dumped_configuration, bool) + + def assert_int(self, dumped_configuration: DumpedConfigurationImport) -> TypeGuard[int]: + return self._assert_type(dumped_configuration, int, bool) + + def assert_float(self, dumped_configuration: DumpedConfigurationImport) -> TypeGuard[float]: + return self._assert_type(dumped_configuration, float) + + def assert_str(self, dumped_configuration: DumpedConfigurationImport) -> TypeGuard[str]: + return self._assert_type(dumped_configuration, str) + + def assert_list(self, dumped_configuration: DumpedConfigurationImport) -> TypeGuard[DumpedConfigurationList]: + return self._assert_type(dumped_configuration, list) + + def assert_sequence(self, dumped_configuration: DumpedConfigurationImport, configuration_value_assert: ConfigurationValueAssert[DumpedConfigurationImportT]) -> TypeGuard[DumpedConfigurationList[DumpedConfigurationImportT]]: + with self.context() as errors: + if self.assert_list(dumped_configuration): + for i, dumped_configuration_item in enumerate(dumped_configuration): + with self.context(str(i)): + self._assert(dumped_configuration_item, configuration_value_assert) + return errors.valid + + def assert_dict(self, dumped_configuration: DumpedConfigurationImport) -> TypeGuard[DumpedConfigurationDict[int]]: + return self._assert_type(dumped_configuration, dict) + + @contextmanager + def _assert_key( + self, + dumped_configuration: DumpedConfigurationImport, + configuration_key: str, + configuration_assert: ConfigurationAssert[DumpedConfigurationImportT], + required: bool, + ) -> Iterator[Tuple[DumpedConfigurationImport, bool]]: + if self.assert_dict(dumped_configuration): + with self.context(configuration_key): + if configuration_key in dumped_configuration: + dumped_configuration_item = dumped_configuration[configuration_key] + if self._assert(dumped_configuration_item, configuration_assert, configuration_key): + yield dumped_configuration_item, True + return + elif required: + self.error(ConfigurationValidationError(_('The key "{configuration_key}" is required.').format( + configuration_key=configuration_key + ))) + yield None, False + + def assert_required_key( + self, + dumped_configuration: DumpedConfigurationImport, + configuration_key: str, + configuration_assert: ConfigurationAssert[DumpedConfigurationImportT], + ) -> ContextManager[Tuple[DumpedConfigurationImport, bool]]: + return self._assert_key( # type: ignore + dumped_configuration, + configuration_key, + configuration_assert, + True, + ) + + def assert_optional_key( + self, + dumped_configuration: DumpedConfigurationImport, + configuration_key: str, + configuration_assert: ConfigurationAssert[DumpedConfigurationImportT], + ) -> ContextManager[Tuple[Optional[DumpedConfigurationImportT], bool]]: + return self._assert_key( # type: ignore + dumped_configuration, + configuration_key, + configuration_assert, + False, + ) + + def assert_mapping(self, dumped_configuration: DumpedConfigurationImport, configuration_assert: ConfigurationAssert[DumpedConfigurationImportT]) -> TypeGuard[Dict[str, DumpedConfigurationImportT]]: + with self.context() as errors: + if self.assert_dict(dumped_configuration): + for configuration_key, dumped_configuration_item in dumped_configuration.items(): + with self.context(configuration_key): + self._assert(dumped_configuration_item, configuration_assert, configuration_key) + return errors.valid + + def assert_record( + self, + dumped_configuration: DumpedConfigurationImport, + fields: Dict[str, Field], + ) -> TypeGuard[Dict[str, DumpedConfigurationImport]]: + if not fields: + raise ValueError('One or more fields are required.') + with self.context() as errors: + if self.assert_dict(dumped_configuration): + known_configuration_keys = set(fields.keys()) + unknown_configuration_keys = set(dumped_configuration.keys()) - known_configuration_keys + for unknown_configuration_key in unknown_configuration_keys: + with self.context(unknown_configuration_key): + self.error(ConfigurationValidationError(_('Unknown key: {unknown_configuration_key}. Did you mean {known_configuration_keys}?').format( + unknown_configuration_key=f'"{unknown_configuration_key}"', + known_configuration_keys=', '.join(map(lambda x: f'"{x}"', sorted(known_configuration_keys))) + ))) + for field_name, field in fields.items(): + configuration_item_assert = self.assert_required_key if field.required else self.assert_optional_key + with configuration_item_assert(dumped_configuration, field_name, field._configuration_assert) as (dumped_configuration_item, valid): + if valid: + field.commit(dumped_configuration_item) + return errors.valid + + def assert_path(self, dumped_path: DumpedConfigurationImport) -> TypeGuard[str]: + if self.assert_str(dumped_path): + Path(dumped_path).expanduser().resolve() + return True + return False + + def assert_directory_path(self, dumped_path: DumpedConfigurationImport) -> TypeGuard[str]: + if self.assert_str(dumped_path): + self.assert_path(dumped_path) + if path.isdir(dumped_path): + return True + self.error(ConfigurationValidationError(_('"{path}" is not a directory.').format(path=dumped_path))) + return False + + def assert_setattr(self, instance: Any, attr_name: str, value: Any) -> None: + with self.catch(): + if hasattr(type(instance), attr_name): + attr = getattr(type(instance), attr_name) + if isinstance(attr, _ReactiveProperty): + attr = attr._decorated_property + if not isinstance(attr, property): + raise RuntimeError(f'Cannot automatically load the configuration for property {type(instance)}.{attr_name}.') + for validator in getattr(attr.fset, '_betty_configuration_validators', ()): + value = validator(instance, value) + # Ensure that the attribute exists. + getattr(instance, attr_name) + self.on_commit(lambda: setattr(instance, attr_name, value)) diff --git a/betty/config/validate.py b/betty/config/validate.py new file mode 100644 index 000000000..dc6d3e41c --- /dev/null +++ b/betty/config/validate.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Union, Any, Callable + +from betty.config.load import ConfigurationValidationError + +try: + from typing_extensions import TypeAlias +except ModuleNotFoundError: + from typing import TypeAlias # type: ignore + +if TYPE_CHECKING: + from betty.builtins import _ + + +Instance: TypeAlias = Any +Value: TypeAlias = Any +Validator: TypeAlias = Callable[[Instance, Value], Value] + + +def validate_positive_number(__, value: Union[int, float]) -> Union[int, float]: + if value <= 0: + raise ConfigurationValidationError(_('This must be a positive number.')) + return value + + +def validate(*validators: Validator) -> Callable[[Callable], Callable[[Instance, Value], None]]: + def build_validated_setter(f: Callable) -> Callable[[Instance, Value], None]: + def validated_setter(instance: Instance, value: Value) -> None: + for validator in validators: + value = validator(instance, value) + f(instance, value) + validated_setter._betty_configuration_validators = validators # type: ignore + return validated_setter + return build_validated_setter diff --git a/betty/cotton_candy/__init__.py b/betty/cotton_candy/__init__.py index 0b9108a04..83ed42383 100644 --- a/betty/cotton_candy/__init__.py +++ b/betty/cotton_candy/__init__.py @@ -10,14 +10,16 @@ from betty.app import Extension from betty.app.extension import ConfigurableExtension, Theme -from betty.config import Configuration, ConfigurationError, DumpedConfiguration, minimize_dumped_configuration +from betty.config import Configuration, DumpedConfigurationImport, DumpedConfigurationExport +from betty.config.dump import minimize_dict +from betty.config.load import ConfigurationValidationError, Loader, Field +from betty.config.validate import validate from betty.cotton_candy.search import Index -from betty.error import ensure_context from betty.generate import Generator from betty.gui import GuiBuilder from betty.jinja2 import Jinja2Provider from betty.npm import _Npm, NpmBuilder, npm -from betty.project import EntityReferences +from betty.project import EntityReferenceCollection if TYPE_CHECKING: from betty.builtins import _ @@ -31,28 +33,26 @@ def __init__(self, hex_value: str): self._hex: str self.hex = hex_value + def _validate_hex(self, hex_value: str) -> str: + if not self._HEX_PATTERN.match(hex_value): + raise ConfigurationValidationError(_('"{hex_value}" is not a valid hexadecimal color, such as #ffc0cb.').format(hex_value=hex_value)) + return hex_value + @reactive # type: ignore @property def hex(self) -> str: return self._hex @hex.setter + @validate(_validate_hex) def hex(self, hex_value: str) -> None: - if hex_value is None: - self._hex = None - return - - if not self._HEX_PATTERN.match(hex_value): - raise ConfigurationError(_('"{hex_value}" is not a valid hexadecimal color, such as #ffc0cb.').format(hex_value=hex_value)) - self._hex = hex_value - def load(self, dumped_configuration: DumpedConfiguration) -> None: - if not isinstance(dumped_configuration, str): - raise ConfigurationError(_('Color configuration must be a string.')) - self.hex = dumped_configuration + def load(self, dumped_configuration: DumpedConfigurationImport, loader: Loader) -> None: + if loader.assert_str(dumped_configuration): + loader.assert_setattr(self, 'hex', dumped_configuration) - def dump(self) -> DumpedConfiguration: + def dump(self) -> DumpedConfigurationExport: return self._hex @@ -64,7 +64,7 @@ class CottonCandyConfiguration(Configuration): def __init__(self): super().__init__() - self._featured_entities = EntityReferences() + self._featured_entities = EntityReferenceCollection() self._featured_entities.react(self) self._primary_inactive_color = _ColorConfiguration(self.DEFAULT_PRIMARY_INACTIVE_COLOR) self._primary_inactive_color.react(self) @@ -76,7 +76,7 @@ def __init__(self): self._link_active_color.react(self) @property - def featured_entities(self) -> EntityReferences: + def featured_entities(self) -> EntityReferenceCollection: return self._featured_entities @property @@ -95,38 +95,38 @@ def link_inactive_color(self) -> _ColorConfiguration: def link_active_color(self) -> _ColorConfiguration: return self._link_active_color - def load(self, dumped_configuration: DumpedConfiguration) -> None: - if not isinstance(dumped_configuration, dict): - raise ConfigurationError(_('The theme configuration must be a mapping (dictionary).')) - - if 'featured_entities' in dumped_configuration: - with ensure_context('featured_entities'): - self._featured_entities.load(dumped_configuration['featured_entities']) - - if 'primary_inactive_color' in dumped_configuration: - with ensure_context('primary_inactive_color'): - self._primary_inactive_color.load(dumped_configuration['primary_inactive_color']) - - if 'primary_active_color' in dumped_configuration: - with ensure_context('primary_active_color'): - self._primary_active_color.load(dumped_configuration['primary_active_color']) - - if 'link_inactive_color' in dumped_configuration: - with ensure_context('link_inactive_color'): - self._link_inactive_color.load(dumped_configuration['link_inactive_color']) - - if 'link_active_color' in dumped_configuration: - with ensure_context('link_active_color'): - self._link_active_color.load(dumped_configuration['link_active_color']) + def load(self, dumped_configuration: DumpedConfigurationImport, loader: Loader) -> None: + loader.assert_record(dumped_configuration, { + 'featured_entities': Field( + False, + self._featured_entities.load, # type: ignore + ), + 'primary_inactive_color': Field( + False, + self._primary_inactive_color.load, # type: ignore + ), + 'primary_active_color': Field( + False, + self._primary_active_color.load, # type: ignore + ), + 'link_inactive_color': Field( + False, + self._link_inactive_color.load, # type: ignore + ), + 'link_active_color': Field( + False, + self._link_active_color.load, # type: ignore + ), + }) - def dump(self) -> DumpedConfiguration: - return minimize_dumped_configuration({ + def dump(self) -> DumpedConfigurationExport: + return minimize_dict({ 'featured_entities': self.featured_entities.dump(), 'primary_inactive_color': self._primary_inactive_color.dump(), 'primary_active_color': self._primary_active_color.dump(), 'link_inactive_color': self._link_inactive_color.dump(), 'link_active_color': self._link_active_color.dump(), - }) + }, True) @reactive diff --git a/betty/cotton_candy/assets/public/localized/index.html.j2 b/betty/cotton_candy/assets/public/localized/index.html.j2 index db5cfc940..f5192dda1 100644 --- a/betty/cotton_candy/assets/public/localized/index.html.j2 +++ b/betty/cotton_candy/assets/public/localized/index.html.j2 @@ -27,7 +27,7 @@ diff --git a/betty/cotton_candy/assets/templates/entity/featured.html.j2 b/betty/cotton_candy/assets/templates/entity/featured.html.j2 index 01f41bb80..4cbc37949 100644 --- a/betty/cotton_candy/assets/templates/entity/featured.html.j2 +++ b/betty/cotton_candy/assets/templates/entity/featured.html.j2 @@ -1,4 +1,4 @@ -{% set entity_type_name_file_part = entity.entity_type() | entity_type_name | camel_case_to_kebab_case %} +{% set entity_type_name_file_part = entity | entity_type_name | camel_case_to_kebab_case %}