periplus-python-sdk 0.6.1__py3-none-any.whl → 0.8.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.
- periplus_python_sdk-0.8.0.dist-info/METADATA +70 -0
- periplus_python_sdk-0.8.0.dist-info/RECORD +16 -0
- periplus_sdk/client.py +37 -16
- periplus_sdk/dbapi.py +98 -77
- periplus_sdk/sql_api.py +28 -6
- periplus_sdk/sqlalchemy.py +35 -28
- periplus_sdk/stream.py +134 -0
- periplus_sdk/types.py +7 -2
- periplus_python_sdk-0.6.1.dist-info/METADATA +0 -234
- periplus_python_sdk-0.6.1.dist-info/RECORD +0 -15
- {periplus_python_sdk-0.6.1.dist-info → periplus_python_sdk-0.8.0.dist-info}/WHEEL +0 -0
- {periplus_python_sdk-0.6.1.dist-info → periplus_python_sdk-0.8.0.dist-info}/entry_points.txt +0 -0
- {periplus_python_sdk-0.6.1.dist-info → periplus_python_sdk-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {periplus_python_sdk-0.6.1.dist-info → periplus_python_sdk-0.8.0.dist-info}/licenses/NOTICE +0 -0
- {periplus_python_sdk-0.6.1.dist-info → periplus_python_sdk-0.8.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: periplus-python-sdk
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Read-only Python client for the public Periplus query API
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Repository, https://github.com/elei-io/periplus
|
|
7
|
+
Project-URL: Issues, https://github.com/elei-io/periplus/issues
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
License-File: NOTICE
|
|
12
|
+
Requires-Dist: httpx>=0.28
|
|
13
|
+
Requires-Dist: pydantic<3,>=2.12
|
|
14
|
+
Requires-Dist: sqlalchemy<3,>=2.0
|
|
15
|
+
Provides-Extra: notebook
|
|
16
|
+
Requires-Dist: marimo[sql]>=0.24.1; extra == "notebook"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Periplus Python SDK
|
|
20
|
+
|
|
21
|
+
Read-only access to the public ClickHouse corpus through the Periplus HTTP API.
|
|
22
|
+
Install the SDK from the same checkout as your deployment:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
python -m pip install ./packages/periplus-python-sdk
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from periplus_sdk import Client
|
|
30
|
+
|
|
31
|
+
with Client("http://localhost:8080") as client:
|
|
32
|
+
result = client.execute(
|
|
33
|
+
"SELECT capture_id, url FROM public_v1.captures LIMIT ?", [10]
|
|
34
|
+
)
|
|
35
|
+
print(result.columns, result.rows)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Use `PERIPLUS_PUBLIC_URL` to omit the URL argument. No database credentials or
|
|
39
|
+
service token are needed for the public gateway. `AsyncClient` provides async
|
|
40
|
+
methods. `prepare` validates/explains SQL; `execute` returns typed columns, rows,
|
|
41
|
+
truncation and query metadata; `helpers` describes the installed public views.
|
|
42
|
+
|
|
43
|
+
The public schema is `public_v1`. HTML joins use `document_id` plus node index;
|
|
44
|
+
`document_id` identifies retained bytes and their HTML interpretation. Raw-byte hashes stay internal.
|
|
45
|
+
There is one query endpoint, with no experimental fallback. Client errors preserve
|
|
46
|
+
server categories and do not automatically retry executed queries.
|
|
47
|
+
|
|
48
|
+
For notebook/SQLAlchemy integration:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from periplus_sdk import sql_api
|
|
52
|
+
from sqlalchemy import text
|
|
53
|
+
|
|
54
|
+
engine = sql_api.create_engine(base_url="http://localhost:8080")
|
|
55
|
+
with engine.connect() as connection:
|
|
56
|
+
print(connection.execute(text("SELECT url FROM public_v1.captures LIMIT 5")).all())
|
|
57
|
+
engine.dispose()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Marimo discovers the five public views through the helper catalogue. Column
|
|
61
|
+
reflection uses `SELECT * ... LIMIT 0` to retrieve native types without reading
|
|
62
|
+
corpus rows; no `SHOW`, `DESCRIBE`, or system-table access is required.
|
|
63
|
+
|
|
64
|
+
The DB-API connection advertises the ClickHouse dialect and converts native
|
|
65
|
+
nullable integer, decimal, date and datetime types. Nested types retain JSON wire
|
|
66
|
+
values. It is read-only: there are no client transactions or writable sessions.
|
|
67
|
+
Streaming cursors expose incomplete/truncated results explicitly; configure
|
|
68
|
+
`allow_partial` only when partial results suit the application.
|
|
69
|
+
|
|
70
|
+
See [the public schema](../../docs/SCHEMA.md) and [query boundary](../../docs/QUERY.md).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
periplus_python_sdk-0.8.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
2
|
+
periplus_python_sdk-0.8.0.dist-info/licenses/NOTICE,sha256=bhbYSqcUB3U_P1-XzloiT81JGniqoYaRLxNkQ1Pm9MQ,52
|
|
3
|
+
periplus_sdk/__init__.py,sha256=WimXYlPB6tCimBO4VSwhcp00dwSL87jMmMuQ4-kINfM,546
|
|
4
|
+
periplus_sdk/client.py,sha256=trcsOL4hnpDByMr8wZ9iz2vzxQWCW60vSYTmpqCj1Gs,7745
|
|
5
|
+
periplus_sdk/dbapi.py,sha256=yDrL2CV7tI3Pawp9hN2czFYqrbXF8ret0Jk4VHRHoYg,12014
|
|
6
|
+
periplus_sdk/errors.py,sha256=rB1n-v8Hc2tu2dtHivz-MlqsCoRC5pTcWogTfM7SMLw,855
|
|
7
|
+
periplus_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
8
|
+
periplus_sdk/sql_api.py,sha256=vRQXzBIcJOUg_eZzzRs7cpE3xygxK5Jo7YfYWN4NxBg,1868
|
|
9
|
+
periplus_sdk/sqlalchemy.py,sha256=e9j4nI_JQ5EDwMOK6WawX1W_QrOnaY3Wa8LchIIJJio,5567
|
|
10
|
+
periplus_sdk/stream.py,sha256=7gOYOMpfeB7NcxQgWjJ6f5NvymYTWhYbPkVdCijSlP8,5218
|
|
11
|
+
periplus_sdk/types.py,sha256=yh_xHwn6TwC45r8eWhxtlrz0w86M5lvhmPOS3QJbXiU,1253
|
|
12
|
+
periplus_python_sdk-0.8.0.dist-info/METADATA,sha256=7A_Jpwq8NJpskMpwQORGPhx-xbMHn_nehsqslBb_6c8,2730
|
|
13
|
+
periplus_python_sdk-0.8.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
periplus_python_sdk-0.8.0.dist-info/entry_points.txt,sha256=Pr14L_7AhLinq-4qDxB4awFVubrvR1BEfkaFVRpdCbU,73
|
|
15
|
+
periplus_python_sdk-0.8.0.dist-info/top_level.txt,sha256=o41t5TzwgoxzSmbKoP6olWW1FyEAGWVYTjeoKadBK40,13
|
|
16
|
+
periplus_python_sdk-0.8.0.dist-info/RECORD,,
|
periplus_sdk/client.py
CHANGED
|
@@ -79,10 +79,11 @@ def _payload(sql: str, parameters: Sequence[JsonValue] | None, schema_version: s
|
|
|
79
79
|
class Client:
|
|
80
80
|
"""Reusable synchronous public query client. Close it or use a with block."""
|
|
81
81
|
|
|
82
|
-
def __init__(self, base_url: str | None = None, *, timeout: float =
|
|
83
|
-
if mode not in {"stable"
|
|
84
|
-
raise ConfigurationError("
|
|
85
|
-
self.
|
|
82
|
+
def __init__(self, base_url: str | None = None, *, timeout: float = 620, mode: Literal["stable"] = "stable"):
|
|
83
|
+
if mode not in {"stable"}:
|
|
84
|
+
raise ConfigurationError("Only the stable public catalogue is available.")
|
|
85
|
+
self.schema_version = "public_v1"
|
|
86
|
+
self._query_path = "api/query/"
|
|
86
87
|
self._http = httpx.Client(**_options(base_url, timeout))
|
|
87
88
|
|
|
88
89
|
def __enter__(self) -> Client:
|
|
@@ -101,11 +102,30 @@ class Client:
|
|
|
101
102
|
raise TransportError("Could not complete the public query request.") from None
|
|
102
103
|
return _decode(response, model)
|
|
103
104
|
|
|
104
|
-
def prepare(self, sql: str, parameters: Sequence[JsonValue] | None = None, *, schema_version: str =
|
|
105
|
-
return self._request("POST", "prep", PreparedQuery, json=_payload(sql, parameters, schema_version))
|
|
105
|
+
def prepare(self, sql: str, parameters: Sequence[JsonValue] | None = None, *, schema_version: str | None = None) -> PreparedQuery:
|
|
106
|
+
return self._request("POST", "prep", PreparedQuery, json=_payload(sql, parameters, schema_version if schema_version is not None else self.schema_version))
|
|
106
107
|
|
|
107
|
-
def execute(self, sql: str, parameters: Sequence[JsonValue] | None = None, *, schema_version: str =
|
|
108
|
-
return self._request("POST", "exec", QueryResult, json=_payload(sql, parameters, schema_version))
|
|
108
|
+
def execute(self, sql: str, parameters: Sequence[JsonValue] | None = None, *, schema_version: str | None = None) -> QueryResult:
|
|
109
|
+
return self._request("POST", "exec", QueryResult, json=_payload(sql, parameters, schema_version if schema_version is not None else self.schema_version))
|
|
110
|
+
|
|
111
|
+
def stream(self, sql: str, parameters: Sequence[JsonValue] | None = None, *,
|
|
112
|
+
schema_version: str | None = None, allow_partial: bool = False):
|
|
113
|
+
"""Stream batches from one snapshot; use as a context manager for early exit."""
|
|
114
|
+
from .stream import MEDIA_TYPE, QueryStream
|
|
115
|
+
try:
|
|
116
|
+
request = self._http.build_request("POST", self._query_path + "exec",
|
|
117
|
+
headers={"accept": MEDIA_TYPE}, json=_payload(sql, parameters, schema_version if schema_version is not None else self.schema_version))
|
|
118
|
+
response = self._http.send(request, stream=True)
|
|
119
|
+
try:
|
|
120
|
+
if not response.is_success:
|
|
121
|
+
response.read()
|
|
122
|
+
_decode(response, QueryResult)
|
|
123
|
+
return QueryStream(response, allow_partial=allow_partial)
|
|
124
|
+
except BaseException:
|
|
125
|
+
response.close()
|
|
126
|
+
raise
|
|
127
|
+
except httpx.RequestError:
|
|
128
|
+
raise TransportError("Could not open the public query stream.") from None
|
|
109
129
|
|
|
110
130
|
def helpers(self) -> QueryHelpers:
|
|
111
131
|
return self._request("GET", "helpers", QueryHelpers)
|
|
@@ -114,10 +134,11 @@ class Client:
|
|
|
114
134
|
class AsyncClient:
|
|
115
135
|
"""Reusable asynchronous public query client. Use an async with block."""
|
|
116
136
|
|
|
117
|
-
def __init__(self, base_url: str | None = None, *, timeout: float =
|
|
118
|
-
if mode not in {"stable"
|
|
119
|
-
raise ConfigurationError("
|
|
120
|
-
self.
|
|
137
|
+
def __init__(self, base_url: str | None = None, *, timeout: float = 620, mode: Literal["stable"] = "stable"):
|
|
138
|
+
if mode not in {"stable"}:
|
|
139
|
+
raise ConfigurationError("Only the stable public catalogue is available.")
|
|
140
|
+
self.schema_version = "public_v1"
|
|
141
|
+
self._query_path = "api/query/"
|
|
121
142
|
self._http = httpx.AsyncClient(**_options(base_url, timeout))
|
|
122
143
|
|
|
123
144
|
async def __aenter__(self) -> AsyncClient:
|
|
@@ -136,11 +157,11 @@ class AsyncClient:
|
|
|
136
157
|
raise TransportError("Could not complete the public query request.") from None
|
|
137
158
|
return _decode(response, model)
|
|
138
159
|
|
|
139
|
-
async def prepare(self, sql: str, parameters: Sequence[JsonValue] | None = None, *, schema_version: str =
|
|
140
|
-
return await self._request("POST", "prep", PreparedQuery, json=_payload(sql, parameters, schema_version))
|
|
160
|
+
async def prepare(self, sql: str, parameters: Sequence[JsonValue] | None = None, *, schema_version: str | None = None) -> PreparedQuery:
|
|
161
|
+
return await self._request("POST", "prep", PreparedQuery, json=_payload(sql, parameters, schema_version if schema_version is not None else self.schema_version))
|
|
141
162
|
|
|
142
|
-
async def execute(self, sql: str, parameters: Sequence[JsonValue] | None = None, *, schema_version: str =
|
|
143
|
-
return await self._request("POST", "exec", QueryResult, json=_payload(sql, parameters, schema_version))
|
|
163
|
+
async def execute(self, sql: str, parameters: Sequence[JsonValue] | None = None, *, schema_version: str | None = None) -> QueryResult:
|
|
164
|
+
return await self._request("POST", "exec", QueryResult, json=_payload(sql, parameters, schema_version if schema_version is not None else self.schema_version))
|
|
144
165
|
|
|
145
166
|
async def helpers(self) -> QueryHelpers:
|
|
146
167
|
return await self._request("GET", "helpers", QueryHelpers)
|
periplus_sdk/dbapi.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Read-only DB-API 2.0 connection over the public query API.
|
|
2
2
|
|
|
3
|
-
Each execute is an independent server snapshot. Fetching consumes a bounded
|
|
4
|
-
|
|
3
|
+
Each execute is an independent server snapshot. Fetching consumes a bounded stream.
|
|
4
|
+
Connections and cursors must not be shared by threads.
|
|
5
5
|
"""
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
@@ -11,11 +11,10 @@ from collections.abc import Sequence
|
|
|
11
11
|
from datetime import date, datetime, time
|
|
12
12
|
from decimal import Decimal
|
|
13
13
|
from typing import Any, Literal
|
|
14
|
-
import warnings
|
|
15
14
|
|
|
16
15
|
from .client import Client
|
|
17
16
|
from .errors import ApiError, ConfigurationError, PeriplusError, ResponseError, TransportError
|
|
18
|
-
from .
|
|
17
|
+
from .stream import StreamResult
|
|
19
18
|
|
|
20
19
|
apilevel = "2.0"
|
|
21
20
|
threadsafety = 1
|
|
@@ -26,10 +25,6 @@ class Warning(builtins.Warning):
|
|
|
26
25
|
"""DB-API warning."""
|
|
27
26
|
|
|
28
27
|
|
|
29
|
-
class TruncationWarning(Warning):
|
|
30
|
-
"""The server returned only part of the query result."""
|
|
31
|
-
|
|
32
|
-
|
|
33
28
|
class Error(PeriplusError):
|
|
34
29
|
"""Base DB-API error; API failures preserve their safe error attributes."""
|
|
35
30
|
|
|
@@ -91,54 +86,47 @@ def TimestampFromTicks(ticks: float) -> datetime:
|
|
|
91
86
|
return datetime.fromtimestamp(ticks)
|
|
92
87
|
|
|
93
88
|
|
|
94
|
-
_INTEGER_TYPES = {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
89
|
+
_INTEGER_TYPES = {f'{prefix}Int{bits}' for prefix in ('', 'U') for bits in (8,16,32,64,128,256)}
|
|
90
|
+
_FLOAT_TYPES = {'Float32', 'Float64'}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _base_type(value: str) -> str:
|
|
94
|
+
while value.startswith(('Nullable(', 'LowCardinality(')):
|
|
95
|
+
value = value[value.index('(')+1:-1]
|
|
96
|
+
return value
|
|
100
97
|
|
|
101
98
|
|
|
102
99
|
class _TypeCategory:
|
|
103
|
-
def __init__(self, names: set[str],
|
|
104
|
-
self.names, self.
|
|
100
|
+
def __init__(self, names: set[str], prefixes: tuple[str, ...] = ()):
|
|
101
|
+
self.names, self.prefixes = names, prefixes
|
|
105
102
|
|
|
106
103
|
def __eq__(self, other: object) -> bool:
|
|
107
|
-
|
|
104
|
+
if not isinstance(other, str):return False
|
|
105
|
+
value = _base_type(other)
|
|
106
|
+
return value in self.names or value.startswith(self.prefixes)
|
|
108
107
|
|
|
109
108
|
|
|
110
|
-
STRING = _TypeCategory({
|
|
111
|
-
BINARY = _TypeCategory(
|
|
112
|
-
NUMBER = _TypeCategory(_INTEGER_TYPES | _FLOAT_TYPES | {
|
|
113
|
-
DATETIME = _TypeCategory({
|
|
109
|
+
STRING = _TypeCategory({'String','UUID','JSON'}, ('FixedString(', 'Enum'))
|
|
110
|
+
BINARY = _TypeCategory(set())
|
|
111
|
+
NUMBER = _TypeCategory(_INTEGER_TYPES | _FLOAT_TYPES | {'Bool'}, ('Decimal',))
|
|
112
|
+
DATETIME = _TypeCategory({'Date','Date32'}, ('DateTime','Time'))
|
|
114
113
|
ROWID = _TypeCategory(set())
|
|
115
114
|
|
|
116
115
|
|
|
117
116
|
def _value(value: Any, sql_type: str) -> Any:
|
|
118
|
-
if value is None:
|
|
119
|
-
|
|
120
|
-
if sql_type in _INTEGER_TYPES:
|
|
121
|
-
|
|
122
|
-
if sql_type
|
|
123
|
-
|
|
124
|
-
if sql_type
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
if sql_type
|
|
128
|
-
try:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return value
|
|
132
|
-
if sql_type in _TIME_TYPES:
|
|
133
|
-
return time.fromisoformat(value)
|
|
134
|
-
if sql_type in _TIMESTAMP_TYPES:
|
|
135
|
-
try:
|
|
136
|
-
return datetime.fromisoformat(value)
|
|
137
|
-
except ValueError:
|
|
138
|
-
return value
|
|
139
|
-
if sql_type == "BLOB":
|
|
140
|
-
return base64.b64decode(value, validate=True)
|
|
141
|
-
# UUIDs remain strings, and nested/other types retain their JSON wire values.
|
|
117
|
+
if value is None:return None
|
|
118
|
+
sql_type = _base_type(sql_type)
|
|
119
|
+
if sql_type in _INTEGER_TYPES:return int(value)
|
|
120
|
+
if sql_type in _FLOAT_TYPES:return float(value)
|
|
121
|
+
if sql_type.startswith('Decimal'):return Decimal(str(value))
|
|
122
|
+
if sql_type == 'Bool':return value in (True, 1, '1', 'true')
|
|
123
|
+
if sql_type in ('Date','Date32'):
|
|
124
|
+
try:return date.fromisoformat(value)
|
|
125
|
+
except ValueError:return value
|
|
126
|
+
if sql_type.startswith('DateTime'):
|
|
127
|
+
try:return datetime.fromisoformat(value)
|
|
128
|
+
except ValueError:return value
|
|
129
|
+
if sql_type.startswith('Time'):return time.fromisoformat(value)
|
|
142
130
|
return value
|
|
143
131
|
|
|
144
132
|
|
|
@@ -147,24 +135,30 @@ def _parameter(value: Any) -> Any:
|
|
|
147
135
|
return value
|
|
148
136
|
if isinstance(value, (Decimal, date, time)):
|
|
149
137
|
return str(value) if isinstance(value, Decimal) else value.isoformat()
|
|
150
|
-
|
|
138
|
+
if isinstance(value, (list, tuple)):
|
|
139
|
+
if any(isinstance(item, (list, tuple, dict)) for item in value):
|
|
140
|
+
raise ProgrammingError("Collection parameters must be one-dimensional lists of scalar values.")
|
|
141
|
+
return [_parameter(item) for item in value]
|
|
142
|
+
raise ProgrammingError("Parameters must be scalar values or scalar lists; use explicit SQL casts for typed values.")
|
|
151
143
|
|
|
152
144
|
|
|
153
145
|
class Connection:
|
|
154
146
|
"""Marimo-discoverable, read-only connection; commit is a no-op."""
|
|
155
147
|
|
|
156
|
-
dialect = "
|
|
148
|
+
dialect = "clickhouse"
|
|
157
149
|
|
|
158
|
-
def __init__(self, base_url: str | None = None, *, timeout: float =
|
|
159
|
-
mode: Literal["stable"
|
|
160
|
-
schema_version: str =
|
|
150
|
+
def __init__(self, base_url: str | None = None, *, timeout: float = 620,
|
|
151
|
+
mode: Literal["stable"] = "stable",
|
|
152
|
+
schema_version: str | None = None, allow_partial: bool = False):
|
|
161
153
|
try:
|
|
162
154
|
self._client = Client(base_url, timeout=timeout, mode=mode)
|
|
163
155
|
except ConfigurationError as exc:
|
|
164
156
|
raise InterfaceError(str(exc)) from exc
|
|
165
|
-
self.schema_version = schema_version
|
|
157
|
+
self.schema_version = schema_version if schema_version is not None else ("public_v1")
|
|
158
|
+
self.allow_partial = allow_partial
|
|
159
|
+
self._cursors = set()
|
|
166
160
|
self.closed = False
|
|
167
|
-
self.last_result:
|
|
161
|
+
self.last_result: StreamResult | None = None
|
|
168
162
|
|
|
169
163
|
def _check(self) -> None:
|
|
170
164
|
if self.closed:
|
|
@@ -172,7 +166,9 @@ class Connection:
|
|
|
172
166
|
|
|
173
167
|
def cursor(self) -> Cursor:
|
|
174
168
|
self._check()
|
|
175
|
-
|
|
169
|
+
cursor = Cursor(self)
|
|
170
|
+
self._cursors.add(cursor)
|
|
171
|
+
return cursor
|
|
176
172
|
|
|
177
173
|
def execute(self, operation: str, parameters: Sequence[Any] | None = None) -> Cursor:
|
|
178
174
|
cursor = self.cursor()
|
|
@@ -192,6 +188,8 @@ class Connection:
|
|
|
192
188
|
|
|
193
189
|
def close(self) -> None:
|
|
194
190
|
if not self.closed:
|
|
191
|
+
for cursor in list(self._cursors):
|
|
192
|
+
cursor.close()
|
|
195
193
|
self._client.close()
|
|
196
194
|
self.closed = True
|
|
197
195
|
self.last_result = None
|
|
@@ -204,25 +202,26 @@ class Connection:
|
|
|
204
202
|
self.close()
|
|
205
203
|
|
|
206
204
|
|
|
207
|
-
def connect(base_url: str | None = None, *, timeout: float =
|
|
208
|
-
mode: Literal["stable"
|
|
209
|
-
schema_version: str =
|
|
210
|
-
return Connection(base_url, timeout=timeout, mode=mode, schema_version=schema_version)
|
|
205
|
+
def connect(base_url: str | None = None, *, timeout: float = 620,
|
|
206
|
+
mode: Literal["stable"] = "stable",
|
|
207
|
+
schema_version: str | None = None, allow_partial: bool = False) -> Connection:
|
|
208
|
+
return Connection(base_url, timeout=timeout, mode=mode, schema_version=schema_version, allow_partial=allow_partial)
|
|
211
209
|
|
|
212
210
|
|
|
213
211
|
class Cursor:
|
|
214
|
-
"""
|
|
212
|
+
"""Incremental cursor. Metadata stays available without retaining consumed rows."""
|
|
215
213
|
|
|
216
214
|
arraysize = 1
|
|
217
215
|
|
|
218
216
|
def __init__(self, connection: Connection):
|
|
219
217
|
self.connection = connection
|
|
220
218
|
self.closed = False
|
|
221
|
-
self.result:
|
|
219
|
+
self.result: StreamResult | None = None
|
|
222
220
|
self.description: list[tuple[Any, ...]] | None = None
|
|
223
221
|
self.rowcount = -1
|
|
224
222
|
self._rows: list[tuple[Any, ...]] = []
|
|
225
223
|
self._position = 0
|
|
224
|
+
self._stream = None
|
|
226
225
|
|
|
227
226
|
def _check(self, *, result: bool = False) -> None:
|
|
228
227
|
self.connection._check()
|
|
@@ -233,6 +232,9 @@ class Cursor:
|
|
|
233
232
|
|
|
234
233
|
def execute(self, operation: str, parameters: Sequence[Any] | None = None) -> Cursor:
|
|
235
234
|
self._check()
|
|
235
|
+
if self._stream is not None:
|
|
236
|
+
self._stream.close()
|
|
237
|
+
self._stream = None
|
|
236
238
|
self.result, self.description, self.rowcount = None, None, -1
|
|
237
239
|
self._rows, self._position = [], 0
|
|
238
240
|
self.connection.last_result = None
|
|
@@ -242,7 +244,9 @@ class Cursor:
|
|
|
242
244
|
raise ProgrammingError("Use a positional parameter sequence with ? placeholders.")
|
|
243
245
|
values = [_parameter(v) for v in parameters] if parameters is not None else []
|
|
244
246
|
try:
|
|
245
|
-
|
|
247
|
+
self._stream = self.connection._client.stream(operation, values, schema_version=self.connection.schema_version,
|
|
248
|
+
allow_partial=self.connection.allow_partial)
|
|
249
|
+
result = self._stream.result
|
|
246
250
|
except ApiError as exc:
|
|
247
251
|
error = ProgrammingError if exc.code == "sql_invalid" else OperationalError
|
|
248
252
|
raise error(str(exc), status_code=exc.status_code, code=exc.code,
|
|
@@ -251,26 +255,37 @@ class Cursor:
|
|
|
251
255
|
raise OperationalError(str(exc)) from exc
|
|
252
256
|
except ResponseError as exc:
|
|
253
257
|
raise InterfaceError(str(exc)) from exc
|
|
254
|
-
if len(result.columns) != len(result.types) or any(len(r) != len(result.columns) for r in result.rows):
|
|
255
|
-
raise InterfaceError("Query columns, types and rows have inconsistent widths.")
|
|
256
|
-
try:
|
|
257
|
-
rows = [tuple(_value(v, t) for v, t in zip(row, result.types, strict=True)) for row in result.rows]
|
|
258
|
-
except (ValueError, TypeError, ArithmeticError) as exc:
|
|
259
|
-
raise DataError("Query value does not match its SQL type.") from exc
|
|
260
258
|
self.result = self.connection.last_result = result
|
|
261
259
|
self.description = [(name, kind, None, None, None, None, None)
|
|
262
260
|
for name, kind in zip(result.columns, result.types, strict=True)]
|
|
263
|
-
self._rows = rows
|
|
264
|
-
self.rowcount = -1 if result.truncated else len(rows)
|
|
265
|
-
if result.truncated:
|
|
266
|
-
warnings.warn(f"Periplus returned a truncated result ({len(rows)} rows); inspect connection.last_result or cursor.result. Fetching does not retrieve additional rows.",
|
|
267
|
-
TruncationWarning, stacklevel=2)
|
|
268
261
|
return self
|
|
269
262
|
|
|
263
|
+
def _batch(self):
|
|
264
|
+
try:
|
|
265
|
+
batch = next(self._stream)
|
|
266
|
+
self._rows = [tuple(_value(v, t) for v, t in zip(row, self.result.types, strict=True)) for row in batch]
|
|
267
|
+
self._position = 0
|
|
268
|
+
return True
|
|
269
|
+
except StopIteration:
|
|
270
|
+
self.rowcount = -1 if self.result.truncated else self.result.row_count
|
|
271
|
+
self._rows, self._position = [], 0
|
|
272
|
+
return False
|
|
273
|
+
except ApiError as exc:
|
|
274
|
+
raise OperationalError(str(exc), status_code=exc.status_code, code=exc.code,
|
|
275
|
+
retry_after_seconds=exc.retry_after_seconds) from exc
|
|
276
|
+
except TransportError as exc:
|
|
277
|
+
raise OperationalError(str(exc)) from exc
|
|
278
|
+
except ResponseError as exc:
|
|
279
|
+
raise InterfaceError(str(exc)) from exc
|
|
280
|
+
except (ValueError, TypeError, ArithmeticError) as exc:
|
|
281
|
+
self._stream.close()
|
|
282
|
+
raise DataError("Query value does not match its SQL type.") from exc
|
|
283
|
+
|
|
270
284
|
def fetchone(self) -> tuple[Any, ...] | None:
|
|
271
285
|
self._check(result=True)
|
|
272
|
-
|
|
273
|
-
|
|
286
|
+
while self._position == len(self._rows):
|
|
287
|
+
if not self._batch():
|
|
288
|
+
return None
|
|
274
289
|
row = self._rows[self._position]
|
|
275
290
|
self._position += 1
|
|
276
291
|
return row
|
|
@@ -280,14 +295,17 @@ class Cursor:
|
|
|
280
295
|
size = self.arraysize if size is None else size
|
|
281
296
|
if not isinstance(size, int) or size < 0:
|
|
282
297
|
raise ProgrammingError("Fetch size must be a non-negative integer.")
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
298
|
+
rows = []
|
|
299
|
+
for _ in range(size):
|
|
300
|
+
row = self.fetchone()
|
|
301
|
+
if row is None:
|
|
302
|
+
break
|
|
303
|
+
rows.append(row)
|
|
286
304
|
return rows
|
|
287
305
|
|
|
288
306
|
def fetchall(self) -> list[tuple[Any, ...]]:
|
|
289
307
|
self._check(result=True)
|
|
290
|
-
return
|
|
308
|
+
return list(self)
|
|
291
309
|
|
|
292
310
|
def executemany(self, operation: str, seq_of_parameters: Any) -> None:
|
|
293
311
|
self._check()
|
|
@@ -300,6 +318,9 @@ class Cursor:
|
|
|
300
318
|
self._check()
|
|
301
319
|
|
|
302
320
|
def close(self) -> None:
|
|
321
|
+
if self._stream is not None:
|
|
322
|
+
self._stream.close()
|
|
323
|
+
self.connection._cursors.discard(self)
|
|
303
324
|
self.closed = True
|
|
304
325
|
self._rows = []
|
|
305
326
|
self.result = None
|
periplus_sdk/sql_api.py
CHANGED
|
@@ -3,23 +3,45 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
from typing import Literal
|
|
5
5
|
|
|
6
|
-
from sqlalchemy import create_engine as _create_engine
|
|
6
|
+
from sqlalchemy import create_engine as _create_engine, event
|
|
7
7
|
from sqlalchemy.engine import Engine, URL
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def create_engine(
|
|
11
11
|
base_url: str | None = None,
|
|
12
12
|
*,
|
|
13
|
-
mode: Literal["stable"
|
|
14
|
-
timeout: float =
|
|
15
|
-
schema_version: str =
|
|
13
|
+
mode: Literal["stable"] = "stable",
|
|
14
|
+
timeout: float = 620,
|
|
15
|
+
schema_version: str | None = None,
|
|
16
|
+
allow_partial: bool = False,
|
|
16
17
|
) -> Engine:
|
|
17
18
|
"""Create a SQLAlchemy engine recognized by marimo and other SQL tools.
|
|
18
19
|
|
|
19
20
|
The public URL defaults to PERIPLUS_PUBLIC_URL. Connections are opened lazily;
|
|
20
21
|
dispose the engine when finished. Each query uses an independent server snapshot.
|
|
21
22
|
"""
|
|
22
|
-
|
|
23
|
+
engine = _create_engine(
|
|
23
24
|
URL.create("periplus", database="periplus"),
|
|
24
|
-
connect_args={"base_url": base_url, "mode": mode, "timeout": timeout, "schema_version": schema_version},
|
|
25
|
+
connect_args={"base_url": base_url, "mode": mode, "timeout": timeout, "schema_version": schema_version, "allow_partial": allow_partial},
|
|
25
26
|
)
|
|
27
|
+
|
|
28
|
+
event.listen(engine, "before_execute", _parameters, retval=True)
|
|
29
|
+
return engine
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parameters(connection, clauseelement, multiparams, params, execution_options):
|
|
33
|
+
bindings = execution_options.get("periplus_parameters", {})
|
|
34
|
+
if bindings and not multiparams:
|
|
35
|
+
names = clauseelement.compile().params
|
|
36
|
+
params = {**{name: value for name, value in bindings.items() if name in names}, **params}
|
|
37
|
+
return clauseelement, multiparams, params
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def bind(engine: Engine, **parameters) -> Engine:
|
|
41
|
+
"""Create a marimo-discoverable engine with named SQL parameters for this cell.
|
|
42
|
+
|
|
43
|
+
Uses the original engine's pool. No query, upload, or server state is created.
|
|
44
|
+
Write :name placeholders in SQL cells; lists can be CAST(:ids AS VARCHAR[]).
|
|
45
|
+
"""
|
|
46
|
+
from .dbapi import _parameter
|
|
47
|
+
return engine.execution_options(periplus_parameters={name: _parameter(value) for name, value in parameters.items()})
|
periplus_sdk/sqlalchemy.py
CHANGED
|
@@ -9,10 +9,11 @@ from sqlalchemy.engine.reflection import cache
|
|
|
9
9
|
from sqlalchemy.sql.compiler import IdentifierPreparer
|
|
10
10
|
|
|
11
11
|
from . import dbapi
|
|
12
|
+
from .errors import ApiError, ResponseError, TransportError
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class SQLType(types.UserDefinedType):
|
|
15
|
-
"""Preserve
|
|
16
|
+
"""Preserve ClickHouse type names, including nested types, during reflection."""
|
|
16
17
|
|
|
17
18
|
cache_ok = True
|
|
18
19
|
|
|
@@ -24,28 +25,21 @@ class SQLType(types.UserDefinedType):
|
|
|
24
25
|
|
|
25
26
|
@property
|
|
26
27
|
def python_type(self) -> type:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if
|
|
30
|
-
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
if
|
|
34
|
-
|
|
35
|
-
if self.name == "DATE":
|
|
36
|
-
return dbapi.date
|
|
37
|
-
if self.name in dbapi._TIMESTAMP_TYPES:
|
|
38
|
-
return dbapi.datetime
|
|
39
|
-
if self.name in dbapi._TIME_TYPES:
|
|
40
|
-
return dbapi.time
|
|
41
|
-
if self.name == "BLOB":
|
|
42
|
-
return bytes
|
|
28
|
+
name = dbapi._base_type(self.name)
|
|
29
|
+
if name in dbapi._INTEGER_TYPES:return int
|
|
30
|
+
if name in dbapi._FLOAT_TYPES:return float
|
|
31
|
+
if name == 'Bool':return bool
|
|
32
|
+
if name.startswith('Decimal'):return dbapi.Decimal
|
|
33
|
+
if name in ('Date','Date32'):return dbapi.date
|
|
34
|
+
if name.startswith('DateTime'):return dbapi.datetime
|
|
35
|
+
if name.startswith('Time'):return dbapi.time
|
|
43
36
|
return str
|
|
44
37
|
|
|
45
38
|
|
|
39
|
+
|
|
46
40
|
class PeriplusDialect(default.DefaultDialect):
|
|
47
|
-
# The server speaks
|
|
48
|
-
name = "
|
|
41
|
+
# The server speaks ClickHouse SQL; this enables the correct notebook SQL dialect.
|
|
42
|
+
name = "clickhouse"
|
|
49
43
|
driver = "periplus"
|
|
50
44
|
supports_statement_cache = False
|
|
51
45
|
supports_sane_rowcount = False
|
|
@@ -91,19 +85,26 @@ class PeriplusDialect(default.DefaultDialect):
|
|
|
91
85
|
|
|
92
86
|
def _complete(self, result):
|
|
93
87
|
raw = result.cursor.result
|
|
94
|
-
if raw.truncated:
|
|
95
|
-
result.close()
|
|
96
|
-
raise exc.InvalidRequestError("Catalogue discovery was truncated by public query limits; refusing an incomplete schema.")
|
|
97
88
|
try:
|
|
98
|
-
|
|
89
|
+
rows = result.fetchall()
|
|
90
|
+
if raw.truncated:
|
|
91
|
+
raise exc.InvalidRequestError("Catalogue discovery was truncated by public query limits; refusing an incomplete schema.")
|
|
92
|
+
return rows
|
|
99
93
|
finally:
|
|
100
94
|
result.close()
|
|
101
95
|
|
|
102
96
|
@cache
|
|
103
97
|
def get_view_names(self, connection, schema=None, **kw):
|
|
104
98
|
schema = self._schema(connection, schema)
|
|
105
|
-
|
|
106
|
-
|
|
99
|
+
try:
|
|
100
|
+
catalogue = connection.connection.dbapi_connection._client.helpers()
|
|
101
|
+
except (ApiError, ResponseError, TransportError) as error:
|
|
102
|
+
raise exc.InvalidRequestError(f"Public catalogue discovery failed: {error}") from error
|
|
103
|
+
if catalogue.schema_version != schema:
|
|
104
|
+
raise exc.InvalidRequestError("Public catalogue does not match the configured schema.")
|
|
105
|
+
prefix = f"{schema}."
|
|
106
|
+
return [relation.name.removeprefix(prefix) for relation in catalogue.relations
|
|
107
|
+
if relation.kind == "view" and relation.name.startswith(prefix)]
|
|
107
108
|
|
|
108
109
|
@cache
|
|
109
110
|
def get_table_names(self, connection, schema=None, **kw):
|
|
@@ -119,9 +120,15 @@ class PeriplusDialect(default.DefaultDialect):
|
|
|
119
120
|
def get_columns(self, connection, table_name, schema=None, **kw):
|
|
120
121
|
schema = self._schema(connection, schema)
|
|
121
122
|
quote = self.identifier_preparer.quote_identifier
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
# A zero-row SELECT obtains native types through the supported public
|
|
124
|
+
# query contract, without scanning the view or requiring system access.
|
|
125
|
+
result = connection.exec_driver_sql(f"SELECT * FROM {quote(schema)}.{quote(table_name)} LIMIT 0")
|
|
126
|
+
metadata = result.cursor.result
|
|
127
|
+
self._complete(result)
|
|
128
|
+
return [{"name": name, "type": SQLType(kind),
|
|
129
|
+
"nullable": kind.startswith("Nullable(") or kind.startswith("LowCardinality(Nullable("),
|
|
130
|
+
"default": None}
|
|
131
|
+
for name, kind in zip(metadata.columns, metadata.types, strict=True)]
|
|
125
132
|
|
|
126
133
|
@cache
|
|
127
134
|
def get_pk_constraint(self, connection, table_name, schema=None, **kw):
|
periplus_sdk/stream.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Incremental query frames. EOF is never a successful completion marker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from pydantic import BaseModel, Field, JsonValue, ValidationError
|
|
9
|
+
|
|
10
|
+
from .errors import ApiError, ResponseError, TransportError
|
|
11
|
+
from .types import PreparedQuery
|
|
12
|
+
|
|
13
|
+
MEDIA_TYPE = "application/x-ndjson"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StreamResult(PreparedQuery):
|
|
17
|
+
columns: list[str]
|
|
18
|
+
types: list[str]
|
|
19
|
+
source_snapshot: int | None = Field(default=None, ge=0)
|
|
20
|
+
limits: dict[str, int]
|
|
21
|
+
complete: bool = False
|
|
22
|
+
truncated: bool = False
|
|
23
|
+
truncation_reason: Literal["max_rows", "max_result_bytes"] | None = None
|
|
24
|
+
row_count: int = 0
|
|
25
|
+
result_bytes: int = 0
|
|
26
|
+
elapsed_ms: float = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Rows(BaseModel):
|
|
30
|
+
type: Literal["rows"]
|
|
31
|
+
rows: list[list[JsonValue]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Completion(BaseModel):
|
|
35
|
+
type: Literal["complete"]
|
|
36
|
+
row_count: int = Field(ge=0)
|
|
37
|
+
result_bytes: int = Field(ge=0)
|
|
38
|
+
truncated: bool
|
|
39
|
+
truncation_reason: Literal["max_rows", "max_result_bytes"] | None
|
|
40
|
+
elapsed_ms: float = Field(ge=0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class QueryStream:
|
|
44
|
+
"""One response, consumed a batch at a time. Close early to cancel delivery."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, response: httpx.Response, *, allow_partial: bool = False):
|
|
47
|
+
self.response = response
|
|
48
|
+
self.allow_partial = allow_partial
|
|
49
|
+
self.closed = False
|
|
50
|
+
self._count = 0
|
|
51
|
+
self._lines = response.iter_lines()
|
|
52
|
+
try:
|
|
53
|
+
if response.headers.get("content-type", "").split(";")[0] != MEDIA_TYPE:
|
|
54
|
+
raise ResponseError("Expected a streaming query response.")
|
|
55
|
+
frame = self._frame()
|
|
56
|
+
if frame.get("type") != "metadata":
|
|
57
|
+
raise ResponseError("Query stream is missing metadata.")
|
|
58
|
+
self.result = StreamResult.model_validate(frame)
|
|
59
|
+
if len(self.result.columns) != len(self.result.types):
|
|
60
|
+
raise ResponseError("Query columns and types have inconsistent widths.")
|
|
61
|
+
except ValidationError:
|
|
62
|
+
self.close()
|
|
63
|
+
raise ResponseError("Invalid query stream metadata.") from None
|
|
64
|
+
except BaseException:
|
|
65
|
+
self.close()
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
def _frame(self):
|
|
69
|
+
try:
|
|
70
|
+
for line in self._lines:
|
|
71
|
+
if not line.strip():
|
|
72
|
+
continue
|
|
73
|
+
frame = json.loads(line)
|
|
74
|
+
if not isinstance(frame, dict):
|
|
75
|
+
raise ResponseError("Invalid query stream frame.")
|
|
76
|
+
if frame.get("type") == "error":
|
|
77
|
+
raise ApiError(frame.get("detail", "Query stream failed."),
|
|
78
|
+
status_code=frame.get("status", 500), code=frame.get("code"))
|
|
79
|
+
return frame
|
|
80
|
+
except httpx.RequestError:
|
|
81
|
+
raise TransportError("Query stream interrupted; the result is incomplete.") from None
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise ResponseError("Invalid query stream frame.") from None
|
|
84
|
+
raise ResponseError("Query stream ended without completion; the result is incomplete.")
|
|
85
|
+
|
|
86
|
+
def __iter__(self):
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __next__(self) -> list[list[JsonValue]]:
|
|
90
|
+
if self.closed:
|
|
91
|
+
if not self.result.complete:
|
|
92
|
+
raise ResponseError("Query stream closed before completion; the result is incomplete.")
|
|
93
|
+
if self.result.truncated and not self.allow_partial:
|
|
94
|
+
raise ResponseError("Query result exceeded its budget and is incomplete.")
|
|
95
|
+
raise StopIteration
|
|
96
|
+
try:
|
|
97
|
+
frame = self._frame()
|
|
98
|
+
if frame.get("type") == "rows":
|
|
99
|
+
rows = Rows.model_validate(frame).rows
|
|
100
|
+
if any(len(row) != len(self.result.columns) for row in rows):
|
|
101
|
+
raise ResponseError("Query row has an inconsistent width.")
|
|
102
|
+
self._count += len(rows)
|
|
103
|
+
return rows
|
|
104
|
+
completion = Completion.model_validate(frame)
|
|
105
|
+
if completion.row_count != self._count:
|
|
106
|
+
raise ResponseError("Query completion row count does not match delivered rows.")
|
|
107
|
+
for name, value in completion.model_dump(exclude={"type"}).items():
|
|
108
|
+
setattr(self.result, name, value)
|
|
109
|
+
self.result.complete = True
|
|
110
|
+
self.close()
|
|
111
|
+
if completion.truncated and not self.allow_partial:
|
|
112
|
+
budget = completion.truncation_reason
|
|
113
|
+
limit = self.result.limits.get(budget, "unknown")
|
|
114
|
+
raise ApiError(f"Query result is incomplete: {budget} ({limit}) reached after {self._count} rows. "
|
|
115
|
+
"Narrow the query or ask the administrator to increase this budget.",
|
|
116
|
+
status_code=422, code="result_limit")
|
|
117
|
+
raise StopIteration
|
|
118
|
+
except ValidationError:
|
|
119
|
+
self.close()
|
|
120
|
+
raise ResponseError("Invalid query stream frame.") from None
|
|
121
|
+
except BaseException:
|
|
122
|
+
self.close()
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
def close(self):
|
|
126
|
+
if not self.closed:
|
|
127
|
+
self.closed = True
|
|
128
|
+
self.response.close()
|
|
129
|
+
|
|
130
|
+
def __enter__(self):
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
def __exit__(self, *args):
|
|
134
|
+
self.close()
|
periplus_sdk/types.py
CHANGED
|
@@ -11,7 +11,7 @@ class Diagnostic(BaseModel):
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class PreparedQuery(BaseModel):
|
|
14
|
-
query_mode: Literal["stable"
|
|
14
|
+
query_mode: Literal["stable"]
|
|
15
15
|
compiler_version: str
|
|
16
16
|
optimizations: list[str]
|
|
17
17
|
schema_version: str
|
|
@@ -28,7 +28,10 @@ class QueryResult(PreparedQuery):
|
|
|
28
28
|
rows: list[list[JsonValue]]
|
|
29
29
|
truncated: bool
|
|
30
30
|
elapsed_ms: float
|
|
31
|
-
source_snapshot: int = Field(ge=0)
|
|
31
|
+
source_snapshot: int | None = Field(default=None, ge=0)
|
|
32
|
+
row_count: int = Field(ge=0)
|
|
33
|
+
result_bytes: int = Field(ge=0)
|
|
34
|
+
truncation_reason: Literal["max_rows", "max_result_bytes"] | None = None
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
class HelperField(BaseModel):
|
|
@@ -48,4 +51,6 @@ class QueryHelper(BaseModel):
|
|
|
48
51
|
|
|
49
52
|
class QueryHelpers(BaseModel):
|
|
50
53
|
catalogue_version: str
|
|
54
|
+
schema_version: str
|
|
55
|
+
relations: list[QueryHelper]
|
|
51
56
|
helpers: list[QueryHelper]
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: periplus-python-sdk
|
|
3
|
-
Version: 0.6.1
|
|
4
|
-
Summary: Read-only Python client for the public Periplus query API
|
|
5
|
-
License-Expression: Apache-2.0
|
|
6
|
-
Project-URL: Repository, https://github.com/elei-io/periplus
|
|
7
|
-
Project-URL: Issues, https://github.com/elei-io/periplus/issues
|
|
8
|
-
Requires-Python: >=3.11
|
|
9
|
-
Description-Content-Type: text/markdown
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
License-File: NOTICE
|
|
12
|
-
Requires-Dist: httpx>=0.28
|
|
13
|
-
Requires-Dist: pydantic<3,>=2.12
|
|
14
|
-
Requires-Dist: sqlalchemy<3,>=2.0
|
|
15
|
-
Provides-Extra: notebook
|
|
16
|
-
Requires-Dist: marimo[sql]>=0.24.1; extra == "notebook"
|
|
17
|
-
Dynamic: license-file
|
|
18
|
-
|
|
19
|
-
# Periplus Python SDK
|
|
20
|
-
|
|
21
|
-
A read-only client for the public Periplus query API. Python 3.11 or later.
|
|
22
|
-
Configure the **public web application URL**, not the internal query or control service.
|
|
23
|
-
No API token, DuckDB installation or lake credentials are needed.
|
|
24
|
-
|
|
25
|
-
```python
|
|
26
|
-
from periplus_sdk import Client
|
|
27
|
-
|
|
28
|
-
with Client("http://localhost:8080") as client:
|
|
29
|
-
result = client.execute(
|
|
30
|
-
"SELECT capture_id FROM public_v1.capture LIMIT ?", [10]
|
|
31
|
-
)
|
|
32
|
-
print(result.columns, result.types)
|
|
33
|
-
print(result.rows)
|
|
34
|
-
print(result.source_snapshot, result.truncated)
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
For a hosted deployment, replace the URL with its public HTTPS origin. Alternatively set
|
|
38
|
-
`PERIPLUS_PUBLIC_URL` and use `Client()`. An optional URL path prefix is preserved.
|
|
39
|
-
The client reuses HTTP connections; close it with a context manager or `close()`.
|
|
40
|
-
|
|
41
|
-
## Marimo SQL cells and schema browser
|
|
42
|
-
|
|
43
|
-
Install the notebook integration from PyPI:
|
|
44
|
-
|
|
45
|
-
```sh
|
|
46
|
-
uv add "periplus-python-sdk[notebook]>=0.6.1"
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
In a Python setup cell, create a SQLAlchemy engine:
|
|
50
|
-
|
|
51
|
-
```python
|
|
52
|
-
from periplus_sdk import sql_api
|
|
53
|
-
|
|
54
|
-
pp = sql_api.create_engine("https://periplus.dev", mode="stable")
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Add a SQL cell, select **pp** in its connection dropdown, and enter:
|
|
58
|
-
|
|
59
|
-
```sql
|
|
60
|
-
SELECT capture_id, requested_url
|
|
61
|
-
FROM public_v1.capture
|
|
62
|
-
LIMIT 10
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Marimo displays the result as a table. Expand **pp → periplus → public_v1** in Data Sources
|
|
66
|
-
to discover views and expand a view to load its columns for SQL completion.
|
|
67
|
-
Discovery uses bounded `SHOW TABLES` and `DESCRIBE` through the same public API;
|
|
68
|
-
no internal catalogue or storage credentials are used. Truncated discovery fails
|
|
69
|
-
explicitly rather than displaying a silently incomplete schema. To eagerly load
|
|
70
|
-
schemas and views, enable their discovery in marimo's Packages & Data settings.
|
|
71
|
-
Column discovery is on demand by default, to avoid many public API requests.
|
|
72
|
-
|
|
73
|
-
The Python equivalent of a SQL cell is:
|
|
74
|
-
|
|
75
|
-
```python
|
|
76
|
-
import marimo as mo
|
|
77
|
-
|
|
78
|
-
captures = mo.sql(
|
|
79
|
-
"SELECT capture_id FROM public_v1.capture LIMIT 10",
|
|
80
|
-
engine=pp,
|
|
81
|
-
)
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Set `mode="experimental"` for the experimental service. Omit the URL to use
|
|
85
|
-
`PERIPLUS_PUBLIC_URL`. Optional `timeout=140` and `schema_version="public_v1"`
|
|
86
|
-
arguments configure the client deadline and public schema. Run `pp.dispose()` when finished. This is a read-only
|
|
87
|
-
SQLAlchemy dialect for textual SQL and reflection, not a writable ORM backend.
|
|
88
|
-
Each statement has its own server snapshot; SQLAlchemy transaction blocks do not
|
|
89
|
-
provide a shared snapshot or rollback. The adapter makes no transaction requests.
|
|
90
|
-
|
|
91
|
-
A complete notebook is in `examples/notebook.py`. The integration is tested with
|
|
92
|
-
marimo 0.24.1 and SQLAlchemy 2.x. SQLAlchemy is included in the standard SDK install; the `notebook` extra adds
|
|
93
|
-
marimo. Existing marimo environments only need `uv add "periplus-python-sdk>=0.6.1"`.
|
|
94
|
-
The returned object is a standard SQLAlchemy Engine, also usable with pandas and
|
|
95
|
-
ordinary Python scripts. Engine creation is lazy; the first query opens a connection.
|
|
96
|
-
|
|
97
|
-
## DB-API connection
|
|
98
|
-
|
|
99
|
-
For SQL cells without schema browsing, or standard cursor-based Python code:
|
|
100
|
-
|
|
101
|
-
```python
|
|
102
|
-
from periplus_sdk import connect
|
|
103
|
-
|
|
104
|
-
with connect("https://periplus.dev", mode="stable") as connection:
|
|
105
|
-
with connection.cursor() as cursor:
|
|
106
|
-
cursor.execute("SELECT capture_id FROM public_v1.capture LIMIT ?", [10])
|
|
107
|
-
print(cursor.description)
|
|
108
|
-
print(cursor.fetchall())
|
|
109
|
-
print(cursor.result.source_snapshot)
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
Connections expose `cursor`, `execute`, `close`, and context managers. Cursors
|
|
113
|
-
support `execute`, `fetchone`, `fetchmany`, `fetchall`, iteration, and close.
|
|
114
|
-
Use positional `?` parameters. Decimal and temporal parameters are sent as
|
|
115
|
-
strings; use explicit SQL casts. Binary and nested parameters are not supported
|
|
116
|
-
by this adapter. Fetching only consumes the bounded result already received;
|
|
117
|
-
it never issues pagination or retries. Connections/cursors are not thread-shared.
|
|
118
|
-
`commit()` is a no-op; `rollback()` and `executemany()` are unsupported.
|
|
119
|
-
|
|
120
|
-
`cursor.result` preserves the original query response. `connection.last_result`
|
|
121
|
-
also retains it after marimo closes a cursor; a new execution clears it first.
|
|
122
|
-
Truncation emits `periplus_sdk.dbapi.TruncationWarning` and sets `rowcount` to -1.
|
|
123
|
-
DB-API failures use the standard exception hierarchy in `periplus_sdk.dbapi`;
|
|
124
|
-
HTTP errors retain `status_code`, `code`, and `retry_after_seconds`.
|
|
125
|
-
|
|
126
|
-
Scalar integer, floating-point, decimal, date, time, timestamp and BLOB results
|
|
127
|
-
are decoded to Python values. UUIDs remain strings. Nested/other SQL types keep
|
|
128
|
-
their JSON wire representation; out-of-range dates/timestamps remain strings.
|
|
129
|
-
Temporal precision is limited to what the server JSON transport preserves.
|
|
130
|
-
The cursor preserves duplicate column names, but dataframe libraries/marimo may
|
|
131
|
-
not: use unique SQL aliases. Dataframe inference can lose types for empty or
|
|
132
|
-
all-null results; `cursor.description` retains the SQL type names.
|
|
133
|
-
|
|
134
|
-
## Stable and experimental APIs
|
|
135
|
-
|
|
136
|
-
Both clients accept `mode="stable"` (the default) or `mode="experimental"` at initialization:
|
|
137
|
-
|
|
138
|
-
```python
|
|
139
|
-
with Client("https://periplus.dev", mode="experimental") as client:
|
|
140
|
-
result = client.execute("SELECT capture_id FROM public_v1.capture LIMIT 1")
|
|
141
|
-
print(result.query_mode, result.compiler_version, result.optimizations)
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
The selected mode applies to preparation, execution, and helper discovery. Experimental
|
|
145
|
-
requests use the public application's `/api/query/experimental/` routes. There is no
|
|
146
|
-
automatic fallback to stable if the experimental service is unavailable.
|
|
147
|
-
`AsyncClient` accepts the same option. Invalid modes raise `ConfigurationError`.
|
|
148
|
-
|
|
149
|
-
## Preparation and helpers
|
|
150
|
-
|
|
151
|
-
```python
|
|
152
|
-
with Client("http://localhost:8080") as client:
|
|
153
|
-
prepared = client.prepare("SELECT capture_id FROM public_v1.capture LIMIT ?", [10])
|
|
154
|
-
print(prepared.diagnostics, prepared.plan)
|
|
155
|
-
result = client.execute(prepared.sql, prepared.parameters)
|
|
156
|
-
helpers = client.helpers()
|
|
157
|
-
print(helpers.catalogue_version, helpers.helpers)
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
Preparation validates and explains without executing the analytical query. Execution independently
|
|
161
|
-
validates and prepares; a prior preparation never authorizes SQL. Linting, diagnostics and future
|
|
162
|
-
SQL optimizations belong to the server. The SDK sends SQL unchanged.
|
|
163
|
-
|
|
164
|
-
## Async use
|
|
165
|
-
|
|
166
|
-
```python
|
|
167
|
-
from periplus_sdk import AsyncClient
|
|
168
|
-
|
|
169
|
-
async def observations():
|
|
170
|
-
async with AsyncClient("http://localhost:8080") as client:
|
|
171
|
-
return await client.execute("SELECT capture_id FROM public_v1.capture LIMIT 10")
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
Use `aclose()` when managing an async client's lifetime explicitly.
|
|
175
|
-
|
|
176
|
-
## Permissions, results and errors
|
|
177
|
-
|
|
178
|
-
- The same public SQL feature switch, shared rate budget, namespace validation and read-only
|
|
179
|
-
execution apply as in the public web workspace. The SDK provides no writes, crawling,
|
|
180
|
-
administrative controls or direct lake attachment.
|
|
181
|
-
- Results retain `query_id`, SQL, parameters, diagnostics, plan, columns, SQL types, JSON rows,
|
|
182
|
-
elapsed milliseconds, `source_snapshot` and `truncated`. Decimals and large integers remain
|
|
183
|
-
strings exactly as returned by the server. Duplicate column names are preserved.
|
|
184
|
-
- Operator-configured execution limits default to 1,000 rows, an 8 MiB result budget and a
|
|
185
|
-
20-second server deadline. Always inspect `truncated`. The SDK does not silently fetch more rows or retry.
|
|
186
|
-
- `ApiError` exposes `status_code`, safe `code`, and `retry_after_seconds` when supplied.
|
|
187
|
-
`TransportError` means HTTP failed; `ResponseError` means a malformed successful response.
|
|
188
|
-
The client timeout defaults to 140 seconds and can be set with `timeout=`. A timeout or local
|
|
189
|
-
cancellation does not guarantee server cancellation. Redirects are not followed automatically.
|
|
190
|
-
- Preparation and execution are attributed to `sdk` in the existing private query history.
|
|
191
|
-
Original SQL and parameters are retained for 30 days; result rows are not stored. Recording is
|
|
192
|
-
best-effort and can be lost during outages or backpressure. This label is not a user identity.
|
|
193
|
-
|
|
194
|
-
## Installation and verification
|
|
195
|
-
|
|
196
|
-
Install the public-v1 client from PyPI:
|
|
197
|
-
|
|
198
|
-
```sh
|
|
199
|
-
python -m pip install "periplus-python-sdk>=0.6.1"
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
Version 0.6.1 supports the current public-v1 contract. For production, configure
|
|
203
|
-
`PERIPLUS_PUBLIC_URL=https://periplus.dev`; no API token is required.
|
|
204
|
-
Run the installed package against an available public app:
|
|
205
|
-
|
|
206
|
-
```sh
|
|
207
|
-
PERIPLUS_PUBLIC_URL=http://localhost:8080 python packages/periplus-python-sdk/examples/smoke.py
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
## Releasing
|
|
211
|
-
|
|
212
|
-
Repository CI publishes immutable releases from tags named
|
|
213
|
-
`periplus-python-sdk-v<version>`. The tag must exactly match the static version
|
|
214
|
-
in `pyproject.toml`; for example, version `0.6.1` is released with:
|
|
215
|
-
|
|
216
|
-
```sh
|
|
217
|
-
git tag periplus-python-sdk-v0.6.1
|
|
218
|
-
git push origin periplus-python-sdk-v0.6.1
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
PyPI publishing uses Trusted Publishing rather than a stored API token. The
|
|
222
|
-
PyPI publisher must be configured for GitHub owner `elei-io`, repository
|
|
223
|
-
`periplus`, workflow `python-sdk-release.yml`, and environment `pypi`. Protect
|
|
224
|
-
that GitHub environment with required reviewers before the first release.
|
|
225
|
-
|
|
226
|
-
## Public v1
|
|
227
|
-
|
|
228
|
-
Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.6.1"`. The previously published 0.2.0 release predates this contract. `prepare` and `execute` accept keyword-only `schema_version="public_v1"` (the default); responses preserve `schema_version` separately from `source_snapshot`. Unavailable versions are rejected by the server.
|
|
229
|
-
|
|
230
|
-
## License
|
|
231
|
-
|
|
232
|
-
Copyright (c) 2026 Ekku Leivonen (elei.io). Licensed under [Apache-2.0](LICENSE);
|
|
233
|
-
see [NOTICE](NOTICE). The server and other repository packages have separate
|
|
234
|
-
licensing described in the root LICENSING.md.
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
periplus_python_sdk-0.6.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
2
|
-
periplus_python_sdk-0.6.1.dist-info/licenses/NOTICE,sha256=bhbYSqcUB3U_P1-XzloiT81JGniqoYaRLxNkQ1Pm9MQ,52
|
|
3
|
-
periplus_sdk/__init__.py,sha256=WimXYlPB6tCimBO4VSwhcp00dwSL87jMmMuQ4-kINfM,546
|
|
4
|
-
periplus_sdk/client.py,sha256=ZwMmVJKNF-FrGh_qwiQ5myhu8XJodb_96ohkqK47yDA,6557
|
|
5
|
-
periplus_sdk/dbapi.py,sha256=lK9ZROaMKXm26wvC7DCKywm3qwSvqeHa7zkktnJVP80,11244
|
|
6
|
-
periplus_sdk/errors.py,sha256=rB1n-v8Hc2tu2dtHivz-MlqsCoRC5pTcWogTfM7SMLw,855
|
|
7
|
-
periplus_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
8
|
-
periplus_sdk/sql_api.py,sha256=ym50tZNVDKzUk9lBXlrT0K79E9b1Cn0btOqw4xshuM8,880
|
|
9
|
-
periplus_sdk/sqlalchemy.py,sha256=AE68tzCrx-OZGOPkSsCZAlXaJn190yH2ez2ZuxI5VrY,4831
|
|
10
|
-
periplus_sdk/types.py,sha256=PTdJO6BYTY97dBd3HMEZ52k6P9s0cMvidjpnc8zUpy4,1045
|
|
11
|
-
periplus_python_sdk-0.6.1.dist-info/METADATA,sha256=K76t7I8jbUYVeWt02bAvRylw8yCJVy7TIoczCK-eQgY,10212
|
|
12
|
-
periplus_python_sdk-0.6.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
-
periplus_python_sdk-0.6.1.dist-info/entry_points.txt,sha256=Pr14L_7AhLinq-4qDxB4awFVubrvR1BEfkaFVRpdCbU,73
|
|
14
|
-
periplus_python_sdk-0.6.1.dist-info/top_level.txt,sha256=o41t5TzwgoxzSmbKoP6olWW1FyEAGWVYTjeoKadBK40,13
|
|
15
|
-
periplus_python_sdk-0.6.1.dist-info/RECORD,,
|
|
File without changes
|
{periplus_python_sdk-0.6.1.dist-info → periplus_python_sdk-0.8.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{periplus_python_sdk-0.6.1.dist-info → periplus_python_sdk-0.8.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|