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.
@@ -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.