diff --git a/.coveragerc b/.coveragerc index 1bb5b027d..bbd146fee 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,7 +2,8 @@ source = betty omit = betty/_package/* - */test*.py + betty/tests/* + betty/pytests/* [report] exclude_lines = diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eabee4ac5..f8bc5ef27 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,7 +66,13 @@ jobs: - name: Install APT dependencies if: startsWith(runner.os, 'Linux') - run: sudo apt-get install libxml2-dev libxslt1-dev + run: sudo apt-get install herbstluftwm libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxml2-dev libxslt1-dev xvfb + + - name: Launch the window manager + if: startsWith(runner.os, 'Linux') + run: | + herbstluftwm & + sleep 1 - name: Install Homebrew dependencies if: startsWith(runner.os, 'macOS') diff --git a/README.md b/README.md index c0e5f71a0..2960e8aae 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) @@ -63,6 +64,7 @@ Options: Commands: clear-caches Clear all caches. demo Explore a demonstration site. + gui Open Betty's graphical user interface (GUI). generate Generate a static site. serve Serve a generated site. ``` @@ -81,6 +83,8 @@ locales: - locale: en-US alias: en - locale: nl +theme: + background_image_id: O0301 assets_directory_path: ./resources extensions: betty.extension.anonymizer.Anonymizer: ~ @@ -113,8 +117,10 @@ 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. -- `assets_directory_path` (optional); The path to a directory containing overrides for any of Betty's - [assets](./betty/assets). +- `assets` (optional); The path to a directory containing overrides for any of Betty's [assets](./betty/assets). +- `theme` (optional); Theme configuration. Keys are the following: + - `background_image_id` (optional); The ID of the file entity whose (image) file to use for page backgrounds if a page + does not provide any image media itself. - `extensions` (optional): The extensions to enable. Keys are extension names, and values are objects containing each extension's configuration. - `betty.extension.anonymizer.Anonymizer`: Removes personal information from private people. Configuration: `~`. - `betty.extension.cleaner.Cleaner`: Removes data (events, media, etc.) that have no relation to any people. Configuration: `~`. diff --git a/betty/app.py b/betty/app.py index 698d3d7fe..525b7b4f9 100644 --- a/betty/app.py +++ b/betty/app.py @@ -4,11 +4,13 @@ from concurrent.futures.thread import ThreadPoolExecutor from pathlib import Path +import aiohttp from jinja2 import Environment +from reactives import reactive, Scope from betty.concurrent import ExceptionRaisingExecutor from betty.dispatch import Dispatcher -from betty.extension import Extension, build_extension_type_graph, ConfigurableExtension +from betty.extension import build_extension_type_graph, ConfigurableExtension, Extension from betty.lock import Locks from betty.render import Renderer, SequentialRenderer @@ -17,42 +19,73 @@ except ImportError: from async_exit_stack import AsyncExitStack from copy import copy -from typing import Type, Dict +from typing import Type, Dict, Optional, Iterable, Sequence from betty.ancestry import Ancestry from betty.config import Configuration from betty.fs import FileSystem -from betty.graph import tsort_grouped +from betty.graph import tsort from betty.locale import open_translations, Translations, negotiate_locale from betty.url import AppUrlGenerator, StaticPathUrlGenerator, LocalizedUrlGenerator, StaticUrlGenerator +@reactive +class Extensions: + def __init__(self, extensions: Optional[Sequence[Extension]] = None): + self._extensions = OrderedDict() + if extensions is not None: + for extension in extensions: + self._extensions[extension.extension_type] = extension + + @Scope.register_self + def __getitem__(self, extension_type: Type[Extension]) -> Extension: + return self._extensions[extension_type] + + @Scope.register_self + def __iter__(self) -> Iterable[Extension]: + return (extension for extension in self._extensions.values()) + + @Scope.register_self + def __eq__(self, other): + if not isinstance(other, Extensions): + return NotImplemented + return self._extensions == other._extensions + + def _add(self, extension: Extension) -> None: + self._extensions[type(extension)] = extension + + def _remove(self, extension_type: Type[Extension]) -> None: + del self._extensions[extension_type] + + +@reactive class App: def __init__(self, configuration: Configuration): self._app_stack = [] self._ancestry = Ancestry() self._configuration = configuration - self._assets = FileSystem((Path(__file__).parent / 'assets', 'utf-8')) + self._assets = FileSystem() self._dispatcher = None self._localized_url_generator = AppUrlGenerator(configuration) self._static_url_generator = StaticPathUrlGenerator(configuration) self._locale = None self._translations = defaultdict(gettext.NullTranslations) self._default_translations = None - self._extensions = OrderedDict() + self._extensions = Extensions() self._extension_exit_stack = AsyncExitStack() - self._init_extensions() - self._init_dispatcher() - self._init_assets() - self._init_translations() self._jinja2_environment = None self._renderer = None self._executor = None self._locks = Locks() + self._http_client = None + + @property + def configuration(self) -> Configuration: + return self._configuration async def enter(self): if not self._app_stack: - for extension in self._extensions.values(): + for extension in self.extensions: await self._extension_exit_stack.enter_async_context(extension) self._default_translations = Translations(self.translations[self.locale]) @@ -73,6 +106,11 @@ async def exit(self): if not self._app_stack: self._executor.shutdown() self._executor = None + + if self._http_client is not None: + await self._http_client.close() + self._http_client = None + await self._extension_exit_stack.aclose() async def __aenter__(self) -> 'App': @@ -85,63 +123,63 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): def locale(self) -> str: if self._locale is not None: return self._locale - return self._configuration.default_locale - - def _init_extensions(self) -> None: - for grouped_extension_types in tsort_grouped(build_extension_type_graph(set(self._configuration.extensions.keys()))): - for extension_type in grouped_extension_types: - extension_args = [] - if issubclass(extension_type, ConfigurableExtension) and extension_type in self.configuration.extensions: - extension_kwargs = self.configuration.extensions[extension_type] - else: - extension_kwargs = {} - - if issubclass(extension_type, AppAwareFactory): - extension = extension_type.new_for_app(self, *extension_args, **extension_kwargs) - else: - extension = extension_type(*extension_args, **extension_kwargs) - - self._extensions[extension_type] = extension - - def _init_dispatcher(self) -> None: - from betty.extension import ExtensionDispatcher - - self._dispatcher = ExtensionDispatcher(self._extensions.values()) - - def _init_assets(self) -> None: - for extension in self._extensions.values(): - if extension.assets_directory_path is not None: - self._assets.paths.appendleft((extension.assets_directory_path, 'utf-8')) - if self._configuration.assets_directory_path: - self._assets.paths.appendleft((self._configuration.assets_directory_path, None)) - - def _init_translations(self) -> None: - self._translations['en-US'] = gettext.NullTranslations() - for locale in self._configuration.locales: - for assets_path, _ in reversed(self._assets.paths): - translations = open_translations(locale, assets_path) - if translations: - translations.add_fallback(self._translations[locale]) - self._translations[locale] = translations + return self._configuration.locales.default.locale @property def ancestry(self) -> Ancestry: return self._ancestry @property - def configuration(self) -> Configuration: - return self._configuration + def extensions(self) -> Extensions: + extensions_enabled_in_configuration = { + extension_configuration.extension_type + for extension_configuration in self._configuration.extensions + if extension_configuration.enabled + } + extension_types = tsort(build_extension_type_graph(extensions_enabled_in_configuration)) + + # Remove disabled extensions. + for extension in list(self._extensions): + extension_type = type(extension) + if extension_type not in extension_types: + self._extensions._remove(extension_type) + + # Add enabled extensions. + for extension_type in extension_types: + if extension_type not in self._extensions: + if issubclass(extension_type, ConfigurableExtension): + if extension_type not in extensions_enabled_in_configuration or self._configuration.extensions[extension_type].extension_type_configuration is None: + configuration = extension_type.default_configuration() + else: + configuration = self._configuration.extensions[extension_type].extension_type_configuration + extension = extension_type(self, configuration) + else: + extension = extension_type(self) + + self._extensions._add(extension) - @property - def extensions(self) -> Dict[Type[Extension], Extension]: return self._extensions + @reactive(on_trigger=(lambda app: app._assets.paths.clear(),)) @property def assets(self) -> FileSystem: + if len(self._assets.paths) == 0: + self._assets.paths.appendleft((Path(__file__).resolve().parent / 'assets', 'utf-8')) + for extension in self.extensions: + if extension.assets_directory_path is not None: + self._assets.paths.appendleft((extension.assets_directory_path, 'utf-8')) + if self._configuration.assets_directory_path: + self._assets.paths.appendleft((self._configuration.assets_directory_path, None)) + return self._assets @property def dispatcher(self) -> Dispatcher: + if self._dispatcher is None: + from betty.extension import ExtensionDispatcher + + self._dispatcher = ExtensionDispatcher(list(self.extensions)) + return self._dispatcher @property @@ -152,10 +190,21 @@ def localized_url_generator(self) -> LocalizedUrlGenerator: def static_url_generator(self) -> StaticUrlGenerator: return self._static_url_generator + @reactive(on_trigger=(lambda app: app._translations.clear(),)) @property def translations(self) -> Dict[str, gettext.NullTranslations]: + if len(self._translations) == 0: + self._translations['en-US'] = gettext.NullTranslations() + for locale_configuration in self._configuration.locales: + for assets_path, _ in reversed(self._assets.paths): + translations = open_translations(locale_configuration.locale, assets_path) + if translations: + translations.add_fallback(self._translations[locale_configuration]) + self._translations[locale_configuration] = translations + return self._translations + @reactive(on_trigger=(lambda app: setattr(app, '_jinja2_environment', None),)) @property def jinja2_environment(self) -> Environment: if not self._jinja2_environment: @@ -164,10 +213,12 @@ def jinja2_environment(self) -> Environment: return self._jinja2_environment + @reactive(on_trigger=(lambda app: setattr(app, '_renderer', None),)) @property def renderer(self) -> Renderer: if not self._renderer: from betty.jinja2 import Jinja2Renderer + self._renderer = SequentialRenderer([ Jinja2Renderer(self.jinja2_environment, self._configuration), ]) @@ -184,8 +235,14 @@ def executor(self) -> Executor: def locks(self) -> Locks: return self._locks + @property + def http_client(self) -> aiohttp.ClientSession: + if self._http_client is None: + self._http_client = aiohttp.ClientSession(connector=aiohttp.TCPConnector(limit_per_host=5)) + return self._http_client + def with_locale(self, locale: str) -> 'App': - locale = negotiate_locale(locale, list(self.configuration.locales.keys())) + locale = negotiate_locale(locale, [locale_configuration.locale for locale_configuration in self.configuration.locales]) if locale is None: raise ValueError('Locale "%s" is not enabled.' % locale) if locale == self.locale: @@ -199,26 +256,3 @@ def with_locale(self, locale: str) -> 'App': app._renderer = None return app - - -class AppAwareFactory: - @classmethod - def new_for_app(cls, app: App, *args, **kwargs): - """ - Create a new instance of cls based on a Betty app. - - Parameters - ---------- - betty.app.App - The Betty app. - *args - Any additional arguments passed on to cls.__init__(). - *kwargs - Any additional keyword arguments passed on to cls.__init__(). - - Returns - ------- - cls - """ - - raise NotImplementedError diff --git a/betty/assets/public/static/sitemap.xml.j2 b/betty/assets/public/static/sitemap.xml.j2 index 5869ac229..0f4fa18e5 100644 --- a/betty/assets/public/static/sitemap.xml.j2 +++ b/betty/assets/public/static/sitemap.xml.j2 @@ -7,10 +7,10 @@ {% set identifiables = identifiables + app.ancestry.files.values() | list %} {% set identifiables = identifiables + app.ancestry.sources.values() | list %} {% set identifiables = identifiables + app.ancestry.citations.values() | list %} - {% for locale in app.configuration.locales %} + {% for locale_configuration in app.configuration.locales %} {% for identifiable in identifiables %} - {{ identifiable | url(absolute=true, locale=locale) }} + {{ identifiable | url(absolute=true, locale=locale_configuration.locale) }} {% endfor %} {% endfor %} diff --git a/betty/assets/templates/base.html.j2 b/betty/assets/templates/base.html.j2 index a312e4e5d..3ee9afa7b 100644 --- a/betty/assets/templates/base.html.j2 +++ b/betty/assets/templates/base.html.j2 @@ -114,9 +114,9 @@