A framework-agnostic library for querying school bell schedules (Python port)
Project description
bells (Python)
A framework-agnostic Python library for querying school bell schedules. This is
a port of the @peterseibel/bells JavaScript library, kept
behaviorally identical to it.
Where the JS library is built on the Temporal API, this port uses only the Python standard library:
| Temporal | Python |
|---|---|
Temporal.PlainDate |
datetime.date |
Temporal.PlainTime |
datetime.time |
Temporal.Instant |
timezone-aware datetime.datetime in UTC |
Temporal.Duration |
datetime.timedelta |
Temporal.PlainDateTime |
naive datetime.datetime |
An "instant" throughout the package is an aware datetime normalized to UTC.
Requires Python 3.9+ (uses zoneinfo). No third-party dependencies.
Calendar data format
The calendar data format is identical to the JS library — see libs/ts/README.md
for the full field reference. Briefly, calendar data is a list of year objects:
[
{
"year": "2025-2026",
"id": "bhs",
"name": "Berkeley High School",
"timezone": "America/Los_Angeles",
"firstDay": "2025-08-13",
"firstDayTeachers": "2025-08-11",
"lastDay": "2026-06-04",
"schedules": {
"NORMAL": [
{ "name": "Period 1", "start": "8:30", "end": "9:28" },
{ "name": "Period 2", "start": "9:34", "end": "10:37" }
]
},
"weekdaySchedules": { "monday": "LATE_START" },
"holidays": ["2025-09-01"]
}
]
API
The public API mirrors the JS one, with method names converted to
snake_case and options passed as a dict with snake_case keys (role,
include_tags).
import json
from bells import BellSchedule
with open("my-calendars.json") as f:
data = json.load(f)
bells = BellSchedule(data, {
"role": "student", # "student" | "teacher" (default: "student")
"include_tags": {
1: ["seventh"], # Monday: include Period 7
2: ["zero", "seventh"],
},
# Or a flat list for the same tags every weekday:
# "include_tags": ["seventh"],
# Which periods are "numbered" and what number they carry, for the
# abstract-time API. Default: match r"^Period (\d+)\b" in the name.
# "period_number": lambda period: ...int or None...,
})
# What's happening right now?
interval = bells.current_interval()
print(interval.name) # e.g. "Period 3"
print(interval.type) # 'period' | 'passing' | 'before-school' | 'after-school' | 'break'
print(interval.left()) # timedelta until end of interval
# Other queries (all accept an optional aware datetime instant):
bells.period_at() # Interval | None (None unless a period)
bells.is_school_day() # bool (accepts an optional date)
bells.current_day_bounds() # {"start": ..., "end": ...} | None
bells.next_school_day_start() # datetime
bells.previous_school_day_end() # datetime
bells.school_time_left() # timedelta
bells.school_time_done() # timedelta
bells.total_school_time() # timedelta
bells.school_days_left() # int
bells.calendar_days_left() # int
bells.next_year_start() # datetime (raises if not loaded)
bells.school_time_between(a, b) # timedelta
bells.summer_bounds() # {"start": ..., "end": ...} | None
School weeks & annotations
A calendar year may carry an optional annotations field — generic extra
information (an AP-testing date range, grading-period closes keyed by
school-week number, ad-hoc dates) that doesn't change the schedule. See the
TypeScript README for the data format; it is
identical across ports. The query API (snake_case here):
# School-week numbering (Monday-anchored ISO weeks with >=1 school day,
# numbered 1..n; role-aware — bind role="student" for grading numbering):
bells.school_weeks() # list[SchoolWeek dict] in chronological order
bells.school_week_count() # int
bells.school_week(9) # SchoolWeek dict | None (by 1-based number)
bells.week_for_date(d) # SchoolWeek dict | None
# Resolved annotation accessors (raw keys -> real dates / school weeks):
bells.range_annotations() # [{"id", "start", "end", "label"?, "kind"?, ...}]
bells.week_annotations() # [{"week", "school_week", "label"?, "kind"?, ...}]
bells.date_annotations() # [{"date", "label"?, "kind"?, ...}]
bells.annotations() # the raw, unvalidated annotations dict
# Unified helpers (each entry tagged with "source": "range"|"week"|"date"):
bells.annotations_on(d) # everything active on date d
bells.annotations_for_week(9) # everything touching school week 9
A SchoolWeek is a dict {"number", "monday", "first_school_day", "last_school_day", "school_day_count"}.
Abstract times
An abstract time describes a moment relative to the schedule — "five minutes
before the end of the period", "start of school next Monday" — rather than as a
wall-clock time. It has three independent parts: a day spec (which date,
possibly relative to a base date), a time anchor (a schedule-defined point in
that day: start_of_period, end_of_period, start_of_day, end_of_day, or
midnight), and a signed HH:MM offset.
Resolution happens in two phases, so the period can stay unbound until query
time (a stored "start of period" resolves differently for a period-2 class than
a period-5 class). The types are plain dicts; parse_time/format_time need no
calendar.
from bells import parse_time, format_time
# Standalone — no calendar needed:
t = parse_time("end_of_period -00:05 +1 day")
format_time(t) # canonical round-trip
# Phase 1 (load time): bind the day spec against a base date. Warnings for
# specs that don't make sense against the calendar (e.g. a school anchor on a
# holiday) are reported via the callback (default: print to stderr).
bound = bells.bind_time(base_date, t, lambda warning: print(warning))
# → {"date": "2026-01-06", "anchor": "end_of_period", "offset": "-00:05"}
# Phase 2 (query time): resolve to a concrete moment (an aware datetime in the
# schedule's timezone), supplying the period if the anchor needs one. None when
# the date has no schedule or no such period.
bells.resolve_time(bound, 3) # datetime | None
# Pieces of the above, usable directly:
bells.resolve_day(base_date, t.get("day")) # date
bells.time_warnings(bound) # list[str] (empty = OK)
bells.add_school_days(d, 3) # n school days out (n may be negative)
bells.period_on_date(d, 3) # period dict | None
bells.current_or_next_period_number() # int | None
The string syntax is anchor [time-offset] [day-part], whitespace-separated
and case-insensitive (e.g. end_of_period -00:05, start_of_day next week,
end_of_day +1 day, midnight +1 week, start_of_day 2026-01-05). Day-part
semantics: ±N day(s) counts school days; ±N week(s) is literal calendar
arithmetic (no snapping); a weekday name means the first such day strictly after
the base date, taken literally even if it's a holiday; the week boundaries
(start of [next] week, end of [next] week) snap to the first/last school day
of the ISO week. start of week on a week with no school days advances to the
first day back (with a warning); end of week on such a week raises. Resolution
that runs past the loaded calendars raises an IndexError.
Calendars
For loading per-year JSON files from a directory or URL:
from bells import Calendars
calendars = Calendars("./calendars/") # or "https://example.com/calendars/"
bells = calendars.for_year("2025-2026", options)
bells = calendars.current(options) # handles summer automatically
Files must be named {year}.json (e.g. 2025-2026.json). Local paths are read
from disk; URL bases are fetched with urllib.
bhs-calendars (bundled BHS data)
As an alternative to supplying your own {year}.json files, the companion
bhs-calendars package ships ready-to-use calendar data for Berkeley High and
nearby middle schools. by_id() groups the bundled years by school (each
group's years sorted chronologically); hand one group straight to
BellSchedule:
from bhs_calendars import by_id, load_all
from bells import BellSchedule
years = by_id()["bhs"] # one school's years, oldest first
bells = BellSchedule(years, options)
all_years = load_all() # or the flat list of every school-year
Install it alongside the library (pip install bhs-calendars). Unlike
Calendars, the data is bundled with the package — no filesystem layout or
network access — but it only covers the BHS-area schools. Equivalent data
packages exist for the TypeScript (@peterseibel/bhs-calendars on npm)
and Java (com.gigamonkeys:bhs-calendars) ports.
Validation
from bells import validate_calendar_data
result = validate_calendar_data(data)
if not result["valid"]:
print(result["errors"])
CLI:
bells-validate calendars.json # after `pip install .`
python -m bells.cli calendars.json # without installing
Tests
cd libs/python
python -m pytest
The test suite is a port of the JS test suite. A behavioral cross-check against the JS library on the real BHS calendars produces identical results.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file bell_schedule-0.8.0.tar.gz.
File metadata
- Download URL: bell_schedule-0.8.0.tar.gz
- Upload date:
- Size: 39.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d02fe0beeda7c9cbfc471065b4388c941de62db3735dec715b5e03f63d3ff9fe
|
|
| MD5 |
81a52a80e10f42bf9effde4b0730a15d
|
|
| BLAKE2b-256 |
4988964095001c9565f69cebb3bd501f677e1586065d57bce75df56036c38f9f
|
Provenance
The following attestation bundles were made for bell_schedule-0.8.0.tar.gz:
Publisher:
publish.yml on gigamonkey/bells
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
bell_schedule-0.8.0.tar.gz -
Subject digest:
d02fe0beeda7c9cbfc471065b4388c941de62db3735dec715b5e03f63d3ff9fe - Sigstore transparency entry: 1998140540
- Sigstore integration time:
-
Permalink:
gigamonkey/bells@0bdcf6e4e30bb938d844bdc4f1fb7225193b4d72 -
Branch / Tag:
refs/tags/v0.8.0 - Owner: https://github.com/gigamonkey
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@0bdcf6e4e30bb938d844bdc4f1fb7225193b4d72 -
Trigger Event:
push
-
Statement type:
File details
Details for the file bell_schedule-0.8.0-py3-none-any.whl.
File metadata
- Download URL: bell_schedule-0.8.0-py3-none-any.whl
- Upload date:
- Size: 30.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f1f6f1edf77aff72cdfb9c79de337af725410c335b39d0a2bd594953f4bf6de2
|
|
| MD5 |
fa763a6a71305639195bc397f376570e
|
|
| BLAKE2b-256 |
be75b63e8ce8b3c4a5922dcd5c785db1f240ff7f249efe604fa97ca7b8f874cc
|
Provenance
The following attestation bundles were made for bell_schedule-0.8.0-py3-none-any.whl:
Publisher:
publish.yml on gigamonkey/bells
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
bell_schedule-0.8.0-py3-none-any.whl -
Subject digest:
f1f6f1edf77aff72cdfb9c79de337af725410c335b39d0a2bd594953f4bf6de2 - Sigstore transparency entry: 1998140641
- Sigstore integration time:
-
Permalink:
gigamonkey/bells@0bdcf6e4e30bb938d844bdc4f1fb7225193b4d72 -
Branch / Tag:
refs/tags/v0.8.0 - Owner: https://github.com/gigamonkey
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@0bdcf6e4e30bb938d844bdc4f1fb7225193b4d72 -
Trigger Event:
push
-
Statement type: