periplus-python-sdk 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {periplus_python_sdk-0.4.0/src/periplus_python_sdk.egg-info → periplus_python_sdk-0.5.0}/PKG-INFO +106 -7
  2. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/README.md +100 -6
  3. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/pyproject.toml +8 -1
  4. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0/src/periplus_python_sdk.egg-info}/PKG-INFO +106 -7
  5. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/src/periplus_python_sdk.egg-info/SOURCES.txt +6 -1
  6. periplus_python_sdk-0.5.0/src/periplus_python_sdk.egg-info/entry_points.txt +2 -0
  7. periplus_python_sdk-0.5.0/src/periplus_python_sdk.egg-info/requires.txt +9 -0
  8. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/src/periplus_sdk/__init__.py +2 -1
  9. periplus_python_sdk-0.5.0/src/periplus_sdk/dbapi.py +324 -0
  10. periplus_python_sdk-0.5.0/src/periplus_sdk/sqlalchemy.py +139 -0
  11. periplus_python_sdk-0.5.0/tests/test_dbapi.py +118 -0
  12. periplus_python_sdk-0.5.0/tests/test_notebook.py +87 -0
  13. periplus_python_sdk-0.4.0/src/periplus_python_sdk.egg-info/requires.txt +0 -2
  14. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/LICENSE +0 -0
  15. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/NOTICE +0 -0
  16. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/setup.cfg +0 -0
  17. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/src/periplus_python_sdk.egg-info/dependency_links.txt +0 -0
  18. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/src/periplus_python_sdk.egg-info/top_level.txt +0 -0
  19. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/src/periplus_sdk/client.py +0 -0
  20. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/src/periplus_sdk/errors.py +0 -0
  21. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/src/periplus_sdk/py.typed +0 -0
  22. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/src/periplus_sdk/types.py +0 -0
  23. {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.5.0}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: periplus-python-sdk
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Read-only Python client for the public Periplus query API
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Repository, https://github.com/elei-io/periplus
@@ -11,6 +11,11 @@ License-File: LICENSE
11
11
  License-File: NOTICE
12
12
  Requires-Dist: httpx>=0.28
13
13
  Requires-Dist: pydantic<3,>=2.12
14
+ Provides-Extra: sqlalchemy
15
+ Requires-Dist: sqlalchemy<3,>=2.0; extra == "sqlalchemy"
16
+ Provides-Extra: notebook
17
+ Requires-Dist: sqlalchemy<3,>=2.0; extra == "notebook"
18
+ Requires-Dist: marimo[sql]>=0.24.1; extra == "notebook"
14
19
  Dynamic: license-file
15
20
 
16
21
  # Periplus Python SDK
@@ -35,6 +40,100 @@ For a hosted deployment, replace the URL with its public HTTPS origin. Alternati
35
40
  `PERIPLUS_PUBLIC_URL` and use `Client()`. An optional URL path prefix is preserved.
36
41
  The client reuses HTTP connections; close it with a context manager or `close()`.
37
42
 
43
+ ## Marimo SQL cells and schema browser
44
+
45
+ Install the notebook integration from PyPI:
46
+
47
+ ```sh
48
+ uv add "periplus-python-sdk[notebook]>=0.5.0"
49
+ ```
50
+
51
+ In a Python setup cell, create a SQLAlchemy engine:
52
+
53
+ ```python
54
+ from sqlalchemy import create_engine
55
+
56
+ pp = create_engine(
57
+ "periplus:///public_v1",
58
+ connect_args={"base_url": "https://periplus.dev", "mode": "stable"},
59
+ )
60
+ ```
61
+
62
+ Add a SQL cell, select **pp** in its connection dropdown, and enter:
63
+
64
+ ```sql
65
+ SELECT capture_id, requested_url
66
+ FROM public_v1.capture
67
+ LIMIT 10
68
+ ```
69
+
70
+ Marimo displays the result as a table. Expand **pp → public_v1** in Data Sources
71
+ to discover views and expand a view to load its columns for SQL completion.
72
+ Discovery uses bounded `SHOW TABLES` and `DESCRIBE` through the same public API;
73
+ no internal catalogue or storage credentials are used. Truncated discovery fails
74
+ explicitly rather than displaying a silently incomplete schema. To eagerly load
75
+ schemas and views, enable their discovery in marimo's Packages & Data settings.
76
+ Column discovery is on demand by default, to avoid many public API requests.
77
+
78
+ The Python equivalent of a SQL cell is:
79
+
80
+ ```python
81
+ import marimo as mo
82
+
83
+ captures = mo.sql(
84
+ "SELECT capture_id FROM public_v1.capture LIMIT 10",
85
+ engine=pp,
86
+ )
87
+ ```
88
+
89
+ Set `mode="experimental"` in `connect_args` for the experimental service. The URL
90
+ path names the public schema; the HTTPS endpoint belongs in `base_url` (or set
91
+ `PERIPLUS_PUBLIC_URL`). Run `pp.dispose()` when finished. This is a read-only
92
+ SQLAlchemy dialect for textual SQL and reflection, not a writable ORM backend.
93
+ Each statement has its own server snapshot; SQLAlchemy transaction blocks do not
94
+ provide a shared snapshot or rollback. The adapter makes no transaction requests.
95
+
96
+ A complete notebook is in `examples/notebook.py`. The integration is tested with
97
+ marimo 0.24.1 and SQLAlchemy 2.x. For SQLAlchemy without marimo, install the
98
+ `sqlalchemy` extra instead of `notebook`.
99
+
100
+ ## DB-API connection
101
+
102
+ For SQL cells without schema browsing, or standard cursor-based Python code:
103
+
104
+ ```python
105
+ from periplus_sdk import connect
106
+
107
+ with connect("https://periplus.dev", mode="stable") as connection:
108
+ with connection.cursor() as cursor:
109
+ cursor.execute("SELECT capture_id FROM public_v1.capture LIMIT ?", [10])
110
+ print(cursor.description)
111
+ print(cursor.fetchall())
112
+ print(cursor.result.source_snapshot)
113
+ ```
114
+
115
+ Connections expose `cursor`, `execute`, `close`, and context managers. Cursors
116
+ support `execute`, `fetchone`, `fetchmany`, `fetchall`, iteration, and close.
117
+ Use positional `?` parameters. Decimal and temporal parameters are sent as
118
+ strings; use explicit SQL casts. Binary and nested parameters are not supported
119
+ by this adapter. Fetching only consumes the bounded result already received;
120
+ it never issues pagination or retries. Connections/cursors are not thread-shared.
121
+ `commit()` is a no-op; `rollback()` and `executemany()` are unsupported.
122
+
123
+ `cursor.result` preserves the original query response. `connection.last_result`
124
+ also retains it after marimo closes a cursor; a new execution clears it first.
125
+ Truncation emits `periplus_sdk.dbapi.TruncationWarning` and sets `rowcount` to -1.
126
+ DB-API failures use the standard exception hierarchy in `periplus_sdk.dbapi`;
127
+ HTTP errors retain `status_code`, `code`, and `retry_after_seconds`.
128
+
129
+ Scalar integer, floating-point, decimal, date, time, timestamp and BLOB results
130
+ are decoded to Python values. UUIDs remain strings. Nested/other SQL types keep
131
+ their JSON wire representation; out-of-range dates/timestamps remain strings.
132
+ Temporal precision is limited to what the server JSON transport preserves.
133
+ The cursor preserves duplicate column names, but dataframe libraries/marimo may
134
+ not: use unique SQL aliases. Dataframe inference can lose types for empty or
135
+ all-null results; `cursor.description` retains the SQL type names.
136
+
38
137
  ## Stable and experimental APIs
