Metadata-Version: 2.4
Name: baltimore-patapsco
Version: 0.18.1
Summary: Shared Django runtime foundations for Baltimore civic applications
Author: Mayor's Office of Performance and Innovation
License-Expression: MIT
License-File: LICENSE
Requires-Dist: asgiref>=3.9,<4.0
Requires-Dist: django>=6.0,<6.1
Requires-Dist: djangorestframework>=3.16,<4.0
Requires-Dist: drf-spectacular>=0.28,<0.31
Requires-Dist: pyyaml>=6.0.3,<7.0
Requires-Dist: sentry-sdk[django]>=2.64.0,<3.0 ; extra == 'observability'
Requires-Dist: dj-database-url>=3.0,<4.0 ; extra == 'runtime'
Requires-Dist: django-environ>=0.12,<0.15 ; extra == 'runtime'
Requires-Dist: whitenoise>=6.9,<7.0 ; extra == 'runtime'
Requires-Python: >=3.13
Provides-Extra: observability
Provides-Extra: runtime
Description-Content-Type: text/markdown

# baltimore-patapsco (Python)

Reusable Django runtime primitives for Baltimore civic applications.

```python
INSTALLED_APPS += ["baltimore.patapsco"]

REST_FRAMEWORK = {
    "EXCEPTION_HANDLER": "baltimore.patapsco.api.exception_handler",
}

urlpatterns = [path("", include("baltimore.patapsco.urls"))]
```

Place `RequestContextMiddleware` early in `MIDDLEWARE` — after
`SecurityMiddleware` (and WhiteNoise when present), before session, auth, and
anything that can short-circuit a response — so every later middleware, view,
and log line sees the bound request ID. The reference app shows the canonical
order:

```python
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "baltimore.patapsco.observability.RequestContextMiddleware",
    # sessions, common, csrf, auth, messages, clickjacking...
]
```

## Stable health and readiness views

Applications that mount or extend the operational views directly use the public
facade:

```python
from baltimore.patapsco.api import HealthView, ReadinessView
```

`HealthView` is process-only liveness. `ReadinessView` runs the established
database check and returns its public-safe 200 or 503 status document. Both use
no authentication classes and `AllowAny`, and both remain ordinary DRF
`APIView` classes with `as_view()`. Both stay outside `ATOMIC_REQUESTS` on every
database, subclasses included, so an outage is answered by the view, not a 500. A database failure is reported once per
worker as it begins, at error level with the exception; later failed probes log
`readiness.database_unavailable` at warning level with only the exception type,
and the first healthy probe logs `readiness.database_recovered`. A probe every
few seconds during an outage therefore sends one Sentry event, not one per probe.

`HealthView` and `DiagnosticsView` render their errors in the Patapsco envelope
their OpenAPI declares, whatever the app's `REST_FRAMEWORK["EXCEPTION_HANDLER"]`
is. A subclass may override `get_exception_handler()` and then owns its schema.

An application may subclass either class, override `get()`, and call
`super().get(request)` before composing app-owned public-safe checks. The parent
response status, request ID, and database result remain the starting contract;
the application owns an explicit serializer and OpenAPI annotation for any
extended payload.

The previous `baltimore.patapsco.api.views` imports resolve to the same class
objects for compatibility. `DiagnosticsView` remains available only from that
module and through the packaged URL configuration; it is intentionally absent
from `baltimore.patapsco.api.__all__` because its staff/public policy is a
separate operational surface. The settings-free package root exports neither
view class.

`api_error_response()` and the DRF exception handler force every 5xx response to
the public `internal_error` code, generic message, and no details. Application
callers cannot opt a server failure out of that safety boundary. An unexpected
exception also rolls back every active database transaction managed by Django's
`ATOMIC_REQUESTS`, so a failed request cannot commit partial writes. Applications
retain ownership of explicitly managed transactions.

Use `build_logging_config()` for the shared JSON/pretty log shape. Modules are
incrementally adoptable; installing the package does not enable auth, email, or
Sentry by itself. Structured log context is copied and recursively redacted,
including credentials nested in mappings and sequences. Credential keys are
matched across case and separator conventions, including `clientAPIKey` and
`X-API-Key`; repeated safe context is preserved and cycles are cut safely.
A Django or DRF request object anywhere in a record, such as the `request` extra
Django adds to every `django.request` 4xx/5xx record, renders as
`{"method": ..., "path": ...}`, never as its repr, so a request object never
carries its query string into a log. Lines other loggers write are their own:
`runserver`'s `django.server` request line still prints the full URL.
`configure_sentry()` renders request objects in Sentry extras and log
breadcrumbs the same way.

