periplus-python-sdk 0.4.0__tar.gz → 0.6.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.
- {periplus_python_sdk-0.4.0/src/periplus_python_sdk.egg-info → periplus_python_sdk-0.6.0}/PKG-INFO +103 -7
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/README.md +99 -6
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/pyproject.toml +8 -1
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0/src/periplus_python_sdk.egg-info}/PKG-INFO +103 -7
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/src/periplus_python_sdk.egg-info/SOURCES.txt +8 -1
- periplus_python_sdk-0.6.0/src/periplus_python_sdk.egg-info/entry_points.txt +2 -0
- periplus_python_sdk-0.6.0/src/periplus_python_sdk.egg-info/requires.txt +6 -0
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/src/periplus_sdk/__init__.py +2 -1
- periplus_python_sdk-0.6.0/src/periplus_sdk/dbapi.py +324 -0
- periplus_python_sdk-0.6.0/src/periplus_sdk/sql_api.py +25 -0
- periplus_python_sdk-0.6.0/src/periplus_sdk/sqlalchemy.py +139 -0
- periplus_python_sdk-0.6.0/tests/test_dbapi.py +118 -0
- periplus_python_sdk-0.6.0/tests/test_notebook.py +87 -0
- periplus_python_sdk-0.6.0/tests/test_sql_api.py +38 -0
- periplus_python_sdk-0.4.0/src/periplus_python_sdk.egg-info/requires.txt +0 -2
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/LICENSE +0 -0
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/NOTICE +0 -0
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/setup.cfg +0 -0
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/src/periplus_python_sdk.egg-info/dependency_links.txt +0 -0
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/src/periplus_python_sdk.egg-info/top_level.txt +0 -0
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/src/periplus_sdk/client.py +0 -0
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/src/periplus_sdk/errors.py +0 -0
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/src/periplus_sdk/py.typed +0 -0
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/src/periplus_sdk/types.py +0 -0
- {periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/tests/test_client.py +0 -0
{periplus_python_sdk-0.4.0/src/periplus_python_sdk.egg-info → periplus_python_sdk-0.6.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: periplus-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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,9 @@ License-File: LICENSE
|
|
|
11
11
|
License-File: NOTICE
|
|
12
12
|
Requires-Dist: httpx>=0.28
|
|
13
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"
|
|
14
17
|
Dynamic: license-file
|
|
15
18
|
|
|
16
19
|
# Periplus Python SDK
|
|
@@ -35,6 +38,99 @@ For a hosted deployment, replace the URL with its public HTTPS origin. Alternati
|
|
|
35
38
|
`PERIPLUS_PUBLIC_URL` and use `Client()`. An optional URL path prefix is preserved.
|
|
36
39
|
The client reuses HTTP connections; close it with a context manager or `close()`.
|
|
37
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.0"
|
|
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 → 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.0"`.
|
|
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
|
+
|
|
38
134
|
## Stable and experimental APIs
|
|
39
135
|
|
|
40
136
|
Both clients accept `mode="stable"` (the default) or `mode="experimental"` at initialization:
|
|
@@ -100,10 +196,10 @@ Use `aclose()` when managing an async client's lifetime explicitly.
|
|
|
100
196
|
Install the public-v1 client from PyPI:
|
|
101
197
|
|
|
102
198
|
```sh
|
|
103
|
-
python -m pip install "periplus-python-sdk>=0.
|
|
199
|
+
python -m pip install "periplus-python-sdk>=0.6.0"
|
|
104
200
|
```
|
|
105
201
|
|
|
106
|
-
Version 0.
|
|
202
|
+
Version 0.6.0 supports the current public-v1 contract. For production, configure
|
|
107
203
|
`PERIPLUS_PUBLIC_URL=https://periplus.dev`; no API token is required.
|
|
108
204
|
Run the installed package against an available public app:
|
|
109
205
|
|
|
@@ -115,11 +211,11 @@ PERIPLUS_PUBLIC_URL=http://localhost:8080 python packages/periplus-python-sdk/ex
|
|
|
115
211
|
|
|
116
212
|
Repository CI publishes immutable releases from tags named
|
|
117
213
|
`periplus-python-sdk-v<version>`. The tag must exactly match the static version
|
|
118
|
-
in `pyproject.toml`; for example, version `0.
|
|
214
|
+
in `pyproject.toml`; for example, version `0.6.0` is released with:
|
|
119
215
|
|
|
120
216
|
```sh
|
|
121
|
-
git tag periplus-python-sdk-v0.
|
|
122
|
-
git push origin periplus-python-sdk-v0.
|
|
217
|
+
git tag periplus-python-sdk-v0.6.0
|
|
218
|
+
git push origin periplus-python-sdk-v0.6.0
|
|
123
219
|
```
|
|
124
220
|
|
|
125
221
|
PyPI publishing uses Trusted Publishing rather than a stored API token. The
|
|
@@ -129,7 +225,7 @@ that GitHub environment with required reviewers before the first release.
|
|
|
129
225
|
|
|
130
226
|
## Public v1
|
|
131
227
|
|
|
132
|
-
Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.
|
|
228
|
+
Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.6.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
229
|
|
|
134
230
|
## License
|
|
135
231
|
|
|
@@ -20,6 +20,99 @@ 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.6.0"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
In a Python setup cell, create a SQLAlchemy engine:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from periplus_sdk import sql_api
|
|
35
|
+
|
|
36
|
+
pp = sql_api.create_engine("https://periplus.dev", mode="stable")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Add a SQL cell, select **pp** in its connection dropdown, and enter:
|
|
40
|
+
|
|
41
|
+
```sql
|
|
42
|
+
SELECT capture_id, requested_url
|
|
43
|
+
FROM public_v1.capture
|
|
44
|
+
LIMIT 10
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Marimo displays the result as a table. Expand **pp → public_v1** in Data Sources
|
|
48
|
+
to discover views and expand a view to load its columns for SQL completion.
|
|
49
|
+
Discovery uses bounded `SHOW TABLES` and `DESCRIBE` through the same public API;
|
|
50
|
+
no internal catalogue or storage credentials are used. Truncated discovery fails
|
|
51
|
+
explicitly rather than displaying a silently incomplete schema. To eagerly load
|
|
52
|
+
schemas and views, enable their discovery in marimo's Packages & Data settings.
|
|
53
|
+
Column discovery is on demand by default, to avoid many public API requests.
|
|
54
|
+
|
|
55
|
+
The Python equivalent of a SQL cell is:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import marimo as mo
|
|
59
|
+
|
|
60
|
+
captures = mo.sql(
|
|
61
|
+
"SELECT capture_id FROM public_v1.capture LIMIT 10",
|
|
62
|
+
engine=pp,
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Set `mode="experimental"` for the experimental service. Omit the URL to use
|
|
67
|
+
`PERIPLUS_PUBLIC_URL`. Optional `timeout=140` and `schema_version="public_v1"`
|
|
68
|
+
arguments configure the client deadline and public schema. Run `pp.dispose()` when finished. This is a read-only
|
|
69
|
+
SQLAlchemy dialect for textual SQL and reflection, not a writable ORM backend.
|
|
70
|
+
Each statement has its own server snapshot; SQLAlchemy transaction blocks do not
|
|
71
|
+
provide a shared snapshot or rollback. The adapter makes no transaction requests.
|
|
72
|
+
|
|
73
|
+
A complete notebook is in `examples/notebook.py`. The integration is tested with
|
|
74
|
+
marimo 0.24.1 and SQLAlchemy 2.x. SQLAlchemy is included in the standard SDK install; the `notebook` extra adds
|
|
75
|
+
marimo. Existing marimo environments only need `uv add "periplus-python-sdk>=0.6.0"`.
|
|
76
|
+
The returned object is a standard SQLAlchemy Engine, also usable with pandas and
|
|
77
|
+
ordinary Python scripts. Engine creation is lazy; the first query opens a connection.
|
|
78
|
+
|
|
79
|
+
## DB-API connection
|
|
80
|
+
|
|
81
|
+
For SQL cells without schema browsing, or standard cursor-based Python code:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from periplus_sdk import connect
|
|
85
|
+
|
|
86
|
+
with connect("https://periplus.dev", mode="stable") as connection:
|
|
87
|
+
with connection.cursor() as cursor:
|
|
88
|
+
cursor.execute("SELECT capture_id FROM public_v1.capture LIMIT ?", [10])
|
|
89
|
+
print(cursor.description)
|
|
90
|
+
print(cursor.fetchall())
|
|
91
|
+
print(cursor.result.source_snapshot)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Connections expose `cursor`, `execute`, `close`, and context managers. Cursors
|
|
95
|
+
support `execute`, `fetchone`, `fetchmany`, `fetchall`, iteration, and close.
|
|
96
|
+
Use positional `?` parameters. Decimal and temporal parameters are sent as
|
|
97
|
+
strings; use explicit SQL casts. Binary and nested parameters are not supported
|
|
98
|
+
by this adapter. Fetching only consumes the bounded result already received;
|
|
99
|
+
it never issues pagination or retries. Connections/cursors are not thread-shared.
|
|
100
|
+
`commit()` is a no-op; `rollback()` and `executemany()` are unsupported.
|
|
101
|
+
|
|
102
|
+
`cursor.result` preserves the original query response. `connection.last_result`
|
|
103
|
+
also retains it after marimo closes a cursor; a new execution clears it first.
|
|
104
|
+
Truncation emits `periplus_sdk.dbapi.TruncationWarning` and sets `rowcount` to -1.
|
|
105
|
+
DB-API failures use the standard exception hierarchy in `periplus_sdk.dbapi`;
|
|
106
|
+
HTTP errors retain `status_code`, `code`, and `retry_after_seconds`.
|
|
107
|
+
|
|
108
|
+
Scalar integer, floating-point, decimal, date, time, timestamp and BLOB results
|
|
109
|
+
are decoded to Python values. UUIDs remain strings. Nested/other SQL types keep
|
|
110
|
+
their JSON wire representation; out-of-range dates/timestamps remain strings.
|
|
111
|
+
Temporal precision is limited to what the server JSON transport preserves.
|
|
112
|
+
The cursor preserves duplicate column names, but dataframe libraries/marimo may
|
|
113
|
+
not: use unique SQL aliases. Dataframe inference can lose types for empty or
|
|
114
|
+
all-null results; `cursor.description` retains the SQL type names.
|
|
115
|
+
|
|
23
116
|
## Stable and experimental APIs
|
|
24
117
|
|
|
25
118
|
Both clients accept `mode="stable"` (the default) or `mode="experimental"` at initialization:
|
|
@@ -85,10 +178,10 @@ Use `aclose()` when managing an async client's lifetime explicitly.
|
|
|
85
178
|
Install the public-v1 client from PyPI:
|
|
86
179
|
|
|
87
180
|
```sh
|
|
88
|
-
python -m pip install "periplus-python-sdk>=0.
|
|
181
|
+
python -m pip install "periplus-python-sdk>=0.6.0"
|
|
89
182
|
```
|
|
90
183
|
|
|
91
|
-
Version 0.
|
|
184
|
+
Version 0.6.0 supports the current public-v1 contract. For production, configure
|
|
92
185
|
`PERIPLUS_PUBLIC_URL=https://periplus.dev`; no API token is required.
|
|
93
186
|
Run the installed package against an available public app:
|
|
94
187
|
|
|
@@ -100,11 +193,11 @@ PERIPLUS_PUBLIC_URL=http://localhost:8080 python packages/periplus-python-sdk/ex
|
|
|
100
193
|
|
|
101
194
|
Repository CI publishes immutable releases from tags named
|
|
102
195
|
`periplus-python-sdk-v<version>`. The tag must exactly match the static version
|
|
103
|
-
in `pyproject.toml`; for example, version `0.
|
|
196
|
+
in `pyproject.toml`; for example, version `0.6.0` is released with:
|
|
104
197
|
|
|
105
198
|
```sh
|
|
106
|
-
git tag periplus-python-sdk-v0.
|
|
107
|
-
git push origin periplus-python-sdk-v0.
|
|
199
|
+
git tag periplus-python-sdk-v0.6.0
|
|
200
|
+
git push origin periplus-python-sdk-v0.6.0
|
|
108
201
|
```
|
|
109
202
|
|
|
110
203
|
PyPI publishing uses Trusted Publishing rather than a stored API token. The
|
|
@@ -114,7 +207,7 @@ that GitHub environment with required reviewers before the first release.
|
|
|
114
207
|
|
|
115
208
|
## Public v1
|
|
116
209
|
|
|
117
|
-
Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.
|
|
210
|
+
Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.6.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
211
|
|
|
119
212
|
## License
|
|
120
213
|
|
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
license = "Apache-2.0"
|
|
3
3
|
license-files = ["LICENSE", "NOTICE"]
|
|
4
4
|
name = "periplus-python-sdk"
|
|
5
|
-
version = "0.
|
|
5
|
+
version = "0.6.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"
|
|
9
9
|
dependencies = [
|
|
10
10
|
"httpx>=0.28",
|
|
11
11
|
"pydantic>=2.12,<3",
|
|
12
|
+
"sqlalchemy>=2.0,<3",
|
|
12
13
|
]
|
|
13
14
|
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
notebook = ["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"
|
{periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0/src/periplus_python_sdk.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: periplus-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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,9 @@ License-File: LICENSE
|
|
|
11
11
|
License-File: NOTICE
|
|
12
12
|
Requires-Dist: httpx>=0.28
|
|
13
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"
|
|
14
17
|
Dynamic: license-file
|
|
15
18
|
|
|
16
19
|
# Periplus Python SDK
|
|
@@ -35,6 +38,99 @@ For a hosted deployment, replace the URL with its public HTTPS origin. Alternati
|
|
|
35
38
|
`PERIPLUS_PUBLIC_URL` and use `Client()`. An optional URL path prefix is preserved.
|
|
36
39
|
The client reuses HTTP connections; close it with a context manager or `close()`.
|
|
37
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.0"
|
|
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 → 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.0"`.
|
|
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
|
+
|
|
38
134
|
## Stable and experimental APIs
|
|
39
135
|
|
|
40
136
|
Both clients accept `mode="stable"` (the default) or `mode="experimental"` at initialization:
|
|
@@ -100,10 +196,10 @@ Use `aclose()` when managing an async client's lifetime explicitly.
|
|
|
100
196
|
Install the public-v1 client from PyPI:
|
|
101
197
|
|
|
102
198
|
```sh
|
|
103
|
-
python -m pip install "periplus-python-sdk>=0.
|
|
199
|
+
python -m pip install "periplus-python-sdk>=0.6.0"
|
|
104
200
|
```
|
|
105
201
|
|
|
106
|
-
Version 0.
|
|
202
|
+
Version 0.6.0 supports the current public-v1 contract. For production, configure
|
|
107
203
|
`PERIPLUS_PUBLIC_URL=https://periplus.dev`; no API token is required.
|
|
108
204
|
Run the installed package against an available public app:
|
|
109
205
|
|
|
@@ -115,11 +211,11 @@ PERIPLUS_PUBLIC_URL=http://localhost:8080 python packages/periplus-python-sdk/ex
|
|
|
115
211
|
|
|
116
212
|
Repository CI publishes immutable releases from tags named
|
|
117
213
|
`periplus-python-sdk-v<version>`. The tag must exactly match the static version
|
|
118
|
-
in `pyproject.toml`; for example, version `0.
|
|
214
|
+
in `pyproject.toml`; for example, version `0.6.0` is released with:
|
|
119
215
|
|
|
120
216
|
```sh
|
|
121
|
-
git tag periplus-python-sdk-v0.
|
|
122
|
-
git push origin periplus-python-sdk-v0.
|
|
217
|
+
git tag periplus-python-sdk-v0.6.0
|
|
218
|
+
git push origin periplus-python-sdk-v0.6.0
|
|
123
219
|
```
|
|
124
220
|
|
|
125
221
|
PyPI publishing uses Trusted Publishing rather than a stored API token. The
|
|
@@ -129,7 +225,7 @@ that GitHub environment with required reviewers before the first release.
|
|
|
129
225
|
|
|
130
226
|
## Public v1
|
|
131
227
|
|
|
132
|
-
Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.
|
|
228
|
+
Install the updated SDK from PyPI with `python -m pip install "periplus-python-sdk>=0.6.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
229
|
|
|
134
230
|
## License
|
|
135
231
|
|
{periplus_python_sdk-0.4.0 → periplus_python_sdk-0.6.0}/src/periplus_python_sdk.egg-info/SOURCES.txt
RENAMED
|
@@ -5,11 +5,18 @@ 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/sql_api.py
|
|
17
|
+
src/periplus_sdk/sqlalchemy.py
|
|
14
18
|
src/periplus_sdk/types.py
|
|
15
|
-
tests/test_client.py
|
|
19
|
+
tests/test_client.py
|
|
20
|
+
tests/test_dbapi.py
|
|
21
|
+
tests/test_notebook.py
|
|
22
|
+
tests/test_sql_api.py
|
|
@@ -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,25 @@
|
|
|
1
|
+
"""Notebook-friendly SQL engine over the public Periplus query API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import create_engine as _create_engine
|
|
7
|
+
from sqlalchemy.engine import Engine, URL
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_engine(
|
|
11
|
+
base_url: str | None = None,
|
|
12
|
+
*,
|
|
13
|
+
mode: Literal["stable", "experimental"] = "stable",
|
|
14
|
+
timeout: float = 140,
|
|
15
|
+
schema_version: str = "public_v1",
|
|
16
|
+
) -> Engine:
|
|
17
|
+
"""Create a SQLAlchemy engine recognized by marimo and other SQL tools.
|
|
18
|
+
|
|
19
|
+
The public URL defaults to PERIPLUS_PUBLIC_URL. Connections are opened lazily;
|
|
20
|
+
dispose the engine when finished. Each query uses an independent server snapshot.
|
|
21
|
+
"""
|
|
22
|
+
return _create_engine(
|
|
23
|
+
URL.create("periplus", database=schema_version),
|
|
24
|
+
connect_args={"base_url": base_url, "mode": mode, "timeout": timeout},
|
|
25
|
+
)
|
|
@@ -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 periplus_sdk import sql_api
|
|
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 = sql_api.create_engine('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')
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from sqlalchemy import inspect, text
|
|
8
|
+
from sqlalchemy.engine import Engine
|
|
9
|
+
from periplus_sdk import sql_api
|
|
10
|
+
from test_client import RESULT
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SQLApiTests(unittest.TestCase):
|
|
14
|
+
def test_engine_options_environment_and_queries(self):
|
|
15
|
+
factory = httpx.Client
|
|
16
|
+
for mode in ('stable', 'experimental'):
|
|
17
|
+
requests = []
|
|
18
|
+
def handler(request):
|
|
19
|
+
requests.append(request)
|
|
20
|
+
return httpx.Response(200, json=dict(RESULT, columns=['n'], types=['INTEGER'], rows=[[42]], truncated=False))
|
|
21
|
+
with self.subTest(mode=mode), patch.dict(os.environ, {'PERIPLUS_PUBLIC_URL':'https://public.example/prefix'}), patch(
|
|
22
|
+
'periplus_sdk.client.httpx.Client',
|
|
23
|
+
side_effect=lambda **kw: factory(**kw, transport=httpx.MockTransport(handler)),
|
|
24
|
+
):
|
|
25
|
+
engine = sql_api.create_engine(mode=mode, timeout=37)
|
|
26
|
+
self.assertIsInstance(engine, Engine)
|
|
27
|
+
self.assertEqual(requests, [])
|
|
28
|
+
try:
|
|
29
|
+
self.assertEqual(inspect(engine).default_schema_name, 'public_v1')
|
|
30
|
+
with engine.connect() as conn:
|
|
31
|
+
self.assertEqual(conn.connection.dbapi_connection._client._http.timeout.read,37)
|
|
32
|
+
self.assertEqual(conn.execute(text('SELECT :n AS n'), {'n':42}).fetchall(), [(42,)])
|
|
33
|
+
suffix = 'experimental/' if mode == 'experimental' else ''
|
|
34
|
+
self.assertEqual(requests[0].url.path, '/prefix/api/query/'+suffix+'exec')
|
|
35
|
+
self.assertEqual(json.loads(requests[0].content)['parameters'], [42])
|
|
36
|
+
self.assertEqual(json.loads(requests[0].content)['schema_version'], 'public_v1')
|
|
37
|
+
finally:
|
|
38
|
+
engine.dispose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|