Skip to content

Commit 41cf43c

Browse files
committed
meh
1 parent c4eef3c commit 41cf43c

6 files changed

Lines changed: 164 additions & 62 deletions

File tree

betty/calendar.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""
2+
The calendar API.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from abc import ABC, abstractmethod
8+
from typing import TYPE_CHECKING
9+
10+
if TYPE_CHECKING:
11+
from betty.machine_name import MachineName
12+
13+
14+
class Calendar(ABC):
15+
"""
16+
A calendar.
17+
"""
18+
19+
@property
20+
@abstractmethod
21+
def id(self) -> MachineName:
22+
"""
23+
The unique calendar ID.
24+
"""
25+
26+
@abstractmethod
27+
def months(self, year: int, /) -> int:
28+
"""
29+
Get the number of months for the given year.
30+
"""
31+
32+
@abstractmethod
33+
def days(self, year: int, month: int, /) -> int:
34+
"""
35+
Get the number of days for the given month.
36+
"""
37+
38+
@abstractmethod
39+
def to_gregorian(self, year: int, month: int, day: int, /) -> tuple[int, int, int]:
40+
"""
41+
Convert a date on this calendar to one on the Gregorian calendar.
42+
"""

betty/calendars/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Reusable calendars.
3+
"""

betty/calendars/gregorian.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""
2+
Gregorian calendars.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from calendar import monthrange
8+
from typing import Final, final, override
9+
10+
from betty.calendar import Calendar
11+
from betty.classtools import Singleton
12+
from betty.machine_name import MachineName
13+
14+
15+
@final
16+
class Gregorian(Calendar, Singleton):
17+
"""
18+
The Gregorian calendar.
19+
20+
This calendar is proleptic, which means it extends back indefinitely.
21+
"""
22+
23+
id: Final[MachineName] = MachineName("gregorian")
24+
25+
@override
26+
def months(self, year: int, /) -> int:
27+
return 12
28+
29+
@override
30+
def days(self, year: int, month: int, /) -> int:
31+
return monthrange(year, month)[1]
32+
33+
@override
34+
def to_gregorian(self, year: int, month: int, day: int, /) -> tuple[int, int, int]:
35+
return year, month, day

betty/calendars/julian.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""
2+
Julian calendars.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from typing import Final, final, override
8+
9+
from convertdate.julian import month_length, to_gregorian
10+
11+
from betty.calendar import Calendar
12+
from betty.classtools import Singleton
13+
from betty.machine_name import MachineName
14+
15+
16+
@final
17+
class Julian(Calendar, Singleton):
18+
"""
19+
The Julian calendar.
20+
"""
21+
22+
id: Final[MachineName] = MachineName("julian")
23+
24+
@override
25+
def months(self, year: int, /) -> int:
26+
raise NotImplementedError
27+
28+
@override
29+
def days(self, year: int, month: int, /) -> int:
30+
return month_length(year, month)
31+
32+
@override
33+
def to_gregorian(self, year: int, month: int, day: int, /) -> tuple[int, int, int]:
34+
return to_gregorian(year, month, day)

betty/date.py

Lines changed: 49 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44

55
from __future__ import annotations
66

7-
import calendar
87
import datetime
98
import operator
109
from functools import total_ordering
1110
from typing import TYPE_CHECKING, Any, Final, final, override
1211

1312
from babel import dates
1413

14+
from betty.calendars.gregorian import Gregorian
1515
from betty.data import Data
1616
from betty.datas.aggregate.record import FieldDefinition
1717
from betty.datas.aggregate.record.object import ObjectDefinition
@@ -28,6 +28,7 @@
2828
from collections.abc import Callable, Mapping
2929
from types import NotImplementedType
3030

31+
from betty.calendar import Calendar
3132
from betty.localizer import Localizer
3233

3334

@@ -57,18 +58,18 @@
5758
),
5859
},
5960
samples=[
60-
lambda: Sample(Date(), label="Minimal", size=Size.MINIMAL),
61+
lambda: Sample(Date(1), label="Minimal", size=Size.MINIMAL),
6162
lambda: Sample(Date(1970, 1, 1, fuzzy=True), label="Full", size=Size.FULL),
6263
],
6364
)
6465
class Date(Localizable, Data):
6566
"""
66-
A (Gregorian) date.
67+
A date.
6768
"""
6869

6970
__slots__ = ("year", "month", "day", "fuzzy", "normalized")
7071

71-
_localize_formats: Final[dict[tuple[bool, bool, bool], Localizable]] = {
72+
_format_date_patterns: Final[dict[tuple[bool, bool, bool], Localizable]] = {
7273
(True, True, True): pgettext("date", "MMMM d, y"),
7374
(True, True, False): pgettext("date", "MMMM, y"),
7475
(True, False, True): pgettext("date", "'day' d 'of the month,' y"),
@@ -85,11 +86,17 @@ def __init__(
8586
month: int | None = None,
8687
day: int | None = None,
8788
*,
89+
calendar: Calendar = Gregorian(), # noqa: B008
8890
fuzzy: bool = False,
8991
):
9092
if year is None and month is None and day is None:
9193
raise ValueError("Dates require a year, and/or a month, and/or a day.")
9294
super().__init__()
95+
self.calendar: Final[Calendar] = calendar
96+
"""
97+
The calendar on which the date is expressed.
98+
"""
99+
93100
self.year: Final[int | None] = year
94101
"""
95102
The year.
@@ -110,34 +117,38 @@ def __init__(
110117
Whether the year, month, and/or day are fuzzy, e.g. not exactly known.
111118
"""
112119

113-
self.normalized: Final[AnyDate | None] = self._normalize()
114-
"""
115-
The normalized, expanded, comparable variant of this date, if at least a year is available,
116-
"""
117-
118-
def _normalize(self) -> AnyDate | None:
120+
# @todo Do we use this still?
121+
def _get_comparable(self) -> AnyDate | None:
119122
if self.year is None:
120123
return None
121124
if self.month is not None and self.day is not None:
122125
return self
123126
if self.month is None:
124127
month_start = 1
125-
month_end = 12
128+
month_end = self.calendar.months(self.year)
126129
else:
127130
month_start = month_end = self.month
128131
if self.day is None:
129132
day_start = 1
130-
day_end = calendar.monthrange(self.year, month_end)[1]
133+
day_end = self.calendar.days(self.year, month_end)
131134
else:
132135
day_start = day_end = self.day
133136
return DateRange(
134-
Date(self.year, month_start, day_start, fuzzy=self.fuzzy),
135-
Date(self.year, month_end, day_end, fuzzy=self.fuzzy),
137+
Date(
138+
*self.calendar.to_gregorian(self.year, month_start, day_start),
139+
calendar=Gregorian(),
140+
fuzzy=self.fuzzy,
141+
),
142+
Date(
143+
*self.calendar.to_gregorian(self.year, month_end, day_end),
144+
calendar=Gregorian(),
145+
fuzzy=self.fuzzy,
146+
),
136147
)
137148

138149
@override
139150
def localize(self, localizer: Localizer, /) -> LocalizedStr:
140-
pattern = self._localize_formats[
151+
pattern = self._format_date_patterns[
141152
(self.year is not None, self.month is not None, self.day is not None)
142153
].localize(localizer)
143154
localized = LocalizedStr(
@@ -159,25 +170,15 @@ def localize(self, localizer: Localizer, /) -> LocalizedStr:
159170
def _compare(
160171
self, other: Any, comparator: Callable[[Any, Any], bool], /
161172
) -> bool | NotImplementedType:
162-
if not isinstance(other, Date):
173+
if not self.year:
163174
return NotImplemented
164-
if self.normalized is self and other.normalized is other:
175+
if not isinstance(other, Date):
165176
return NotImplemented
166-
if not self.normalized or not other.normalized:
177+
if not other.year:
167178
return NotImplemented
168179
return comparator(
169-
(
170-
self.normalized.year,
171-
self.normalized.month,
172-
self.normalized.day,
173-
self.normalized.fuzzy,
174-
),
175-
(
176-
other.normalized.year,
177-
other.normalized.month,
178-
other.normalized.day,
179-
other.normalized.fuzzy,
180-
),
180+
(self.year, self.month, self.day, self.fuzzy),
181+
(other.year, other.month, other.day, other.fuzzy),
181182
)
182183

183184
def __contains__(self, other: AnyDate) -> bool:
@@ -241,7 +242,7 @@ class DateRange(Localizable, Data):
241242

242243
__slots__ = ("start", "start_is_boundary", "end", "end_is_boundary", "normalized")
243244

244-
_localize_formats: Final[Mapping[tuple[bool | None, bool | None], Localizable]] = {
245+
_localizables: Final[Mapping[tuple[bool | None, bool | None], Localizable]] = {
245246
(False, False): _("from {start_date} until {end_date}"),
246247
(False, True): _("from {start_date} until sometime before {end_date}"),
247248
(True, False): _("from sometime after {start_date} until {end_date}"),
@@ -268,57 +269,43 @@ def __init__(
268269
self.start_is_boundary: Final[bool] = start_is_boundary
269270
self.end: Final[Date | None] = end
270271
self.end_is_boundary: Final[bool] = end_is_boundary
271-
self.normalized: Final[DateRange | None] = self._normalize()
272-
"""
273-
The normalized, expanded, comparable variant of this date range, if at least a start and/or end year is available,
274-
"""
272+
self._comparable: Final[DateRange | None] = self._get_comparable()
275273

276-
def _normalize(self) -> DateRange | None:
277-
start = self.start.normalized if self.start else None
278-
end = self.end.normalized if self.end else None
279-
if start is None and end is None:
280-
return None
281-
if self.start is start and self.end is end:
274+
def _get_comparable(self) -> DateRange | None:
275+
if self.start and self.start.year is not None:
282276
return self
283-
return DateRange(
284-
None
285-
if start is None
286-
else start
287-
if isinstance(start, Date)
288-
else start.start,
289-
None if end is None else end if isinstance(end, Date) else end.end,
290-
start_is_boundary=self.start_is_boundary,
291-
end_is_boundary=self.end_is_boundary,
292-
)
277+
if self.end and self.end.year is not None:
278+
return self
279+
return None
293280

294281
@override
295282
def localize(self, localizer: Localizer, /) -> LocalizedStr:
296-
formatter_configuration: tuple[bool | None, bool | None] = (None, None)
297-
formatter_arguments = {}
283+
localizable_key: tuple[bool | None, bool | None] = (None, None)
284+
localizable_args = {}
298285

299286
if self.start:
300-
formatter_arguments["start_date"] = self.start.localize(localizer)
301-
formatter_configuration = (
287+
localizable_args["start_date"] = self.start.localize(localizer)
288+
localizable_key = (
302289
self.start_is_boundary,
303-
formatter_configuration[1],
290+
localizable_key[1],
304291
)
305292

306293
if self.end:
307-
formatter_arguments["end_date"] = self.end.localize(localizer)
308-
formatter_configuration = (
309-
formatter_configuration[0],
294+
localizable_args["end_date"] = self.end.localize(localizer)
295+
localizable_key = (
296+
localizable_key[0],
310297
self.end_is_boundary,
311298
)
312299

313300
return (
314301
self
315-
._localize_formats[formatter_configuration]
316-
.format(**formatter_arguments)
302+
._localizables[localizable_key]
303+
.format(**localizable_args)
317304
.localize(localizer)
318305
)
319306

320307
def __contains__(self, other: AnyDate) -> bool:
321-
if not self.normalized:
308+
if not self._comparable:
322309
return False
323310

324311
if isinstance(other, Date):

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies = [
2222
'aiohttp-client-cache ~= 0.14',
2323
'aiosqlite ~= 0.21',
2424
'babel ~= 2.16',
25+
'convertdate ~= 2.4',
2526
'docutils ~= 0.21',
2627
'geopy ~= 2.4',
2728
'jinja2 ~= 3.1',

0 commit comments

Comments
 (0)