Metadata-Version: 2.5
Name: vibedasher
Version: 4.3.20
Summary: Python SDK for Vibedasher, the home for the dashboards your AI built: query your datasets with typed SQL and drive hosted dashboards from your own code.
Project-URL: Homepage, https://vibedasher.com
Project-URL: Source, https://github.com/JulienGdnr/vibedasher
Author: Vibedasher
License: MIT
Keywords: analytics,api,bi,dashboards,sdk,vibedasher
Requires-Python: >=3.9
Requires-Dist: attrs>=22.2.0
Requires-Dist: httpx<0.29.0,>=0.23.0
Requires-Dist: msgpack>=1.0.0
Requires-Dist: python-dateutil>=2.8.0
Requires-Dist: typing-extensions>=4.0.0
Description-Content-Type: text/markdown

# vibedasher — Python SDK

Official Python SDK for [Vibedasher](https://vibedasher.com) — **the home for the
dashboards your AI built**. Your coding agent (Claude Code, Codex, Cursor, Gemini)
writes the dashboard as React + SQL; Vibedasher hosts it at a URL behind your login,
with per-viewer row filtering, embeds, refresh schedules, alerts and subscriptions.
Managed cloud (EU, Frankfurt) or self-hosted.

This SDK is the programmatic half of that. The method you'll use most is
`client.query(...)` — it runs SQL across your **managed datasets** and returns
**typed rows**, so you can also render a dashboard natively from your own backend.

## Install

```bash
pip install vibedasher
```

## Own the code — start here

Vibedasher hosts the dashboard and the data behind it (ETL, datasets, query). A
dashboard is never locked in: **eject it into your own repo** whenever you want the
frontend back, and wire it to your data with a single call:

```python
from vibedasher import Vibedasher

# Server-side (API key held on your server, never shipped to a browser).
client = Vibedasher(api_key="sk_...")

result = client.query(
    sql="SELECT region, AVG(lead_price_usd) AS avg_price FROM sales GROUP BY region",
    params={"region": "Caribe"},     # bound/escaped server-side, never interpolated
)

for col in result.columns:
    print(col.name, col.type)        # e.g. region string / avg_price number
for row in result.rows:
    print(row["region"], row["avg_price"])
```

- **`dataset_ids` is optional (plural).** Omit it and the server infers the dataset
  set from the aliases your `sql` references. Pass it to pin an exact set explicitly:
  `client.query(sql=..., dataset_ids=[12, 47], params=...)` — each id resolves
  server-side to that dataset's alias under RLS, and a dataset not in the set is
  rejected (deny-don't-drop). A mistyped/unknown alias raises
  `400 query_dataset_unresolved`.
- **`sql` is inline and alias-only** — it references dataset aliases, never a
  physical table.
- **Results are typed columnar** — `result.columns` are `QueryColumn(name, type)`
  with `type` ∈ `string | number | boolean | date | timestamp | json`, and
  `result.rows` are dicts keyed by column name.
- **Transport is hidden** — inline vs presigned object storage, MessagePack
  decoding, and transient retries all happen inside `query()`. One call in, typed
  rows out.

> `query()` is the one hand-written method (`_query.py`); everything else is
> generated (see below).

### Where the query runs: `type="wasm"`

By default the server executes your SQL and returns rows. Pass `type="wasm"` to
get a **plan** instead — the injected SQL plus one presigned Parquet per dataset —
and execute it yourself (in DuckDB-WASM in a browser, or plain DuckDB locally):

```python
result = client.query(sql="SELECT region, COUNT(*) FROM sales GROUP BY region", type="wasm")

if result.mode == "wasm":
    for src in result.plan.sources:
        print(src.alias, src.url, src.bytes)   # register each under `alias`
    print(result.plan.sql)                     # then run this
else:
    print(result.rows, result.fallback_reason) # ran server-side, and why
```

**Authorization does not move.** The server still authorizes, validates
alias-only SQL, and injects row filters — in that order, *before* the capability
check. Asking for wasm skips no gate; you get a plan only for data you were
already allowed to read.

**Asking for wasm does not guarantee getting it.** If any participating dataset
isn't wasm-capable — no current extract, an extract over the size cap, or a
mandatory row-level filter — the query runs server-side and the response says so
via `mode: "backend"` and a `fallback_reason`. It is never a silent downgrade, so
**always branch on `result.mode`, never on what you requested.**

`VibedasherEmbedClient` (scoped-token) has no wasm lane and raises `ValueError`
rather than quietly serving rows.

### Embed vs eject

- **Embed (iframe, zero-code):** paste a snippet; we host and render. Nothing to build.
- **Eject (this SDK, own-your-code):** pull the dashboard's code into your stack and
  feed it data via `client.query(...)`. Your app owns the rendering; only the data
  crosses the wire. See a runnable host app in `examples/nextjs-embed` (TS), the
  same contract as here.

### Client-side (no backend) auth

For a pure-frontend app, mint a short-TTL **scoped token** and use the same method
via the token-pinned client:

```python
from vibedasher import VibedasherEmbedClient

client = VibedasherEmbedClient(token=scoped_token)
result = client.query(sql=sql, params=params)
# The token pins the viz + datasets — dataset_ids are accepted for symmetry but the
# server enforces the token's bound set.
```

### Concurrency: a 503 is a limit, not an outage

The query engine has a finite, shared concurrency ceiling. A `503 Service
Unavailable` from `query()` means the request was throttled, not that the service
is down — the same SQL run serially succeeds. `query()` retries throttled
requests (429/502/503/504) with jittered backoff and honours `Retry-After`.

`httpx.Client` is synchronous, so one client is already one query at a time and
there is no in-flight gate here — that lives in the TypeScript SDK, where a
browser dashboard fans out on mount. If you thread this client yourself, keep the
width modest.

Errors raise `QueryHttpError` (a `httpx.HTTPStatusError` subclass, so existing
`except` clauses keep working) carrying `status_code`, the API's own message, and
`request_id` — quote that in a bug report.

## Everything else: resource methods (generated)

Datasets, uploads, viz metadata, API keys, etc. are resource-grouped on the same
client, generated from the OpenAPI spec with flat, fully typed signatures: path
params positional (in path order), then the request body, then the query params as
keyword-only kwargs. No `**kwargs`, no bare `Any`; a non-2xx raises
`VibedasherApiError` (`status_code`, `detail`, `request_id`).

```python
from vibedasher import Vibedasher, VibedasherApiError
from vibedasher.models import DatasetUpdate

client = Vibedasher(api_key="sk_...")
page = client.datasets.list(limit=50, folder_id=7)               # -> DatasetListResponse
ds = client.datasets.update(42, DatasetUpdate(name="renamed"))   # -> DatasetResponse
client.viz.delete(7)                                             # 204 -> None
try:
    client.viz.get(999999)
except VibedasherApiError as e:
    print(e.status_code, e.detail, e.request_id)
```

The low-level generated modules remain available for anything the facade does not
expose (`vibedasher.api.<tag>.<operation_id>` with `.sync()`, `.sync_detailed()`,
`.asyncio()`, `.asyncio_detailed()`), driven by `create_client(api_key)`.

Auth is an API key sent as `X-Api-Key` (mint one via `POST /v1/api-keys`). The
base URL defaults to `https://api.vibedasher.com/api`, one hostname that reads the
credential and routes to the region owning the account — so there is no region to
look up. `region="eu-central-1"` still works and pins the client to that region's
own host; `base_url` overrides both.

### Paging a list endpoint: `iter_all()`

Every cursor-paginated list endpoint returns one page plus a `nextKey`:

```json
{ "datasets": [ ... ], "nextKey": 812 }
```

`nextKey: null` means that was the last page — it is the **only** end-of-list
signal. Rather than hand-rolling that loop, iterate:

```python
for dataset in client.datasets.iter_all():
    print(dataset.name)                    # pages transparently, rows streamed

for dataset in client.datasets.iter_all(page_size=50, max_pages=10, folder_id=7):
    ...                                    # cap the work; extra kwargs are forwarded

for page in client.datasets.iter_pages():
    print(page.next_key)                   # page-at-a-time, e.g. to checkpoint
```

Nested collections take the method name:

```python
for upload in client.datasets.iter_all(method="list_uploads", dataset_id=4):
    ...
```

Notes:

- **Bounded memory** — rows are yielded as each page arrives, and `break` stops
  the requests immediately. Nothing is prefetched or accumulated.
- **`max_pages`** caps the work: iterating an unbounded table to exhaustion is a
  footgun, so the ceiling is one keyword away.
- **A repeated cursor raises `CursorNotAdvancingError`** instead of looping
  forever. That is a server bug in the endpoint's `nextKey`; the SDK surfaces it
  rather than absorbing it as a short read.
- For a paginated endpoint that is not on the resource, the standalone form
  works on any list callable: `from vibedasher import iter_all`.

## A bare `403 {"message":"Forbidden"}` is always the credential

The edge rejects a request before the application sees it, so its refusal body
is one word and nothing else. That one word stands for three different
mistakes, and the response cannot tell you which:

| what happened | what to do |
|---|---|
| no credential sent | set `X-Api-Key` |
| the key was sent as `Authorization: Bearer` | move it to `X-Api-Key` — `Authorization` is read only as a login session token, so a key placed there is never seen |
| the key was sent in `X-Api-Key` and refused | it is unknown, revoked or expired; mint a new one |

Two conclusions this response does **not** support, and both are the ones
usually drawn from it:

- **It is not a path problem.** With an accepted credential, an unknown path
  answers `404` and names the path in the body. A bare `Forbidden` never means
  the URL was wrong.
- **It is not a permissions problem.** Roles and resource access are decided by
  the application, which answers with a reason. The edge only ever asks whether
  a credential is valid, so a bare `Forbidden` is never "ask your human for
  access".

The single most common first-contact mistake is the second row: `Authorization:
Bearer <key>` returns `403` on *every* route, including plain reads, with a
perfectly good key.

Fix the header first, then read the body — once a credential is accepted, the
API distinguishes all four conditions on its own.

## Layout

- `vibedasher/_query.py` — the hand-written `query()` (transport, decode, retry, typed rows).
- `vibedasher/_pagination.py` — the hand-written `iter_all()` / `iter_pages()` cursor loop.
- `vibedasher/_facade.py` — GENERATED resource classes with the flat signatures + `VibedasherApiError`.
- `tests/test_facade_surface.py` — the blocking lint on `_facade.py` (no `**kwargs`, no bare `Any`, snake_case only).
- `vibedasher/api/<tag>/<operation>.py` — one module per API operation.
- `vibedasher/models/` — request/response models (attrs classes).
- `vibedasher/factory.py` — `create_client(api_key, region=..., base_url=...)`; `region` is optional (one hostname routes by credential).

> **Generated code** — do not edit `vibedasher/` by hand. Regenerate with `make py`
> from `packages/sdk/`. Source of truth: `../openapi.json`. `query()` and the
> pagination iterators are the deliberate exceptions — hand-written in
> `packages/sdk/py/{_query,_pagination}.py` and re-copied after each generation.
