Skip to content

Usage guide

Everything here assumes the quickstart basics: one CivicClerkClient per tenant, used as a context manager.

Datetimes are local wall-clock time

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 whatever the municipality's timezone — and nothing in the API declares the tenant's timezone (verified against the OData schema and live tenants across five US states). The library therefore returns naive datetimes carrying the local reading.

If you need aware datetimes, attach the municipality's timezone yourself:

from zoneinfo import ZoneInfo

local_tz = ZoneInfo("America/New_York")  # you know your municipality
aware = event.starts_at.replace(tzinfo=local_tz)

Date filters on iter_events are interpreted on the same local wall-clock timeline, so "meetings this week" means the municipality's week — no conversion needed.

Request pacing, retries, and caching

The client is polite by default: consecutive requests (including pagination pages and downloads) are spaced at least min_request_interval seconds apart — 1 s out of the box, i.e. at most ~1 request/second against what are typically small municipal servers, a rate a human browsing the portal could produce. Pacing bounds the instantaneous rate only, not total volume — see Acceptable use below.

client = CivicClerkClient("exampletown", min_request_interval=0)  # you own the rate

Retries and caching are deliberately not built in — they are policy that varies per application. Configure them on an httpx.Client and inject it:

import httpx
from libmuni.civicclerk import CivicClerkClient

http = httpx.Client(
    timeout=30.0,
    transport=httpx.HTTPTransport(retries=3),  # connect-level retries
    follow_redirects=True,  # file streams may redirect to storage
)
client = CivicClerkClient("exampletown", http_client=http)

The client the library creates for you follows redirects already; an injected client keeps httpx's default of not following them, so set follow_redirects=True unless you have a reason not to.

Treat every API-provided request target as untrusted: attachment downloads use the response's signed URL, event pagination follows its absolute continuation URL, and enabled redirects apply to all requests. A malicious tenant can direct those requests to internal services, carrying custom headers from an injected client. Do not attach credentials to that client, and isolate its network when using tenants you do not trust. See the packaged security policy for the full threat model.

For response-level retries with backoff and a development cache, consider wrapping your client with careful — a library built for exactly this kind of polite scraping — and injecting the result.

Acceptable use

The portals this library reads are operated under the platform vendor's and the individual jurisdiction's terms of use, which bind anyone accessing them — no account required. Those terms commonly restrict automated access and cap total requests over a window (for example, a daily page-request ceiling with penalties above it), and they can change at any time. This library does not reproduce or track them: read the terms for the portals you query and keep your usage within them.

The default pacing keeps the instantaneous rate to roughly what a human browsing the portal would produce, but it does not bound total volume — a single unfiltered listing can fan out to hundreds of paged requests, and repeated runs add up. Staying under any per-day or per-window limit is the caller's responsibility: filter listings, cache results, and space out repeated runs.

Listing meetings

iter_events is lazy: the API serves pages of 15 and the client fetches each page only when iteration reaches it. Collect with list(...) when you want everything.

With no filters it walks the tenant's entire meeting history — potentially thousands of events and hundreds of paged requests — so pass a date range for anything interactive:

import datetime as dt

recent = list(
    client.iter_events(
        from_date=dt.date.today() - dt.timedelta(days=30),
        to_date=dt.date.today(),
    )
)

Downloads

Two id namespaces exist, and they must not be mixed:

  • PublishedFile.file_id (meeting-level documents) → download_file
  • AgendaAttachment (agenda-item documents) → download_attachment

The namespaces overlap, so an attachment id passed to download_file usually raises NotFoundError — but when the number happens to exist as a file id too, it silently downloads the wrong document. Always go through the matching method.

Practical limits inherited from the API:

  • No resume. Range headers are ignored and HEAD is rejected, so a failed download restarts from zero. The library never leaves a partial file at the destination, and an existing destination remains unchanged.
  • Empty 200 means unavailable. The API answers unknown or unpublished files with an empty body and status 200; the library normalizes that to NotFoundError.
  • Attachment URLs expire. AgendaAttachment.download_url is a signed, time-limited URL; an expired one surfaces as APIStatusError.
  • get_file_text is best-effort upstream text extraction: None means the API returned no text, not that the document has none.

Naming a file you have not downloaded yet

download_file and download_attachment need a destination path before the request goes out — but the only authoritative source for a file's type is the response, and HEAD is rejected, so there is no way to ask ahead of time. Hardcoding .pdf is right until the day a tenant publishes something else.

open_file and open_attachment hand you the response at the one moment the information is actionable: after the headers arrive, before the body is read. Both yield a FileStream, valid only inside the with block.

for file in event.published_files:
    with client.open_file(file.file_id) as stream:
        # stream.content_type -> "application/pdf" (or None)
        # stream.size         -> bytes you will receive, or None if undeclared
        stream.save(downloads / f"{file.name}{stream.suffix or ''}")

The name is yours and the extension is the response's, and they meet inside the block: PublishedFile.name and AgendaAttachment.file_name carry no extension, and stream.suffix is derived from the response — the Content-Disposition filename, else the content type, else the URL path. It is None when the response gives no basis for one, rather than guessing.

save(destination) writes exactly the path you hand it: an extensionless destination stays extensionless, and nothing is renamed or appended. It never leaves a partial file behind.

To skip the disk entirely — streaming into a parser or an object store — consume the bytes directly:

with client.open_attachment(attachment) as stream:
    body = b"".join(stream.iter_bytes())  # or feed the chunks somewhere

The stream is single-pass and closes on exit; iter_bytes and save are invalid afterward. An unavailable file (the empty-200 trap above) raises NotFoundError from the with statement itself, so a caller that only inspects the headers can never write a 0-byte file.

Errors

Every operational error subclasses CivicClerkError — see the API reference for the full taxonomy and when each error is raised. Invalid arguments raise ValueError and local file writes raise OSError, both standard built-ins outside the hierarchy.

Absent optional data is never an error: an event with no published materials has an empty published_files, not an exception. A blanket except CivicClerkError catches everything the client can raise.

The library deliberately emits no logs of its own. Typed exceptions provide safe library-level diagnostics, with credentials removed from their URLs; configure logging or event hooks on your injected httpx.Client when you need request timing, retry, or wire-level observability. Ensure that application or HTTP-client logging also redacts signed URLs, because models retain their full download URLs for making requests.

Reaching unmodeled fields

Every model keeps its unmodified API payload in .raw. When the upstream API grows a field the library doesn't model yet, it is already reachable:

event.raw["secondaryIanaTimeZone"]  # anything the API sent