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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ httpx>=0.28
2
+ pydantic<3,>=2.12
@@ -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,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))