39
138
 
40
139
  Both clients accept `mode="stable"` (the default) or `mode="experimental"` at initialization:
@@ -100,10 +199,10 @@ Use `aclose()` when managing an async client's lifetime explicitly.
100
199
  Install the public-v1 client from PyPI:
101
200
 
102
201
  ```sh
103
- python -m pip install "periplus-python-sdk>=0.4.0"
202
+ python -m pip install "periplus-python-sdk>=0.5.0"
104
203
  ```
105
204
 
106
- Version 0.4.0 supports the current public-v1 contract. For production, configure
205
+ Version 0.5.0 supports the current public-v1 contract. For production, configure
107
206
  `PERIPLUS_PUBLIC_URL=https://periplus.dev`; no API token is required.
108
207
  Run the installed package against an available public app:
109
208
 
@@ -115,11 +214,11 @@ PERIPLUS_PUBLIC_URL=http://localhost:8080 python packages/periplus-python-sdk/ex
115
214
 
116
215
  Repository CI publishes immutable releases from tags named
117
216
  `periplus-python-sdk-v<version>`. The tag must exactly match the static version
118
- in `pyproject.toml`; for example, version `0.4.0` is released with:
217
+ in `pyproject.toml`; for example, version `0.5.0` is released with:
119
218
 
120
219
  ```sh
121
- git tag periplus-python-sdk-v0.4.0
122
- git push origin periplus-python-sdk-v0.4.0
220
+ git tag periplus-python-sdk-v0.5.0
221
+ git push origin periplus-python-sdk-v0.5.0
123
222
  ```
124
223
 
125
224
  PyPI publishing uses Trusted Publishing rather than a stored API token. The
@@ -129,7 +228,7 @@ that GitHub environment with required reviewers before the first release.
129
228
 
130
229
  ## Public v1
131
230
 
132
- Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.4.0"`. 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.
231
+ Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.5.0"`. 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.
133
232
 
134
233
  ## License
135
234
 
@@ -20,6 +20,100 @@ For a hosted deployment, replace the URL with its public HTTPS origin. Alternati
20
20
  `PERIPLUS_PUBLIC_URL` and use `Client()`. An optional URL path prefix is preserved.
21
21
  The client reuses HTTP connections; close it with a context manager or `close()`.
22
22
 
23
+ ## Marimo SQL cells and schema browser
24
+
25
+ Install the notebook integration from PyPI:
26
+
27
+ ```sh
28
+ uv add "periplus-python-sdk[notebook]>=0.5.0"
29
+ ```
30
+
31
+ In a Python setup cell, create a SQLAlchemy engine:
32
+
33
+ ```python
34
+ from sqlalchemy import create_engine
35
+
36
+ pp = create_engine(
37
+ "periplus:///public_v1",
38
+ connect_args={"base_url": "https://periplus.dev", "mode": "stable"},
39
+ )
40
+ ```
41
+
42
+ Add a SQL cell, select **pp** in its connection dropdown, and enter:
43
+
44
+ ```sql
45
+ SELECT capture_id, requested_url
46
+ FROM public_v1.capture
47
+ LIMIT 10
48
+ ```
49
+
50
+ Marimo displays the result as a table. Expand **pp → public_v1** in Data Sources
51
+ to discover views and expand a view to load its columns for SQL completion.
52
+ Discovery uses bounded `SHOW TABLES` and `DESCRIBE` through the same public API;
53
+ no internal catalogue or storage credentials are used. Truncated discovery fails
54
+ explicitly rather than displaying a silently incomplete schema. To eagerly load
55
+ schemas and views, enable their discovery in marimo's Packages & Data settings.
56
+ Column discovery is on demand by default, to avoid many public API requests.
57
+
58
+ The Python equivalent of a SQL cell is:
59
+
60
+ ```python
61
+ import marimo as mo
62
+
63
+ captures = mo.sql(
64
+ "SELECT capture_id FROM public_v1.capture LIMIT 10",
65
+ engine=pp,
66
+ )
67
+ ```
68
+
69
+ Set `mode="experimental"` in `connect_args` for the experimental service. The URL
70
+ path names the public schema; the HTTPS endpoint belongs in `base_url` (or set
71
+ `PERIPLUS_PUBLIC_URL`). Run `pp.dispose()` when finished. This is a read-only
72
+ SQLAlchemy dialect for textual SQL and reflection, not a writable ORM backend.
73
+ Each statement has its own server snapshot; SQLAlchemy transaction blocks do not
74
+ provide a shared snapshot or rollback. The adapter makes no transaction requests.
75
+
76
+ A complete notebook is in `examples/notebook.py`. The integration is tested with
77
+ marimo 0.24.1 and SQLAlchemy 2.x. For SQLAlchemy without marimo, install the
78
+ `sqlalchemy` extra instead of `notebook`.
79
+
80
+ ## DB-API connection
81
+
82
+ For SQL cells without schema browsing, or standard cursor-based Python code:
83
+
84
+ ```python
85
+ from periplus_sdk import connect
86
+
87
+ with connect("https://periplus.dev", mode="stable") as connection:
88
+ with connection.cursor() as cursor:
89
+ cursor.execute("SELECT capture_id FROM public_v1.capture LIMIT ?", [10])
90
+ print(cursor.description)
91
+ print(cursor.fetchall())
92
+ print(cursor.result.source_snapshot)
93
+ ```
94
+
95
+ Connections expose `cursor`, `execute`, `close`, and context managers. Cursors
96
+ support `execute`, `fetchone`, `fetchmany`, `fetchall`, iteration, and close.
97
+ Use positional `?` parameters. Decimal and temporal parameters are sent as
98
+ strings; use explicit SQL casts. Binary and nested parameters are not supported
99
+ by this adapter. Fetching only consumes the bounded result already received;
100
+ it never issues pagination or retries. Connections/cursors are not thread-shared.
101
+ `commit()` is a no-op; `rollback()` and `executemany()` are unsupported.
102
+
103
+ `cursor.result` preserves the original query response. `connection.last_result`
104
+ also retains it after marimo closes a cursor; a new execution clears it first.
105
+ Truncation emits `periplus_sdk.dbapi.TruncationWarning` and sets `rowcount` to -1.
106
+ DB-API failures use the standard exception hierarchy in `periplus_sdk.dbapi`;
107
+ HTTP errors retain `status_code`, `code`, and `retry_after_seconds`.
108
+
109
+ Scalar integer, floating-point, decimal, date, time, timestamp and BLOB results
110
+ are decoded to Python values. UUIDs remain strings. Nested/other SQL types keep
111
+ their JSON wire representation; out-of-range dates/timestamps remain strings.
112
+ Temporal precision is limited to what the server JSON transport preserves.
113
+ The cursor preserves duplicate column names, but dataframe libraries/marimo may
114
+ not: use unique SQL aliases. Dataframe inference can lose types for empty or
115
+ all-null results; `cursor.description` retains the SQL type names.
116
+
23
117
  ## Stable and experimental APIs
