diff --git a/betty/app/__init__.py b/betty/app/__init__.py index 6e0c163d1..4813ddcbd 100644 --- a/betty/app/__init__.py +++ b/betty/app/__init__.py @@ -15,7 +15,7 @@ from betty.resource import Releaser, Acquirer try: - from typing import Self + from typing import Self # type: ignore except ImportError: from typing_extensions import Self @@ -30,13 +30,10 @@ Occupation, Retirement, Correspondence, Confirmation from betty.project import Project -if TYPE_CHECKING: - from betty.url import StaticUrlGenerator, ContentNegotiationUrlGenerator - try: from graphlib import TopologicalSorter, CycleError except ImportError: - from graphlib_backport import TopologicalSorter + from graphlib_backport import TopologicalSorter # type: ignore import aiohttp from jinja2 import Environment as Jinja2Environment @@ -55,6 +52,11 @@ from betty.locale import negotiate_locale, TranslationsRepository, Translations, rfc_1766_to_bcp_47, bcp_47_to_rfc_1766, \ getdefaultlocale + +if TYPE_CHECKING: + from betty.builtins import _ + from betty.url import StaticUrlGenerator, ContentNegotiationUrlGenerator + CONFIGURATION_DIRECTORY_PATH = HOME_DIRECTORY_PATH / 'configuration' @@ -77,9 +79,17 @@ def __init__(self): def configuration_file_path(self) -> Path: return CONFIGURATION_DIRECTORY_PATH / 'app.json' + @configuration_file_path.setter + def configuration_file_path(self, __) -> None: + pass + + @configuration_file_path.deleter + def configuration_file_path(self) -> None: + pass + @reactive # type: ignore @property - def locale(self) -> str: + def locale(self) -> Optional[str]: if self._locale is None: return getdefaultlocale() return self._locale @@ -120,7 +130,8 @@ def __init__(self, *args, **kwargs): self.configuration.read() self._acquired = False - self._extensions = None + self._extensions = _AppExtensions() + self._extensions_initialized = False self._project = Project() self._assets = FileSystem() self._dispatcher = None @@ -165,7 +176,7 @@ def acquire(self) -> None: if isinstance(extension, Acquirer): extension.acquire() if isinstance(extension, Releaser): - self._activation_exit_stack.push(extension.release) + self._acquire_contexts.callback(extension.release) except BaseException: self.release() raise @@ -231,8 +242,8 @@ def project(self) -> Project: @property def extensions(self) -> Extensions: - if self._extensions is None: - self._extensions = _AppExtensions() + if not self._extensions_initialized: + self._extensions_initialized = True self._update_extensions() self.project.configuration.extensions.react(self._update_extensions) @@ -259,7 +270,7 @@ def _update_extensions(self) -> None: extensions_batch = [] for extension_type in extension_types_batch: if issubclass(extension_type, ConfigurableExtension) and extension_type in self.project.configuration.extensions: - extension = extension_type(self, configuration=self.project.configuration.extensions[extension_type].extension_configuration) + extension: Extension = extension_type(self, configuration=self.project.configuration.extensions[extension_type].extension_configuration) else: extension = extension_type(self) extensions_batch.append(extension) @@ -267,7 +278,7 @@ def _update_extensions(self) -> None: extensions.append(extensions_batch) self._extensions._update(extensions) - @reactive + @reactive # type: ignore @property def assets(self) -> FileSystem: if len(self._assets) == 0: @@ -284,8 +295,9 @@ def assets(self) -> None: def _build_assets(self) -> None: self._assets.prepend(ASSETS_DIRECTORY_PATH, 'utf-8') for extension in self.extensions.flatten(): - if extension.assets_directory_path() is not None: - self._assets.prepend(extension.assets_directory_path(), 'utf-8') + extension_assets_directory_path = extension.assets_directory_path() + if extension_assets_directory_path is not None: + self._assets.prepend(extension_assets_directory_path, 'utf-8') if self.project.configuration.assets_directory_path: self._assets.prepend(self.project.configuration.assets_directory_path) @@ -308,7 +320,7 @@ def static_url_generator(self) -> StaticUrlGenerator: def translations(self) -> TranslationsRepository: return self._translations - @reactive + @reactive # type: ignore @property def jinja2_environment(self) -> Jinja2Environment: if not self._jinja2_environment: @@ -321,7 +333,7 @@ def jinja2_environment(self) -> Jinja2Environment: def jinja2_environment(self) -> None: self._jinja2_environment = None - @reactive + @reactive # type: ignore @property def renderer(self) -> Renderer: if not self._renderer: @@ -347,7 +359,7 @@ def executor(self) -> Executor: def locks(self) -> Locks: return self._locks - @reactive + @reactive # type: ignore @property def http_client(self) -> aiohttp.ClientSession: if not self._http_client: @@ -355,14 +367,14 @@ def http_client(self) -> aiohttp.ClientSession: weakref.finalize(self, sync(self._http_client.close)) return self._http_client - @http_client.deleter + @http_client.deleter # type: ignore @sync async def http_client(self) -> None: if self._http_client is not None: await self._http_client.close() self._http_client = None - @reactive + @reactive # type: ignore @property @sync async def entity_types(self) -> Set[Type[Entity]]: @@ -381,7 +393,11 @@ async def entity_types(self) -> Set[Type[Entity]]: } return self._entity_types - @reactive + @entity_types.deleter + def entity_types(self) -> None: + self._entity_types = None + + @reactive # type: ignore @property @sync async def event_types(self) -> Set[Type[EventType]]: @@ -409,7 +425,3 @@ async def event_types(self) -> Set[Type[EventType]]: Confirmation, } return self._event_types - - @entity_types.deleter - def entity_types(self) -> None: - self._entity_types = None diff --git a/betty/app/extension.py b/betty/app/extension.py index b597bd701..a24e9cf74 100644 --- a/betty/app/extension.py +++ b/betty/app/extension.py @@ -4,7 +4,8 @@ from collections import defaultdict from importlib.metadata import entry_points from pathlib import Path -from typing import Type, Set, Optional, Any, List, Dict, Sequence, TypeVar, Union, Iterable, TYPE_CHECKING, Generic +from typing import Type, Set, Optional, Any, List, Dict, TypeVar, Union, Iterable, TYPE_CHECKING, Generic, \ + Iterator from reactives.factory.type import ReactiveInstance @@ -30,7 +31,7 @@ class Dependencies(AllRequirements): def __init__(self, extension_type: Type[Extension]): for dependency in extension_type.depends_on(): try: - dependency_requirements = [dependency.requires() for dependency in extension_type.depends_on()] + dependency_requirements = tuple(dependency.requires() for dependency in extension_type.depends_on()) except RecursionError: raise CyclicDependencyError([dependency]) super().__init__(dependency_requirements) @@ -54,7 +55,7 @@ def __init__(self, app: App, *args, **kwargs): @classmethod def requires(cls) -> AllRequirements: - return AllRequirements([Dependencies(cls)] if cls.depends_on() else []) + return AllRequirements((Dependencies(cls),) if cls.depends_on() else ()) @classmethod def name(cls) -> str: @@ -105,14 +106,13 @@ class Extensions(ReactiveInstance): def __getitem__(self, extension_type: Union[Type[ExtensionT], str]) -> ExtensionT: raise NotImplementedError - def __iter__(self) -> Sequence[Sequence[Extension]]: + def __iter__(self) -> Iterator[Iterator[Extension]]: raise NotImplementedError - def flatten(self) -> Sequence[Extension]: - for batch in self: - yield from batch + def flatten(self) -> Iterator[Extension]: + raise NotImplementedError - def __contains__(self, extension_type: Union[Type[Extension], str]) -> bool: + def __contains__(self, extension_type: Union[Type[Extension], str, Any]) -> bool: raise NotImplementedError @@ -131,11 +131,15 @@ def __getitem__(self, extension_type: Union[Type[Extension], str]) -> Extension: raise KeyError(f'Unknown extension of type "{extension_type}"') @scope.register_self - def __iter__(self) -> Sequence[Sequence[Extension]]: + def __iter__(self) -> Iterator[Iterator[Extension]]: # Use a generator so we discourage calling code from storing the result. for batch in self._extensions: yield (extension for extension in batch) + def flatten(self) -> Iterator[Extension]: + for batch in self: + yield from batch + @scope.register_self def __contains__(self, extension_type: Union[Type[Extension], str]) -> bool: if isinstance(extension_type, str): @@ -174,8 +178,11 @@ async def _dispatch(*args, **kwargs) -> List[Any]: return _dispatch -def build_extension_type_graph(extension_types: Iterable[Type[Extension]]) -> Dict: - extension_types_graph = defaultdict(set) +ExtensionTypeGraph = Dict[Type[Extension], Set[Type[Extension]]] + + +def build_extension_type_graph(extension_types: Iterable[Type[Extension]]) -> ExtensionTypeGraph: + extension_types_graph: ExtensionTypeGraph = defaultdict(set) # Add dependencies to the extension graph. for extension_type in extension_types: _extend_extension_type_graph(extension_types_graph, extension_type) diff --git a/betty/assets/betty.pot b/betty/assets/betty.pot index 15c29c060..c449a3415 100644 --- a/betty/assets/betty.pot +++ b/betty/assets/betty.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Betty VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-05-16 23:56+0100\n" +"POT-Creation-Date: 2022-06-01 16:02+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -461,6 +461,9 @@ msgstr "" msgid "Locale" msgstr "" +msgid "Locale aliases must not contain slashes." +msgstr "" + msgid "Locales configuration must be a list." msgstr "" diff --git a/betty/assets/locale/fr_FR/LC_MESSAGES/betty.po b/betty/assets/locale/fr_FR/LC_MESSAGES/betty.po index 31a8cd71c..459a626a2 100644 --- a/betty/assets/locale/fr_FR/LC_MESSAGES/betty.po +++ b/betty/assets/locale/fr_FR/LC_MESSAGES/betty.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-05-16 23:56+0100\n" +"POT-Creation-Date: 2022-06-01 16:02+0100\n" "PO-Revision-Date: 2020-11-27 19:49+0100\n" "Last-Translator: \n" "Language: fr\n" @@ -550,6 +550,9 @@ msgstr "" msgid "Locale" msgstr "Un nom de language." +msgid "Locale aliases must not contain slashes." +msgstr "" + msgid "Locales configuration must be a list." msgstr "" diff --git a/betty/assets/locale/nl_NL/LC_MESSAGES/betty.po b/betty/assets/locale/nl_NL/LC_MESSAGES/betty.po index 482df8f14..c406bfe45 100644 --- a/betty/assets/locale/nl_NL/LC_MESSAGES/betty.po +++ b/betty/assets/locale/nl_NL/LC_MESSAGES/betty.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-05-16 23:56+0100\n" +"POT-Creation-Date: 2022-06-01 16:02+0100\n" "PO-Revision-Date: 2022-04-08 01:58+0100\n" "Last-Translator: \n" "Language: nl\n" @@ -27,9 +27,11 @@ msgid "" " Betty can add to your site, such as media galleries, " "maps, and browsable family trees.\n" " " -msgstr "Betty is een programma dat van een stamboom een website bouwt zoals de site die je nu aan het bezoeken bent." -"Des te meer informatie je genealogisch onderzoek bevat, des te meer interactiviteit Betty aan je site toe kan voegen," -"zoals mediagalerijen, kaarten, en visuele stambomen." +msgstr "" +"Betty is een programma dat van een stamboom een website bouwt zoals de " +"site die je nu aan het bezoeken bent.Des te meer informatie je " +"genealogisch onderzoek bevat, des te meer interactiviteit Betty aan je " +"site toe kan voegen,zoals mediagalerijen, kaarten, en visuele stambomen." #, python-format msgid "" @@ -42,12 +44,10 @@ msgid "" " " msgstr "" "\n" -" Betty is vernoemd naar " -"%(liberta_lankester_label)s, en deze website bevat een uittreksel van haar familiegeschiedenis. Je kan de " -"pagina's over haar en haar familie " -"bekijken om een idee te krijgen " -"van hoe een Betty-site eruit ziet." -"\n" +" Betty is vernoemd naar %(liberta_lankester_label)s, en " +"deze website bevat een uittreksel van haar familiegeschiedenis. Je kan de" +" pagina's over haar en haar familie bekijken om een idee te krijgen van " +"hoe een Betty-site eruit ziet.\n" " " #, python-format @@ -567,6 +567,9 @@ msgstr "Laden..." msgid "Locale" 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." diff --git a/betty/assets/locale/uk/LC_MESSAGES/betty.po b/betty/assets/locale/uk/LC_MESSAGES/betty.po index 0b12c6c2b..060cfde13 100644 --- a/betty/assets/locale/uk/LC_MESSAGES/betty.po +++ b/betty/assets/locale/uk/LC_MESSAGES/betty.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Betty VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2022-05-16 23:56+0100\n" +"POT-Creation-Date: 2022-06-01 16:02+0100\n" "PO-Revision-Date: 2020-05-02 22:29+0100\n" "Last-Translator: FULL NAME \n" "Language: uk\n" @@ -534,6 +534,9 @@ msgstr "" msgid "Locale" msgstr "" +msgid "Locale aliases must not contain slashes." +msgstr "" + msgid "Locales configuration must be a list." msgstr "" diff --git a/betty/asyncio.py b/betty/asyncio.py index a810f2ffa..0ea901a09 100644 --- a/betty/asyncio.py +++ b/betty/asyncio.py @@ -1,13 +1,12 @@ import asyncio -import gc import inspect import sys -import types from asyncio import events, coroutines -from asyncio.runners import _cancel_all_tasks +from asyncio.runners import _cancel_all_tasks # type: ignore from contextlib import suppress from functools import wraps from threading import Thread +from typing import Any def _sync_function(f): @@ -65,12 +64,9 @@ def _run(main, *, debug=None): loop.run_until_complete(loop.shutdown_asyncgens()) # Improvement: Python 3.9 added the ability to shut down the default executor. if sys.version_info.minor >= 9: - loop.run_until_complete(loop.shutdown_default_executor()) - # Improvement: Work around BPO-39232 (https://bugs.python.org/issue39232) based on - # https://github.com/Cog-Creators/Red-DiscordBot/pull/3566/files. - if sys.platform == 'win32': - gc.collect() - loop._check_closed = types.MethodType(lambda: None, loop) + loop.run_until_complete( + loop.shutdown_default_executor() # type: ignore + ) finally: events.set_event_loop(None) loop.close() @@ -90,7 +86,7 @@ async def run(self) -> None: except BaseException as e: self._e = e - def join(self, *args, **kwargs) -> None: + def join(self, *args, **kwargs) -> Any: super().join(*args, **kwargs) if self._e: raise self._e diff --git a/betty/builtins.pyi b/betty/builtins.pyi index 5794758d4..50b1491b1 100644 --- a/betty/builtins.pyi +++ b/betty/builtins.pyi @@ -1,2 +1,14 @@ def _(_: str) -> str: pass + +def gettext(_: str) -> str: + pass + +def ngettext(_: str, __: str, ___: int) -> str: + pass + +def pgettext(_: str, __: str) -> str: + pass + +def npgettext(_: str, __: str, ___: str, ____: int) -> str: + pass diff --git a/betty/cleaner/__init__.py b/betty/cleaner/__init__.py index b05d51936..becdaa058 100644 --- a/betty/cleaner/__init__.py +++ b/betty/cleaner/__init__.py @@ -1,13 +1,14 @@ -from collections import defaultdict - from betty.anonymizer import Anonymizer from betty.app.extension import Extension try: from graphlib import TopologicalSorter except ImportError: - from graphlib_backport import TopologicalSorter -from typing import Set, Type, Dict + 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.gui import GuiBuilder @@ -39,10 +40,13 @@ def _clean_event(ancestry: Ancestry, event: Event) -> None: del ancestry.entities[Event][event] +_PlacesGraph = Dict[Place, Set[Place]] + + def _clean_places(ancestry: Ancestry) -> None: places = ancestry.entities[Place] - def _extend_place_graph(graph: Dict, enclosing_place: Place) -> None: + def _extend_place_graph(graph: _PlacesGraph, enclosing_place: Place) -> None: enclosures = enclosing_place.encloses # Ensure each place appears in the graph, even if they're anonymous. graph.setdefault(enclosing_place, set()) @@ -53,7 +57,7 @@ def _extend_place_graph(graph: Dict, enclosing_place: Place) -> None: if not seen_enclosed_place: _extend_place_graph(graph, enclosed_place) - places_graph = defaultdict(set) + places_graph: _PlacesGraph = {} for place in places: _extend_place_graph(places_graph, place) diff --git a/betty/cli.py b/betty/cli.py index ddb93bb58..1720b7db0 100644 --- a/betty/cli.py +++ b/betty/cli.py @@ -5,7 +5,12 @@ from contextlib import suppress, contextmanager from functools import wraps from os import getcwd, path -from typing import Callable, Dict, Optional +from typing import Callable, Dict, Optional, TYPE_CHECKING + +from PyQt6.QtWidgets import QMainWindow + +if TYPE_CHECKING: + from betty.builtins import _ import click from click import get_current_context, Context, Option @@ -151,6 +156,7 @@ async def _demo(): async def _gui(configuration_file_path: Optional[str]): with App() as app: qapp = BettyApplication([sys.argv[0]]) + window: QMainWindow if configuration_file_path is None: window = WelcomeWindow(app) else: diff --git a/betty/concurrent.py b/betty/concurrent.py index 2f6c66fea..1742392a4 100644 --- a/betty/concurrent.py +++ b/betty/concurrent.py @@ -1,10 +1,11 @@ -from concurrent.futures._base import Executor, wait +from concurrent.futures._base import Executor, wait, Future +from typing import List class ExceptionRaisingAwaitableExecutor(Executor): def __init__(self, executor: Executor): self._executor = executor - self._awaitables = [] + self._awaitables: List[Future] = [] def submit(self, *args, **kwargs): future = self._executor.submit(*args, **kwargs) diff --git a/betty/demo/__init__.py b/betty/demo/__init__.py index aabf6aa5b..2aa8f4019 100644 --- a/betty/demo/__init__.py +++ b/betty/demo/__init__.py @@ -191,8 +191,8 @@ async def load(self) -> None: class DemoServer(Server): def __init__(self): - self._server = None - self._app = None + self._app = App() + self._server = serve.AppServer(self._app) self._stack = ExitStack() @property @@ -200,8 +200,7 @@ def public_url(self) -> str: return self._server.public_url async def start(self) -> None: - self._app = App() - self._stack.enter_context(self._app.acquire_locale()) + self._stack.enter_context(self._app) self._app.project.configuration.extensions.add(ProjectExtensionConfiguration(Demo)) # Include all of the translations Betty ships with. self._app.project.configuration.locales.replace([ @@ -210,11 +209,9 @@ async def start(self) -> None: LocaleConfiguration('fr-FR', 'fr'), LocaleConfiguration('uk', 'uk'), ]) - self._server = None try: await load.load(self._app) await generate.generate(self._app) - self._server = serve.AppServer(self._app) await self._server.start() except BaseException: self._stack.close() diff --git a/betty/fs.py b/betty/fs.py index 48f853434..f32df844f 100644 --- a/betty/fs.py +++ b/betty/fs.py @@ -27,7 +27,7 @@ async def iterfiles(path: PathLike) -> AsyncIterable[Path]: - for dir_path, _, filenames in os.walk(path): + for dir_path, _, filenames in os.walk(str(path)): for filename in filenames: yield Path(dir_path) / filename @@ -38,10 +38,10 @@ def hashfile(path: PathLike) -> str: class FileSystem: class _Open: - def __init__(self, fs: FileSystem, file_paths: Tuple[PathLike]): + def __init__(self, fs: FileSystem, file_paths: Tuple[PathLike, ...]): self._fs = fs self._file_paths = file_paths - self._file = None + self._file: Optional[AsyncContextManager] = None async def __aenter__(self): for file_path in map(Path, self._file_paths): @@ -62,7 +62,7 @@ def __len__(self) -> int: return len(self._paths) @property - def paths(self) -> Sequence[Tuple[Path, str]]: + def paths(self) -> Sequence[Tuple[Path, Optional[str]]]: return list(self._paths) def prepend(self, path: PathLike, fs_encoding: Optional[str] = None) -> None: diff --git a/betty/generate.py b/betty/generate.py index 3b68753c1..73cb4b0e0 100644 --- a/betty/generate.py +++ b/betty/generate.py @@ -6,10 +6,11 @@ import shutil from contextlib import suppress from pathlib import Path -from typing import Iterable, Any +from typing import Iterable, Any, TYPE_CHECKING, cast, AsyncContextManager, List import aiofiles from aiofiles import os as aiofiles_os +from aiofiles.threadpool.text import AsyncTextIOWrapper from babel import Locale from jinja2 import TemplateNotFound @@ -17,11 +18,16 @@ from betty.jinja2 import Environment from betty.json import JSONEncoder from betty.locale import bcp_47_to_rfc_1766 +from betty.model import EntityCollection, Entity from betty.model.ancestry import File, Person, Place, Event, Citation, Source, Note from betty.openapi import build_specification +if TYPE_CHECKING: + from betty.builtins import _ + + try: - from resource import getrlimit, RLIMIT_NOFILE + from resource import getrlimit, RLIMIT_NOFILE # type: ignore _GENERATE_CONCURRENCY = math.ceil(getrlimit(RLIMIT_NOFILE)[0] / 2) except ImportError: _GENERATE_CONCURRENCY = 999 @@ -79,7 +85,14 @@ async def _generate(app: App) -> None: (app.project.ancestry.entities[Citation], 'citation'), (app.project.ancestry.entities[Source], 'source'), ] - async for coroutine in _generate_entity_type(www_directory_path, entities, entity_type_name, app, locale, app.jinja2_environment) + async for coroutine in _generate_entity_type( + www_directory_path, + cast(EntityCollection[Entity], entities), + entity_type_name, + app, + locale, + app.jinja2_environment, + ) ], _generate_entity_type_list_json(www_directory_path, app.project.ancestry.entities[Note], 'note', app), *[ @@ -102,16 +115,16 @@ async def _generate(app: App) -> None: logger.info(_('Generated OpenAPI documentation in {locale}.').format(locale=locale_label)) -def _create_file(path: Path) -> object: +def _create_file(path: Path) -> AsyncContextManager[AsyncTextIOWrapper]: path.parent.mkdir(exist_ok=True, parents=True) - return aiofiles.open(path, 'w', encoding='utf-8') + return cast(AsyncContextManager[AsyncTextIOWrapper], aiofiles.open(path, 'w', encoding='utf-8')) -def _create_html_resource(path: Path) -> object: +def _create_html_resource(path: Path) -> AsyncContextManager[AsyncTextIOWrapper]: return _create_file(path / 'index.html') -def _create_json_resource(path: Path) -> object: +def _create_json_resource(path: Path) -> AsyncContextManager[AsyncTextIOWrapper]: return _create_file(path / 'index.json') @@ -163,8 +176,12 @@ async def _generate_entity_type_list_json(www_directory_path: Path, entities: It 'collection': [] } for entity in entities: - data['collection'].append(app.url_generator.generate( - entity, 'application/json', absolute=True)) + cast(List[str], data['collection']).append( + app.url_generator.generate( + entity, + 'application/json', + absolute=True, + )) rendered_json = json.dumps(data) async with _create_json_resource(entity_type_path) as f: await f.write(rendered_json) diff --git a/betty/gramps/config.py b/betty/gramps/config.py index 600f9ab60..4a5492616 100644 --- a/betty/gramps/config.py +++ b/betty/gramps/config.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Any, Iterable +from typing import Optional, List, Any, Iterable, TYPE_CHECKING from reactives import reactive, ReactiveList @@ -7,6 +7,10 @@ from betty.os import PathLike +if TYPE_CHECKING: + from betty.builtins import _ + + class FamilyTreeConfiguration(Configuration): def __init__(self, file_path: Optional[PathLike] = None): super().__init__() @@ -17,7 +21,7 @@ def __eq__(self, other): return False return self._file_path == other.file_path - @reactive + @reactive # type: ignore @property def file_path(self) -> Optional[Path]: return self._file_path diff --git a/betty/gramps/gui.py b/betty/gramps/gui.py index 8b4c12145..e2d84b82e 100644 --- a/betty/gramps/gui.py +++ b/betty/gramps/gui.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import List +from typing import List, TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from betty.builtins import _ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QWidget, QFormLayout, QPushButton, QFileDialog, QLineEdit, QHBoxLayout, QVBoxLayout, \ @@ -19,8 +22,7 @@ class _FamilyTrees(LocalizedWidget): def __init__(self, app: App, *args, **kwargs): - super().__init__(*args, **kwargs) - self._app = app + super().__init__(app, *args, **kwargs) self._layout = QVBoxLayout() self.setLayout(self._layout) @@ -32,14 +34,13 @@ def __init__(self, app: App, *args, **kwargs): self._build_family_trees() self._add_family_tree_button = QPushButton() - self._add_family_tree_button.released.connect(self._add_family_tree) + self._add_family_tree_button.released.connect(self._add_family_tree) # type: ignore self._layout.addWidget(self._add_family_tree_button, 1) @reactive(on_trigger_call=True) def _build_family_trees(self) -> None: if self._family_trees_widget is not None: self._layout.removeWidget(self._family_trees_widget) - self._family_trees_widget.setParent(None) del self._family_trees_widget del self._family_trees_layout del self._family_trees_remove_buttons @@ -55,7 +56,7 @@ def _remove_family_tree() -> None: del self._app.extensions[Gramps].configuration.family_trees[i] self._family_trees_layout.addWidget(Text(str(family_tree.file_path)), i, 0) self._family_trees_remove_buttons.insert(i, QPushButton()) - self._family_trees_remove_buttons[i].released.connect(_remove_family_tree) + self._family_trees_remove_buttons[i].released.connect(_remove_family_tree) # type: ignore self._family_trees_layout.addWidget(self._family_trees_remove_buttons[i], i, 1) self._layout.insertWidget(0, self._family_trees_widget, alignment=Qt.AlignmentFlag.AlignTop) @@ -72,8 +73,7 @@ def _add_family_tree(self): @reactive class _GrampsGuiWidget(LocalizedWidget): def __init__(self, app: App, *args, **kwargs): - super().__init__(*args, **kwargs) - self._app = app + super().__init__(app, *args, **kwargs) self._layout = QVBoxLayout() self.setLayout(self._layout) @@ -82,12 +82,12 @@ def __init__(self, app: App, *args, **kwargs): class _AddFamilyTreeWindow(BettyWindow): - width = 500 - height = 100 + window_width = 500 + window_height = 100 def __init__(self, app: App, *args, **kwargs): super().__init__(app, *args, **kwargs) - self._family_tree = None + self._family_tree: Optional[FamilyTreeConfiguration] = None self._layout = QFormLayout() @@ -98,35 +98,35 @@ def __init__(self, app: App, *args, **kwargs): def _update_configuration_file_path(file_path: str) -> None: if not file_path: - self._widget._save_and_close.setDisabled(True) + self._save_and_close.setDisabled(True) return try: if self._family_tree is None: self._family_tree = FamilyTreeConfiguration(file_path) else: self._family_tree.file_path = Path(file_path) - mark_valid(self._widget._file_path) - self._widget._save_and_close.setDisabled(False) + mark_valid(self._file_path) + self._save_and_close.setDisabled(False) except ConfigurationError as e: - mark_invalid(self._widget._file_path, str(e)) - self._widget._save_and_close.setDisabled(True) - self._widget._file_path = QLineEdit() - self._widget._file_path.textChanged.connect(_update_configuration_file_path) + mark_invalid(self._file_path, str(e)) + self._save_and_close.setDisabled(True) + self._file_path = QLineEdit() + self._file_path.textChanged.connect(_update_configuration_file_path) # type: ignore file_path_layout = QHBoxLayout() - file_path_layout.addWidget(self._widget._file_path) + file_path_layout.addWidget(self._file_path) @catch_exceptions def find_family_tree_file_path() -> None: found_family_tree_file_path, __ = QFileDialog.getOpenFileName( self._widget, _('Load the family tree from...'), - directory=self._widget._file_path.text(), + directory=self._file_path.text(), ) if '' != found_family_tree_file_path: - self._widget._file_path.setText(found_family_tree_file_path) - self._widget._file_path_find = QPushButton('...') - self._widget._file_path_find.released.connect(find_family_tree_file_path) - file_path_layout.addWidget(self._widget._file_path_find) + self._file_path.setText(found_family_tree_file_path) + self._file_path_find = QPushButton('...') + self._file_path_find.released.connect(find_family_tree_file_path) # type: ignore + file_path_layout.addWidget(self._file_path_find) self._file_path_label = QLabel() self._layout.addRow(self._file_path_label, file_path_layout) @@ -135,21 +135,24 @@ def find_family_tree_file_path() -> None: @catch_exceptions def save_and_close_family_tree() -> None: - self._app.extensions[Gramps].configuration.family_trees.append(self._family_tree) + self._app.extensions[Gramps].configuration.family_trees.append( + # At this point we know this is no longer None. + self._family_tree # type: ignore + ) self.close() - self._widget._save_and_close = QPushButton() - self._widget._save_and_close.setDisabled(True) - self._widget._save_and_close.released.connect(save_and_close_family_tree) - buttons_layout.addWidget(self._widget._save_and_close) + self._save_and_close = QPushButton() + self._save_and_close.setDisabled(True) + self._save_and_close.released.connect(save_and_close_family_tree) # type: ignore + buttons_layout.addWidget(self._save_and_close) - self._widget._cancel = QPushButton() - self._widget._cancel.released.connect(self.close) - buttons_layout.addWidget(self._widget._cancel) + self._cancel = QPushButton() + self._cancel.released.connect(self.close) # type: ignore + buttons_layout.addWidget(self._cancel) def _do_set_translatables(self) -> None: self._file_path_label.setText(_('File path')) - self._widget._save_and_close.setText(_('Save and close')) - self._widget._cancel.setText(_('Cancel')) + self._save_and_close.setText(_('Save and close')) + self._cancel.setText(_('Cancel')) @property def title(self) -> str: diff --git a/betty/gramps/loader.py b/betty/gramps/loader.py index e29d66c77..634e161f2 100644 --- a/betty/gramps/loader.py +++ b/betty/gramps/loader.py @@ -4,7 +4,7 @@ from collections import defaultdict from contextlib import suppress from tempfile import TemporaryDirectory -from typing import Optional, List, Union, Iterable +from typing import Optional, List, Union, Iterable, Dict, Type, Tuple from xml.etree import ElementTree import aiofiles @@ -12,7 +12,7 @@ from betty.config import Path from betty.gramps.error import GrampsError -from betty.load import getLogger, Loader +from betty.load import getLogger from betty.locale import DateRange, Datey, Date from betty.media_type import MediaType from betty.model import Entity, FlattenedEntityCollection, FlattenedEntity, unflatten @@ -35,6 +35,11 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) +class XPathError(GrampsError, RuntimeError): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + async def load_file(ancestry: Ancestry, file_path: PathLike) -> None: file_path = Path(file_path).resolve() logger = getLogger() @@ -63,9 +68,13 @@ async def load_file(ancestry: Ancestry, file_path: PathLike) -> None: def load_gramps(ancestry: Ancestry, gramps_path: PathLike) -> None: gramps_path = Path(gramps_path).resolve() try: - with gzip.open(gramps_path) as f: + with gzip.open(gramps_path, mode='r') as f: xml = f.read() - load_xml(ancestry, xml, rootname(gramps_path)) + load_xml( + ancestry, + xml, # type: ignore + rootname(gramps_path), + ) except OSError: raise GrampsLoadFileError() @@ -76,7 +85,9 @@ def load_gpkg(ancestry: Ancestry, gpkg_path: PathLike) -> None: tar_file = gzip.open(gpkg_path) try: with TemporaryDirectory() as cache_directory_path: - tarfile.open(fileobj=tar_file).extractall(cache_directory_path) + tarfile.open( + fileobj=tar_file, # type: ignore + ).extractall(cache_directory_path) load_gramps(ancestry, Path(cache_directory_path) / 'data.gramps') except tarfile.ReadError: raise GrampsLoadFileError('Could not read "%s" as a *.tar file after un-gzipping it.' % gpkg_path) @@ -90,17 +101,19 @@ def load_xml(ancestry: Ancestry, xml: Union[str, PathLike], gramps_tree_director with open(xml) as f: xml = f.read() try: - tree = ElementTree.ElementTree(ElementTree.fromstring(xml)) + tree = ElementTree.ElementTree(ElementTree.fromstring( + xml, # type: ignore + )) except ElementTree.ParseError as e: raise GrampsLoadFileError(e) _Loader(ancestry, tree, gramps_tree_directory_path).load() -class _Loader(Loader): +class _Loader: def __init__(self, ancestry: Ancestry, tree: ElementTree.ElementTree, gramps_tree_directory_path: Path): self._ancestry = ancestry self._flattened_entities = FlattenedEntityCollection() - self._added_entity_counts = defaultdict(lambda: 0) + self._added_entity_counts: Dict[Type[Entity], int] = defaultdict(lambda: 0) self._tree = tree self._gramps_tree_directory_path = gramps_tree_directory_path @@ -155,8 +168,11 @@ def _xpath(element: ElementTree.Element, selector: str) -> List[ElementTree.Elem return element.findall(selector, namespaces=_NS) -def _xpath1(element: ElementTree.Element, selector: str) -> Optional[ElementTree.Element]: - return element.find(selector, namespaces=_NS) +def _xpath1(element: ElementTree.Element, selector: str) -> ElementTree.Element: + found_element = element.find(selector, namespaces=_NS) + if found_element is None: + raise XPathError(f'Cannot find an element "{selector}" within {element}.') + return found_element _DATE_PATTERN = re.compile(r'^.{4}((-.{2})?-.{2})?$') @@ -164,36 +180,44 @@ def _xpath1(element: ElementTree.Element, selector: str) -> Optional[ElementTree def _load_date(element: ElementTree.Element) -> Optional[Datey]: - dateval_element = _xpath1(element, './ns:dateval') - if dateval_element is not None and dateval_element.get('cformat') is None: - dateval_type = dateval_element.get('type') - if dateval_type is None: - return _load_dateval(dateval_element, 'val') - dateval_type = str(dateval_type) - if dateval_type == 'about': - date = _load_dateval(dateval_element, 'val') - if date is None: - return None - date.fuzzy = True - return date - if dateval_type == 'before': - return DateRange(None, _load_dateval(dateval_element, 'val'), end_is_boundary=True) - if dateval_type == 'after': - return DateRange(_load_dateval(dateval_element, 'val'), start_is_boundary=True) - datespan_element = _xpath1(element, './ns:datespan') - if datespan_element is not None and datespan_element.get('cformat') is None: - return DateRange(_load_dateval(datespan_element, 'start'), _load_dateval(datespan_element, 'stop')) - daterange_element = _xpath1(element, './ns:daterange') - if daterange_element is not None and daterange_element.get('cformat') is None: - return DateRange(_load_dateval(daterange_element, 'start'), _load_dateval(daterange_element, 'stop'), start_is_boundary=True, end_is_boundary=True) + with suppress(XPathError): + dateval_element = _xpath1(element, './ns:dateval') + if dateval_element.get('cformat') is None: + dateval_type = dateval_element.get('type') + if dateval_type is None: + return _load_dateval(dateval_element, 'val') + dateval_type = str(dateval_type) + if dateval_type == 'about': + date = _load_dateval(dateval_element, 'val') + if date is None: + return None + date.fuzzy = True + return date + if dateval_type == 'before': + return DateRange(None, _load_dateval(dateval_element, 'val'), end_is_boundary=True) + if dateval_type == 'after': + return DateRange(_load_dateval(dateval_element, 'val'), start_is_boundary=True) + with suppress(XPathError): + datespan_element = _xpath1(element, './ns:datespan') + if datespan_element.get('cformat') is None: + return DateRange(_load_dateval(datespan_element, 'start'), _load_dateval(datespan_element, 'stop')) + with suppress(XPathError): + daterange_element = _xpath1(element, './ns:daterange') + if daterange_element.get('cformat') is None: + return DateRange(_load_dateval(daterange_element, 'start'), _load_dateval(daterange_element, 'stop'), start_is_boundary=True, end_is_boundary=True) return None def _load_dateval(element: ElementTree.Element, value_attribute_name: str) -> Optional[Date]: dateval = str(element.get(value_attribute_name)) if _DATE_PATTERN.fullmatch(dateval): - date_parts = [int(part) if _DATE_PART_PATTERN.fullmatch( - part) and int(part) > 0 else None for part in dateval.split('-')] + date_parts: Tuple[Optional[int], Optional[int], Optional[int]] = tuple( # type: ignore + int(part) + if _DATE_PART_PATTERN.fullmatch(part) and int(part) > 0 + else None + for part + in dateval.split('-') + ) date = Date(*date_parts) dateval_quality = element.get('quality') if dateval_quality == 'estimated': @@ -202,30 +226,37 @@ def _load_dateval(element: ElementTree.Element, value_attribute_name: str) -> Op return None -def _load_notes(loader: _Loader, database: ElementTree.Element): +def _load_notes(loader: _Loader, database: ElementTree.Element) -> None: for element in _xpath(database, './ns:notes/ns:note'): _load_note(loader, element) -def _load_note(loader: _Loader, element: ElementTree.Element): +def _load_note(loader: _Loader, element: ElementTree.Element) -> None: handle = element.get('handle') note_id = element.get('id') - text = _xpath1(element, './ns:text').text + assert note_id is not None + text_element = _xpath1(element, './ns:text') + assert text_element is not None + text = str(text_element.text) loader.add_entity(FlattenedEntity(Note(note_id, text), handle)) -def _load_objects(loader: _Loader, database: ElementTree.Element, gramps_tree_directory_path: Path): +def _load_objects(loader: _Loader, database: ElementTree.Element, gramps_tree_directory_path: Path) -> None: for element in _xpath(database, './ns:objects/ns:object'): _load_object(loader, element, gramps_tree_directory_path) -def _load_object(loader: _Loader, element: ElementTree.Element, gramps_tree_directory_path: Path): +def _load_object(loader: _Loader, element: ElementTree.Element, gramps_tree_directory_path: Path) -> None: file_handle = element.get('handle') file_id = element.get('id') file_element = _xpath1(element, './ns:file') - file_path = gramps_tree_directory_path / file_element.get('src') + src = file_element.get('src') + assert src is not None + file_path = gramps_tree_directory_path / src file = File(file_id, file_path) - file.media_type = MediaType(file_element.get('mime')) + mime = file_element.get('mime') + assert mime is not None + file.media_type = MediaType(mime) description = file_element.get('description') if description: file.description = description @@ -237,23 +268,33 @@ def _load_object(loader: _Loader, element: ElementTree.Element, gramps_tree_dire loader.add_association(File, file_handle, 'notes', Note, note_handle) -def _load_people(loader: _Loader, database: ElementTree.Element): +def _load_people(loader: _Loader, database: ElementTree.Element) -> None: for element in _xpath(database, './ns:people/ns:person'): _load_person(loader, element) -def _load_person(loader: _Loader, element: ElementTree.Element): +def _load_person(loader: _Loader, element: ElementTree.Element) -> None: person_handle = element.get('handle') + assert person_handle is not None person = Person(element.get('id')) name_elements = sorted(_xpath(element, './ns:name'), key=lambda x: x.get('alt') == '1') person_names = [] for name_element in name_elements: is_alternative = name_element.get('alt') == '1' - individual_name_element = _xpath1(name_element, './ns:first') - individual_name = None if individual_name_element is None else individual_name_element.text - surname_elements = [surname_element for surname_element in _xpath( - name_element, './ns:surname') if surname_element.text is not None] + try: + individual_name = _xpath1(name_element, './ns:first').text + except XPathError: + individual_name = None + surname_elements = [ + surname_element + for surname_element + in _xpath( + name_element, + './ns:surname' + ) + if surname_element.text is not None + ] if surname_elements: for surname_element in surname_elements: if not is_alternative: @@ -286,12 +327,12 @@ def _load_person(loader: _Loader, element: ElementTree.Element): loader.add_entity(flattened_person) -def _load_families(loader: _Loader, database: ElementTree.Element): +def _load_families(loader: _Loader, database: ElementTree.Element) -> None: for element in _xpath(database, './ns:families/ns:family'): _load_family(loader, element) -def _load_family(loader: _Loader, element: ElementTree.Element): +def _load_family(loader: _Loader, element: ElementTree.Element) -> None: parent_handles = [] # Load the father. @@ -351,8 +392,9 @@ def _load_place(loader: _Loader, element: ElementTree.Element) -> None: # The Gramps language is a single ISO language code, which is a valid BCP 47 locale. language = name_element.get('lang') date = _load_date(name_element) - name = PlaceName(name_element.get('value'), locale=language, date=date) - names.append(name) + name = name_element.get('value') + assert name is not None + names.append(PlaceName(name, locale=language, date=date)) place = Place(element.get('id'), names) @@ -372,18 +414,16 @@ def _load_place(loader: _Loader, element: ElementTree.Element) -> None: def _load_coordinates(element: ElementTree.Element) -> Optional[Point]: - coord_element = _xpath1(element, './ns:coord') - - if coord_element is None: - return None + with suppress(XPathError): + coord_element = _xpath1(element, './ns:coord') - # We could not load/validate the Gramps coordinates, because they are too freeform. - with suppress(BaseException): - return Point(coord_element.get('lat'), coord_element.get('long')) + # We could not load/validate the Gramps coordinates, because they are too freeform. + with suppress(BaseException): + return Point(coord_element.get('lat'), coord_element.get('long')) return None -def _load_events(loader: _Loader, database: ElementTree.Element): +def _load_events(loader: _Loader, database: ElementTree.Element) -> None: for element in _xpath(database, './ns:events/ns:event'): _load_event(loader, element) @@ -413,17 +453,18 @@ def _load_events(loader: _Loader, database: ElementTree.Element): } -def _load_event(loader: _Loader, element: ElementTree.Element): +def _load_event(loader: _Loader, element: ElementTree.Element) -> None: event_handle = element.get('handle') event_id = element.get('id') - gramps_type = _xpath1(element, './ns:type') + gramps_type = _xpath1(element, './ns:type').text + assert gramps_type is not None try: - event_type = _EVENT_TYPE_MAP[gramps_type.text] + event_type = _EVENT_TYPE_MAP[gramps_type] except KeyError: event_type = UnknownEventType() getLogger().warning( - 'Betty is unfamiliar with Gramps event "%s"\'s type of "%s". The event was imported, but its type was set to "%s".' % (event_id, gramps_type.text, event_type.label)) + 'Betty is unfamiliar with Gramps event "%s"\'s type of "%s". The event was imported, but its type was set to "%s".' % (event_id, gramps_type, event_type.label)) event = Event(event_id, event_type) @@ -435,9 +476,8 @@ def _load_event(loader: _Loader, element: ElementTree.Element): loader.add_association(Event, event_handle, 'place', Place, place_handle) # Load the description. - description_element = _xpath1(element, './ns:description') - if description_element is not None: - event.description = description_element.text + with suppress(XPathError): + event.description = _xpath1(element, './ns:description').text _load_attribute_privacy(event, element, 'attribute') @@ -455,36 +495,44 @@ def _load_repositories(loader: _Loader, database: ElementTree.Element) -> None: def _load_repository(loader: _Loader, element: ElementTree.Element) -> None: repository_source_handle = element.get('handle') - source = Source(element.get('id'), _xpath1(element, './ns:rname').text) + source = Source( + element.get('id'), + _xpath1(element, './ns:rname').text, + ) _load_urls(source, element) loader.add_entity(FlattenedEntity(source, repository_source_handle)) -def _load_sources(loader: _Loader, database: ElementTree.Element): +def _load_sources(loader: _Loader, database: ElementTree.Element) -> None: for element in _xpath(database, './ns:sources/ns:source'): _load_source(loader, element) def _load_source(loader: _Loader, element: ElementTree.Element) -> None: source_handle = element.get('handle') + try: + source_name = _xpath1(element, './ns:stitle').text + except XPathError: + source_name = None - source = Source(element.get('id'), _xpath1(element, './ns:stitle').text) + source = Source( + element.get('id'), + source_name + ) repository_source_handle = _load_handle('reporef', element) if repository_source_handle is not None: loader.add_association(Source, source_handle, 'contained_by', Source, repository_source_handle) # Load the author. - sauthor_element = _xpath1(element, './ns:sauthor') - if sauthor_element is not None: - source.author = sauthor_element.text + with suppress(XPathError): + source.author = _xpath1(element, './ns:sauthor').text # Load the publication info. - spubinfo_element = _xpath1(element, './ns:spubinfo') - if spubinfo_element is not None: - source.publisher = spubinfo_element.text + with suppress(XPathError): + source.publisher = _xpath1(element, './ns:spubinfo').text _load_attribute_privacy(source, element, 'srcattribute') @@ -508,37 +556,39 @@ def _load_citation(loader: _Loader, element: ElementTree.Element) -> None: citation.date = _load_date(element) _load_attribute_privacy(citation, element, 'srcattribute') - page = _xpath1(element, './ns:page') - if page is not None: - citation.location = page.text + with suppress(XPathError): + citation.location = _xpath1(element, './ns:page').text flattened_citation = FlattenedEntity(citation, citation_handle) _load_objref(loader, flattened_citation, element) loader.add_entity(flattened_citation) -def _load_citationref(loader: _Loader, owner: Entity, element: ElementTree.Element): +def _load_citationref(loader: _Loader, owner: Entity, element: ElementTree.Element) -> None: for citation_handle in _load_handles('citationref', element): loader.add_association(unflatten(owner).entity_type(), owner.id, 'citations', Citation, citation_handle) def _load_handles(handle_type: str, element: ElementTree.Element) -> Iterable[str]: for citation_handle_element in _xpath(element, f'./ns:{handle_type}'): - yield citation_handle_element.get('hlink') + hlink = citation_handle_element.get('hlink') + if hlink: + yield hlink def _load_handle(handle_type: str, element: ElementTree.Element) -> Optional[str]: for citation_handle_element in _xpath(element, f'./ns:{handle_type}'): return citation_handle_element.get('hlink') + return None -def _load_objref(loader: _Loader, owner: Entity, element: ElementTree.Element): +def _load_objref(loader: _Loader, owner: Entity, element: ElementTree.Element) -> None: file_handles = _load_handles('objref', element) for file_handle in file_handles: loader.add_association(unflatten(owner).entity_type(), owner.id, 'files', File, file_handle) -def _load_urls(owner: HasLinks, element: ElementTree.Element): +def _load_urls(owner: HasLinks, element: ElementTree.Element) -> None: url_elements = _xpath(element, './ns:url') for url_element in url_elements: link = Link(str(url_element.get('href'))) @@ -561,6 +611,6 @@ def _load_attribute_privacy(resource: HasPrivacy, element: ElementTree.Element, def _load_attribute(name: str, element: ElementTree.Element, tag: str) -> Optional[str]: - attribute_element = _xpath1(element, './ns:%s[@type="betty:%s"]' % (tag, name)) - if attribute_element is not None: - return attribute_element.get('value') + with suppress(XPathError): + return _xpath1(element, './ns:%s[@type="betty:%s"]' % (tag, name)).get('value') + return None diff --git a/betty/gui/__init__.py b/betty/gui/__init__.py index af2810346..32f062711 100644 --- a/betty/gui/__init__.py +++ b/betty/gui/__init__.py @@ -6,13 +6,13 @@ from PyQt6.QtCore import pyqtSlot, QObject from PyQt6.QtGui import QIcon -from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget +from PyQt6.QtWidgets import QApplication, QWidget from betty.app import App from betty.config import APP_CONFIGURATION_FORMATS from betty.error import UserFacingError from betty.gui.error import ExceptionError, UnexpectedExceptionError -from betty.gui.locale import LocalizedWidget +from betty.gui.locale import LocalizedWindow if TYPE_CHECKING: from betty.builtins import _ @@ -43,14 +43,13 @@ def mark_invalid(widget: QWidget, reason: str) -> None: widget.setToolTip(reason) -class BettyWindow(QMainWindow, LocalizedWidget): - width = NotImplemented - height = NotImplemented +class BettyWindow(LocalizedWindow): + window_width = 800 + window_height = 600 def __init__(self, app: App, *args, **kwargs): - super().__init__(*args, **kwargs) - self._app = app - self.resize(self.width, self.height) + super().__init__(app, *args, **kwargs) + self.resize(self.window_width, self.window_height) self.setWindowIcon(QIcon(path.join(path.dirname(__file__), 'assets', 'public', 'static', 'betty-512x512.png'))) geometry = self.frameGeometry() geometry.moveCenter(QApplication.primaryScreen().availableGeometry().center()) diff --git a/betty/gui/app.py b/betty/gui/app.py index 6ee820e0e..4991664bb 100644 --- a/betty/gui/app.py +++ b/betty/gui/app.py @@ -1,21 +1,19 @@ import webbrowser from datetime import datetime from os import path -from typing import Sequence, Type, TYPE_CHECKING +from typing import TYPE_CHECKING from PyQt6.QtCore import Qt, QCoreApplication from PyQt6.QtGui import QIcon, QAction from PyQt6.QtWidgets import QFormLayout, QWidget, QVBoxLayout, QHBoxLayout, QFileDialog, QPushButton from betty import about, cache -from betty.app import Extension from betty.asyncio import sync from betty.gui import BettyWindow, get_configuration_file_filter from betty.gui.error import catch_exceptions from betty.gui.locale import TranslationsLocaleCollector from betty.gui.serve import ServeDemoWindow from betty.gui.text import Text -from betty.importlib import import_any from betty.project import ProjectConfiguration if TYPE_CHECKING: @@ -23,71 +21,66 @@ class BettyMainWindow(BettyWindow): - width = 800 - height = 600 - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setWindowIcon(QIcon(path.join(path.dirname(__file__), 'assets', 'public', 'static', 'betty-512x512.png'))) - self._initialize_menu() - - @property - def title(self) -> str: - return 'Betty' - def _initialize_menu(self) -> None: menu_bar = self.menuBar() self.betty_menu = menu_bar.addMenu('&Betty') - self.betty_menu.new_project_action = QAction(self) - self.betty_menu.new_project_action.setShortcut('Ctrl+N') - self.betty_menu.new_project_action.triggered.connect(lambda _: self.new_project()) - self.betty_menu.addAction(self.betty_menu.new_project_action) + self.new_project_action = QAction(self) + self.new_project_action.setShortcut('Ctrl+N') + self.new_project_action.triggered.connect(lambda _: self.new_project()) # type: ignore + self.betty_menu.addAction(self.new_project_action) - self.betty_menu.open_project_action = QAction(self) - self.betty_menu.open_project_action.setShortcut('Ctrl+O') - self.betty_menu.open_project_action.triggered.connect(lambda _: self.open_project()) - self.betty_menu.addAction(self.betty_menu.open_project_action) + self.open_project_action = QAction(self) + self.open_project_action.setShortcut('Ctrl+O') + self.open_project_action.triggered.connect(lambda _: self.open_project()) # type: ignore + self.betty_menu.addAction(self.open_project_action) - self.betty_menu._demo_action = QAction(self) - self.betty_menu._demo_action.triggered.connect(lambda _: self._demo()) - self.betty_menu.addAction(self.betty_menu._demo_action) + self._demo_action = QAction(self) + self._demo_action.triggered.connect(lambda _: self._demo()) # type: ignore + self.betty_menu.addAction(self._demo_action) - self.betty_menu.open_application_configuration_action = QAction(self) - self.betty_menu.open_application_configuration_action.triggered.connect(lambda _: self.open_application_configuration()) - self.betty_menu.addAction(self.betty_menu.open_application_configuration_action) + self.open_application_configuration_action = QAction(self) + self.open_application_configuration_action.triggered.connect(lambda _: self.open_application_configuration()) # type: ignore + self.betty_menu.addAction(self.open_application_configuration_action) - self.betty_menu.clear_caches_action = QAction(self) - self.betty_menu.clear_caches_action.triggered.connect(lambda _: self.clear_caches()) - self.betty_menu.addAction(self.betty_menu.clear_caches_action) + self.clear_caches_action = QAction(self) + self.clear_caches_action.triggered.connect(lambda _: self.clear_caches()) # type: ignore + self.betty_menu.addAction(self.clear_caches_action) - self.betty_menu.exit_action = QAction(self) - self.betty_menu.exit_action.setShortcut('Ctrl+Q') - self.betty_menu.exit_action.triggered.connect(QCoreApplication.quit) - self.betty_menu.addAction(self.betty_menu.exit_action) + self.exit_action = QAction(self) + self.exit_action.setShortcut('Ctrl+Q') + self.exit_action.triggered.connect(QCoreApplication.quit) # type: ignore + self.betty_menu.addAction(self.exit_action) self.help_menu = menu_bar.addMenu('') - self.help_menu.view_issues_action = QAction(self) - self.help_menu.view_issues_action.triggered.connect(lambda _: self.view_issues()) - self.help_menu.addAction(self.help_menu.view_issues_action) + self.view_issues_action = QAction(self) + self.view_issues_action.triggered.connect(lambda _: self.view_issues()) # type: ignore + self.help_menu.addAction(self.view_issues_action) - self.help_menu.about_action = QAction(self) - self.help_menu.about_action.triggered.connect(lambda _: self._about_betty()) - self.help_menu.addAction(self.help_menu.about_action) + self.about_action = QAction(self) + self.about_action.triggered.connect(lambda _: self._about_betty()) # type: ignore + self.help_menu.addAction(self.about_action) + + @property + def title(self) -> str: + return 'Betty' def _do_set_translatables(self) -> None: super()._do_set_translatables() - self.betty_menu.new_project_action.setText(_('New project...')) - self.betty_menu.open_project_action.setText(_('Open project...')) - self.betty_menu._demo_action.setText(_('View demo site...')) - self.betty_menu.open_application_configuration_action.setText(_('Settings...')) - self.betty_menu.clear_caches_action.setText(_('Clear all caches')) - self.betty_menu.exit_action.setText(_('Exit')) + self.new_project_action.setText(_('New project...')) + self.open_project_action.setText(_('Open project...')) + self._demo_action.setText(_('View demo site...')) + self.open_application_configuration_action.setText(_('Settings...')) + self.clear_caches_action.setText(_('Clear all caches')) + self.exit_action.setText(_('Exit')) self.help_menu.setTitle('&' + _('Help')) - self.help_menu.view_issues_action.setText(_('Report bugs and request new features')) - self.help_menu.about_action.setText(_('About Betty')) + self.view_issues_action.setText(_('Report bugs and request new features')) + self.about_action.setText(_('About Betty')) @catch_exceptions def view_issues(self) -> None: @@ -167,10 +160,10 @@ class _WelcomeAction(QPushButton): class WelcomeWindow(BettyMainWindow): # Allow the window to be as narrow as it can be. - width = 1 + window_width = 1 # This is a best guess at the minimum required height, because if we set this to 1, like the width, some of the # text will be clipped. - height = 450 + window_height = 450 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -196,11 +189,11 @@ def __init__(self, *args, **kwargs): central_layout.addLayout(project_layout) self.open_project_button = _WelcomeAction(self) - self.open_project_button.released.connect(self.open_project) + self.open_project_button.released.connect(self.open_project) # type: ignore project_layout.addWidget(self.open_project_button) self.new_project_button = _WelcomeAction(self) - self.new_project_button.released.connect(self.new_project) + self.new_project_button.released.connect(self.new_project) # type: ignore project_layout.addWidget(self.new_project_button) self._demo_instruction = _WelcomeHeading() @@ -208,7 +201,7 @@ def __init__(self, *args, **kwargs): central_layout.addWidget(self._demo_instruction) self.demo_button = _WelcomeAction(self) - self.demo_button.released.connect(self._demo) + self.demo_button.released.connect(self._demo) # type: ignore central_layout.addWidget(self.demo_button) def _do_set_translatables(self) -> None: @@ -223,8 +216,8 @@ def _do_set_translatables(self) -> None: class _AboutBettyWindow(BettyWindow): - width = 500 - height = 100 + window_width = 500 + window_height = 100 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -247,8 +240,8 @@ def title(self) -> str: class ApplicationConfiguration(BettyWindow): - width = 400 - height = 150 + window_width = 400 + window_height = 150 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -264,7 +257,3 @@ def __init__(self, *args, **kwargs): @property def title(self) -> str: return _('Configuration') - - @property - def extension_types(self) -> Sequence[Type[Extension]]: - return [import_any(extension_name) for extension_name in self._EXTENSION_NAMES] diff --git a/betty/gui/error.py b/betty/gui/error.py index 8220b342c..ffe0410f5 100644 --- a/betty/gui/error.py +++ b/betty/gui/error.py @@ -2,7 +2,10 @@ import functools import traceback -from typing import Optional, Callable, Any, List +from typing import Optional, Callable, Any, List, TYPE_CHECKING + +if TYPE_CHECKING: + from betty.builtins import _ from PyQt6.QtCore import QMetaObject, Qt, Q_ARG, QObject from PyQt6.QtGui import QCloseEvent, QIcon @@ -87,12 +90,14 @@ def __init__( self.button(QMessageBox.StandardButton.Close).setIcon(QIcon()) self.setDefaultButton(QMessageBox.StandardButton.Close) self.setEscapeButton(QMessageBox.StandardButton.Close) - self.button(QMessageBox.StandardButton.Close).clicked.connect(self.close) + self.button(QMessageBox.StandardButton.Close).clicked.connect(self.close) # type: ignore def closeEvent(self, event: QCloseEvent) -> None: Error._errors.remove(self) if self._close_parent: - self.parent().close() + parent = self.parent() + if isinstance(parent, QWidget): + parent.close() super().closeEvent(event) diff --git a/betty/gui/locale.py b/betty/gui/locale.py index 2d01cab23..e89b8ec00 100644 --- a/betty/gui/locale.py +++ b/betty/gui/locale.py @@ -1,14 +1,14 @@ -from typing import Set, TYPE_CHECKING +from typing import Set, TYPE_CHECKING, List, Tuple from PyQt6 import QtGui -from PyQt6.QtWidgets import QComboBox, QLabel, QWidget +from PyQt6.QtWidgets import QComboBox, QLabel, QWidget, QMainWindow from babel.core import Locale from reactives import reactive from reactives.factory.type import ReactiveInstance from betty.app import App from betty.gui.text import Caption -from betty.locale import bcp_47_to_rfc_1766, getdefaultlocale, negotiate_locale +from betty.locale import bcp_47_to_rfc_1766, getdefaultlocale, negotiate_locale, getdefaultlocale_rfc_1766 if TYPE_CHECKING: from betty.builtins import _ @@ -21,12 +21,12 @@ def __init__(self, app: App, allowed_locales: Set[str]): self._app = app self._allowed_locales = allowed_locales - allowed_locale_names = [] + allowed_locale_names: List[Tuple[str, str]] = [] for allowed_locale in allowed_locales: allowed_locale_names.append((allowed_locale, Locale.parse(bcp_47_to_rfc_1766(allowed_locale)).get_display_name())) allowed_locale_names = sorted(allowed_locale_names, key=lambda x: x[1]) # This is the operating system default, for which we'll set a label in self._do_set_translatables() - allowed_locale_names.insert(0, (None, None)) + allowed_locale_names.insert(0, ('', '')) def _update_configuration_locale() -> None: self._app.configuration.locale = self._configuration_locale.currentData() @@ -35,7 +35,7 @@ def _update_configuration_locale() -> None: self._configuration_locale.addItem(locale_name, locale) if locale == self._app.configuration.locale: self._configuration_locale.setCurrentIndex(i) - self._configuration_locale.currentIndexChanged.connect(_update_configuration_locale) + self._configuration_locale.currentIndexChanged.connect(_update_configuration_locale) # type: ignore self._configuration_locale_label = QLabel() self._configuration_locale_caption = Caption() @@ -56,36 +56,43 @@ def rows(self): def _set_translatables(self) -> None: with self._app.acquire_locale(): self._configuration_locale.setItemText(0, _('Operating system default: {locale_name}').format( - locale_name=Locale.parse(bcp_47_to_rfc_1766(getdefaultlocale())).get_display_name(locale=bcp_47_to_rfc_1766(self._app.locale)), + locale_name=Locale.parse(getdefaultlocale_rfc_1766()).get_display_name(locale=bcp_47_to_rfc_1766(self._app.locale)), )) self._configuration_locale_label.setText(_('Locale')) locale = self.locale.currentData() if locale is None: locale = getdefaultlocale() - translations_locale = negotiate_locale( - locale, - set(self._app.translations.locales), - ) - if translations_locale is None: - self._configuration_locale_caption.setText(_('There are no translations for {locale_name}.').format( - locale_name=Locale.parse(bcp_47_to_rfc_1766(locale)).get_display_name(locale=bcp_47_to_rfc_1766(self._app.locale)), - )) - else: - negotiated_locale_translations_coverage = self._app.translations.coverage(translations_locale) - if 'en-US' == translations_locale: - negotiated_locale_translations_coverage_percentage = 100 + if locale != '': + translations_locale = negotiate_locale( + locale, + set(self._app.translations.locales), + ) + if translations_locale is None: + self._configuration_locale_caption.setText(_('There are no translations for {locale_name}.').format( + locale_name=Locale.parse(bcp_47_to_rfc_1766(locale)).get_display_name( + locale=bcp_47_to_rfc_1766(self._app.locale), + ), + )) else: - negotiated_locale_translations_coverage_percentage = 100 / (negotiated_locale_translations_coverage[1] / negotiated_locale_translations_coverage[0]) - self._configuration_locale_caption.setText(_('The translations for {locale_name} are {coverage_percentage}% complete.').format( - locale_name=Locale.parse(bcp_47_to_rfc_1766(translations_locale)).get_display_name(locale=bcp_47_to_rfc_1766(self._app.locale)), - coverage_percentage=round(negotiated_locale_translations_coverage_percentage) - )) + negotiated_locale_translations_coverage = self._app.translations.coverage(translations_locale) + if 'en-US' == translations_locale: + negotiated_locale_translations_coverage_percentage = 100 + else: + negotiated_locale_translations_coverage_percentage = round(100 / (negotiated_locale_translations_coverage[1] / negotiated_locale_translations_coverage[0])) + self._configuration_locale_caption.setText(_('The translations for {locale_name} are {coverage_percentage}% complete.').format( + locale_name=Locale.parse(bcp_47_to_rfc_1766(translations_locale)).get_display_name(locale=bcp_47_to_rfc_1766(self._app.locale)), + coverage_percentage=round(negotiated_locale_translations_coverage_percentage) + )) @reactive -class LocalizedWidget(QWidget, ReactiveInstance): +class _LocalizedObject(ReactiveInstance): + def __init__(self, app: App, *args, **kwargs): + super().__init__(*args, **kwargs) + self._app = app + def showEvent(self, event: QtGui.QShowEvent) -> None: - super().showEvent(event) + super().showEvent(event) # type: ignore self._set_translatables() @reactive(on_trigger_call=True) @@ -95,3 +102,11 @@ def _set_translatables(self) -> None: def _do_set_translatables(self) -> None: pass + + +class LocalizedWidget(_LocalizedObject, QWidget): + pass + + +class LocalizedWindow(_LocalizedObject, QMainWindow): + pass diff --git a/betty/gui/logging.py b/betty/gui/logging.py index a062373c4..af0010a45 100644 --- a/betty/gui/logging.py +++ b/betty/gui/logging.py @@ -26,6 +26,7 @@ def _normalize_level(self, record_level: int) -> int: for level in self._LEVELS: if record_level >= level: return level + return logging.NOTSET class LogRecordViewer(QWidget): @@ -46,7 +47,7 @@ class _LogRecordViewerHandlerObject(QObject): def __init__(self, viewer: LogRecordViewer): super().__init__() - self.log.connect(viewer.log, Qt.ConnectionType.QueuedConnection) + self.log.connect(viewer.log, Qt.ConnectionType.QueuedConnection) # type: ignore class LogRecordViewerHandler(logging.Handler): diff --git a/betty/gui/model.py b/betty/gui/model.py index c226b7abe..d1d6679fc 100644 --- a/betty/gui/model.py +++ b/betty/gui/model.py @@ -13,8 +13,7 @@ class EntityReferenceCollector(LocalizedWidget): def __init__(self, app: App, entity_reference: EntityReference, label_builder: Optional[Callable[[], str]] = None, caption_builder: Optional[Callable[[], str]] = None): - super().__init__() - self._app = app + super().__init__(app) self._entity_reference = entity_reference self._label_builder = label_builder self._caption_builder = caption_builder @@ -23,23 +22,23 @@ def __init__(self, app: App, entity_reference: EntityReference, label_builder: O self.setLayout(self._layout) if self._entity_reference.entity_type_constraint: - self._entity_type = QLabel() + self._entity_type_label = QLabel() else: def _update_entity_type() -> None: self._entity_reference.entity_type = self._entity_type.currentData() self._entity_type = QComboBox() - self._entity_type.currentIndexChanged.connect(_update_entity_type) + self._entity_type.currentIndexChanged.connect(_update_entity_type) # type: ignore for i, entity_type in enumerate(sorted(self._app.entity_types, key=lambda entity_type: entity_type.entity_type_label())): self._entity_type.addItem(entity_type.entity_type_label(), entity_type) if entity_type == self._entity_reference.entity_type: self._entity_type.setCurrentIndex(i) - self._configuration_entity_type_label = QLabel() - self._layout.addRow(self._configuration_entity_type_label, self._entity_type) + self._entity_type_label = QLabel() + self._layout.addRow(self._entity_type_label, self._entity_type) def _update_entity_id() -> None: self._entity_reference.entity_id = self._entity_id.text() self._entity_id = QLineEdit() - self._entity_id.textChanged.connect(_update_entity_id) + self._entity_id.textChanged.connect(_update_entity_id) # type: ignore self._entity_id_label = QLabel() self._layout.addRow(self._entity_id_label, self._entity_id) @@ -58,8 +57,7 @@ def _set_translatables(self) -> None: class EntityReferencesCollector(LocalizedWidget): def __init__(self, app: App, entity_references: EntityReferences, label_builder: Optional[Callable[[], str]] = None, caption_builder: Optional[Callable[[], str]] = None): - super().__init__() - self._app = app + super().__init__(app) self._entity_references = entity_references self._label_builder = label_builder self._caption_builder = caption_builder @@ -81,15 +79,15 @@ def __init__(self, app: App, entity_references: EntityReferences, label_builder: self._entity_reference_collectors_widget.setLayout(self._entity_reference_collectors_layout) self._layout.addWidget(self._entity_reference_collectors_widget) - self._entity_reference_collection_widgets = [] - self._entity_reference_remove_buttons = [] + self._entity_reference_collection_widgets: List[QWidget] = [] + self._entity_reference_remove_buttons: List[QPushButton] = [] if caption_builder: self._caption = QLabel() self._layout.addWidget(self._caption) self._add_entity_reference_button = QPushButton() - self._add_entity_reference_button.released.connect(self._add_entity_reference) + self._add_entity_reference_button.released.connect(self._add_entity_reference) # type: ignore self._layout.addWidget(self._add_entity_reference_button) self._build_entity_references_collection() @@ -126,7 +124,7 @@ def _build_entity_reference_collection(self, i: int, entity_reference: EntityRef entity_reference_remove_button = QPushButton() self._entity_reference_remove_buttons.insert(i, entity_reference_remove_button) - entity_reference_remove_button.released.connect(lambda: self._remove_entity_reference(i)) + entity_reference_remove_button.released.connect(lambda: self._remove_entity_reference(i)) # type: ignore layout.addWidget(entity_reference_remove_button) @reactive(on_trigger_call=True) diff --git a/betty/gui/project.py b/betty/gui/project.py index 1cf1a41f1..95e72dfc4 100644 --- a/betty/gui/project.py +++ b/betty/gui/project.py @@ -3,7 +3,7 @@ import copy import itertools import re -from typing import Sequence, Type, Union, TYPE_CHECKING, Any +from typing import Type, Union, TYPE_CHECKING, Any, Optional from urllib.parse import urlparse from PyQt6.QtCore import Qt, QThread @@ -28,9 +28,8 @@ from betty.gui.model import EntityReferenceCollector, EntityReferencesCollector from betty.gui.serve import ServeAppWindow from betty.gui.text import Text, Caption -from betty.importlib import import_any from betty.locale import rfc_1766_to_bcp_47, bcp_47_to_rfc_1766 -from betty.project import ProjectExtensionConfiguration, LocaleConfiguration, LocalesConfiguration +from betty.project import ProjectExtensionConfiguration, LocaleConfiguration if TYPE_CHECKING: from betty.builtins import _ @@ -42,15 +41,14 @@ def __init__(self, pane_selectors_layout: QLayout, panes_layout: QStackedLayout, self.setProperty('pane-selector', 'true') self.setFlat(panes_layout.currentWidget() != pane) self.setCursor(Qt.CursorShape.PointingHandCursor) - self.released.connect(lambda: [pane_selectors_layout.itemAt(i).widget().setFlat(True) for i in range(0, pane_selectors_layout.count())]) - self.released.connect(lambda: self.setFlat(False)) - self.released.connect(lambda: panes_layout.setCurrentWidget(pane)) + self.released.connect(lambda: [pane_selectors_layout.itemAt(i).widget().setFlat(True) for i in range(0, pane_selectors_layout.count())]) # type: ignore + self.released.connect(lambda: self.setFlat(False)) # type: ignore + self.released.connect(lambda: panes_layout.setCurrentWidget(pane)) # type: ignore class _GeneralPane(LocalizedWidget): def __init__(self, app: App, *args, **kwargs): - super().__init__(*args, **kwargs) - self._app = app + super().__init__(app, *args, **kwargs) self._form = QFormLayout() self.setLayout(self._form) @@ -67,7 +65,7 @@ def _update_configuration_title(title: str) -> None: self._app.project.configuration.title = title self._configuration_title = QLineEdit() self._configuration_title.setText(self._app.project.configuration.title) - self._configuration_title.textChanged.connect(_update_configuration_title) + self._configuration_title.textChanged.connect(_update_configuration_title) # type: ignore self._configuration_title_label = QLabel() self._form.addRow(self._configuration_title_label, self._configuration_title) @@ -76,7 +74,7 @@ def _update_configuration_author(author: str) -> None: self._app.project.configuration.author = author self._configuration_author = QLineEdit() self._configuration_author.setText(self._app.project.configuration.author) - self._configuration_author.textChanged.connect(_update_configuration_author) + self._configuration_author.textChanged.connect(_update_configuration_author) # type: ignore self._configuration_author_label = QLabel() self._form.addRow(self._configuration_author_label, self._configuration_author) @@ -98,16 +96,16 @@ def _update_configuration_url(url: str) -> None: mark_valid(self._configuration_url) self._configuration_url = QLineEdit() self._configuration_url.setText(self._app.project.configuration.base_url + self._app.project.configuration.root_path) - self._configuration_url.textChanged.connect(_update_configuration_url) + self._configuration_url.textChanged.connect(_update_configuration_url) # type: ignore self._configuration_url_label = QLabel() self._form.addRow(self._configuration_url_label, self._configuration_url) def _build_lifetime_threshold(self) -> None: - def _update_configuration_lifetime_threshold(lifetime_threshold: str) -> None: - if re.fullmatch(r'^\d+$', lifetime_threshold) is None: + def _update_configuration_lifetime_threshold(lifetime_threshold_value: str) -> None: + if re.fullmatch(r'^\d+$', lifetime_threshold_value) is None: mark_invalid(self._configuration_url, _('The lifetime threshold must consist of digits only.')) return - lifetime_threshold = int(lifetime_threshold) + lifetime_threshold = int(lifetime_threshold_value) try: self._app.project.configuration.lifetime_threshold = lifetime_threshold mark_valid(self._configuration_url) @@ -116,7 +114,7 @@ def _update_configuration_lifetime_threshold(lifetime_threshold: str) -> None: self._configuration_lifetime_threshold = QLineEdit() self._configuration_lifetime_threshold.setFixedWidth(32) self._configuration_lifetime_threshold.setText(str(self._app.project.configuration.lifetime_threshold)) - self._configuration_lifetime_threshold.textChanged.connect(_update_configuration_lifetime_threshold) + self._configuration_lifetime_threshold.textChanged.connect(_update_configuration_lifetime_threshold) # type: ignore self._configuration_lifetime_threshold_label = QLabel() self._form.addRow(self._configuration_lifetime_threshold_label, self._configuration_lifetime_threshold) self._configuration_lifetime_threshold_caption = Caption() @@ -127,7 +125,7 @@ def _update_configuration_debug(mode: bool) -> None: self._app.project.configuration.debug = mode self._development_debug = QCheckBox() self._development_debug.setChecked(self._app.project.configuration.debug) - self._development_debug.toggled.connect(_update_configuration_debug) + self._development_debug.toggled.connect(_update_configuration_debug) # type: ignore self._form.addRow(self._development_debug) self._development_debug_caption = Caption() self._form.addRow(self._development_debug_caption) @@ -139,7 +137,7 @@ def _update_configuration_clean_urls(clean_urls: bool) -> None: self._content_negotiation.setChecked(False) self._clean_urls = QCheckBox() self._clean_urls.setChecked(self._app.project.configuration.clean_urls) - self._clean_urls.toggled.connect(_update_configuration_clean_urls) + self._clean_urls.toggled.connect(_update_configuration_clean_urls) # type: ignore self._form.addRow(self._clean_urls) self._clean_urls_caption = Caption() self._form.addRow(self._clean_urls_caption) @@ -151,7 +149,7 @@ def _update_configuration_content_negotiation(content_negotiation: bool) -> None self._clean_urls.setChecked(True) self._content_negotiation = QCheckBox() self._content_negotiation.setChecked(self._app.project.configuration.content_negotiation) - self._content_negotiation.toggled.connect(_update_configuration_content_negotiation) + self._content_negotiation.toggled.connect(_update_configuration_content_negotiation) # type: ignore self._form.addRow(self._content_negotiation) self._content_negotiation_caption = Caption() self._form.addRow(self._content_negotiation_caption) @@ -204,8 +202,7 @@ def __init__(self, app: App, *args, **kwargs): @reactive class _LocalizationPane(LocalizedWidget): def __init__(self, app: App, *args, **kwargs): - super().__init__(*args, **kwargs) - self._app = app + super().__init__(app, *args, **kwargs) self._layout = QVBoxLayout() self.setLayout(self._layout) @@ -215,7 +212,7 @@ def __init__(self, app: App, *args, **kwargs): self._build_locales_configuration() self._add_locale_button = QPushButton() - self._add_locale_button.released.connect(self._add_locale) + self._add_locale_button.released.connect(self._add_locale) # type: ignore self._layout.addWidget(self._add_locale_button, 1) @reactive(on_trigger_call=True) @@ -248,7 +245,7 @@ def _build_locale_configuration(self, locale_configuration: LocaleConfiguration, def _update_locales_configuration_default(): self._app.project.configuration.locales.default = locale_configuration - self._locales_configuration_widget._default_buttons[locale_configuration.locale].clicked.connect(_update_locales_configuration_default) + self._locales_configuration_widget._default_buttons[locale_configuration.locale].clicked.connect(_update_locales_configuration_default) # type: ignore self._default_locale_button_group.addButton(self._locales_configuration_widget._default_buttons[locale_configuration.locale]) self._locales_configuration_layout.addWidget(self._locales_configuration_widget._default_buttons[locale_configuration.locale], i, 0) @@ -257,7 +254,7 @@ def _update_locales_configuration_default(): def _remove_locale() -> None: del self._app.project.configuration.locales[locale_configuration.locale] self._locales_configuration_widget._remove_buttons[locale_configuration.locale] = QPushButton() - self._locales_configuration_widget._remove_buttons[locale_configuration.locale].released.connect(_remove_locale) + self._locales_configuration_widget._remove_buttons[locale_configuration.locale].released.connect(_remove_locale) # type: ignore self._locales_configuration_layout.addWidget(self._locales_configuration_widget._remove_buttons[locale_configuration.locale], i, 1) else: self._locales_configuration_widget._remove_buttons[locale_configuration.locale] = None @@ -271,17 +268,16 @@ def _do_set_translatables(self) -> None: button.setText(_('Remove')) def _add_locale(self): - window = _AddLocaleWindow(self._app, self._app.project.configuration.locales, self) + window = _AddLocaleWindow(self._app, self) window.show() class _AddLocaleWindow(BettyWindow): - width = 500 - height = 250 + window_width = 500 + window_height = 250 - def __init__(self, app: App, locales_configuration: LocalesConfiguration, *args, **kwargs): + def __init__(self, app: App, *args, **kwargs): super().__init__(app, *args, **kwargs) - self._locales_configuration = locales_configuration self._layout = QFormLayout() self._widget = QWidget() @@ -302,11 +298,11 @@ def __init__(self, app: App, locales_configuration: LocalesConfiguration, *args, self._layout.addRow(buttons_layout) self._save_and_close = QPushButton(_('Save and close')) - self._save_and_close.released.connect(self._save_and_close_locale) + self._save_and_close.released.connect(self._save_and_close_locale) # type: ignore buttons_layout.addWidget(self._save_and_close) self._cancel = QPushButton(_('Cancel')) - self._cancel.released.connect(self.close) + self._cancel.released.connect(self.close) # type: ignore buttons_layout.addWidget(self._cancel) def _do_set_translatables(self) -> None: @@ -321,13 +317,13 @@ def title(self) -> str: @catch_exceptions def _save_and_close_locale(self) -> None: locale = self._locale_collector.locale.currentData() - alias = self._alias.text().strip() + alias: Optional[str] = self._alias.text().strip() if alias == '': alias = None try: - self._locales_configuration.add(LocaleConfiguration(locale, alias)) + with self._app.acquire_locale(): + self._app.project.configuration.locales.add(LocaleConfiguration(locale, alias)) except ConfigurationError as e: - mark_invalid(self._locale, str(e)) mark_invalid(self._alias, str(e)) return self.close() @@ -335,9 +331,8 @@ def _save_and_close_locale(self) -> None: @reactive class _ExtensionPane(LocalizedWidget): - def __init__(self, app: App, extension_type: Type[Union[Extension, GuiBuilder]], *args, **kwargs): - super().__init__(*args, **kwargs) - self._app = app + def __init__(self, app: App, extension_type: Union[Type[Extension], Type[GuiBuilder]], *args, **kwargs): + super().__init__(app, *args, **kwargs) self._extension_type = extension_type layout = QVBoxLayout() @@ -356,11 +351,11 @@ def _update_enabled(enabled: bool) -> None: self._app.project.configuration.extensions[extension_type].enabled = enabled except KeyError: self._app.project.configuration.extensions.add(ProjectExtensionConfiguration( - extension_type, + extension_type, # type: ignore enabled, )) if enabled: - extension_gui_widget = self._app.extensions[extension_type].gui_build() + extension_gui_widget = self._app.extensions[extension_type].gui_build() # type: ignore if extension_gui_widget is not None: layout.addWidget(extension_gui_widget) else: @@ -373,18 +368,29 @@ def _update_enabled(enabled: bool) -> None: self._extension_enabled = QCheckBox() self._extension_enabled.setChecked(extension_type in self._app.extensions) - self._extension_enabled.setDisabled(extension_type in itertools.chain([enabled_extension_type.depends_on() for enabled_extension_type in self._app.extensions.flatten()])) - self._extension_enabled.toggled.connect(_update_enabled) + self._extension_enabled.setDisabled( + extension_type # type: ignore + in itertools.chain([ + enabled_extension_type.depends_on() + for enabled_extension_type + in self._app.extensions.flatten() + ]) + ) + self._extension_enabled.toggled.connect(_update_enabled) # type: ignore enable_layout.addRow(self._extension_enabled) if extension_type in self._app.extensions: - extension_gui_widget = self._app.extensions[extension_type].gui_build() + extension_gui_widget = self._app.extensions[extension_type].gui_build() # type: ignore if extension_gui_widget is not None: layout.addWidget(extension_gui_widget) def _do_set_translatables(self) -> None: - self._extension_description.setText(self._extension_type.gui_description()) - self._extension_enabled.setText(_('Enable {extension}').format(extension=self._extension_type.label())) + self._extension_description.setText( + self._extension_type.gui_description(), # type: ignore + ) + self._extension_enabled.setText(_('Enable {extension}').format( + extension=self._extension_type.label(), # type: ignore + )) class ProjectWindow(BettyMainWindow): @@ -427,6 +433,27 @@ def __init__(self, app: App, *args, **kwargs): self._extension_configuration_pane_selectors[extension_type] = _PaneButton(pane_selectors_layout, panes_layout, extension_pane, self) pane_selectors_layout.addWidget(self._extension_configuration_pane_selectors[extension_type]) + menu_bar = self.menuBar() + + self.project_menu = QMenu() + menu_bar.addMenu(self.project_menu) + menu_bar.insertMenu(self.help_menu.menuAction(), self.project_menu) + + self.save_project_as_action = QAction(self) + self.save_project_as_action.setShortcut('Ctrl+Shift+S') + self.save_project_as_action.triggered.connect(lambda _: self._save_project_as()) # type: ignore + self.addAction(self.save_project_as_action) + + self.generate_action = QAction(self) + self.generate_action.setShortcut('Ctrl+G') + self.generate_action.triggered.connect(lambda _: self._generate()) # type: ignore + self.addAction(self.generate_action) + + self.serve_action = QAction(self) + self.serve_action.setShortcut('Ctrl+Alt+S') + self.serve_action.triggered.connect(lambda _: self._serve()) # type: ignore + self.addAction(self.serve_action) + def show(self) -> None: self._app.project.configuration.autowrite = True super().show() @@ -438,9 +465,9 @@ def close(self) -> bool: def _do_set_translatables(self) -> None: super()._do_set_translatables() self.project_menu.setTitle('&' + _('Project')) - self.project_menu.save_project_as_action.setText(_('Save this project as...')) - self.project_menu.generate_action.setText(_('Generate site')) - self.project_menu.serve_action.setText(_('Serve site')) + self.save_project_as_action.setText(_('Save this project as...')) + self.generate_action.setText(_('Generate site')) + self.serve_action.setText(_('Serve site')) self._general_configuration_pane_selector.setText(_('General')) self._theme_configuration_pane_selector.setText(_('Theme')) self._localization_configuration_pane_selector.setText(_('Localization')) @@ -451,34 +478,6 @@ def _do_set_translatables(self) -> None: def _set_window_title(self) -> None: self.setWindowTitle('%s - Betty' % self._app.project.configuration.title) - @property - def extension_types(self) -> Sequence[Type[Extension]]: - return [import_any(extension_name) for extension_name in self._EXTENSION_NAMES] - - def _initialize_menu(self) -> None: - super()._initialize_menu() - - menu_bar = self.menuBar() - - self.project_menu = QMenu() - menu_bar.addMenu(self.project_menu) - menu_bar.insertMenu(self.help_menu.menuAction(), self.project_menu) - - self.project_menu.save_project_as_action = QAction(self) - self.project_menu.save_project_as_action.setShortcut('Ctrl+Shift+S') - self.project_menu.save_project_as_action.triggered.connect(lambda _: self._save_project_as()) - self.project_menu.addAction(self.project_menu.save_project_as_action) - - self.project_menu.generate_action = QAction(self) - self.project_menu.generate_action.setShortcut('Ctrl+G') - self.project_menu.generate_action.triggered.connect(lambda _: self._generate()) - self.project_menu.addAction(self.project_menu.generate_action) - - self.project_menu.serve_action = QAction(self) - self.project_menu.serve_action.setShortcut('Ctrl+Alt+S') - self.project_menu.serve_action.triggered.connect(lambda _: self._serve()) - self.project_menu.addAction(self.project_menu.serve_action) - @catch_exceptions def _save_project_as(self) -> None: configuration_file_path, __ = QFileDialog.getSaveFileName( @@ -515,8 +514,8 @@ async def run(self) -> None: class _GenerateWindow(BettyWindow): - width = 500 - height = 100 + window_width = 500 + window_height = 100 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -537,17 +536,17 @@ def __init__(self, *args, **kwargs): self._close_button = QPushButton(_('Close')) self._close_button.setDisabled(True) - self._close_button.released.connect(self.close) + self._close_button.released.connect(self.close) # type: ignore button_layout.addWidget(self._close_button) self._serve_button = QPushButton(_('View site')) self._serve_button.setDisabled(True) - self._serve_button.released.connect(self._serve) + self._serve_button.released.connect(self._serve) # type: ignore button_layout.addWidget(self._serve_button) self._logging_handler = LogRecordViewerHandler(self._log_record_viewer) self._thread = _GenerateThread(copy.copy(self._app), self) - self._thread.finished.connect(self._finish_generate) + self._thread.finished.connect(self._finish_generate) # type: ignore @property def title(self) -> str: diff --git a/betty/gui/serve.py b/betty/gui/serve.py index 39c721a1a..7f18b0c02 100644 --- a/betty/gui/serve.py +++ b/betty/gui/serve.py @@ -1,17 +1,16 @@ import copy -from contextlib import suppress from os import path from typing import TYPE_CHECKING from PyQt6.QtCore import Qt, QThread, pyqtSignal from PyQt6.QtWidgets import QVBoxLayout, QWidget, QPushButton -from betty import serve from betty.app import App from betty.asyncio import sync from betty.config import ConfigurationError from betty.gui import BettyWindow from betty.gui.text import Text +from betty.serve import Server, AppServer if TYPE_CHECKING: from betty.builtins import _ @@ -20,7 +19,7 @@ class _ServeThread(QThread): server_started = pyqtSignal() - def __init__(self, app: App, server: serve.Server, *args, **kwargs): + def __init__(self, app: App, server: Server, *args, **kwargs): super().__init__(*args, **kwargs) self._app = app self._server = server @@ -33,7 +32,8 @@ async def run(self) -> None: @sync async def stop(self) -> None: - await self._server.stop() + if self._server: + await self._server.stop() self._app.release() @@ -45,15 +45,15 @@ class _ServeWindow(BettyWindow): get_instance() method. """ - width = 500 - height = 100 + window_width = 500 + window_height = 100 _instance = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._thread = None - self._server = NotImplemented + self._server: Server self._central_layout = QVBoxLayout() central_widget = QWidget() @@ -80,17 +80,18 @@ def _server_started(self) -> None: self._loading_instruction.close() - instance_instruction = Text(self._build_instruction()) - instance_instruction.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._central_layout.addWidget(instance_instruction) + with self._app.acquire_locale(): + instance_instruction = Text(self._build_instruction()) + instance_instruction.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._central_layout.addWidget(instance_instruction) - general_instruction = Text(_('Keep this window open to keep the site running.')) - general_instruction.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._central_layout.addWidget(general_instruction) + general_instruction = Text(_('Keep this window open to keep the site running.')) + general_instruction.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._central_layout.addWidget(general_instruction) - stop_server_button = QPushButton(_('Stop the site'), self) - stop_server_button.released.connect(self.close) - self._central_layout.addWidget(stop_server_button) + stop_server_button = QPushButton(_('Stop the site'), self) + stop_server_button.released.connect(self.close) # type: ignore + self._central_layout.addWidget(stop_server_button) def show(self) -> None: super().show() @@ -109,10 +110,9 @@ def close(self) -> bool: return super().close() def _stop(self) -> None: - with suppress(AttributeError): + if self._thread is not None: self._thread.stop() self._thread = None - self._server = None self.__class__._instance = None @@ -127,7 +127,7 @@ class ServeAppWindow(_ServeWindow): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._server = serve.AppServer(self._app) + self._server = AppServer(self._app) if not path.isdir(self._app.project.configuration.www_directory_path): self.close() diff --git a/betty/jinja2.py b/betty/jinja2.py index 9893bc0cd..4af3d551d 100644 --- a/betty/jinja2.py +++ b/betty/jinja2.py @@ -6,8 +6,8 @@ import warnings from contextlib import suppress from pathlib import Path -from typing import Dict, Callable, Iterable, Type, Optional, Any, Union, Iterator, AsyncIterable, ContextManager, cast, \ - AsyncContextManager, MutableMapping, List +from typing import Dict, Callable, Iterable, Type, Optional, Any, Union, Iterator, ContextManager, cast, \ + AsyncContextManager, MutableMapping, List, TYPE_CHECKING import aiofiles import pdf2image @@ -46,12 +46,16 @@ from betty.string import camel_case_to_snake_case, camel_case_to_kebab_case, upper_camel_case_to_lower_camel_case +if TYPE_CHECKING: + from betty.builtins import gettext, ngettext, pgettext, npgettext + + class _Citer: def __init__(self): - self._citations = [] + self._citations: List[Citation] = [] def __iter__(self) -> Iterator[Citation]: - return enumerate(self._citations, 1) + return enumerate(self._citations, 1) # type: ignore def __len__(self) -> int: return len(self._citations) @@ -212,7 +216,7 @@ def _init_extensions(self) -> None: self.globals.update(extension.globals) self.filters.update(extension.filters) - def negotiate_template(self, names: List[Union[str, Template]], parent: Optional[str] = None, globals: Optional[MutableMapping[str, Any]] = None) -> Template: + def negotiate_template(self, names: List[str], parent: Optional[str] = None, globals: Optional[MutableMapping[str, Any]] = None) -> Template: for name in names: with suppress(TemplateNotFound): return self.get_template(name, parent, globals) @@ -264,7 +268,7 @@ def _filter_json(context: Context, data: Any, indent: Optional[int] = None) -> s """ Converts a value to a JSON string. """ - return stdjson.dumps(data, indent=indent, cls=JSONEncoder.get_factory(context.environment.app)) + return stdjson.dumps(data, indent=indent, cls=JSONEncoder.get_factory(cast(Environment, context.environment).app)) @pass_context @@ -325,7 +329,7 @@ def _filter_unique(items: Iterable) -> Iterator: @pass_context -def _filter_map(context: Context, value: Union[AsyncIterable, Iterable], *args: Any, **kwargs: Any,): +def _filter_map(context: Context, value: Iterable, *args: Any, **kwargs: Any,): """ Maps an iterable's values. @@ -333,7 +337,7 @@ def _filter_map(context: Context, value: Union[AsyncIterable, Iterable], *args: """ if value: if len(args) > 0 and isinstance(args[0], Macro): - func = args[0] + func: Union[Macro, Callable[[Any], bool]] = args[0] else: func = prepare_map(context, args, kwargs) for item in value: @@ -355,16 +359,15 @@ def _do_filter_file(file_source_path: Path, file_destination_path: Path) -> None def _filter_image(app: App, file: File, width: Optional[int] = None, height: Optional[int] = None) -> str: - if width is None and height is None: - raise ValueError('At least the width or height must be given.') - destination_name = '%s-' % file.id - if width is None: + if height: destination_name += '-x%d' % height - elif height is None: + elif width: destination_name += '%dx-' % width - else: + elif height and width: destination_name += '%dx%d' % (width, height) + else: + raise ValueError('At least the width or height must be given.') file_directory_path = app.project.configuration.www_directory_path / 'file' @@ -423,9 +426,9 @@ def _execute_filter_image(image: Image, file_path: Path, cache_directory_path: P cache_directory_path.mkdir(exist_ok=True, parents=True) with image: if width is not None: - width = min(width, image.width) + width = min(width, image.window_width) if height is not None: - height = min(height, image.height) + height = min(height, image.window_height) if width is None: size = height @@ -443,7 +446,7 @@ def _execute_filter_image(image: Image, file_path: Path, cache_directory_path: P @pass_context def _filter_negotiate_localizeds(context: Context, localizeds: Iterable[Localized]) -> Optional[Localized]: - return negotiate_localizeds(context.environment.app.locale, list(localizeds)) + return negotiate_localizeds(cast(Environment, context.environment).app.locale, list(localizeds)) @pass_context @@ -453,7 +456,7 @@ def _filter_sort_localizeds(context: Context, localizeds: Iterable[Localized], l get_sort_attr = make_attrgetter(context.environment, sort_attribute) def _get_sort_key(x): - return get_sort_attr(negotiate_localizeds(context.environment.app.locale, get_localized_attr(x))) + return get_sort_attr(negotiate_localizeds(cast(Environment, context.environment).app.locale, get_localized_attr(x))) return sorted(localizeds, key=_get_sort_key) @@ -463,7 +466,7 @@ def _filter_select_localizeds(context: Context, localizeds: Iterable[Localized], for localized in localizeds: if include_unspecified and localized.locale in {None, 'mis', 'mul', 'und', 'zxx'}: yield localized - if localized.locale is not None and negotiate_locale(context.environment.app.locale, {localized.locale}) is not None: + if localized.locale is not None and negotiate_locale(cast(Environment, context.environment).app.locale, {localized.locale}) is not None: yield localized @@ -471,15 +474,19 @@ def _filter_select_localizeds(context: Context, localizeds: Iterable[Localized], def _filter_negotiate_dateds(context: Context, dateds: Iterable[Dated], date: Optional[Datey]) -> Optional[Dated]: with suppress(StopIteration): return next(_filter_select_dateds(context, dateds, date)) + return None @pass_context def _filter_select_dateds(context: Context, dateds: Iterable[Dated], date: Optional[Datey]) -> Iterator[Dated]: if date is None: date = context.resolve_or_missing('today') - return filter(lambda dated: dated.date is None or dated.date.comparable and dated.date in date, dateds) + return filter( + lambda dated: dated.date is None or dated.date.comparable and dated.date in date, # type: ignore + dateds, + ) @pass_context def _filter_format_date(context: Context, date: Datey) -> str: - return format_datey(date, context.environment.app.locale) + return format_datey(date, cast(Environment, context.environment).app.locale) diff --git a/betty/json.py b/betty/json.py index 2de3b91c6..3d9ae2a4a 100644 --- a/betty/json.py +++ b/betty/json.py @@ -1,7 +1,7 @@ import json as stdjson from os import path from pathlib import Path -from typing import Dict, Any +from typing import Dict, Any, Type, Callable import jsonschema from geopy import Point @@ -33,7 +33,7 @@ class JSONEncoder(stdjson.JSONEncoder): def __init__(self, app: App, *args, **kwargs): stdjson.JSONEncoder.__init__(self, *args, **kwargs) self._app = app - self._mappers = { + self._mappers: Dict[Type, Callable[[Any], Any]] = { Path: str, PlaceName: self._encode_localized_name, Place: self._encode_place, @@ -82,7 +82,7 @@ async def _encode_entity(self, encoded: Dict, entity: Entity) -> None: canonical = Link(self._generate_url(entity)) canonical.relationship = 'canonical' - canonical.media_type = 'application/json' + canonical.media_type = MediaType('application/json') encoded['links'].append(canonical) link_urls = [link.url for link in encoded['links']] @@ -101,7 +101,7 @@ async def _encode_entity(self, encoded: Dict, entity: Entity) -> None: html = Link(self._generate_url(entity, media_type='text/html')) html.relationship = 'alternate' - html.media_type = 'text/html' + html.media_type = MediaType('text/html') encoded['links'].append(html) def _encode_described(self, encoded: Dict, described: Described) -> None: @@ -198,7 +198,7 @@ def _encode_place(self, place: Place) -> Dict: self._encode_has_links(encoded, place) if place.coordinates is not None: encoded['coordinates'] = place.coordinates - encoded['@context']['coordinates'] = 'https://schema.org/geo' + encoded['@context']['coordinates'] = 'https://schema.org/geo' # type: ignore return encoded def _encode_person(self, person: Person) -> Dict: @@ -217,7 +217,7 @@ def _encode_person(self, person: Person) -> Dict: 'presences': [], } for presence in person.presences: - encoded['presences'].append({ + encoded['presences'].append({ # type: ignore '@context': { 'event': 'https://schema.org/performerIn', }, @@ -230,7 +230,7 @@ def _encode_person(self, person: Person) -> Dict: return encoded def _encode_person_name(self, name: PersonName) -> Dict: - encoded = {} + encoded: Dict[str, Any] = {} if name.individual is not None or name.affiliation is not None: encoded.update({ '@context': {}, @@ -272,7 +272,7 @@ def _encode_event(self, event: Event) -> Dict: encoded.update({ '@context': {}, }) - encoded['@context']['place'] = 'https://schema.org/location' + encoded['@context']['place'] = 'https://schema.org/location' # type: ignore return encoded def _encode_event_type(self, event_type: EventType) -> str: @@ -291,7 +291,9 @@ def _encode_citation(self, citation: Citation) -> Dict: for fact in citation.facts: if isinstance(fact.id, GeneratedEntityId): continue - encoded['facts'].append(self._generate_url(fact)) + encoded['facts'].append( # type: ignore + self._generate_url(fact), + ) self._encode_entity(encoded, citation) self._encode_dated(encoded, citation) return encoded diff --git a/betty/locale.py b/betty/locale.py index d74c4b66a..98571e58d 100644 --- a/betty/locale.py +++ b/betty/locale.py @@ -15,7 +15,7 @@ from gettext import NullTranslations, GNUTranslations from io import StringIO from pathlib import Path -from typing import Optional, Tuple, Union, List, Dict, Callable, Any, Iterator, Set, Sequence +from typing import Optional, Tuple, Union, List, Dict, Callable, Any, Iterator, Set, Sequence, TYPE_CHECKING import babel from babel import dates, Locale @@ -26,6 +26,10 @@ from betty.fs import hashfile, FileSystem +if TYPE_CHECKING: + from betty.builtins import _ + + def rfc_1766_to_bcp_47(locale: str) -> str: return locale.replace('_', '-') @@ -34,8 +38,15 @@ def bcp_47_to_rfc_1766(locale: str) -> str: return locale.replace('-', '_') -def getdefaultlocale() -> str: - return rfc_1766_to_bcp_47(locale.getdefaultlocale()[0]) +def getdefaultlocale() -> Optional[str]: + rfc_1766_locale = getdefaultlocale_rfc_1766() + if rfc_1766_locale: + return rfc_1766_to_bcp_47(rfc_1766_locale) + return None + + +def getdefaultlocale_rfc_1766() -> Optional[str]: + return locale.getdefaultlocale()[0] class Localized: @@ -88,7 +99,10 @@ def to_range(self) -> DateRange: month_start = month_end = self.month if self.day is None: day_start = 1 - day_end = calendar.monthrange(self.year, month_end)[1] + day_end = calendar.monthrange( + self.year, # type: ignore + month_end, + )[1] else: day_start = day_end = self.day return DateRange(Date(self.year, month_start, day_start), Date(self.year, month_end, day_end)) @@ -104,7 +118,7 @@ def _compare(self, other, comparator): if not other.complete: other = other.to_range() if not selfish.complete: - selfish = selfish.to_range() + selfish = selfish.to_range() # type: ignore return comparator(selfish, other) def __contains__(self, other): @@ -200,42 +214,57 @@ def __contains__(self, other): if other <= self.end: return True - def __lt__(self, other): - if not isinstance(other, (Date, DateRange)): - return NotImplemented + def _get_comparable_date(self, date: Optional[Date]) -> Optional[Date]: + if date and date.comparable: + return date + return None + + _LT_DATE_RANGE_COMPARATORS = { + (True, True, True, True): lambda self_start, self_end, other_start, other_end: self_start < other_start, + (True, True, True, False): lambda self_start, self_end, other_start, other_end: self_start <= other_start or self_end < other_end, + (True, True, False, True): lambda self_start, self_end, other_start, other_end: self_start < other_end or self_end <= other_end, + (True, True, False, False): lambda self_start, self_end, other_start, other_end: NotImplemented, + (True, False, True, True): lambda self_start, self_end, other_start, other_end: self_start < other_start, + (True, False, True, False): lambda self_start, self_end, other_start, other_end: self_start < other_start, + (True, False, False, True): lambda self_start, self_end, other_start, other_end: self_start < other_end, + (True, False, False, False): lambda self_start, self_end, other_start, other_end: NotImplemented, + (False, True, True, True): lambda self_start, self_end, other_start, other_end: self_end <= other_start, + (False, True, True, False): lambda self_start, self_end, other_start, other_end: self_end <= other_start, + (False, True, False, True): lambda self_start, self_end, other_start, other_end: self_end < other_end, + (False, True, False, False): lambda self_start, self_end, other_start, other_end: NotImplemented, + (False, False, True, True): lambda self_start, self_end, other_start, other_end: NotImplemented, + (False, False, True, False): lambda self_start, self_end, other_start, other_end: NotImplemented, + (False, False, False, True): lambda self_start, self_end, other_start, other_end: NotImplemented, + (False, False, False, False): lambda self_start, self_end, other_start, other_end: NotImplemented, + } - if not self.comparable or not other.comparable: - return NotImplemented + _LT_DATE_COMPARATORS = { + (True, True): lambda self_start, self_end, other: self_start < other, + (True, False): lambda self_start, self_end, other: self_start < other, + (False, True): lambda self_start, self_end, other: self_end <= other, + (False, False): lambda self_start, self_end, other: NotImplemented, + } - self_has_start = self.start is not None and self.start.comparable - self_has_end = self.end is not None and self.end.comparable + def __lt__(self, other: Any) -> bool: + if not isinstance(other, (Date, DateRange)): + return NotImplemented + self_start = self._get_comparable_date(self.start) + self_end = self._get_comparable_date(self.end) + signature = ( + self_start is not None, + self_end is not None, + ) if isinstance(other, DateRange): - other_has_start = other.start is not None and other.start.comparable - other_has_end = other.end is not None and other.end.comparable - - if self_has_start and other_has_start: - if self.start == other.start: - # If both end dates are missing or incomparable, we consider them equal. - if (self.end is None or not self.end.comparable) and (other.end is None or other.end.comparable): - return False - if self_has_end and other_has_end: - return self.end < other.end - return other.end is None - return self.start < other.start - - if self_has_start: - return self.start < other.end - - if other_has_start: - return self.end <= other.start - - return self.end < other.end - - if self_has_start: - return self.start < other - if self_has_end: - return self.end <= other + other_start = self._get_comparable_date(other.start) + other_end = self._get_comparable_date(other.end) + return self._LT_DATE_RANGE_COMPARATORS[( + *signature, + other_start is not None, + other_end is not None, + )](self_start, self_end, other_start, other_end) + else: + return self._LT_DATE_COMPARATORS[signature](self_start, self_end, other) def __eq__(self, other): if isinstance(other, Date): @@ -300,9 +329,11 @@ def uninstall(self) -> None: if self._previous_context is None: raise TranslationsInstallationError('These translations are not yet installed.') - if self != self._stack[self._thread_id][0]: - raise TranslationsInstallationError(f'These translations were not the last to be installed. {self._stack[self._thread_id].index(self)} other translation(s) must be uninstalled before these translations can be uninstalled as well.') - del self._stack[self._thread_id][0] + thread_id = self._thread_id + assert thread_id + if self != self._stack[thread_id][0]: + raise TranslationsInstallationError(f'These translations were not the last to be installed. {self._stack[thread_id].index(self)} other translation(s) must be uninstalled before these translations can be uninstalled as well.') + del self._stack[thread_id][0] for key in self._GETTEXT_BUILTINS: # Built-ins are not owned by Betty, so allow for them to have disappeared. @@ -323,12 +354,12 @@ def _get_current_context(self) -> _Context: class TranslationsRepository: def __init__(self, assets: FileSystem): self._assets = assets - self._translations = {} + self._translations: Dict[str, NullTranslations] = {} @property def locales(self) -> Iterator[str]: yield 'en-US' - for assets_directory_path, _ in reversed(self._assets.paths): + for assets_directory_path, __ in reversed(self._assets.paths): for po_file_path in glob.glob(str(assets_directory_path / 'locale' / '*' / 'LC_MESSAGES' / 'betty.po')): yield rfc_1766_to_bcp_47(Path(po_file_path).parents[1].name) @@ -343,9 +374,9 @@ def __getitem__(self, locale: Any) -> Translations: except KeyError: return Translations(self._build_translations(locale)) - def _build_translations(self, locale: str) -> Translations: + def _build_translations(self, locale: str) -> NullTranslations: self._translations[locale] = NullTranslations() - for assets_directory_path, _ in reversed(self._assets.paths): + for assets_directory_path, __ in reversed(self._assets.paths): translations = self._open_translations(locale, assets_directory_path) if translations: translations.add_fallback(self._translations[locale]) @@ -382,14 +413,14 @@ def coverage(self, locale: str) -> Tuple[int, int]: return len(translations), len(translatables.union(translations)) def _get_translatables(self) -> Iterator[str]: - for assets_directory_path, _ in self._assets.paths: + for assets_directory_path, __ in self._assets.paths: with suppress(FileNotFoundError): with open(assets_directory_path / 'betty.pot') as f: for entry in pofile(f.read()): yield entry.msgid_with_context def _get_translations(self, locale: str) -> Iterator[str]: - for assets_directory_path, _ in reversed(self._assets.paths): + for assets_directory_path, __ in reversed(self._assets.paths): with suppress(FileNotFoundError): with open(assets_directory_path / 'locale' / bcp_47_to_rfc_1766(locale) / 'LC_MESSAGES' / 'betty.po') as f: for entry in pofile(f.read()): @@ -406,6 +437,7 @@ def negotiate_locale(preferred_locale: str, available_locales: Set[str]) -> Opti negotiated_locale = babel.negotiate_locale([preferred_locale], [available_locale.split('-', 1)[0]]) if negotiated_locale is not None: return available_locale + return None def negotiate_localizeds(preferred_locale: str, localizeds: Sequence[Localized]) -> Optional[Localized]: @@ -419,6 +451,7 @@ def negotiate_localizeds(preferred_locale: str, localizeds: Sequence[Localized]) return localized with suppress(IndexError): return localizeds[0] + return None def format_datey(date: Datey, locale: str) -> str: @@ -456,11 +489,13 @@ def format_date(date: Date, locale: str) -> str: } -def _format_date_parts(date: Date, locale: str) -> str: +def _format_date_parts(date: Optional[Date], locale: str) -> str: if date is None: raise IncompleteDateError('This date is None.') try: - date_parts_format = _FORMAT_DATE_PARTS_FORMATTERS[tuple(map(lambda x: x is not None, date.parts))]() + date_parts_format = _FORMAT_DATE_PARTS_FORMATTERS[tuple( + map(lambda x: x is not None, date.parts), # type: ignore + )]() except KeyError: raise IncompleteDateError('This date does not have enough parts to be rendered.') parts = map(lambda x: 1 if x is None else x, date.parts) @@ -496,20 +531,26 @@ def _format_date_parts(date: Date, locale: str) -> str: def format_date_range(date_range: DateRange, locale: str) -> str: - formatter_configuration = () + formatter_configuration: Tuple[Optional[bool], Optional[bool], Optional[bool], Optional[bool]] = (None, None, None, None) formatter_arguments = {} - try: + with suppress(IncompleteDateError): formatter_arguments['start_date'] = _format_date_parts(date_range.start, locale) - formatter_configuration += (date_range.start.fuzzy, date_range.start_is_boundary) - except IncompleteDateError: - formatter_configuration += (None, None) - - try: + formatter_configuration = ( + None if date_range.start is None else date_range.start.fuzzy, + date_range.start_is_boundary, + formatter_configuration[2], + formatter_configuration[3], + ) + + with suppress(IncompleteDateError): formatter_arguments['end_date'] = _format_date_parts(date_range.end, locale) - formatter_configuration += (date_range.end.fuzzy, date_range.end_is_boundary) - except IncompleteDateError: - formatter_configuration += (None, None) + formatter_configuration = ( + formatter_configuration[0], + formatter_configuration[1], + None if date_range.end is None else date_range.end.fuzzy, + date_range.end_is_boundary, + ) if not formatter_arguments: raise IncompleteDateError('This date range does not have enough parts to be rendered.') diff --git a/betty/logging.py b/betty/logging.py index 36731b1d3..f24529770 100644 --- a/betty/logging.py +++ b/betty/logging.py @@ -1,26 +1,26 @@ -import logging +from logging import CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET, StreamHandler, LogRecord import sys -from logging import CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET, StreamHandler class CliHandler(StreamHandler): - COLOR_LEVELS = [ - (CRITICAL, 91), - (ERROR, 91), - (WARNING, 93), - (INFO, 92), - (DEBUG, 97), - (NOTSET, 97), - ] + COLOR_LEVELS = { + CRITICAL: 91, + ERROR: 91, + WARNING: 93, + INFO: 92, + DEBUG: 97, + NOTSET: 97, + } def __init__(self): StreamHandler.__init__(self, sys.stderr) - def format(self, record: logging.LogRecord) -> str: + def format(self, record: LogRecord) -> str: s = StreamHandler.format(self, record) - for level, color in self.COLOR_LEVELS: + for level, color in self.COLOR_LEVELS.items(): if record.levelno >= level: return self._color(s, color) + return self._color(s, self.COLOR_LEVELS[NOTSET]) def _color(self, s: str, color: int) -> str: return '\033[%dm%s\033[0m' % (color, s) diff --git a/betty/maps/__init__.py b/betty/maps/__init__.py index bbf60ccb3..c0e0a2b61 100644 --- a/betty/maps/__init__.py +++ b/betty/maps/__init__.py @@ -2,7 +2,10 @@ from contextlib import suppress from pathlib import Path from shutil import copy2, copytree -from typing import Optional, Iterable, Set, Type +from typing import Optional, Set, Type, TYPE_CHECKING, List + +if TYPE_CHECKING: + from betty.builtins import _ from betty.app.extension import Extension from betty.generate import Generator @@ -37,16 +40,16 @@ def assets_directory_path(cls) -> Optional[Path]: return Path(__file__).parent / 'assets' @property - def public_css_paths(self) -> Iterable[str]: - return { + def public_css_paths(self) -> List[str]: + return [ self._app.static_url_generator.generate('maps.css'), - } + ] @property - def public_js_paths(self) -> Iterable[str]: - return { + def public_js_paths(self) -> List[str]: + return [ self._app.static_url_generator.generate('maps.js'), - } + ] @classmethod def label(cls) -> str: diff --git a/betty/model/__init__.py b/betty/model/__init__.py index 8887de0b1..3c4f49c61 100644 --- a/betty/model/__init__.py +++ b/betty/model/__init__.py @@ -3,14 +3,13 @@ import copy import functools import operator -from abc import ABC from dataclasses import dataclass from enum import Enum from functools import reduce from typing import TypeVar, Generic, Callable, List, Optional, Iterable, Any, Type, Union, Set, overload, cast, Iterator try: - from typing import Self + from typing import Self # type: ignore except ImportError: from typing_extensions import Self @@ -90,7 +89,7 @@ def get_entity_type(entity_type_name: str) -> Type[Entity]: raise ValueError(f'Unknown entity type "{entity_type_name}"') from None -class EntityCollection(Generic[EntityT], ABC): +class EntityCollection(Generic[EntityT]): @property def list(self) -> List[EntityT]: return [*self] @@ -484,7 +483,7 @@ def __contains__(self, value: Union[EntityT, Any]) -> bool: return False def _contains_by_entity(self, other_entity: EntityT) -> bool: - for entity in self: + for entity in self: # type: ignore if other_entity is entity: return True return False @@ -630,7 +629,7 @@ def __init__(self, owner_attr_name: str, associate_attr_name: str): self._associate_attr_name = associate_attr_name -class _BidirectionalAssociateCollection(_AssociateCollection, ABC): +class _BidirectionalAssociateCollection(_AssociateCollection): def __init__(self, owner: EntityU, associate_type: Type[EntityT], associate_attr_name: str): super().__init__(owner, associate_type) self._associate_attr_name = associate_attr_name @@ -749,7 +748,7 @@ def _copy_entity(cls, entity: EntityT) -> EntityT: return copied def _restore_init_values(self) -> None: - for entity in self._entities: + for entity in self._entities: # type: ignore entity = unflatten(entity) for association_registration in _EntityTypeAssociationRegistry.get_associations(entity.__class__): setattr( diff --git a/betty/model/ancestry.py b/betty/model/ancestry.py index afb4a5d9e..cb0bbf12b 100644 --- a/betty/model/ancestry.py +++ b/betty/model/ancestry.py @@ -245,10 +245,10 @@ class Citation(Dated, HasFiles, HasPrivacy, Entity): source: Source location: Optional[str] - def __init__(self, citation_id: Optional[str], source: Source): + def __init__(self, citation_id: Optional[str], source: Optional[Source]): super().__init__(citation_id) self.location = None - self.source = source + self.source = source # type: ignore @property def facts(self) -> EntityCollection[HasCitations]: @@ -295,10 +295,10 @@ class Enclosure(Dated, HasCitations, Entity): encloses: Place enclosed_by: Place - def __init__(self, encloses: Place, enclosed_by: Place, *args, **kwargs): + def __init__(self, encloses: Optional[Place], enclosed_by: Optional[Place], *args, **kwargs): super().__init__(*args, **kwargs) - self.encloses = encloses - self.enclosed_by = enclosed_by + self.encloses = encloses # type: ignore + self.enclosed_by = enclosed_by # type: ignore @classmethod def entity_type_label(cls) -> str: @@ -430,11 +430,11 @@ class Presence(Entity): event: Event role: PresenceRole - def __init__(self, person: Person, role: PresenceRole, event: Event, *args, **kwargs): + def __init__(self, person: Optional[Person], role: PresenceRole, event: Optional[Event], *args, **kwargs): super().__init__(*args, **kwargs) - self.person = person + self.person = person # type: ignore self.role = role - self.event = event + self.event = event # type: ignore @classmethod def entity_type_label(cls) -> str: @@ -494,13 +494,13 @@ def associated_files(self) -> Iterable[File]: class PersonName(Localized, HasCitations, Entity): person: Person - def __init__(self, person: Person, individual: Optional[str] = None, affiliation: Optional[str] = None, *args, **kwargs): + def __init__(self, person: Optional[Person], individual: Optional[str] = None, affiliation: Optional[str] = None, *args, **kwargs): super().__init__(*args, **kwargs) self._individual = individual self._affiliation = affiliation # Set the person association last, because the association requires comparisons, and self.__eq__() uses the # individual and affiliation names. - self.person = person + self.person = person # type: ignore @classmethod def entity_type_label(cls) -> str: diff --git a/betty/npm/__init__.py b/betty/npm/__init__.py index 54e736e38..feab44878 100644 --- a/betty/npm/__init__.py +++ b/betty/npm/__init__.py @@ -8,14 +8,17 @@ from contextlib import suppress from pathlib import Path from subprocess import CalledProcessError -from typing import Sequence, Set, Optional, Type +from typing import Sequence, Set, Optional, Type, TYPE_CHECKING from aiofiles.tempfile import TemporaryDirectory from betty import subprocess from betty.app.extension import Extension, discover_extension_types from betty.asyncio import sync -from betty.requirement import Requirement, AnyRequirement +from betty.requirement import Requirement, AnyRequirement, AllRequirements + +if TYPE_CHECKING: + from betty.builtins import _ async def npm(arguments: Sequence[str], **kwargs) -> aiosubprocess.Process: @@ -68,12 +71,13 @@ def is_assets_build_directory_path(path: Path) -> bool: class _AssetsRequirement(Requirement): - def __init__(self, extension_types: Set[Type[Extension | NpmBuilder]]): + def __init__(self, extension_types: Set[Type[Extension] | Type[NpmBuilder]]): self._extension_types = extension_types self._summary = _('Pre-built assets') + self._details: Optional[str] if not self.met: extension_names = sorted( - extension_type.name() + extension_type.name() # type: ignore for extension_type in self._extension_types - self._extension_types_with_built_assets ) @@ -117,8 +121,13 @@ def discover_npm_builders() -> Set[Type[Extension | NpmBuilder]]: } -def _get_assets_directory_path(extension_type: Type[Extension | NpmBuilder]) -> Path: - return extension_type.assets_directory_path() / _Npm.name() +def _get_assets_directory_path(extension_type: Type[Extension] | Type[NpmBuilder]) -> Path: + assert issubclass(extension_type, Extension) + assert issubclass(extension_type, NpmBuilder) + assets_directory_path = extension_type.assets_directory_path() + if not assets_directory_path: + raise RuntimeError(f'Extension {extension_type} does not have an assets directory.') + return assets_directory_path / _Npm.name() def _get_assets_src_directory_path(extension_type: Type[Extension | NpmBuilder]) -> Path: @@ -136,6 +145,8 @@ async def build_assets(extension: Extension | NpmBuilder) -> Path: async def _build_assets_to_directory_path(extension: Extension | NpmBuilder, assets_directory_path: Path) -> None: + assert isinstance(extension, Extension) + assert isinstance(extension, NpmBuilder) with suppress(FileNotFoundError): shutil.rmtree(assets_directory_path) os.makedirs(assets_directory_path) @@ -144,25 +155,28 @@ async def _build_assets_to_directory_path(extension: Extension | NpmBuilder, ass class _Npm(Extension): - _npm_requirement = None - _assets_requirement = None - _requirement = None + _npm_requirement: Optional[_NpmRequirement] = None + _assets_requirement: Optional[_AssetsRequirement] = None + _requirement: Optional[AllRequirements] = None @classmethod def _ensure_requirements(cls) -> None: if cls._requirement is None: cls._npm_requirement = _NpmRequirement.check() cls._assets_requirement = _AssetsRequirement(discover_npm_builders()) - cls._requirement = AnyRequirement([cls._npm_requirement, cls._assets_requirement]) + assert cls._npm_requirement is not None + assert cls._assets_requirement is not None + cls._requirement = AllRequirements((AnyRequirement((cls._npm_requirement, cls._assets_requirement)),)) @classmethod - def requires(cls) -> Requirement: + def requires(cls) -> AllRequirements: cls._ensure_requirements() return super().requires() + cls._requirement async def install(self, extension_type: Type[Extension | NpmBuilder], working_directory_path: Path) -> None: self._ensure_requirements() - self._npm_requirement.assert_met() + if self._npm_requirement: + self._npm_requirement.assert_met() shutil.copytree( _get_assets_src_directory_path(extension_type), @@ -173,6 +187,7 @@ async def install(self, extension_type: Type[Extension | NpmBuilder], working_di await npm(['install', '--production'], cwd=working_directory_path) def _get_cached_assets_build_directory_path(self, extension_type: Type[Extension | NpmBuilder]) -> Path: + assert issubclass(extension_type, Extension) and issubclass(extension_type, NpmBuilder) return self.cache_directory_path / extension_type.name() async def ensure_assets(self, extension: Extension | NpmBuilder) -> Path: @@ -184,7 +199,8 @@ async def ensure_assets(self, extension: Extension | NpmBuilder) -> Path: if is_assets_build_directory_path(assets_build_directory_path): return assets_build_directory_path - self._npm_requirement.assert_met() + if self._npm_requirement: + self._npm_requirement.assert_met() return await self._build_cached_assets(extension) async def _build_cached_assets(self, extension: Extension | NpmBuilder) -> Path: diff --git a/betty/openapi.py b/betty/openapi.py index c0df029c2..98f08b202 100644 --- a/betty/openapi.py +++ b/betty/openapi.py @@ -1,10 +1,14 @@ -from typing import Dict +from typing import Dict, TYPE_CHECKING from betty import about from betty.app import App from betty.media_type import EXTENSIONS +if TYPE_CHECKING: + from betty.builtins import _ + + class _Resource: def __init__(self, name: str, collection_endpoint_summary: str, collection_response_description: str, single_endpoint_summary: str, single_response_description): @@ -17,18 +21,48 @@ def __init__(self, name: str, collection_endpoint_summary: str, collection_respo def _get_resources(): return [ - _Resource('file', _('Retrieve the collection of files.'), _( - 'The collection of files.'), _('Retrieve a single file.'), _('The file.')), - _Resource('person', _('Retrieve the collection of people.'), _( - 'The collection of people.'), _('Retrieve a single person.'), _('The person.')), - _Resource('place', _('Retrieve the collection of places.'), _( - 'The collection of places.'), _('Retrieve a single place.'), _('The place.')), - _Resource('event', _('Retrieve the collection of events.'), _( - 'The collection of events.'), _('Retrieve a single event.'), _('The event.')), - _Resource('citation', _('Retrieve the collection of citations.'), _( - 'The collection of citations.'), _('Retrieve a single citation.'), _('The citation.')), - _Resource('source', _('Retrieve the collection of sources.'), _( - 'The collection of sources.'), _('Retrieve a single source.'), _('The source.')), + _Resource( + 'file', + _('Retrieve the collection of files.'), + _('The collection of files.'), + _('Retrieve a single file.'), + _('The file.'), + ), + _Resource( + 'person', + _('Retrieve the collection of people.'), + _('The collection of people.'), + _('Retrieve a single person.'), + _('The person.'), + ), + _Resource( + 'place', + _('Retrieve the collection of places.'), + _('The collection of places.'), + _('Retrieve a single place.'), + _('The place.'), + ), + _Resource( + 'event', + _('Retrieve the collection of events.'), + _('The collection of events.'), + _('Retrieve a single event.'), + _('The event.'), + ), + _Resource( + 'citation', + _('Retrieve the collection of citations.'), + _('The collection of citations.'), + _('Retrieve a single citation.'), + _('The citation.'), + ), + _Resource( + 'source', + _('Retrieve the collection of sources.'), + _('The collection of sources.'), + _('Retrieve a single source.'), + _('The source.'), + ), ] @@ -114,7 +148,7 @@ def build_specification(app: App) -> Dict: else: collection_path = '/{locale}/%s/index.json' % resource.name single_path = '/{locale}/%s/{id}/index.json' % resource.name - specification['paths'].update({ + specification['paths'].update({ # type: ignore collection_path: { 'get': { 'summary': resource.collection_endpoint_summary, @@ -153,7 +187,7 @@ def build_specification(app: App) -> Dict: # Add components for content negotiation. if app.project.configuration.content_negotiation: - specification['components']['parameters']['Accept'] = { + specification['components']['parameters']['Accept'] = { # type: ignore 'name': 'Accept', 'in': 'header', 'description': _('An HTTP [Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept) header.'), @@ -161,7 +195,7 @@ def build_specification(app: App) -> Dict: 'enum': list(EXTENSIONS.keys()), }, } - specification['components']['parameters']['Accept-Language'] = { + specification['components']['parameters']['Accept-Language'] = { # type: ignore 'name': 'Accept-Language', 'in': 'header', 'description': _('An HTTP [Accept-Language](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) header.'), @@ -170,12 +204,12 @@ def build_specification(app: App) -> Dict: }, 'example': app.project.configuration.locales[app.project.configuration.locales.default.locale].alias, } - specification['components']['schemas']['html'] = { + specification['components']['schemas']['html'] = { # type: ignore 'type': 'string', 'description': _('An HTML5 document.'), } else: - specification['components']['parameters']['locale'] = { + specification['components']['parameters']['locale'] = { # type: ignore 'name': 'locale', 'in': 'path', 'required': True, @@ -189,7 +223,7 @@ def build_specification(app: App) -> Dict: # Add default behavior to all requests. for path in specification['paths']: - specification['paths'][path]['get']['responses'].update({ + specification['paths'][path]['get']['responses'].update({ # type: ignore '401': { '$ref': '#/components/responses/401', }, @@ -201,7 +235,7 @@ def build_specification(app: App) -> Dict: }, }) if app.project.configuration.content_negotiation: - specification['paths'][path]['parameters'] = [ + specification['paths'][path]['parameters'] = [ # type: ignore { '$ref': '#/components/parameters/Accept', }, @@ -210,7 +244,7 @@ def build_specification(app: App) -> Dict: }, ] else: - specification['paths'][path]['parameters'] = [ + specification['paths'][path]['parameters'] = [ # type: ignore { '$ref': '#/components/parameters/locale', }, @@ -218,10 +252,10 @@ def build_specification(app: App) -> Dict: # Add default behavior to all responses. if app.project.configuration.content_negotiation: - responses = list(specification['components']['responses'].values()) + responses = list(specification['components']['responses'].values()) # type: ignore for path in specification['paths']: responses.append( - specification['paths'][path]['get']['responses']['200']) + specification['paths'][path]['get']['responses']['200']) # type: ignore for response in responses: response['content']['text/html'] = { 'schema': { diff --git a/betty/os.py b/betty/os.py index 8aa1dbcfb..2526e7ce1 100644 --- a/betty/os.py +++ b/betty/os.py @@ -1,6 +1,6 @@ import os import shutil -from typing import Union +from typing import Union, Optional PathLike = Union[str, os.PathLike] @@ -15,7 +15,7 @@ def link_or_copy(source_path: PathLike, destination_path: PathLike) -> None: class ChDir: def __init__(self, directory_path: PathLike): self._directory_path = directory_path - self._owd = None + self._owd: Optional[str] = None def __enter__(self) -> None: self.change() @@ -30,4 +30,6 @@ def change(self) -> 'ChDir': return self def revert(self) -> None: - os.chdir(self._owd) + owd = self._owd + if owd is not None: + os.chdir(owd) diff --git a/betty/privatizer/__init__.py b/betty/privatizer/__init__.py index a7471c081..a93ab822a 100644 --- a/betty/privatizer/__init__.py +++ b/betty/privatizer/__init__.py @@ -1,6 +1,11 @@ import logging from datetime import datetime -from typing import Optional, List +from typing import Optional, List, TYPE_CHECKING + +from betty.model import Entity + +if TYPE_CHECKING: + from betty.builtins import _ from betty.app.extension import Extension from betty.functools import walk @@ -25,7 +30,7 @@ def gui_description(cls) -> str: def privatize(ancestry: Ancestry, lifetime_threshold: int = 125) -> None: - seen = [] + seen: List[Entity] = [] privatized = 0 for person in ancestry.entities[Person]: @@ -55,7 +60,7 @@ def _mark_private(has_privacy: HasPrivacy) -> None: has_privacy.private = True -def _privatize_person(person: Person, seen: List, lifetime_threshold: int) -> None: +def _privatize_person(person: Person, seen: List[Entity], lifetime_threshold: int) -> None: # Do not change existing explicit privacy declarations. if person.private is None: person.private = _person_is_private(person, lifetime_threshold) @@ -72,7 +77,7 @@ def _privatize_person(person: Person, seen: List, lifetime_threshold: int) -> No _privatize_has_files(person, seen) -def _privatize_event(event: Event, seen: List) -> None: +def _privatize_event(event: Event, seen: List[Entity]) -> None: if not event.private: return @@ -84,13 +89,13 @@ def _privatize_event(event: Event, seen: List) -> None: _privatize_has_files(event, seen) -def _privatize_has_citations(has_citations: HasCitations, seen: List) -> None: +def _privatize_has_citations(has_citations: HasCitations, seen: List[Entity]) -> None: for citation in has_citations.citations: _mark_private(citation) _privatize_citation(citation, seen) -def _privatize_citation(citation: Citation, seen: List) -> None: +def _privatize_citation(citation: Citation, seen: List[Entity]) -> None: if not citation.private: return @@ -103,7 +108,7 @@ def _privatize_citation(citation: Citation, seen: List) -> None: _privatize_has_files(citation, seen) -def _privatize_source(source: Source, seen: List) -> None: +def _privatize_source(source: Source, seen: List[Entity]) -> None: if not source.private: return @@ -114,13 +119,13 @@ def _privatize_source(source: Source, seen: List) -> None: _privatize_has_files(source, seen) -def _privatize_has_files(has_files: HasFiles, seen: List) -> None: +def _privatize_has_files(has_files: HasFiles, seen: List[Entity]) -> None: for file in has_files.files: _mark_private(file) _privatize_file(file, seen) -def _privatize_file(file: File, seen: List) -> None: +def _privatize_file(file: File, seen: List[Entity]) -> None: if not file.private: return diff --git a/betty/project.py b/betty/project.py index 4b61631ff..248958134 100644 --- a/betty/project.py +++ b/betty/project.py @@ -314,6 +314,8 @@ def dump(self) -> DumpedConfiguration: class LocaleConfiguration: def __init__(self, locale: str, alias: Optional[str] = None): self._locale = locale + if alias is not None and '/' in alias: + raise ConfigurationError(_('Locale aliases must not contain slashes.')) self._alias = alias def __repr__(self): diff --git a/betty/requirement.py b/betty/requirement.py index 8de0337d0..6b50375fb 100644 --- a/betty/requirement.py +++ b/betty/requirement.py @@ -1,7 +1,5 @@ -from abc import ABC, abstractmethod from textwrap import indent -from typing import Optional, Iterable, TYPE_CHECKING - +from typing import Optional, Iterable, TYPE_CHECKING, Tuple if TYPE_CHECKING: from betty.builtins import _ @@ -35,8 +33,8 @@ def __str__(self) -> str: class _RequirementCollection(Requirement): - def __init__(self, requirements: Iterable[Requirement]): - self._requirements = tuple(requirements) + def __init__(self, requirements: Tuple[Requirement, ...]): + self._requirements = requirements @property def requirements(self) -> Iterable[Requirement]: @@ -56,7 +54,7 @@ def __str__(self) -> str: class AnyRequirement(_RequirementCollection): - def __init__(self, requirements: Iterable[Requirement]): + def __init__(self, requirements: Tuple[Requirement, ...]): super().__init__(requirements) self._summary = _('One or more of these requirements must be met') @@ -77,7 +75,7 @@ def summary(self) -> str: class AllRequirements(_RequirementCollection): - def __init__(self, requirements: Iterable[Requirement]): + def __init__(self, requirements: Tuple[Requirement, ...]): super().__init__(requirements) self._summary = _('All of these requirements must be met') @@ -103,11 +101,10 @@ def requirement(self) -> Requirement: return self._requirement -class Requirer(ABC): +class Requirer: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @classmethod - @abstractmethod def requires(cls) -> Requirement: raise NotImplementedError diff --git a/betty/search.py b/betty/search.py index a56584e87..b24f2a401 100644 --- a/betty/search.py +++ b/betty/search.py @@ -28,7 +28,7 @@ def _render_entity(self, entity: Entity): def _build_person(self, person: Person) -> Optional[Dict]: if person.private: - return + return None names = [] for name in person.names: if name.individual is not None: @@ -40,6 +40,7 @@ def _build_person(self, person: Person) -> Optional[Dict]: 'text': ' '.join(names), 'result': self._render_entity(person), } + return None def _build_place(self, place: Place) -> Optional[Dict]: return { @@ -53,3 +54,4 @@ def _build_file(self, file: File) -> Optional[Dict]: 'text': file.description.lower(), 'result': self._render_entity(file), } + return None diff --git a/betty/serve.py b/betty/serve.py index 51fef7b09..1c9618528 100644 --- a/betty/serve.py +++ b/betty/serve.py @@ -7,12 +7,16 @@ import webbrowser from http.server import SimpleHTTPRequestHandler, HTTPServer from io import StringIO -from typing import Iterable, Optional +from typing import Iterable, Optional, TYPE_CHECKING from betty.app import App from betty.error import UserFacingError from betty.os import ChDir + +if TYPE_CHECKING: + from betty.builtins import _ + DEFAULT_PORT = 8000 @@ -67,7 +71,7 @@ def servers(self) -> Iterable[Server]: class AppServer(Server): def __init__(self, app: App): self._app = app - self._server = None + self._server: Optional[Server] = None def _get_server(self) -> Server: for extension in self._app.extensions.flatten(): @@ -91,7 +95,8 @@ def public_url(self) -> str: return self._server.public_url async def stop(self) -> None: - await self._server.stop() + if self._server: + await self._server.stop() class _BuiltinServerRequestHandler(SimpleHTTPRequestHandler): @@ -121,13 +126,14 @@ async def start(self) -> None: @property def public_url(self) -> str: if self._port is not None: - return 'http://localhost:%d' % self._port + return f'http://localhost:{self._port}' raise NoPublicUrlBecauseServerNotStartedError() def _serve(self): with contextlib.redirect_stderr(StringIO()): with ChDir(self._app.project.configuration.www_directory_path): with copy.copy(self._app): + assert self._http_server self._http_server.serve_forever() async def stop(self) -> None: diff --git a/betty/tests/conftest.py b/betty/tests/conftest.py index ec3e22804..672426e67 100644 --- a/betty/tests/conftest.py +++ b/betty/tests/conftest.py @@ -78,17 +78,22 @@ def _navigate(item: Union[QMainWindow, QMenu], attributes: List[str]) -> None: QWidgetT = TypeVar('QWidgetT', bound=QWidget) -AssertTopLevelWidget = Callable[[Type[QWidgetT]], QWidgetT] +AssertTopLevelWidget = Callable[[Union[Type[QWidgetT], QWidgetT]], QWidgetT] @pytest.fixture def assert_top_level_widget(qapp: BettyApplication, qtbot: QtBot) -> AssertTopLevelWidget: - def _wait_assert_top_level_widget(widget_type: Type[QWidget]) -> QWidget: + def _wait_assert_top_level_widget(widget_type: Union[Type[QWidgetT], QWidgetT]) -> QWidget: widgets = [] def __assert_top_level_widget(): nonlocal widgets - widgets = [widget for widget in qapp.topLevelWidgets() if isinstance(widget, widget_type) and widget.isVisible()] + widgets = [ + widget + for widget + in qapp.topLevelWidgets() + if widget.isVisible() and (isinstance(widget, widget_type) if isinstance(widget_type, type) else widget is widget_type) + ] assert len(widgets) == 1 qtbot.waitUntil(__assert_top_level_widget) widget = widgets[0] @@ -97,13 +102,18 @@ def __assert_top_level_widget(): return _wait_assert_top_level_widget -AssertNotTopLevelWidget = Callable[[Type[QWidget]], None] +AssertNotTopLevelWidget = Callable[[Union[Type[QWidget], QWidgetT]], None] @pytest.fixture def assert_not_top_level_widget(qapp: BettyApplication, qtbot: QtBot) -> AssertNotTopLevelWidget: - def _assert_not_top_level_widget(widget_type: Type[QWidget]) -> None: - widgets = [widget for widget in qapp.topLevelWidgets() if isinstance(widget, widget_type) and widget.isVisible()] + def _assert_not_top_level_widget(widget_type: Union[Type[QWidget], QWidgetT]) -> None: + widgets = [ + widget + for widget + in qapp.topLevelWidgets() + if widget.isVisible() and (isinstance(widget, widget_type) if isinstance(widget_type, type) else widget is widget_type) + ] assert len(widgets) == 0 return _assert_not_top_level_widget @@ -111,20 +121,23 @@ def _assert_not_top_level_widget(widget_type: Type[QWidget]) -> None: QMainWindowT = TypeVar('QMainWindowT', bound=QMainWindow) -AssertWindow = Callable[[Type[QMainWindowT]], QMainWindowT] +AssertWindow = Callable[[Union[Type[QMainWindowT], QMainWindowT]], QMainWindowT] @pytest.fixture def assert_window(assert_top_level_widget: AssertTopLevelWidget) -> AssertWindow: - def _assert_window(window_type: Type[QMainWindowT]) -> QMainWindowT: + def _assert_window(window_type: Union[Type[QMainWindowT], QMainWindowT]) -> QMainWindowT: return assert_top_level_widget(window_type) return _assert_window +AssertNotWindow = Callable[[Union[Type[QMainWindowT], QMainWindowT]], None] + + @pytest.fixture -def assert_not_window(assert_not_top_level_widget: AssertNotTopLevelWidget): - def _assert_window(window_type: Type[QMainWindow]) -> None: - return assert_not_top_level_widget(window_type) +def assert_not_window(assert_not_top_level_widget: AssertNotTopLevelWidget) -> AssertNotWindow: + def _assert_window(window_type: Union[Type[QMainWindowT], QMainWindowT]) -> None: + assert_not_top_level_widget(window_type) return _assert_window diff --git a/betty/tests/extension/gramps/test_gui.py b/betty/tests/extension/gramps/test_gui.py index 857cbf7df..049a74aaa 100644 --- a/betty/tests/extension/gramps/test_gui.py +++ b/betty/tests/extension/gramps/test_gui.py @@ -2,6 +2,8 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QFileDialog +from pytest_mock import MockerFixture +from pytestqt.qtbot import QtBot from reactives import ReactiveList from betty.app import App @@ -9,10 +11,10 @@ from betty.gramps.config import FamilyTreeConfiguration from betty.gramps.gui import _AddFamilyTreeWindow from betty.project import ProjectExtensionConfiguration -from betty.tests.conftest import AssertWindow +from betty.tests.conftest import AssertWindow, AssertNotWindow -async def test_add_family_tree_set_path(assert_not_window, assert_window: AssertWindow, qtbot) -> None: +def test_add_family_tree_set_path(assert_not_window: AssertNotWindow, assert_window: AssertWindow, qtbot: QtBot) -> None: with App() as app: app.project.configuration.extensions.add(ProjectExtensionConfiguration(Gramps)) sut = app.extensions[Gramps] @@ -24,9 +26,9 @@ async def test_add_family_tree_set_path(assert_not_window, assert_window: Assert add_family_tree_window = assert_window(_AddFamilyTreeWindow) file_path = '/tmp/family-tree.gpkg' - add_family_tree_window._widget._file_path.setText(file_path) + add_family_tree_window._file_path.setText(file_path) - qtbot.mouseClick(add_family_tree_window._widget._save_and_close, Qt.MouseButton.LeftButton) + qtbot.mouseClick(add_family_tree_window._save_and_close, Qt.MouseButton.LeftButton) assert_not_window(_AddFamilyTreeWindow) assert len(sut.configuration.family_trees) == 1 @@ -34,7 +36,7 @@ async def test_add_family_tree_set_path(assert_not_window, assert_window: Assert assert family_tree.file_path == Path(file_path) -async def test_add_family_tree_find_path(assert_window, mocker, qtbot) -> None: +def test_add_family_tree_find_path(assert_window: AssertWindow, mocker: MockerFixture, qtbot: QtBot) -> None: with App() as app: app.project.configuration.extensions.add(ProjectExtensionConfiguration(Gramps)) sut = app.extensions[Gramps] @@ -47,15 +49,15 @@ async def test_add_family_tree_find_path(assert_window, mocker, qtbot) -> None: add_family_tree_window = assert_window(_AddFamilyTreeWindow) file_path = '/tmp/family-tree.gpkg' mocker.patch.object(QFileDialog, 'getOpenFileName', mocker.MagicMock(return_value=[file_path, None])) - qtbot.mouseClick(add_family_tree_window._widget._file_path_find, Qt.MouseButton.LeftButton) - qtbot.mouseClick(add_family_tree_window._widget._save_and_close, Qt.MouseButton.LeftButton) + qtbot.mouseClick(add_family_tree_window._file_path_find, Qt.MouseButton.LeftButton) + qtbot.mouseClick(add_family_tree_window._save_and_close, Qt.MouseButton.LeftButton) assert len(sut.configuration.family_trees) == 1 family_tree = sut.configuration.family_trees[0] assert family_tree.file_path == Path(file_path) -async def test_remove_family_tree(qtbot) -> None: +def test_remove_family_tree(qtbot) -> None: with App() as app: app.project.configuration.extensions.add(ProjectExtensionConfiguration( Gramps, diff --git a/betty/tests/gui/test_app.py b/betty/tests/gui/test_app.py index e49189368..c7aac019d 100644 --- a/betty/tests/gui/test_app.py +++ b/betty/tests/gui/test_app.py @@ -4,6 +4,8 @@ import pytest from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QFileDialog +from pytest_mock import MockerFixture +from pytestqt.qtbot import QtBot from betty import fs from betty.app import App @@ -14,11 +16,12 @@ from betty.gui.serve import ServeDemoWindow from betty.project import ProjectConfiguration from betty.tests import patch_cache +from betty.tests.conftest import Navigate, AssertWindow class TestBettyMainWindow: @patch_cache - def test_view_demo_site(self, assert_window, mocker, navigate, qtbot): + def test_view_demo_site(self, assert_window: AssertWindow, mocker: MockerFixture, navigate: Navigate, qtbot: QtBot): mocker.patch('webbrowser.open_new_tab') mocker.patch('betty.gui.serve.ServeDemoWindow._start') @@ -27,12 +30,12 @@ def test_view_demo_site(self, assert_window, mocker, navigate, qtbot): qtbot.addWidget(sut) sut.show() - navigate(sut, ['betty_menu', '_demo_action']) + navigate(sut, ['_demo_action']) assert_window(ServeDemoWindow) @patch_cache - def test_clear_caches(self, navigate, qtbot): + def test_clear_caches(self, navigate: Navigate, qtbot: QtBot): with App() as app: sut = BettyMainWindow(app) qtbot.addWidget(sut) @@ -40,24 +43,24 @@ def test_clear_caches(self, navigate, qtbot): cached_file_path = path.join(fs.CACHE_DIRECTORY_PATH, 'KeepMeAroundPlease') open(cached_file_path, 'w').close() - navigate(sut, ['betty_menu', 'clear_caches_action']) + navigate(sut, ['clear_caches_action']) with pytest.raises(FileNotFoundError): open(cached_file_path) - def test_open_about_window(self, assert_window, navigate, qtbot) -> None: + def test_open_about_window(self, assert_window: AssertWindow, navigate: Navigate, qtbot: QtBot) -> None: with App() as app: sut = BettyMainWindow(app) qtbot.addWidget(sut) sut.show() - navigate(sut, ['help_menu', 'about_action']) + navigate(sut, ['about_action']) assert_window(_AboutBettyWindow) class TestWelcomeWindow: - def test_open_project_with_invalid_file_should_error(self, assert_error, mocker, qtbot, tmpdir) -> None: + def test_open_project_with_invalid_file_should_error(self, assert_error, mocker: MockerFixture, qtbot: QtBot, tmpdir) -> None: with App() as app: sut = WelcomeWindow(app) qtbot.addWidget(sut) @@ -72,7 +75,7 @@ def test_open_project_with_invalid_file_should_error(self, assert_error, mocker, error = assert_error(ExceptionError) assert isinstance(error.exception, ConfigurationError) - def test_open_project_with_valid_file_should_show_project_window(self, assert_window, mocker, qtbot) -> None: + def test_open_project_with_valid_file_should_show_project_window(self, assert_window: AssertWindow, mocker: MockerFixture, qtbot: QtBot) -> None: title = 'My First Ancestry Site' configuration = ProjectConfiguration() configuration.title = title @@ -89,7 +92,7 @@ def test_open_project_with_valid_file_should_show_project_window(self, assert_wi window = assert_window(ProjectWindow) assert window._app.project.configuration.title == title - def test_view_demo_site(self, assert_window, mocker, qtbot) -> None: + def test_view_demo_site(self, assert_window: AssertWindow, mocker: MockerFixture, qtbot: QtBot) -> None: mocker.patch('webbrowser.open_new_tab') mocker.patch('betty.gui.serve.ServeDemoWindow._start') @@ -104,7 +107,7 @@ def test_view_demo_site(self, assert_window, mocker, qtbot) -> None: class TestApplicationConfiguration: - async def test_application_configuration_autowrite(self, navigate, qtbot) -> None: + async def test_application_configuration_autowrite(self, navigate: Navigate, qtbot: QtBot) -> None: with App() as app: app.configuration.autowrite = True diff --git a/betty/tests/gui/test_project.py b/betty/tests/gui/test_project.py index 9972b33eb..e688ee32c 100644 --- a/betty/tests/gui/test_project.py +++ b/betty/tests/gui/test_project.py @@ -3,6 +3,8 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QFileDialog from babel import Locale +from pytest_mock import MockerFixture +from pytestqt.qtbot import QtBot from betty.app import App from betty.gui.project import ProjectWindow, _AddLocaleWindow, _GenerateWindow, _ThemePane, _LocalizationPane, \ @@ -11,10 +13,11 @@ from betty.locale import bcp_47_to_rfc_1766 from betty.model import Entity from betty.project import ProjectConfiguration, LocaleConfiguration, EntityReference +from betty.tests.conftest import AssertNotWindow, AssertInvalid, AssertWindow, Navigate class TestProjectWindow: - async def test_save_project_as_should_create_duplicate_configuration_file(self, mocker, navigate, qtbot, tmpdir) -> None: + def test_save_project_as_should_create_duplicate_configuration_file(self, mocker: MockerFixture, navigate: Navigate, qtbot: QtBot, tmpdir) -> None: configuration = ProjectConfiguration() configuration.write(tmpdir.join('betty.json')) with App() as app: @@ -24,14 +27,14 @@ async def test_save_project_as_should_create_duplicate_configuration_file(self, save_as_configuration_file_path = tmpdir.join('save-as', 'betty.json') mocker.patch.object(QFileDialog, 'getSaveFileName', mocker.MagicMock(return_value=[save_as_configuration_file_path, None])) - navigate(sut, ['project_menu', 'save_project_as_action']) + navigate(sut, ['save_project_as_action']) with open(save_as_configuration_file_path) as f: assert json.load(f) == configuration.dump() class TestGeneralPane: - async def test_title(self, qtbot) -> None: + def test_title(self, qtbot: QtBot) -> None: with App() as app: sut = _GeneralPane(app) qtbot.addWidget(sut) @@ -41,7 +44,7 @@ async def test_title(self, qtbot) -> None: sut._configuration_title.setText(title) assert app.project.configuration.title == title - async def test_author(self, qtbot) -> None: + def test_author(self, qtbot: QtBot) -> None: with App() as app: sut = _GeneralPane(app) qtbot.addWidget(sut) @@ -51,7 +54,7 @@ async def test_author(self, qtbot) -> None: sut._configuration_title.setText(title) assert app.project.configuration.title == title - async def test_url(self, qtbot) -> None: + def test_url(self, qtbot: QtBot) -> None: with App() as app: sut = _GeneralPane(app) qtbot.addWidget(sut) @@ -61,7 +64,7 @@ async def test_url(self, qtbot) -> None: assert app.project.configuration.base_url == 'https://example.com' assert app.project.configuration.root_path == 'my-first-ancestry' - async def test_lifetime_threshold(self, qtbot) -> None: + def test_lifetime_threshold(self, qtbot: QtBot) -> None: with App() as app: sut = _GeneralPane(app) qtbot.addWidget(sut) @@ -70,7 +73,7 @@ async def test_lifetime_threshold(self, qtbot) -> None: sut._configuration_lifetime_threshold.setText('123') assert app.project.configuration.lifetime_threshold == 123 - async def test_lifetime_threshold_with_non_digit_input(self, qtbot) -> None: + def test_lifetime_threshold_with_non_digit_input(self, qtbot: QtBot) -> None: with App() as app: sut = _GeneralPane(app) qtbot.addWidget(sut) @@ -80,7 +83,7 @@ async def test_lifetime_threshold_with_non_digit_input(self, qtbot) -> None: sut._configuration_lifetime_threshold.setText('a1') assert app.project.configuration.lifetime_threshold == original_lifetime_threshold - async def test_lifetime_threshold_with_zero_input(self, qtbot) -> None: + def test_lifetime_threshold_with_zero_input(self, qtbot: QtBot) -> None: with App() as app: sut = _GeneralPane(app) qtbot.addWidget(sut) @@ -90,7 +93,7 @@ async def test_lifetime_threshold_with_zero_input(self, qtbot) -> None: sut._configuration_lifetime_threshold.setText('0') assert app.project.configuration.lifetime_threshold == original_lifetime_threshold - async def test_debug(self, qtbot) -> None: + def test_debug(self, qtbot: QtBot) -> None: with App() as app: sut = _GeneralPane(app) qtbot.addWidget(sut) @@ -101,7 +104,7 @@ async def test_debug(self, qtbot) -> None: sut._development_debug.setChecked(False) assert not app.project.configuration.debug - async def test_clean_urls(self, qtbot) -> None: + def test_clean_urls(self, qtbot: QtBot) -> None: with App() as app: sut = _GeneralPane(app) qtbot.addWidget(sut) @@ -112,7 +115,7 @@ async def test_clean_urls(self, qtbot) -> None: sut._clean_urls.setChecked(False) assert app.project.configuration.clean_urls is False - async def test_content_negotiation(self, qtbot) -> None: + def test_content_negotiation(self, qtbot: QtBot) -> None: with App() as app: sut = _GeneralPane(app) qtbot.addWidget(sut) @@ -125,7 +128,7 @@ async def test_content_negotiation(self, qtbot) -> None: class TestThemePane: - async def test_background_image(self, qtbot) -> None: + def test_background_image(self, qtbot: QtBot) -> None: with App() as app: sut = _ThemePane(app) qtbot.addWidget(sut) @@ -135,7 +138,7 @@ async def test_background_image(self, qtbot) -> None: sut._background_image_entity_reference_collector._entity_id.setText(background_image_id) assert app.project.configuration.theme.background_image.entity_id == background_image_id - async def test_add_featured_entities(self, qtbot) -> None: + def test_add_featured_entities(self, qtbot: QtBot) -> None: with App() as app: sut = _ThemePane(app) qtbot.addWidget(sut) @@ -147,7 +150,7 @@ async def test_add_featured_entities(self, qtbot) -> None: sut._featured_entities_entity_references_collector._entity_reference_collectors[0]._entity_id.setText(entity_id) assert app.project.configuration.theme.featured_entities[0].entity_id == entity_id - async def test_change_featured_entities(self, qtbot) -> None: + def test_change_featured_entities(self, qtbot: QtBot) -> None: with App() as app: entity_reference_1 = EntityReference(Entity, '123') entity_reference_2 = EntityReference(Entity, '456') @@ -164,7 +167,7 @@ async def test_change_featured_entities(self, qtbot) -> None: sut._featured_entities_entity_references_collector._entity_reference_collectors[1]._entity_id.setText(entity_id) assert app.project.configuration.theme.featured_entities[1].entity_id == entity_id - async def test_remove_featured_entities(self, qtbot) -> None: + def test_remove_featured_entities(self, qtbot: QtBot) -> None: with App() as app: entity_reference_1 = EntityReference(Entity, '123') entity_reference_2 = EntityReference(Entity, '456') @@ -183,27 +186,16 @@ async def test_remove_featured_entities(self, qtbot) -> None: class TestLocalizationPane: - async def test_add_locale(self, qtbot, assert_not_window, assert_window) -> None: + def test_add_locale(self, qtbot: QtBot, assert_window: AssertWindow) -> None: with App() as app: sut = _LocalizationPane(app) qtbot.addWidget(sut) sut.show() qtbot.mouseClick(sut._add_locale_button, Qt.MouseButton.LeftButton) - add_locale_window = assert_window(_AddLocaleWindow) + assert_window(_AddLocaleWindow) - locale = 'nl-NL' - alias = 'nl' - add_locale_window._locale_collector.locale.setCurrentText(Locale.parse(bcp_47_to_rfc_1766(locale)).get_display_name()) - add_locale_window._alias.setText(alias) - - qtbot.mouseClick(add_locale_window._save_and_close, Qt.MouseButton.LeftButton) - assert_not_window(_AddLocaleWindow) - - assert locale in sut._app.project.configuration.locales - assert app.project.configuration.locales[locale].alias == alias - - async def test_remove_locale(self, qtbot, tmpdir) -> None: + def test_remove_locale(self, qtbot: QtBot) -> None: locale = 'de-DE' with App() as app: app.project.configuration.locales.add(LocaleConfiguration('nl-NL')) @@ -218,7 +210,7 @@ async def test_remove_locale(self, qtbot, tmpdir) -> None: assert locale not in app.project.configuration.locales - async def test_default_locale(self, qtbot, tmpdir) -> None: + def test_default_locale(self, qtbot: QtBot) -> None: locale = 'de-DE' with App() as app: app.project.configuration.locales.add(LocaleConfiguration('nl-NL')) @@ -233,7 +225,7 @@ async def test_default_locale(self, qtbot, tmpdir) -> None: assert app.project.configuration.locales.default == LocaleConfiguration(locale) - async def test_project_window_autowrite(self, navigate, qtbot) -> None: + def test_project_window_autowrite(self, navigate: Navigate, qtbot: QtBot) -> None: with App() as app: app.project.configuration.autowrite = True @@ -251,7 +243,7 @@ async def test_project_window_autowrite(self, navigate, qtbot) -> None: class TestGenerateWindow: - async def test_close_button_should_close_window(self, assert_not_window, navigate, qtbot) -> None: + def test_close_button_should_close_window(self, assert_not_window: AssertNotWindow, navigate: Navigate, qtbot: QtBot) -> None: with App() as app: sut = _GenerateWindow(app) qtbot.addWidget(sut) @@ -260,9 +252,9 @@ async def test_close_button_should_close_window(self, assert_not_window, navigat sut.show() qtbot.mouseClick(sut._close_button, Qt.MouseButton.LeftButton) - assert_not_window(_GenerateWindow) + assert_not_window(sut) - async def test_serve_button_should_open_serve_window(self, assert_window, mocker, navigate, qtbot) -> None: + def test_serve_button_should_open_serve_window(self, assert_window: AssertWindow, mocker: MockerFixture, navigate: Navigate, qtbot: QtBot) -> None: mocker.patch('webbrowser.open_new_tab') with App() as app: sut = _GenerateWindow(app) @@ -273,3 +265,53 @@ async def test_serve_button_should_open_serve_window(self, assert_window, mocker qtbot.mouseClick(sut._serve_button, Qt.MouseButton.LeftButton) assert_window(ServeAppWindow) + + +class TestAddLocaleWindow: + def test_without_alias(self, assert_window: AssertWindow, assert_not_window: AssertNotWindow, qtbot: QtBot) -> None: + with App() as app: + sut = _AddLocaleWindow(app) + qtbot.addWidget(sut) + sut.show() + + locale = 'nl-NL' + sut._locale_collector.locale.setCurrentText(Locale.parse(bcp_47_to_rfc_1766(locale)).get_display_name()) + + qtbot.mouseClick(sut._save_and_close, Qt.MouseButton.LeftButton) + assert_not_window(sut) + + assert locale in sut._app.project.configuration.locales + assert locale == app.project.configuration.locales[locale].alias + + def test_with_valid_alias(self, assert_window: AssertWindow, assert_not_window: AssertNotWindow, qtbot: QtBot) -> None: + with App() as app: + sut = _AddLocaleWindow(app) + qtbot.addWidget(sut) + sut.show() + + locale = 'nl-NL' + alias = 'nl' + sut._locale_collector.locale.setCurrentText(Locale.parse(bcp_47_to_rfc_1766(locale)).get_display_name()) + sut._alias.setText(alias) + + qtbot.mouseClick(sut._save_and_close, Qt.MouseButton.LeftButton) + assert_not_window(sut) + + assert locale in sut._app.project.configuration.locales + assert alias == app.project.configuration.locales[locale].alias + + def test_with_invalid_alias(self, assert_window: AssertWindow, assert_invalid: AssertInvalid, qtbot: QtBot) -> None: + with App() as app: + sut = _AddLocaleWindow(app) + qtbot.addWidget(sut) + sut.show() + + locale = 'nl-NL' + alias = '/' + sut._locale_collector.locale.setCurrentText(Locale.parse(bcp_47_to_rfc_1766(locale)).get_display_name()) + sut._alias.setText(alias) + + qtbot.mouseClick(sut._save_and_close, Qt.MouseButton.LeftButton) + + assert_window(sut) + assert_invalid(sut._alias) diff --git a/betty/tests/npm/test___init__.py b/betty/tests/npm/test___init__.py index a0862de17..a79616bbe 100644 --- a/betty/tests/npm/test___init__.py +++ b/betty/tests/npm/test___init__.py @@ -1,6 +1,7 @@ from subprocess import CalledProcessError import pytest +from pytest_mock import MockerFixture from betty.app import App from betty.npm import _NpmRequirement @@ -16,7 +17,7 @@ def test_check_met(self) -> None: CalledProcessError(1, ''), FileNotFoundError(), ]) - def test_check_unmet(self, e: Exception, mocker) -> None: + def test_check_unmet(self, e: Exception, mocker: MockerFixture) -> None: m_npm = mocker.patch('betty.npm.npm') m_npm.side_effect = e with App(): diff --git a/betty/tests/test_project.py b/betty/tests/test_project.py index bd6f4a80d..a2732709b 100644 --- a/betty/tests/test_project.py +++ b/betty/tests/test_project.py @@ -229,6 +229,13 @@ def test_alias_explicit(self): sut = LocaleConfiguration(locale, alias) assert alias == sut.alias + def test_invalid_alias(self): + locale = 'nl-NL' + alias = '/' + with pytest.raises(ConfigurationError): + with App(): + LocaleConfiguration(locale, alias) + @pytest.mark.parametrize('expected, sut, other', [ (False, LocaleConfiguration('nl', 'NL'), 'not a locale configuration'), (False, LocaleConfiguration('nl', 'NL'), 999), diff --git a/betty/trees/__init__.py b/betty/trees/__init__.py index 27b4023dc..e93cd380f 100644 --- a/betty/trees/__init__.py +++ b/betty/trees/__init__.py @@ -2,7 +2,10 @@ import subprocess from pathlib import Path from shutil import copy2 -from typing import Optional, Iterable, Set, Type +from typing import Optional, Set, Type, List, TYPE_CHECKING + +if TYPE_CHECKING: + from betty.builtins import _ from betty.app.extension import Extension from betty.generate import Generator @@ -35,16 +38,16 @@ def assets_directory_path(cls) -> Optional[Path]: return Path(__file__).parent / 'assets' @property - def public_css_paths(self) -> Iterable[str]: - return { + def public_css_paths(self) -> List[str]: + return [ self._app.static_url_generator.generate('trees.css'), - } + ] @property - def public_js_paths(self) -> Iterable[str]: - return { + def public_js_paths(self) -> List[str]: + return [ self._app.static_url_generator.generate('trees.js'), - } + ] @classmethod def label(cls) -> str: diff --git a/mypy.ini b/mypy.ini index 5197ea50b..53498040f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,40 +1,5 @@ [mypy] files = ./betty -exclude = (?x)( - ^betty/os.py$ - | ^betty/path.py$ - | ^betty/model/__init__.py$ - | ^betty/logging.py$ - | ^betty/concurrent.py$ - | ^betty/gramps/config.py$ - | ^betty/asyncio.py$ - | ^betty/fs.py$ - | ^betty/locale.py$ - | ^betty/app/extension.py$ - | ^betty/app/__init__.py$ - | ^betty/search.py$ - | ^betty/json.py$ - | ^betty/jinja2.py$ - | ^betty/serve.py$ - | ^betty/openapi.py$ - | ^betty/npm/__init__.py$ - | ^betty/generate.py$ - | ^betty/gramps/loader.py$ - | ^betty/gui/logging\.py$ - | ^betty/gui/locale\.py$ - | ^betty/gui/model\.py$ - | ^betty/gui/error\.py$ - | ^betty/gui/serve\.py$ - | ^betty/gui/app\.py$ - | ^betty/gui/project\.py$ - | ^betty/trees/__init__\.py$ - | ^betty/maps/__init__\.py$ - | ^betty/demo/__init__\.py$ - | ^betty/cli\.py$ - | ^betty/privatizer/__init__.py$ - | ^betty/gramps/gui\.py$ - | ^betty/cleaner/__init__\.py$ - ) [mypy-betty.*] check_untyped_defs = True @@ -49,6 +14,9 @@ ignore_missing_imports = True [mypy-geopy.*] ignore_missing_imports = True +[mypy-graphlib.*] +ignore_missing_imports = True + [mypy-graphlib_backport.*] ignore_missing_imports = True diff --git a/setup.py b/setup.py index 6d064d3a6..d5dbeb580 100644 --- a/setup.py +++ b/setup.py @@ -81,6 +81,7 @@ 'twine ~= 4.0.0', 'types-aiofiles ~= 0.8.8', 'types-mock ~= 4.0.13', + 'types-polib ~= 1.1.12', 'types-pyyaml ~= 6.0.6', 'types-requests ~= 2.27.19', 'types-setuptools ~= 57.4.14',