provably-sdk 0.1.0__py3-none-any.whl

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.
provably/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """Python SDK for the Provably API."""
2
+
3
+ from provably._answer_model import AggregateAnswer, QueryAnswer, ResultsetAnswer, TabularData
4
+ from provably._auth_api import Organization, OrganizationType
5
+ from provably._config import (
6
+ MemoryTokenStore,
7
+ OAuthTokens,
8
+ ProvablyConfig,
9
+ ProvablyConfigError,
10
+ TokenStore,
11
+ configure,
12
+ )
13
+ from provably._errors import (
14
+ ProvablyAPIError,
15
+ ProvablyAuthError,
16
+ ProvablyConnectionError,
17
+ ProvablyDataError,
18
+ ProvablyError,
19
+ ProvablyNotFoundError,
20
+ ProvablyResourceAlreadyExistsError,
21
+ ProvablyUnauthorizedError,
22
+ )
23
+ from provably.auth_service import ProvablyAuthService, auth_service
24
+ from provably.connection import ConnectionInfo
25
+ from provably.oauth_login import browser_login
26
+ from provably.service import ProvablyService, service
27
+
28
+ __all__ = [
29
+ "AggregateAnswer",
30
+ "ConnectionInfo",
31
+ "MemoryTokenStore",
32
+ "OAuthTokens",
33
+ "Organization",
34
+ "OrganizationType",
35
+ "ProvablyAPIError",
36
+ "ProvablyAuthError",
37
+ "ProvablyAuthService",
38
+ "ProvablyConfig",
39
+ "ProvablyConfigError",
40
+ "ProvablyConnectionError",
41
+ "ProvablyDataError",
42
+ "ProvablyError",
43
+ "ProvablyNotFoundError",
44
+ "ProvablyResourceAlreadyExistsError",
45
+ "ProvablyService",
46
+ "ProvablyUnauthorizedError",
47
+ "QueryAnswer",
48
+ "ResultsetAnswer",
49
+ "TabularData",
50
+ "TokenStore",
51
+ "auth_service",
52
+ "browser_login",
53
+ "configure",
54
+ "service",
55
+ ]
@@ -0,0 +1,78 @@
1
+ """Data validation and serialization contracts for the Rust API layers."""
2
+
3
+ from typing import Any, Literal
4
+
5
+ import msgspec
6
+ import structlog
7
+ from pydantic import BaseModel, Field, model_validator
8
+
9
+ _log = structlog.get_logger(__name__)
10
+
11
+ __all__ = ["QueryAnswer", "TabularData", "AggregateAnswer", "ResultsetAnswer"]
12
+
13
+
14
+ def _safe_deserialize(cell: Any) -> Any:
15
+ """Helper to cleanly parse stringified JSON containers using high-speed C decoding."""
16
+ if isinstance(cell, str) and cell.strip().startswith(("{", "[")):
17
+ try:
18
+ return msgspec.json.decode(cell)
19
+ except Exception as e:
20
+ _log.debug("safe_deserialize_fallback", error=str(e))
21
+ return cell
22
+ return cell
23
+
24
+
25
+ class TabularData(BaseModel):
26
+ columns: list[dict[str, Any]] = Field(default_factory=list)
27
+ rows: list[list[Any]] = Field(default_factory=list)
28
+
29
+ def extract_value(self) -> Any:
30
+ """Unpacks tabular results down to the core target payload."""
31
+ if not self.rows:
32
+ return {"columns": self.columns, "rows": self.rows}
33
+
34
+ row0 = self.rows[0]
35
+ # Extracted column names are normalized to lowercase
36
+ col_names = [str(c.get("name") or "").lower() for c in self.columns]
37
+
38
+ # Target 1: Extract intercept record payloads cleanly if present
39
+ if "raw_response" in col_names:
40
+ return _safe_deserialize(row0[col_names.index("raw_response")])
41
+
42
+ # Target 2: Single cell scalar
43
+ if len(col_names) == 1 and len(row0) == 1:
44
+ return _safe_deserialize(row0[0])
45
+
46
+ return {"columns": self.columns, "rows": self.rows}
47
+
48
+
49
+ class AggregateAnswer(BaseModel):
50
+ type: Literal["aggregate"]
51
+ value: str
52
+
53
+ def extract_value(self) -> Any:
54
+ return _safe_deserialize(self.value)
55
+
56
+
57
+ class ResultsetAnswer(BaseModel):
58
+ type: Literal["resultset"]
59
+ value: TabularData
60
+
61
+ def extract_value(self) -> Any:
62
+ return self.value.extract_value()
63
+
64
+
65
+ class QueryAnswer(BaseModel):
66
+ """Wrapper mapping the structure of QueryAnswer enum."""
67
+
68
+ root: AggregateAnswer | ResultsetAnswer = Field(..., discriminator="type")
69
+
70
+ @model_validator(mode="before")
71
+ @classmethod
72
+ def wrap_root(cls, data: Any) -> Any:
73
+ if isinstance(data, dict) and "root" not in data:
74
+ return {"root": data}
75
+ return data
76
+
77
+ def flatten(self) -> Any:
78
+ return self.root.extract_value()
provably/_api.py ADDED
@@ -0,0 +1,407 @@
1
+ """Provably API — named methods for every endpoint.
2
+
3
+ :class:`ProvablyAPI` covers five resource groups:
4
+ - **Middlewares** — create the Provably middleware for an org
5
+ - **Databases / Schemas / Tables / Columns** — onboard and inspect the connected database
6
+ - **Collections** — manage query collections
7
+ - **Integrations** — register external integrations
8
+ - **Queries & Proofs** — run queries, generate proofs, poll status
9
+ """
10
+
11
+ import functools
12
+ import uuid
13
+ from typing import Any
14
+
15
+ from provably._config import ProvablyConfig, ProvablyConfigError, current
16
+ from provably._http import get_http
17
+
18
+
19
+ class ProvablyAPI:
20
+ """Provably API endpoints."""
21
+
22
+ def __init__(self, config: ProvablyConfig | None = None) -> None:
23
+ c = config or current().config()
24
+ self.org_id = c.org_id
25
+ self.app = c.app_url
26
+
27
+ def _generic_path(self) -> str:
28
+ return "/api/v1"
29
+
30
+ def _org_path(self) -> str:
31
+ if self.org_id is None:
32
+ raise ProvablyConfigError("No organisation configured; pass org_id to provably.configure()")
33
+ return f"/api/v1/organizations/{self.org_id}"
34
+
35
+ # ------------------------------------------------------------------
36
+ # Feedback
37
+ # ------------------------------------------------------------------
38
+ async def create_feedback(self, body: dict[str, Any], files: dict[str, Any] | None = None) -> None:
39
+ """
40
+ Send a feedback.
41
+ """
42
+ path = "/api/v1/feedback"
43
+
44
+ await get_http().post_multipart(path, body, files=files)
45
+ return
46
+
47
+ # ------------------------------------------------------------------
48
+ # Organizations
49
+ # ------------------------------------------------------------------
50
+
51
+ async def list_organizations(self) -> list[dict[str, Any]]:
52
+ """
53
+ List all organizations accessible to the authenticated user (via API key).
54
+
55
+ Returns:
56
+ list[dict[str, Any]]: List of organization objects (each contains at least ``id`` and ``name``).
57
+ """
58
+ path = "/api/v1/organizations"
59
+ result: list[dict[str, Any]] = await get_http().get(path)
60
+ return result
61
+
62
+ # ------------------------------------------------------------------
63
+ # Sandboxes
64
+ # ------------------------------------------------------------------
65
+
66
+ async def create_sandbox(self, org_id: uuid.UUID, *, token: str | None = None) -> dict[str, Any]:
67
+ """
68
+ Create a hosted sandbox database for the given organisation.
69
+
70
+ Args:
71
+ org_id: The ID of the organisation that owns the sandbox.
72
+ token: Optional JWT token for authentication (used during init).
73
+
74
+ Returns:
75
+ dict[str, Any]: Sandbox record with ``status`` and ``connection_uri``.
76
+ """
77
+ path = "/api/v1/sandboxes"
78
+ result: dict[str, Any] = await get_http().post(path, {"org_id": str(org_id)}, token=token)
79
+ return result
80
+
81
+ async def get_sandbox(self, *, token: str | None = None) -> dict[str, Any]:
82
+ """
83
+ Retrieve the current sandbox for the authenticated user.
84
+
85
+ Args:
86
+ token: Optional JWT token for authentication (used during init).
87
+
88
+ Returns:
89
+ dict[str, Any]: Sandbox record with ``status`` and ``connection_uri``.
90
+ """
91
+ path = "/api/v1/sandboxes"
92
+ result: dict[str, Any] = await get_http().get(path, token=token)
93
+ return result
94
+
95
+ async def delete_sandbox(self, *, token: str | None = None) -> None:
96
+ """
97
+ Delete the sandbox for the authenticated user.
98
+
99
+ Args:
100
+ token: Optional JWT token for authentication (used during init).
101
+ """
102
+ path = "/api/v1/sandboxes"
103
+ await get_http().delete(path, token=token)
104
+
105
+ # ------------------------------------------------------------------
106
+ # Middlewares
107
+ # ------------------------------------------------------------------
108
+
109
+ async def create_middleware(self) -> dict[str, Any]:
110
+ """
111
+ Create the Provably middleware for the configured org.
112
+
113
+ Returns:
114
+ dict[str, Any]: The raw JSON response from the API.
115
+ """
116
+ path = f"{self._org_path()}/middlewares/provably"
117
+
118
+ result: dict[str, Any] = await get_http().post(path)
119
+ return result
120
+
121
+ async def list_middlewares(self) -> list[dict[str, Any]]:
122
+ """
123
+ List all middlewares.
124
+
125
+ Returns:
126
+ Any: The raw JSON response from the API.
127
+ """
128
+ path = f"{self._org_path()}/middlewares"
129
+
130
+ result: list[dict[str, Any]] = await get_http().get(path)
131
+ return result
132
+
133
+ # ------------------------------------------------------------------
134
+ # Databases
135
+ # ------------------------------------------------------------------
136
+
137
+ async def create_database(self, middleware_id: uuid.UUID, body: dict[str, Any]) -> dict[str, Any]:
138
+ """
139
+ Onboard a database to a middleware.
140
+
141
+ Args:
142
+ middleware_id: The ID of the middleware to attach the database to.
143
+ body: The database connection payload.
144
+
145
+ Returns:
146
+ httpx.Response: The raw HTTP response from the API.
147
+ """
148
+ path = f"{self._org_path()}/middlewares/{middleware_id}/databases"
149
+
150
+ result: dict[str, Any] = await get_http().post(path, body)
151
+ return result
152
+
153
+ async def list_databases(self, middleware_id: uuid.UUID) -> list[dict[str, Any]]:
154
+ """
155
+ List all databases attached to a middleware.
156
+
157
+ Args:
158
+ middleware_id: The ID of the middleware to query.
159
+
160
+ Returns:
161
+ Any: The raw JSON response from the API.
162
+ """
163
+ path = f"{self._org_path()}/middlewares/{middleware_id}/databases"
164
+
165
+ result: list[dict[str, Any]] = await get_http().get(path)
166
+ return result
167
+
168
+ # ------------------------------------------------------------------
169
+ # Schemas / Tables / Columns
170
+ # ------------------------------------------------------------------
171
+
172
+ async def list_columns_from_database(
173
+ self,
174
+ middleware_id: uuid.UUID,
175
+ database_id: uuid.UUID,
176
+ schema_id: uuid.UUID,
177
+ table_id: uuid.UUID,
178
+ ) -> list[dict[str, Any]]:
179
+ """
180
+ List all columns in a table.
181
+
182
+ Args:
183
+ middleware_id: The ID of the middleware owning the database.
184
+ database_id: The ID of the database containing the schema.
185
+ schema_id: The ID of the schema containing the table.
186
+ table_id: The ID of the table to inspect.
187
+
188
+ Returns:
189
+ list[dict[str, Any]]: The raw JSON response from the API.
190
+ """
191
+ path = (
192
+ f"{self._org_path()}/middlewares/{middleware_id}"
193
+ f"/databases/{database_id}/schemas/{schema_id}/tables/{table_id}/columns"
194
+ )
195
+ result: list[dict[str, Any]] = await get_http().get(path)
196
+ return result
197
+
198
+ # ------------------------------------------------------------------
199
+ # Data
200
+ # ------------------------------------------------------------------
201
+
202
+ async def get_data(self) -> dict[str, Any]:
203
+ """
204
+ Retrieve data for the configured org.
205
+
206
+ Returns:
207
+ dict[str, Any]: The raw JSON response from the API.
208
+ """
209
+ path = f"{self._org_path()}/data"
210
+ result: dict[str, Any] = await get_http().get(path)
211
+ return result
212
+
213
+ # ------------------------------------------------------------------
214
+ # Collections
215
+ # ------------------------------------------------------------------
216
+
217
+ async def list_collections(self) -> list[dict[str, Any]]:
218
+ """
219
+ List all collections for the configured org.
220
+
221
+ Returns:
222
+ list[dict[str, Any]]: The raw JSON response from the API.
223
+ """
224
+ path = f"{self._org_path()}/collections"
225
+ result: list[dict[str, Any]] = await get_http().get(path)
226
+ return result
227
+
228
+ async def create_collection(self, body: dict[str, Any]) -> dict[str, Any]:
229
+ """
230
+ Create a new collection for the configured org.
231
+
232
+ Args:
233
+ body: The collection creation payload.
234
+
235
+ Returns:
236
+ httpx.Response: The raw HTTP response from the API.
237
+ """
238
+ path = f"{self._org_path()}/collections"
239
+
240
+ result: dict[str, Any] = await get_http().post(path, body)
241
+ return result
242
+
243
+ # ------------------------------------------------------------------
244
+ # Integrations
245
+ # ------------------------------------------------------------------
246
+
247
+ async def ensure_integration(self, body: dict[str, Any]) -> dict[str, Any]:
248
+ """
249
+ Idempotent get-or-create an integration for the configured org.
250
+
251
+ Reuses an existing enabled integration with the same name linked to the
252
+ exact requested collections, returning its full key instead of minting a
253
+ duplicate.
254
+
255
+ Args:
256
+ body: The integration registration payload.
257
+
258
+ Returns:
259
+ dict[str, Any]: The raw JSON response from the API.
260
+ """
261
+ path = f"{self._org_path()}/integrations/ensure"
262
+
263
+ result: dict[str, Any] = await get_http().post(path, body)
264
+ return result
265
+
266
+ async def list_integrations(self, query: str | None = None) -> list[dict[str, Any]]:
267
+ """
268
+ List all integrations for the configured org.
269
+
270
+ Args:
271
+ query: Optional search string to filter integrations by name.
272
+
273
+ Returns:
274
+ list[dict[str, Any]]: The raw JSON response from the API.
275
+ """
276
+ path = f"{self._org_path()}/integrations"
277
+ params = {"query": query} if query is not None else None
278
+ result: list[dict[str, Any]] = await get_http().get(path, params=params)
279
+ return result
280
+
281
+ # ------------------------------------------------------------------
282
+ # Preprocess
283
+ # ------------------------------------------------------------------
284
+
285
+ async def start_preprocess(self, middleware_id: uuid.UUID, table_id: uuid.UUID) -> dict[str, Any]:
286
+ """
287
+ Start a preprocessing job for a table.
288
+
289
+ Args:
290
+ middleware_id: The ID of the middleware owning the table.
291
+ table_id: The ID of the table to preprocess.
292
+
293
+ Returns:
294
+ dict[str, Any]: The raw JSON response from the API.
295
+ """
296
+ path = f"{self._org_path()}/middlewares/{middleware_id}/tables/{table_id}/preprocess"
297
+
298
+ result: dict[str, Any] = await get_http().post(path, {"force": True})
299
+ return result
300
+
301
+ async def get_preprocess_status(self, middleware_id: uuid.UUID, table_id: uuid.UUID) -> dict[str, Any]:
302
+ """
303
+ Get the preprocessing status for a table.
304
+
305
+ Args:
306
+ middleware_id: The ID of the middleware owning the table.
307
+ table_id: The ID of the table to check.
308
+
309
+ Returns:
310
+ dict[str, Any]: The raw JSON response from the API.
311
+ """
312
+ path = f"{self._org_path()}/middlewares/{middleware_id}/tables/{table_id}/preprocess"
313
+
314
+ result: dict[str, Any] = await get_http().get(path)
315
+ return result
316
+
317
+ # ------------------------------------------------------------------
318
+ # Queries / Proofs
319
+ # ------------------------------------------------------------------
320
+
321
+ async def run_query(self, middleware_id: uuid.UUID, collection_id: uuid.UUID, sql: str) -> dict[str, Any]:
322
+ """
323
+ Run a SQL query through a middleware and request a proof.
324
+
325
+ Args:
326
+ middleware_id: The ID of the middleware to execute the query against.
327
+ collection_id: The ID of the collection to associate the query with.
328
+ sql: The SQL query string to execute.
329
+
330
+ Returns:
331
+ dict[str, Any]: The raw JSON response from the API.
332
+ """
333
+ path = f"{self._org_path()}/middlewares/{middleware_id}/query"
334
+
335
+ result: dict[str, Any] = await get_http().post(
336
+ path, {"query": sql, "require_proof": True, "collection_id": str(collection_id)}
337
+ )
338
+ return result
339
+
340
+ async def get_query(self, query_id: uuid.UUID, *, api_key: str | None = None) -> dict[str, Any]:
341
+ """
342
+ Retrieve a query record by ID.
343
+
344
+ Args:
345
+ query_id: The ID of the query to retrieve.
346
+ api_key: Optional API key override for this request.
347
+
348
+ Returns:
349
+ dict[str, Any]: The raw JSON response from the API.
350
+ """
351
+ path = f"{self._org_path()}/queries/{query_id}"
352
+
353
+ result: dict[str, Any] = await get_http().get(path, api_key=api_key)
354
+ return result
355
+
356
+ async def get_query_proof(self, proof_id: uuid.UUID, *, api_key: str | None = None) -> bytes:
357
+ """
358
+ Download the raw proof data by proof ID.
359
+
360
+ Args:
361
+ proof_id: The ID of the proof to retrieve.
362
+ api_key: Optional API key override for this request.
363
+
364
+ Returns:
365
+ bytes: The raw proof content.
366
+ """
367
+ path = f"{self._generic_path()}/proof_requests/{proof_id}/download"
368
+
369
+ return await get_http().get_raw(path, api_key=api_key)
370
+
371
+ async def verify_proof(self, query_id: uuid.UUID, *, api_key: str | None = None) -> dict[str, Any]:
372
+ """
373
+ Request verification for an existing query proof.
374
+
375
+ Args:
376
+ query_id: The unique identifier of the query whose proof needs
377
+ verification.
378
+ api_key: Optional API key override for this request.
379
+
380
+ Returns:
381
+ dict[str, Any]: A response confirming the verification
382
+ task has been successfully initiated.
383
+
384
+ Raises:
385
+ ProvablyAPIError: If the query does not exist or verification
386
+ cannot be initiated.
387
+ """
388
+ path = f"{self._org_path()}/queries/{query_id}/verify"
389
+
390
+ result: dict[str, Any] = await get_http().post(path, {}, api_key=api_key)
391
+ return result
392
+
393
+ # ------------------------------------------------------------------
394
+ # URL helpers
395
+ # ------------------------------------------------------------------
396
+
397
+ def query_record_url(self, query_record_id: uuid.UUID) -> str:
398
+ """Provably Data Admin URL for a query record."""
399
+ if not query_record_id:
400
+ raise ValueError("query_record_id is required")
401
+ return f"{self.app}/org/{self.org_id}/query-record/{query_record_id}"
402
+
403
+
404
+ @functools.lru_cache(maxsize=1)
405
+ def get_api() -> ProvablyAPI:
406
+ """Return the shared :class:`ProvablyAPI`, constructed on first call."""
407
+ return ProvablyAPI()