24
118
 
25
119
  Both clients accept `mode="stable"` (the default) or `mode="experimental"` at initialization:
@@ -85,10 +179,10 @@ Use `aclose()` when managing an async client's lifetime explicitly.
85
179
  Install the public-v1 client from PyPI:
86
180
 
87
181
  ```sh
88
- python -m pip install "periplus-python-sdk>=0.4.0"
182
+ python -m pip install "periplus-python-sdk>=0.5.0"
89
183
  ```
90
184
 
91
- Version 0.4.0 supports the current public-v1 contract. For production, configure
185
+ Version 0.5.0 supports the current public-v1 contract. For production, configure
92
186
  `PERIPLUS_PUBLIC_URL=https://periplus.dev`; no API token is required.
93
187
  Run the installed package against an available public app:
94
188
 
@@ -100,11 +194,11 @@ PERIPLUS_PUBLIC_URL=http://localhost:8080 python packages/periplus-python-sdk/ex
100
194
 
101
195
  Repository CI publishes immutable releases from tags named
102
196
  `periplus-python-sdk-v<version>`. The tag must exactly match the static version
103
- in `pyproject.toml`; for example, version `0.4.0` is released with:
197
+ in `pyproject.toml`; for example, version `0.5.0` is released with:
104
198
 
105
199
  ```sh
106
- git tag periplus-python-sdk-v0.4.0
107
- git push origin periplus-python-sdk-v0.4.0
200
+ git tag periplus-python-sdk-v0.5.0
201
+ git push origin periplus-python-sdk-v0.5.0
108
202
  ```
109
203
 
110
204
  PyPI publishing uses Trusted Publishing rather than a stored API token. The
@@ -114,7 +208,7 @@ that GitHub environment with required reviewers before the first release.
114
208
 
115
209
  ## Public v1
116
210
 
117
- Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.4.0"`. 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.
211
+ Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.5.0"`. 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.
118
212
 
119
213
  ## License
120
214
 
@@ -2,7 +2,7 @@
2
2
  license = "Apache-2.0"
3
3
  license-files = ["LICENSE", "NOTICE"]
4
4
  name = "periplus-python-sdk"
5
- version = "0.4.0"
5
+ version = "0.5.0"
6
6
  description = "Read-only Python client for the public Periplus query API"
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.11"
@@ -11,6 +11,13 @@ dependencies = [
11
11
  "pydantic>=2.12,<3",
12
12
  ]
13
13
 
14
+ [project.optional-dependencies]
15
+ sqlalchemy = ["sqlalchemy>=2.0,<3"]
16
+ notebook = ["sqlalchemy>=2.0,<3", "marimo[sql]>=0.24.1"]
17
+
18
+ [project.entry-points."sqlalchemy.dialects"]
19
+ periplus = "periplus_sdk.sqlalchemy:PeriplusDialect"
20
+
14
21
  [project.urls]
15
22
  Repository = "https://github.com/elei-io/periplus"
16
23
  Issues = "https://github.com/elei-io/periplus/issues"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: periplus-python-sdk
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Read-only Python client for the public Periplus query API
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Repository, https://github.com/elei-io/periplus
@@ -11,6 +11,11 @@ License-File: LICENSE
11
11
  License-File: NOTICE
12
12
  Requires-Dist: httpx>=0.28
13
13
  Requires-Dist: pydantic<3,>=2.12
14
+ Provides-Extra: sqlalchemy
15
+ Requires-Dist: sqlalchemy<3,>=2.0; extra == "sqlalchemy"
16
+ Provides-Extra: notebook
17
+ Requires-Dist: sqlalchemy<3,>=2.0; extra == "notebook"
18
+ Requires-Dist: marimo[sql]>=0.24.1; extra == "notebook"
14
19
  Dynamic: license-file
15
20
 
16
21
  # Periplus Python SDK
@@ -35,6 +40,100 @@ For a hosted deployment, replace the URL with its public HTTPS origin. Alternati
35
40
  `PERIPLUS_PUBLIC_URL` and use `Client()`. An optional URL path prefix is preserved.
36
41
  The client reuses HTTP connections; close it with a context manager or `close()`.
37
42
 
