Metadata-Version: 2.4
Name: horizonmedia-blu-sdk
Version: 1.2.3
Summary: Make a Python API a Blu module: verify Blu callers (users, services, your own IdP, API keys), route policies, and Concierge tools (MCP). FastAPI, Flask and Django adapters.
Author: Horizon Media
License-Expression: LicenseRef-Proprietary
Project-URL: Repository, https://github.com/HorizonMedia/blu-composable-experience
Classifier: Programming Language :: Python :: 3
Classifier: Framework :: FastAPI
Classifier: Framework :: Flask
Classifier: Framework :: Django
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.27
Requires-Dist: joserfc>=1.0
Requires-Dist: mcp>=2.0
Requires-Dist: pydantic>=2.5
Requires-Dist: starlette>=0.40
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.110; extra == "fastapi"
Provides-Extra: flask
Requires-Dist: flask>=3.0; extra == "flask"
Provides-Extra: django
Requires-Dist: django>=4.2; extra == "django"
Requires-Dist: asgiref>=3.7; extra == "django"
Provides-Extra: dev
Requires-Dist: fastapi>=0.110; extra == "dev"
Requires-Dist: flask>=3.0; extra == "dev"
Requires-Dist: django>=4.2; extra == "dev"
Requires-Dist: asgiref>=3.7; extra == "dev"
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: pyright>=1.1.380; extra == "dev"

# horizonmedia-blu-sdk (Python)

Make your Python API a Blu module. It's the Python counterpart of `@blu/sdk/server`:

- **Verify Blu's callers:** users signed in through Blu, other backends with Blu service tokens, users of your own identity provider, and API keys you issue.
- **Route policies.**
- **Tools for the Blu Concierge** (MCP).

There are adapters for **FastAPI**, **Flask** and **Django**. The core is framework-neutral.

Blu's front-end pieces (the React hooks, isolated frames, the Next.js adapter) stay in the JS packages. A Python team usually pairs this with a small JS front end, or exposes only an API and Concierge tools.

## Install

