bell-schedule 0.7.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.
@@ -0,0 +1,241 @@
1
+ Metadata-Version: 2.4
2
+ Name: bell-schedule
3
+ Version: 0.7.0
4
+ Summary: A framework-agnostic library for querying school bell schedules (Python port)
5
+ Author: Peter Seibel
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/gigamonkey/bells
8
+ Project-URL: Repository, https://github.com/gigamonkey/bells
9
+ Project-URL: Bug Tracker, https://github.com/gigamonkey/bells/issues
10
+ Keywords: bell schedule,school,calendar,timetable,bells,education
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest; extra == "test"
26
+ Dynamic: license-file
27
+
28
+ # bells (Python)
29
+
30
+ A framework-agnostic Python library for querying school bell schedules. This is
31
+ a port of the [`@peterseibel/bells`](https://github.com/gigamonkey/bells/tree/main/libs/ts) JavaScript library, kept
32
+ behaviorally identical to it.
33
+
34
+ Where the JS library is built on the [Temporal API](https://tc39.es/proposal-temporal/),
35
+ this port uses only the Python standard library:
36
+
37
+ | Temporal | Python |
38
+ | ----------------------- | ------------------------------------------ |
39
+ | `Temporal.PlainDate` | `datetime.date` |
40
+ | `Temporal.PlainTime` | `datetime.time` |
41
+ | `Temporal.Instant` | timezone-aware `datetime.datetime` in UTC |
42
+ | `Temporal.Duration` | `datetime.timedelta` |
43
+ | `Temporal.PlainDateTime`| naive `datetime.datetime` |
44
+
45
+ An "instant" throughout the package is an aware `datetime` normalized to UTC.
46
+
47
+ Requires Python 3.9+ (uses `zoneinfo`). No third-party dependencies.
48
+
49
+ ## Calendar data format
50
+
51
+ The calendar data format is identical to the JS library — see [`libs/ts/README.md`](https://github.com/gigamonkey/bells/blob/main/libs/ts/README.md)
52
+ for the full field reference. Briefly, calendar data is a list of year objects:
53
+
54
+ ```json
55
+ [
56
+ {
57
+ "year": "2025-2026",
58
+ "id": "bhs",
59
+ "name": "Berkeley High School",
60
+ "timezone": "America/Los_Angeles",
61
+ "firstDay": "2025-08-13",
62
+ "firstDayTeachers": "2025-08-11",
63
+ "lastDay": "2026-06-04",
64
+ "schedules": {
65
+ "NORMAL": [
66
+ { "name": "Period 1", "start": "8:30", "end": "9:28" },
67
+ { "name": "Period 2", "start": "9:34", "end": "10:37" }
68
+ ]
69
+ },
70
+ "weekdaySchedules": { "monday": "LATE_START" },
71
+ "holidays": ["2025-09-01"]
72
+ }
73
+ ]
74
+ ```
75
+
76
+ ## API
77
+
78
+ The public API mirrors the JS one, with method names converted to
79
+ `snake_case` and options passed as a dict with snake_case keys (`role`,
80
+ `include_tags`).
81
+
82
+ ```python
83
+ import json
84
+ from bells import BellSchedule
85
+
86
+ with open("my-calendars.json") as f:
87
+ data = json.load(f)
88
+
89
+ bells = BellSchedule(data, {
90
+ "role": "student", # "student" | "teacher" (default: "student")
91
+ "include_tags": {
92
+ 1: ["seventh"], # Monday: include Period 7
93
+ 2: ["zero", "seventh"],
94
+ },
95
+ # Or a flat list for the same tags every weekday:
96
+ # "include_tags": ["seventh"],
97
+
98
+ # Which periods are "numbered" and what number they carry, for the
99
+ # abstract-time API. Default: match r"^Period (\d+)\b" in the name.
100
+ # "period_number": lambda period: ...int or None...,
101
+ })
102
+
103
+ # What's happening right now?
104
+ interval = bells.current_interval()
105
+ print(interval.name) # e.g. "Period 3"
106
+ print(interval.type) # 'period' | 'passing' | 'before-school' | 'after-school' | 'break'
107
+ print(interval.left()) # timedelta until end of interval
108
+
109
+ # Other queries (all accept an optional aware datetime instant):
110
+ bells.period_at() # Interval | None (None unless a period)
111
+ bells.is_school_day() # bool (accepts an optional date)
112
+ bells.current_day_bounds() # {"start": ..., "end": ...} | None
113
+ bells.next_school_day_start() # datetime
114
+ bells.previous_school_day_end() # datetime
115
+ bells.school_time_left() # timedelta
116
+ bells.school_time_done() # timedelta
117
+ bells.total_school_time() # timedelta
118
+ bells.school_days_left() # int
119
+ bells.calendar_days_left() # int
120
+ bells.next_year_start() # datetime (raises if not loaded)
121
+ bells.school_time_between(a, b) # timedelta
122
+ bells.summer_bounds() # {"start": ..., "end": ...} | None
123
+ ```
124
+
125
+ ### Abstract times
126
+
127
+ An *abstract time* describes a moment relative to the schedule — "five minutes
128
+ before the end of the period", "start of school next Monday" — rather than as a
129
+ wall-clock time. It has three independent parts: a *day spec* (which date,
130
+ possibly relative to a base date), a *time anchor* (a schedule-defined point in
131
+ that day: `start_of_period`, `end_of_period`, `start_of_day`, `end_of_day`, or
132
+ `midnight`), and a signed `HH:MM` offset.
133
+
134
+ Resolution happens in two phases, so the period can stay unbound until query
135
+ time (a stored "start of period" resolves differently for a period-2 class than
136
+ a period-5 class). The types are plain dicts; `parse_time`/`format_time` need no
137
+ calendar.
138
+
139
+ ```python
140
+ from bells import parse_time, format_time
141
+
142
+ # Standalone — no calendar needed:
143
+ t = parse_time("end_of_period -00:05 +1 day")
144
+ format_time(t) # canonical round-trip
145
+
146
+ # Phase 1 (load time): bind the day spec against a base date. Warnings for
147
+ # specs that don't make sense against the calendar (e.g. a school anchor on a
148
+ # holiday) are reported via the callback (default: print to stderr).
149
+ bound = bells.bind_time(base_date, t, lambda warning: print(warning))
150
+ # → {"date": "2026-01-06", "anchor": "end_of_period", "offset": "-00:05"}
151
+
152
+ # Phase 2 (query time): resolve to a concrete moment (an aware datetime in the
153
+ # schedule's timezone), supplying the period if the anchor needs one. None when
154
+ # the date has no schedule or no such period.
155
+ bells.resolve_time(bound, 3) # datetime | None
156
+
157
+ # Pieces of the above, usable directly:
158
+ bells.resolve_day(base_date, t.get("day")) # date
159
+ bells.time_warnings(bound) # list[str] (empty = OK)
160
+ bells.add_school_days(d, 3) # n school days out (n may be negative)
161
+ bells.period_on_date(d, 3) # period dict | None
162
+ bells.current_or_next_period_number() # int | None
163
+ ```
164
+
165
+ The string syntax is `anchor [time-offset] [day-part]`, whitespace-separated
166
+ and case-insensitive (e.g. `end_of_period -00:05`, `start_of_day next week`,
167
+ `end_of_day +1 day`, `midnight +1 week`, `start_of_day 2026-01-05`). Day-part
168
+ semantics: `±N day(s)` counts *school* days; `±N week(s)` is literal calendar
169
+ arithmetic (no snapping); a weekday name means the first such day strictly after
170
+ the base date, taken literally even if it's a holiday; the week boundaries
171
+ (`start of [next] week`, `end of [next] week`) snap to the first/last school day
172
+ of the ISO week. `start of week` on a week with no school days advances to the
173
+ first day back (with a warning); `end of week` on such a week raises. Resolution
174
+ that runs past the loaded calendars raises an `IndexError`.
175
+
176
+ ### `Calendars`
177
+
178
+ For loading per-year JSON files from a directory or URL:
179
+
180
+ ```python
181
+ from bells import Calendars
182
+
183
+ calendars = Calendars("./calendars/") # or "https://example.com/calendars/"
184
+
185
+ bells = calendars.for_year("2025-2026", options)
186
+ bells = calendars.current(options) # handles summer automatically
187
+ ```
188
+
189
+ Files must be named `{year}.json` (e.g. `2025-2026.json`). Local paths are read
190
+ from disk; URL bases are fetched with `urllib`.
191
+
192
+ ### `bhs-calendars` (bundled BHS data)
193
+
194
+ As an alternative to supplying your own `{year}.json` files, the companion
195
+ `bhs-calendars` package ships ready-to-use calendar data for Berkeley High and
196
+ nearby middle schools. `by_id()` groups the bundled years by school (each
197
+ group's years sorted chronologically); hand one group straight to
198
+ `BellSchedule`:
199
+
200
+ ```python
201
+ from bhs_calendars import by_id, load_all
202
+ from bells import BellSchedule
203
+
204
+ years = by_id()["bhs"] # one school's years, oldest first
205
+ bells = BellSchedule(years, options)
206
+
207
+ all_years = load_all() # or the flat list of every school-year
208
+ ```
209
+
210
+ Install it alongside the library (`pip install bhs-calendars`). Unlike
211
+ `Calendars`, the data is bundled with the package — no filesystem layout or
212
+ network access — but it only covers the BHS-area schools. Equivalent data
213
+ packages exist for the [TypeScript](https://github.com/gigamonkey/bells/tree/main/libs/ts) (`@peterseibel/bhs-calendars` on npm)
214
+ and [Java](https://github.com/gigamonkey/bells/tree/main/libs/java) (`com.gigamonkeys:bhs-calendars`) ports.
215
+
216
+ ### Validation
217
+
218
+ ```python
219
+ from bells import validate_calendar_data
220
+
221
+ result = validate_calendar_data(data)
222
+ if not result["valid"]:
223
+ print(result["errors"])
224
+ ```
225
+
226
+ CLI:
227
+
228
+ ```sh
229
+ bells-validate calendars.json # after `pip install .`
230
+ python -m bells.cli calendars.json # without installing
231
+ ```
232
+
233
+ ## Tests
234
+
235
+ ```sh
236
+ cd libs/python
237
+ python -m pytest
238
+ ```
239
+
240
+ The test suite is a port of the JS test suite. A behavioral cross-check against
241
+ the JS library on the real BHS calendars produces identical results.
@@ -0,0 +1,14 @@
1
+ bell_schedule-0.7.0.dist-info/licenses/LICENSE,sha256=SYzcv7abfLjQvCJUokP_qhSM3iCV0tHd6mXx3UlrtZA,1074
2
+ bells/__init__.py,sha256=wwbkgA9LDtq93OSXsAoWM4tWV8WXJq6VhLSmIg-uDgs,721
3
+ bells/abstract_time.py,sha256=bGpzUTYi2Hq8u8Z1MmSBiDmzWjk25BfryjY9hPhtSbY,6567
4
+ bells/bell_schedule.py,sha256=HVwRZPPXcWJmuH3RShiJgoPbjRfwRAcbphNcwaWhQvI,19425
5
+ bells/calendar.py,sha256=8ngDw7O3fljNtXwCcZhObp8-dUrQXgYc1GlqCRjDXlY,17961
6
+ bells/calendars.py,sha256=AIFt3wwegeUIwmwCWGnIAMXC0w4QdtTVVyIQ2oAZ_P8,3927
7
+ bells/cli.py,sha256=6fDCwha7F9FfZUuj0UJapJsm3Hx5-sLqBbEGKYDI7Ks,1666
8
+ bells/datetimeutil.py,sha256=XQUOYRJsnk7Xzq3xnkUybJoq9QZvxPpsVlZOVhZ5cWw,6135
9
+ bells/validate.py,sha256=pO0VUCIX7u2c3WOwHDnbpeEMy85rvbX4faNn93DnxXc,12726
10
+ bell_schedule-0.7.0.dist-info/METADATA,sha256=mqoMc1UE9K1tFNBzldmAnPWpGp6wsbaQJDWhbHrMEiE,9498
11
+ bell_schedule-0.7.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ bell_schedule-0.7.0.dist-info/entry_points.txt,sha256=T96xLhgrFtAzoVSfKcS62ea5ebJ5Nq-xrY70XmJ81UE,50
13
+ bell_schedule-0.7.0.dist-info/top_level.txt,sha256=0efalOUwO-jvfeJ3c844fmuUgV1rPELoayjda1orGf8,6
14
+ bell_schedule-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bells-validate = bells.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Peter Seibel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ bells
bells/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """bells — a framework-agnostic library for querying school bell schedules.
2
+
3
+ Python port of the ``@peterseibel/bells`` JavaScript library. Built on the
4
+ standard library ``datetime`` and ``zoneinfo`` modules instead of Temporal.
5
+ """
6
+
7
+ from .abstract_time import format_time, parse_time
8
+ from .bell_schedule import BellSchedule
9
+ from .calendar import Calendar, Interval, Period, Schedule, normalize_include_tags
10
+ from .calendars import Calendars
11
+ from .validate import validate_calendar_data
12
+
13
+ __all__ = [
14
+ "BellSchedule",
15
+ "Calendars",
16
+ "Calendar",
17
+ "Schedule",
18
+ "Period",
19
+ "Interval",
20
+ "normalize_include_tags",
21
+ "validate_calendar_data",
22
+ "parse_time",
23
+ "format_time",
24
+ ]
25
+
26
+ __version__ = "0.5.0"
bells/abstract_time.py ADDED
@@ -0,0 +1,198 @@
1
+ """Abstract times: moments described relative to the school schedule ("five
2
+ minutes before the end of the period", "start of school next Monday") rather
3
+ than as wall-clock times.
4
+
5
+ Python counterpart of the JavaScript ``abstract-time.js`` module. An abstract
6
+ time has three independent parts: a day spec (which date, possibly relative to
7
+ a base date), a time anchor (a schedule-defined point in that day), and a
8
+ signed HH:MM offset. Resolution happens in two phases: day binding
9
+ (:meth:`BellSchedule.bind_time`, producing a bound time) and time resolution
10
+ (:meth:`BellSchedule.resolve_time`, supplying the period if the anchor needs
11
+ one).
12
+
13
+ This module holds the types and the string syntax (:func:`parse_time` /
14
+ :func:`format_time`); everything that needs a calendar lives on BellSchedule.
15
+
16
+ The types are plain dicts, mirroring the rest of the Python port:
17
+
18
+ - ``AbstractTime``: ``{"anchor": str, "offset"?: str, "day"?: DaySpec}``
19
+ - ``BoundTime``: ``{"date": str, "anchor": str, "offset": str}``
20
+ - ``DaySpec``: ``{"type": "date", "date": str}`` |
21
+ ``{"type": "schoolDays"|"weeks", "n": int}`` |
22
+ ``{"type": "weekday", "weekday": int}`` |
23
+ ``{"type": "week", "edge": "start"|"end", "n": int}``
24
+
25
+ Anchors: ``start_of_period``, ``end_of_period``, ``start_of_day``,
26
+ ``end_of_day``, ``midnight``.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from datetime import date
33
+
34
+ ANCHORS = (
35
+ "start_of_period",
36
+ "end_of_period",
37
+ "start_of_day",
38
+ "end_of_day",
39
+ "midnight",
40
+ )
41
+
42
+ WEEKDAY_NUMBERS = {
43
+ "monday": 1, "mon": 1,
44
+ "tuesday": 2, "tue": 2,
45
+ "wednesday": 3, "wed": 3,
46
+ "thursday": 4, "thu": 4,
47
+ "friday": 5, "fri": 5,
48
+ "saturday": 6, "sat": 6,
49
+ "sunday": 7, "sun": 7,
50
+ }
51
+
52
+ WEEKDAY_NAMES = {
53
+ 1: "monday",
54
+ 2: "tuesday",
55
+ 3: "wednesday",
56
+ 4: "thursday",
57
+ 5: "friday",
58
+ 6: "saturday",
59
+ 7: "sunday",
60
+ }
61
+
62
+ _OFFSET_RE = re.compile(r"^([+-]?)(\d{1,2}):(\d{2})$")
63
+
64
+ # A signed time-offset token: the string syntax requires the sign.
65
+ _OFFSET_TOKEN_RE = re.compile(r"^[+-]\d{1,2}:\d{2}$")
66
+
67
+ _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
68
+
69
+ _SIGNED_INT_RE = re.compile(r"^([+-])(\d+)$")
70
+
71
+
72
+ def parse_offset_minutes(offset: str) -> int:
73
+ """Parse an ``'[-+]HH:MM'`` offset into signed minutes.
74
+
75
+ The sign is optional here (stored offsets may be unsigned, e.g. ``'00:00'``);
76
+ the string syntax requires it so an offset token is unambiguous.
77
+ """
78
+ m = _OFFSET_RE.match(offset)
79
+ if m:
80
+ minutes = int(m.group(3))
81
+ if minutes <= 59:
82
+ total = int(m.group(2)) * 60 + minutes
83
+ return -total if m.group(1) == "-" else total
84
+ raise ValueError(f'Invalid time offset "{offset}"')
85
+
86
+
87
+ def _format_offset(minutes: int) -> str:
88
+ sign = "-" if minutes < 0 else "+"
89
+ abs_minutes = abs(minutes)
90
+ return f"{sign}{abs_minutes // 60:02d}:{abs_minutes % 60:02d}"
91
+
92
+
93
+ def _parse_day_part(tokens: list[str]) -> dict:
94
+ def bad() -> ValueError:
95
+ return ValueError(f'Unrecognized day part "{" ".join(tokens)}"')
96
+
97
+ if len(tokens) == 1:
98
+ tok = tokens[0]
99
+ if _ISO_DATE_RE.match(tok):
100
+ try:
101
+ date.fromisoformat(tok)
102
+ except ValueError:
103
+ raise ValueError(f'Invalid date "{tok}"')
104
+ return {"type": "date", "date": tok}
105
+ if tok in WEEKDAY_NUMBERS:
106
+ return {"type": "weekday", "weekday": WEEKDAY_NUMBERS[tok]}
107
+ raise bad()
108
+
109
+ if len(tokens) == 2:
110
+ if tokens[0] == "next" and tokens[1] == "week":
111
+ return {"type": "week", "edge": "start", "n": 1}
112
+ if _SIGNED_INT_RE.match(tokens[0]):
113
+ n = int(tokens[0])
114
+ if tokens[1] in ("day", "days"):
115
+ return {"type": "schoolDays", "n": n}
116
+ if tokens[1] in ("week", "weeks"):
117
+ return {"type": "weeks", "n": n}
118
+ raise bad()
119
+
120
+ if tokens[0] in ("start", "end") and tokens[1] == "of":
121
+ if len(tokens) == 3 and tokens[2] == "week":
122
+ return {"type": "week", "edge": tokens[0], "n": 0}
123
+ if len(tokens) == 4 and tokens[2] == "next" and tokens[3] == "week":
124
+ return {"type": "week", "edge": tokens[0], "n": 1}
125
+
126
+ raise bad()
127
+
128
+
129
+ def parse_time(spec: str) -> dict:
130
+ """Parse the compact one-line syntax: ``anchor [time-offset] [day-part]``,
131
+ whitespace-separated, case-insensitive. E.g. ``'end_of_period -00:05'``,
132
+ ``'start_of_day next week'``, ``'end_of_day +1 day'``. Raises on unknown
133
+ anchors, malformed offsets, and unrecognized day parts.
134
+ """
135
+ tokens = spec.strip().lower().split()
136
+ if not tokens:
137
+ raise ValueError("Empty abstract-time spec")
138
+
139
+ anchor = tokens.pop(0)
140
+ if anchor not in ANCHORS:
141
+ raise ValueError(f'Unknown anchor "{anchor}"')
142
+
143
+ t: dict = {"anchor": anchor}
144
+
145
+ if tokens and _OFFSET_TOKEN_RE.match(tokens[0]):
146
+ offset = tokens.pop(0)
147
+ parse_offset_minutes(offset) # validate (e.g. minutes <= 59)
148
+ t["offset"] = offset
149
+
150
+ if tokens:
151
+ t["day"] = _parse_day_part(tokens)
152
+
153
+ return t
154
+
155
+
156
+ def _format_day_part(day: dict) -> str:
157
+ kind = day["type"]
158
+ if kind == "date":
159
+ return day["date"]
160
+ if kind in ("schoolDays", "weeks"):
161
+ n = day["n"]
162
+ if not isinstance(n, int):
163
+ raise ValueError(f"Cannot format non-integer day spec {n}")
164
+ abs_n = abs(n)
165
+ unit = "day" if kind == "schoolDays" else "week"
166
+ plural = "" if abs_n == 1 else "s"
167
+ return f"{'-' if n < 0 else '+'}{abs_n} {unit}{plural}"
168
+ if kind == "weekday":
169
+ name = WEEKDAY_NAMES.get(day["weekday"])
170
+ if not name:
171
+ raise ValueError(
172
+ f"Invalid weekday {day['weekday']} (must be 1=Monday..7=Sunday)"
173
+ )
174
+ return name
175
+ if kind == "week":
176
+ n = day["n"]
177
+ if n == 0:
178
+ return f"{day['edge']} of week"
179
+ if n == 1:
180
+ return f"{day['edge']} of next week"
181
+ raise ValueError(
182
+ f"Cannot format week spec with n={n} (string syntax covers n=0 and n=1)"
183
+ )
184
+ raise ValueError(f'Unknown day spec type "{kind}"')
185
+
186
+
187
+ def format_time(t: dict) -> str:
188
+ """Canonical string form of an abstract time; round-trips through
189
+ :func:`parse_time`."""
190
+ if t["anchor"] not in ANCHORS:
191
+ raise ValueError(f'Unknown anchor "{t["anchor"]}"')
192
+ parts = [t["anchor"]]
193
+ offset = parse_offset_minutes(t.get("offset") or "+00:00")
194
+ if offset != 0:
195
+ parts.append(_format_offset(offset))
196
+ if t.get("day"):
197
+ parts.append(_format_day_part(t["day"]))
198
+ return " ".join(parts)