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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/incorta-sdk)](https://pypi.org/project/incorta-sdk/)
26
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./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
+ [![PyPI version](https://img.shields.io/pypi/v/incorta-sdk)](https://pypi.org/project/incorta-sdk/)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./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"]