The SDK is public on PyPI as [`horizonmedia-blu-sdk`](https://pypi.org/project/horizonmedia-blu-sdk/). You still import it as `blu_sdk`. There's no token or private index to configure.

```
pip install "horizonmedia-blu-sdk[fastapi]~=1.2.3"
```

**pyproject.toml** (pip / uv):

```toml
[project]
dependencies = ["horizonmedia-blu-sdk[fastapi]~=1.2.3"]
```

**requirements.txt:** `horizonmedia-blu-sdk[fastapi]~=1.2.3`

**Poetry:** `poetry add "horizonmedia-blu-sdk[fastapi]@^1.2.3"`

Extras: `[fastapi]`, `[flask]` or `[django]`. Without an extra, you get the framework-neutral core and the ASGI middleware. `~=1.2.3` takes fixes and new 1.x features (any 1.x from 1.2.3 up) and never a 2.0. Commit your lock file so builds are repeatable. The GitHub release for each `sdk-py-v*` tag says what changed.

Before 1.2.3 the SDK was installed from Git tags (`blu-sdk @ git+https://github.com/HorizonMedia/blu-composable-experience@sdk-py-v…`). To move, replace that line with the one above and drop any token you set up to clone the repository. `blu check` warns about the old form.

## Quick start (FastAPI)

```python
from fastapi import Depends, FastAPI
from blu_sdk import BluPlatformAuth, ModuleAuth, Principal, PrincipalDirectory
from blu_sdk.asgi import CorsAllowlist
from blu_sdk.fastapi import BluFastAPI

BLU_ISSUER = os.environ["BLU_ISSUER"]  # this environment's Blu (its host-api URL)
SELF = os.environ["PUBLIC_URL"]  # this API's own URL

blu = BluFastAPI(
    ModuleAuth(
        [
            BluPlatformAuth(
                issuer=BLU_ISSUER,
                module_id="forecast",  # = blu.module.json "id"
                role_map={
                    "viewer": ["forecast:read"],  # YOUR decision: Blu role → your permissions
                    "analyst": ["forecast:read"],
                    "owner": ["forecast:read", "forecast:write"],
                },
            )
        ],
        api_url=SELF,
    ),
    directory=PrincipalDirectory(),  # Blu users recorded on first sight (no account needed)
)

app = FastAPI()
blu.install(app)  # refusals answer {"error", "detail"} with their status
app.add_middleware(CorsAllowlist, origins=[os.environ["BLU_SHELL_ORIGIN"]])


@app.get("/api/forecast")
async def forecast(p: Principal = Depends(blu.require({"blu": ["forecast:read"]}))):
    return rows_for(p.client_id)  # scope EVERY query by the verified client
```

Flask (`blu_sdk.flask.BluFlask`, decorator `@blu.require(...)`, `current_principal()`) and Django (`blu_sdk.django.configure(...)`, decorator `@require(...)`, `request.blu_principal`) work the same way. Each module's docstring has a complete example.

## The four ways in

| Entry point | Strategy | Who | Client comes from |
|---|---|---|---|
| `blu` | `BluPlatformAuth(issuer, module_id, role_map)` | a user signed in through Blu: in a Blu shell, through the Concierge, or your own app's "Sign in with Blu" | the token (`cid`) |
| `service` | `BluServiceAuth(issuer, module_id)` | another backend, with a Blu client-credentials token; scopes granted in the Blu Admin | `X-Client-Id`, from the clients the token lists |
| `team` | `TeamIdpAuth(issuer, audience, jwks)` | users of your own identity provider (Cognito, Auth0, Entra, …); map your claims with `to_principal` | your token |
| `api-key` | `ApiKeyAuth(keys)` | outside integrations, server-to-server only (requests with an `Origin` are refused); keys stored as `hash_api_key(...)` only | `X-Client-Id`, from the key's own grant |

Turn entry points on per deployment: `enabled_modes(os.environ.get("AUTH_MODES"))`.

**What Blu guarantees about a Blu token:**
- It's for **your module only** (audience `blu-module:<module_id>`). A token for another module, or the shell's own session token, is refused here.
- It's only issued while your module is enabled for that client.
- It lives 60 seconds, so a role or access change in the Blu Admin reaches you within a minute.

**Passwordless test sign-ins** (local personas, sign-in simulators) carry `amr: ["mock"]`. Only an API whose own `api_url` is on the developer's machine accepts them; leave `api_url` out and they're always refused.

## The principal

Every entry point yields the same `Principal`:

| Field | Meaning |
|---|---|
| `auth_mode` | which entry point the caller used |
| `kind` | `user` or `service` |
| `subject`, `display_name`, `email` | who it is |
| `client_id` | the active client. Scope every query by it; never trust a client id from the request body |
| `roles`, `permissions` | Blu roles, and your permissions after the role map (`p.can("forecast:read")`) |
| `key` | a stable identity (`<mode>\|<issuer>\|<subject>`). Key your own records on it |
| `via`, `actor` | for Blu callers: which app asked (`blu-host`, `blu-concierge`, your own app), and software acting for the user (`blu-concierge`) |

## Route policies

`require({...})` (or `ModuleAuth.authorize(principal, policy, asked_client_id)`) names which entry points may call a route, and the permissions each needs, all of which are required. An entry point that isn't named is refused even if it authenticated, so you can keep a route team-only:

```python
blu.require({"blu": ["forecast:read"], "team": ["forecast:read"], "service": ["forecast:read"]})
blu.require({"team": ["forecast:admin"]})  # never Blu
```

A user whose request names a different client than their credential gets `403 client_mismatch`, never an override.

## Concierge tools (MCP)

Wrap your read endpoints as tools. Each runs with the caller's own permissions and client, and a caller only sees the tools its role allows.

```python
from pydantic import BaseModel, Field
from blu_sdk import McpTool, ToolResult, link


class Args(BaseModel):
    months: int = Field(12, ge=1, le=12)


async def get_forecast(p, args: Args) -> ToolResult:
    rows = rows_for(p.client_id)[: args.months]
    return ToolResult(
        summary=f"{len(rows)} months for {p.client_id} …", data=rows, links=[link("forecast", {"year": "2027"}, "2027 forecast")]
    )  # routes from your manifest only


TOOLS = [
    McpTool(
        name="get_forecast",
        title="Spend forecast",
        description="Forecast team: monthly spend forecast for the current client. Use for …",
        input=Args,
        policy={"blu": ["forecast:read"]},
        read_only=True,
        run=get_forecast,
    )
]

blu.mount_mcp(app, name="forecast", version="1.0.0", tools=TOOLS, manifest=MANIFEST)  # POST /mcp
```

- **The manifest is required.** The endpoint refuses to start unless the tools match `mcp.tools` in your `blu.module.json` exactly: the names, the `readOnly` flags, and the `requires` (which must equal each tool's `policy`).
- **Links** name routes from your manifest, never URLs. Links to undeclared routes are dropped.
- **The Concierge only reads.** It never uses a write tool (`read_only=False`): the SDK doesn't list or run one for a caller acting for a user (the Concierge's tokens carry `act`), and Blu never offers one. Write tools still serve your own callers (users, services).
- **Flask and Django** serve `blu_sdk.mcp.McpEndpoint` from their ASGI side (see each adapter's docstring).

## CORS

`blu_sdk.asgi.CorsAllowlist(app, origins=[...])` echoes only the origins you list and answers preflights for `Authorization`. Others get no CORS headers, so the browser won't hand them your responses.

- An **inline** module is called from the Blu shell's origin.
- An **isolated** module's frame page calls from its own origin.
- Your own standalone app needs its own origin listed too.

Blu sends credentials as headers (Bearer), never cookies.

## Configuration per environment

| Env var (suggested) | Value |
|---|---|
| `BLU_ISSUER` | this environment's Blu issuer, `blu_issuer(stage)` (the JWKS is `<issuer>/.well-known/jwks.json`) |
| `BLU_STAGE` | `dev`, `staging` or `prod`; `blu_shell_origins(stage)` gives the shell's origin for CORS (`https://dev.blu-ce.horizonmedia.com`; prod: `https://blu-ce.horizonmedia.com`) |
| `PUBLIC_URL` | this API's own URL (`api_url`; also the manifest's `apiBase`) |
| `AUTH_MODES` | e.g. `blu,team` |

Never hard-code a Blu environment.

## Register a release

Once per release, after uploading its files to their versioned folder:

```sh
BLU_CI_TOKEN=… python -m blu_sdk register      # in the folder with blu.module.json
```

It sends the manifest to the Blu registry (`blu_sdk.REGISTRY_URL`, `https://registry.blu-ce.horizonmedia.com`; `BLU_REGISTRY_URL` or `--registry` overrides it). Releases are immutable: bump `version` for every release. Blu copies and reviews the release (read the report with your CI token at `GET <registry>/v1/modules/<id>/versions/<version>/report`), then Blu admins approve it environment by environment. Keep your API backward-compatible for about 30 days: an environment can be rolled back to an earlier front end.

## Check your integration

Run `blu check` from the JS SDK (`npx blu check`, or ask your Blu contact) in the folder with your `blu.module.json`. It reads Python too. It checks:
- your imports;
- that blu-sdk is declared, pinned to a release tag;
- that `module_id` matches the manifest;
- that the MCP endpoint is given the manifest and defines every declared tool;
- that links go only to declared routes.

## Tests

Strategies take `jwks=` as the key set itself instead of a URL, so tests can sign their own tokens:

```python
BluPlatformAuth(issuer="https://blu.test", module_id="forecast", role_map=ROLE_MAP, jwks={"keys": [public_jwk]})
```

## Same behaviour as the JS SDK

This SDK and `@blu/sdk/server` run the same authentication cases: real signed tokens, API keys and policies, in `packages/sdk/test/auth-vectors.json`. They must give the same principal, or the same status and error code, for every case. The manifest checks share `packages/contracts/test/vectors.json` in the same way. A rule change lands in both SDKs, or the tests fail.

## Releasing (Blu platform team)

1. Bump `version` in `pyproject.toml` and `__version__` in `src/blu_sdk/__init__.py`.
2. Run `pnpm test:backend` at the repository root. It runs this package's tests and both shared case files in both languages.
3. Tag the commit `sdk-py-v<version>` and push the tag: `git tag sdk-py-v1.0.1 && git push origin sdk-py-v1.0.1`. The `release-sdk-python` workflow then:
   - checks that the tag matches the version;
   - runs the tests;
   - publishes the wheel and source distribution to PyPI (trusted publishing from the `pypi` environment, no token);
   - creates a GitHub release with them attached.

   Tags are never moved or reused.
