Architecture decisions
Thin typed client, not a navigable domain model
v1 exposes one client class whose methods map ~1:1 to API endpoints, each returning typed models with a raw-payload escape hatch.
The API is undocumented and its behavior (pagination, optional fields) is still being learned; explicit I/O keeps every HTTP request visible so callers own retries and caching, and pure parse functions stay testable against recorded fixtures. A lazy-fetching domain layer (Site → Board → Meeting) hides I/O and locks in design guesses too early — it can be added later on top of the client without breaking anything.
Lenient two-tier parsing on frozen dataclasses, no pydantic
Each model declares a tiny set of load-bearing fields (ids, dates) whose absence raises a typed payload error; every other field is best-effort Optional and never raises. Models are frozen dataclasses carrying the raw response dict; parsing is hand-written pure functions.
The upstream API is undocumented and makes no compatibility promises, so
strict schema validation would turn every cosmetic upstream change into a
caller-facing crash — strictness against a schema we don't control is a
liability, not a safety feature. The raw dict is the contract with the
future: new upstream fields are reachable immediately and modeled in a later
minor release. Pydantic was rejected because its value is exactly the
validation we don't want, at the cost of a heavy dependency; hand-written
parse functions keep the runtime dependency footprint at httpx alone and put
each lenient/strict decision in plain sight. Library semver covers the
library surface only; upstream drift is detected by an opt-in live contract
test suite and handled in releases.
Polite pacing by default; retries and caching stay caller-owned
The client enforces a minimum interval between requests
(min_request_interval, default 1.0 s, 0 disables); it still ships no
retries or caching.
CivicClerk tenants are small municipal servers with no observed rate
enforcement, and one unfiltered listing call can fan out to hundreds of
paged requests — a docs-only recommendation protects the service only when
users read docs. Well-regarded clients of unenforced services pace
proactively by default (scrapelib, built for state-legislature scraping,
defaults to 60 requests/minute; PyGithub added
seconds_between_requests=0.25 after users tripped abuse detection in the
wild), and every request now carries this library's name in its
User-Agent, so misbehavior is attributable to the library. The default of
1 s (≈1 request/second) matches scrapelib and stays within what the
platform terms of use frame as a rate "a human can reasonably produce";
the faster PyGithub value targets a large service's abuse detection, not a
tiny municipal server, so it is not the right anchor here. Pacing bounds
the instantaneous rate only; the terms' per-day request ceiling is a
volume limit that pacing cannot enforce, so it stays caller-owned and
documented (docs/usage.md) rather than encoded — the numbers are the
operator's to set and change, and duplicating them invites drift. The gate lives in the client (not the
httpx transport) so injecting a client for unrelated reasons does not
silently shed politeness — disabling is an explicit constructor argument.
Retries and caching remain caller policy: they are the genuinely subtle,
use-case-dependent pieces, and a configured httpx.Client (or a wrapper
library such as careful) can be injected to supply them. careful was
considered for the pacing itself and rejected inside the library: pre-1.0
API, adds a transitive dependency (structlog) against the httpx-only
footprint, and two of its three features are ones we deliberately leave to
callers.
Naive wall-clock datetimes — no upstream timezone source exists
Model datetimes are naive local wall-clock values, and the library makes no attempt to attach a timezone.
The API stamps a Z (UTC) suffix on datetimes that are actually local
wall-clock times (a 7 pm meeting arrives as 19:00:00Z), and nothing
upstream reliably declares the tenant's timezone: the OData $metadata
shows the only timezone-typed fields are secondaryIanaTimeZone /
secondaryAbbreviatedTimeZone on events — observed empty across five
public tenants in five US states (2026-07-14) — and the Settings entity
carries only portal display labels. Deriving a zone from the event
location's address would bake in per-tenant geography guesses, which the
tenant-generic rule forbids. Callers who need aware datetimes attach the
municipality's timezone themselves; re-verify with the live suite before
revisiting.
Tenant-generic by construction
The client takes the CivicClerk site subdomain as its only required constructor argument, and no municipality-specific policy (file-naming conventions, agenda-title parsing, meeting-selection rules) lives in the library. Such policy varies per municipality and per consuming tool; baking any one municipality's rules in would misparse every other tenant. Recorded test fixtures are scrubbed to fictional tenants for the same reason.
Typed exceptions are the diagnostic boundary; logging stays caller-owned
The library emits no logs of its own. Its operations have no hidden retries, caches, or background state to explain, so typed exceptions carry the useful library context while an injected httpx client or the consuming application owns request timing and wire-level observability. This also avoids duplicating logs at two layers and reduces the chance that signed attachment URLs are persisted; exception-visible URLs omit userinfo, queries, and fragments.
Downloads expose the response as a context manager
A caller must choose a destination path before the request is made, but the
only authoritative source for a file's type is the response, and HEAD
answers 405 — so there is no way to ask ahead of time. The result was that
every caller hardcoded .pdf and would be silently wrong the day a tenant
published something else.
open_file and open_attachment yield a FileStream after the headers
arrive and before the body is read — the one moment the naming information is
actionable. The alternatives both force the decision before the information
exists: a result object means downloading to a temp path and renaming
afterward, and a directory-valued destination would write the server's own
filename, which a live probe showed to be <file_id>.pdf for meeting files
and a GUID for attachments — strictly worse than the human name the caller
already holds in PublishedFile.name or AgendaAttachment.file_name.
So the caller owns the name and the response owns the extension. save()
writes its destination verbatim rather than appending a missing suffix: the
suffix is in scope inside the with block, so auto-appending would buy one
string interpolation while making the return value diverge from the argument
and making an intentionally extensionless file impossible.
suffix is derived, never guessed, because no single source covers both
endpoints: the Content-Disposition filename (meeting files send one whose
name is only the id restated, but whose extension is truthful), then the
content type, then the request URL's path (which rescues attachments, whose
GUID blob URLs end in a real extension but carry no Content-Disposition).
When all three yield nothing it is None — the bytes are not sniffed, and a
guessed .pdf would be worse than an honest absence.
Emptiness is settled as the stream is opened, not partway through iteration:
the API's "HTTP 200 with a 0-byte body" way of saying unavailable has to
raise NotFoundError from the with statement itself, or a caller who only
inspects the headers would happily write a 0-byte PDF. When Content-Length
is absent that requires reading the first chunk, which iter_bytes then
replays — otherwise every chunked body would silently lose its first chunk.
download_file and download_attachment are retained and reimplemented on
the context managers. They remain the right one-liner for "I already know the
name I want", and removing them would be a breaking change that buys nothing.
Vendor clients in one package, unified by jurisdiction rather than a shared model
libmuni ships one thin typed client per municipal software vendor
(libmuni.civicclerk, later libmuni.municode, libmuni.civicplus) in a
single distribution, and deliberately defines no cross-vendor domain model.
A town's public record is split across vendors — CivicClerk publishes
meetings, Municode publishes the charter and ordinances, CivicPlus runs the
city site — so what unifies them is the jurisdiction, not a schema. A
Meeting and an Ordinance share no field worth abstracting, and a common
supertype would flatten both; the shared surface here is a kind of client,
not a kind of record. Separate distributions per vendor were rejected as
premature at one maintainer and an httpx-only footprint — splitting a package
later is mechanical, while merging published names is not. Callers who want a
normalized cross-platform record should target the Open Civic Data spec,
which already exists and is maintained; emitting it stays a caller or
application concern, because OCD requires a timezone this library has no
source for (see "Naive wall-clock datetimes").
Exception root now, shared core deferred until a second client
MuniError is the root of every operational exception the library raises
(request, parse, and download failures), and each vendor's hierarchy descends
from it (CivicClerkError(MuniError)). Invalid arguments raise ValueError
and local file writes raise OSError, standard built-ins left outside the
hierarchy by design. No shared HTTP, streaming, or parsing core exists yet —
each vendor client owns its own.
The exception root is the one piece of shared surface that is free to add
before the first release and a breaking change after it: callers of a
multi-vendor library need a single base to catch. Everything else that
currently looks generic — the pacing gate, User-Agent injection, FileStream,
DNS-label tenant validation — is generic only by coincidence of there being a
single example. Municode publishes codified text with no tenant subdomain and
no OData pagination, so a core extracted today would be designed against a
sample size of one. The second client reveals the real seam; until then,
duplication is cheaper than the wrong abstraction.