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.
@@ -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 = 140, mode: Literal["stable", "experimental"] = "stable"):
83
- if mode not in {"stable", "experimental"}:
84
- raise ConfigurationError("mode must be stable or experimental.")
85
- self._query_path = "api/query/experimental/" if mode == "experimental" else "api/query/"
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 = "public_v1") -> PreparedQuery:
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 = "public_v1") -> QueryResult:
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 = 140, mode: Literal["stable", "experimental"] = "stable"):
118
- if mode not in {"stable", "experimental"}:
119
- raise ConfigurationError("mode must be stable or experimental.")
120
- self._query_path = "api/query/experimental/" if mode == "experimental" else "api/query/"
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 = "public_v1") -> PreparedQuery:
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 = "public_v1") -> QueryResult:
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 local
4
- result, never a remote cursor. Connections and cursors must not be shared by threads.
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 .types import QueryResult
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 = {"TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT", "UTINYINT",
95
- "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT", "BIGNUM"}
96
- _FLOAT_TYPES = {"FLOAT", "DOUBLE", "REAL"}
97
- _TIME_TYPES = {"TIME", "TIME WITH TIME ZONE", "TIMETZ"}
98
- _TIMESTAMP_TYPES = {"TIMESTAMP", "TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_NS",
99
- "TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ"}
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], prefix: str = ""):
104
- self.names, self.prefix = names, prefix
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
- return isinstance(other, str) and (other in self.names or bool(self.prefix and other.startswith(self.prefix)))
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({"VARCHAR", "UUID", "JSON", "ENUM"})
111
- BINARY = _TypeCategory({"BLOB"})
112
- NUMBER = _TypeCategory(_INTEGER_TYPES | _FLOAT_TYPES | {"BOOLEAN"}, "DECIMAL(")
113
- DATETIME = _TypeCategory({"DATE"} | _TIME_TYPES | _TIMESTAMP_TYPES)
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
- return None
120
- if sql_type in _INTEGER_TYPES:
121
- return int(value)
122
- if sql_type in _FLOAT_TYPES:
123
- return float(value)
124
- if sql_type.startswith("DECIMAL("):
125
- return Decimal(str(value))
126
- # Preserve infinities and out-of-range dates rather than clipping them.
127
- if sql_type == "DATE":
128
- try:
129
- return date.fromisoformat(value)
130
- except ValueError:
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
- raise ProgrammingError("Parameters must be scalar JSON values, Decimal, date, time or datetime; use explicit SQL casts for typed strings.")
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 = "duckdb"
148
+ dialect = "clickhouse"
157
149
 
158
- def __init__(self, base_url: str | None = None, *, timeout: float = 140,
159
- mode: Literal["stable", "experimental"] = "stable",
160
- schema_version: str = "public_v1"):
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: QueryResult | None = None
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
- return Cursor(self)
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 = 140,
208
- mode: Literal["stable", "experimental"] = "stable",
209
- schema_version: str = "public_v1") -> Connection:
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
- """A buffered result. Metadata stays on result after rows are consumed."""
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: QueryResult | None = None
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
- result = self.connection._client.execute(operation, values, schema_version=self.connection.schema_version)
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
- if self._position == len(self._rows):
273
- return None
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
- end = min(self._position + size, len(self._rows))
284
- rows = self._rows[self._position:end]
285
- self._position = end
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 self.fetchmany(len(self._rows) - self._position)
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", "experimental"] = "stable",
14
- timeout: float = 140,
15
- schema_version: str = "public_v1",
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
- return _create_engine(
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()})
@@ -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 DuckDB type names, including nested types, during reflection."""
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
- if self.name in dbapi._INTEGER_TYPES:
28
- return int
29
- if self.name in dbapi._FLOAT_TYPES:
30
- return float
31
- if self.name == "BOOLEAN":
32
- return bool
33
- if self.name.startswith("DECIMAL("):
34
- return dbapi.Decimal
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 DuckDB SQL; this enables the correct notebook SQL dialect.
48
- name = "duckdb"
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
- return result.fetchall()
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
- result = connection.exec_driver_sql(f"SHOW TABLES FROM {self.identifier_preparer.quote_identifier(schema)}")
106
- return [row[0] for row in self._complete(result)]
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
- result = connection.exec_driver_sql(f"DESCRIBE {quote(schema)}.{quote(table_name)}")
123
- return [{"name": row[0], "type": SQLType(row[1]), "nullable": row[2] != "NO",
124
- "default": row[4]} for row in self._complete(result)]
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", "experimental"]
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,,