incorta-sdk 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- incorta_sdk-0.5.0/.gitignore +6 -0
- incorta_sdk-0.5.0/LICENSE +21 -0
- incorta_sdk-0.5.0/PKG-INFO +293 -0
- incorta_sdk-0.5.0/README.md +271 -0
- incorta_sdk-0.5.0/pyproject.toml +84 -0
- incorta_sdk-0.5.0/src/incorta_sdk/__init__.py +145 -0
- incorta_sdk-0.5.0/src/incorta_sdk/_http.py +165 -0
- incorta_sdk-0.5.0/src/incorta_sdk/client.py +212 -0
- incorta_sdk-0.5.0/src/incorta_sdk/config.py +105 -0
- incorta_sdk-0.5.0/src/incorta_sdk/enums.py +199 -0
- incorta_sdk-0.5.0/src/incorta_sdk/exceptions.py +239 -0
- incorta_sdk-0.5.0/src/incorta_sdk/models.py +392 -0
- incorta_sdk-0.5.0/src/incorta_sdk/py.typed +0 -0
- incorta_sdk-0.5.0/src/incorta_sdk/query.py +429 -0
- incorta_sdk-0.5.0/src/incorta_sdk/resources/__init__.py +7 -0
- incorta_sdk-0.5.0/src/incorta_sdk/resources/data.py +329 -0
- incorta_sdk-0.5.0/src/incorta_sdk/resources/schemas.py +225 -0
- incorta_sdk-0.5.0/src/incorta_sdk/resources/tables.py +88 -0
- incorta_sdk-0.5.0/src/incorta_sdk/user_client.py +87 -0
- incorta_sdk-0.5.0/tests/__init__.py +0 -0
- incorta_sdk-0.5.0/tests/conftest.py +474 -0
- incorta_sdk-0.5.0/tests/test_client.py +233 -0
- incorta_sdk-0.5.0/tests/test_data.py +324 -0
- incorta_sdk-0.5.0/tests/test_http.py +123 -0
- incorta_sdk-0.5.0/tests/test_schemas.py +180 -0
- incorta_sdk-0.5.0/tests/test_tables.py +108 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Incorta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: incorta-sdk
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Read Incorta schemas, tables, views, and columns as the signed-in user — OAuth 2.0 sessions from incorta-auth, no personal access tokens.
|
|
5
|
+
Project-URL: Repository, https://github.com/Incorta/IncortaSDK
|
|
6
|
+
Author: Incorta
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: analytics,incorta,metadata,oauth,oidc,schema
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: incorta-auth==0.5.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# incorta-sdk
|
|
24
|
+
|
|
25
|
+
[](https://pypi.org/project/incorta-sdk/)
|
|
26
|
+
[](./LICENSE)
|
|
27
|
+
|
|
28
|
+
Read Incorta **schemas, tables, views, and columns** from Python, as the
|
|
29
|
+
**signed-in user**. Sessions come from
|
|
30
|
+
[`incorta-auth`](https://pypi.org/project/incorta-auth/) — OAuth 2.0 against the
|
|
31
|
+
authorization server built into Incorta — so this client has no identity of its
|
|
32
|
+
own and no personal access token to store.
|
|
33
|
+
|
|
34
|
+
The TypeScript twin is [`@incorta/sdk`](../sdk/README.md); the two speak the
|
|
35
|
+
same API, model the same distinctions, and share the same `INCORTA_*`
|
|
36
|
+
configuration.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install incorta-sdk
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Why it is scoped to a user
|
|
43
|
+
|
|
44
|
+
Every call carries a user's own Incorta access token, so Incorta filters the
|
|
45
|
+
results: two people hitting the same endpoint of your app see two different
|
|
46
|
+
catalogs. That is a property of the design, not a setting — there is no
|
|
47
|
+
app-level identity to over-share from, and nothing to revoke separately when
|
|
48
|
+
someone leaves.
|
|
49
|
+
|
|
50
|
+
It also means the *scoped* client, not the top-level one, is what you hold:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
IncortaClient configuration + OAuth (build once, at startup)
|
|
54
|
+
└── for_request() → IncortaUserClient (build per request)
|
|
55
|
+
├── schemas
|
|
56
|
+
└── tables
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
`IncortaClient()` takes the same settings as `IncortaAuth`, each falling back to
|
|
62
|
+
its environment variable:
|
|
63
|
+
|
|
64
|
+
| Setting | Environment variable | Meaning |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `incorta_url` | `INCORTA_URL` | Environment root **including** any context path (often `/incorta`), **without** `/api/v2` |
|
|
67
|
+
| `tenant` | `INCORTA_TENANT` | Tenant name, e.g. `default` |
|
|
68
|
+
| `client_id` | `INCORTA_CLIENT_ID` | OAuth client id (see `incorta-auth register`) |
|
|
69
|
+
| `client_secret` | `INCORTA_CLIENT_SECRET` | OAuth client secret |
|
|
70
|
+
| `secret` | `INCORTA_AUTH_SECRET` | Session-cookie encryption key (≥ 32 chars) |
|
|
71
|
+
| `internal_incorta_url` | `INCORTA_INTERNAL_URL` | Optional split-horizon address for server-to-server calls |
|
|
72
|
+
|
|
73
|
+
Plus two of its own: `timeout` (seconds, default `30`) and `max_retries`
|
|
74
|
+
(default `3`, for 429/5xx and network errors only — client errors are never
|
|
75
|
+
retried).
|
|
76
|
+
|
|
77
|
+
When the app already builds an `IncortaAuth` — the usual case, since it needs
|
|
78
|
+
one to serve logins — pass it in rather than letting this package create a
|
|
79
|
+
second:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from incorta_auth import IncortaAuth
|
|
83
|
+
from incorta_sdk import IncortaClient
|
|
84
|
+
|
|
85
|
+
auth = IncortaAuth(app_access="catalog")
|
|
86
|
+
client = IncortaClient(auth=auth)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
### FastAPI
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from fastapi import Depends, FastAPI, Request
|
|
95
|
+
from incorta_auth import IncortaAuthMiddleware
|
|
96
|
+
from incorta_sdk import IncortaClient
|
|
97
|
+
|
|
98
|
+
app = FastAPI()
|
|
99
|
+
app.add_middleware(IncortaAuthMiddleware) # serves /auth/*, requires a session
|
|
100
|
+
|
|
101
|
+
client = IncortaClient() # once, at startup
|
|
102
|
+
|
|
103
|
+
@app.get("/api/schemas")
|
|
104
|
+
def schemas(request: Request):
|
|
105
|
+
incorta = client.for_request(request) # per request, as this user
|
|
106
|
+
return [
|
|
107
|
+
{"name": schema.name, "description": schema.description}
|
|
108
|
+
for schema in incorta.schemas.physical()
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
@app.get("/api/tables/{schema_name}/{table_name}")
|
|
112
|
+
def table(schema_name: str, table_name: str, request: Request):
|
|
113
|
+
incorta = client.for_request(request)
|
|
114
|
+
obj = incorta.tables.get(schema_name, table_name)
|
|
115
|
+
return {
|
|
116
|
+
"name": obj.qualified_name,
|
|
117
|
+
"columns": [{"name": c.name, "type": c.data_type} for c in obj.columns],
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`for_request` works with any request object exposing a `headers` mapping —
|
|
122
|
+
Starlette/FastAPI, Django, and Flask all qualify. Given a session you already
|
|
123
|
+
hold (the FastAPI `get_session` dependency, or `request.state`), use
|
|
124
|
+
`client.for_session(session)`; given a raw header, `client.for_cookie_header(...)`.
|
|
125
|
+
|
|
126
|
+
### Streamlit
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import streamlit as st
|
|
130
|
+
from incorta_auth.streamlit import incorta_auth
|
|
131
|
+
from incorta_sdk import IncortaClient
|
|
132
|
+
|
|
133
|
+
incorta_auth.require_login()
|
|
134
|
+
|
|
135
|
+
client = st.cache_resource(IncortaClient)()
|
|
136
|
+
incorta = client.for_access_token(incorta_auth.access_token())
|
|
137
|
+
|
|
138
|
+
st.write([schema.name for schema in incorta.schemas.list()])
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Surface
|
|
142
|
+
|
|
143
|
+
### `IncortaClient`
|
|
144
|
+
|
|
145
|
+
| Member | Purpose |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `auth` | The underlying `IncortaAuth` — mount its middleware for login |
|
|
148
|
+
| `for_request(request)` | Scoped client for the user behind a request |
|
|
149
|
+
| `for_session(session)` | Scoped client from a session you already read |
|
|
150
|
+
| `for_cookie_header(header)` | Scoped client from a raw `Cookie` header |
|
|
151
|
+
| `for_access_token(token, *, user=None)` | Scoped client from a bare access token |
|
|
152
|
+
| `info` | `base_url`, `tenant`, `timeout`, `max_retries` — no secrets |
|
|
153
|
+
| `close()` | Releases the connection pool (only if it built the auth instance) |
|
|
154
|
+
|
|
155
|
+
### `IncortaUserClient.schemas`
|
|
156
|
+
|
|
157
|
+
| Method | Returns |
|
|
158
|
+
| --- | --- |
|
|
159
|
+
| `list(type=..., limit=0, offset=0, sort_by=...)` | `list[SchemaInfo]` |
|
|
160
|
+
| `list_page(...)` | `Page[SchemaInfo]` — adds the server-side `total` |
|
|
161
|
+
| `iter_all(type=..., page_size=100)` | Iterator, one page fetched at a time |
|
|
162
|
+
| `physical()` / `business()` | Shorthands for the type filter |
|
|
163
|
+
| `get(name)` | `PhysicalSchema` or `BusinessSchema`, with contents |
|
|
164
|
+
| `exists(name)` | `bool` |
|
|
165
|
+
|
|
166
|
+
### `IncortaUserClient.tables`
|
|
167
|
+
|
|
168
|
+
| Method | Returns |
|
|
169
|
+
| --- | --- |
|
|
170
|
+
| `get(schema, name)` | `Table` or `View`, columns populated |
|
|
171
|
+
| `list(schema)` / `names(schema)` | Every object, or just their names |
|
|
172
|
+
| `columns(schema, name)` | `list[Column]` |
|
|
173
|
+
| `tables_only(schema)` / `views_only(schema)` | Filtered by kind |
|
|
174
|
+
| `exists(schema, name)` | `bool` |
|
|
175
|
+
|
|
176
|
+
Names are matched case-insensitively.
|
|
177
|
+
|
|
178
|
+
### `IncortaUserClient.data`
|
|
179
|
+
|
|
180
|
+
Reads rows out of business views. Fields are addressed by their fully qualified
|
|
181
|
+
name, `SCHEMA.VIEW.COLUMN`.
|
|
182
|
+
|
|
183
|
+
| Method | Returns |
|
|
184
|
+
| --- | --- |
|
|
185
|
+
| `query(measures, *, rows=..., aggregate=False, filters=..., sorting=..., page_size=0, ...)` | `QueryResult` |
|
|
186
|
+
| `iter_rows(measures, *, page_size=1000, ...)` | Iterator of rows, one page fetched at a time |
|
|
187
|
+
| `csv(measures, ...)` | `str` — the CSV Incorta rendered |
|
|
188
|
+
| `raw(body)` | The decoded response for a body sent verbatim |
|
|
189
|
+
| `build_body(measures, ...)` | The request body, without sending it |
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
result = incorta.data.query(
|
|
193
|
+
[Measure(field="HR_BS.Employee_BS.SALARY", aggregation="sum", label="payroll")],
|
|
194
|
+
rows=["HR_BS.Employee_BS.JOB_TITLE"],
|
|
195
|
+
aggregate=True,
|
|
196
|
+
filters=[Filter.on("HR_BS.Employee_BS.JOB_TITLE", "IN_LIST", ["Accountant"])],
|
|
197
|
+
sorting=[Sort(field="HR_BS.Employee_BS.JOB_TITLE", direction="desc")],
|
|
198
|
+
)
|
|
199
|
+
result.headers # ["JOB_TITLE", "payroll"]
|
|
200
|
+
result.dicts() # [{"JOB_TITLE": "Accountant", "payroll": "39600.0"}]
|
|
201
|
+
result.total_rows # rows matching beyond this page
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
A bare string is shorthand for `Measure(field=...)` or `Dimension(field=...)`.
|
|
205
|
+
Cells always come back as strings — Incorta renders every value as text.
|
|
206
|
+
|
|
207
|
+
`aggregate=False` gives a flat extract; `aggregate=True` folds each measure with
|
|
208
|
+
its `aggregation` and groups by `rows` and `columns`.
|
|
209
|
+
|
|
210
|
+
### Models
|
|
211
|
+
|
|
212
|
+
Frozen dataclasses in `snake_case`. `PhysicalSchema` exposes `.tables`,
|
|
213
|
+
`BusinessSchema` exposes `.views`, and both expose `.objects` so type-agnostic
|
|
214
|
+
code works against either. Every model keeps the untouched API record in `.raw`,
|
|
215
|
+
so a field this package does not model is still reachable.
|
|
216
|
+
|
|
217
|
+
### Errors
|
|
218
|
+
|
|
219
|
+
Everything derives from `IncortaError`:
|
|
220
|
+
|
|
221
|
+
```text
|
|
222
|
+
IncortaError
|
|
223
|
+
├── IncortaConfigError a setting is missing or malformed
|
|
224
|
+
├── IncortaAuthRequiredError no signed-in user on this request
|
|
225
|
+
├── IncortaSessionExpiredError the captured token aged out (raised locally)
|
|
226
|
+
├── IncortaConnectionError environment unreachable
|
|
227
|
+
│ └── IncortaTimeoutError
|
|
228
|
+
├── IncortaAPIError non-2xx, carrying .status_code and .code
|
|
229
|
+
│ ├── AuthenticationError 401 — Incorta refused the token
|
|
230
|
+
│ ├── PermissionDeniedError 403 — this user lacks access
|
|
231
|
+
│ ├── NotFoundError 404
|
|
232
|
+
│ │ └── SchemaNotFoundError
|
|
233
|
+
│ └── IncortaServerError 5xx
|
|
234
|
+
└── TableNotFoundError detected client-side, lists what does exist
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Token lifetime
|
|
238
|
+
|
|
239
|
+
`for_request` and `for_cookie_header` refresh the access token as they read the
|
|
240
|
+
session, so a client built per request always starts fresh. A scoped client held
|
|
241
|
+
past its token's expiry raises `IncortaSessionExpiredError` **before** making a
|
|
242
|
+
request, rather than letting Incorta answer 401 — build one per request and the
|
|
243
|
+
case never arises.
|
|
244
|
+
|
|
245
|
+
## Behaviour both SDKs share
|
|
246
|
+
|
|
247
|
+
These are the API quirks the SDKs exist to absorb, handled identically in Python
|
|
248
|
+
and TypeScript.
|
|
249
|
+
|
|
250
|
+
- **`schemaType` fails silently.** `?schemaType=TYPO` returns HTTP 200 with
|
|
251
|
+
*business* schemas, and so does omitting the parameter. A typo would hand you
|
|
252
|
+
plausible but wrong data, so both clients validate the value locally and
|
|
253
|
+
always send it explicitly.
|
|
254
|
+
- **Physical and business schemas return disjoint keys.** A physical schema
|
|
255
|
+
carries `tablesDetails`; a business schema carries `viewsDetails`. The other
|
|
256
|
+
key is absent entirely, not empty. Both clients return a different type for
|
|
257
|
+
each rather than one half-null shape.
|
|
258
|
+
- **The API misspells its own value** as `BUSSINESS_VIEW` (three S's). Both
|
|
259
|
+
clients round-trip that spelling and accept the corrected one, so nothing
|
|
260
|
+
breaks whichever way Incorta resolves it.
|
|
261
|
+
- **There is no per-table endpoint.** Fetching one table means fetching its
|
|
262
|
+
whole schema, so prefer `schemas.get(name)` once over N `tables.get` calls.
|
|
263
|
+
- **`aggregate` defaults to *true* when omitted.** A flat extract written
|
|
264
|
+
without it returns **zero rows with HTTP 200**, reporting string columns as
|
|
265
|
+
`double`. Both clients always send the flag explicitly.
|
|
266
|
+
- **Aggregate queries ignore the top-level `sorting` list.** Sorting is read
|
|
267
|
+
only from inside a dimension. Both clients route each sort onto the dimension
|
|
268
|
+
it names, and reject a sort matching none rather than letting it vanish.
|
|
269
|
+
- **`format: "csv"` cannot be unstringified.** Asking for both returns the
|
|
270
|
+
header line alone, with HTTP 200. Both clients pick the encoding themselves.
|
|
271
|
+
- **`nullValueAs: "DASH"` is documented but rejected** with HTTP 400. Both
|
|
272
|
+
clients omit it from the accepted values and say why.
|
|
273
|
+
- **The query endpoint uses a different error envelope**, `{"errorMessages":
|
|
274
|
+
[{"message": "INC_..."}]}`, and answers some 400s in plain text rather than
|
|
275
|
+
JSON. Both clients parse all three shapes onto the same error object.
|
|
276
|
+
- **Errors carry a stable `INC_` code** inside `{"message": "INC_09030108: ..."}`.
|
|
277
|
+
Both clients parse it onto the error object separately from the prose.
|
|
278
|
+
- **Tokens never appear** in logs, `repr()`, or a client's public surface.
|
|
279
|
+
|
|
280
|
+
## Development
|
|
281
|
+
|
|
282
|
+
`incorta-auth` is resolved from `../auth-python` (`[tool.uv.sources]`), so the
|
|
283
|
+
SDK is always checked against the auth code in this commit rather than the last
|
|
284
|
+
release:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
uv sync
|
|
288
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Released off the same `py-v{version}` tag as `incorta-auth`, at the same
|
|
292
|
+
version; the publish pipeline pins the dependency to that exact version. See
|
|
293
|
+
the [root README](../../README.md#releases).
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# incorta-sdk
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/incorta-sdk/)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
Read Incorta **schemas, tables, views, and columns** from Python, as the
|
|
7
|
+
**signed-in user**. Sessions come from
|
|
8
|
+
[`incorta-auth`](https://pypi.org/project/incorta-auth/) — OAuth 2.0 against the
|
|
9
|
+
authorization server built into Incorta — so this client has no identity of its
|
|
10
|
+
own and no personal access token to store.
|
|
11
|
+
|
|
12
|
+
The TypeScript twin is [`@incorta/sdk`](../sdk/README.md); the two speak the
|
|
13
|
+
same API, model the same distinctions, and share the same `INCORTA_*`
|
|
14
|
+
configuration.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install incorta-sdk
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Why it is scoped to a user
|
|
21
|
+
|
|
22
|
+
Every call carries a user's own Incorta access token, so Incorta filters the
|
|
23
|
+
results: two people hitting the same endpoint of your app see two different
|
|
24
|
+
catalogs. That is a property of the design, not a setting — there is no
|
|
25
|
+
app-level identity to over-share from, and nothing to revoke separately when
|
|
26
|
+
someone leaves.
|
|
27
|
+
|
|
28
|
+
It also means the *scoped* client, not the top-level one, is what you hold:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
IncortaClient configuration + OAuth (build once, at startup)
|
|
32
|
+
└── for_request() → IncortaUserClient (build per request)
|
|
33
|
+
├── schemas
|
|
34
|
+
└── tables
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
`IncortaClient()` takes the same settings as `IncortaAuth`, each falling back to
|
|
40
|
+
its environment variable:
|
|
41
|
+
|
|
42
|
+
| Setting | Environment variable | Meaning |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| `incorta_url` | `INCORTA_URL` | Environment root **including** any context path (often `/incorta`), **without** `/api/v2` |
|
|
45
|
+
| `tenant` | `INCORTA_TENANT` | Tenant name, e.g. `default` |
|
|
46
|
+
| `client_id` | `INCORTA_CLIENT_ID` | OAuth client id (see `incorta-auth register`) |
|
|
47
|
+
| `client_secret` | `INCORTA_CLIENT_SECRET` | OAuth client secret |
|
|
48
|
+
| `secret` | `INCORTA_AUTH_SECRET` | Session-cookie encryption key (≥ 32 chars) |
|
|
49
|
+
| `internal_incorta_url` | `INCORTA_INTERNAL_URL` | Optional split-horizon address for server-to-server calls |
|
|
50
|
+
|
|
51
|
+
Plus two of its own: `timeout` (seconds, default `30`) and `max_retries`
|
|
52
|
+
(default `3`, for 429/5xx and network errors only — client errors are never
|
|
53
|
+
retried).
|
|
54
|
+
|
|
55
|
+
When the app already builds an `IncortaAuth` — the usual case, since it needs
|
|
56
|
+
one to serve logins — pass it in rather than letting this package create a
|
|
57
|
+
second:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from incorta_auth import IncortaAuth
|
|
61
|
+
from incorta_sdk import IncortaClient
|
|
62
|
+
|
|
63
|
+
auth = IncortaAuth(app_access="catalog")
|
|
64
|
+
client = IncortaClient(auth=auth)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
### FastAPI
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from fastapi import Depends, FastAPI, Request
|
|
73
|
+
from incorta_auth import IncortaAuthMiddleware
|
|
74
|
+
from incorta_sdk import IncortaClient
|
|
75
|
+
|
|
76
|
+
app = FastAPI()
|
|
77
|
+
app.add_middleware(IncortaAuthMiddleware) # serves /auth/*, requires a session
|
|
78
|
+
|
|
79
|
+
client = IncortaClient() # once, at startup
|
|
80
|
+
|
|
81
|
+
@app.get("/api/schemas")
|
|
82
|
+
def schemas(request: Request):
|
|
83
|
+
incorta = client.for_request(request) # per request, as this user
|
|
84
|
+
return [
|
|
85
|
+
{"name": schema.name, "description": schema.description}
|
|
86
|
+
for schema in incorta.schemas.physical()
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
@app.get("/api/tables/{schema_name}/{table_name}")
|
|
90
|
+
def table(schema_name: str, table_name: str, request: Request):
|
|
91
|
+
incorta = client.for_request(request)
|
|
92
|
+
obj = incorta.tables.get(schema_name, table_name)
|
|
93
|
+
return {
|
|
94
|
+
"name": obj.qualified_name,
|
|
95
|
+
"columns": [{"name": c.name, "type": c.data_type} for c in obj.columns],
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`for_request` works with any request object exposing a `headers` mapping —
|
|
100
|
+
Starlette/FastAPI, Django, and Flask all qualify. Given a session you already
|
|
101
|
+
hold (the FastAPI `get_session` dependency, or `request.state`), use
|
|
102
|
+
`client.for_session(session)`; given a raw header, `client.for_cookie_header(...)`.
|
|
103
|
+
|
|
104
|
+
### Streamlit
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import streamlit as st
|
|
108
|
+
from incorta_auth.streamlit import incorta_auth
|
|
109
|
+
from incorta_sdk import IncortaClient
|
|
110
|
+
|
|
111
|
+
incorta_auth.require_login()
|
|
112
|
+
|
|
113
|
+
client = st.cache_resource(IncortaClient)()
|
|
114
|
+
incorta = client.for_access_token(incorta_auth.access_token())
|
|
115
|
+
|
|
116
|
+
st.write([schema.name for schema in incorta.schemas.list()])
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Surface
|
|
120
|
+
|
|
121
|
+
### `IncortaClient`
|
|
122
|
+
|
|
123
|
+
| Member | Purpose |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `auth` | The underlying `IncortaAuth` — mount its middleware for login |
|
|
126
|
+
| `for_request(request)` | Scoped client for the user behind a request |
|
|
127
|
+
| `for_session(session)` | Scoped client from a session you already read |
|
|
128
|
+
| `for_cookie_header(header)` | Scoped client from a raw `Cookie` header |
|
|
129
|
+
| `for_access_token(token, *, user=None)` | Scoped client from a bare access token |
|
|
130
|
+
| `info` | `base_url`, `tenant`, `timeout`, `max_retries` — no secrets |
|
|
131
|
+
| `close()` | Releases the connection pool (only if it built the auth instance) |
|
|
132
|
+
|
|
133
|
+
### `IncortaUserClient.schemas`
|
|
134
|
+
|
|
135
|
+
| Method | Returns |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `list(type=..., limit=0, offset=0, sort_by=...)` | `list[SchemaInfo]` |
|
|
138
|
+
| `list_page(...)` | `Page[SchemaInfo]` — adds the server-side `total` |
|
|
139
|
+
| `iter_all(type=..., page_size=100)` | Iterator, one page fetched at a time |
|
|
140
|
+
| `physical()` / `business()` | Shorthands for the type filter |
|
|
141
|
+
| `get(name)` | `PhysicalSchema` or `BusinessSchema`, with contents |
|
|
142
|
+
| `exists(name)` | `bool` |
|
|
143
|
+
|
|
144
|
+
### `IncortaUserClient.tables`
|
|
145
|
+
|
|
146
|
+
| Method | Returns |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| `get(schema, name)` | `Table` or `View`, columns populated |
|
|
149
|
+
| `list(schema)` / `names(schema)` | Every object, or just their names |
|
|
150
|
+
| `columns(schema, name)` | `list[Column]` |
|
|
151
|
+
| `tables_only(schema)` / `views_only(schema)` | Filtered by kind |
|
|
152
|
+
| `exists(schema, name)` | `bool` |
|
|
153
|
+
|
|
154
|
+
Names are matched case-insensitively.
|
|
155
|
+
|
|
156
|
+
### `IncortaUserClient.data`
|
|
157
|
+
|
|
158
|
+
Reads rows out of business views. Fields are addressed by their fully qualified
|
|
159
|
+
name, `SCHEMA.VIEW.COLUMN`.
|
|
160
|
+
|
|
161
|
+
| Method | Returns |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| `query(measures, *, rows=..., aggregate=False, filters=..., sorting=..., page_size=0, ...)` | `QueryResult` |
|
|
164
|
+
| `iter_rows(measures, *, page_size=1000, ...)` | Iterator of rows, one page fetched at a time |
|
|
165
|
+
| `csv(measures, ...)` | `str` — the CSV Incorta rendered |
|
|
166
|
+
| `raw(body)` | The decoded response for a body sent verbatim |
|
|
167
|
+
| `build_body(measures, ...)` | The request body, without sending it |
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
result = incorta.data.query(
|
|
171
|
+
[Measure(field="HR_BS.Employee_BS.SALARY", aggregation="sum", label="payroll")],
|
|
172
|
+
rows=["HR_BS.Employee_BS.JOB_TITLE"],
|
|
173
|
+
aggregate=True,
|
|
174
|
+
filters=[Filter.on("HR_BS.Employee_BS.JOB_TITLE", "IN_LIST", ["Accountant"])],
|
|
175
|
+
sorting=[Sort(field="HR_BS.Employee_BS.JOB_TITLE", direction="desc")],
|
|
176
|
+
)
|
|
177
|
+
result.headers # ["JOB_TITLE", "payroll"]
|
|
178
|
+
result.dicts() # [{"JOB_TITLE": "Accountant", "payroll": "39600.0"}]
|
|
179
|
+
result.total_rows # rows matching beyond this page
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
A bare string is shorthand for `Measure(field=...)` or `Dimension(field=...)`.
|
|
183
|
+
Cells always come back as strings — Incorta renders every value as text.
|
|
184
|
+
|
|
185
|
+
`aggregate=False` gives a flat extract; `aggregate=True` folds each measure with
|
|
186
|
+
its `aggregation` and groups by `rows` and `columns`.
|
|
187
|
+
|
|
188
|
+
### Models
|
|
189
|
+
|
|
190
|
+
Frozen dataclasses in `snake_case`. `PhysicalSchema` exposes `.tables`,
|
|
191
|
+
`BusinessSchema` exposes `.views`, and both expose `.objects` so type-agnostic
|
|
192
|
+
code works against either. Every model keeps the untouched API record in `.raw`,
|
|
193
|
+
so a field this package does not model is still reachable.
|
|
194
|
+
|
|
195
|
+
### Errors
|
|
196
|
+
|
|
197
|
+
Everything derives from `IncortaError`:
|
|
198
|
+
|
|
199
|
+
```text
|
|
200
|
+
IncortaError
|
|
201
|
+
├── IncortaConfigError a setting is missing or malformed
|
|
202
|
+
├── IncortaAuthRequiredError no signed-in user on this request
|
|
203
|
+
├── IncortaSessionExpiredError the captured token aged out (raised locally)
|
|
204
|
+
├── IncortaConnectionError environment unreachable
|
|
205
|
+
│ └── IncortaTimeoutError
|
|
206
|
+
├── IncortaAPIError non-2xx, carrying .status_code and .code
|
|
207
|
+
│ ├── AuthenticationError 401 — Incorta refused the token
|
|
208
|
+
│ ├── PermissionDeniedError 403 — this user lacks access
|
|
209
|
+
│ ├── NotFoundError 404
|
|
210
|
+
│ │ └── SchemaNotFoundError
|
|
211
|
+
│ └── IncortaServerError 5xx
|
|
212
|
+
└── TableNotFoundError detected client-side, lists what does exist
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Token lifetime
|
|
216
|
+
|
|
217
|
+
`for_request` and `for_cookie_header` refresh the access token as they read the
|
|
218
|
+
session, so a client built per request always starts fresh. A scoped client held
|
|
219
|
+
past its token's expiry raises `IncortaSessionExpiredError` **before** making a
|
|
220
|
+
request, rather than letting Incorta answer 401 — build one per request and the
|
|
221
|
+
case never arises.
|
|
222
|
+
|
|
223
|
+
## Behaviour both SDKs share
|
|
224
|
+
|
|
225
|
+
These are the API quirks the SDKs exist to absorb, handled identically in Python
|
|
226
|
+
and TypeScript.
|
|
227
|
+
|
|
228
|
+
- **`schemaType` fails silently.** `?schemaType=TYPO` returns HTTP 200 with
|
|
229
|
+
*business* schemas, and so does omitting the parameter. A typo would hand you
|
|
230
|
+
plausible but wrong data, so both clients validate the value locally and
|
|
231
|
+
always send it explicitly.
|
|
232
|
+
- **Physical and business schemas return disjoint keys.** A physical schema
|
|
233
|
+
carries `tablesDetails`; a business schema carries `viewsDetails`. The other
|
|
234
|
+
key is absent entirely, not empty. Both clients return a different type for
|
|
235
|
+
each rather than one half-null shape.
|
|
236
|
+
- **The API misspells its own value** as `BUSSINESS_VIEW` (three S's). Both
|
|
237
|
+
clients round-trip that spelling and accept the corrected one, so nothing
|
|
238
|
+
breaks whichever way Incorta resolves it.
|
|
239
|
+
- **There is no per-table endpoint.** Fetching one table means fetching its
|
|
240
|
+
whole schema, so prefer `schemas.get(name)` once over N `tables.get` calls.
|
|
241
|
+
- **`aggregate` defaults to *true* when omitted.** A flat extract written
|
|
242
|
+
without it returns **zero rows with HTTP 200**, reporting string columns as
|
|
243
|
+
`double`. Both clients always send the flag explicitly.
|
|
244
|
+
- **Aggregate queries ignore the top-level `sorting` list.** Sorting is read
|
|
245
|
+
only from inside a dimension. Both clients route each sort onto the dimension
|
|
246
|
+
it names, and reject a sort matching none rather than letting it vanish.
|
|
247
|
+
- **`format: "csv"` cannot be unstringified.** Asking for both returns the
|
|
248
|
+
header line alone, with HTTP 200. Both clients pick the encoding themselves.
|
|
249
|
+
- **`nullValueAs: "DASH"` is documented but rejected** with HTTP 400. Both
|
|
250
|
+
clients omit it from the accepted values and say why.
|
|
251
|
+
- **The query endpoint uses a different error envelope**, `{"errorMessages":
|
|
252
|
+
[{"message": "INC_..."}]}`, and answers some 400s in plain text rather than
|
|
253
|
+
JSON. Both clients parse all three shapes onto the same error object.
|
|
254
|
+
- **Errors carry a stable `INC_` code** inside `{"message": "INC_09030108: ..."}`.
|
|
255
|
+
Both clients parse it onto the error object separately from the prose.
|
|
256
|
+
- **Tokens never appear** in logs, `repr()`, or a client's public surface.
|
|
257
|
+
|
|
258
|
+
## Development
|
|
259
|
+
|
|
260
|
+
`incorta-auth` is resolved from `../auth-python` (`[tool.uv.sources]`), so the
|
|
261
|
+
SDK is always checked against the auth code in this commit rather than the last
|
|
262
|
+
release:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
uv sync
|
|
266
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy && uv run pytest
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Released off the same `py-v{version}` tag as `incorta-auth`, at the same
|
|
270
|
+
version; the publish pipeline pins the dependency to that exact version. See
|
|
271
|
+
the [root README](../../README.md#releases).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "incorta-sdk"
|
|
3
|
+
# Placeholder — the real version is computed from git history by
|
|
4
|
+
# semantic-release and stamped here (uv version) only in CI at publish time.
|
|
5
|
+
# See release.python.config.cjs; release tags are py-v{version}.
|
|
6
|
+
version = "0.5.0"
|
|
7
|
+
description = "Read Incorta schemas, tables, views, and columns as the signed-in user — OAuth 2.0 sessions from incorta-auth, no personal access tokens."
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Incorta" }]
|
|
13
|
+
keywords = ["incorta", "analytics", "schema", "metadata", "oauth", "oidc"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Database",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
# Sessions, token refresh, and the environment/tenant configuration all come
|
|
26
|
+
# from the auth SDK — this package has no credential handling of its own.
|
|
27
|
+
# Released in lockstep from this repo (same py-v tag), so the publish
|
|
28
|
+
# pipeline stamps this floor to the exact version being cut.
|
|
29
|
+
"incorta-auth==0.5.0",
|
|
30
|
+
# The same HTTP library incorta-auth uses, so both share one client and one
|
|
31
|
+
# test-transport injection point.
|
|
32
|
+
"httpx>=0.27",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Repository = "https://github.com/Incorta/IncortaSDK"
|
|
37
|
+
|
|
38
|
+
# Locally, resolve the sibling package from the workspace rather than PyPI, so
|
|
39
|
+
# a change in packages/auth-python is picked up without a release. Build
|
|
40
|
+
# backends ignore [tool.uv.sources] — the published wheel keeps the plain
|
|
41
|
+
# version requirement above.
|
|
42
|
+
[tool.uv.sources]
|
|
43
|
+
incorta-auth = { path = "../auth-python", editable = true }
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8.0",
|
|
48
|
+
"mypy>=1.11",
|
|
49
|
+
"ruff>=0.6",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[build-system]
|
|
53
|
+
requires = ["hatchling"]
|
|
54
|
+
build-backend = "hatchling.build"
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.wheel]
|
|
57
|
+
packages = ["src/incorta_sdk"]
|
|
58
|
+
|
|
59
|
+
[tool.hatch.build.targets.sdist]
|
|
60
|
+
include = ["src/incorta_sdk", "tests", "README.md", "LICENSE"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
line-length = 100
|
|
67
|
+
target-version = "py312"
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
|
71
|
+
# RUF022 wants __all__ alphabetised; ours is grouped by concern (client, enums,
|
|
72
|
+
# models, exceptions) with comments delimiting the groups, which reads better
|
|
73
|
+
# and is what the docs mirror.
|
|
74
|
+
ignore = ["RUF022"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.format]
|
|
77
|
+
# The README's fenced examples use aligned trailing comments as a visual
|
|
78
|
+
# column; the formatter would collapse that alignment.
|
|
79
|
+
exclude = ["*.md"]
|
|
80
|
+
|
|
81
|
+
[tool.mypy]
|
|
82
|
+
python_version = "3.12"
|
|
83
|
+
strict = true
|
|
84
|
+
files = ["src/incorta_sdk"]
|