Python toolkit for the Baltimore City Mayor's Office of Performance and Innovation (OPI).
Project description
Camden
Baltimore's civic data SDK.
Named for Camden Yards, the Inner Harbor rail-and-ballpark hub where Baltimore's traffic gathers and arrives — just as this SDK gathers the city's data and delivers it with provenance.
Camden gives analysts and data staff in the Baltimore City Mayor's Office of Performance and Innovation (OPI) one consistent, safe, repeatable way to do the things we do every week: pull SharePoint lists into pandas, query city databases, fetch Open Baltimore layers, geocode addresses, send report emails, and post to Teams.
Part of the Baltimore civic foundations
Camden is one of three independently versioned foundations OPI maintains for Baltimore civic software:
- camden (this repo) — Python data SDK (
baltimore-camden): Graph/SharePoint, Open Baltimore, city databases, geocoding, provenance, delivery. - patapsco — platform / integration point (
baltimore-patapsco+@city-of-baltimore/patapsco): request IDs, safe error envelopes, observability, health/readiness, typed frontend client, and app scaffold. Authentication remains app-local today. - bromo / Baltimore Design System — TypeScript presentation layer (
@city-of-baltimore/bromo): tokens, theme, shell, accessible components.
How the pieces fit together: patapsco/docs/foundations.md is the canonical architecture overview. The shared naming rule (baltimore-<name> on PyPI, @city-of-baltimore/<name> on npm) is ADR-0005.
Contents
- Part of the Baltimore civic foundations
- Philosophy
- What Camden supports
- Installation
- Configuration
- Quickstart
- Coming from r4OPI
- Development
- Roadmap
- Contributing
- License
Philosophy
Camden exists to lower the ramp: reusable helpers, copy-pasteable recipes, and shared standards, so each analyst doesn't hand-roll their own auth, connection strings, and API calls.
- Python is the preferred default for new shared, repeatable, production-facing, integrated, or automated workflows.
- R remains welcome for statistical analysis, exploratory work, and existing R/Shiny assets.
Design principles: plain-English function names, predictable return types (pandas in, pandas out), small composable functions, no magic, no hard-coded credentials, and everything configurable without editing code.
What Camden supports
| Area | Module | What you get |
|---|---|---|
| Microsoft Graph auth | camden.graph |
GraphAuth (app-only or signed-in) and a hardened GraphClient — retries, paging, and $batch built in |
| SharePoint & OneDrive | camden.sharepoint |
Lists ↔ DataFrames with where= filters (non-indexed columns fall back gracefully), display-name labels, choice validation, person lookups, describe() diagnostics; schema admin (create_list/delete_list, typed column builders, add_column/delete_column); document libraries by path (ls, mkdir, versions, recursive download, upload, delete); file transfer by pasted link; extension-aware read_sharepoint() / write_sharepoint() |
camden.mail |
send_mail() through Graph (attachments, HTML, cc/bcc) — no ad hoc SMTP |
|
| Teams | camden.teams |
Post to channels by team/channel name, list team members/rosters, or fire a webhook from unattended jobs |
| Planner | camden.planner |
Plans/buckets/tasks as DataFrames; create and update tasks (buckets by name, assignees by email) with Planner's If-Match etag dance handled for you |
| Notifications | camden.notify |
Provider-neutral send() with Graph-mail, SendGrid, and Teams-webhook transports; sent/skipped/failed statuses instead of exceptions; "all" or "failover" strategies |
| City databases | camden.db |
Named registry (SQL Server + Postgres/PostGIS via kind), read_sql()/read_table() → DataFrames, write_sql() bulk writes, read_postgis() → GeoDataFrames, SQLAlchemy URLs, sql_in() |
| Artifact publication | camden.artifacts |
Storage-neutral immutable generations plus one manifest-last commit marker for related object sets; strict manifest parsing and payload size/SHA-256 verification keep readers off mixed or tampered generations |
| Open Baltimore | camden.opendata |
search_datasets() finds datasets; get_layer()/get_table()/download_layer()/iter_layer_rows() fetch them with stable ID ordering, duplicate guards, retries, and validated response shapes; provenance built in: describe_layer(), one-snapshot assert_fields() schema guards, where_since() incremental clauses, and an integrity-checked manifest_for() commit marker for every cached file |
| Census/ACS | camden.census |
acs() Baltimore City estimates at county/tract/block-group level, friendly variable names, numeric columns, join-ready geoid |
| Geocoding | camden.geocoding |
City ESRI locator with Census fallback, address scrubbing; batch geocode_frame() dedupes, rate-limits, and logs progress |
| Spatial recipes | camden.geo |
spatial_join() (which neighborhood is each point in — accepts plain lon/lat DataFrames), buffer_feet() (no accidental degree buffers), points_from_frame(), reproject() with named CRS constants |
| Excel reports | camden.excel |
write_excel(): branded headers, frozen rows, autofilter, currency/percent/date formats — one call from DataFrame to presentable workbook |
| Report templates | camden.reporting |
html_report() (branded email-ready HTML from DataFrames + text) and excel_report() (About sheet with title/timestamp/run id + one sheet per section) |
| Validation & diffs | camden.validate |
check_frame() — required columns/dtypes, nulls, uniqueness, allowed values, all problems in one teaching error; diff_frames() — keyed add/update/delete plans that power list sync |
| Job runtime | camden.runtime |
configure_logging() (UTC, run-id correlation, quiet deps), log_duration() timing on success and failure, safe_summary() config logging with secrets redacted |
| City utilities | camden.dates, camden.formats, camden.branding |
Fiscal years, dollar formatting, CCF→gallons, OPI brand palette |
What Camden is not: a stats library, a dashboarding framework, or a place for one-off analysis code. If a helper is only useful to one project, it belongs in that project.
Installation
Camden supports Python 3.13 and 3.14. The repository also pins the family-wide
Node.js 24 tooling baseline in .nvmrc; Camden itself ships no JavaScript
runtime or npm package.
Releases are published to PyPI as baltimore-camden from a matching vX.Y.Z
GitHub release through OIDC trusted publishing. Consumer applications pin a
released version rather than a moving Git branch.
The PyPI project and trusted-publisher binding are active; releases publish through the workflow without a stored registry token.
The repository source is the audited 0.8.3 release candidate. Until its matching tag and OIDC workflow publish successfully, registry consumers remain on 0.8.2; do not substitute a local path for downstream proof.
# with uv (preferred)
uv add "baltimore-camden[all]==0.8.2"
# with pip
pip install "baltimore-camden[all]==0.8.2"
Extras keep the core light — install only what you need:
| Extra | Adds | Needed for |
|---|---|---|
| (none) | httpx, msal, pandas, pyarrow, pydantic | Graph, SharePoint, mail, Teams, Open Baltimore tables/Parquet, geocoding, utilities |
[db] |
pyodbc, SQLAlchemy | camden.db SQL Server profiles |
[postgres] |
psycopg, SQLAlchemy | camden.db Postgres/PostGIS profiles |
[excel] |
openpyxl | camden.excel styled workbooks, .xlsx writes in write_sharepoint |
[geo] |
geopandas | camden.opendata layers, spatial file reading/writing |
[all] |
everything above | — |
Configuration
Camden reads CAMDEN_* environment variables (or a local .env file — see .env.example). Every value has an OPI default, and the legacy variables opi_graph_id / opi_graph_secret still work, so most existing machines need zero setup.
| Variable | Default | Purpose |
|---|---|---|
CAMDEN_CLIENT_ID |
legacy opi_graph_id |
Entra app registration ID |
CAMDEN_CLIENT_SECRET |
legacy opi_graph_secret |
Client secret (app-only auth) |
CAMDEN_TENANT |
bmore.onmicrosoft.com |
Entra tenant |
CAMDEN_DEFAULT_SITE_URL |
OPI SharePoint site | Site used when none is given |
CAMDEN_MAIL_SENDER |
— | From-mailbox for app-only email |
CAMDEN_SENDGRID_API_KEY |
— | Enables the SendGrid notification transport |
CAMDEN_CENSUS_API_KEY |
— | Census API key for camden.census (free signup) |
CAMDEN_SQL_DRIVER |
auto-detected | ODBC driver name |
CAMDEN_DATABASES_FILE |
— | TOML file extending the database registry |
CAMDEN_CACHE_DIR |
~/.cache/camden |
Where cached open-data pulls live |
CAMDEN_TIMEOUT |
30 |
HTTP timeout (seconds) for outbound requests |
CAMDEN_GRAPH_MAX_RETRIES |
4 |
Retry budget for transient Graph failures |
CAMDEN_GRAPH_BASE_URL |
Graph v1.0 | Microsoft Graph endpoint (rarely changed) |
CAMDEN_GRAPH_SCOPES |
.default |
OAuth scopes requested for Graph tokens |
Credentials never live in code, and Settings never prints secrets. Any function also accepts an explicit camden.Settings object when you need per-call overrides; inspect the resolved configuration with camden.get_settings().
Quickstart
from baltimore import camden
# SharePoint lists <-> pandas
site = camden.get_site() # the OPI site by default
updates = site.get_list("Book of Business - Weekly Updates")
on_track = updates.to_df(where="fields/Status eq 'On track'", labels="display")
updates.choices("Status") # valid options before you write
updates.append(new_rows_df) # person columns: use site.user_lookup_id(email)
# Sync a tracker safely: dry-run the plan, then apply (deletes are opt-in;
# columns you don't manage — like user-written notes — are never touched)
print(updates.diff(weekly_df, key="Title").summary()) # add 3, update 2, delete 0, unchanged 41
updates.sync(weekly_df, key="Title")
# Paste a link, get data — and write it back the same way
df = camden.read_sharepoint("https://bmore.sharepoint.com/:x:/r/sites/opi/...xlsx")
camden.write_sharepoint(df, "weekly.xlsx", folder_link) # DataFrame -> xlsx -> SharePoint
# Document libraries, by path
docs = site.drive() # the default "Documents" library
docs.ls("General", recursive=True) # name/path/kind/size/modified
docs.download_dir("General/Reports", "reports/")
docs.upload("weekly.xlsx", "General/Reports")
# City databases -> DataFrames (parameterize with ?, never f-strings)
permits = camden.read_sql(
"SELECT * FROM dbo.Permits WHERE issued_date >= ?", "citistat", params=["2026-01-01"]
)
neighborhoods = camden.read_table("dbo.Neighborhoods", "citistat")
camden.write_sql(
camden.check_frame(scores_df, columns={"score": "number"}, no_nulls=["agency"]),
"dbo.Scores", "opi-sandbox", if_exists="append",
)
conn = camden.connect("citistat") # raw connection when you need one
# Open Baltimore: search the catalog, then pull any dataset by name, page URL, or id
camden.search_datasets("parking fines") # title, dataset_id, snippet, modified
hoods = camden.get_shape("neighborhoods") # GeoDataFrame, EPSG:4326
vacants = camden.get_layer( # paste a dataset page straight from the catalog
"https://data.baltimorecity.gov/datasets/8ed6565f93a04455ae6bd5bcb7d272db_19/explore"
)
camden.describe_layer("real_property") # provenance: rows, last edit, owner
camden.assert_fields("vbns", {"NoticeDate": "Date"}) # fail fast on schema drift
camden.layer_fields("real_property") # inspect the schema before filtering
camden.layer_count("real_property") # size up a big pull first (~238k)
fines = camden.get_table( # JSON rows only — no geometry, much faster
"d2a2330d6a374ad39a24a0d7f7b58f19_0", # parking fines: ~11M rows
where="ViolFine > 500",
fields=["Citation", "Description", "ViolFine", "ViolDate"],
)
camden.download_layer( # ginormous pulls: stream to Parquet, flat memory
"d2a2330d6a374ad39a24a0d7f7b58f19_0", "fines.parquet", where="ViolDate >= DATE '2026-01-01'"
) # then pd.read_parquet(...) — or pl.scan_parquet(...) if you prefer polars
fines = camden.cached_table( # or cache it: downloads at most once per max_age
"d2a2330d6a374ad39a24a0d7f7b58f19_0",
where=camden.where_since("ViolDate", "2026-01-01"), # incremental pulls
max_age="6h",
) # cache_layer(...) returns the Parquet path instead; every download writes a
# manifest commit marker — camden.manifest_for(path) verifies the file hash and
# says exactly what query produced it — and iter_layer_rows() streams row-by-row
# Geocoding + spatial tagging
located = camden.geocode_frame(df["address"], fallback=True, rate_limit_per_second=5)
tagged = camden.spatial_join(located, hoods, keep=["Name"]) # neighborhood per address
# Styled Excel in one call (branded header, filters, number formats)
camden.write_excel(summary_df, "weekly.xlsx", currency=["TotalFines"], percent=["ShareResolved"])
# Email a report; post to Teams
camden.send_mail(to="team@baltimorecity.gov", subject="Weekly VBN report",
body="Attached.", attachments=["vbns.xlsx"])
camden.send_channel_message("OPI", "Book of Business", "Reporting period is <b>open</b>.")
# City utilities
camden.fiscal_year_label("2026-07-01") # "FY27"
camden.week_bounds("2026-07-08") # Fri-Thu reporting week: (Jul 3, Jul 9)
camden.dollars(121324520.45) # "$121,324,520.45"
Teams note: Graph only allows channel messages from signed-in users (delegated auth). For unattended/scheduled jobs, use
camden.teams.send_webhook()with a channel's Workflows URL — no credentials required.
Coming from r4OPI
| r4OPI | Camden |
|---|---|
sharepointLogin() |
GraphAuth() (automatic inside every client) |
sharepointConnect() |
camden.get_site() |
sharedWithMe(url) |
camden.sharepoint.download_file(url) — direct API, no browser |
sqlConnection("citistat") |
camden.connect("citistat") |
ordTbl() |
camden.db.ordered_select(conn, table) / ordered_columns() |
getGeoShapeFromOB("neighborhoods") |
camden.get_shape("neighborhoods") |
getOBdatasets("vbns") |
camden.get_dataset("vbns") |
cityGeocoder(addr) / censusGeocoder(addr) |
camden.geocode(addr) / camden.geocode(addr, source="census"); batches: camden.geocode_frame(addresses) |
scrubAddress(addr) |
camden.scrub_address(addr) |
whatFY(d) / whatFY(d, abbrv=TRUE) |
camden.fiscal_year(d) / camden.fiscal_year_label(d) |
dollarWithCents(x) |
camden.dollars(x) |
ccfToGallons(x) |
camden.ccf_to_gallons(x) |
vectorToSql(v) |
camden.sql_in(v) |
opiColors() |
camden.opi_colors() / camden.opi_palette() |
New in Camden (no r4OPI equivalent): send_mail, send_channel_message, send_webhook, upload_file, read_sharepoint, list update/delete, SharePoint URL parsing, shared_with_me() listing.
Development
One command contract for humans, CI, and agents — Taskfile.yml (install go-task):
git clone https://github.com/city-of-baltimore/camden && cd camden
task setup # dev environment from the committed uv.lock
task check # complete pre-push gate: static checks, coverage, package install, docs
task validate # locked setup + complete release gate
task fmt # auto-format and fix lint findings
task ci # lean cloud gate: locked setup, static checks, fast offline coverage
task docs-serve # live-preview the documentation site (mkdocs-material + mkdocstrings)
uv run pre-commit install # commit checks + complete task check before push
Unit tests run offline with no credentials. Live-tenant checks are opt-in: put credentials in .env and run task integration (each check skips individually when its target isn't configured — see .env.example).
See AGENTS.md for architecture, conventions, and testing patterns, and CONTRIBUTING.md for the pull-request workflow.
Roadmap
Direction lives in ROADMAP.md; execution is tracked with GitHub milestones and issues. Highlights: Excel read/write helpers, geospatial recipes (joins, buffers, CRS), Postgres/PostGIS support, and standard report outputs.
Contributing
Contributions are welcome — new helpers, recipes, docs, and bug reports. Branch, open a PR to main, and CI must pass. If you have an idea but aren't sure where it fits, open an issue and ask.
License
Distributed under the MIT License. See LICENSE for more information.
Maintainers
Mayor's Office of Performance and Innovation (OPI).
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 baltimore_camden-0.8.3.tar.gz.
File metadata
- Download URL: baltimore_camden-0.8.3.tar.gz
- Upload date:
- Size: 93.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2a32e097e98f15db1a78bf1e0ca202260f24c7059234702707fa1c6027bdad03
|
|
| MD5 |
b181a80a596528df94fd81bd9232d897
|
|
| BLAKE2b-256 |
170c4a7e7b9293677e587a2dd7b0382fd2d9047d934d965d85b3b58a544f498d
|
File details
Details for the file baltimore_camden-0.8.3-py3-none-any.whl.
File metadata
- Download URL: baltimore_camden-0.8.3-py3-none-any.whl
- Upload date:
- Size: 121.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0cfd1e61e7d7ebcdc0bab6725a35e0295e2dd4ab4bd5c202d5c9f5ed1ad4e102
|
|
| MD5 |
37202d3ed25852daa92846523a4651d6
|
|
| BLAKE2b-256 |
4b7be07fa4557018edb376a4f348053eb9dc7ff652648bb1a35defa80d3101b3
|