Bind a record's identity once it is known and every later line of the request,
including `request.complete`, carries it under `context`:

```python
from baltimore.patapsco.observability import bind_log_context

bind_log_context(case_id=case.public_id)
```

Bind identifiers, never resident data: logs redact credentials, not civic
contact or location fields. `None` values are ignored, an explicit record
`extra` wins over a bound value, and outside a request the binding persists
unless the work runs inside `request_context()`. Bound values do not tag Sentry
events.

## File downloads

`stream_csv()` streams a CSV export that a client can always tell is incomplete,
and `content_disposition()` names a file safely for every client:

```python
from baltimore.patapsco.api import stream_csv


class CaseExportView(APIView):
    def get(self, request):
        rows = cases_for(request).values_list("number", "opened", "status").iterator()
        return stream_csv(request, rows, header=["Case", "Opened", "Status"], filename="cases.csv")
```

A download holds one 64 Ki-character chunk in memory whatever its row count,
under WSGI and ASGI. A failure before the first chunk is the view's ordinary
error response. A failure after it is logged as `download.stream_failed` with
the request ID and re-raised, so the server aborts the transfer instead of
ending it. `max_rows=` answers 413 with the error envelope (`export_too_large`,
`details.max_rows`) before anything is sent, and otherwise sends the file with a
Content-Length. `bom=True` adds the byte order mark Excel needs for non-ASCII
text. A request that carries `X-Download-End-Marker`, as `downloadFile()` in
`@city-of-baltimore/patapsco` sends, gets its token echoed and appended to the
body, so a cut is visible even where HTTP framing hides it (runserver, or a
proxy that speaks HTTP/1.0 to gunicorn). Call it from a synchronous view: it
reads the first rows before returning.

Every cell of the header and rows goes through `spreadsheet_safe_cell()`, with
no opt-out, so no cell opens in a spreadsheet as a formula. Text that starts
with TAB, CR or LF, or whose first character after whitespace is `=`, `+`, `-`,
`@` or a full-width form of one, gets a leading `'`. Typed numbers and text
that is exactly a negative decimal (`-12.5`, not `-0500`) are left alone.
Vertical tab and form feed become a space, and the other C0 controls except
TAB, LF and CR are removed, so a pasted control never fails an export. Call it
yourself for a CSV written anywhere else, such as an import report on disk:

```python
from baltimore.patapsco.api import spreadsheet_safe_cell

writer.writerow([spreadsheet_safe_cell(value) for value in row])
```

For an XLSX text cell, `spreadsheet_safe_text()` gives the same text with the
controls scrubbed and no `'`. The writer's string type is the protection
(openpyxl `data_type = "s"`, xlsxwriter `write_string`); a `'` prefix never is,
because XLSX stores it as visible text:

```python
from baltimore.patapsco.api import spreadsheet_safe_text

cell = sheet.cell(row=row, column=column, value=spreadsheet_safe_text(value))
cell.data_type = "s"
```

