API reference
Client
Read-only client for one CivicClerk tenant's public API.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tenant
|
str
|
The tenant's subdomain — the |
required |
http_client
|
Client | None
|
Optional preconfigured |
None
|
min_request_interval
|
float
|
Minimum seconds between requests — the
client sleeps as needed so consecutive requests (including
pagination and downloads) stay at least this far apart.
Defaults to |
1.0
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Security
Attacker-influenced request targets. Attachment downloads follow
AgendaAttachment.download_url verbatim, event pagination follows
the response's absolute @odata.nextLink, and the library-owned
client follows redirects for every request. A malicious or
compromised tenant can therefore choose a host the client connects
to — including internal or link-local addresses (server-side request
forgery). Any header on the injected http_client is sent along,
and httpx strips only Authorization on a cross-origin
redirect, not custom headers or cookies. Do not put credentials on
the injected client; when using untrusted tenants, isolate the
network and consider an http_client with
follow_redirects=False.
Exception diagnostics retain only a URL's scheme, host, port, and path. Userinfo, query strings, and fragments are omitted so signed attachment credentials and continuation tokens do not enter routine exception logs.
No response-size bounds. The client streams downloads to disk in
chunks (large files never load fully into memory), but neither
downloads nor JSON list responses are capped: a hostile or broken
endpoint can exhaust disk (downloads) or memory (a listing page is
parsed whole). Bounding response size is the caller's
responsibility, alongside retries, caching, and rate limiting —
enforce it in the injected http_client or around the call.
Example
with CivicClerkClient("exampletown") as client: ... for category in client.get_event_categories(): ... print(category.id, category.name)
get_event_categories
get_event_categories() -> list[EventCategory]
Fetch the tenant's meeting categories (boards and committees).
Returns:
| Type | Description |
|---|---|
list[EventCategory]
|
All categories the tenant publishes, in API order. |
Raises:
| Type | Description |
|---|---|
UnknownTenantError
|
If the tenant subdomain does not exist. |
APIStatusError
|
If the API responds with an error status. |
NetworkError
|
If the request fails at the network level. |
PayloadError
|
If the response body is not usable. |
iter_events
iter_events(
*,
category_id: int | None = None,
from_date: date | None = None,
to_date: date | None = None,
) -> Iterator[Event]
Iterate the tenant's meetings, oldest first, fetching lazily.
The API serves events in pages of 15; pages are fetched on demand
(one HTTP request per page), so consuming only the first few
events costs only the first request. Collect with list(...)
when you need the complete result. With no filters that is the
tenant's entire meeting history — potentially thousands of events
and hundreds of requests — so pass a date range for anything
interactive.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
category_id
|
int | None
|
Only events of this category (board/committee) —
see |
None
|
from_date
|
date | None
|
Only events on or after this date. |
None
|
to_date
|
date | None
|
Only events on or before this date. Dates are
interpreted on the tenant's local wall-clock timeline,
matching the API's datetime semantics (see
|
None
|
Yields:
| Type | Description |
|---|---|
Event
|
Matching events, ordered by start time ascending. |
Raises:
| Type | Description |
|---|---|
UnknownTenantError
|
If the tenant subdomain does not exist. |
APIStatusError
|
If the API responds with an error status. |
NetworkError
|
If the request fails at the network level. |
PayloadError
|
If a response body is not usable. |
get_agenda
get_agenda(agenda_id: int) -> Agenda
Fetch the structured agenda (sections, items, attachments) for a meeting.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agenda_id
|
int
|
The agenda's id — see |
required |
Returns:
| Type | Description |
|---|---|
Agenda
|
The agenda's full item tree; use |
Agenda
|
flatten it. |
Raises:
| Type | Description |
|---|---|
NotFoundError
|
If |
APIStatusError
|
If the API responds with another error status. |
NetworkError
|
If the request fails at the network level. |
PayloadError
|
If the response body is not usable. |
open_file
open_file(
file_id: int,
) -> AbstractContextManager[FileStream]
Open a meeting-level file's response without writing it to disk.
Use this when the destination filename depends on the response: the
yielded FileStream reports the file's content type, extension,
and size before its body is read, so a caller can compose a name
from PublishedFile.name (which has no extension) and the
stream's suffix. Performs exactly one HTTP request, paced like
any other.
The stream is only valid inside the with block; the response
closes on exit. To download to a path you have already chosen, use
download_file instead.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
file_id
|
int
|
A |
required |
Returns:
| Type | Description |
|---|---|
AbstractContextManager[FileStream]
|
A context manager yielding an open |
Raises:
| Type | Description |
|---|---|
NotFoundError
|
If |
APIStatusError
|
If the API responds with another error status. |
NetworkError
|
If the request fails at the network level. |
open_attachment
open_attachment(
attachment: AgendaAttachment,
) -> AbstractContextManager[FileStream]
Open an agenda attachment's response without writing it to disk.
The streaming counterpart to download_attachment, and the
attachment counterpart to open_file: the yielded FileStream
reports the attachment's content type, extension, and size before
its body is read, so a caller can compose a name from
AgendaAttachment.file_name (which has no extension) and the
stream's suffix.
The bytes come from attachment.download_url — a signed,
time-limited blob URL on a different host than the tenant API,
requested with the same HTTP client. Expired URLs surface as
APIStatusError.
Security
download_url is taken verbatim from the tenant's API
response, so the tenant chooses the host this connects to
(redirects included) — see the class Security note on
server-side request forgery and injected-client credentials
before fetching attachments from tenants you do not trust.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
attachment
|
AgendaAttachment
|
The attachment to open; must have a
|
required |
Returns:
| Type | Description |
|---|---|
AbstractContextManager[FileStream]
|
A context manager yielding an open |
AbstractContextManager[FileStream]
|
inside its |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
NotFoundError
|
If the response body is empty, which the API
also uses to mean "unavailable". Raised on entering the
|
APIStatusError
|
If the API responds with an error status (including an expired signed URL). |
NetworkError
|
If the request fails at the network level. |
download_file
download_file(file_id: int, destination: Path) -> Path
Download a meeting-level file to destination.
Streams the response in chunks, so large files never load fully
into memory. There is no existence-check endpoint (HEAD answers
405) and Range headers are ignored, so downloads cannot be
resumed — a failed download must be retried from the start.
The one-liner for when you already know the name you want. When the
name depends on the file's type, open the stream with open_file
instead: it reports the content type, extension, and size before the
body is read, and destination here is written verbatim.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
file_id
|
int
|
A |
required |
destination
|
Path
|
Local path to write the file to. Left untouched (no partial file) if the download fails. |
required |
Returns:
| Type | Description |
|---|---|
Path
|
|
Raises:
| Type | Description |
|---|---|
NotFoundError
|
If |
APIStatusError
|
If the API responds with another error status. |
NetworkError
|
If the request fails at the network level. |
download_attachment
download_attachment(
attachment: AgendaAttachment, destination: Path
) -> Path
Download an agenda attachment to destination via its signed URL.
Streams the response in chunks from attachment.download_url — a
signed, time-limited blob URL on a different host than the tenant
API, requested with the same injected HTTP client. Signed URLs
expire; a request against an expired one surfaces as
APIStatusError, not a distinct error type.
Security
download_url comes verbatim from the tenant's API response,
so the tenant chooses the host this connects to (redirects
included). See the class Security note on server-side
request forgery, injected-client credentials, and the absence
of a response-size cap before downloading from untrusted
tenants.
The one-liner for when you already know the name you want. When the
name depends on the file's type, open the stream with
open_attachment instead; destination here is written
verbatim.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
attachment
|
AgendaAttachment
|
The attachment to download; must have a
|
required |
destination
|
Path
|
Local path to write the file to. Left untouched (no partial file) if the download fails. |
required |
Returns:
| Type | Description |
|---|---|
Path
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
NotFoundError
|
If the response body is empty, which the API also uses to mean "unavailable". |
APIStatusError
|
If the API responds with an error status (including an expired signed URL). |
NetworkError
|
If the request fails at the network level. |
get_file_text
get_file_text(file_id: int) -> str | None
Fetch the API's best-effort plain-text extraction of a file.
This is a best-effort upstream feature, not a guarantee: the API
returns clean extracted text for some files, and an empty 200
response for others — including files that do have a text layer,
and invalid ids — without distinguishing why. None means the
API did not provide text; it does not mean the file lacks one.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
file_id
|
int
|
A |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The extracted text, or |
Raises:
| Type | Description |
|---|---|
NotFoundError
|
If |
APIStatusError
|
If the API responds with another error status. |
NetworkError
|
If the request fails at the network level. |
close
close() -> None
Close the underlying HTTP client.
Models
All models are frozen dataclasses. Each keeps the unmodified API payload in
its raw attribute, so upstream fields the library does not model yet stay
reachable.
One meeting (past or upcoming) on a tenant's calendar.
Datetime semantics: the API stamps a Z (UTC) suffix on values that
are actually local wall-clock times, so all datetimes on this model are
naive and carry the wall-clock reading as published — an event at
19:00 local arrives as 19:00, whatever the timezone.
id
instance-attribute
id: int
The event's stable numeric id.
starts_at
instance-attribute
starts_at: datetime
Scheduled start as a naive local wall-clock datetime.
name
class-attribute
instance-attribute
name: str | None = None
Event title.
description
class-attribute
instance-attribute
description: str | None = None
Event description, None when blank.
category_id
class-attribute
instance-attribute
category_id: int | None = None
Id of the event's category (board or committee).
category_name
class-attribute
instance-attribute
category_name: str | None = None
Name of the event's category.
agenda_id
class-attribute
instance-attribute
agenda_id: int | None = None
Key for the structured agenda, or None when no agenda is
published (the API encodes that as 0).
agenda_name
class-attribute
instance-attribute
agenda_name: str | None = None
Title of the published agenda.
duration
class-attribute
instance-attribute
duration: timedelta | None = None
Scheduled length, or None when the portal left it unset (the
API encodes that as zero hours and minutes).
location
class-attribute
instance-attribute
location: EventLocation | None = None
Physical venue, or None when no address is given.
virtual_meeting_url
class-attribute
instance-attribute
virtual_meeting_url: str | None = None
Join link (Zoom, Teams, ...) when the portal publishes one.
youtube_video_id
class-attribute
instance-attribute
youtube_video_id: str | None = None
YouTube id of the meeting recording, if any.
media_stream_url
class-attribute
instance-attribute
media_stream_url: str | None = None
Direct stream URL of the recording, if any.
published_files
class-attribute
instance-attribute
published_files: list[PublishedFile] = field(
default_factory=list
)
Meeting-level documents (agenda, packet, minutes, ...) published for this event.
raw
class-attribute
instance-attribute
raw: dict[str, Any] = field(
default_factory=dict, repr=False
)
The unmodified API object this model was parsed from.
recording_url
property
recording_url: str | None
A watchable URL for the meeting recording, if one exists.
Prefers the YouTube video when the portal uploaded one, falling
back to the direct media stream; None when the event has no
recording.
from_api
classmethod
from_api(raw: dict[str, Any]) -> Event
Build an Event from one Events response object.
Raises:
| Type | Description |
|---|---|
PayloadError
|
If the load-bearing |
A meeting category (board, commission, or committee) of a tenant.
id
instance-attribute
id: int
The category's stable numeric id, used to filter events.
name
class-attribute
instance-attribute
name: str | None = None
Human-readable category name (None if the API omits it).
sort_order
class-attribute
instance-attribute
sort_order: int | None = None
Position in the tenant's configured display order.
is_public
class-attribute
instance-attribute
is_public: bool | None = None
Whether the tenant marks the category public.
parent_id
class-attribute
instance-attribute
parent_id: int | None = None
Id of the parent category, or None for top-level categories
(the API encodes "no parent" as -1).
raw
class-attribute
instance-attribute
raw: dict[str, Any] = field(
default_factory=dict, repr=False
)
The unmodified API object this model was parsed from.
from_api
classmethod
from_api(raw: dict[str, Any]) -> EventCategory
Build an EventCategory from one API response object.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
dict[str, Any]
|
One entry of an |
required |
Returns:
| Type | Description |
|---|---|
EventCategory
|
The parsed category, with |
Raises:
| Type | Description |
|---|---|
PayloadError
|
If the load-bearing |
The physical address an event is held at.
All fields are best-effort; portals frequently use address1 as a
free-text room or venue description.
address1
class-attribute
instance-attribute
address1: str | None = None
First address line (often the venue or room).
address2
class-attribute
instance-attribute
address2: str | None = None
Second address line.
city
class-attribute
instance-attribute
city: str | None = None
City name.
state
class-attribute
instance-attribute
state: str | None = None
State or region.
zip_code
class-attribute
instance-attribute
zip_code: str | None = None
Postal code.
raw
class-attribute
instance-attribute
raw: dict[str, Any] = field(
default_factory=dict, repr=False
)
The unmodified API object this model was parsed from.
from_api
classmethod
from_api(raw: dict[str, Any]) -> EventLocation | None
Build an EventLocation, or None when it is empty.
The API always includes an eventLocation object; when every
address component is blank there is no usable location and this
returns None so callers can simply test truthiness.
One meeting-level document published alongside an event.
file_id
instance-attribute
file_id: int
Stable numeric id, used to download the file.
file_type
class-attribute
instance-attribute
file_type: str | None = None
The portal's label for the document ("Agenda", "Agenda Packet",
"Minutes", ...), or None if omitted.
name
class-attribute
instance-attribute
name: str | None = None
Human-readable file title.
published_at
class-attribute
instance-attribute
published_at: datetime | None = None
When the portal published the file, as a naive datetime (the API's
timezone markers are unreliable; see Event).
raw
class-attribute
instance-attribute
raw: dict[str, Any] = field(
default_factory=dict, repr=False
)
The unmodified API object this model was parsed from.
from_api
classmethod
from_api(raw: dict[str, Any]) -> PublishedFile
Build a PublishedFile from one publishedFiles entry.
Raises:
| Type | Description |
|---|---|
PayloadError
|
If the load-bearing |
The structured agenda (sections, items, attachments) for one meeting.
agenda_id
instance-attribute
agenda_id: int
The agenda's stable numeric id — matches Event.agenda_id
(see that attribute for the 0/"no agenda" sentinel, already
normalized to None there).
items
class-attribute
instance-attribute
items: list[AgendaItem] = field(default_factory=list)
Top-level agenda nodes, ordered by sort_order. Use
walk_items to flatten the whole tree.
raw
class-attribute
instance-attribute
raw: dict[str, Any] = field(
default_factory=dict, repr=False
)
The unmodified API response this model was parsed from.
walk_items
walk_items() -> Iterator[AgendaItem]
Yield every item in the agenda tree, depth-first in sort order.
Each item is yielded before its children, and children are visited
in sort_order before the next sibling — the same order the
portal displays the agenda in — so callers can flatten the
section/item tree into one ordered sequence without recursing
themselves.
from_api
classmethod
from_api(raw: dict[str, Any]) -> Agenda
Build an Agenda from a GET /Meetings/{agendaId} response.
Raises:
| Type | Description |
|---|---|
PayloadError
|
If the load-bearing |
One node (section or item) in a structured agenda's tree.
item_id
instance-attribute
item_id: int
Stable numeric id of the agenda item.
name
class-attribute
instance-attribute
name: str | None = None
Item title.
is_section
class-attribute
instance-attribute
is_section: bool = False
Whether this node is a section heading rather than a substantive agenda item.
sort_order
class-attribute
instance-attribute
sort_order: int | None = None
Position of this item within the whole agenda's flat display order (shared across nesting levels).
attachments
class-attribute
instance-attribute
attachments: list[AgendaAttachment] = field(
default_factory=list
)
Documents attached directly to this item.
children
class-attribute
instance-attribute
children: list[AgendaItem] = field(default_factory=list)
Nested items, ordered by sort_order.
raw
class-attribute
instance-attribute
raw: dict[str, Any] = field(
default_factory=dict, repr=False
)
The unmodified API object this model was parsed from.
from_api
classmethod
from_api(raw: dict[str, Any]) -> AgendaItem
Build an AgendaItem from one items/childItems entry.
Raises:
| Type | Description |
|---|---|
PayloadError
|
If the load-bearing |
One document attached to a structured agenda item.
attachment_id
instance-attribute
attachment_id: int
Stable numeric id of the attachment. Note this id is reused in a
different namespace by the file-download endpoint, so it must never be
used to synthesize a download URL — use download_url instead.
file_name
class-attribute
instance-attribute
file_name: str | None = None
Human-readable title of the attachment.
is_link
class-attribute
instance-attribute
is_link: bool = False
Whether this attachment is a link rather than a downloadable file —
link attachments have no download_url.
download_url
class-attribute
instance-attribute
download_url: str | None = None
Signed, time-limited URL to download the file directly, or None
when absent (typically because the attachment is a link, or the portal
has not published a file).
raw
class-attribute
instance-attribute
raw: dict[str, Any] = field(
default_factory=dict, repr=False
)
The unmodified API object this model was parsed from.
from_api
classmethod
from_api(raw: dict[str, Any]) -> AgendaAttachment
Build an AgendaAttachment from one attachmentsList entry.
Raises:
| Type | Description |
|---|---|
PayloadError
|
If the load-bearing |
Errors
Every operational error any libmuni client raises subclasses MuniError. The
CivicClerk client's errors subclass CivicClerkError, which in turn subclasses
MuniError, so a caller spanning platforms can catch them all at once. Two
Python built-ins stay outside the hierarchy by design: ValueError for an
invalid tenant or argument, and OSError for a failed local file write.
Bases: MuniError
Base class for every error the CivicClerk client raises.
Bases: APIStatusError
The tenant subdomain does not appear to exist.
The API returns an empty 404 both for unknown tenants and unknown
paths; because this library only requests endpoints that exist on
every tenant, a 404 on an always-present endpoint (such as
/EventCategories) is interpreted as "no such tenant" — most often
a typo in the tenant name.
Bases: APIStatusError
The id-parameterized resource does not exist for this tenant.
Raised by id-parameterized lookups (such as
CivicClerkClient.get_agenda) when the requested id has no data — unlike
UnknownTenantError, this says nothing about whether the tenant
itself exists. The API is inconsistent about how it signals this: some
invalid ids answer with a plain 404, while others answer 200 with an
empty/default payload (id reset to 0, no items) — both are
surfaced as this error.
Bases: CivicClerkError
The API answered with an error status code.
Attributes:
| Name | Type | Description |
|---|---|---|
status_code |
The HTTP status code of the response. |
|
url |
The URL that was requested. |
Bases: CivicClerkError
The request failed before a valid HTTP response arrived.
Covers DNS failures, refused connections, timeouts, and TLS problems.
The originating httpx.TransportError is the __cause__.
Bases: CivicClerkError
The API responded successfully but the body was not usable.
Raised when a response is not valid JSON, or when a load-bearing
field (an id or date the models cannot function without) is missing.
Optional fields never raise this — they parse to None.