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.
- bell_schedule-0.7.0.dist-info/METADATA +241 -0
- bell_schedule-0.7.0.dist-info/RECORD +14 -0
- bell_schedule-0.7.0.dist-info/WHEEL +5 -0
- bell_schedule-0.7.0.dist-info/entry_points.txt +2 -0
- bell_schedule-0.7.0.dist-info/licenses/LICENSE +21 -0
- bell_schedule-0.7.0.dist-info/top_level.txt +1 -0
- bells/__init__.py +26 -0
- bells/abstract_time.py +198 -0
- bells/bell_schedule.py +474 -0
- bells/calendar.py +492 -0
- bells/calendars.py +104 -0
- bells/cli.py +57 -0
- bells/datetimeutil.py +163 -0
- bells/validate.py +333 -0
|
@@ -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,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)
|