diff --git a/ancv/visualization/templates.py b/ancv/visualization/templates.py index 09d2af9..d02f2f1 100644 --- a/ancv/visualization/templates.py +++ b/ancv/visualization/templates.py @@ -145,7 +145,15 @@ def _format_date(self, date: date) -> str: if is_last_doy and self.dec31_as_year: format = self.theme.datefmt.year_only - return format_date(date, format=format, locale=self.locale) + # Code-wise, `format_date` could accept `DateTimePattern` as its `format` + # directly (version 2.10.3): + # https://github.com/python-babel/babel/blob/25e436016970443226d0ec19cf74ac8476369b33/babel/dates.py#L683 + # However, the type annotation is wrong/more restrictive, and it only accepts + # `str`. The original stringly-typed date pattern hides in the `pattern` + # attribute: + pattern = format.pattern + + return format_date(date, format=pattern, locale=self.locale) @lru_cache(maxsize=1_000) def _format_date_range( diff --git a/ancv/visualization/themes.py b/ancv/visualization/themes.py index ea0269d..31b98c8 100644 --- a/ancv/visualization/themes.py +++ b/ancv/visualization/themes.py @@ -1,8 +1,14 @@ +from babel.dates import DateTimePattern, parse_pattern from pydantic import BaseModel from rich.style import Style class Emphasis(BaseModel): + """Emphasis styles for different levels of importance. + + Using `rich.Style`, each can be styled arbitrarily. + """ + maximum: Style strong: Style medium: Style @@ -13,17 +19,35 @@ class Config: class DateFormat(BaseModel): - full: str - year_only: str + """Date formats for different levels of detail. + + The `full` format is as detailed as possible, while `year_only` should only contain + the year. Formats may otherwise be in whatever format you desire (ISO8601, + localized, months spelled out etc.). For more context, see: + https://babel.pocoo.org/en/latest/dates.html + + Using `babel.dates.DateTimePattern` and forcing it here over `str` allows for + considerably better type safety (`str` is the worst offender in terms of typing) and + fast failure: at application startup, when a theme is loaded but `parse_pattern` (or + similar) fails, the program won't launch altogether, instead of failing at runtime. + """ + + full: DateTimePattern + year_only: DateTimePattern + + class Config: + arbitrary_types_allowed = True # No validator for `DateTimePattern` class Theme(BaseModel): - emphasis: Emphasis - bullet: str - rulechar: str - sep: str - range_sep: str - datefmt: DateFormat + """A theme, containing styles and other formatting options.""" + + emphasis: Emphasis # styles for different levels of importance + bullet: str # bullet character to use in (unordered) lists + rulechar: str # character for *horizontal* rules + sep: str # separator character for joined-together strings (e.g. "•" for "foo•bar") + range_sep: str # separator character for ranges (e.g. "..." for "2010...2020") + datefmt: DateFormat # date formats in different levels of detail # See here for available colors: @@ -41,7 +65,9 @@ class Theme(BaseModel): sep="•", range_sep="–", rulechar="─", - datefmt=DateFormat(full="yyyy-MM", year_only="yyyy"), + datefmt=DateFormat( + full=parse_pattern("yyyy-MM"), year_only=parse_pattern("yyyy") + ), ), "grayscale": Theme( emphasis=Emphasis( @@ -54,7 +80,9 @@ class Theme(BaseModel): sep="*", range_sep="–", rulechar="─", - datefmt=DateFormat(full="MMMM yyyy", year_only="yyyy"), + datefmt=DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), ), "basic": Theme( emphasis=Emphasis( @@ -67,7 +95,9 @@ class Theme(BaseModel): sep="•", range_sep="–", rulechar="─", - datefmt=DateFormat(full="MMMM yyyy", year_only="yyyy"), + datefmt=DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), ), "lollipop": Theme( emphasis=Emphasis( @@ -80,7 +110,9 @@ class Theme(BaseModel): sep="•", range_sep="➔", rulechar="─", - datefmt=DateFormat(full="MMMM yyyy", year_only="yyyy"), + datefmt=DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), ), "hendrix": Theme( emphasis=Emphasis( @@ -93,6 +125,8 @@ class Theme(BaseModel): sep="•", range_sep="➔", rulechar="─", - datefmt=DateFormat(full="MMMM yyyy", year_only="yyyy"), + datefmt=DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), ), } diff --git a/tests/visualization/test_templates.py b/tests/visualization/test_templates.py index c4336b7..5b4da88 100644 --- a/tests/visualization/test_templates.py +++ b/tests/visualization/test_templates.py @@ -3,6 +3,7 @@ import pytest from babel.core import Locale +from babel.dates import parse_pattern from rich.console import NewLine, RenderableType from rich.style import Style @@ -57,7 +58,9 @@ def test_ensure_single_trailing_newline( ( None, None, - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "-", "present", @@ -68,7 +71,9 @@ def test_ensure_single_trailing_newline( ( None, date(2900, 3, 1), - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "-", "present", @@ -79,7 +84,9 @@ def test_ensure_single_trailing_newline( ( date(163, 12, 1), None, - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "-", "present", @@ -90,7 +97,9 @@ def test_ensure_single_trailing_newline( ( date(2021, 1, 1), None, - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "-", "today", @@ -101,7 +110,9 @@ def test_ensure_single_trailing_newline( ( date(2021, 1, 1), date(2021, 2, 1), - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "-", "present", @@ -112,7 +123,7 @@ def test_ensure_single_trailing_newline( ( date(1999, 4, 1), date(2018, 9, 1), - DateFormat(full="yyyy-MM", year_only="yyyy"), + DateFormat(full=parse_pattern("yyyy-MM"), year_only=parse_pattern("yyyy")), Locale("en"), "-", "present", @@ -123,7 +134,7 @@ def test_ensure_single_trailing_newline( ( date(1999, 4, 1), date(2018, 9, 1), - DateFormat(full="yyyy-MM", year_only="yyyy"), + DateFormat(full=parse_pattern("yyyy-MM"), year_only=parse_pattern("yyyy")), Locale("en"), "***", "present", @@ -134,7 +145,9 @@ def test_ensure_single_trailing_newline( ( date(1999, 3, 1), date(2018, 10, 1), - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("de"), "***", "heute", @@ -145,7 +158,9 @@ def test_ensure_single_trailing_newline( ( date(1999, 3, 1), date(2018, 10, 1), - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("es"), "***", "heute", @@ -156,7 +171,9 @@ def test_ensure_single_trailing_newline( ( date(2018, 3, 1), date(2018, 4, 1), - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "***", "present", @@ -167,7 +184,9 @@ def test_ensure_single_trailing_newline( ( date(2018, 4, 1), date(2018, 4, 1), - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "***", "present", @@ -178,7 +197,9 @@ def test_ensure_single_trailing_newline( ( None, date(2000, 12, 31), - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "---", "present", @@ -189,7 +210,9 @@ def test_ensure_single_trailing_newline( ( date(2000, 12, 31), date(2000, 12, 31), - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "---", "present", @@ -200,7 +223,9 @@ def test_ensure_single_trailing_newline( ( date(2000, 12, 31), None, - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "---", "present", @@ -211,7 +236,9 @@ def test_ensure_single_trailing_newline( ( date(2000, 12, 31), date(2002, 12, 31), - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "---", "present", @@ -222,7 +249,9 @@ def test_ensure_single_trailing_newline( ( date(2000, 12, 30), date(2002, 12, 30), - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "---", "present", @@ -233,7 +262,9 @@ def test_ensure_single_trailing_newline( ( date(2000, 12, 31), date(2002, 12, 31), - DateFormat(full="MMMM yyyy", year_only="yyyy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("yyyy") + ), Locale("en"), "---", "present", @@ -244,7 +275,9 @@ def test_ensure_single_trailing_newline( ( date(1995, 12, 31), date(1999, 12, 31), - DateFormat(full="MMMM yyyy", year_only="''yy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("''yy") + ), Locale("en"), "---", "present", @@ -255,7 +288,9 @@ def test_ensure_single_trailing_newline( ( date(1995, 12, 31), date(1999, 12, 31), - DateFormat(full="MMMM yyyy", year_only="'Anno' yy"), + DateFormat( + full=parse_pattern("MMMM yyyy"), year_only=parse_pattern("'Anno' yy") + ), Locale("en"), "to", "present",