Skip to content

Commit

Permalink
fava_options: stricter checks, refactor and better testing
Browse files Browse the repository at this point in the history
  • Loading branch information
yagebu committed Sep 20, 2024
1 parent 7d71762 commit b992d88
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 66 deletions.
175 changes: 109 additions & 66 deletions src/fava/core/fava_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -48,15 +50,50 @@ 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."""

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
Expand All @@ -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:
Expand Down Expand Up @@ -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}"
Expand Down
15 changes: 15 additions & 0 deletions src/fava/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import gettext
import logging
import re
import time
Expand All @@ -24,6 +25,7 @@

from _typeshed.wsgi import StartResponse
from _typeshed.wsgi import WSGIEnvironment
from babel import Locale
from flask.wrappers import Response


Expand All @@ -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")
Expand Down
36 changes: 36 additions & 0 deletions tests/test_core_fava_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<string>", 0)


def test_fava_options(load_doc_custom_entries: list[Custom]) -> None:
"""
2016-06-14 custom "fava-option" "default-file"
Expand All @@ -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)
Expand All @@ -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(
Expand All @@ -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")
22 changes: 22 additions & 0 deletions tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]:
Expand Down

0 comments on commit b992d88

Please sign in to comment.