periplus-python-sdk 0.2.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.2.0/PKG-INFO +108 -0
- periplus_python_sdk-0.2.0/README.md +97 -0
- periplus_python_sdk-0.2.0/pyproject.toml +24 -0
- periplus_python_sdk-0.2.0/setup.cfg +4 -0
- periplus_python_sdk-0.2.0/src/periplus_python_sdk.egg-info/PKG-INFO +108 -0
- periplus_python_sdk-0.2.0/src/periplus_python_sdk.egg-info/SOURCES.txt +13 -0
- periplus_python_sdk-0.2.0/src/periplus_python_sdk.egg-info/dependency_links.txt +1 -0
- periplus_python_sdk-0.2.0/src/periplus_python_sdk.egg-info/requires.txt +2 -0
- periplus_python_sdk-0.2.0/src/periplus_python_sdk.egg-info/top_level.txt +1 -0
- periplus_python_sdk-0.2.0/src/periplus_sdk/__init__.py +8 -0
- periplus_python_sdk-0.2.0/src/periplus_sdk/client.py +140 -0
- periplus_python_sdk-0.2.0/src/periplus_sdk/errors.py +28 -0
- periplus_python_sdk-0.2.0/src/periplus_sdk/py.typed +1 -0
- periplus_python_sdk-0.2.0/src/periplus_sdk/types.py +45 -0
- periplus_python_sdk-0.2.0/tests/test_client.py +110 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: periplus-python-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Read-only Python client for the public Periplus query API
|
|
5
|
+
Project-URL: Repository, https://github.com/elei-io/periplus
|
|
6
|
+
Project-URL: Issues, https://github.com/elei-io/periplus/issues
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: httpx>=0.28
|
|
10
|
+
Requires-Dist: pydantic<3,>=2.12
|
|
11
|
+
|
|
12
|
+
# Periplus Python SDK
|
|
13
|
+
|
|
14
|
+
A read-only client for the public Periplus query API. Python 3.11 or later.
|
|
15
|
+
Configure the **public web application URL**, not the internal query or control service.
|
|
16
|
+
No API token, DuckDB installation or lake credentials are needed.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from periplus_sdk import Client
|
|
20
|
+
|
|
21
|
+
with Client("http://localhost:8080") as client:
|
|
22
|
+
result = client.execute(
|
|
23
|
+
"SELECT observation_id FROM web.observation LIMIT ?", [10]
|
|
24
|
+
)
|
|
25
|
+
print(result.columns, result.types)
|
|
26
|
+
print(result.rows)
|
|
27
|
+
print(result.source_snapshot, result.truncated)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
For a hosted deployment, replace the URL with its public HTTPS origin. Alternatively set
|
|
31
|
+
`PERIPLUS_PUBLIC_URL` and use `Client()`. An optional URL path prefix is preserved.
|
|
32
|
+
The client reuses HTTP connections; close it with a context manager or `close()`.
|
|
33
|
+
|
|
34
|
+
## Preparation and helpers
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
with Client("http://localhost:8080") as client:
|
|
38
|
+
prepared = client.prepare("SELECT observation_id FROM web.observation LIMIT ?", [10])
|
|
39
|
+
print(prepared.diagnostics, prepared.plan)
|
|
40
|
+
result = client.execute(prepared.sql, prepared.parameters)
|
|
41
|
+
helpers = client.helpers()
|
|
42
|
+
print(helpers.catalogue_version, helpers.helpers)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Preparation validates and explains without executing the analytical query. Execution independently
|
|
46
|
+
validates and prepares; a prior preparation never authorizes SQL. Linting, diagnostics and future
|
|
47
|
+
SQL optimizations belong to the server. The SDK sends SQL unchanged.
|
|
48
|
+
|
|
49
|
+
## Async use
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from periplus_sdk import AsyncClient
|
|
53
|
+
|
|
54
|
+
async def observations():
|
|
55
|
+
async with AsyncClient("http://localhost:8080") as client:
|
|
56
|
+
return await client.execute("SELECT observation_id FROM web.observation LIMIT 10")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Use `aclose()` when managing an async client's lifetime explicitly.
|
|
60
|
+
|
|
61
|
+
## Permissions, results and errors
|
|
62
|
+
|
|
63
|
+
- The same public SQL feature switch, shared rate budget, namespace validation and read-only
|
|
64
|
+
execution apply as in the public web workspace. The SDK provides no writes, crawling,
|
|
65
|
+
administrative controls or direct lake attachment.
|
|
66
|
+
- Results retain `query_id`, SQL, parameters, diagnostics, plan, columns, SQL types, JSON rows,
|
|
67
|
+
elapsed milliseconds, `source_snapshot` and `truncated`. Decimals and large integers remain
|
|
68
|
+
strings exactly as returned by the server. Duplicate column names are preserved.
|
|
69
|
+
- Operator-configured execution limits default to 1,000 rows, an 8 MiB result budget and a
|
|
70
|
+
20-second server deadline. Always inspect `truncated`. The SDK does not silently fetch more rows or retry.
|
|
71
|
+
- `ApiError` exposes `status_code`, safe `code`, and `retry_after_seconds` when supplied.
|
|
72
|
+
`TransportError` means HTTP failed; `ResponseError` means a malformed successful response.
|
|
73
|
+
The client timeout defaults to 140 seconds and can be set with `timeout=`. A timeout or local
|
|
74
|
+
cancellation does not guarantee server cancellation. Redirects are not followed automatically.
|
|
75
|
+
- Preparation and execution are attributed to `sdk` in the existing private query history.
|
|
76
|
+
Original SQL and parameters are retained for 30 days; result rows are not stored. Recording is
|
|
77
|
+
best-effort and can be lost during outages or backpressure. This label is not a user identity.
|
|
78
|
+
|
|
79
|
+
## Installation and verification
|
|
80
|
+
|
|
81
|
+
Install from PyPI:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
python -m pip install periplus-python-sdk==0.2.0
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For local development: `python -m pip install ./packages/periplus-python-sdk`.
|
|
88
|
+
Run the installed package against an available public app:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
PERIPLUS_PUBLIC_URL=http://localhost:8080 python packages/periplus-python-sdk/examples/smoke.py
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Releasing
|
|
95
|
+
|
|
96
|
+
Repository CI publishes immutable releases from tags named
|
|
97
|
+
`periplus-python-sdk-v<version>`. The tag must exactly match the static version
|
|
98
|
+
in `pyproject.toml`; for example, version `0.2.0` is released with:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
git tag periplus-python-sdk-v0.2.0
|
|
102
|
+
git push origin periplus-python-sdk-v0.2.0
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
PyPI publishing uses Trusted Publishing rather than a stored API token. The
|
|
106
|
+
PyPI publisher must be configured for GitHub owner `elei-io`, repository
|
|
107
|
+
`periplus`, workflow `python-sdk-release.yml`, and environment `pypi`. Protect
|
|
108
|
+
that GitHub environment with required reviewers before the first release.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Periplus Python SDK
|
|
2
|
+
|
|
3
|
+
A read-only client for the public Periplus query API. Python 3.11 or later.
|
|
4
|
+
Configure the **public web application URL**, not the internal query or control service.
|
|
5
|
+
No API token, DuckDB installation or lake credentials are needed.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from periplus_sdk import Client
|
|
9
|
+
|
|
10
|
+
with Client("http://localhost:8080") as client:
|
|
11
|
+
result = client.execute(
|
|
12
|
+
"SELECT observation_id FROM web.observation LIMIT ?", [10]
|
|
13
|
+
)
|
|
14
|
+
print(result.columns, result.types)
|
|
15
|
+
print(result.rows)
|
|
16
|
+
print(result.source_snapshot, result.truncated)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
For a hosted deployment, replace the URL with its public HTTPS origin. Alternatively set
|
|
20
|
+
`PERIPLUS_PUBLIC_URL` and use `Client()`. An optional URL path prefix is preserved.
|
|
21
|
+
The client reuses HTTP connections; close it with a context manager or `close()`.
|
|
22
|
+
|
|
23
|
+
## Preparation and helpers
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
with Client("http://localhost:8080") as client:
|
|
27
|
+
prepared = client.prepare("SELECT observation_id FROM web.observation LIMIT ?", [10])
|
|
28
|
+
print(prepared.diagnostics, prepared.plan)
|
|
29
|
+
result = client.execute(prepared.sql, prepared.parameters)
|
|
30
|
+
helpers = client.helpers()
|
|
31
|
+
print(helpers.catalogue_version, helpers.helpers)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Preparation validates and explains without executing the analytical query. Execution independently
|
|
35
|
+
validates and prepares; a prior preparation never authorizes SQL. Linting, diagnostics and future
|
|
36
|
+
SQL optimizations belong to the server. The SDK sends SQL unchanged.
|
|
37
|
+
|
|
38
|
+
## Async use
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from periplus_sdk import AsyncClient
|
|
42
|
+
|
|
43
|
+
async def observations():
|
|
44
|
+
async with AsyncClient("http://localhost:8080") as client:
|
|
45
|
+
return await client.execute("SELECT observation_id FROM web.observation LIMIT 10")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Use `aclose()` when managing an async client's lifetime explicitly.
|
|
49
|
+
|
|
50
|
+
## Permissions, results and errors
|
|
51
|
+
|
|
52
|
+
- The same public SQL feature switch, shared rate budget, namespace validation and read-only
|
|
53
|
+
execution apply as in the public web workspace. The SDK provides no writes, crawling,
|
|
54
|
+
administrative controls or direct lake attachment.
|
|
55
|
+
- Results retain `query_id`, SQL, parameters, diagnostics, plan, columns, SQL types, JSON rows,
|
|
56
|
+
elapsed milliseconds, `source_snapshot` and `truncated`. Decimals and large integers remain
|
|
57
|
+
strings exactly as returned by the server. Duplicate column names are preserved.
|
|
58
|
+
- Operator-configured execution limits default to 1,000 rows, an 8 MiB result budget and a
|
|
59
|
+
20-second server deadline. Always inspect `truncated`. The SDK does not silently fetch more rows or retry.
|
|
60
|
+
- `ApiError` exposes `status_code`, safe `code`, and `retry_after_seconds` when supplied.
|
|
61
|
+
`TransportError` means HTTP failed; `ResponseError` means a malformed successful response.
|
|
62
|
+
The client timeout defaults to 140 seconds and can be set with `timeout=`. A timeout or local
|
|
63
|
+
cancellation does not guarantee server cancellation. Redirects are not followed automatically.
|
|
64
|
+
- Preparation and execution are attributed to `sdk` in the existing private query history.
|
|
65
|
+
Original SQL and parameters are retained for 30 days; result rows are not stored. Recording is
|
|
66
|
+
best-effort and can be lost during outages or backpressure. This label is not a user identity.
|
|
67
|
+
|
|
68
|
+
## Installation and verification
|
|
69
|
+
|
|
70
|
+
Install from PyPI:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
python -m pip install periplus-python-sdk==0.2.0
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
For local development: `python -m pip install ./packages/periplus-python-sdk`.
|
|
77
|
+
Run the installed package against an available public app:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
PERIPLUS_PUBLIC_URL=http://localhost:8080 python packages/periplus-python-sdk/examples/smoke.py
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Releasing
|
|
84
|
+
|
|
85
|
+
Repository CI publishes immutable releases from tags named
|
|
86
|
+
`periplus-python-sdk-v<version>`. The tag must exactly match the static version
|
|
87
|
+
in `pyproject.toml`; for example, version `0.2.0` is released with:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
git tag periplus-python-sdk-v0.2.0
|
|
91
|
+
git push origin periplus-python-sdk-v0.2.0
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
PyPI publishing uses Trusted Publishing rather than a stored API token. The
|
|
95
|
+
PyPI publisher must be configured for GitHub owner `elei-io`, repository
|
|
96
|
+
`periplus`, workflow `python-sdk-release.yml`, and environment `pypi`. Protect
|
|
97
|
+
that GitHub environment with required reviewers before the first release.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "periplus-python-sdk"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Read-only Python client for the public Periplus query API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"httpx>=0.28",
|
|
9
|
+
"pydantic>=2.12,<3",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Repository = "https://github.com/elei-io/periplus"
|
|
14
|
+
Issues = "https://github.com/elei-io/periplus/issues"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["setuptools>=77"]
|
|
18
|
+
build-backend = "setuptools.build_meta"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.package-data]
|
|
24
|
+
periplus_sdk = ["py.typed"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: periplus-python-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Read-only Python client for the public Periplus query API
|
|
5
|
+
Project-URL: Repository, https://github.com/elei-io/periplus
|
|
6
|
+
Project-URL: Issues, https://github.com/elei-io/periplus/issues
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: httpx>=0.28
|
|
10
|
+
Requires-Dist: pydantic<3,>=2.12
|
|
11
|
+
|
|
12
|
+
# Periplus Python SDK
|
|
13
|
+
|
|
14
|
+
A read-only client for the public Periplus query API. Python 3.11 or later.
|
|
15
|
+
Configure the **public web application URL**, not the internal query or control service.
|
|
16
|
+
No API token, DuckDB installation or lake credentials are needed.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from periplus_sdk import Client
|
|
20
|
+
|
|
21
|
+
with Client("http://localhost:8080") as client:
|
|
22
|
+
result = client.execute(
|
|
23
|
+
"SELECT observation_id FROM web.observation LIMIT ?", [10]
|
|
24
|
+
)
|
|
25
|
+
print(result.columns, result.types)
|
|
26
|
+
print(result.rows)
|
|
27
|
+
print(result.source_snapshot, result.truncated)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
For a hosted deployment, replace the URL with its public HTTPS origin. Alternatively set
|
|
31
|
+
`PERIPLUS_PUBLIC_URL` and use `Client()`. An optional URL path prefix is preserved.
|
|
32
|
+
The client reuses HTTP connections; close it with a context manager or `close()`.
|
|
33
|
+
|
|
34
|
+
## Preparation and helpers
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
with Client("http://localhost:8080") as client:
|
|
38
|
+
prepared = client.prepare("SELECT observation_id FROM web.observation LIMIT ?", [10])
|
|
39
|
+
print(prepared.diagnostics, prepared.plan)
|
|
40
|
+
result = client.execute(prepared.sql, prepared.parameters)
|
|
41
|
+
helpers = client.helpers()
|
|
42
|
+
print(helpers.catalogue_version, helpers.helpers)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Preparation validates and explains without executing the analytical query. Execution independently
|
|
46
|
+
validates and prepares; a prior preparation never authorizes SQL. Linting, diagnostics and future
|
|
47
|
+
SQL optimizations belong to the server. The SDK sends SQL unchanged.
|
|
48
|
+
|
|
49
|
+
## Async use
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from periplus_sdk import AsyncClient
|
|
53
|
+
|
|
54
|
+
async def observations():
|
|
55
|
+
async with AsyncClient("http://localhost:8080") as client:
|
|
56
|
+
return await client.execute("SELECT observation_id FROM web.observation LIMIT 10")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Use `aclose()` when managing an async client's lifetime explicitly.
|
|
60
|
+
|
|
61
|
+
## Permissions, results and errors
|
|
62
|
+
|
|
63
|
+
- The same public SQL feature switch, shared rate budget, namespace validation and read-only
|
|
64
|
+
execution apply as in the public web workspace. The SDK provides no writes, crawling,
|
|
65
|
+
administrative controls or direct lake attachment.
|
|
66
|
+
- Results retain `query_id`, SQL, parameters, diagnostics, plan, columns, SQL types, JSON rows,
|
|
67
|
+
elapsed milliseconds, `source_snapshot` and `truncated`. Decimals and large integers remain
|
|
68
|
+
strings exactly as returned by the server. Duplicate column names are preserved.
|
|
69
|
+
- Operator-configured execution limits default to 1,000 rows, an 8 MiB result budget and a
|
|
70
|
+
20-second server deadline. Always inspect `truncated`. The SDK does not silently fetch more rows or retry.
|
|
71
|
+
- `ApiError` exposes `status_code`, safe `code`, and `retry_after_seconds` when supplied.
|
|
72
|
+
`TransportError` means HTTP failed; `ResponseError` means a malformed successful response.
|
|
73
|
+
The client timeout defaults to 140 seconds and can be set with `timeout=`. A timeout or local
|
|
74
|
+
cancellation does not guarantee server cancellation. Redirects are not followed automatically.
|
|
75
|
+
- Preparation and execution are attributed to `sdk` in the existing private query history.
|
|
76
|
+
Original SQL and parameters are retained for 30 days; result rows are not stored. Recording is
|
|
77
|
+
best-effort and can be lost during outages or backpressure. This label is not a user identity.
|
|
78
|
+
|
|
79
|
+
## Installation and verification
|
|
80
|
+
|
|
81
|
+
Install from PyPI:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
python -m pip install periplus-python-sdk==0.2.0
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For local development: `python -m pip install ./packages/periplus-python-sdk`.
|
|
88
|
+
Run the installed package against an available public app:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
PERIPLUS_PUBLIC_URL=http://localhost:8080 python packages/periplus-python-sdk/examples/smoke.py
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Releasing
|
|
95
|
+
|
|
96
|
+
Repository CI publishes immutable releases from tags named
|
|
97
|
+
`periplus-python-sdk-v<version>`. The tag must exactly match the static version
|
|
98
|
+
in `pyproject.toml`; for example, version `0.2.0` is released with:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
git tag periplus-python-sdk-v0.2.0
|
|
102
|
+
git push origin periplus-python-sdk-v0.2.0
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
PyPI publishing uses Trusted Publishing rather than a stored API token. The
|
|
106
|
+
PyPI publisher must be configured for GitHub owner `elei-io`, repository
|
|
107
|
+
`periplus`, workflow `python-sdk-release.yml`, and environment `pypi`. Protect
|
|
108
|
+
that GitHub environment with required reviewers before the first release.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/periplus_python_sdk.egg-info/PKG-INFO
|
|
4
|
+
src/periplus_python_sdk.egg-info/SOURCES.txt
|
|
5
|
+
src/periplus_python_sdk.egg-info/dependency_links.txt
|
|
6
|
+
src/periplus_python_sdk.egg-info/requires.txt
|
|
7
|
+
src/periplus_python_sdk.egg-info/top_level.txt
|
|
8
|
+
src/periplus_sdk/__init__.py
|
|
9
|
+
src/periplus_sdk/client.py
|
|
10
|
+
src/periplus_sdk/errors.py
|
|
11
|
+
src/periplus_sdk/py.typed
|
|
12
|
+
src/periplus_sdk/types.py
|
|
13
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
periplus_sdk
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Read-only Python clients for the public Periplus query API."""
|
|
2
|
+
from .client import AsyncClient, Client
|
|
3
|
+
from .errors import ApiError, ConfigurationError, PeriplusError, ResponseError, TransportError
|
|
4
|
+
from .types import Diagnostic, PreparedQuery, QueryHelper, QueryHelpers, QueryResult
|
|
5
|
+
|
|
6
|
+
__all__ = ["AsyncClient", "Client", "ApiError", "ConfigurationError", "PeriplusError",
|
|
7
|
+
"ResponseError", "TransportError", "Diagnostic", "PreparedQuery", "QueryHelper",
|
|
8
|
+
"QueryHelpers", "QueryResult"]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""HTTP clients for the public application's existing /api/query routes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from email.utils import parsedate_to_datetime
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
from typing import TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel, JsonValue, ValidationError
|
|
13
|
+
|
|
14
|
+
from .errors import ApiError, ConfigurationError, ResponseError, TransportError
|
|
15
|
+
from .types import PreparedQuery, QueryHelpers, QueryResult
|
|
16
|
+
|
|
17
|
+
Model = TypeVar("Model", bound=BaseModel)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _options(base_url: str | None, timeout: float) -> dict:
|
|
21
|
+
value = (base_url or os.environ.get("PERIPLUS_PUBLIC_URL", "")).strip()
|
|
22
|
+
try:
|
|
23
|
+
url = httpx.URL(value)
|
|
24
|
+
except httpx.InvalidURL:
|
|
25
|
+
raise ConfigurationError("Provide a valid public application URL.") from None
|
|
26
|
+
if url.scheme not in {"http", "https"} or not url.host or url.userinfo or url.query or url.fragment:
|
|
27
|
+
raise ConfigurationError("Provide an HTTP(S) public application URL without credentials, query or fragment.")
|
|
28
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
29
|
+
raise ConfigurationError("timeout must be a positive finite number of seconds.")
|
|
30
|
+
return dict(base_url=str(url).rstrip("/") + "/", timeout=timeout,
|
|
31
|
+
headers={"x-periplus-query-source": "sdk", "accept": "application/json"},
|
|
32
|
+
follow_redirects=False)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _retry_after(value: str | None) -> float | None:
|
|
36
|
+
if value is None:
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
seconds = float(value)
|
|
40
|
+
except ValueError:
|
|
41
|
+
try:
|
|
42
|
+
moment = parsedate_to_datetime(value)
|
|
43
|
+
if moment.utcoffset() is None:
|
|
44
|
+
return None
|
|
45
|
+
seconds = (moment - datetime.now(UTC)).total_seconds()
|
|
46
|
+
except (ValueError, TypeError, OverflowError):
|
|
47
|
+
return None
|
|
48
|
+
return max(0, seconds) if math.isfinite(seconds) else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _decode(response: httpx.Response, model: type[Model]) -> Model:
|
|
52
|
+
if not response.is_success:
|
|
53
|
+
code = None
|
|
54
|
+
message = f"Public query request failed (HTTP {response.status_code})."
|
|
55
|
+
try:
|
|
56
|
+
body = response.json()
|
|
57
|
+
if isinstance(body, dict):
|
|
58
|
+
detail = body.get("detail")
|
|
59
|
+
error = detail if isinstance(detail, dict) else body
|
|
60
|
+
code = error.get("code")
|
|
61
|
+
if isinstance(error.get("detail"), str):
|
|
62
|
+
message = error["detail"]
|
|
63
|
+
except ValueError:
|
|
64
|
+
pass
|
|
65
|
+
raise ApiError(message, status_code=response.status_code,
|
|
66
|
+
code=code if isinstance(code, str) else None,
|
|
67
|
+
retry_after_seconds=_retry_after(response.headers.get("retry-after")))
|
|
68
|
+
try:
|
|
69
|
+
return model.model_validate(response.json())
|
|
70
|
+
except (ValueError, ValidationError):
|
|
71
|
+
raise ResponseError("Public query response did not match the expected contract.") from None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _payload(sql: str, parameters: Sequence[JsonValue] | None) -> dict:
|
|
75
|
+
# Server owns SQL validation, linting, preparation and optimization.
|
|
76
|
+
return {"sql": sql, "parameters": list(parameters) if parameters is not None else []}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Client:
|
|
80
|
+
"""Reusable synchronous public query client. Close it or use a with block."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, base_url: str | None = None, *, timeout: float = 140):
|
|
83
|
+
self._http = httpx.Client(**_options(base_url, timeout))
|
|
84
|
+
|
|
85
|
+
def __enter__(self) -> Client:
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def __exit__(self, *args):
|
|
89
|
+
self.close()
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
self._http.close()
|
|
93
|
+
|
|
94
|
+
def _request(self, method: str, path: str, model: type[Model], **kwargs) -> Model:
|
|
95
|
+
try:
|
|
96
|
+
response = self._http.request(method, "api/query/" + path, **kwargs)
|
|
97
|
+
except httpx.RequestError:
|
|
98
|
+
raise TransportError("Could not complete the public query request.") from None
|
|
99
|
+
return _decode(response, model)
|
|
100
|
+
|
|
101
|
+
def prepare(self, sql: str, parameters: Sequence[JsonValue] | None = None) -> PreparedQuery:
|
|
102
|
+
return self._request("POST", "prep", PreparedQuery, json=_payload(sql, parameters))
|
|
103
|
+
|
|
104
|
+
def execute(self, sql: str, parameters: Sequence[JsonValue] | None = None) -> QueryResult:
|
|
105
|
+
return self._request("POST", "exec", QueryResult, json=_payload(sql, parameters))
|
|
106
|
+
|
|
107
|
+
def helpers(self) -> QueryHelpers:
|
|
108
|
+
return self._request("GET", "helpers", QueryHelpers)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AsyncClient:
|
|
112
|
+
"""Reusable asynchronous public query client. Use an async with block."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, base_url: str | None = None, *, timeout: float = 140):
|
|
115
|
+
self._http = httpx.AsyncClient(**_options(base_url, timeout))
|
|
116
|
+
|
|
117
|
+
async def __aenter__(self) -> AsyncClient:
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
async def __aexit__(self, *args):
|
|
121
|
+
await self.aclose()
|
|
122
|
+
|
|
123
|
+
async def aclose(self) -> None:
|
|
124
|
+
await self._http.aclose()
|
|
125
|
+
|
|
126
|
+
async def _request(self, method: str, path: str, model: type[Model], **kwargs) -> Model:
|
|
127
|
+
try:
|
|
128
|
+
response = await self._http.request(method, "api/query/" + path, **kwargs)
|
|
129
|
+
except httpx.RequestError:
|
|
130
|
+
raise TransportError("Could not complete the public query request.") from None
|
|
131
|
+
return _decode(response, model)
|
|
132
|
+
|
|
133
|
+
async def prepare(self, sql: str, parameters: Sequence[JsonValue] | None = None) -> PreparedQuery:
|
|
134
|
+
return await self._request("POST", "prep", PreparedQuery, json=_payload(sql, parameters))
|
|
135
|
+
|
|
136
|
+
async def execute(self, sql: str, parameters: Sequence[JsonValue] | None = None) -> QueryResult:
|
|
137
|
+
return await self._request("POST", "exec", QueryResult, json=_payload(sql, parameters))
|
|
138
|
+
|
|
139
|
+
async def helpers(self) -> QueryHelpers:
|
|
140
|
+
return await self._request("GET", "helpers", QueryHelpers)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Public query failures, including the server's safe error category."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PeriplusError(Exception):
|
|
5
|
+
"""Base SDK error."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(PeriplusError, ValueError):
|
|
9
|
+
"""The public application URL or timeout is invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransportError(PeriplusError):
|
|
13
|
+
"""The public application could not be reached; no automatic retry occurs."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ResponseError(PeriplusError):
|
|
17
|
+
"""The public application returned an invalid response."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApiError(PeriplusError):
|
|
21
|
+
"""A public gateway or query-service rejection."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, *, status_code: int, code: str | None = None,
|
|
24
|
+
retry_after_seconds: float | None = None):
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.code = code
|
|
28
|
+
self.retry_after_seconds = retry_after_seconds
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Public query wire types; SQL types and JSON values are preserved."""
|
|
2
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Diagnostic(BaseModel):
|
|
6
|
+
severity: str
|
|
7
|
+
code: str
|
|
8
|
+
message: str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PreparedQuery(BaseModel):
|
|
12
|
+
query_id: str
|
|
13
|
+
sql: str
|
|
14
|
+
parameters: list[JsonValue]
|
|
15
|
+
diagnostics: list[Diagnostic]
|
|
16
|
+
plan: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class QueryResult(PreparedQuery):
|
|
20
|
+
columns: list[str]
|
|
21
|
+
types: list[str]
|
|
22
|
+
rows: list[list[JsonValue]]
|
|
23
|
+
truncated: bool
|
|
24
|
+
elapsed_ms: float
|
|
25
|
+
source_snapshot: int = Field(ge=0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HelperField(BaseModel):
|
|
29
|
+
name: str
|
|
30
|
+
description: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class QueryHelper(BaseModel):
|
|
34
|
+
name: str
|
|
35
|
+
kind: str
|
|
36
|
+
description: str
|
|
37
|
+
parameters: list[HelperField]
|
|
38
|
+
columns: list[HelperField]
|
|
39
|
+
notes: list[str]
|
|
40
|
+
examples: list[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class QueryHelpers(BaseModel):
|
|
44
|
+
catalogue_version: str
|
|
45
|
+
helpers: list[QueryHelper]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from periplus_sdk import AsyncClient, Client, ApiError, ConfigurationError, ResponseError, TransportError
|
|
9
|
+
|
|
10
|
+
PREP = dict(query_id='q', sql='SELECT ? AS n', parameters=[1], diagnostics=[], plan='plan')
|
|
11
|
+
RESULT = dict(**PREP, columns=['n', 'n'], types=['BIGINT', 'DECIMAL(20,2)'],
|
|
12
|
+
rows=[['9007199254740993', '123.45']], truncated=True, elapsed_ms=1.2, source_snapshot=4)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ClientTests(unittest.TestCase):
|
|
16
|
+
def client(self, handler):
|
|
17
|
+
factory = httpx.Client
|
|
18
|
+
with patch('periplus_sdk.client.httpx.Client', side_effect=lambda **kw:
|
|
19
|
+
factory(**kw, transport=httpx.MockTransport(handler))):
|
|
20
|
+
client = Client('https://public.example/prefix/')
|
|
21
|
+
self.addCleanup(client.close)
|
|
22
|
+
return client
|
|
23
|
+
|
|
24
|
+
def test_public_routes_values_and_no_credentials(self):
|
|
25
|
+
requests = []
|
|
26
|
+
def handler(request):
|
|
27
|
+
requests.append(request)
|
|
28
|
+
self.assertNotIn('authorization', request.headers)
|
|
29
|
+
self.assertEqual(request.headers['x-periplus-query-source'], 'sdk')
|
|
30
|
+
if request.url.path.endswith('helpers'):
|
|
31
|
+
return httpx.Response(200, json={'catalogue_version': '3.0.0', 'helpers': []})
|
|
32
|
+
self.assertEqual(json.loads(request.content), {'sql': PREP['sql'], 'parameters': [1]})
|
|
33
|
+
return httpx.Response(200, json=RESULT if request.url.path.endswith('exec') else PREP)
|
|
34
|
+
with patch.dict(os.environ, {'PERIPLUS_QUERY_API_TOKEN': 'secret', 'PERIPLUS_API_TOKEN': 'admin'}):
|
|
35
|
+
client = self.client(handler)
|
|
36
|
+
self.assertEqual(client.prepare(PREP['sql'], [1]).plan, 'plan')
|
|
37
|
+
result = client.execute(PREP['sql'], [1])
|
|
38
|
+
self.assertEqual(result.rows, RESULT['rows'])
|
|
39
|
+
self.assertEqual(result.columns, ['n', 'n'])
|
|
40
|
+
self.assertTrue(result.truncated)
|
|
41
|
+
self.assertEqual(result.source_snapshot, 4)
|
|
42
|
+
self.assertEqual(client.helpers().catalogue_version, '3.0.0')
|
|
43
|
+
self.assertEqual([r.url.path for r in requests], ['/prefix/api/query/prep', '/prefix/api/query/exec', '/prefix/api/query/helpers'])
|
|
44
|
+
|
|
45
|
+
def test_errors_preserve_categories_and_never_retry(self):
|
|
46
|
+
for status, body, code in [(429, {'code': 'service_busy', 'detail': 'Busy'}, 'service_busy'),
|
|
47
|
+
(403, {'code': 'feature_disabled', 'detail': 'Disabled'}, 'feature_disabled'),
|
|
48
|
+
(503, {'detail': {'code': 'access_unavailable', 'detail': 'Offline'}}, 'access_unavailable'),
|
|
49
|
+
(422, {'detail': [{'msg': 'Invalid'}]}, None)]:
|
|
50
|
+
calls = []
|
|
51
|
+
def handler(request):
|
|
52
|
+
calls.append(request)
|
|
53
|
+
return httpx.Response(status, json=body, headers={'Retry-After': '2'})
|
|
54
|
+
with self.subTest(status=status), self.client(handler) as client:
|
|
55
|
+
with self.assertRaises(ApiError) as raised:
|
|
56
|
+
client.execute('SELECT 1')
|
|
57
|
+
self.assertEqual(raised.exception.status_code, status)
|
|
58
|
+
self.assertEqual(raised.exception.code, code)
|
|
59
|
+
self.assertEqual(raised.exception.retry_after_seconds, 2)
|
|
60
|
+
self.assertEqual(len(calls), 1)
|
|
61
|
+
|
|
62
|
+
def test_malformed_response_redirect_and_transport_error(self):
|
|
63
|
+
for response in [httpx.Response(200, text='<html>'), httpx.Response(200, json={})]:
|
|
64
|
+
with self.client(lambda request: response) as client:
|
|
65
|
+
with self.assertRaises(ResponseError):
|
|
66
|
+
client.execute('SELECT 1')
|
|
67
|
+
with self.client(lambda request: httpx.Response(302, headers={'Location': 'https://other.example'})) as client:
|
|
68
|
+
with self.assertRaises(ApiError):
|
|
69
|
+
client.helpers()
|
|
70
|
+
def fail(request):
|
|
71
|
+
raise httpx.ReadTimeout('contains sensitive origin', request=request)
|
|
72
|
+
with self.client(fail) as client:
|
|
73
|
+
with self.assertRaisesRegex(TransportError, 'Could not complete'):
|
|
74
|
+
client.execute('SELECT 1')
|
|
75
|
+
|
|
76
|
+
def test_configuration_and_removed_privileged_api(self):
|
|
77
|
+
for url in ['', 'postgres://host', 'https://user:secret@host', 'https://host/?token=secret', 'https://host/#a']:
|
|
78
|
+
with patch.dict(os.environ, {}, clear=True), self.subTest(url=url):
|
|
79
|
+
with self.assertRaises(ConfigurationError):
|
|
80
|
+
Client(url)
|
|
81
|
+
for timeout in [0, -1, float('nan'), float('inf')]:
|
|
82
|
+
with self.assertRaises(ConfigurationError):
|
|
83
|
+
Client('https://public.example', timeout=timeout)
|
|
84
|
+
with patch.dict(os.environ, {'PERIPLUS_PUBLIC_URL': 'https://public.example'}):
|
|
85
|
+
with Client() as client:
|
|
86
|
+
self.assertEqual(str(client._http.base_url), 'https://public.example/')
|
|
87
|
+
import periplus_sdk
|
|
88
|
+
self.assertFalse(hasattr(periplus_sdk, 'conn'))
|
|
89
|
+
self.assertFalse(hasattr(periplus_sdk, 'collections'))
|
|
90
|
+
self.assertFalse(hasattr(periplus_sdk, 'frontier'))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AsyncClientTests(unittest.IsolatedAsyncioTestCase):
|
|
94
|
+
async def test_async_parity_and_close(self):
|
|
95
|
+
factory = httpx.AsyncClient
|
|
96
|
+
calls = []
|
|
97
|
+
def handler(request):
|
|
98
|
+
calls.append(request)
|
|
99
|
+
if request.url.path.endswith('helpers'):
|
|
100
|
+
return httpx.Response(200, json={'catalogue_version': '3.0.0', 'helpers': []})
|
|
101
|
+
return httpx.Response(200, json=RESULT if request.url.path.endswith('exec') else PREP)
|
|
102
|
+
with patch('periplus_sdk.client.httpx.AsyncClient', side_effect=lambda **kw:
|
|
103
|
+
factory(**kw, transport=httpx.MockTransport(handler))):
|
|
104
|
+
async with AsyncClient('https://public.example') as client:
|
|
105
|
+
self.assertEqual((await client.prepare('SELECT ?', [1])).parameters, [1])
|
|
106
|
+
self.assertEqual((await client.execute('SELECT ?', [1])).rows, RESULT['rows'])
|
|
107
|
+
self.assertEqual((await client.helpers()).catalogue_version, '3.0.0')
|
|
108
|
+
self.assertTrue(client._http.is_closed)
|
|
109
|
+
self.assertEqual(len(calls), 3)
|
|
110
|
+
self.assertTrue(all(r.headers['x-periplus-query-source'] == 'sdk' for r in calls))
|