Skip to content

Commit

Permalink
Add a graphical user interface.
Browse files Browse the repository at this point in the history
  • Loading branch information
bartfeenstra authored and Bart Feenstra committed Jul 11, 2021
1 parent d86e925 commit 8ac9c18
Show file tree
Hide file tree
Showing 76 changed files with 3,331 additions and 741 deletions.
3 changes: 2 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
source = betty
omit =
betty/_package/*
*/test*.py
betty/tests/*
betty/pytests/*

[report]
exclude_lines =
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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.
```
Expand All @@ -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: ~
Expand Down Expand Up @@ -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: `~`.
Expand Down
186 changes: 110 additions & 76 deletions betty/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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])
Expand All @@ -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':
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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),
])
Expand All @@ -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:
Expand All @@ -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
4 changes: 2 additions & 2 deletions betty/assets/public/static/sitemap.xml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
<url>
<loc>{{ identifiable | url(absolute=true, locale=locale) }}</loc>
<loc>{{ identifiable | url(absolute=true, locale=locale_configuration.locale) }}</loc>
</url>
{% endfor %}
{% endfor %}
Expand Down
6 changes: 3 additions & 3 deletions betty/assets/templates/base.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@
<div class="nav-primary-expanded">
<ul class="nav-secondary">
{% set ns = namespace(available_locales=[]) %}
{% for available_locale in app.configuration.locales %}
{% set available_locale_data = app.configuration.locales[available_locale].alias | locale_get_data %}
{% do ns.available_locales.append((available_locale, available_locale_data.get_display_name())) %}
{% for available_locale_configuration in app.configuration.locales %}
{% set available_locale_data = available_locale_configuration.alias | locale_get_data %}
{% do ns.available_locales.append((available_locale_configuration.locale, available_locale_data.get_display_name())) %}
{% endfor %}
{% for available_locale, available_locale_name in ns.available_locales | sort(attribute='1') %}
<li><a href="{{ page_resource | url(locale=available_locale) }}" hreflang="{{ available_locale }}" lang="{{ available_locale }}" rel="alternate">{{ available_locale_name }}</a></li>
Expand Down
11 changes: 11 additions & 0 deletions betty/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import logging
import shutil
from contextlib import suppress

from betty import fs


async def clear():
with suppress(FileNotFoundError):
shutil.rmtree(fs.CACHE_DIRECTORY_PATH)
logging.getLogger().info('All caches cleared.')
Loading

0 comments on commit 8ac9c18

Please sign in to comment.