43
+ ## Marimo SQL cells and schema browser
44
+
45
+ Install the notebook integration from PyPI:
46
+
47
+ ```sh
48
+ uv add "periplus-python-sdk[notebook]>=0.5.0"
49
+ ```
50
+
51
+ In a Python setup cell, create a SQLAlchemy engine:
52
+
53
+ ```python
54
+ from sqlalchemy import create_engine
55
+
56
+ pp = create_engine(
57
+ "periplus:///public_v1",
58
+ connect_args={"base_url": "https://periplus.dev", "mode": "stable"},
59
+ )
60
+ ```
61
+
62
+ Add a SQL cell, select **pp** in its connection dropdown, and enter:
63
+
64
+ ```sql
65
+ SELECT capture_id, requested_url
66
+ FROM public_v1.capture
67
+ LIMIT 10
68
+ ```
69
+
70
+ Marimo displays the result as a table. Expand **pp → public_v1** in Data Sources
71
+ to discover views and expand a view to load its columns for SQL completion.
72
+ Discovery uses bounded `SHOW TABLES` and `DESCRIBE` through the same public API;
73
+ no internal catalogue or storage credentials are used. Truncated discovery fails
74
+ explicitly rather than displaying a silently incomplete schema. To eagerly load
75
+ schemas and views, enable their discovery in marimo's Packages & Data settings.
76
+ Column discovery is on demand by default, to avoid many public API requests.
77
+
78
+ The Python equivalent of a SQL cell is:
79
+
80
+ ```python
81
+ import marimo as mo
82
+
83
+ captures = mo.sql(
84
+ "SELECT capture_id FROM public_v1.capture LIMIT 10",
85
+ engine=pp,
86
+ )
87
+ ```
88
+
89
+ Set `mode="experimental"` in `connect_args` for the experimental service. The URL
90
+ path names the public schema; the HTTPS endpoint belongs in `base_url` (or set
91
+ `PERIPLUS_PUBLIC_URL`). Run `pp.dispose()` when finished. This is a read-only
92
+ SQLAlchemy dialect for textual SQL and reflection, not a writable ORM backend.
93
+ Each statement has its own server snapshot; SQLAlchemy transaction blocks do not
94
+ provide a shared snapshot or rollback. The adapter makes no transaction requests.
95
+
96
+ A complete notebook is in `examples/notebook.py`. The integration is tested with
97
+ marimo 0.24.1 and SQLAlchemy 2.x. For SQLAlchemy without marimo, install the
98
+ `sqlalchemy` extra instead of `notebook`.
99
+
100
+ ## DB-API connection
101
+
102
+ For SQL cells without schema browsing, or standard cursor-based Python code:
103
+
104
+ ```python
105
+ from periplus_sdk import connect
106
+
107
+ with connect("https://periplus.dev", mode="stable") as connection:
108
+ with connection.cursor() as cursor:
109
+ cursor.execute("SELECT capture_id FROM public_v1.capture LIMIT ?", [10])
110
+ print(cursor.description)
111
+ print(cursor.fetchall())
112
+ print(cursor.result.source_snapshot)
113
+ ```
114
+
115
+ Connections expose `cursor`, `execute`, `close`, and context managers. Cursors
116
+ support `execute`, `fetchone`, `fetchmany`, `fetchall`, iteration, and close.
117
+ Use positional `?` parameters. Decimal and temporal parameters are sent as
118
+ strings; use explicit SQL casts. Binary and nested parameters are not supported
119
+ by this adapter. Fetching only consumes the bounded result already received;
120
+ it never issues pagination or retries. Connections/cursors are not thread-shared.
121
+ `commit()` is a no-op; `rollback()` and `executemany()` are unsupported.
122
+
123
+ `cursor.result` preserves the original query response. `connection.last_result`
124
+ also retains it after marimo closes a cursor; a new execution clears it first.
125
+ Truncation emits `periplus_sdk.dbapi.TruncationWarning` and sets `rowcount` to -1.
126
+ DB-API failures use the standard exception hierarchy in `periplus_sdk.dbapi`;
127
+ HTTP errors retain `status_code`, `code`, and `retry_after_seconds`.
128
+
129
+ Scalar integer, floating-point, decimal, date, time, timestamp and BLOB results
130
+ are decoded to Python values. UUIDs remain strings. Nested/other SQL types keep
131
+ their JSON wire representation; out-of-range dates/timestamps remain strings.
132
+ Temporal precision is limited to what the server JSON transport preserves.
133
+ The cursor preserves duplicate column names, but dataframe libraries/marimo may
134
+ not: use unique SQL aliases. Dataframe inference can lose types for empty or
135
+ all-null results; `cursor.description` retains the SQL type names.
136
+
38
137
  ## Stable and experimental APIs
39
138
 
40
139
  Both clients accept `mode="stable"` (the default) or `mode="experimental"` at initialization:
@@ -100,10 +199,10 @@ Use `aclose()` when managing an async client's lifetime explicitly.
100
199
  Install the public-v1 client from PyPI:
101
200
 
102
201
  ```sh
103
- python -m pip install "periplus-python-sdk>=0.4.0"
202
+ python -m pip install "periplus-python-sdk>=0.5.0"
104
203
  ```
105
204
 
106
- Version 0.4.0 supports the current public-v1 contract. For production, configure
205
+ Version 0.5.0 supports the current public-v1 contract. For production, configure
107
206
  `PERIPLUS_PUBLIC_URL=https://periplus.dev`; no API token is required.
108
207
  Run the installed package against an available public app:
109
208
 
@@ -115,11 +214,11 @@ PERIPLUS_PUBLIC_URL=http://localhost:8080 python packages/periplus-python-sdk/ex
115
214
 
116
215
  Repository CI publishes immutable releases from tags named
117
216
  `periplus-python-sdk-v<version>`. The tag must exactly match the static version
118
- in `pyproject.toml`; for example, version `0.4.0` is released with:
217
+ in `pyproject.toml`; for example, version `0.5.0` is released with:
119
218
 
120
219
  ```sh
121
- git tag periplus-python-sdk-v0.4.0
122
- git push origin periplus-python-sdk-v0.4.0
220
+ git tag periplus-python-sdk-v0.5.0
221
+ git push origin periplus-python-sdk-v0.5.0
123
222
  ```
124
223
 
125
224
  PyPI publishing uses Trusted Publishing rather than a stored API token. The
@@ -129,7 +228,7 @@ that GitHub environment with required reviewers before the first release.
129
228
 
130
229
  ## Public v1
131
230
 
132
- Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.4.0"`. 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.
231
+ Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.5.0"`. 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.
133
232
 
134
233
  ## License
135
234
 
@@ -5,11 +5,16 @@ pyproject.toml
5
5
  src/periplus_python_sdk.egg-info/PKG-INFO
6
6
  src/periplus_python_sdk.egg-info/SOURCES.txt
7
7
  src/periplus_python_sdk.egg-info/dependency_links.txt
8
+ src/periplus_python_sdk.egg-info/entry_points.txt
8
9
  src/periplus_python_sdk.egg-info/requires.txt
9
10
  src/periplus_python_sdk.egg-info/top_level.txt
10
11
  src/periplus_sdk/__init__.py
11
12
  src/periplus_sdk/client.py
13
+ src/periplus_sdk/dbapi.py
12
14
  src/periplus_sdk/errors.py
13
15
  src/periplus_sdk/py.typed
16
+ src/periplus_sdk/sqlalchemy.py
14
17
  src/periplus_sdk/types.py