The [wire contract](../../docs/reference/wire-contract.md#spreadsheet-safe-cells)
states the whole policy. The TypeScript SDK's `spreadsheetSafeText`,
`spreadsheetSafeCell` and `buildCsv` apply it in the browser, tested against the
same vectors.

`content_disposition(filename, disposition="attachment")` adds what Django's
header lacks: an ASCII `filename="..."` fallback beside `filename*` for
non-ASCII names, and a name reduced to its final path component, without
control or bidi-control characters, and cut to 255 UTF-8 bytes.

## Session lifecycle

An application that wants one session policy applies it in settings, installs
the middleware after `AuthenticationMiddleware`, and authenticates its API
with Patapsco's `SessionAuthentication`:

```python
from datetime import timedelta

from baltimore.patapsco.sessions import session_settings

globals().update(session_settings(idle_timeout=timedelta(hours=1)))
MIDDLEWARE += ["baltimore.patapsco.sessions.middleware.SessionActivityMiddleware"]
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = [
    "baltimore.patapsco.sessions.authentication.SessionAuthentication",
]
```

Only requests a person starts renew the session. Health, readiness, the status
read and views marked `@session_passive` never do. A signed-out API request
gets 401 with a `WWW-Authenticate` challenge. `baltimore.patapsco.urls` then
mounts `GET /api/session/status` and `POST /api/session/renew`.
[Session lifecycle](../../docs/use/sessions.md) is the adoption sequence, and
the [wire contract](../../docs/reference/wire-contract.md#session-lifecycle) is
the exact behaviour. Nothing changes until `session_settings()` is applied.

## Sentry privacy defaults

Install the `observability` extra and call the shared initializer only when a DSN
is configured:

```python
from baltimore.patapsco.observability import configure_sentry

configure_sentry(
    environment="production",
    release="my-app@1.2.3",
    traces_sample_rate=0.1,
)
```

`configure_sentry()` is deny-by-default: it disables automatic PII, request-body
capture, and stack-frame local variables. Its event pipeline also removes request
cookies and query strings, strips query/fragment data from request URLs and the
`Referer` request header, reduces
breadcrumb, span, and trace data URLs (`url`, `http.url`, `url.full`) to origin
plus path and removes `http.query`, `http.fragment`, `url.query`, and
`url.fragment` from them, and recursively redacts the shared credential-key fragments, Sentry's credential/session
denylist, and common civic contact and precise-location fields. This protection
also applies to transaction events. Safe operational fields such as request IDs,
routes, and HTTP methods remain available.

`traces_sampler` optionally accepts a callable with the SDK sampling-context
dictionary and a return rate from zero to one. It decides whether a trace is
recorded at creation, before event processing; it takes precedence over
`traces_sample_rate` and the parent decision. Apps own route exclusions and
whether to honor `context["parent_sampled"]`. Omitting the callback preserves
SDK defaults. Callback exceptions retain SDK behavior and can propagate, so app
samplers must handle their expected failure cases. Initialization failures still
return `False` without exposing configuration in logs.

Pass `dsn=""` to explicitly disable reporting even when `SENTRY_DSN` exists;
omitting the argument or passing `None` permits that environment fallback.
For domain enrichment, register a public SDK global event processor once, after
successful initialization. It runs before the shared scrubber and final privacy
hooks for errors and transactions. Retain stronger app policies there; do not
import private Patapsco filters or replace the client's privacy options.

Redaction is key-based. Applications must not attach sensitive values under
misleading keys or place resident data in exception messages. Product-specific
fields still require a privacy review before being added to Sentry context.

## Configuration surface

Everything the library reads from the environment or Django settings:

| Name                          | Kind           | Default               | Meaning                                                                                                                                                       |
| ----------------------------- | -------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APP_ENV`                     | env var        | `local`               | Environment label in diagnostics and Sentry events; overridden by `PATAPSCO_ENVIRONMENT` when set                                                             |
| `APP_RELEASE`                 | env var        | —                     | Release identifier in diagnostics and Sentry events; overridden by `PATAPSCO_RELEASE` when set                                                                |
| `APP_LOG_FORMAT`              | env var        | `json`                | `json` or `pretty` output from `build_logging_config()`                                                                                                       |
| `APP_LOG_LEVEL`               | env var        | `INFO`                | Root log level from `build_logging_config()`                                                                                                                  |
| `SENTRY_DSN`                  | env var        | —                     | Enables `configure_sentry()`; absent means Sentry stays off                                                                                                   |
| `PATAPSCO_SERVICE_NAME`       | Django setting | project name          | Service label in the diagnostics payload                                                                                                                      |
| `PATAPSCO_ENVIRONMENT`        | Django setting | `APP_ENV` env var     | Overrides the diagnostics `environment` field via Django settings instead of the process environment (a real `override_settings` test seam)                   |
| `PATAPSCO_RELEASE`            | Django setting | `APP_RELEASE` env var | Overrides the diagnostics `release` field via Django settings instead of the process environment (a real `override_settings` test seam)                       |
| `PATAPSCO_DIAGNOSTICS_PUBLIC` | Django setting | `False`               | **Security-relevant:** flips `/api/diagnostics` from `IsAdminUser` to `AllowAny`. Leave `False` unless the deployment deliberately publishes runtime metadata |
| `PATAPSCO_SESSIONS`           | Django setting | unset                 | The session policy `session_settings()` writes; setting it mounts the session endpoints and lets `SessionActivityMiddleware` load. Do not write it by hand    |
