From 67b8d0af8bdc1f735d79f9a48362a0e63e28cfe6 Mon Sep 17 00:00:00 2001 From: Bart Feenstra Date: Fri, 5 Jul 2024 23:21:20 +0100 Subject: [PATCH] Refactor the Deserialization API into an Assertion API --- betty/app.py | 14 +- .../{serde/load.py => assertion/__init__.py} | 1156 ++++++++--------- betty/{serde => assertion}/error.py | 32 +- betty/cli/__init__.py | 2 +- betty/config.py | 11 +- betty/extension/cotton_candy/__init__.py | 19 +- betty/extension/gramps/config.py | 2 +- betty/extension/gramps/gui.py | 5 +- betty/extension/nginx/config.py | 5 +- betty/extension/wikipedia/config.py | 2 +- betty/gui/project.py | 2 +- betty/jinja2/__init__.py | 3 +- betty/project/__init__.py | 6 +- betty/serde/dump.py | 13 +- betty/serde/format.py | 10 +- betty/tests/assertion/__init__.py | 107 ++ .../test___init__.py} | 10 +- .../tests/{serde => assertion}/test_error.py | 49 +- betty/tests/coverage/test_coverage.py | 64 +- .../extension/cotton_candy/test___init__.py | 4 +- betty/tests/extension/gramps/test_config.py | 11 +- betty/tests/extension/nginx/test_config.py | 4 +- .../tests/extension/wikipedia/test_config.py | 4 +- betty/tests/gui/test_app.py | 6 +- betty/tests/project/test___init__.py | 23 +- betty/tests/serde/__init__.py | 107 -- betty/tests/serde/test_format.py | 3 +- betty/tests/test_config.py | 4 +- betty/typing.py | 14 + 29 files changed, 843 insertions(+), 849 deletions(-) rename betty/{serde/load.py => assertion/__init__.py} (89%) rename betty/{serde => assertion}/error.py (80%) create mode 100644 betty/tests/assertion/__init__.py rename betty/tests/{serde/test_load.py => assertion/test___init__.py} (98%) rename betty/tests/{serde => assertion}/test_error.py (73%) diff --git a/betty/app.py b/betty/app.py index 66ccbee92..300550ec1 100644 --- a/betty/app.py +++ b/betty/app.py @@ -14,6 +14,13 @@ from typing_extensions import override from betty import fs +from betty.assertion import ( + OptionalField, + assert_record, + assert_setattr, + assert_str, +) +from betty.assertion.error import AssertionFailed from betty.assets import AssetRepository from betty.asyncio import wait_to_thread from betty.cache.file import BinaryFileCache, PickledFileCache @@ -25,13 +32,6 @@ from betty.locale import LocalizerRepository, get_data, DEFAULT_LOCALE, Localizer from betty.locale.localizable import _ from betty.serde.dump import minimize, void_none, Dump, VoidableDump -from betty.serde.load import ( - AssertionFailed, - OptionalField, - assert_record, - assert_setattr, - assert_str, -) from betty.warnings import deprecate if TYPE_CHECKING: diff --git a/betty/serde/load.py b/betty/assertion/__init__.py similarity index 89% rename from betty/serde/load.py rename to betty/assertion/__init__.py index 3caf21531..097b6830c 100644 --- a/betty/serde/load.py +++ b/betty/assertion/__init__.py @@ -1,588 +1,568 @@ -""" -Provide a deserialization API. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import ( - Callable, - Any, - Generic, - TYPE_CHECKING, - TypeVar, - MutableSequence, - MutableMapping, - overload, - cast, - TypeAlias, -) - -from betty.locale import ( - LocaleNotFoundError, - get_data, - UNDETERMINED_LOCALE, -) -from betty.locale.localizable import _, plain, Localizable -from betty.model import ( - Entity, - get_entity_type, - EntityTypeImportError, - EntityTypeInvalidError, - EntityTypeError, -) -from betty.serde.dump import DumpType, Void -from betty.serde.error import SerdeError, SerdeErrorCollection - -if TYPE_CHECKING: - from betty.project.extension import Extension - - -_DumpTypeT = TypeVar("_DumpTypeT", bound=DumpType) -_AssertionValueT = TypeVar("_AssertionValueT") -_AssertionReturnT = TypeVar("_AssertionReturnT") -_AssertionReturnU = TypeVar("_AssertionReturnU") - -Assertion: TypeAlias = Callable[ - [ - _AssertionValueT, - ], - _AssertionReturnT, -] - - -Number: TypeAlias = int | float - - -class LoadError(SerdeError): - """ - Raised for any error while deserializing data. - """ - - pass # pragma: no cover - - -class AssertionFailed(LoadError): - """ - Raised when an assertion failed while deserializing data. - """ - - pass # pragma: no cover - - -class FormatError(LoadError): - """ - Raised when data that is being deserialized is provided in an unknown (undeserializable) format. - """ - - pass # pragma: no cover - - -_AssertionsExtendReturnT = TypeVar("_AssertionsExtendReturnT") -_AssertionsIntermediateValueReturnT = TypeVar("_AssertionsIntermediateValueReturnT") - - -class AssertionChain(Generic[_AssertionValueT, _AssertionReturnT]): - """ - An assertion chain. - - Assertion chains let you chain/link/combine assertions into pipelines that take an input - value and, if the assertions pass, return an output value. Each chain may be (re)used as many - times as needed. - - Assertion chains are assertions themselves: you can use a chain wherever you can use a 'plain' - assertion. - - Assertions chains are `monads `_. - While uncommon in Python, this allows us to create these chains in a type-safe way, and tools - like mypy can confirm that all assertions in any given chain are compatible with each other. - """ - - def __init__(self, _assertion: Assertion[_AssertionValueT, _AssertionReturnT]): - self._assertion = _assertion - - def chain( - self, assertion: Assertion[_AssertionReturnT, _AssertionsExtendReturnT] - ) -> AssertionChain[_AssertionValueT, _AssertionsExtendReturnT]: - """ - Extend the chain with the given assertion. - """ - return AssertionChain(lambda value: assertion(self(value))) - - def __or__( - self, _assertion: Assertion[_AssertionReturnT, _AssertionsExtendReturnT] - ) -> AssertionChain[_AssertionValueT, _AssertionsExtendReturnT]: - return self.chain(_assertion) - - def __call__(self, value: _AssertionValueT) -> _AssertionReturnT: - """ - Invoke the chain with a value. - - This method may be called more than once. - """ - return self._assertion(value) - - -@dataclass(frozen=True) -class _Field(Generic[_AssertionValueT, _AssertionReturnT]): - name: str - assertion: Assertion[_AssertionValueT, _AssertionReturnT] | None = None - - -@dataclass(frozen=True) -class RequiredField( - Generic[_AssertionValueT, _AssertionReturnT], - _Field[_AssertionValueT, _AssertionReturnT], -): - """ - A required key-value mapping field. - """ - - pass # pragma: no cover - - -@dataclass(frozen=True) -class OptionalField( - Generic[_AssertionValueT, _AssertionReturnT], - _Field[_AssertionValueT, _AssertionReturnT], -): - """ - An optional key-value mapping field. - """ - - pass # pragma: no cover - - -_AssertionBuilderFunction = Callable[[_AssertionValueT], _AssertionReturnT] -_AssertionBuilderMethod = Callable[[object, _AssertionValueT], _AssertionReturnT] -_AssertionBuilder = "_AssertionBuilderFunction[ValueT, ReturnT] | _AssertionBuilderMethod[ValueT, ReturnT]" - - -def _assert_type_violation_error_message( - asserted_type: type[DumpType], -) -> Localizable: - messages = { - None: _("This must be none/null."), - bool: _("This must be a boolean."), - int: _("This must be a whole number."), - float: _("This must be a decimal number."), - str: _("This must be a string."), - list: _("This must be a list."), - dict: _("This must be a key-value mapping."), - } - return messages[asserted_type] # type: ignore[index] - - -def _assert_type( - value: Any, - value_required_type: type[_DumpTypeT], - value_disallowed_type: type[DumpType] | None = None, -) -> _DumpTypeT: - if isinstance(value, value_required_type) and ( - value_disallowed_type is None or not isinstance(value, value_disallowed_type) - ): - return value - raise AssertionFailed( - _assert_type_violation_error_message( - value_required_type, # type: ignore[arg-type] - ) - ) - - -def assert_or( - if_assertion: Assertion[_AssertionValueT, _AssertionReturnT], - else_assertion: Assertion[_AssertionValueT, _AssertionReturnU], -) -> AssertionChain[_AssertionValueT, _AssertionReturnT | _AssertionReturnU]: - """ - Assert that at least one of the given assertions passed. - """ - - def _assert_or(value: Any) -> _AssertionReturnT | _AssertionReturnU: - assertions = (if_assertion, else_assertion) - errors = SerdeErrorCollection() - for assertion in assertions: - try: - return assertion(value) - except SerdeError as e: - if e.raised(AssertionFailed): - errors.append(e) - raise errors - - return AssertionChain(_assert_or) - - -def assert_none() -> AssertionChain[Any, None]: - """ - Assert that a value is ``None``. - """ - - def _assert_none(value: Any) -> None: - _assert_type(value, type(None)) - - return AssertionChain(_assert_none) - - -def assert_bool() -> AssertionChain[Any, bool]: - """ - Assert that a value is a Python ``bool``. - """ - - def _assert_bool(value: Any) -> bool: - return _assert_type(value, bool) - - return AssertionChain(_assert_bool) - - -def assert_int() -> AssertionChain[Any, int]: - """ - Assert that a value is a Python ``int``. - """ - - def _assert_int(value: Any) -> int: - return _assert_type(value, int, bool) - - return AssertionChain(_assert_int) - - -def assert_float() -> AssertionChain[Any, float]: - """ - Assert that a value is a Python ``float``. - """ - - def _assert_float(value: Any) -> float: - return _assert_type(value, float) - - return AssertionChain(_assert_float) - - -def assert_number() -> AssertionChain[Any, Number]: - """ - Assert that a value is a number (a Python ``int`` or ``float``). - """ - return assert_or(assert_int(), assert_float()) - - -def assert_positive_number() -> AssertionChain[Any, Number]: - """ - Assert that a vaue is a positive nu,ber. - """ - - def _assert_positive_number( - value: Any, - ) -> Number: - value = assert_number()(value) - if value <= 0: - raise AssertionFailed(_("This must be a positive number.")) - return value - - return AssertionChain(_assert_positive_number) - - -def assert_str() -> AssertionChain[Any, str]: - """ - Assert that a value is a Python ``str``. - """ - - def _assert_str(value: Any) -> str: - return _assert_type(value, str) - - return AssertionChain(_assert_str) - - -def assert_list() -> AssertionChain[Any, list[Any]]: - """ - Assert that a value is a Python ``list``. - """ - - def _assert_list(value: Any) -> list[Any]: - return _assert_type(value, list) - - return AssertionChain(_assert_list) - - -def assert_dict() -> AssertionChain[Any, dict[str, Any]]: - """ - Assert that a value is a Python ``dict``. - """ - - def _assert_dict(value: Any) -> dict[str, Any]: - return _assert_type(value, dict) - - return AssertionChain(_assert_dict) - - -def assert_sequence( - item_assertion: Assertion[Any, _AssertionReturnT], -) -> AssertionChain[Any, MutableSequence[_AssertionReturnT]]: - """ - Assert that a value is a sequence and that all item values are of the given type. - """ - - def _assert_sequence(value: Any) -> MutableSequence[_AssertionReturnT]: - list_value = assert_list()(value) - sequence: MutableSequence[_AssertionReturnT] = [] - with SerdeErrorCollection().assert_valid() as errors: - for value_item_index, value_item_value in enumerate(list_value): - with errors.catch(plain(value_item_index)): - sequence.append(item_assertion(value_item_value)) - return sequence - - return AssertionChain(_assert_sequence) - - -def assert_mapping( - item_assertion: Assertion[Any, _AssertionReturnT], -) -> AssertionChain[Any, MutableMapping[str, _AssertionReturnT]]: - """ - Assert that a value is a key-value mapping and assert that all item values are of the given type. - """ - - def _assert_mapping(value: Any) -> MutableMapping[str, _AssertionReturnT]: - dict_value = assert_dict()(value) - mapping: MutableMapping[str, _AssertionReturnT] = {} - with SerdeErrorCollection().assert_valid() as errors: - for value_item_key, value_item_value in dict_value.items(): - with errors.catch(plain(value_item_key)): - mapping[value_item_key] = item_assertion(value_item_value) - return mapping - - return AssertionChain(_assert_mapping) - - -def assert_fields( - *fields: _Field[Any, Any], -) -> AssertionChain[Any, MutableMapping[str, Any]]: - """ - Assert that a value is a key-value mapping of arbitrary value types, and assert several of its values. - """ - - def _assert_fields(value: Any) -> MutableMapping[str, Any]: - value_dict = assert_dict()(value) - mapping: MutableMapping[str, Any] = {} - with SerdeErrorCollection().assert_valid() as errors: - for field in fields: - with errors.catch(plain(field.name)): - if field.name in value_dict: - if field.assertion: - mapping[field.name] = field.assertion( - value_dict[field.name] - ) - elif isinstance(field, RequiredField): - raise AssertionFailed(_("This field is required.")) - return mapping - - return AssertionChain(_assert_fields) - - -@overload -def assert_field( - field: RequiredField[_AssertionValueT, _AssertionReturnT], -) -> AssertionChain[_AssertionValueT, _AssertionReturnT]: - pass # pragma: no cover - - -@overload -def assert_field( - field: OptionalField[_AssertionValueT, _AssertionReturnT], -) -> AssertionChain[_AssertionValueT, _AssertionReturnT | type[Void]]: - pass # pragma: no cover - - -def assert_field( - field: _Field[_AssertionValueT, _AssertionReturnT], -) -> ( - AssertionChain[_AssertionValueT, _AssertionReturnT] - | AssertionChain[_AssertionValueT, _AssertionReturnT | type[Void]] -): - """ - Assert that a value is a key-value mapping of arbitrary value types, and assert a single of its values. - """ - - def _assert_field(value: Any) -> _AssertionReturnT | type[Void]: - fields = assert_fields(field)(value) - try: - return cast("_AssertionReturnT | type[Void]", fields[field.name]) - except KeyError: - if isinstance(field, RequiredField): - raise - return Void - - return AssertionChain(_assert_field) - - -def assert_record( - *fields: _Field[Any, Any], -) -> AssertionChain[Any, MutableMapping[str, Any]]: - """ - Assert that a value is a record: a key-value mapping of arbitrary value types, with a known structure. - - To validate a key-value mapping as a records, assertions for all possible keys - MUST be provided. Any keys present in the value for which no field assertions - are provided will cause the entire record assertion to fail. - """ - if not len(fields): - raise ValueError("One or more fields are required.") - - def _assert_record(value: Any) -> MutableMapping[str, Any]: - dict_value = assert_dict()(value) - known_keys = {x.name for x in fields} - unknown_keys = set(dict_value.keys()) - known_keys - with SerdeErrorCollection().assert_valid() as errors: - for unknown_key in unknown_keys: - with errors.catch(plain(unknown_key)): - raise AssertionFailed( - _( - "Unknown key: {unknown_key}. Did you mean {known_keys}?" - ).format( - unknown_key=f'"{unknown_key}"', - known_keys=", ".join( - (f'"{x}"' for x in sorted(known_keys)) - ), - ) - ) - return assert_fields(*fields)(dict_value) - - return AssertionChain(_assert_record) - - -def assert_path() -> AssertionChain[Any, Path]: - """ - Assert that a value is a path to a file or directory on disk that may or may not exist. - """ - return assert_str().chain(lambda value: Path(value).expanduser().resolve()) - - -def assert_directory_path() -> AssertionChain[Any, Path]: - """ - Assert that a value is a path to an existing directory. - """ - - def _assert_directory_path(value: Any) -> Path: - directory_path = assert_path()(value) - if directory_path.is_dir(): - return directory_path - raise AssertionFailed(_('"{path}" is not a directory.').format(path=value)) - - return AssertionChain(_assert_directory_path) - - -def assert_locale() -> AssertionChain[Any, str]: - """ - Assert that a value is a valid `IETF BCP 47 language tag `_. - """ - - def _assert_locale( - value: Any, - ) -> str: - value = assert_str()(value) - - # Allow locales for which no system information usually exists. - if value == UNDETERMINED_LOCALE: - return value - - try: - get_data(value) - return value - except LocaleNotFoundError: - raise AssertionFailed( - _('"{locale}" is not a valid IETF BCP 47 language tag.').format( - locale=value - ) - ) from None - - return AssertionChain(_assert_locale) - - -def assert_setattr( - instance: object, - attr_name: str, -) -> AssertionChain[Any, Any]: - """ - Set a value for the given object's attribute. - """ - - def _assert_setattr(value: Any) -> Any: - setattr(instance, attr_name, value) - # Return the getter's return value rather than the assertion value, just - # in case the setter and/or getter perform changes to the value. - return getattr(instance, attr_name) - - return AssertionChain(_assert_setattr) - - -def assert_extension_type() -> AssertionChain[Any, type[Extension]]: - """ - Assert that a value is an extension type. - - This assertion passes if the value is fully qualified :py:class:`betty.project.extension.Extension` subclass name. - """ - - def _assert_extension_type( - value: Any, - ) -> type[Extension]: - from betty.project.extension import ( - get_extension_type, - ExtensionTypeImportError, - ExtensionTypeInvalidError, - ExtensionTypeError, - ) - - assert_str()(value) - try: - return get_extension_type(value) - except ExtensionTypeImportError: - raise AssertionFailed( - _( - 'Cannot find and import "{extension_type}".', - ).format(extension_type=str(value)) - ) from None - except ExtensionTypeInvalidError: - raise AssertionFailed( - _( - '"{extension_type}" is not a valid Betty extension type.', - ).format(extension_type=str(value)) - ) from None - except ExtensionTypeError: - raise AssertionFailed( - _( - 'Cannot determine the extension type for "{extension_type}". Did you perhaps make a typo, or could it be that the extension type comes from another package that is not yet installed?', - ).format(extension_type=str(value)) - ) from None - - return AssertionChain(_assert_extension_type) - - -def assert_entity_type() -> AssertionChain[Any, type[Entity]]: - """ - Assert that a value is an entity type. - - This assertion passes if the value is fully qualified :py:class:`betty.model.Entity` subclass name. - """ - - def _assert_entity_type( - value: Any, - ) -> type[Entity]: - assert_str()(value) - try: - return get_entity_type(value) - except EntityTypeImportError: - raise AssertionFailed( - _( - 'Cannot find and import "{entity_type}".', - ).format(entity_type=str(value)) - ) from None - except EntityTypeInvalidError: - raise AssertionFailed( - _('"{entity_type}" is not a valid Betty entity type.').format( - entity_type=str(value) - ) - ) from None - except EntityTypeError: - raise AssertionFailed( - _( - 'Cannot determine the entity type for "{entity_type}". Did you perhaps make a typo, or could it be that the entity type comes from another package that is not yet installed?' - ).format(entity_type=str(value)) - ) from None - - return AssertionChain(_assert_entity_type) +""" +The Assertion API. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from types import NoneType +from typing import ( + Callable, + Any, + Generic, + TYPE_CHECKING, + TypeVar, + MutableSequence, + MutableMapping, + overload, + cast, + TypeAlias, +) + +from betty.assertion.error import AssertionFailedGroup, AssertionFailed +from betty.locale import ( + LocaleNotFoundError, + get_data, + UNDETERMINED_LOCALE, +) +from betty.locale.localizable import _, plain, Localizable +from betty.model import ( + Entity, + get_entity_type, + EntityTypeImportError, + EntityTypeInvalidError, + EntityTypeError, +) +from betty.typing import Void + +if TYPE_CHECKING: + from collections.abc import Mapping + from betty.project.extension import Extension + + +Number: TypeAlias = int | float + + +_AssertionValueT = TypeVar("_AssertionValueT") +_AssertionReturnT = TypeVar("_AssertionReturnT") +_AssertionReturnU = TypeVar("_AssertionReturnU") + +Assertion: TypeAlias = Callable[ + [ + _AssertionValueT, + ], + _AssertionReturnT, +] + +_AssertionsExtendReturnT = TypeVar("_AssertionsExtendReturnT") +_AssertionsIntermediateValueReturnT = TypeVar("_AssertionsIntermediateValueReturnT") + + +class AssertionChain(Generic[_AssertionValueT, _AssertionReturnT]): + """ + An assertion chain. + + Assertion chains let you chain/link/combine assertions into pipelines that take an input + value and, if the assertions pass, return an output value. Each chain may be (re)used as many + times as needed. + + Assertion chains are assertions themselves: you can use a chain wherever you can use a 'plain' + assertion. + + Assertions chains are `monads `_. + While uncommon in Python, this allows us to create these chains in a type-safe way, and tools + like mypy can confirm that all assertions in any given chain are compatible with each other. + """ + + def __init__(self, _assertion: Assertion[_AssertionValueT, _AssertionReturnT]): + self._assertion = _assertion + + def chain( + self, assertion: Assertion[_AssertionReturnT, _AssertionsExtendReturnT] + ) -> AssertionChain[_AssertionValueT, _AssertionsExtendReturnT]: + """ + Extend the chain with the given assertion. + """ + return AssertionChain(lambda value: assertion(self(value))) + + def __or__( + self, _assertion: Assertion[_AssertionReturnT, _AssertionsExtendReturnT] + ) -> AssertionChain[_AssertionValueT, _AssertionsExtendReturnT]: + return self.chain(_assertion) + + def __call__(self, value: _AssertionValueT) -> _AssertionReturnT: + """ + Invoke the chain with a value. + + This method may be called more than once. + """ + return self._assertion(value) + + +@dataclass(frozen=True) +class _Field(Generic[_AssertionValueT, _AssertionReturnT]): + name: str + assertion: Assertion[_AssertionValueT, _AssertionReturnT] | None = None + + +@dataclass(frozen=True) +class RequiredField( + Generic[_AssertionValueT, _AssertionReturnT], + _Field[_AssertionValueT, _AssertionReturnT], +): + """ + A required key-value mapping field. + """ + + pass # pragma: no cover + + +@dataclass(frozen=True) +class OptionalField( + Generic[_AssertionValueT, _AssertionReturnT], + _Field[_AssertionValueT, _AssertionReturnT], +): + """ + An optional key-value mapping field. + """ + + pass # pragma: no cover + + +_AssertionBuilderFunction = Callable[[_AssertionValueT], _AssertionReturnT] +_AssertionBuilderMethod = Callable[[object, _AssertionValueT], _AssertionReturnT] +_AssertionBuilder = "_AssertionBuilderFunction[ValueT, ReturnT] | _AssertionBuilderMethod[ValueT, ReturnT]" + + +AssertTypeType: TypeAlias = bool | dict[Any, Any] | float | int | list[Any] | None | str +AssertTypeTypeT = TypeVar("AssertTypeTypeT", bound=AssertTypeType) + + +def _assert_type_violation_error_message( + asserted_type: type[AssertTypeType], +) -> Localizable: + messages: Mapping[type[AssertTypeType], Localizable] = { + NoneType: _("This must be none/null."), + bool: _("This must be a boolean."), + int: _("This must be a whole number."), + float: _("This must be a decimal number."), + str: _("This must be a string."), + list: _("This must be a list."), + dict: _("This must be a key-value mapping."), + } + return messages[asserted_type] + + +def _assert_type( + value: Any, + value_required_type: type[AssertTypeTypeT], + value_disallowed_type: type[AssertTypeType] | None = None, +) -> AssertTypeTypeT: + if isinstance(value, value_required_type) and ( + value_disallowed_type is None or not isinstance(value, value_disallowed_type) + ): + return value + raise AssertionFailed( + _assert_type_violation_error_message( + value_required_type, # type: ignore[arg-type] + ) + ) + + +def assert_or( + if_assertion: Assertion[_AssertionValueT, _AssertionReturnT], + else_assertion: Assertion[_AssertionValueT, _AssertionReturnU], +) -> AssertionChain[_AssertionValueT, _AssertionReturnT | _AssertionReturnU]: + """ + Assert that at least one of the given assertions passed. + """ + + def _assert_or(value: Any) -> _AssertionReturnT | _AssertionReturnU: + assertions = (if_assertion, else_assertion) + errors = AssertionFailedGroup() + for assertion in assertions: + try: + return assertion(value) + except AssertionFailed as e: + if e.raised(AssertionFailed): + errors.append(e) + raise errors + + return AssertionChain(_assert_or) + + +def assert_none() -> AssertionChain[Any, None]: + """ + Assert that a value is ``None``. + """ + + def _assert_none(value: Any) -> None: + _assert_type(value, NoneType) + + return AssertionChain(_assert_none) + + +def assert_bool() -> AssertionChain[Any, bool]: + """ + Assert that a value is a Python ``bool``. + """ + + def _assert_bool(value: Any) -> bool: + return _assert_type(value, bool) + + return AssertionChain(_assert_bool) + + +def assert_int() -> AssertionChain[Any, int]: + """ + Assert that a value is a Python ``int``. + """ + + def _assert_int(value: Any) -> int: + return _assert_type(value, int, bool) + + return AssertionChain(_assert_int) + + +def assert_float() -> AssertionChain[Any, float]: + """ + Assert that a value is a Python ``float``. + """ + + def _assert_float(value: Any) -> float: + return _assert_type(value, float) + + return AssertionChain(_assert_float) + + +def assert_number() -> AssertionChain[Any, Number]: + """ + Assert that a value is a number (a Python ``int`` or ``float``). + """ + return assert_or(assert_int(), assert_float()) + + +def assert_positive_number() -> AssertionChain[Any, Number]: + """ + Assert that a vaue is a positive nu,ber. + """ + + def _assert_positive_number( + value: Any, + ) -> Number: + value = assert_number()(value) + if value <= 0: + raise AssertionFailed(_("This must be a positive number.")) + return value + + return AssertionChain(_assert_positive_number) + + +def assert_str() -> AssertionChain[Any, str]: + """ + Assert that a value is a Python ``str``. + """ + + def _assert_str(value: Any) -> str: + return _assert_type(value, str) + + return AssertionChain(_assert_str) + + +def assert_list() -> AssertionChain[Any, list[Any]]: + """ + Assert that a value is a Python ``list``. + """ + + def _assert_list(value: Any) -> list[Any]: + return _assert_type(value, list) + + return AssertionChain(_assert_list) + + +def assert_dict() -> AssertionChain[Any, dict[str, Any]]: + """ + Assert that a value is a Python ``dict``. + """ + + def _assert_dict(value: Any) -> dict[str, Any]: + return _assert_type(value, dict) + + return AssertionChain(_assert_dict) + + +def assert_sequence( + item_assertion: Assertion[Any, _AssertionReturnT], +) -> AssertionChain[Any, MutableSequence[_AssertionReturnT]]: + """ + Assert that a value is a sequence and that all item values are of the given type. + """ + + def _assert_sequence(value: Any) -> MutableSequence[_AssertionReturnT]: + list_value = assert_list()(value) + sequence: MutableSequence[_AssertionReturnT] = [] + with AssertionFailedGroup().assert_valid() as errors: + for value_item_index, value_item_value in enumerate(list_value): + with errors.catch(plain(value_item_index)): + sequence.append(item_assertion(value_item_value)) + return sequence + + return AssertionChain(_assert_sequence) + + +def assert_mapping( + item_assertion: Assertion[Any, _AssertionReturnT], +) -> AssertionChain[Any, MutableMapping[str, _AssertionReturnT]]: + """ + Assert that a value is a key-value mapping and assert that all item values are of the given type. + """ + + def _assert_mapping(value: Any) -> MutableMapping[str, _AssertionReturnT]: + dict_value = assert_dict()(value) + mapping: MutableMapping[str, _AssertionReturnT] = {} + with AssertionFailedGroup().assert_valid() as errors: + for value_item_key, value_item_value in dict_value.items(): + with errors.catch(plain(value_item_key)): + mapping[value_item_key] = item_assertion(value_item_value) + return mapping + + return AssertionChain(_assert_mapping) + + +def assert_fields( + *fields: _Field[Any, Any], +) -> AssertionChain[Any, MutableMapping[str, Any]]: + """ + Assert that a value is a key-value mapping of arbitrary value types, and assert several of its values. + """ + + def _assert_fields(value: Any) -> MutableMapping[str, Any]: + value_dict = assert_dict()(value) + mapping: MutableMapping[str, Any] = {} + with AssertionFailedGroup().assert_valid() as errors: + for field in fields: + with errors.catch(plain(field.name)): + if field.name in value_dict: + if field.assertion: + mapping[field.name] = field.assertion( + value_dict[field.name] + ) + elif isinstance(field, RequiredField): + raise AssertionFailed(_("This field is required.")) + return mapping + + return AssertionChain(_assert_fields) + + +@overload +def assert_field( + field: RequiredField[_AssertionValueT, _AssertionReturnT], +) -> AssertionChain[_AssertionValueT, _AssertionReturnT]: + pass # pragma: no cover + + +@overload +def assert_field( + field: OptionalField[_AssertionValueT, _AssertionReturnT], +) -> AssertionChain[_AssertionValueT, _AssertionReturnT | type[Void]]: + pass # pragma: no cover + + +def assert_field( + field: _Field[_AssertionValueT, _AssertionReturnT], +) -> ( + AssertionChain[_AssertionValueT, _AssertionReturnT] + | AssertionChain[_AssertionValueT, _AssertionReturnT | type[Void]] +): + """ + Assert that a value is a key-value mapping of arbitrary value types, and assert a single of its values. + """ + + def _assert_field(value: Any) -> _AssertionReturnT | type[Void]: + fields = assert_fields(field)(value) + try: + return cast("_AssertionReturnT | type[Void]", fields[field.name]) + except KeyError: + if isinstance(field, RequiredField): + raise + return Void + + return AssertionChain(_assert_field) + + +def assert_record( + *fields: _Field[Any, Any], +) -> AssertionChain[Any, MutableMapping[str, Any]]: + """ + Assert that a value is a record: a key-value mapping of arbitrary value types, with a known structure. + + To validate a key-value mapping as a records, assertions for all possible keys + MUST be provided. Any keys present in the value for which no field assertions + are provided will cause the entire record assertion to fail. + """ + if not len(fields): + raise ValueError("One or more fields are required.") + + def _assert_record(value: Any) -> MutableMapping[str, Any]: + dict_value = assert_dict()(value) + known_keys = {x.name for x in fields} + unknown_keys = set(dict_value.keys()) - known_keys + with AssertionFailedGroup().assert_valid() as errors: + for unknown_key in unknown_keys: + with errors.catch(plain(unknown_key)): + raise AssertionFailed( + _( + "Unknown key: {unknown_key}. Did you mean {known_keys}?" + ).format( + unknown_key=f'"{unknown_key}"', + known_keys=", ".join( + (f'"{x}"' for x in sorted(known_keys)) + ), + ) + ) + return assert_fields(*fields)(dict_value) + + return AssertionChain(_assert_record) + + +def assert_path() -> AssertionChain[Any, Path]: + """ + Assert that a value is a path to a file or directory on disk that may or may not exist. + """ + return assert_str().chain(lambda value: Path(value).expanduser().resolve()) + + +def assert_directory_path() -> AssertionChain[Any, Path]: + """ + Assert that a value is a path to an existing directory. + """ + + def _assert_directory_path(value: Any) -> Path: + directory_path = assert_path()(value) + if directory_path.is_dir(): + return directory_path + raise AssertionFailed(_('"{path}" is not a directory.').format(path=value)) + + return AssertionChain(_assert_directory_path) + + +def assert_locale() -> AssertionChain[Any, str]: + """ + Assert that a value is a valid `IETF BCP 47 language tag `_. + """ + + def _assert_locale( + value: Any, + ) -> str: + value = assert_str()(value) + + # Allow locales for which no system information usually exists. + if value == UNDETERMINED_LOCALE: + return value + + try: + get_data(value) + return value + except LocaleNotFoundError: + raise AssertionFailed( + _('"{locale}" is not a valid IETF BCP 47 language tag.').format( + locale=value + ) + ) from None + + return AssertionChain(_assert_locale) + + +def assert_setattr( + instance: object, + attr_name: str, +) -> AssertionChain[Any, Any]: + """ + Set a value for the given object's attribute. + """ + + def _assert_setattr(value: Any) -> Any: + setattr(instance, attr_name, value) + # Return the getter's return value rather than the assertion value, just + # in case the setter and/or getter perform changes to the value. + return getattr(instance, attr_name) + + return AssertionChain(_assert_setattr) + + +def assert_extension_type() -> AssertionChain[Any, type[Extension]]: + """ + Assert that a value is an extension type. + + This assertion passes if the value is fully qualified :py:class:`betty.project.extension.Extension` subclass name. + """ + + def _assert_extension_type( + value: Any, + ) -> type[Extension]: + from betty.project.extension import ( + get_extension_type, + ExtensionTypeImportError, + ExtensionTypeInvalidError, + ExtensionTypeError, + ) + + assert_str()(value) + try: + return get_extension_type(value) + except ExtensionTypeImportError: + raise AssertionFailed( + _( + 'Cannot find and import "{extension_type}".', + ).format(extension_type=str(value)) + ) from None + except ExtensionTypeInvalidError: + raise AssertionFailed( + _( + '"{extension_type}" is not a valid Betty extension type.', + ).format(extension_type=str(value)) + ) from None + except ExtensionTypeError: + raise AssertionFailed( + _( + 'Cannot determine the extension type for "{extension_type}". Did you perhaps make a typo, or could it be that the extension type comes from another package that is not yet installed?', + ).format(extension_type=str(value)) + ) from None + + return AssertionChain(_assert_extension_type) + + +def assert_entity_type() -> AssertionChain[Any, type[Entity]]: + """ + Assert that a value is an entity type. + + This assertion passes if the value is fully qualified :py:class:`betty.model.Entity` subclass name. + """ + + def _assert_entity_type( + value: Any, + ) -> type[Entity]: + assert_str()(value) + try: + return get_entity_type(value) + except EntityTypeImportError: + raise AssertionFailed( + _( + 'Cannot find and import "{entity_type}".', + ).format(entity_type=str(value)) + ) from None + except EntityTypeInvalidError: + raise AssertionFailed( + _('"{entity_type}" is not a valid Betty entity type.').format( + entity_type=str(value) + ) + ) from None + except EntityTypeError: + raise AssertionFailed( + _( + 'Cannot determine the entity type for "{entity_type}". Did you perhaps make a typo, or could it be that the entity type comes from another package that is not yet installed?' + ).format(entity_type=str(value)) + ) from None + + return AssertionChain(_assert_entity_type) diff --git a/betty/serde/error.py b/betty/assertion/error.py similarity index 80% rename from betty/serde/error.py rename to betty/assertion/error.py index c66fa3837..e983ba103 100644 --- a/betty/serde/error.py +++ b/betty/assertion/error.py @@ -1,5 +1,5 @@ """ -Provide serialization error handling utilities. +Provide assertion failures. """ from __future__ import annotations @@ -17,9 +17,9 @@ from betty.locale import Localizer -class SerdeError(UserFacingError, ValueError): +class AssertionFailed(UserFacingError, ValueError): """ - A (de)serialization error. + An assertion failure. """ def __init__(self, message: Localizable): @@ -35,7 +35,7 @@ def localize(self, localizer: Localizer) -> str: + indent("\n".join(localized_contexts), "- ") ).strip() - def raised(self, error_type: type[SerdeError]) -> bool: + def raised(self, error_type: type[AssertionFailed]) -> bool: """ Check if the error matches the given error type. """ @@ -60,19 +60,19 @@ def _copy(self) -> Self: return type(self)(self._localizable_message) -class SerdeErrorCollection(SerdeError): +class AssertionFailedGroup(AssertionFailed): """ - A collection of zero or more (de)serialization errors. + A group of zero or more assertion failures. """ def __init__( self, - errors: list[SerdeError] | None = None, + errors: list[AssertionFailed] | None = None, ): super().__init__(_("The following errors occurred")) - self._errors: list[SerdeError] = errors or [] + self._errors: list[AssertionFailed] = errors or [] - def __iter__(self) -> Iterator[SerdeError]: + def __iter__(self) -> Iterator[AssertionFailed]: yield from self._errors @override @@ -80,14 +80,14 @@ def localize(self, localizer: Localizer) -> str: return "\n\n".join((error.localize(localizer) for error in self._errors)) @override - def __reduce__(self) -> tuple[type[Self], tuple[list[SerdeError]]]: # type: ignore[override] + def __reduce__(self) -> tuple[type[Self], tuple[list[AssertionFailed]]]: # type: ignore[override] return type(self), (self._errors,) def __len__(self) -> int: return len(self._errors) @override - def raised(self, error_type: type[SerdeError]) -> bool: + def raised(self, error_type: type[AssertionFailed]) -> bool: return any(error.raised(error_type) for error in self._errors) @property @@ -116,12 +116,12 @@ def assert_valid(self) -> Iterator[Self]: if self.invalid: # type: ignore[redundant-expr] raise self - def append(self, *errors: SerdeError) -> None: + def append(self, *errors: AssertionFailed) -> None: """ Append errors to this collection. """ for error in errors: - if isinstance(error, SerdeErrorCollection): + if isinstance(error, AssertionFailedGroup): self.append(*error) else: self._errors.append(error.with_context(*self._contexts)) @@ -137,17 +137,17 @@ def _copy(self) -> Self: return type(self)() @contextmanager - def catch(self, *contexts: Localizable) -> Iterator[SerdeErrorCollection]: + def catch(self, *contexts: Localizable) -> Iterator[AssertionFailedGroup]: """ Catch any errors raised within this context manager and add them to the collection. :return: A new collection that will only contain any newly raised errors. """ - context_errors: SerdeErrorCollection = SerdeErrorCollection() + context_errors: AssertionFailedGroup = AssertionFailedGroup() if contexts: context_errors = context_errors.with_context(*contexts) try: yield context_errors - except SerdeError as e: + except AssertionFailed as e: context_errors.append(e) self.append(*context_errors) diff --git a/betty/cli/__init__.py b/betty/cli/__init__.py index 20912cb71..c7e5ad11c 100644 --- a/betty/cli/__init__.py +++ b/betty/cli/__init__.py @@ -33,7 +33,7 @@ from betty.locale.localizable import _ from betty.logging import CliHandler from betty.project import Project -from betty.serde.load import AssertionFailed +from betty.assertion.error import AssertionFailed if TYPE_CHECKING: from collections.abc import Coroutine, Mapping diff --git a/betty/config.py b/betty/config.py index b24bb65c8..7d442ba11 100644 --- a/betty/config.py +++ b/betty/config.py @@ -34,14 +34,15 @@ from aiofiles.os import makedirs from typing_extensions import override +from betty.assertion import assert_dict, assert_sequence +from betty.assertion.error import AssertionFailedGroup from betty.asyncio import wait_to_thread from betty.classtools import repr_instance from betty.functools import slice_to_range from betty.locale.localizable import plain -from betty.serde.dump import Dumpable, Dump, minimize, VoidableDump, Void -from betty.serde.error import SerdeErrorCollection +from betty.serde.dump import Dumpable, Dump, minimize, VoidableDump from betty.serde.format import FormatRepository -from betty.serde.load import assert_dict, assert_sequence +from betty.typing import Void if TYPE_CHECKING: from _weakref import ReferenceType @@ -179,7 +180,7 @@ async def read(self, configuration_file_path: Path | None = None) -> None: formats = FormatRepository() with ( - SerdeErrorCollection().assert_valid() as errors, + AssertionFailedGroup().assert_valid() as errors, # Change the working directory to allow relative paths to be resolved # against the configuration file's directory path. chdir(self.configuration_file_path.parent), @@ -351,7 +352,7 @@ def load_item(self, dump: Dump) -> _ConfigurationT: """ Create and load a new item from the given dump, or raise an assertion error. - :raise betty.serde.load.AssertionFailed: Raised when the dump is invalid and cannot be loaded. + :raise betty.assertion.error.AssertionFailed: Raised when the dump is invalid and cannot be loaded. """ raise NotImplementedError(repr(self)) diff --git a/betty/extension/cotton_candy/__init__.py b/betty/extension/cotton_candy/__init__.py index 7f2f1c8df..e4c292376 100644 --- a/betty/extension/cotton_candy/__init__.py +++ b/betty/extension/cotton_candy/__init__.py @@ -13,6 +13,14 @@ from typing_extensions import override from betty import fs +from betty.assertion import ( + OptionalField, + assert_str, + assert_record, + assert_path, + assert_setattr, +) +from betty.assertion.error import AssertionFailed from betty.config import Configuration from betty.extension.cotton_candy.search import Index from betty.extension.webpack import Webpack, WebpackEntryPointProvider @@ -36,15 +44,8 @@ from betty.os import link_or_copy from betty.project import EntityReferenceSequence, EntityReference from betty.project.extension import ConfigurableExtension, Extension, Theme -from betty.serde.dump import minimize, Dump, VoidableDump, Void -from betty.serde.load import ( - AssertionFailed, - OptionalField, - assert_str, - assert_record, - assert_path, - assert_setattr, -) +from betty.serde.dump import minimize, Dump, VoidableDump +from betty.typing import Void if TYPE_CHECKING: from PyQt6.QtWidgets import QWidget diff --git a/betty/extension/gramps/config.py b/betty/extension/gramps/config.py index 41c41c66a..155e824a3 100644 --- a/betty/extension/gramps/config.py +++ b/betty/extension/gramps/config.py @@ -11,7 +11,7 @@ from betty.config import Configuration, ConfigurationSequence from betty.serde.dump import minimize, Dump, VoidableDump -from betty.serde.load import ( +from betty.assertion import ( RequiredField, OptionalField, assert_record, diff --git a/betty/extension/gramps/gui.py b/betty/extension/gramps/gui.py index 4ad9d2e81..43ad12074 100644 --- a/betty/extension/gramps/gui.py +++ b/betty/extension/gramps/gui.py @@ -22,6 +22,7 @@ ) from typing_extensions import override +from betty.assertion.error import AssertionFailed from betty.extension import Gramps from betty.extension.gramps.config import FamilyTreeConfiguration from betty.gui import mark_valid, mark_invalid @@ -30,7 +31,7 @@ from betty.gui.text import Text from betty.gui.window import BettyMainWindow from betty.locale.localizable import _, Localizable -from betty.serde.error import SerdeError + if TYPE_CHECKING: from betty.project import Project @@ -143,7 +144,7 @@ def _update_configuration_file_path(file_path: str) -> None: self._family_tree.file_path = Path(file_path) mark_valid(self._file_path) self._save_and_close.setDisabled(False) - except SerdeError as e: + except AssertionFailed as e: mark_invalid(self._file_path, str(e)) self._save_and_close.setDisabled(True) diff --git a/betty/extension/nginx/config.py b/betty/extension/nginx/config.py index 8010037c9..f1082b4c9 100644 --- a/betty/extension/nginx/config.py +++ b/betty/extension/nginx/config.py @@ -5,8 +5,9 @@ from typing_extensions import override from betty.config import Configuration -from betty.serde.dump import Dump, VoidableDump, minimize, Void, VoidableDictDump -from betty.serde.load import ( +from betty.serde.dump import Dump, VoidableDump, minimize, VoidableDictDump +from betty.typing import Void +from betty.assertion import ( OptionalField, assert_record, assert_or, diff --git a/betty/extension/wikipedia/config.py b/betty/extension/wikipedia/config.py index d5f735ebf..fc986dd08 100644 --- a/betty/extension/wikipedia/config.py +++ b/betty/extension/wikipedia/config.py @@ -8,7 +8,7 @@ from betty.config import Configuration from betty.serde.dump import Dump, VoidableDump, minimize, VoidableDictDump -from betty.serde.load import ( +from betty.assertion import ( OptionalField, assert_record, assert_bool, diff --git a/betty/gui/project.py b/betty/gui/project.py index 4f7f46432..2ff050f46 100644 --- a/betty/gui/project.py +++ b/betty/gui/project.py @@ -60,7 +60,7 @@ from betty.model import UserFacingEntity, Entity from betty.project import LocaleConfiguration, Project, EntityTypeConfiguration from betty.project.extension import UserFacingExtension -from betty.serde.load import AssertionFailed +from betty.assertion.error import AssertionFailed from betty.typing import internal if TYPE_CHECKING: diff --git a/betty/jinja2/__init__.py b/betty/jinja2/__init__.py index 357643503..535adc329 100644 --- a/betty/jinja2/__init__.py +++ b/betty/jinja2/__init__.py @@ -27,7 +27,8 @@ from betty.locale import Date, Localizer, DEFAULT_LOCALIZER from betty.model import Entity, get_entity_type from betty.render import Renderer -from betty.serde.dump import Dumpable, DictDump, VoidableDump, Void, Dump +from betty.serde.dump import Dumpable, DictDump, VoidableDump, Dump +from betty.typing import Void if TYPE_CHECKING: from betty.project.extension import Extension diff --git a/betty/project/__init__.py b/betty/project/__init__.py index 34451b9c3..76df8a201 100644 --- a/betty/project/__init__.py +++ b/betty/project/__init__.py @@ -79,11 +79,10 @@ VoidableDump, void_none, minimize, - Void, VoidableDictDump, ) -from betty.serde.load import ( - AssertionFailed, +from betty.typing import Void +from betty.assertion import ( Assertion, RequiredField, OptionalField, @@ -99,6 +98,7 @@ assert_positive_number, assert_fields, ) +from betty.assertion.error import AssertionFailed if TYPE_CHECKING: from betty.app import App diff --git a/betty/serde/dump.py b/betty/serde/dump.py index 307a78815..a62c70335 100644 --- a/betty/serde/dump.py +++ b/betty/serde/dump.py @@ -6,18 +6,7 @@ from typing import TypeVar, Sequence, Mapping, overload, Literal, TypeAlias, Any - -class Void: - """ - A sentinel that describes the absence of a value. - - Using this sentinel allows for actual values to be ``None``. Like ``None``, - ``Void`` is only ever used through its type, and never instantiated. - """ - - def __new__(cls): # pragma: no cover # noqa D102 - raise RuntimeError("The Void sentinel cannot be instantiated.") - +from betty.typing import Void #: The Python types that define a serialized dump. DumpType: TypeAlias = bool | int | float | str | None | list["Dump"] | dict[str, "Dump"] diff --git a/betty/serde/format.py b/betty/serde/format.py index 37a5f657c..4a8011670 100644 --- a/betty/serde/format.py +++ b/betty/serde/format.py @@ -10,9 +10,9 @@ import yaml from typing_extensions import override +from betty.assertion.error import AssertionFailed from betty.locale.localizable import plain, Localizable, _ from betty.serde.dump import Dump, VoidableDump -from betty.serde.load import FormatError if TYPE_CHECKING: from betty.locale import Localizer @@ -175,3 +175,11 @@ def localize(self, localizer: Localizer) -> str: for extension in serde_format.extensions ] ) + + +class FormatError(AssertionFailed): + """ + Raised when data that is being deserialized is provided in an unknown (undeserializable) format. + """ + + pass # pragma: no cover diff --git a/betty/tests/assertion/__init__.py b/betty/tests/assertion/__init__.py new file mode 100644 index 000000000..079357290 --- /dev/null +++ b/betty/tests/assertion/__init__.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import overload, Iterable, Iterator, Any + +from betty.assertion.error import AssertionFailedGroup, AssertionFailed +from betty.locale import DEFAULT_LOCALIZER + + +class SerdeAssertionError(AssertionError): + pass + + +@overload +def assert_error( + actual_error: AssertionFailed | AssertionFailedGroup, + *, + error: AssertionFailed, + error_type: None = None, + error_message: None = None, + error_contexts: None = None, +) -> list[AssertionFailed]: + pass + + +@overload +def assert_error( + actual_error: AssertionFailed | AssertionFailedGroup, + *, + error: None = None, + error_type: type[AssertionFailed] = AssertionFailed, + error_message: str | None = None, + error_contexts: list[str] | None = None, +) -> list[AssertionFailed]: + pass + + +def assert_error( + actual_error: AssertionFailed | AssertionFailedGroup, + *, + error: AssertionFailed | None = None, + error_type: type[AssertionFailed] | None = AssertionFailed, + error_message: str | None = None, + error_contexts: list[str] | None = None, +) -> list[AssertionFailed]: + """ + Assert that an error group contains an error matching the given parameters. + """ + expected_error_contexts: list[str] | None + actual_errors: Iterable[AssertionFailed] + if isinstance(actual_error, AssertionFailedGroup): + actual_errors = [*actual_error] + else: + actual_errors = [actual_error] + + expected_error_type: type + expected_error_message = None + expected_error_contexts = None + if error: + expected_error_type = type(error) + expected_error_message = str(error) + expected_error_contexts = [ + error.localize(DEFAULT_LOCALIZER) for error in error.contexts + ] + else: + expected_error_type = error_type # type: ignore[assignment] + if error_message is not None: + expected_error_message = error_message + if error_contexts is not None: + expected_error_contexts = error_contexts + + errors = [ + actual_error + for actual_error in actual_errors + if isinstance(actual_error, expected_error_type) + ] + if expected_error_message is not None: + errors = [ + actual_error + for actual_error in actual_errors + if str(actual_error).startswith(expected_error_message) + ] + if expected_error_contexts is not None: + errors = [ + actual_error + for actual_error in actual_errors + if expected_error_contexts + == [error.localize(DEFAULT_LOCALIZER) for error in actual_error.contexts] + ] + if errors: + return errors + raise SerdeAssertionError( + "Failed raising a serialization or deserialization error." + ) + + +@contextmanager +def raises_error(*args: Any, **kwargs: Any) -> Iterator[AssertionFailedGroup]: + """ + Provide a context manager to assert that an error group contains an error matching the given parameters. + """ + try: + with AssertionFailedGroup().catch() as errors: + yield errors + finally: + assert_error(errors, *args, **kwargs) + errors.assert_valid() diff --git a/betty/tests/serde/test_load.py b/betty/tests/assertion/test___init__.py similarity index 98% rename from betty/tests/serde/test_load.py rename to betty/tests/assertion/test___init__.py index 5e9c513c7..fe63016e6 100644 --- a/betty/tests/serde/test_load.py +++ b/betty/tests/assertion/test___init__.py @@ -6,10 +6,7 @@ import pytest from aiofiles.tempfile import TemporaryDirectory -from betty.locale.localizable import plain -from betty.serde.dump import Void -from betty.serde.load import ( - AssertionFailed, +from betty.assertion import ( Number, OptionalField, RequiredField, @@ -32,7 +29,10 @@ assert_fields, assert_field, ) -from betty.tests.serde import raises_error +from betty.assertion.error import AssertionFailed +from betty.locale.localizable import plain +from betty.typing import Void +from betty.tests.assertion import raises_error _T = TypeVar("_T") diff --git a/betty/tests/serde/test_error.py b/betty/tests/assertion/test_error.py similarity index 73% rename from betty/tests/serde/test_error.py rename to betty/tests/assertion/test_error.py index dfdcaeaa3..31012008d 100644 --- a/betty/tests/serde/test_error.py +++ b/betty/tests/assertion/test_error.py @@ -1,17 +1,16 @@ from betty.locale import DEFAULT_LOCALIZER from betty.locale.localizable import plain -from betty.serde.error import SerdeError, SerdeErrorCollection -from betty.serde.load import LoadError -from betty.tests.serde import assert_error +from betty.assertion.error import AssertionFailed, AssertionFailedGroup +from betty.tests.assertion import assert_error -class TestSerdeError: +class TestAssertionFailed: async def test_localizewithout_contexts(self) -> None: - sut = SerdeError(plain("Something went wrong!")) + sut = AssertionFailed(plain("Something went wrong!")) assert sut.localize(DEFAULT_LOCALIZER) == "Something went wrong!" async def test_localize_with_contexts(self) -> None: - sut = SerdeError(plain("Something went wrong!")) + sut = AssertionFailed(plain("Something went wrong!")) sut = sut.with_context(plain("Somewhere, at some point...")) sut = sut.with_context(plain("Somewhere else, too...")) assert ( @@ -20,7 +19,7 @@ async def test_localize_with_contexts(self) -> None: ) async def test_with_context(self) -> None: - sut = SerdeError(plain("Something went wrong!")) + sut = AssertionFailed(plain("Something went wrong!")) sut_with_context = sut.with_context(plain("Somewhere, at some point...")) assert sut != sut_with_context assert [ @@ -28,31 +27,31 @@ async def test_with_context(self) -> None: ] == ["Somewhere, at some point..."] -class TestSerdeErrorCollection: +class TestAssertionFailedGroup: async def test_localize_without_errors(self) -> None: - sut = SerdeErrorCollection() + sut = AssertionFailedGroup() assert sut.localize(DEFAULT_LOCALIZER) == "" async def test_localize_with_one_error(self) -> None: - sut = SerdeErrorCollection() - sut.append(SerdeError(plain("Something went wrong!"))) + sut = AssertionFailedGroup() + sut.append(AssertionFailed(plain("Something went wrong!"))) assert sut.localize(DEFAULT_LOCALIZER) == "Something went wrong!" async def test_localize_with_multiple_errors(self) -> None: - sut = SerdeErrorCollection() - sut.append(SerdeError(plain("Something went wrong!"))) - sut.append(SerdeError(plain("Something else went wrong, too!"))) + sut = AssertionFailedGroup() + sut.append(AssertionFailed(plain("Something went wrong!"))) + sut.append(AssertionFailed(plain("Something else went wrong, too!"))) assert ( sut.localize(DEFAULT_LOCALIZER) == "Something went wrong!\n\nSomething else went wrong, too!" ) async def test_localize_with_predefined_contexts(self) -> None: - sut = SerdeErrorCollection() + sut = AssertionFailedGroup() sut = sut.with_context(plain("Somewhere, at some point...")) sut = sut.with_context(plain("Somewhere else, too...")) - error_1 = SerdeError(plain("Something went wrong!")) - error_2 = SerdeError(plain("Something else went wrong, too!")) + error_1 = AssertionFailed(plain("Something went wrong!")) + error_2 = AssertionFailed(plain("Something else went wrong, too!")) sut.append(error_1) sut.append(error_2) assert not len(error_1.contexts) @@ -63,9 +62,9 @@ async def test_localize_with_predefined_contexts(self) -> None: ) async def test_localize_with_postdefined_contexts(self) -> None: - sut = SerdeErrorCollection() - error_1 = SerdeError(plain("Something went wrong!")) - error_2 = SerdeError(plain("Something else went wrong, too!")) + sut = AssertionFailedGroup() + error_1 = AssertionFailed(plain("Something went wrong!")) + error_2 = AssertionFailed(plain("Something else went wrong, too!")) sut.append(error_1) sut.append(error_2) sut = sut.with_context(plain("Somewhere, at some point...")) @@ -78,7 +77,7 @@ async def test_localize_with_postdefined_contexts(self) -> None: ) async def test_with_context(self) -> None: - sut = SerdeErrorCollection() + sut = AssertionFailedGroup() sut_with_context = sut.with_context(plain("Somewhere, at some point...")) assert sut is not sut_with_context assert [ @@ -86,16 +85,16 @@ async def test_with_context(self) -> None: ] == ["Somewhere, at some point..."] async def test_catch_without_contexts(self) -> None: - sut = SerdeErrorCollection() - error = LoadError(plain("Help!")) + sut = AssertionFailedGroup() + error = AssertionFailed(plain("Help!")) with sut.catch() as errors: raise error assert_error(errors, error=error) # type: ignore[unreachable] assert_error(sut, error=error) async def test_catch_with_contexts(self) -> None: - sut = SerdeErrorCollection() - error = LoadError(plain("Help!")) + sut = AssertionFailedGroup() + error = AssertionFailed(plain("Help!")) with sut.catch(plain("Somewhere")) as errors: raise error assert_error(errors, error=error.with_context(plain("Somewhere"))) # type: ignore[unreachable] diff --git a/betty/tests/coverage/test_coverage.py b/betty/tests/coverage/test_coverage.py index 03f1ac108..2082b5cec 100644 --- a/betty/tests/coverage/test_coverage.py +++ b/betty/tests/coverage/test_coverage.py @@ -80,6 +80,33 @@ class TestKnownToBeMissing: }, "AppConfiguration": TestKnownToBeMissing, }, + "betty/assertion/__init__.py": { + "assert_assertions": TestKnownToBeMissing, + "assert_entity_type": TestKnownToBeMissing, + "assert_extension_type": TestKnownToBeMissing, + "assert_locale": TestKnownToBeMissing, + "assert_none": TestKnownToBeMissing, + "assert_setattr": TestKnownToBeMissing, + "Fields": TestKnownToBeMissing, + "OptionalField": TestKnownToBeMissing, + "RequiredField": TestKnownToBeMissing, + }, + "betty/assertion/error.py": { + "AssertionFailed": { + "contexts": TestKnownToBeMissing, + "raised": TestKnownToBeMissing, + }, + "AssertionFailedGroup": { + "__iter__": TestKnownToBeMissing, + "__len__": TestKnownToBeMissing, + "__reduce__": TestKnownToBeMissing, + "append": TestKnownToBeMissing, + "assert_valid": TestKnownToBeMissing, + "invalid": TestKnownToBeMissing, + "raised": TestKnownToBeMissing, + "valid": TestKnownToBeMissing, + }, + }, "betty/asyncio.py": { "gather": TestKnownToBeMissing, }, @@ -687,25 +714,10 @@ class TestKnownToBeMissing: }, "RequirementError": TestKnownToBeMissing, }, - "betty/serde/error.py": { - "SerdeError": { - "contexts": TestKnownToBeMissing, - "raised": TestKnownToBeMissing, - }, - "SerdeErrorCollection": { - "__iter__": TestKnownToBeMissing, - "__len__": TestKnownToBeMissing, - "__reduce__": TestKnownToBeMissing, - "append": TestKnownToBeMissing, - "assert_valid": TestKnownToBeMissing, - "invalid": TestKnownToBeMissing, - "raised": TestKnownToBeMissing, - "valid": TestKnownToBeMissing, - }, - }, "betty/serde/format.py": { # This is an interface. "Format": TestKnownToBeMissing, + "FormatError": TestKnownToBeMissing, "FormatRepository": TestKnownToBeMissing, "FormatStr": TestKnownToBeMissing, "Json": { @@ -717,23 +729,6 @@ class TestKnownToBeMissing: "label": TestKnownToBeMissing, }, }, - "betty/serde/load.py": { - "assert_assertions": TestKnownToBeMissing, - "assert_entity_type": TestKnownToBeMissing, - "assert_extension_type": TestKnownToBeMissing, - "assert_locale": TestKnownToBeMissing, - "assert_none": TestKnownToBeMissing, - "assert_setattr": TestKnownToBeMissing, - # This is an empty class. - "AssertionFailed": TestKnownToBeMissing, - "Fields": TestKnownToBeMissing, - # This is an empty class. - "FormatError": TestKnownToBeMissing, - # This is an empty class. - "LoadError": TestKnownToBeMissing, - "OptionalField": TestKnownToBeMissing, - "RequiredField": TestKnownToBeMissing, - }, "betty/serve.py": { "ProjectServer": TestKnownToBeMissing, "BuiltinProjectServer": { @@ -757,6 +752,9 @@ class TestKnownToBeMissing: }, "betty/serde/dump.py": TestKnownToBeMissing, "betty/sphinx/extension/replacements.py": TestKnownToBeMissing, + "betty/typing.py": { + "Void": TestKnownToBeMissing, + }, "betty/url.py": { # This is an abstract base class. "LocalizedUrlGenerator": TestKnownToBeMissing, diff --git a/betty/tests/extension/cotton_candy/test___init__.py b/betty/tests/extension/cotton_candy/test___init__.py index 1473eef8a..da2a1c48a 100644 --- a/betty/tests/extension/cotton_candy/test___init__.py +++ b/betty/tests/extension/cotton_candy/test___init__.py @@ -27,8 +27,8 @@ ) from betty.model.event_type import Birth, UnknownEventType, EventType, Death from betty.project import EntityReference, DEFAULT_LIFETIME_THRESHOLD -from betty.serde.load import AssertionFailed -from betty.tests.serde import raises_error +from betty.assertion.error import AssertionFailed +from betty.tests.assertion import raises_error if TYPE_CHECKING: from pathlib import Path diff --git a/betty/tests/extension/gramps/test_config.py b/betty/tests/extension/gramps/test_config.py index 5afadadff..4d102fbb3 100644 --- a/betty/tests/extension/gramps/test_config.py +++ b/betty/tests/extension/gramps/test_config.py @@ -1,17 +1,20 @@ from collections.abc import Iterable, Sequence from pathlib import Path -from typing import Any +from typing import Any, TYPE_CHECKING from betty.extension.gramps.config import ( FamilyTreeConfiguration, GrampsConfiguration, FamilyTreeConfigurationSequence, ) -from betty.serde.dump import Void, Dump -from betty.serde.load import AssertionFailed -from betty.tests.serde import raises_error +from betty.typing import Void +from betty.assertion.error import AssertionFailed +from betty.tests.assertion import raises_error from betty.tests.test_config import ConfigurationSequenceTestBase +if TYPE_CHECKING: + from betty.serde.dump import Dump + class TestFamilyTreeConfigurationSequence( ConfigurationSequenceTestBase[FamilyTreeConfiguration] diff --git a/betty/tests/extension/nginx/test_config.py b/betty/tests/extension/nginx/test_config.py index a1b000452..0fbd5523e 100644 --- a/betty/tests/extension/nginx/test_config.py +++ b/betty/tests/extension/nginx/test_config.py @@ -4,8 +4,8 @@ import pytest from betty.extension.nginx.config import NginxConfiguration -from betty.serde.load import AssertionFailed -from betty.tests.serde import raises_error +from betty.assertion.error import AssertionFailed +from betty.tests.assertion import raises_error if TYPE_CHECKING: from betty.serde.dump import Dump diff --git a/betty/tests/extension/wikipedia/test_config.py b/betty/tests/extension/wikipedia/test_config.py index 14e6b1e83..bd0bfc56f 100644 --- a/betty/tests/extension/wikipedia/test_config.py +++ b/betty/tests/extension/wikipedia/test_config.py @@ -4,8 +4,8 @@ import pytest from betty.extension.wikipedia.config import WikipediaConfiguration -from betty.serde.load import AssertionFailed -from betty.tests.serde import raises_error +from betty.assertion.error import AssertionFailed +from betty.tests.assertion import raises_error if TYPE_CHECKING: from betty.serde.dump import Dump diff --git a/betty/tests/gui/test_app.py b/betty/tests/gui/test_app.py index 8851ee7d6..daccdfd31 100644 --- a/betty/tests/gui/test_app.py +++ b/betty/tests/gui/test_app.py @@ -5,6 +5,7 @@ from PyQt6.QtWidgets import QFileDialog from pytest_mock import MockerFixture +from betty.assertion.error import AssertionFailed from betty.gui.app import ( WelcomeWindow, _AboutBettyWindow, @@ -14,9 +15,8 @@ from betty.gui.project import ProjectWindow from betty.gui.serve import ServeDemoWindow from betty.project import ProjectConfiguration -from betty.serde.error import SerdeError -from betty.tests.conftest import BettyQtBot from betty.tests.cli.test___init__ import NoOpServer +from betty.tests.conftest import BettyQtBot class TestBettyPrimaryWindow: @@ -80,7 +80,7 @@ async def test_open_project_with_invalid_file_should_error( ) betty_qtbot.mouse_click(sut.open_project_button) - betty_qtbot.assert_exception_error(contained_error_type=SerdeError) + betty_qtbot.assert_exception_error(contained_error_type=AssertionFailed) async def test_open_project_with_valid_file_should_show_project_window( self, diff --git a/betty/tests/project/test___init__.py b/betty/tests/project/test___init__.py index d3021802a..02f291606 100644 --- a/betty/tests/project/test___init__.py +++ b/betty/tests/project/test___init__.py @@ -4,6 +4,14 @@ import pytest +from betty.assertion import ( + RequiredField, + assert_bool, + assert_record, + assert_setattr, + assert_int, +) +from betty.assertion.error import AssertionFailed from betty.config import Configuration from betty.locale import DEFAULT_LOCALE, UNDETERMINED_LOCALE from betty.model import Entity, get_entity_type_name, UserFacingEntity @@ -25,21 +33,12 @@ ConfigurableExtension, CyclicDependencyError, ) -from betty.serde.dump import Void -from betty.serde.error import SerdeError -from betty.serde.load import ( - AssertionFailed, - RequiredField, - assert_bool, - assert_record, - assert_setattr, - assert_int, -) -from betty.tests.serde import raises_error +from betty.tests.assertion import raises_error from betty.tests.test_config import ( ConfigurationMappingTestBase, ConfigurationSequenceTestBase, ) +from betty.typing import Void if TYPE_CHECKING: from betty.app import App @@ -299,7 +298,7 @@ async def test___eq__( async def test_load_with_invalid_dump(self) -> None: dump: Dump = {} sut = LocaleConfiguration(DEFAULT_LOCALE) - with raises_error(error_type=SerdeError): + with raises_error(error_type=AssertionFailed): sut.load(dump) async def test_load_with_locale(self) -> None: diff --git a/betty/tests/serde/__init__.py b/betty/tests/serde/__init__.py index cd8c7474f..e69de29bb 100644 --- a/betty/tests/serde/__init__.py +++ b/betty/tests/serde/__init__.py @@ -1,107 +0,0 @@ -from __future__ import annotations - -from contextlib import contextmanager -from typing import overload, Iterable, Iterator, Any - -from betty.locale import DEFAULT_LOCALIZER -from betty.serde.error import SerdeError, SerdeErrorCollection - - -class SerdeAssertionError(AssertionError): - pass - - -@overload -def assert_error( - actual_error: SerdeError | SerdeErrorCollection, - *, - error: SerdeError, - error_type: None = None, - error_message: None = None, - error_contexts: None = None, -) -> list[SerdeError]: - pass - - -@overload -def assert_error( - actual_error: SerdeError | SerdeErrorCollection, - *, - error: None = None, - error_type: type[SerdeError] = SerdeError, - error_message: str | None = None, - error_contexts: list[str] | None = None, -) -> list[SerdeError]: - pass - - -def assert_error( - actual_error: SerdeError | SerdeErrorCollection, - *, - error: SerdeError | None = None, - error_type: type[SerdeError] | None = SerdeError, - error_message: str | None = None, - error_contexts: list[str] | None = None, -) -> list[SerdeError]: - """ - Assert that an error collection contains an error matching the given parameters. - """ - expected_error_contexts: list[str] | None - actual_errors: Iterable[SerdeError] - if isinstance(actual_error, SerdeErrorCollection): - actual_errors = [*actual_error] - else: - actual_errors = [actual_error] - - expected_error_type: type - expected_error_message = None - expected_error_contexts = None - if error: - expected_error_type = type(error) - expected_error_message = str(error) - expected_error_contexts = [ - error.localize(DEFAULT_LOCALIZER) for error in error.contexts - ] - else: - expected_error_type = error_type # type: ignore[assignment] - if error_message is not None: - expected_error_message = error_message - if error_contexts is not None: - expected_error_contexts = error_contexts - - errors = [ - actual_error - for actual_error in actual_errors - if isinstance(actual_error, expected_error_type) - ] - if expected_error_message is not None: - errors = [ - actual_error - for actual_error in actual_errors - if str(actual_error).startswith(expected_error_message) - ] - if expected_error_contexts is not None: - errors = [ - actual_error - for actual_error in actual_errors - if expected_error_contexts - == [error.localize(DEFAULT_LOCALIZER) for error in actual_error.contexts] - ] - if errors: - return errors - raise SerdeAssertionError( - "Failed raising a serialization or deserialization error." - ) - - -@contextmanager -def raises_error(*args: Any, **kwargs: Any) -> Iterator[SerdeErrorCollection]: - """ - Provide a context manager to assert that an error collection contains an error matching the given parameters. - """ - try: - with SerdeErrorCollection().catch() as errors: - yield errors - finally: - assert_error(errors, *args, **kwargs) - errors.assert_valid() diff --git a/betty/tests/serde/test_format.py b/betty/tests/serde/test_format.py index 2bf662175..7d30fcaa1 100644 --- a/betty/tests/serde/test_format.py +++ b/betty/tests/serde/test_format.py @@ -1,7 +1,6 @@ import pytest -from betty.serde.format import Yaml, Json -from betty.serde.load import FormatError +from betty.serde.format import Yaml, Json, FormatError from typing import TYPE_CHECKING if TYPE_CHECKING: diff --git a/betty/tests/test_config.py b/betty/tests/test_config.py index 0375e949b..7a8187225 100644 --- a/betty/tests/test_config.py +++ b/betty/tests/test_config.py @@ -15,8 +15,7 @@ ConfigurationSequence, ConfigurationKey, ) -from betty.serde.load import ( - FormatError, +from betty.assertion import ( assert_dict, assert_record, RequiredField, @@ -24,6 +23,7 @@ assert_setattr, assert_int, ) +from betty.serde.format import FormatError if TYPE_CHECKING: from betty.serde.dump import Dump, VoidableDump diff --git a/betty/typing.py b/betty/typing.py index e4a0eeb7f..eb5fb30fa 100644 --- a/betty/typing.py +++ b/betty/typing.py @@ -2,6 +2,8 @@ Providing typing utilities. """ +from __future__ import annotations + import re from typing import TypeVar @@ -45,3 +47,15 @@ def public(target: _T) -> _T: directly, but **MAY** use any of its attributes that are marked ``@public``. """ return target + + +class Void: + """ + A sentinel that describes the absence of a value. + + Using this sentinel allows for actual values to be ``None``. Like ``None``, + ``Void`` is only ever used through its type, and never instantiated. + """ + + def __new__(cls): # pragma: no cover # noqa D102 + raise RuntimeError("The Void sentinel cannot be instantiated.")