15
- tests/test_client.py
18
+ tests/test_client.py
19
+ tests/test_dbapi.py
20
+ tests/test_notebook.py
@@ -0,0 +1,2 @@
1
+ [sqlalchemy.dialects]
2
+ periplus = periplus_sdk.sqlalchemy:PeriplusDialect
@@ -0,0 +1,9 @@
1
+ httpx>=0.28
2
+ pydantic<3,>=2.12
3
+
4
+ [notebook]
5
+ sqlalchemy<3,>=2.0
6
+ marimo[sql]>=0.24.1
7
+
8
+ [sqlalchemy]
9
+ sqlalchemy<3,>=2.0
@@ -1,8 +1,9 @@
1
1
  """Read-only Python clients for the public Periplus query API."""
2
+ from .dbapi import connect
2
3
  from .client import AsyncClient, Client
3
4
  from .errors import ApiError, ConfigurationError, PeriplusError, ResponseError, TransportError
4
5
  from .types import Diagnostic, PreparedQuery, QueryHelper, QueryHelpers, QueryResult
5
6
 
6
- __all__ = ["AsyncClient", "Client", "ApiError", "ConfigurationError", "PeriplusError",
7
+ __all__ = ["connect", "AsyncClient", "Client", "ApiError", "ConfigurationError", "PeriplusError",
7
8
  "ResponseError", "TransportError", "Diagnostic", "PreparedQuery", "QueryHelper",
8
9
  "QueryHelpers", "QueryResult"]
@@ -0,0 +1,324 @@
1
+ """Read-only DB-API 2.0 connection over the public query API.
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.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import builtins
10
+ from collections.abc import Sequence
11
+ from datetime import date, datetime, time
12
+ from decimal import Decimal
13
+ from typing import Any, Literal
14
+ import warnings
15
+
16
+ from .client import Client
17
+ from .errors import ApiError, ConfigurationError, PeriplusError, ResponseError, TransportError
18
+ from .types import QueryResult
19
+
20
+ apilevel = "2.0"
21
+ threadsafety = 1
22
+ paramstyle = "qmark"
23
+
24
+
25
+ class Warning(builtins.Warning):
26
+ """DB-API warning."""
27
+
28
+
29
+ class TruncationWarning(Warning):
30
+ """The server returned only part of the query result."""
31
+
32
+
33
+ class Error(PeriplusError):
34
+ """Base DB-API error; API failures preserve their safe error attributes."""
35
+
36
+ def __init__(self, message: str, *, status_code: int | None = None,
37
+ code: str | None = None, retry_after_seconds: float | None = None):
38
+ super().__init__(message)
39
+ self.status_code = status_code
40
+ self.code = code
41
+ self.retry_after_seconds = retry_after_seconds
42
+
43
+
44
+ class InterfaceError(Error):
45
+ """Invalid connection or wire response."""
46
+
47
+
48
+ class DatabaseError(Error):
49
+ """Query failure."""
50
+
51
+
52
+ class DataError(DatabaseError):
53
+ """A value cannot be represented."""
54
+
55
+
56
+ class OperationalError(DatabaseError):
57
+ """Service, policy or transport failure."""
58
+
59
+
60
+ class IntegrityError(DatabaseError):
61
+ """Integrity constraint failure."""
62
+
63
+
64
+ class InternalError(DatabaseError):
65
+ """Internal query failure."""
66
+
67
+
68
+ class ProgrammingError(DatabaseError):
69
+ """Invalid SQL, parameters or cursor use."""
70
+
71
+
72
+ class NotSupportedError(DatabaseError):
73
+ """Operation is outside the read-only query contract."""
74
+
75
+
76
+ Date = date
77
+ Time = time
78
+ Timestamp = datetime
79
+ Binary = bytes
80
+
81
+
82
+ def DateFromTicks(ticks: float) -> date:
83
+ return datetime.fromtimestamp(ticks).date()
84
+
85
+
86
+ def TimeFromTicks(ticks: float) -> time:
87
+ return datetime.fromtimestamp(ticks).time()
88
+
89
+
90
+ def TimestampFromTicks(ticks: float) -> datetime:
91
+ return datetime.fromtimestamp(ticks)
92
+
93
+
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"}
100
+
101
+
102
+ class _TypeCategory:
103
+ def __init__(self, names: set[str], prefix: str = ""):
104
+ self.names, self.prefix = names, prefix
105
+
106
+ 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)))
108
+
109
+
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)
114
+ ROWID = _TypeCategory(set())
115
+
116
+
117
+ 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.
142
+ return value
143
+
144
+
145
+ def _parameter(value: Any) -> Any:
146
+ if value is None or isinstance(value, (str, bool, int, float)):
147
+ return value
148
+ if isinstance(value, (Decimal, date, time)):
149
+ 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.")
151
+
152
+
153
+ class Connection:
154
+ """Marimo-discoverable, read-only connection; commit is a no-op."""
155
+
156
+ dialect = "duckdb"
157
+
158
+ def __init__(self, base_url: str | None = None, *, timeout: float = 140,
159
+ mode: Literal["stable", "experimental"] = "stable",
160
+ schema_version: str = "public_v1"):
161
+ try:
162
+ self._client = Client(base_url, timeout=timeout, mode=mode)
163
+ except ConfigurationError as exc:
164
+ raise InterfaceError(str(exc)) from exc
165
+ self.schema_version = schema_version
166
+ self.closed = False
167
+ self.last_result: QueryResult | None = None
168
+
169
+ def _check(self) -> None:
170
+ if self.closed:
171
+ raise InterfaceError("Connection is closed.")
172
+
173
+ def cursor(self) -> Cursor:
174
+ self._check()
175
+ return Cursor(self)
176
+
177
+ def execute(self, operation: str, parameters: Sequence[Any] | None = None) -> Cursor:
178
+ cursor = self.cursor()
179
+ try:
180
+ return cursor.execute(operation, parameters)
181
+ except BaseException:
182
+ cursor.close()
183
+ raise
184
+
185
+ def commit(self) -> None:
186
+ """No-op: each read executes in its own server transaction."""
187
+ self._check()
188
+
189
+ def rollback(self) -> None:
190
+ self._check()
191
+ raise NotSupportedError("Periplus has no client transactions to roll back.")
192
+
193
+ def close(self) -> None:
194
+ if not self.closed:
195
+ self._client.close()
196
+ self.closed = True
197
+ self.last_result = None
198
+
199
+ def __enter__(self) -> Connection:
200
+ self._check()
201
+ return self
202
+
203
+ def __exit__(self, *args: Any) -> None:
204
+ self.close()
205
+
206
+
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)
211
+
212
+
213
+ class Cursor:
214
+ """A buffered result. Metadata stays on result after rows are consumed."""
215
+
216
+ arraysize = 1
217
+
218
+ def __init__(self, connection: Connection):
219
+ self.connection = connection
220
+ self.closed = False
221
+ self.result: QueryResult | None = None
222
+ self.description: list[tuple[Any, ...]] | None = None
223
+ self.rowcount = -1
224
+ self._rows: list[tuple[Any, ...]] = []
225
+ self._position = 0
226
+
227
+ def _check(self, *, result: bool = False) -> None:
228
+ self.connection._check()
229
+ if self.closed:
230
+ raise InterfaceError("Cursor is closed.")
231
+ if result and self.result is None:
232
+ raise ProgrammingError("Execute a query before fetching rows.")
233
+
234
+ def execute(self, operation: str, parameters: Sequence[Any] | None = None) -> Cursor:
235
+ self._check()
236
+ self.result, self.description, self.rowcount = None, None, -1
237
+ self._rows, self._position = [], 0
238
+ self.connection.last_result = None
239
+ if not isinstance(operation, str):
240
+ raise ProgrammingError("SQL must be a string.")
241
+ if parameters is not None and (not isinstance(parameters, Sequence) or isinstance(parameters, (str, bytes))):
242
+ raise ProgrammingError("Use a positional parameter sequence with ? placeholders.")
243
+ values = [_parameter(v) for v in parameters] if parameters is not None else []
244
+ try:
245
+ result = self.connection._client.execute(operation, values, schema_version=self.connection.schema_version)
246
+ except ApiError as exc:
247
+ error = ProgrammingError if exc.code == "sql_invalid" else OperationalError
248
+ raise error(str(exc), status_code=exc.status_code, code=exc.code,
249
+ retry_after_seconds=exc.retry_after_seconds) from exc
250
+ except TransportError as exc:
251
+ raise OperationalError(str(exc)) from exc
252
+ except ResponseError as exc:
253
+ 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
+ self.result = self.connection.last_result = result
261
+ self.description = [(name, kind, None, None, None, None, None)
262
+ 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
+ return self
269
+
270
+ def fetchone(self) -> tuple[Any, ...] | None:
271
+ self._check(result=True)
272
+ if self._position == len(self._rows):
273
+ return None
274
+ row = self._rows[self._position]
275
+ self._position += 1
276
+ return row
277
+
278
+ def fetchmany(self, size: int | None = None) -> list[tuple[Any, ...]]:
279
+ self._check(result=True)
280
+ size = self.arraysize if size is None else size
281
+ if not isinstance(size, int) or size < 0:
282
+ 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
286
+ return rows
287
+
288
+ def fetchall(self) -> list[tuple[Any, ...]]:
289
+ self._check(result=True)
290
+ return self.fetchmany(len(self._rows) - self._position)
291
+
292
+ def executemany(self, operation: str, seq_of_parameters: Any) -> None:
293
+ self._check()
294
+ raise NotSupportedError("Batch execution is not supported by the read-only query API.")
295
+
296
+ def setinputsizes(self, sizes: Any) -> None:
297
+ self._check()
298
+
299
+ def setoutputsize(self, size: int, column: int | None = None) -> None:
300
+ self._check()
301
+
302
+ def close(self) -> None:
303
+ self.closed = True
304
+ self._rows = []
305
+ self.result = None
306
+ self.description = None
307
+ self.rowcount = -1
308
+
309
+ def __iter__(self) -> Cursor:
310
+ self._check(result=True)
311
+ return self
312
+
313
+ def __next__(self) -> tuple[Any, ...]:
314
+ row = self.fetchone()
315
+ if row is None:
316
+ raise StopIteration
317
+ return row
318
+
319
+ def __enter__(self) -> Cursor:
320
+ self._check()
321
+ return self
322
+
323
+ def __exit__(self, *args: Any) -> None:
324
+ self.close()
@@ -0,0 +1,139 @@
1
+ """SQLAlchemy dialect for public Periplus queries and bounded reflection."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from sqlalchemy import exc, types
7
+ from sqlalchemy.engine import default
8
+ from sqlalchemy.engine.reflection import cache
9
+ from sqlalchemy.sql.compiler import IdentifierPreparer
10
+
11
+ from . import dbapi
12
+
13
+
14
+ class SQLType(types.UserDefinedType):
15
+ """Preserve DuckDB type names, including nested types, during reflection."""
16
+
17
+ cache_ok = True
18
+
19
+ def __init__(self, name: str):
20
+ self.name = name
21
+
22
+ def get_col_spec(self, **kw: Any) -> str:
23
+ return self.name
24
+
25
+ @property
26
+ 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
43
+ return str
44
+
45
+
46
+ class PeriplusDialect(default.DefaultDialect):
47
+ # The server speaks DuckDB SQL; this enables the correct notebook SQL dialect.
48
+ name = "duckdb"
49
+ driver = "periplus"
50
+ supports_statement_cache = False
51
+ supports_sane_rowcount = False
52
+ supports_sane_multi_rowcount = False
53
+ supports_native_decimal = True
54
+ default_paramstyle = "qmark"
55
+ preparer = IdentifierPreparer
56
+
57
+ @classmethod
58
+ def import_dbapi(cls):
59
+ return dbapi
60
+
61
+ def create_connect_args(self, url):
62
+ if url.username or url.password or url.host or url.port:
63
+ raise exc.ArgumentError("Use periplus:///public_v1 with base_url and mode in connect_args.")
64
+ if url.query:
65
+ raise exc.ArgumentError("Pass connection options in connect_args, not URL query parameters.")
66
+ return [], {"schema_version": url.database or "public_v1"}
67
+
68
+ def initialize(self, connection):
69
+ self.default_schema_name = connection.connection.dbapi_connection.schema_version
70
+
71
+ def do_rollback(self, dbapi_connection):
72
+ # SQLAlchemy resets pooled connections this way. There is no remote
73
+ # transaction: each read already completed in its own server snapshot.
74
+ pass
75
+
76
+ def do_begin(self, dbapi_connection):
77
+ pass
78
+
79
+ def do_commit(self, dbapi_connection):
80
+ dbapi_connection.commit()
81
+
82
+ def _schema(self, connection, schema):
83
+ current = connection.connection.dbapi_connection.schema_version
84
+ if schema is not None and schema != current:
85
+ raise exc.InvalidRequestError(f"Only the configured public schema {current!r} is available.")
86
+ return current
87
+
88
+ @cache
89
+ def get_schema_names(self, connection, **kw):
90
+ return [self._schema(connection, None)]
91
+
92
+ def _complete(self, result):
93
+ 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
+ try:
98
+ return result.fetchall()
99
+ finally:
100
+ result.close()
101
+
102
+ @cache
103
+ def get_view_names(self, connection, schema=None, **kw):
104
+ 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)]
107
+
108
+ @cache
109
+ def get_table_names(self, connection, schema=None, **kw):
110
+ self._schema(connection, schema)
111
+ # All queryable public catalogue relations are views.
112
+ return []
113
+
114
+ @cache
115
+ def has_table(self, connection, table_name, schema=None, **kw):
116
+ return table_name in self.get_view_names(connection, schema)
117
+
118
+ @cache
119
+ def get_columns(self, connection, table_name, schema=None, **kw):
120
+ schema = self._schema(connection, schema)
121
+ 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)]
125
+
126
+ @cache
127
+ def get_pk_constraint(self, connection, table_name, schema=None, **kw):
128
+ self._schema(connection, schema)
129
+ return {"name": None, "constrained_columns": []}
130
+
131
+ @cache
132
+ def get_foreign_keys(self, connection, table_name, schema=None, **kw):
133
+ self._schema(connection, schema)
134
+ return []
135
+
136
+ @cache
137
+ def get_indexes(self, connection, table_name, schema=None, **kw):
138
+ self._schema(connection, schema)
139
+ return []
@@ -0,0 +1,118 @@
1
+ import unittest
2
+ from datetime import date, datetime, time
3
+ from decimal import Decimal
4
+ from unittest.mock import patch
5
+
6
+ import httpx
7
+
8
+ from periplus_sdk import connect, dbapi
9
+ from test_client import RESULT
10
+
11
+
12
+ def response(**kw):
13
+ return dict(RESULT, truncated=False, **kw)
14
+
15
+
16
+ class DBAPITests(unittest.TestCase):
17
+ def connection(self, handler=None, **options):
18
+ factory = httpx.Client
19
+ handler = handler or (lambda request: httpx.Response(200, json=response()))
20
+ with patch('periplus_sdk.client.httpx.Client', side_effect=lambda **kw:
21
+ factory(**kw, transport=httpx.MockTransport(handler))):
22
+ c = connect('https://public.example/prefix', **options)
23
+ self.addCleanup(c.close)
24
+ return c
25
+
26
+ def test_cursor_consumption_description_and_metadata(self):
27
+ c = self.connection()
28
+ cur = c.cursor()
29
+ with self.assertRaises(dbapi.ProgrammingError):
30
+ cur.fetchone()
31
+ cur.execute('SELECT ? AS n', [1])
32
+ self.assertEqual(cur.rowcount, 1)
33
+ self.assertEqual([d[0] for d in cur.description], ['n', 'n'])
34
+ self.assertEqual(cur.description[1][1], 'DECIMAL(20,2)')
35
+ self.assertEqual(cur.fetchmany(0), [])
36
+ self.assertEqual(cur.fetchone(), (9007199254740993, Decimal('123.45')))
37
+ self.assertIsNone(cur.fetchone())
38
+ self.assertEqual(cur.fetchall(), [])
39
+ self.assertEqual(cur.result.source_snapshot, 4)
40
+ self.assertIs(c.last_result, cur.result)
41
+ cur.execute('SELECT 1')
42
+ self.assertEqual(list(cur), [(9007199254740993, Decimal('123.45'))])
43
+ other = c.execute('SELECT 1')
44
+ self.assertEqual(other.rowcount, 1)
45
+ self.assertEqual(cur.fetchall(), [])
46
+
47
+ def test_empty_and_fetchmany(self):
48
+ c = self.connection(lambda r: httpx.Response(200, json=response(columns=['n'], types=['INTEGER'], rows=[[1],[2],[3]])))
49
+ cur = c.execute('SELECT 1')
50
+ cur.arraysize = 2
51
+ self.assertEqual(cur.fetchmany(), [(1,), (2,)])
52
+ self.assertEqual(cur.fetchmany(10), [(3,)])
53
+ with self.assertRaises(dbapi.ProgrammingError):
54
+ cur.fetchmany(-1)
55
+ c = self.connection(lambda r: httpx.Response(200, json=response(rows=[])))
56
+ cur = c.execute('SELECT 1 WHERE false')
57
+ self.assertEqual(cur.rowcount, 0)
58
+ self.assertEqual(len(cur.description), 2)
59
+ self.assertEqual(cur.fetchall(), [])
60
+
61
+ def test_modes_parameters_and_scalar_decoding(self):
62
+ requests = []
63
+ def handler(r):
64
+ requests.append(r)
65
+ return httpx.Response(200, json=response(columns=['d','t','ts','b','f','n','s'],
66
+ types=['DATE','TIME','TIMESTAMP WITH TIME ZONE','BLOB','DOUBLE','BIGINT','VARCHAR'],
67
+ rows=[['2026-09-11','12:34:56','2026-09-11T12:34:56+00:00','aGk=','inf',None,'9007199254740993']]))
68
+ c = self.connection(handler, mode='experimental')
69
+ row = c.execute('SELECT CAST(? AS DATE)', [date(2026,9,11)]).fetchone()
70
+ self.assertEqual(row, (date(2026,9,11),time(12,34,56),datetime.fromisoformat('2026-09-11T12:34:56+00:00'),b'hi',float('inf'),None,'9007199254740993'))
71
+ self.assertEqual(requests[0].url.path, '/prefix/api/query/experimental/exec')
72
+ self.assertEqual(requests[0].headers['x-periplus-query-source'], 'sdk')
73
+
74
+ def test_truncation_and_lifecycle(self):
75
+ c = self.connection(lambda r: httpx.Response(200,json=RESULT))
76
+ with self.assertWarns(dbapi.TruncationWarning):
77
+ cur = c.execute('SELECT 1')
78
+ self.assertEqual(cur.rowcount, -1)
79
+ self.assertTrue(c.last_result.truncated)
80
+ c.commit()
81
+ with self.assertRaises(dbapi.NotSupportedError):
82
+ c.rollback()
83
+ with self.assertRaises(dbapi.NotSupportedError):
84
+ cur.executemany('SELECT 1', [])
85
+ cur.close()
86
+ with self.assertRaises(dbapi.InterfaceError):
87
+ cur.fetchall()
88
+ c.close()
89
+ c.close()
90
+ with self.assertRaises(dbapi.InterfaceError):
91
+ c.cursor()
92
+
93
+ def test_failures_reset_results_and_preserve_errors(self):
94
+ calls = []
95
+ def handler(r):
96
+ calls.append(r)
97
+ return httpx.Response(200,json=response()) if len(calls)==1 else httpx.Response(429,json={'code':'service_busy','detail':'Busy'},headers={'Retry-After':'2'})
98
+ c = self.connection(handler)
99
+ cur = c.execute('SELECT 1')
100
+ with self.assertRaises(dbapi.OperationalError) as e:
101
+ cur.execute('SELECT 1')
102
+ self.assertEqual(e.exception.status_code,429)
103
+ self.assertEqual(e.exception.retry_after_seconds,2)
104
+ self.assertEqual(e.exception.code,'service_busy')
105
+ self.assertIsNone(c.last_result)
106
+ self.assertIsNone(cur.description)
107
+ self.assertEqual(len(calls),2)
108
+ with self.assertRaises(dbapi.ProgrammingError):
109
+ cur.fetchall()
110
+ for params in ({'n':1},'bad',[object()]):
111
+ with self.assertRaises(dbapi.ProgrammingError):
112
+ cur.execute('SELECT ?',params)
113
+ self.assertEqual(len(calls),2)
114
+
115
+ def test_malformed_rows(self):
116
+ c=self.connection(lambda r:httpx.Response(200,json=response(rows=[[1]])))
117
+ with self.assertRaises(dbapi.InterfaceError):
118
+ c.execute('SELECT 1')
@@ -0,0 +1,87 @@
1
+ """Optional integration tests; release CI installs the notebook extra."""
2
+ import importlib.util
3
+ import unittest
4
+ from unittest.mock import patch
5
+ import httpx
6
+ from test_client import RESULT
7
+
8
+
9
+ @unittest.skipUnless(importlib.util.find_spec('sqlalchemy') and importlib.util.find_spec('marimo'), 'notebook extra required')
10
+ class NotebookTests(unittest.TestCase):
11
+ def engine(self, *, truncated=False):
12
+ from sqlalchemy import create_engine
13
+ factory = httpx.Client
14
+ self.requests = []
15
+ def handler(request):
16
+ import json
17
+ sql = json.loads(request.content)['sql']
18
+ self.requests.append(sql)
19
+ columns, types, rows = ['n'], ['INTEGER'], [[42]]
20
+ if sql.startswith('SHOW TABLES'):
21
+ columns, types, rows = ['name'], ['VARCHAR'], [['capture']]
22
+ if sql.startswith('DESCRIBE'):
23
+ columns = ['column_name','column_type','null','key','default','extra']
24
+ types = ['VARCHAR']*6
25
+ rows = [['capture_id','UUID','NO',None,None,None],['captured_at','TIMESTAMP WITH TIME ZONE','YES',None,None,None]]
26
+ return httpx.Response(200,json=dict(RESULT,columns=columns,types=types,rows=rows,truncated=truncated))
27
+ patcher = patch('periplus_sdk.client.httpx.Client', side_effect=lambda **kw: factory(**kw,transport=httpx.MockTransport(handler)))
28
+ patcher.start()
29
+ self.addCleanup(patcher.stop)
30
+ engine = create_engine('periplus:///public_v1',connect_args={'base_url':'https://public.example'})
31
+ self.addCleanup(engine.dispose)
32
+ return engine
33
+
34
+ def test_reflection_and_repeated_queries(self):
35
+ from sqlalchemy import inspect, text
36
+ engine=self.engine()
37
+ inspector=inspect(engine)
38
+ self.assertEqual(inspector.get_schema_names(),['public_v1'])
39
+ self.assertEqual(inspector.get_table_names(),[])
40
+ self.assertEqual(inspector.get_view_names(),['capture'])
41
+ self.assertTrue(inspector.has_table('capture'))
42
+ columns=inspector.get_columns('capture')
43
+ self.assertEqual(columns[0]['name'],'capture_id')
44
+ self.assertEqual(str(columns[1]['type']),'TIMESTAMP WITH TIME ZONE')
45
+ self.assertEqual(inspector.get_pk_constraint('capture')['constrained_columns'],[])
46
+ for _ in range(2):
47
+ with engine.connect() as c:
48
+ self.assertEqual(c.execute(text('SELECT :n AS n'),{'n':42}).fetchall(),[(42,)])
49
+ self.assertFalse(any('ROLLBACK' in s or 'BEGIN' in s for s in self.requests))
50
+
51
+ def test_marimo_discovery_schema_browser_and_sql_cell(self):
52
+ import marimo as mo
53
+ from marimo._sql.get_engines import get_engines_from_variables
54
+ engine=self.engine()
55
+ found=get_engines_from_variables([('pp',engine)])
56
+ self.assertEqual(len(found),1)
57
+ adapter=found[0][1]
58
+ databases=adapter.get_databases(include_schemas=True,include_tables=True,include_table_details=True)
59
+ self.assertEqual(databases[0].schemas[0].name,'public_v1')
60
+ table=databases[0].schemas[0].tables[0]
61
+ self.assertEqual(table.name,'capture')
62
+ self.assertEqual([c.name for c in table.columns],['capture_id','captured_at'])
63
+ df=mo.sql('SELECT 42 AS n',engine=engine,output=False)
64
+ self.assertEqual(df.rows(),[(42,)])
65
+
66
+ def test_dbapi_discovery(self):
67
+ from periplus_sdk import connect
68
+ from marimo._sql.engines.dbapi import DBAPIEngine
69
+ with connect('https://public.example') as c:
70
+ self.assertTrue(DBAPIEngine.is_compatible(c))
71
+
72
+ def test_truncated_discovery_is_not_silently_partial(self):
73
+ from sqlalchemy import inspect, exc
74
+ from periplus_sdk.dbapi import TruncationWarning
75
+ engine=self.engine(truncated=True)
76
+ with self.assertWarns(TruncationWarning):
77
+ with self.assertRaises(exc.InvalidRequestError):
78
+ inspect(engine).get_view_names()
79
+
80
+ def test_reflection_quotes_identifiers_and_rejects_private_schemas(self):
81
+ from sqlalchemy import inspect, exc
82
+ engine=self.engine()
83
+ inspector=inspect(engine)
84
+ inspector.get_columns('odd"name')
85
+ self.assertIn('DESCRIBE "public_v1"."odd""name"',self.requests)
86
+ with self.assertRaises(exc.InvalidRequestError):
87
+ inspector.get_columns('visits',schema='ingest')
@@ -1,2 +0,0 @@
1
- httpx>=0.28
2
- pydantic<3,>=2.12