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.
Files changed (50) hide show
  1. lexdays/__init__.py +53 -0
  2. lexdays/__main__.py +3 -0
  3. lexdays/_calendar.py +541 -0
  4. lexdays/_data/calendars/de-bb.json +23 -0
  5. lexdays/_data/calendars/de-be.json +56 -0
  6. lexdays/_data/calendars/de-bw.json +46 -0
  7. lexdays/_data/calendars/de-by-augsburg.json +23 -0
  8. lexdays/_data/calendars/de-by-catholic.json +23 -0
  9. lexdays/_data/calendars/de-by.json +60 -0
  10. lexdays/_data/calendars/de-hb.json +30 -0
  11. lexdays/_data/calendars/de-he.json +22 -0
  12. lexdays/_data/calendars/de-hh.json +24 -0
  13. lexdays/_data/calendars/de-mv.json +36 -0
  14. lexdays/_data/calendars/de-ni.json +24 -0
  15. lexdays/_data/calendars/de-nw.json +34 -0
  16. lexdays/_data/calendars/de-rp.json +34 -0
  17. lexdays/_data/calendars/de-sh.json +24 -0
  18. lexdays/_data/calendars/de-sl.json +46 -0
  19. lexdays/_data/calendars/de-sn.json +50 -0
  20. lexdays/_data/calendars/de-st.json +35 -0
  21. lexdays/_data/calendars/de-th.json +39 -0
  22. lexdays/_data/calendars/de.json +143 -0
  23. lexdays/_data/calendars/eu-cj.json +164 -0
  24. lexdays/_data/calendars/gb-eaw.json +220 -0
  25. lexdays/_data/calendars/tr.json +204 -0
  26. lexdays/_data/calendars/us-ma.json +69 -0
  27. lexdays/_data/calendars/us.json +245 -0
  28. lexdays/_data/i18n/messages.json +523 -0
  29. lexdays/_data/rulepacks/de/zpo.json +64 -0
  30. lexdays/_data/rulepacks/eu/cj.json +86 -0
  31. lexdays/_data/rulepacks/gb-eaw/cpr.json +537 -0
  32. lexdays/_data/rulepacks/tr/aym.json +81 -0
  33. lexdays/_data/rulepacks/tr/cmk.json +322 -0
  34. lexdays/_data/rulepacks/tr/hmk.json +440 -0
  35. lexdays/_data/rulepacks/tr/iik.json +124 -0
  36. lexdays/_data/rulepacks/tr/iyuk.json +357 -0
  37. lexdays/_data/rulepacks/tr/vuk.json +103 -0
  38. lexdays/_data/rulepacks/us/frcp.json +121 -0
  39. lexdays/_data.py +59 -0
  40. lexdays/_dates.py +130 -0
  41. lexdays/_i18n.py +55 -0
  42. lexdays/_rules.py +816 -0
  43. lexdays/cli.py +162 -0
  44. lexdays/errors.py +31 -0
  45. lexdays/py.typed +0 -0
  46. lexdays-0.1.0.dist-info/METADATA +78 -0
  47. lexdays-0.1.0.dist-info/RECORD +50 -0
  48. lexdays-0.1.0.dist-info/WHEEL +4 -0
  49. lexdays-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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
+ }