bell-schedule 0.7.0__tar.gz
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/LICENSE +21 -0
- bell_schedule-0.7.0/PKG-INFO +241 -0
- bell_schedule-0.7.0/README.md +214 -0
- bell_schedule-0.7.0/bell_schedule.egg-info/PKG-INFO +241 -0
- bell_schedule-0.7.0/bell_schedule.egg-info/SOURCES.txt +24 -0
- bell_schedule-0.7.0/bell_schedule.egg-info/dependency_links.txt +1 -0
- bell_schedule-0.7.0/bell_schedule.egg-info/entry_points.txt +2 -0
- bell_schedule-0.7.0/bell_schedule.egg-info/requires.txt +3 -0
- bell_schedule-0.7.0/bell_schedule.egg-info/top_level.txt +1 -0
- bell_schedule-0.7.0/bells/__init__.py +26 -0
- bell_schedule-0.7.0/bells/abstract_time.py +198 -0
- bell_schedule-0.7.0/bells/bell_schedule.py +474 -0
- bell_schedule-0.7.0/bells/calendar.py +492 -0
- bell_schedule-0.7.0/bells/calendars.py +104 -0
- bell_schedule-0.7.0/bells/cli.py +57 -0
- bell_schedule-0.7.0/bells/datetimeutil.py +163 -0
- bell_schedule-0.7.0/bells/validate.py +333 -0
- bell_schedule-0.7.0/pyproject.toml +44 -0
- bell_schedule-0.7.0/setup.cfg +4 -0
- bell_schedule-0.7.0/tests/test_abstract_time.py +508 -0
- bell_schedule-0.7.0/tests/test_bell_schedule.py +178 -0
- bell_schedule-0.7.0/tests/test_calendar.py +373 -0
- bell_schedule-0.7.0/tests/test_calendars.py +40 -0
- bell_schedule-0.7.0/tests/test_datetime.py +232 -0
- bell_schedule-0.7.0/tests/test_golden.py +183 -0
- bell_schedule-0.7.0/tests/test_validate.py +362 -0
|
@@ -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,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,214 @@
|
|
|
1
|
+
# bells (Python)
|
|
2
|
+
|
|
3
|
+
A framework-agnostic Python library for querying school bell schedules. This is
|
|
4
|
+
a port of the [`@peterseibel/bells`](https://github.com/gigamonkey/bells/tree/main/libs/ts) JavaScript library, kept
|
|
5
|
+
behaviorally identical to it.
|
|
6
|
+
|
|
7
|
+
Where the JS library is built on the [Temporal API](https://tc39.es/proposal-temporal/),
|
|
8
|
+
this port uses only the Python standard library:
|
|
9
|
+
|
|
10
|
+
| Temporal | Python |
|
|
11
|
+
| ----------------------- | ------------------------------------------ |
|
|
12
|
+
| `Temporal.PlainDate` | `datetime.date` |
|
|
13
|
+
| `Temporal.PlainTime` | `datetime.time` |
|
|
14
|
+
| `Temporal.Instant` | timezone-aware `datetime.datetime` in UTC |
|
|
15
|
+
| `Temporal.Duration` | `datetime.timedelta` |
|
|
16
|
+
| `Temporal.PlainDateTime`| naive `datetime.datetime` |
|
|
17
|
+
|
|
18
|
+
An "instant" throughout the package is an aware `datetime` normalized to UTC.
|
|
19
|
+
|
|
20
|
+
Requires Python 3.9+ (uses `zoneinfo`). No third-party dependencies.
|
|
21
|
+
|
|
22
|
+
## Calendar data format
|
|
23
|
+
|
|
24
|
+
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)
|
|
25
|
+
for the full field reference. Briefly, calendar data is a list of year objects:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
[
|
|
29
|
+
{
|
|
30
|
+
"year": "2025-2026",
|
|
31
|
+
"id": "bhs",
|
|
32
|
+
"name": "Berkeley High School",
|
|
33
|
+
"timezone": "America/Los_Angeles",
|
|
34
|
+
"firstDay": "2025-08-13",
|
|
35
|
+
"firstDayTeachers": "2025-08-11",
|
|
36
|
+
"lastDay": "2026-06-04",
|
|
37
|
+
"schedules": {
|
|
38
|
+
"NORMAL": [
|
|
39
|
+
{ "name": "Period 1", "start": "8:30", "end": "9:28" },
|
|
40
|
+
{ "name": "Period 2", "start": "9:34", "end": "10:37" }
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"weekdaySchedules": { "monday": "LATE_START" },
|
|
44
|
+
"holidays": ["2025-09-01"]
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## API
|
|
50
|
+
|
|
51
|
+
The public API mirrors the JS one, with method names converted to
|
|
52
|
+
`snake_case` and options passed as a dict with snake_case keys (`role`,
|
|
53
|
+
`include_tags`).
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import json
|
|
57
|
+
from bells import BellSchedule
|
|
58
|
+
|
|
59
|
+
with open("my-calendars.json") as f:
|
|
60
|
+
data = json.load(f)
|
|
61
|
+
|
|
62
|
+
bells = BellSchedule(data, {
|
|
63
|
+
"role": "student", # "student" | "teacher" (default: "student")
|
|
64
|
+
"include_tags": {
|
|
65
|
+
1: ["seventh"], # Monday: include Period 7
|
|
66
|
+
2: ["zero", "seventh"],
|
|
67
|
+
},
|
|
68
|
+
# Or a flat list for the same tags every weekday:
|
|
69
|
+
# "include_tags": ["seventh"],
|
|
70
|
+
|
|
71
|
+
# Which periods are "numbered" and what number they carry, for the
|
|
72
|
+
# abstract-time API. Default: match r"^Period (\d+)\b" in the name.
|
|
73
|
+
# "period_number": lambda period: ...int or None...,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
# What's happening right now?
|
|
77
|
+
interval = bells.current_interval()
|
|
78
|
+
print(interval.name) # e.g. "Period 3"
|
|
79
|
+
print(interval.type) # 'period' | 'passing' | 'before-school' | 'after-school' | 'break'
|
|
80
|
+
print(interval.left()) # timedelta until end of interval
|
|
81
|
+
|
|
82
|
+
# Other queries (all accept an optional aware datetime instant):
|
|
83
|
+
bells.period_at() # Interval | None (None unless a period)
|
|
84
|
+
bells.is_school_day() # bool (accepts an optional date)
|
|
85
|
+
bells.current_day_bounds() # {"start": ..., "end": ...} | None
|
|
86
|
+
bells.next_school_day_start() # datetime
|
|
87
|
+
bells.previous_school_day_end() # datetime
|
|
88
|
+
bells.school_time_left() # timedelta
|
|
89
|
+
bells.school_time_done() # timedelta
|
|
90
|
+
bells.total_school_time() # timedelta
|
|
91
|
+
bells.school_days_left() # int
|
|
92
|
+
bells.calendar_days_left() # int
|
|
93
|
+
bells.next_year_start() # datetime (raises if not loaded)
|
|
94
|
+
bells.school_time_between(a, b) # timedelta
|
|
95
|
+
bells.summer_bounds() # {"start": ..., "end": ...} | None
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Abstract times
|
|
99
|
+
|
|
100
|
+
An *abstract time* describes a moment relative to the schedule — "five minutes
|
|
101
|
+
before the end of the period", "start of school next Monday" — rather than as a
|
|
102
|
+
wall-clock time. It has three independent parts: a *day spec* (which date,
|
|
103
|
+
possibly relative to a base date), a *time anchor* (a schedule-defined point in
|
|
104
|
+
that day: `start_of_period`, `end_of_period`, `start_of_day`, `end_of_day`, or
|
|
105
|
+
`midnight`), and a signed `HH:MM` offset.
|
|
106
|
+
|
|
107
|
+
Resolution happens in two phases, so the period can stay unbound until query
|
|
108
|
+
time (a stored "start of period" resolves differently for a period-2 class than
|
|
109
|
+
a period-5 class). The types are plain dicts; `parse_time`/`format_time` need no
|
|
110
|
+
calendar.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from bells import parse_time, format_time
|
|
114
|
+
|
|
115
|
+
# Standalone — no calendar needed:
|
|
116
|
+
t = parse_time("end_of_period -00:05 +1 day")
|
|
117
|
+
format_time(t) # canonical round-trip
|
|
118
|
+
|
|
119
|
+
# Phase 1 (load time): bind the day spec against a base date. Warnings for
|
|
120
|
+
# specs that don't make sense against the calendar (e.g. a school anchor on a
|
|
121
|
+
# holiday) are reported via the callback (default: print to stderr).
|
|
122
|
+
bound = bells.bind_time(base_date, t, lambda warning: print(warning))
|
|
123
|
+
# → {"date": "2026-01-06", "anchor": "end_of_period", "offset": "-00:05"}
|
|
124
|
+
|
|
125
|
+
# Phase 2 (query time): resolve to a concrete moment (an aware datetime in the
|
|
126
|
+
# schedule's timezone), supplying the period if the anchor needs one. None when
|
|
127
|
+
# the date has no schedule or no such period.
|
|
128
|
+
bells.resolve_time(bound, 3) # datetime | None
|
|
129
|
+
|
|
130
|
+
# Pieces of the above, usable directly:
|
|
131
|
+
bells.resolve_day(base_date, t.get("day")) # date
|
|
132
|
+
bells.time_warnings(bound) # list[str] (empty = OK)
|
|
133
|
+
bells.add_school_days(d, 3) # n school days out (n may be negative)
|
|
134
|
+
bells.period_on_date(d, 3) # period dict | None
|
|
135
|
+
bells.current_or_next_period_number() # int | None
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The string syntax is `anchor [time-offset] [day-part]`, whitespace-separated
|
|
139
|
+
and case-insensitive (e.g. `end_of_period -00:05`, `start_of_day next week`,
|
|
140
|
+
`end_of_day +1 day`, `midnight +1 week`, `start_of_day 2026-01-05`). Day-part
|
|
141
|
+
semantics: `±N day(s)` counts *school* days; `±N week(s)` is literal calendar
|
|
142
|
+
arithmetic (no snapping); a weekday name means the first such day strictly after
|
|
143
|
+
the base date, taken literally even if it's a holiday; the week boundaries
|
|
144
|
+
(`start of [next] week`, `end of [next] week`) snap to the first/last school day
|
|
145
|
+
of the ISO week. `start of week` on a week with no school days advances to the
|
|
146
|
+
first day back (with a warning); `end of week` on such a week raises. Resolution
|
|
147
|
+
that runs past the loaded calendars raises an `IndexError`.
|
|
148
|
+
|
|
149
|
+
### `Calendars`
|
|
150
|
+
|
|
151
|
+
For loading per-year JSON files from a directory or URL:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from bells import Calendars
|
|
155
|
+
|
|
156
|
+
calendars = Calendars("./calendars/") # or "https://example.com/calendars/"
|
|
157
|
+
|
|
158
|
+
bells = calendars.for_year("2025-2026", options)
|
|
159
|
+
bells = calendars.current(options) # handles summer automatically
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Files must be named `{year}.json` (e.g. `2025-2026.json`). Local paths are read
|
|
163
|
+
from disk; URL bases are fetched with `urllib`.
|
|
164
|
+
|
|
165
|
+
### `bhs-calendars` (bundled BHS data)
|
|
166
|
+
|
|
167
|
+
As an alternative to supplying your own `{year}.json` files, the companion
|
|
168
|
+
`bhs-calendars` package ships ready-to-use calendar data for Berkeley High and
|
|
169
|
+
nearby middle schools. `by_id()` groups the bundled years by school (each
|
|
170
|
+
group's years sorted chronologically); hand one group straight to
|
|
171
|
+
`BellSchedule`:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from bhs_calendars import by_id, load_all
|
|
175
|
+
from bells import BellSchedule
|
|
176
|
+
|
|
177
|
+
years = by_id()["bhs"] # one school's years, oldest first
|
|
178
|
+
bells = BellSchedule(years, options)
|
|
179
|
+
|
|
180
|
+
all_years = load_all() # or the flat list of every school-year
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Install it alongside the library (`pip install bhs-calendars`). Unlike
|
|
184
|
+
`Calendars`, the data is bundled with the package — no filesystem layout or
|
|
185
|
+
network access — but it only covers the BHS-area schools. Equivalent data
|
|
186
|
+
packages exist for the [TypeScript](https://github.com/gigamonkey/bells/tree/main/libs/ts) (`@peterseibel/bhs-calendars` on npm)
|
|
187
|
+
and [Java](https://github.com/gigamonkey/bells/tree/main/libs/java) (`com.gigamonkeys:bhs-calendars`) ports.
|
|
188
|
+
|
|
189
|
+
### Validation
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from bells import validate_calendar_data
|
|
193
|
+
|
|
194
|
+
result = validate_calendar_data(data)
|
|
195
|
+
if not result["valid"]:
|
|
196
|
+
print(result["errors"])
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
CLI:
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
bells-validate calendars.json # after `pip install .`
|
|
203
|
+
python -m bells.cli calendars.json # without installing
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Tests
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
cd libs/python
|
|
210
|
+
python -m pytest
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The test suite is a port of the JS test suite. A behavioral cross-check against
|
|
214
|
+
the JS library on the real BHS calendars produces identical results.
|