From b992d88df8652af1ad302b7e099599ca8daeac1a Mon Sep 17 00:00:00 2001 From: Jakob Schnitzer Date: Fri, 20 Sep 2024 20:23:39 +0200 Subject: [PATCH] fava_options: stricter checks, refactor and better testing --- src/fava/core/fava_options.py | 175 ++++++++++++++++++++------------ src/fava/util/__init__.py | 15 +++ tests/test_core_fava_options.py | 36 +++++++ tests/test_util.py | 22 ++++ 4 files changed, 182 insertions(+), 66 deletions(-) diff --git a/src/fava/core/fava_options.py b/src/fava/core/fava_options.py index 23217da2b..a7e3c9568 100644 --- a/src/fava/core/fava_options.py +++ b/src/fava/core/fava_options.py @@ -12,6 +12,7 @@ from dataclasses import dataclass from dataclasses import field from dataclasses import fields +from pathlib import Path from typing import Pattern from typing import TYPE_CHECKING @@ -20,6 +21,7 @@ from fava.beans.funcs import get_position from fava.helpers import BeancountError +from fava.util import get_translations from fava.util.date import END_OF_YEAR from fava.util.date import parse_fye_string @@ -48,6 +50,41 @@ class InsertEntryOption: lineno: int +class MissingOptionError(ValueError): # noqa: D101 + def __init__(self) -> None: + super().__init__("Custom entry is missing option name.") + + +class UnknownOptionError(ValueError): # noqa: D101 + def __init__(self, key: str) -> None: + super().__init__(f"Unknown option `{key}`") + + +class NotARegularExpressionError(TypeError): # noqa: D101 + def __init__(self, value: str) -> None: + super().__init__(f"Should be a regular expression: '{value}'.") + + +class NotAStringOptionError(TypeError): # noqa: D101 + def __init__(self, key: str) -> None: + super().__init__(f"Expected string value for option `{key}`") + + +class UnknownLocaleOptionError(ValueError): # noqa: D101 + def __init__(self, value: str) -> None: + super().__init__(f"Unknown locale: '{value}'.") + + +class UnsupportedLanguageOptionError(ValueError): # noqa: D101 + def __init__(self, value: str) -> None: + super().__init__(f"Fava has no translations for: '{value}'.") + + +class InvalidFiscalYearEndOptionError(ValueError): # noqa: D101 + def __init__(self, value: str) -> None: + super().__init__(f"Invalid 'fiscal_year_end' option: '{value}'.") + + @dataclass class FavaOptions: """Options for Fava that can be set in the Beancount file.""" @@ -55,8 +92,8 @@ class FavaOptions: account_journal_include_children: bool = True auto_reload: bool = False collapse_pattern: list[Pattern[str]] = field(default_factory=list) - currency_column: int = 61 conversion_currencies: tuple[str, ...] = () + currency_column: int = 61 default_file: str | None = None default_page: str = "income_statement/" fiscal_year_end: FiscalYearEnd = END_OF_YEAR @@ -76,88 +113,92 @@ class FavaOptions: uptodate_indicator_grey_lookback_days: int = 60 use_external_editor: bool = False + def set_collapse_pattern(self, value: str) -> None: + """Set the collapse_pattern option.""" + try: + pattern = re.compile(value) + except re.error as err: + raise NotARegularExpressionError(value) from err + self.collapse_pattern.append(pattern) -_fields = fields(FavaOptions) -All_OPTS = {f.name for f in _fields} -BOOL_OPTS = {f.name for f in _fields if str(f.type) == "bool"} -INT_OPTS = {f.name for f in _fields if str(f.type) == "int"} -TUPLE_OPTS = {f.name for f in _fields if f.type.startswith("tuple[str,")} -STR_OPTS = {f.name for f in _fields if f.type.startswith("str")} - - -class UnknownOptionError(ValueError): # noqa: D101 - def __init__(self, key: str) -> None: - super().__init__(f"Unknown option `{key}`") - - -class NotARegularExpressionError(TypeError): # noqa: D101 - def __init__(self, value: str) -> None: - super().__init__(f"Should be a regular expression: '{value}'.") + def set_default_file(self, value: str, filename: str) -> None: + """Set the default_file option.""" + if value is not None: + self.default_file = str(Path(value).absolute()) + else: + self.value = filename + def set_fiscal_year_end(self, value: str) -> None: + """Set the fiscal_year_end option.""" + fye = parse_fye_string(value) + if fye is None: + raise InvalidFiscalYearEndOptionError(value) + self.fiscal_year_end = fye -class NotAStringOptionError(TypeError): # noqa: D101 - def __init__(self, key: str) -> None: - super().__init__(f"Expected string value for option `{key}`") + def set_insert_entry( + self, value: str, date: datetime.date, filename: str, lineno: int + ) -> None: + """Set the insert_entry option.""" + try: + pattern = re.compile(value) + except re.error as err: + raise NotARegularExpressionError(value) from err + opt = InsertEntryOption(date, pattern, filename, lineno) + self.insert_entry.append(opt) + def set_language(self, value: str) -> None: + """Set the locale option.""" + try: + locale = Locale.parse(value) + if ( + not locale.language == "en" + and get_translations(locale) is None + ): + raise UnsupportedLanguageOptionError(value) + self.language = value + except UnknownLocaleError as err: + raise UnknownLocaleOptionError(value) from err -class UnknownLocaleOptionError(ValueError): # noqa: D101 - def __init__(self, value: str) -> None: - super().__init__(f"Unknown locale: '{value}'.") + def set_locale(self, value: str) -> None: + """Set the locale option.""" + try: + Locale.parse(value) + self.locale = value + except UnknownLocaleError as err: + raise UnknownLocaleOptionError(value) from err -class InvalidFiscalYearEndOptionError(ValueError): # noqa: D101 - def __init__(self, value: str) -> None: - super().__init__(f"Invalid 'fiscal_year_end' option: '{value}'.") +_fields = fields(FavaOptions) +All_OPTS = {f.name for f in _fields} +BOOL_OPTS = {f.name for f in _fields if str(f.type) == "bool"} +INT_OPTS = {f.name for f in _fields if str(f.type) == "int"} +TUPLE_OPTS = {f.name for f in _fields if f.type.startswith("tuple[str,")} +STR_OPTS = {f.name for f in _fields if f.type.startswith("str")} -def parse_option_custom_entry( # noqa: PLR0912 - entry: Custom, - options: FavaOptions, -) -> None: +def parse_option_custom_entry(entry: Custom, options: FavaOptions) -> None: """Parse a single custom fava-option entry and set option accordingly.""" - key = entry.values[0].value.replace("-", "_") + key = str(entry.values[0].value).replace("-", "_") if key not in All_OPTS: raise UnknownOptionError(key) value = entry.values[1].value if len(entry.values) > 1 else None - - if key == "default_file": - if value is None: - filename, _lineno = get_position(entry) - else: - filename = value - - options.default_file = filename - return - - if not isinstance(value, str): + if value and not isinstance(value, str): raise NotAStringOptionError(key) + filename, lineno = get_position(entry) - if key == "insert_entry": - try: - pattern = re.compile(value) - except re.error as err: - raise NotARegularExpressionError(value) from err - filename, lineno = get_position(entry) - opt = InsertEntryOption(entry.date, pattern, filename, lineno) - options.insert_entry.append(opt) - elif key == "collapse_pattern": - try: - pattern = re.compile(value) - except re.error as err: - raise NotARegularExpressionError(value) from err - options.collapse_pattern.append(pattern) - elif key == "locale": - try: - Locale.parse(value) - options.locale = value - except UnknownLocaleError as err: - raise UnknownLocaleOptionError(value) from err + if key == "collapse_pattern": + options.set_collapse_pattern(value) + elif key == "default_file": + options.default_file = value if value is not None else filename elif key == "fiscal_year_end": - fye = parse_fye_string(value) - if fye is None: - raise InvalidFiscalYearEndOptionError(value) - options.fiscal_year_end = fye + options.set_fiscal_year_end(value) + elif key == "insert_entry": + options.set_insert_entry(value, entry.date, filename, lineno) + elif key == "language": + options.set_language(value) + elif key == "locale": + options.set_locale(value) elif key in STR_OPTS: setattr(options, key, value) elif key in BOOL_OPTS: @@ -189,6 +230,8 @@ def parse_options( for entry in (e for e in custom_entries if e.type == "fava-option"): try: + if not entry.values: + raise MissingOptionError parse_option_custom_entry(entry, options) except (IndexError, TypeError, ValueError) as err: msg = f"Failed to parse fava-option entry: {err!s}" diff --git a/src/fava/util/__init__.py b/src/fava/util/__init__.py index 191504acb..64807c791 100644 --- a/src/fava/util/__init__.py +++ b/src/fava/util/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import gettext import logging import re import time @@ -24,6 +25,7 @@ from _typeshed.wsgi import StartResponse from _typeshed.wsgi import WSGIEnvironment + from babel import Locale from flask.wrappers import Response @@ -38,6 +40,19 @@ def setup_logging() -> None: logging.getLogger("werkzeug").addFilter(filter_api_changed) +def get_translations(locale: Locale) -> str | None: + """Check whether Fava has translations for the locale. + + Args: + locale: The locale to search for + + Returns: + The path to the found translations or None if none matched. + """ + translations_dir = Path(__file__).parent.parent / "translations" + return gettext.find("messages", str(translations_dir), [str(locale)]) + + if TYPE_CHECKING: # pragma: no cover Item = TypeVar("Item") P = ParamSpec("P") diff --git a/tests/test_core_fava_options.py b/tests/test_core_fava_options.py index 9ed1e5bec..e6e1219de 100644 --- a/tests/test_core_fava_options.py +++ b/tests/test_core_fava_options.py @@ -4,15 +4,36 @@ import re from typing import TYPE_CHECKING +import pytest + from fava.core.charts import dumps +from fava.core.fava_options import FavaOptions from fava.core.fava_options import InsertEntryOption +from fava.core.fava_options import NotARegularExpressionError from fava.core.fava_options import parse_options +from fava.core.fava_options import UnknownLocaleOptionError +from fava.core.fava_options import UnsupportedLanguageOptionError from fava.util.date import FiscalYearEnd if TYPE_CHECKING: # pragma: no cover from fava.beans.abc import Custom +def test_fava_options_errors(load_doc_custom_entries: list[Custom]) -> None: + """ + 2016-06-14 custom "fava-option" + 2016-06-14 custom "fava-option" 10 + 2016-06-14 custom "fava-option" 10 10 + 2016-06-14 custom "fava-option" "indent" 10 + 2016-06-14 custom "fava-option" "fiscal-year-end" "not a date" + """ + options, errors = parse_options(load_doc_custom_entries) + assert len(errors) == 5 + + with pytest.raises(NotARegularExpressionError): + options.set_insert_entry("((", datetime.date.min, "", 0) + + def test_fava_options(load_doc_custom_entries: list[Custom]) -> None: """ 2016-06-14 custom "fava-option" "default-file" @@ -28,6 +49,7 @@ def test_fava_options(load_doc_custom_entries: list[Custom]) -> None: 2016-04-14 custom "fava-option" "fiscal-year-end" "01-11" 2016-04-14 custom "fava-option" "conversion-currencies" "USD EUR HOOLI" 2016-06-14 custom "fava-option" "default-file" "/some/file/name" + 2016-04-14 custom "fava-option" "language" "en" """ options, errors = parse_options(load_doc_custom_entries) @@ -37,6 +59,7 @@ def test_fava_options(load_doc_custom_entries: list[Custom]) -> None: assert len(errors) == 3 + assert options.locale == "en" assert options.indent == 4 assert options.insert_entry == [ InsertEntryOption( @@ -52,3 +75,16 @@ def test_fava_options(load_doc_custom_entries: list[Custom]) -> None: assert options.fiscal_year_end == FiscalYearEnd(1, 11) assert options.conversion_currencies == ("USD", "EUR", "HOOLI") assert options.default_file == "/some/file/name" + + +def test_fava_options_language() -> None: + options = FavaOptions() + options.set_locale("en") + assert options.locale == "en" + with pytest.raises(UnknownLocaleOptionError): + options.set_locale("invalid") + with pytest.raises(UnknownLocaleOptionError): + options.set_language("invalid") + with pytest.raises(UnsupportedLanguageOptionError): + options.set_language("km") + options.set_language("zh") diff --git a/tests/test_util.py b/tests/test_util.py index f28d7d1b8..8aab5fda4 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -3,9 +3,11 @@ from typing import Iterable from typing import TYPE_CHECKING +from babel import Locale from werkzeug.test import Client from werkzeug.wrappers import Response +from fava.util import get_translations from fava.util import listify from fava.util import next_key from fava.util import send_file_inline @@ -18,6 +20,26 @@ from flask import Flask +def test_get_translations() -> None: + de = get_translations(Locale.parse("de")) + assert de + assert de == get_translations(Locale.parse("de_AT")) + assert get_translations(Locale.parse("pt")) + pt = get_translations(Locale.parse("pt_PT")) + assert pt + assert "/pt/" in pt + assert get_translations(Locale.parse("pt_BR")) + + zh = get_translations(Locale.parse("zh")) + assert zh + assert "/zh/" in zh + zh_tw = get_translations(Locale.parse("zh_TW")) + assert zh_tw + assert "/zh_Hant_TW/" in zh_tw + + assert not get_translations(Locale.parse("km")) + + def test_listify() -> None: @listify def fun() -> Iterable[int]: