lexdays 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lexdays/__init__.py +53 -0
- lexdays/__main__.py +3 -0
- lexdays/_calendar.py +541 -0
- lexdays/_data/calendars/de-bb.json +23 -0
- lexdays/_data/calendars/de-be.json +56 -0
- lexdays/_data/calendars/de-bw.json +46 -0
- lexdays/_data/calendars/de-by-augsburg.json +23 -0
- lexdays/_data/calendars/de-by-catholic.json +23 -0
- lexdays/_data/calendars/de-by.json +60 -0
- lexdays/_data/calendars/de-hb.json +30 -0
- lexdays/_data/calendars/de-he.json +22 -0
- lexdays/_data/calendars/de-hh.json +24 -0
- lexdays/_data/calendars/de-mv.json +36 -0
- lexdays/_data/calendars/de-ni.json +24 -0
- lexdays/_data/calendars/de-nw.json +34 -0
- lexdays/_data/calendars/de-rp.json +34 -0
- lexdays/_data/calendars/de-sh.json +24 -0
- lexdays/_data/calendars/de-sl.json +46 -0
- lexdays/_data/calendars/de-sn.json +50 -0
- lexdays/_data/calendars/de-st.json +35 -0
- lexdays/_data/calendars/de-th.json +39 -0
- lexdays/_data/calendars/de.json +143 -0
- lexdays/_data/calendars/eu-cj.json +164 -0
- lexdays/_data/calendars/gb-eaw.json +220 -0
- lexdays/_data/calendars/tr.json +204 -0
- lexdays/_data/calendars/us-ma.json +69 -0
- lexdays/_data/calendars/us.json +245 -0
- lexdays/_data/i18n/messages.json +523 -0
- lexdays/_data/rulepacks/de/zpo.json +64 -0
- lexdays/_data/rulepacks/eu/cj.json +86 -0
- lexdays/_data/rulepacks/gb-eaw/cpr.json +537 -0
- lexdays/_data/rulepacks/tr/aym.json +81 -0
- lexdays/_data/rulepacks/tr/cmk.json +322 -0
- lexdays/_data/rulepacks/tr/hmk.json +440 -0
- lexdays/_data/rulepacks/tr/iik.json +124 -0
- lexdays/_data/rulepacks/tr/iyuk.json +357 -0
- lexdays/_data/rulepacks/tr/vuk.json +103 -0
- lexdays/_data/rulepacks/us/frcp.json +121 -0
- lexdays/_data.py +59 -0
- lexdays/_dates.py +130 -0
- lexdays/_i18n.py +55 -0
- lexdays/_rules.py +816 -0
- lexdays/cli.py +162 -0
- lexdays/errors.py +31 -0
- lexdays/py.typed +0 -0
- lexdays-0.1.0.dist-info/METADATA +78 -0
- lexdays-0.1.0.dist-info/RECORD +50 -0
- lexdays-0.1.0.dist-info/WHEEL +4 -0
- lexdays-0.1.0.dist-info/entry_points.txt +2 -0
- lexdays-0.1.0.dist-info/licenses/LICENSE +21 -0
lexdays/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""lexdays — explainable legal deadlines and business days.
|
|
2
|
+
|
|
3
|
+
>>> import lexdays
|
|
4
|
+
>>> cal = lexdays.calendar("us")
|
|
5
|
+
>>> cal.add_business_days("2026-07-02", 1).isoformat()
|
|
6
|
+
'2026-07-06'
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._calendar import Calendar, Holiday, calendar, list_calendars
|
|
12
|
+
from ._rules import (
|
|
13
|
+
Citation,
|
|
14
|
+
DeadlineResult,
|
|
15
|
+
Notice,
|
|
16
|
+
Period,
|
|
17
|
+
RulePack,
|
|
18
|
+
TraceStep,
|
|
19
|
+
compute,
|
|
20
|
+
list_rulepacks,
|
|
21
|
+
rulepack,
|
|
22
|
+
)
|
|
23
|
+
from .errors import (
|
|
24
|
+
CoverageError,
|
|
25
|
+
DataError,
|
|
26
|
+
LexdaysError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
OptionError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Calendar",
|
|
35
|
+
"Citation",
|
|
36
|
+
"CoverageError",
|
|
37
|
+
"DataError",
|
|
38
|
+
"DeadlineResult",
|
|
39
|
+
"Holiday",
|
|
40
|
+
"LexdaysError",
|
|
41
|
+
"NotFoundError",
|
|
42
|
+
"Notice",
|
|
43
|
+
"OptionError",
|
|
44
|
+
"Period",
|
|
45
|
+
"RulePack",
|
|
46
|
+
"TraceStep",
|
|
47
|
+
"__version__",
|
|
48
|
+
"calendar",
|
|
49
|
+
"compute",
|
|
50
|
+
"list_calendars",
|
|
51
|
+
"list_rulepacks",
|
|
52
|
+
"rulepack",
|
|
53
|
+
]
|
lexdays/__main__.py
ADDED
lexdays/_calendar.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""Holiday calendars defined as data, plus business-day arithmetic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import date, timedelta
|
|
9
|
+
from typing import Any, TypeAlias
|
|
10
|
+
|
|
11
|
+
from . import _data, _dates, _i18n
|
|
12
|
+
from .errors import CoverageError, DataError
|
|
13
|
+
|
|
14
|
+
DEFAULT_KINDS: tuple[str, ...] = ("public",)
|
|
15
|
+
HALF_DAY_POLICIES = ("working", "non_working")
|
|
16
|
+
|
|
17
|
+
_TIME = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
|
18
|
+
_KIND = re.compile(r"^[a-z][a-z_]*$")
|
|
19
|
+
_DIRECTIONS = ("on_or_after", "after", "on_or_before", "before")
|
|
20
|
+
_CALENDAR_KEYS = {
|
|
21
|
+
"$schema", "id", "name", "description", "extends", "weekend", "timezone",
|
|
22
|
+
"coverage", "sources", "holidays", "one_off", "remove", "notes",
|
|
23
|
+
}
|
|
24
|
+
_HOLIDAY_KEYS = {
|
|
25
|
+
"id", "name", "kind", "rule", "since", "until", "except_years",
|
|
26
|
+
"half_day", "observed", "source", "note",
|
|
27
|
+
}
|
|
28
|
+
_ONE_OFF_KEYS = {"id", "name", "date", "kind", "half_day", "source", "note"}
|
|
29
|
+
_RULE_KEYS = {
|
|
30
|
+
"fixed": {"month", "day"},
|
|
31
|
+
"nth_weekday": {"month", "weekday", "n"},
|
|
32
|
+
"easter": {"offset", "method"},
|
|
33
|
+
"dates": {"dates"},
|
|
34
|
+
"weekday_relative": {"month", "day", "weekday", "direction"},
|
|
35
|
+
}
|
|
36
|
+
_Date: TypeAlias = date # the Holiday.date field shadows the type inside the class
|
|
37
|
+
|
|
38
|
+
# Safety net: no real calendar has this many non-working days in a row.
|
|
39
|
+
_MAX_NON_WORKING_RUN = 1000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Holiday:
|
|
44
|
+
"""One holiday occurrence on a specific date."""
|
|
45
|
+
|
|
46
|
+
date: date
|
|
47
|
+
id: str
|
|
48
|
+
name: Mapping[str, str] = field(hash=False, compare=False)
|
|
49
|
+
kind: str = "public"
|
|
50
|
+
half_day_from: str | None = None
|
|
51
|
+
"""``"13:00"`` when the day is non-working only from that time on."""
|
|
52
|
+
observed_from: _Date | None = None
|
|
53
|
+
"""Original date, when this occurrence is an observed/substitute day."""
|
|
54
|
+
source: str | None = field(default=None, hash=False, compare=False)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def is_half_day(self) -> bool:
|
|
58
|
+
return self.half_day_from is not None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_observed(self) -> bool:
|
|
62
|
+
return self.observed_from is not None
|
|
63
|
+
|
|
64
|
+
def label(self, lang: str = "en") -> str:
|
|
65
|
+
if self.observed_from is None:
|
|
66
|
+
return _i18n.pick(self.name, lang)
|
|
67
|
+
return _i18n.pick(_i18n.render("holiday.observed", {"name": self.name}), lang)
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"date": self.date.isoformat(),
|
|
72
|
+
"id": self.id,
|
|
73
|
+
"name": dict(self.name),
|
|
74
|
+
"kind": self.kind,
|
|
75
|
+
"half_day_from": self.half_day_from,
|
|
76
|
+
"observed_from": self.observed_from.isoformat() if self.observed_from else None,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
Kinds: TypeAlias = str | Iterable[str] | None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _kinds(kinds: Kinds) -> frozenset[str]:
|
|
84
|
+
if kinds is None:
|
|
85
|
+
return frozenset(DEFAULT_KINDS)
|
|
86
|
+
if isinstance(kinds, str):
|
|
87
|
+
return frozenset((kinds,))
|
|
88
|
+
return frozenset(kinds)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _half(policy: str) -> str:
|
|
92
|
+
if policy not in HALF_DAY_POLICIES:
|
|
93
|
+
raise ValueError(f"half_days must be one of {HALF_DAY_POLICIES}, got {policy!r}")
|
|
94
|
+
return policy
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _int(value: Any, where: str, lo: int, hi: int) -> int:
|
|
98
|
+
if isinstance(value, bool) or not isinstance(value, int) or not lo <= value <= hi:
|
|
99
|
+
raise DataError(f"{where}: expected an integer in [{lo}, {hi}], got {value!r}")
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_keys(obj: Any, allowed: set[str], where: str) -> Mapping[str, Any]:
|
|
104
|
+
if not isinstance(obj, Mapping):
|
|
105
|
+
raise DataError(f"{where}: expected an object")
|
|
106
|
+
unknown = set(obj) - allowed
|
|
107
|
+
if unknown:
|
|
108
|
+
raise DataError(f"{where}: unknown field(s) {sorted(unknown)}")
|
|
109
|
+
return obj
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _check_name(value: Any, where: str) -> Mapping[str, str]:
|
|
113
|
+
if not isinstance(value, Mapping) or not value or not all(
|
|
114
|
+
isinstance(k, str) and isinstance(v, str) and v for k, v in value.items()
|
|
115
|
+
):
|
|
116
|
+
raise DataError(f"{where}: 'name' must be a non-empty {{lang: text}} object")
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check_half_day(value: Any, where: str) -> str | None:
|
|
121
|
+
if value is None:
|
|
122
|
+
return None
|
|
123
|
+
_check_keys(value, {"from"}, f"{where}.half_day")
|
|
124
|
+
start = value.get("from")
|
|
125
|
+
if not isinstance(start, str) or not _TIME.match(start):
|
|
126
|
+
raise DataError(f"{where}.half_day.from: expected HH:MM, got {start!r}")
|
|
127
|
+
return start
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Calendar:
|
|
131
|
+
"""A holiday calendar: weekend days plus holiday rules, all loaded from data.
|
|
132
|
+
|
|
133
|
+
Use :func:`lexdays.calendar` to load a bundled one by id, or pass a spec
|
|
134
|
+
dictionary to build your own.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, spec: Mapping[str, Any], *, parent: Calendar | None = None) -> None:
|
|
138
|
+
spec = _check_keys(spec, _CALENDAR_KEYS, "calendar")
|
|
139
|
+
cal_id = spec.get("id")
|
|
140
|
+
if not isinstance(cal_id, str) or not cal_id:
|
|
141
|
+
raise DataError("calendar: 'id' must be a non-empty string")
|
|
142
|
+
where = f"calendar {cal_id!r}"
|
|
143
|
+
self.id: str = cal_id
|
|
144
|
+
self.name: Mapping[str, str] = (
|
|
145
|
+
_check_name(spec["name"], where)
|
|
146
|
+
if "name" in spec
|
|
147
|
+
else (parent.name if parent else {"en": cal_id})
|
|
148
|
+
)
|
|
149
|
+
weekend = spec.get("weekend", sorted(parent.weekend) if parent else [6, 7])
|
|
150
|
+
if not isinstance(weekend, list) or len(weekend) > 6:
|
|
151
|
+
raise DataError(f"{where}: 'weekend' must be a list of at most 6 ISO weekdays")
|
|
152
|
+
self.weekend: frozenset[int] = frozenset(_int(d, f"{where}.weekend", 1, 7) for d in weekend)
|
|
153
|
+
self.timezone: str | None = spec.get("timezone", parent.timezone if parent else None)
|
|
154
|
+
self.coverage: tuple[int, int] | None = self._coverage(spec, parent, where)
|
|
155
|
+
self.sources: tuple[Any, ...] = tuple(spec.get("sources", ()))
|
|
156
|
+
|
|
157
|
+
own = [self._check_holiday(h, where) for h in spec.get("holidays", ())]
|
|
158
|
+
ids = [h["id"] for h in own]
|
|
159
|
+
if len(ids) != len(set(ids)):
|
|
160
|
+
raise DataError(f"{where}: duplicate holiday ids")
|
|
161
|
+
remove = set(spec.get("remove", ()))
|
|
162
|
+
inherited = [
|
|
163
|
+
h for h in (parent._specs if parent else ()) if h["id"] not in remove and h["id"] not in ids
|
|
164
|
+
]
|
|
165
|
+
self._specs: tuple[Mapping[str, Any], ...] = (*inherited, *own)
|
|
166
|
+
self._one_off: tuple[Holiday, ...] = (
|
|
167
|
+
*(parent._one_off if parent else ()),
|
|
168
|
+
*(self._check_one_off(o, where) for o in spec.get("one_off", ())),
|
|
169
|
+
)
|
|
170
|
+
self._cache: dict[int, tuple[Holiday, ...]] = {}
|
|
171
|
+
self._index: dict[int, dict[date, tuple[Holiday, ...]]] = {}
|
|
172
|
+
|
|
173
|
+
def __repr__(self) -> str:
|
|
174
|
+
return f"Calendar({self.id!r})"
|
|
175
|
+
|
|
176
|
+
# ----------------------------------------------------------------- loading
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _coverage(spec: Mapping[str, Any], parent: Calendar | None, where: str) -> tuple[int, int] | None:
|
|
180
|
+
if "coverage" not in spec:
|
|
181
|
+
return parent.coverage if parent else None
|
|
182
|
+
cov = _check_keys(spec["coverage"], {"from", "to"}, f"{where}.coverage")
|
|
183
|
+
lo = _int(cov.get("from"), f"{where}.coverage.from", _dates.MIN_YEAR + 1, _dates.MAX_YEAR - 1)
|
|
184
|
+
hi = _int(cov.get("to"), f"{where}.coverage.to", lo, _dates.MAX_YEAR - 1)
|
|
185
|
+
return (lo, hi)
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _check_holiday(spec: Any, where: str) -> Mapping[str, Any]:
|
|
189
|
+
spec = _check_keys(spec, _HOLIDAY_KEYS, f"{where}.holidays[]")
|
|
190
|
+
hid = spec.get("id")
|
|
191
|
+
if not isinstance(hid, str) or not hid:
|
|
192
|
+
raise DataError(f"{where}: every holiday needs an 'id'")
|
|
193
|
+
here = f"{where}.holidays[{hid}]"
|
|
194
|
+
_check_name(spec.get("name"), here)
|
|
195
|
+
kind = spec.get("kind", "public")
|
|
196
|
+
if not isinstance(kind, str) or not _KIND.match(kind):
|
|
197
|
+
raise DataError(f"{here}.kind: invalid kind {kind!r}")
|
|
198
|
+
rule = spec.get("rule")
|
|
199
|
+
if not isinstance(rule, Mapping) or rule.get("type") not in _RULE_KEYS:
|
|
200
|
+
raise DataError(f"{here}.rule.type must be one of {sorted(_RULE_KEYS)}")
|
|
201
|
+
rtype = rule["type"]
|
|
202
|
+
_check_keys(rule, _RULE_KEYS[rtype] | {"type"}, f"{here}.rule")
|
|
203
|
+
if rtype in ("fixed", "weekday_relative"):
|
|
204
|
+
month = _int(rule.get("month"), f"{here}.rule.month", 1, 12)
|
|
205
|
+
day = _int(rule.get("day"), f"{here}.rule.day", 1, 31)
|
|
206
|
+
if day > _dates.days_in_month(2000, month): # 2000 is a leap year
|
|
207
|
+
raise DataError(f"{here}.rule: {month}/{day} never exists")
|
|
208
|
+
if rtype in ("nth_weekday", "weekday_relative"):
|
|
209
|
+
_int(rule.get("weekday"), f"{here}.rule.weekday", 1, 7)
|
|
210
|
+
if rtype == "nth_weekday":
|
|
211
|
+
_int(rule.get("month"), f"{here}.rule.month", 1, 12)
|
|
212
|
+
n = _int(rule.get("n"), f"{here}.rule.n", -5, 5)
|
|
213
|
+
if n == 0:
|
|
214
|
+
raise DataError(f"{here}.rule.n must not be 0")
|
|
215
|
+
if rtype == "weekday_relative" and rule.get("direction") not in _DIRECTIONS:
|
|
216
|
+
raise DataError(f"{here}.rule.direction must be one of {_DIRECTIONS}")
|
|
217
|
+
if rtype == "easter":
|
|
218
|
+
_int(rule.get("offset", 0), f"{here}.rule.offset", -200, 200)
|
|
219
|
+
if rule.get("method", "western") not in ("western", "orthodox"):
|
|
220
|
+
raise DataError(f"{here}.rule.method must be 'western' or 'orthodox'")
|
|
221
|
+
if rtype == "dates":
|
|
222
|
+
values = rule.get("dates")
|
|
223
|
+
if not isinstance(values, list) or not values:
|
|
224
|
+
raise DataError(f"{here}.rule.dates must be a non-empty list")
|
|
225
|
+
parsed = [_dates.parse_date(v, field=f"{here}.rule.dates") for v in values]
|
|
226
|
+
if len(parsed) != len(set(parsed)):
|
|
227
|
+
raise DataError(f"{here}.rule.dates contains duplicates")
|
|
228
|
+
spec = {**spec, "_parsed": tuple(sorted(parsed))}
|
|
229
|
+
for key in ("since", "until"):
|
|
230
|
+
if key in spec and spec[key] is not None:
|
|
231
|
+
_int(spec[key], f"{here}.{key}", _dates.MIN_YEAR, _dates.MAX_YEAR)
|
|
232
|
+
for year in spec.get("except_years", ()):
|
|
233
|
+
_int(year, f"{here}.except_years", _dates.MIN_YEAR, _dates.MAX_YEAR)
|
|
234
|
+
checked: dict[str, Any] = {**spec, "_half": _check_half_day(spec.get("half_day"), here)}
|
|
235
|
+
observed = checked.get("observed")
|
|
236
|
+
if observed is not None:
|
|
237
|
+
_check_keys(observed, {"shift", "avoid_collision", "kind"}, f"{here}.observed")
|
|
238
|
+
obs_kind = observed.get("kind")
|
|
239
|
+
kinds_given = (
|
|
240
|
+
[obs_kind] if isinstance(obs_kind, str)
|
|
241
|
+
else list(obs_kind.values()) if isinstance(obs_kind, Mapping)
|
|
242
|
+
else [] if obs_kind is None else [None]
|
|
243
|
+
)
|
|
244
|
+
for value in kinds_given:
|
|
245
|
+
if not isinstance(value, str) or not _KIND.match(value):
|
|
246
|
+
raise DataError(f"{here}.observed.kind must be a kind or a {{weekday: kind}} object")
|
|
247
|
+
shift = observed.get("shift")
|
|
248
|
+
if not isinstance(shift, Mapping) or not shift:
|
|
249
|
+
raise DataError(f"{here}.observed.shift must map ISO weekdays to day offsets")
|
|
250
|
+
for wd, offset in shift.items():
|
|
251
|
+
_int(int(wd) if str(wd).isdigit() else -1, f"{here}.observed.shift key", 1, 7)
|
|
252
|
+
_int(offset, f"{here}.observed.shift[{wd}]", -6, 6)
|
|
253
|
+
return checked
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _check_one_off(spec: Any, where: str) -> Holiday:
|
|
257
|
+
spec = _check_keys(spec, _ONE_OFF_KEYS, f"{where}.one_off[]")
|
|
258
|
+
oid = spec.get("id")
|
|
259
|
+
if not isinstance(oid, str) or not oid:
|
|
260
|
+
raise DataError(f"{where}: every one-off holiday needs an 'id'")
|
|
261
|
+
here = f"{where}.one_off[{oid}]"
|
|
262
|
+
kind = spec.get("kind", "public")
|
|
263
|
+
if not isinstance(kind, str) or not _KIND.match(kind):
|
|
264
|
+
raise DataError(f"{here}.kind: invalid kind {kind!r}")
|
|
265
|
+
return Holiday(
|
|
266
|
+
date=_dates.parse_date(spec.get("date"), field=f"{here}.date"),
|
|
267
|
+
id=oid,
|
|
268
|
+
name=_check_name(spec.get("name"), here),
|
|
269
|
+
kind=kind,
|
|
270
|
+
half_day_from=_check_half_day(spec.get("half_day"), here),
|
|
271
|
+
source=spec.get("source"),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------- holiday lists
|
|
275
|
+
|
|
276
|
+
def _check_coverage(self, year: int) -> None:
|
|
277
|
+
if self.coverage is not None and not self.coverage[0] <= year <= self.coverage[1]:
|
|
278
|
+
lo, hi = self.coverage
|
|
279
|
+
raise CoverageError(
|
|
280
|
+
f"calendar {self.id!r} has verified holiday data for {lo}–{hi} only; "
|
|
281
|
+
f"{year} is outside that range"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _raw_dates(spec: Mapping[str, Any], year: int) -> list[date]:
|
|
286
|
+
if spec.get("since") is not None and year < spec["since"]:
|
|
287
|
+
return []
|
|
288
|
+
if spec.get("until") is not None and year > spec["until"]:
|
|
289
|
+
return []
|
|
290
|
+
if year in spec.get("except_years", ()):
|
|
291
|
+
return []
|
|
292
|
+
rule = spec["rule"]
|
|
293
|
+
rtype = rule["type"]
|
|
294
|
+
if rtype == "fixed":
|
|
295
|
+
if rule["day"] > _dates.days_in_month(year, rule["month"]):
|
|
296
|
+
return [] # 29 February outside leap years
|
|
297
|
+
return [date(year, rule["month"], rule["day"])]
|
|
298
|
+
if rtype == "nth_weekday":
|
|
299
|
+
found = _dates.nth_weekday(year, rule["month"], rule["weekday"], rule["n"])
|
|
300
|
+
return [found] if found else []
|
|
301
|
+
if rtype == "easter":
|
|
302
|
+
base = (
|
|
303
|
+
_dates.easter_orthodox(year)
|
|
304
|
+
if rule.get("method") == "orthodox"
|
|
305
|
+
else _dates.easter_western(year)
|
|
306
|
+
)
|
|
307
|
+
return [base + timedelta(days=rule.get("offset", 0))]
|
|
308
|
+
if rtype == "dates":
|
|
309
|
+
return [d for d in spec["_parsed"] if d.year == year]
|
|
310
|
+
# weekday_relative
|
|
311
|
+
if rule["day"] > _dates.days_in_month(year, rule["month"]):
|
|
312
|
+
return []
|
|
313
|
+
anchor = date(year, rule["month"], rule["day"])
|
|
314
|
+
return [_dates.weekday_relative(anchor, rule["weekday"], rule["direction"])]
|
|
315
|
+
|
|
316
|
+
def _year(self, year: int) -> tuple[Holiday, ...]:
|
|
317
|
+
cached = self._cache.get(year)
|
|
318
|
+
if cached is not None:
|
|
319
|
+
return cached
|
|
320
|
+
pairs: list[tuple[Holiday, Mapping[str, Any] | None]] = []
|
|
321
|
+
# Neighbouring years are included because observed days can cross
|
|
322
|
+
# 1 January (e.g. a Saturday New Year observed on 31 December).
|
|
323
|
+
for y in (year - 1, year, year + 1):
|
|
324
|
+
if not _dates.MIN_YEAR < y < _dates.MAX_YEAR:
|
|
325
|
+
continue
|
|
326
|
+
for spec in self._specs:
|
|
327
|
+
for day in self._raw_dates(spec, y):
|
|
328
|
+
holiday = Holiday(
|
|
329
|
+
date=day,
|
|
330
|
+
id=spec["id"],
|
|
331
|
+
name=spec["name"],
|
|
332
|
+
kind=spec.get("kind", "public"),
|
|
333
|
+
half_day_from=spec["_half"],
|
|
334
|
+
source=spec.get("source"),
|
|
335
|
+
)
|
|
336
|
+
pairs.append((holiday, spec))
|
|
337
|
+
pairs.extend((o, None) for o in self._one_off if o.date.year == y)
|
|
338
|
+
pairs.sort(key=lambda p: (p[0].date, p[0].id))
|
|
339
|
+
|
|
340
|
+
taken: dict[str, set[date]] = {}
|
|
341
|
+
for holiday, _ in pairs:
|
|
342
|
+
taken.setdefault(holiday.kind, set()).add(holiday.date)
|
|
343
|
+
observed: list[Holiday] = []
|
|
344
|
+
for holiday, holiday_spec in pairs:
|
|
345
|
+
rule = holiday_spec.get("observed") if holiday_spec else None
|
|
346
|
+
if not rule:
|
|
347
|
+
continue
|
|
348
|
+
offset = rule["shift"].get(str(holiday.date.isoweekday()))
|
|
349
|
+
if not offset:
|
|
350
|
+
continue
|
|
351
|
+
target = holiday.date + timedelta(days=offset)
|
|
352
|
+
if rule.get("avoid_collision"):
|
|
353
|
+
step = 1 if offset > 0 else -1
|
|
354
|
+
while target.isoweekday() in self.weekend or target in taken[holiday.kind]:
|
|
355
|
+
target += timedelta(days=step)
|
|
356
|
+
taken[holiday.kind].add(target)
|
|
357
|
+
obs_kind = rule.get("kind")
|
|
358
|
+
if isinstance(obs_kind, Mapping):
|
|
359
|
+
obs_kind = obs_kind.get(str(holiday.date.isoweekday()))
|
|
360
|
+
observed.append(
|
|
361
|
+
Holiday(
|
|
362
|
+
date=target,
|
|
363
|
+
id=holiday.id,
|
|
364
|
+
name=holiday.name,
|
|
365
|
+
kind=obs_kind or holiday.kind,
|
|
366
|
+
half_day_from=holiday.half_day_from,
|
|
367
|
+
observed_from=holiday.date,
|
|
368
|
+
source=holiday.source,
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
result = tuple(
|
|
372
|
+
sorted(
|
|
373
|
+
(h for h in [*(p[0] for p in pairs), *observed] if h.date.year == year),
|
|
374
|
+
key=lambda h: (h.date, h.observed_from is not None, h.id),
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
self._cache[year] = result
|
|
378
|
+
index: dict[date, list[Holiday]] = {}
|
|
379
|
+
for h in result:
|
|
380
|
+
index.setdefault(h.date, []).append(h)
|
|
381
|
+
self._index[year] = {d: tuple(v) for d, v in index.items()}
|
|
382
|
+
return result
|
|
383
|
+
|
|
384
|
+
def holidays(self, year: int, *, kinds: Kinds = "all") -> list[Holiday]:
|
|
385
|
+
"""All holidays in ``year``. ``kinds="all"`` (default) includes every kind."""
|
|
386
|
+
year = _int(year, "year", _dates.MIN_YEAR + 1, _dates.MAX_YEAR - 1)
|
|
387
|
+
self._check_coverage(year)
|
|
388
|
+
items = self._year(year)
|
|
389
|
+
if kinds == "all":
|
|
390
|
+
return list(items)
|
|
391
|
+
wanted = _kinds(kinds)
|
|
392
|
+
return [h for h in items if h.kind in wanted]
|
|
393
|
+
|
|
394
|
+
def holidays_on(self, day: date | str, *, kinds: Kinds = "all") -> list[Holiday]:
|
|
395
|
+
day = _dates.parse_date(day)
|
|
396
|
+
self._check_coverage(day.year)
|
|
397
|
+
self._year(day.year)
|
|
398
|
+
items = self._index[day.year].get(day, ())
|
|
399
|
+
if kinds == "all":
|
|
400
|
+
return list(items)
|
|
401
|
+
wanted = _kinds(kinds)
|
|
402
|
+
return [h for h in items if h.kind in wanted]
|
|
403
|
+
|
|
404
|
+
# ------------------------------------------------------------ business days
|
|
405
|
+
|
|
406
|
+
def is_weekend(self, day: date | str) -> bool:
|
|
407
|
+
return _dates.parse_date(day).isoweekday() in self.weekend
|
|
408
|
+
|
|
409
|
+
def non_working_reason(
|
|
410
|
+
self,
|
|
411
|
+
day: date | str,
|
|
412
|
+
*,
|
|
413
|
+
kinds: Kinds = None,
|
|
414
|
+
half_days: str = "working",
|
|
415
|
+
weekend: bool = True,
|
|
416
|
+
) -> Holiday | str | None:
|
|
417
|
+
"""Why ``day`` is not a working day: a :class:`Holiday`, ``"weekend"``, or ``None``."""
|
|
418
|
+
day = _dates.parse_date(day)
|
|
419
|
+
policy = _half(half_days)
|
|
420
|
+
for holiday in self.holidays_on(day, kinds=_kinds(kinds)):
|
|
421
|
+
if not holiday.is_half_day or policy == "non_working":
|
|
422
|
+
return holiday
|
|
423
|
+
if weekend and day.isoweekday() in self.weekend:
|
|
424
|
+
return "weekend"
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
def is_business_day(
|
|
428
|
+
self,
|
|
429
|
+
day: date | str,
|
|
430
|
+
*,
|
|
431
|
+
kinds: Kinds = None,
|
|
432
|
+
half_days: str = "working",
|
|
433
|
+
weekend: bool = True,
|
|
434
|
+
) -> bool:
|
|
435
|
+
"""``True`` unless ``day`` is a weekend day or a holiday of one of ``kinds``.
|
|
436
|
+
|
|
437
|
+
``half_days="working"`` (default) treats half-day holidays such as the
|
|
438
|
+
afternoon of an eve as working days.
|
|
439
|
+
"""
|
|
440
|
+
return self.non_working_reason(day, kinds=kinds, half_days=half_days, weekend=weekend) is None
|
|
441
|
+
|
|
442
|
+
def _step(self, day: date, direction: int, inclusive: bool, **opts: Any) -> date:
|
|
443
|
+
current = day if inclusive else day + timedelta(days=direction)
|
|
444
|
+
for _ in range(_MAX_NON_WORKING_RUN):
|
|
445
|
+
if self.is_business_day(current, **opts):
|
|
446
|
+
return current
|
|
447
|
+
current += timedelta(days=direction)
|
|
448
|
+
raise DataError(f"calendar {self.id!r}: no working day within {_MAX_NON_WORKING_RUN} days of {day}")
|
|
449
|
+
|
|
450
|
+
def next_business_day(
|
|
451
|
+
self, day: date | str, *, inclusive: bool = False, kinds: Kinds = None,
|
|
452
|
+
half_days: str = "working", weekend: bool = True,
|
|
453
|
+
) -> date:
|
|
454
|
+
"""First working day after ``day`` (or on it, with ``inclusive=True``)."""
|
|
455
|
+
return self._step(
|
|
456
|
+
_dates.parse_date(day), 1, inclusive, kinds=kinds, half_days=half_days, weekend=weekend
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
def previous_business_day(
|
|
460
|
+
self, day: date | str, *, inclusive: bool = False, kinds: Kinds = None,
|
|
461
|
+
half_days: str = "working", weekend: bool = True,
|
|
462
|
+
) -> date:
|
|
463
|
+
"""Last working day before ``day`` (or on it, with ``inclusive=True``)."""
|
|
464
|
+
return self._step(
|
|
465
|
+
_dates.parse_date(day), -1, inclusive, kinds=kinds, half_days=half_days, weekend=weekend
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def add_business_days(
|
|
469
|
+
self, day: date | str, n: int, *, kinds: Kinds = None,
|
|
470
|
+
half_days: str = "working", weekend: bool = True,
|
|
471
|
+
) -> date:
|
|
472
|
+
"""Move ``n`` working days from ``day`` (negative ``n`` moves backwards).
|
|
473
|
+
|
|
474
|
+
The start day itself is never counted; ``n=0`` returns ``day`` unchanged.
|
|
475
|
+
"""
|
|
476
|
+
current = _dates.parse_date(day)
|
|
477
|
+
if isinstance(n, bool) or not isinstance(n, int):
|
|
478
|
+
raise TypeError(f"n must be an int, got {type(n).__name__}")
|
|
479
|
+
direction = 1 if n > 0 else -1
|
|
480
|
+
for _ in range(abs(n)):
|
|
481
|
+
current = self._step(
|
|
482
|
+
current, direction, False, kinds=kinds, half_days=half_days, weekend=weekend
|
|
483
|
+
)
|
|
484
|
+
return current
|
|
485
|
+
|
|
486
|
+
def business_days_between(
|
|
487
|
+
self, start: date | str, end: date | str, *, kinds: Kinds = None,
|
|
488
|
+
half_days: str = "working", weekend: bool = True,
|
|
489
|
+
) -> int:
|
|
490
|
+
"""Signed number of working days from ``start`` to ``end``.
|
|
491
|
+
|
|
492
|
+
Counts working days ``d`` with ``start < d <= end`` when ``end`` is
|
|
493
|
+
after ``start``, and minus the working days with ``end <= d < start``
|
|
494
|
+
when it is before. This makes it the inverse of
|
|
495
|
+
:meth:`add_business_days`: ``add_business_days(start,
|
|
496
|
+
business_days_between(start, end)) == end`` whenever ``end`` is a
|
|
497
|
+
working day, in both directions.
|
|
498
|
+
"""
|
|
499
|
+
a, b = _dates.parse_date(start, field="start"), _dates.parse_date(end, field="end")
|
|
500
|
+
opts: dict[str, Any] = {"kinds": kinds, "half_days": half_days, "weekend": weekend}
|
|
501
|
+
if b >= a:
|
|
502
|
+
days = (a + timedelta(days=i) for i in range(1, (b - a).days + 1))
|
|
503
|
+
return sum(1 for d in days if self.is_business_day(d, **opts))
|
|
504
|
+
days = (b + timedelta(days=i) for i in range((a - b).days))
|
|
505
|
+
return -sum(1 for d in days if self.is_business_day(d, **opts))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# --------------------------------------------------------------------- registry
|
|
509
|
+
|
|
510
|
+
_loaded: dict[str, Calendar] = {}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def load_calendar(cal_id: str, _chain: tuple[str, ...] = ()) -> Calendar:
|
|
514
|
+
"""Load a bundled calendar by id (cached)."""
|
|
515
|
+
if cal_id in _loaded:
|
|
516
|
+
return _loaded[cal_id]
|
|
517
|
+
if cal_id in _chain:
|
|
518
|
+
raise DataError(f"calendar inheritance cycle: {' -> '.join((*_chain, cal_id))}")
|
|
519
|
+
spec = _data.load("calendars", cal_id)
|
|
520
|
+
parent_id = spec.get("extends")
|
|
521
|
+
parent = load_calendar(parent_id, (*_chain, cal_id)) if parent_id else None
|
|
522
|
+
cal = Calendar(spec, parent=parent)
|
|
523
|
+
_loaded[cal_id] = cal
|
|
524
|
+
return cal
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def calendar(value: str | Mapping[str, Any] | Calendar) -> Calendar:
|
|
528
|
+
"""Return a :class:`Calendar` from an id, a spec dictionary, or a calendar."""
|
|
529
|
+
if isinstance(value, Calendar):
|
|
530
|
+
return value
|
|
531
|
+
if isinstance(value, str):
|
|
532
|
+
return load_calendar(value)
|
|
533
|
+
if isinstance(value, Mapping):
|
|
534
|
+
parent_id = value.get("extends")
|
|
535
|
+
return Calendar(value, parent=load_calendar(parent_id) if parent_id else None)
|
|
536
|
+
raise TypeError(f"expected a calendar id, spec or Calendar, got {type(value).__name__}")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def list_calendars() -> list[str]:
|
|
540
|
+
"""Ids of all bundled calendars."""
|
|
541
|
+
return _data.list_ids("calendars")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../spec/calendar.schema.json",
|
|
3
|
+
"id": "de-bb",
|
|
4
|
+
"extends": "de",
|
|
5
|
+
"name": {
|
|
6
|
+
"en": "Germany — Brandenburg",
|
|
7
|
+
"de": "Deutschland — Brandenburg"
|
|
8
|
+
},
|
|
9
|
+
"holidays": [
|
|
10
|
+
{
|
|
11
|
+
"id": "reformation_day",
|
|
12
|
+
"name": {
|
|
13
|
+
"en": "Reformation Day",
|
|
14
|
+
"de": "Reformationstag"
|
|
15
|
+
},
|
|
16
|
+
"rule": {
|
|
17
|
+
"type": "fixed",
|
|
18
|
+
"month": 10,
|
|
19
|
+
"day": 31
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../spec/calendar.schema.json",
|
|
3
|
+
"id": "de-be",
|
|
4
|
+
"extends": "de",
|
|
5
|
+
"name": {
|
|
6
|
+
"en": "Germany — Berlin",
|
|
7
|
+
"de": "Deutschland — Berlin"
|
|
8
|
+
},
|
|
9
|
+
"holidays": [
|
|
10
|
+
{
|
|
11
|
+
"id": "womens_day",
|
|
12
|
+
"name": {
|
|
13
|
+
"en": "International Women's Day",
|
|
14
|
+
"de": "Internationaler Frauentag"
|
|
15
|
+
},
|
|
16
|
+
"rule": {
|
|
17
|
+
"type": "fixed",
|
|
18
|
+
"month": 3,
|
|
19
|
+
"day": 8
|
|
20
|
+
},
|
|
21
|
+
"since": 2019
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"one_off": [
|
|
25
|
+
{
|
|
26
|
+
"id": "liberation_day",
|
|
27
|
+
"name": {
|
|
28
|
+
"en": "75th anniversary of the end of the Second World War",
|
|
29
|
+
"de": "Tag der Befreiung"
|
|
30
|
+
},
|
|
31
|
+
"date": "2020-05-08"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "liberation_day",
|
|
35
|
+
"name": {
|
|
36
|
+
"en": "80th anniversary of the end of the Second World War",
|
|
37
|
+
"de": "Tag der Befreiung"
|
|
38
|
+
},
|
|
39
|
+
"date": "2025-05-08"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "uprising_anniversary",
|
|
43
|
+
"name": {
|
|
44
|
+
"en": "75th anniversary of the 1953 uprising",
|
|
45
|
+
"de": "75. Jahrestag des Volksaufstands vom 17. Juni 1953"
|
|
46
|
+
},
|
|
47
|
+
"date": "2028-06-17"
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"sources": [
|
|
51
|
+
{
|
|
52
|
+
"title": "Berlin: einmaliger Feiertag am 8. Mai 2025",
|
|
53
|
+
"url": "https://www.berlin.de/aktuelles/9188766-958090-einmaliger-feiertag-am-8-mai-2025.html"
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|