Metadata-Version: 2.5
Name: incorta-sdk
Version: 0.5.0
Summary: Read Incorta schemas, tables, views, and columns as the signed-in user — OAuth 2.0 sessions from incorta-auth, no personal access tokens.
Project-URL: Repository, https://github.com/Incorta/IncortaSDK
Author: Incorta
License-Expression: MIT
License-File: LICENSE
Keywords: analytics,incorta,metadata,oauth,oidc,schema
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: httpx>=0.27
Requires-Dist: incorta-auth==0.5.0
Description-Content-Type: text/markdown

# incorta-sdk

[![PyPI version](https://img.shields.io/pypi/v/incorta-sdk)](https://pypi.org/project/incorta-sdk/)
[![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)

Read Incorta **schemas, tables, views, and columns** from Python, as the
**signed-in user**. Sessions come from
[`incorta-auth`](https://pypi.org/project/incorta-auth/) — OAuth 2.0 against the
authorization server built into Incorta — so this client has no identity of its
own and no personal access token to store.

The TypeScript twin is [`@incorta/sdk`](../sdk/README.md); the two speak the
same API, model the same distinctions, and share the same `INCORTA_*`
configuration.

```bash
pip install incorta-sdk
```

## Why it is scoped to a user

Every call carries a user's own Incorta access token, so Incorta filters the
results: two people hitting the same endpoint of your app see two different
catalogs. That is a property of the design, not a setting — there is no
app-level identity to over-share from, and nothing to revoke separately when
someone leaves.

It also means the *scoped* client, not the top-level one, is what you hold:

```text
IncortaClient          configuration + OAuth  (build once, at startup)
  └── for_request()  → IncortaUserClient      (build per request)
        ├── schemas
        └── tables
```

## Configuration

`IncortaClient()` takes the same settings as `IncortaAuth`, each falling back to
its environment variable:

| Setting | Environment variable | Meaning |
| --- | --- | --- |
| `incorta_url` | `INCORTA_URL` | Environment root **including** any context path (often `/incorta`), **without** `/api/v2` |
| `tenant` | `INCORTA_TENANT` | Tenant name, e.g. `default` |
| `client_id` | `INCORTA_CLIENT_ID` | OAuth client id (see `incorta-auth register`) |
| `client_secret` | `INCORTA_CLIENT_SECRET` | OAuth client secret |
| `secret` | `INCORTA_AUTH_SECRET` | Session-cookie encryption key (≥ 32 chars) |
| `internal_incorta_url` | `INCORTA_INTERNAL_URL` | Optional split-horizon address for server-to-server calls |

Plus two of its own: `timeout` (seconds, default `30`) and `max_retries`
(default `3`, for 429/5xx and network errors only — client errors are never
retried).

When the app already builds an `IncortaAuth` — the usual case, since it needs
one to serve logins — pass it in rather than letting this package create a
second:

```python
from incorta_auth import IncortaAuth
from incorta_sdk import IncortaClient

auth = IncortaAuth(app_access="catalog")
client = IncortaClient(auth=auth)
```

## Usage

### FastAPI

```python
from fastapi import Depends, FastAPI, Request
from incorta_auth import IncortaAuthMiddleware
from incorta_sdk import IncortaClient

app = FastAPI()
app.add_middleware(IncortaAuthMiddleware)   # serves /auth/*, requires a session

client = IncortaClient()                    # once, at startup

@app.get("/api/schemas")
def schemas(request: Request):
    incorta = client.for_request(request)   # per request, as this user
    return [
        {"name": schema.name, "description": schema.description}
        for schema in incorta.schemas.physical()
    ]

@app.get("/api/tables/{schema_name}/{table_name}")
def table(schema_name: str, table_name: str, request: Request):
    incorta = client.for_request(request)
    obj = incorta.tables.get(schema_name, table_name)
    return {
        "name": obj.qualified_name,
        "columns": [{"name": c.name, "type": c.data_type} for c in obj.columns],
    }
```

`for_request` works with any request object exposing a `headers` mapping —
Starlette/FastAPI, Django, and Flask all qualify. Given a session you already
hold (the FastAPI `get_session` dependency, or `request.state`), use
`client.for_session(session)`; given a raw header, `client.for_cookie_header(...)`.

### Streamlit

```python
import streamlit as st
from incorta_auth.streamlit import incorta_auth
from incorta_sdk import IncortaClient

incorta_auth.require_login()

client = st.cache_resource(IncortaClient)()
incorta = client.for_access_token(incorta_auth.access_token())

st.write([schema.name for schema in incorta.schemas.list()])
```

## Surface

### `IncortaClient`

| Member | Purpose |
| --- | --- |
| `auth` | The underlying `IncortaAuth` — mount its middleware for login |
| `for_request(request)` | Scoped client for the user behind a request |
| `for_session(session)` | Scoped client from a session you already read |
| `for_cookie_header(header)` | Scoped client from a raw `Cookie` header |
| `for_access_token(token, *, user=None)` | Scoped client from a bare access token |
| `info` | `base_url`, `tenant`, `timeout`, `max_retries` — no secrets |
| `close()` | Releases the connection pool (only if it built the auth instance) |

### `IncortaUserClient.schemas`

| Method | Returns |
| --- | --- |
| `list(type=..., limit=0, offset=0, sort_by=...)` | `list[SchemaInfo]` |
| `list_page(...)` | `Page[SchemaInfo]` — adds the server-side `total` |
| `iter_all(type=..., page_size=100)` | Iterator, one page fetched at a time |
| `physical()` / `business()` | Shorthands for the type filter |
| `get(name)` | `PhysicalSchema` or `BusinessSchema`, with contents |
| `exists(name)` | `bool` |

### `IncortaUserClient.tables`

| Method | Returns |
| --- | --- |
| `get(schema, name)` | `Table` or `View`, columns populated |
| `list(schema)` / `names(schema)` | Every object, or just their names |
| `columns(schema, name)` | `list[Column]` |
| `tables_only(schema)` / `views_only(schema)` | Filtered by kind |
| `exists(schema, name)` | `bool` |

Names are matched case-insensitively.

### `IncortaUserClient.data`

Reads rows out of business views. Fields are addressed by their fully qualified
name, `SCHEMA.VIEW.COLUMN`.

| Method | Returns |
| --- | --- |
| `query(measures, *, rows=..., aggregate=False, filters=..., sorting=..., page_size=0, ...)` | `QueryResult` |
| `iter_rows(measures, *, page_size=1000, ...)` | Iterator of rows, one page fetched at a time |
| `csv(measures, ...)` | `str` — the CSV Incorta rendered |
| `raw(body)` | The decoded response for a body sent verbatim |
| `build_body(measures, ...)` | The request body, without sending it |

```python
result = incorta.data.query(
    [Measure(field="HR_BS.Employee_BS.SALARY", aggregation="sum", label="payroll")],
    rows=["HR_BS.Employee_BS.JOB_TITLE"],
    aggregate=True,
    filters=[Filter.on("HR_BS.Employee_BS.JOB_TITLE", "IN_LIST", ["Accountant"])],
    sorting=[Sort(field="HR_BS.Employee_BS.JOB_TITLE", direction="desc")],
)
result.headers      # ["JOB_TITLE", "payroll"]
result.dicts()      # [{"JOB_TITLE": "Accountant", "payroll": "39600.0"}]
result.total_rows   # rows matching beyond this page
```

A bare string is shorthand for `Measure(field=...)` or `Dimension(field=...)`.
Cells always come back as strings — Incorta renders every value as text.

`aggregate=False` gives a flat extract; `aggregate=True` folds each measure with
its `aggregation` and groups by `rows` and `columns`.

### Models

Frozen dataclasses in `snake_case`. `PhysicalSchema` exposes `.tables`,
`BusinessSchema` exposes `.views`, and both expose `.objects` so type-agnostic
code works against either. Every model keeps the untouched API record in `.raw`,
so a field this package does not model is still reachable.

### Errors

Everything derives from `IncortaError`:

```text
IncortaError
├── IncortaConfigError            a setting is missing or malformed
├── IncortaAuthRequiredError      no signed-in user on this request
├── IncortaSessionExpiredError    the captured token aged out (raised locally)
├── IncortaConnectionError        environment unreachable
│   └── IncortaTimeoutError
├── IncortaAPIError               non-2xx, carrying .status_code and .code
│   ├── AuthenticationError       401 — Incorta refused the token
│   ├── PermissionDeniedError     403 — this user lacks access
│   ├── NotFoundError             404
│   │   └── SchemaNotFoundError
│   └── IncortaServerError        5xx
└── TableNotFoundError            detected client-side, lists what does exist
```

## Token lifetime

`for_request` and `for_cookie_header` refresh the access token as they read the
session, so a client built per request always starts fresh. A scoped client held
past its token's expiry raises `IncortaSessionExpiredError` **before** making a
request, rather than letting Incorta answer 401 — build one per request and the
case never arises.

## Behaviour both SDKs share

These are the API quirks the SDKs exist to absorb, handled identically in Python
and TypeScript.

- **`schemaType` fails silently.** `?schemaType=TYPO` returns HTTP 200 with
  *business* schemas, and so does omitting the parameter. A typo would hand you
  plausible but wrong data, so both clients validate the value locally and
  always send it explicitly.
- **Physical and business schemas return disjoint keys.** A physical schema
  carries `tablesDetails`; a business schema carries `viewsDetails`. The other
  key is absent entirely, not empty. Both clients return a different type for
  each rather than one half-null shape.
- **The API misspells its own value** as `BUSSINESS_VIEW` (three S's). Both
  clients round-trip that spelling and accept the corrected one, so nothing
  breaks whichever way Incorta resolves it.
- **There is no per-table endpoint.** Fetching one table means fetching its
  whole schema, so prefer `schemas.get(name)` once over N `tables.get` calls.
- **`aggregate` defaults to *true* when omitted.** A flat extract written
  without it returns **zero rows with HTTP 200**, reporting string columns as
  `double`. Both clients always send the flag explicitly.
- **Aggregate queries ignore the top-level `sorting` list.** Sorting is read
  only from inside a dimension. Both clients route each sort onto the dimension
  it names, and reject a sort matching none rather than letting it vanish.
- **`format: "csv"` cannot be unstringified.** Asking for both returns the
  header line alone, with HTTP 200. Both clients pick the encoding themselves.
- **`nullValueAs: "DASH"` is documented but rejected** with HTTP 400. Both
  clients omit it from the accepted values and say why.
- **The query endpoint uses a different error envelope**, `{"errorMessages":
  [{"message": "INC_..."}]}`, and answers some 400s in plain text rather than
  JSON. Both clients parse all three shapes onto the same error object.
- **Errors carry a stable `INC_` code** inside `{"message": "INC_09030108: ..."}`.
  Both clients parse it onto the error object separately from the prose.
- **Tokens never appear** in logs, `repr()`, or a client's public surface.

## Development

`incorta-auth` is resolved from `../auth-python` (`[tool.uv.sources]`), so the
SDK is always checked against the auth code in this commit rather than the last
release:

```bash
uv sync
uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest
```

Released off the same `py-v{version}` tag as `incorta-auth`, at the same
version; the publish pipeline pins the dependency to that exact version. See
the [root README](../../README.md#releases).
