Metadata-Version: 2.4
Name: unofficial-mural-python-client
Version: 0.1.0
Summary: Unofficial, community-maintained typed Python client for the MURAL Public API (OpenAPI 3.1 spec-accurate)
Author: Francisco Pandol
License-Expression: MIT
License-File: LICENSE
Keywords: httpx,mural,mural-api,pydantic,sdk,unofficial
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.13
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2.7
Requires-Dist: python-dotenv>=1.0
Requires-Dist: typer>=0.12
Provides-Extra: dev
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Requires-Dist: ty>=0.0.56; extra == 'dev'
Description-Content-Type: text/markdown

# unofficial-mural-python-client

> **Unofficial, community-maintained project.** Not affiliated with, endorsed by, or supported by
> MURAL. For official resources, see [developers.mural.co](https://developers.mural.co).

A typed Python client for the [MURAL Public API](https://developers.mural.co), covering all
76 endpoints of the OpenAPI 3.1 spec — murals,
widgets (all 10 types), rooms, workspaces, templates, search, and user identity.

- **Spec-accurate**: every request/response model is a pydantic v2 model hand-derived from
  the spec — camelCase wire format, snake_case Python attributes, enums as `Literal`s.
- **Client-side validation**: request bodies are validated before the HTTP call, so
  malformed input fails fast instead of via a 400.
- **OAuth2 built in**: authorization-code flow, automatic token refresh on 401, and an
  interactive localhost helper for getting your first token.
- **Transparent pagination**: every list endpoint has a raw single-page method and an
  `iter_*` generator that follows `next` cursors.
- **Typed errors**: HTTP errors map to an exception hierarchy carrying the parsed MURAL
  error body.

## Install

```bash
uv add unofficial-mural-python-client
```

### Install the latest development version

To track `main` (or a specific tag) instead of the latest PyPI release:

```bash
uv add git+https://github.com/franpandol/unofficial-mural-python-client
```

Pin to a tag for reproducible installs, e.g.
`git+https://github.com/franpandol/unofficial-mural-python-client@v0.1.0`.

### Contributing / running from source

```bash
git clone https://github.com/franpandol/unofficial-mural-python-client
cd unofficial-mural-python-client
uv sync --extra dev
```

## Set up a MURAL OAuth app

1. Sign in at [developers.mural.co](https://developers.mural.co) and create a new app.
2. Add the redirect URI `http://localhost:8765/callback` (used by the local OAuth helper).
3. Select the scopes you need — least privilege is supported; e.g. a read-only integration
   only needs `murals:read` + `identity:read`.
4. Copy `.env.example` to `.env` and fill in `MURAL_CLIENT_ID`, `MURAL_CLIENT_SECRET`,
   and `MURAL_REDIRECT_URI`.

If MURAL shows "The redirect URL is invalid", the app dashboard does not allow the exact
`MURAL_REDIRECT_URI` being sent. For the quickstart, add `http://localhost:8765/callback`
exactly: `http` (not `https`), `localhost` (not `127.0.0.1`), port `8765`, path
`/callback`, and no trailing slash.

If MURAL shows "Required permission scopes not recognized", add every requested scope to
that same app's permission-scope allowlist. The quickstart requests `identity:read`,
`murals:read`, and `murals:write`; `murals:write` is required because the smoke test
creates widgets and deletes the test mural.

## Quickstart

```python
from mural import MuralClient, MuralOAuth, run_local_oauth_flow
from mural.models import CreateMuralRequest, CreateStickyNoteWidget

oauth = MuralOAuth.from_env(scopes=["identity:read", "murals:read", "murals:write"])
token = run_local_oauth_flow(oauth)  # opens the browser, catches the redirect

with MuralClient(token, oauth=oauth) as client:
    me = client.users.me()

    mural = client.murals.create(CreateMuralRequest(room_id=1234567890, title="Retro"))

    client.widgets.create_sticky_notes(
        mural.id,
        [CreateStickyNoteWidget(shape="rectangle", x=100, y=100, text="Went well ✅")],
    )

    for widget in client.widgets.iter_list(mural.id):
        print(widget.type, widget.id)
```

Or run the end-to-end smoke test (creates and deletes a real mural):

```bash
python examples/quickstart.py --room-id <numeric-room-id>
```

See [`examples/README.md`](examples/README.md) for the full set of runnable examples and a
suggested reading order.

## CLI

The package installs a Typer-powered `mural` command:

```bash
uv sync --extra dev
uv run mural --help
```

Authenticate once with the same OAuth environment variables used by the SDK:

```bash
uv run mural auth login \
  --scope identity:read \
  --scope murals:read \
  --scope murals:write

uv run mural auth whoami
```

Common commands print JSON and reuse the SDK's typed request/response models:

```bash
uv run mural workspaces list --limit 10
uv run mural workspaces rooms <workspace-id>
uv run mural rooms create --workspace-id <workspace-id> --name "Project room" --type private
uv run mural murals create --room-id <numeric-room-id> --title "Retro"
uv run mural widgets create-sticky-note <mural-id> --text "Went well" --x 100 --y 100
uv run mural search murals <workspace-id> --query "retro"
```

By default, OAuth tokens are stored at `~/.config/mural/token.json` with private file
permissions. Use `--token-path <path>` on auth and API commands to override it.

### Token persistence and auto-refresh

`MuralClient` refreshes the access token once on a 401 when built with an `oauth` and a
token that has a `refresh_token`. Persist rotated tokens via the callback:

```python
client = MuralClient(token, oauth=oauth, on_token_refresh=save_token_somewhere)
```

### Pagination

```python
page = client.workspaces.list(limit=25)          # one page: page.value, page.next
for ws in client.workspaces.iter_list():          # all pages, transparently
    print(ws.name)
```

### Errors

```python
from mural import MuralNotFoundError

try:
    client.murals.get("workspace1234.000")
except MuralNotFoundError as error:
    print(error.status_code, error.error.code, error.error.message)
```

`MuralBadRequestError` (400), `MuralAuthError` (401), `MuralPermissionError` (403),
`MuralNotFoundError` (404), `MuralRateLimitError` (429), `MuralServerError` (5xx) — all
subclasses of `MuralAPIError`.

## Resource map

| Attribute | Endpoints |
|---|---|
| `client.murals` | CRUD, duplicate, export, access info, assets, private mode, chat, tags, timer, users/permissions, visitor settings, voting sessions |
| `client.widgets` | list/get/delete + typed create/update per type: sticky note*, shape*, textbox*, title*, area, arrow, comment, file, image, table |
| `client.rooms` | CRUD, folders, murals listing, members, invite/remove |
| `client.workspaces` | list/get, murals, rooms, templates, invite |
| `client.templates` | default templates, create-from-mural, delete, create-mural-from-template |
| `client.search` | murals, rooms, templates within a workspace |
| `client.users` | `/users/me` |

\* bulk endpoints — take a list of bodies, return a list of created widgets, matching the API.

## Tests

```bash
pytest --cov          # mocked with respx; no network, 98 tests, 99% coverage
```
