Metadata-Version: 2.5
Name: educate-custodian-consumer
Version: 0.1.5
Summary: Typed runtime config and secrets for Educate! apps, backed by Custodian
Project-URL: Repository, https://github.com/experienceeducate/educate-secrets-manager
License-Expression: MIT
License-File: LICENSE
Keywords: configuration,fastapi,pydantic,secrets
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Pydantic :: 2
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: httpx>=0.27
Requires-Dist: nats-py>=2.7
Requires-Dist: pydantic-settings>=2.3
Requires-Dist: pydantic>=2.7
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Description-Content-Type: text/markdown

# educate-custodian-consumer

Typed runtime config and secrets for Educate! apps, backed by
[Custodian](https://github.com/experienceeducate/educate-secrets-manager).

Declare what your app needs as ordinary typed fields. This package fetches
them at startup, keeps them current when they change, and gets out of the
way — application code reads a plain attribute and never knows that one
value came from a `.env` file and another from an encrypted store over the
network, or that the second one silently rotated twenty minutes into the
process's life.

```python
from typing import Any
from educate_custodian_consumer import CustodianSettings, from_config, from_secret

class Settings(CustodianSettings):
    clerk_secret_key: str = from_secret()
    database_url: str = from_secret("postgresql://localhost:5432/sample_dev")
    max_upload_size_mb: int = from_config(10)
    checkout_rollout: dict[str, Any] = from_config({})
    registration_flow_enabled: bool = from_config(False, name="ENABLE_NEW_REGISTRATION_FLOW")

    log_level: str = "INFO"   # plain env config, read identically
```

```python
from contextlib import asynccontextmanager
from educate_custodian_consumer import CustodianClient
from fastapi import FastAPI

settings = Settings()
client = CustodianClient(settings)

@asynccontextmanager
async def lifespan(app: FastAPI):
    await client.start()          # fetch, assert fully loaded, subscribe
    try:
        yield
    finally:
        await client.close()

app = FastAPI(lifespan=lifespan)

@app.get("/limits")
async def limits():
    return {"max_upload_size_mb": settings.max_upload_size_mb}   # always current
```

FastAPI is the example, not a dependency: the client is `httpx` + `nats-py`
+ pydantic, and works the same from a worker or a script.

## Install

```bash
pip install educate-custodian-consumer
```

Python 3.12+.

## Configuration

Every setting below is read from the environment (or `.env`), and only from
there. They say *where* to fetch and *with what*, so they can't themselves
come from Custodian.

| Env var | Default | |
|---|---|---|
| `CUSTODIAN_ORG_SLUG` | — | required |
| `CUSTODIAN_APP_SLUG` | — | required |
| `CUSTODIAN_ENV_SLUG` | — | required |
| `CUSTODIAN_API_KEY` | — | required; the `sk_live_...` service-account key |
| `CUSTODIAN_BASE_URL` | `http://localhost:8080` | |
| `CUSTODIAN_NATS_URL` | `nats://localhost:4222` | empty disables live updates |
| `CUSTODIAN_FETCH_MODE` | `auto` | `auto`, `batch` or `per_name` |
| `CUSTODIAN_TOKEN_REFRESH_MARGIN_SECONDS` | `60` | |
| `CUSTODIAN_HTTP_TIMEOUT_SECONDS` | `10.0` | |

## How values resolve

Three layers, each overriding the one before:

1. **Field default** — what you get with no configuration anywhere.
2. **Environment** — `.env` and real env vars, pydantic-settings' normal job.
3. **Custodian** — fetched at startup, re-fetched on change.

**Custodian wins.** Env is the bootstrap and offline-dev layer; Custodian is
authoritative at runtime.

A field marked `from_secret()` / `from_config()` with no default *must* be
supplied by one of the layers: `start()` raises rather than let the app
serve traffic half-configured. Give a field a real default and it becomes a
genuine fallback instead.

Names match case-insensitively, so a `clerk_secret_key` field resolves the
`CLERK_SECRET_KEY` the console shows. Pass `name=` only when a field can't
be named after its secret at all.

## Live updates

`start()` subscribes to this app/environment's change events and re-fetches
the affected field when one arrives — the event says *that* something
changed, never what it changed to, so the value always comes from the API.
Rotating a secret in the console updates every running replica within about
a second, with no restart and no polling.

Deleting a value in Custodian reverts the field to whatever env or the field
default supplied, rather than leaving it stale.

Set `CUSTODIAN_NATS_URL=""` to disable this. It's supported, and it's a
downgrade the package logs loudly: the process then holds the values it
booted with until it restarts.

## `missing` vs. `unreadable`

The distinction the client exists to get right. "No value here" means fall
back to the layer underneath. "A value exists and Custodian could not
produce it" means **keep what you have and never fall back** — falling back
asserts that no value exists, and for that name one does.

So an unreadable secret fails startup, even when an env fallback would have
satisfied the field. A runtime refresh does the opposite: it logs the name
and Custodian's `request_id`, and keeps serving on the value it has.

## `fetch_mode`

`auto` (the default) fetches everything in two requests where the Custodian
it's pointed at supports batch endpoints, and falls back to one request per
field — for the life of the process, logged once — where it doesn't. That
makes this package installable against a deployment that hasn't taken the
batch endpoints yet, and it picks up the improvement the day that deployment
does.

`batch` refuses to fall back. `per_name` pins the one-request-per-field
behavior.

## What this package will never do

- **No writes.** No create, no rotate, no approvals, no admin surface. The
  `-consumer` in the name is the promise.
- **No secret value in any log line, `repr`, or exception message.** Errors
  carry the name and scope; `loaded_from_custodian` returns names only, and
  is safe in a health check.
- **No on-disk cache.** Nothing is written to a file, a temp dir, or a
  `~/.custodian` anything.
- **No env var read** other than the `CUSTODIAN_*` set above.

## License

MIT — see [LICENSE](LICENSE).
