terminusdb-async 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors import TerminusDBError
|
|
11
|
+
|
|
12
|
+
Json = dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncTerminusClient:
|
|
16
|
+
"""Thin async wrapper around the TerminusDB HTTP API.
|
|
17
|
+
|
|
18
|
+
The client owns an ``httpx.AsyncClient`` unless one is injected. Resource paths
|
|
19
|
+
target a branch by default: ``<org>/<db>/local/branch/<branch>``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
base_url: str,
|
|
25
|
+
*,
|
|
26
|
+
organization: str,
|
|
27
|
+
database: str,
|
|
28
|
+
branch: str = "main",
|
|
29
|
+
username: str | None = None,
|
|
30
|
+
password: str | None = None,
|
|
31
|
+
token: str | None = None,
|
|
32
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
33
|
+
http_client: httpx.AsyncClient | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
if token and username:
|
|
36
|
+
raise ValueError("Use token auth or basic auth, not both")
|
|
37
|
+
self.base_url = base_url.rstrip("/")
|
|
38
|
+
self.organization = organization
|
|
39
|
+
self.database = database
|
|
40
|
+
self.branch = branch
|
|
41
|
+
self._owns_client = http_client is None
|
|
42
|
+
|
|
43
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
44
|
+
auth: httpx.Auth | None = None
|
|
45
|
+
if token:
|
|
46
|
+
headers["Authorization"] = f"Token {token}"
|
|
47
|
+
elif username is not None:
|
|
48
|
+
auth = httpx.BasicAuth(username, password or "")
|
|
49
|
+
|
|
50
|
+
self.http = http_client or httpx.AsyncClient(
|
|
51
|
+
base_url=self.base_url,
|
|
52
|
+
headers=headers,
|
|
53
|
+
auth=auth,
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
async def __aenter__(self) -> "AsyncTerminusClient":
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
61
|
+
await self.aclose()
|
|
62
|
+
|
|
63
|
+
async def aclose(self) -> None:
|
|
64
|
+
if self._owns_client:
|
|
65
|
+
await self.http.aclose()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def database_path(self) -> str:
|
|
69
|
+
return f"{quote(self.organization, safe='')}/{quote(self.database, safe='')}"
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def resource_path(self) -> str:
|
|
73
|
+
return f"{self.database_path}/local/branch/{quote(self.branch, safe='')}"
|
|
74
|
+
|
|
75
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
76
|
+
response = await self.http.request(method, path, **kwargs)
|
|
77
|
+
if response.is_error:
|
|
78
|
+
try:
|
|
79
|
+
body: object = response.json()
|
|
80
|
+
except Exception:
|
|
81
|
+
body = response.text
|
|
82
|
+
raise TerminusDBError(
|
|
83
|
+
f"TerminusDB {method} {path} failed with HTTP {response.status_code}",
|
|
84
|
+
status_code=response.status_code,
|
|
85
|
+
body=body,
|
|
86
|
+
)
|
|
87
|
+
return response
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _decode(response: httpx.Response) -> Any:
|
|
91
|
+
if not response.content:
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
return response.json()
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
docs: list[Any] = []
|
|
97
|
+
for line in response.text.splitlines():
|
|
98
|
+
line = line.strip()
|
|
99
|
+
if line:
|
|
100
|
+
docs.append(json.loads(line))
|
|
101
|
+
return docs
|
|
102
|
+
|
|
103
|
+
async def ok(self) -> Any:
|
|
104
|
+
return self._decode(await self._request("GET", "/api/ok"))
|
|
105
|
+
|
|
106
|
+
async def info(self) -> Any:
|
|
107
|
+
return self._decode(await self._request("GET", "/api/info"))
|
|
108
|
+
|
|
109
|
+
async def get_document(
|
|
110
|
+
self,
|
|
111
|
+
document_id: str,
|
|
112
|
+
*,
|
|
113
|
+
graph_type: str = "instance",
|
|
114
|
+
raw_json: bool = False,
|
|
115
|
+
) -> Json:
|
|
116
|
+
params = {"id": document_id, "graph_type": graph_type, "raw_json": _bool(raw_json)}
|
|
117
|
+
data = self._decode(await self._request("GET", f"/api/document/{self.resource_path}", params=params))
|
|
118
|
+
if isinstance(data, list):
|
|
119
|
+
if len(data) != 1:
|
|
120
|
+
raise TerminusDBError(f"Expected one document, got {len(data)}", body=data)
|
|
121
|
+
return data[0]
|
|
122
|
+
if not isinstance(data, dict):
|
|
123
|
+
raise TerminusDBError("Unexpected document response", body=data)
|
|
124
|
+
return data
|
|
125
|
+
|
|
126
|
+
async def get_documents(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
document_type: str | None = None,
|
|
130
|
+
ids: Sequence[str] | None = None,
|
|
131
|
+
graph_type: str = "instance",
|
|
132
|
+
count: int | None = None,
|
|
133
|
+
skip: int | None = None,
|
|
134
|
+
) -> list[Json]:
|
|
135
|
+
params: dict[str, Any] = {"graph_type": graph_type}
|
|
136
|
+
if document_type:
|
|
137
|
+
params["type"] = document_type
|
|
138
|
+
if ids:
|
|
139
|
+
params["ids"] = json.dumps(list(ids))
|
|
140
|
+
if count is not None:
|
|
141
|
+
params["count"] = count
|
|
142
|
+
if skip is not None:
|
|
143
|
+
params["skip"] = skip
|
|
144
|
+
data = self._decode(await self._request("GET", f"/api/document/{self.resource_path}", params=params))
|
|
145
|
+
if data is None:
|
|
146
|
+
return []
|
|
147
|
+
if isinstance(data, dict):
|
|
148
|
+
return [data]
|
|
149
|
+
return list(data)
|
|
150
|
+
|
|
151
|
+
async def query_documents(
|
|
152
|
+
self,
|
|
153
|
+
query: Mapping[str, Any],
|
|
154
|
+
*,
|
|
155
|
+
document_type: str | None = None,
|
|
156
|
+
count: int | None = None,
|
|
157
|
+
skip: int | None = None,
|
|
158
|
+
graph_type: str = "instance",
|
|
159
|
+
) -> list[Json]:
|
|
160
|
+
payload: dict[str, Any] = {"query": dict(query), "graph_type": graph_type}
|
|
161
|
+
if document_type:
|
|
162
|
+
payload["@rdf:type"] = document_type
|
|
163
|
+
if count is not None:
|
|
164
|
+
payload["count"] = count
|
|
165
|
+
if skip is not None:
|
|
166
|
+
payload["skip"] = skip
|
|
167
|
+
response = await self._request(
|
|
168
|
+
"POST",
|
|
169
|
+
f"/api/document/{self.resource_path}",
|
|
170
|
+
headers={"X-HTTP-Method-Override": "GET"},
|
|
171
|
+
json=payload,
|
|
172
|
+
)
|
|
173
|
+
data = self._decode(response)
|
|
174
|
+
if data is None:
|
|
175
|
+
return []
|
|
176
|
+
if isinstance(data, dict):
|
|
177
|
+
return [data]
|
|
178
|
+
return list(data)
|
|
179
|
+
|
|
180
|
+
async def insert_documents(
|
|
181
|
+
self,
|
|
182
|
+
documents: Mapping[str, Any] | Sequence[Mapping[str, Any]],
|
|
183
|
+
*,
|
|
184
|
+
author: str,
|
|
185
|
+
message: str,
|
|
186
|
+
graph_type: str = "instance",
|
|
187
|
+
overwrite: bool = False,
|
|
188
|
+
full_replace: bool = False,
|
|
189
|
+
raw_json: bool = False,
|
|
190
|
+
require_migration: bool = False,
|
|
191
|
+
allow_destructive_migration: bool = False,
|
|
192
|
+
) -> list[str]:
|
|
193
|
+
params = {
|
|
194
|
+
"author": author,
|
|
195
|
+
"message": message,
|
|
196
|
+
"graph_type": graph_type,
|
|
197
|
+
"overwrite": _bool(overwrite),
|
|
198
|
+
"full_replace": _bool(full_replace),
|
|
199
|
+
"raw_json": _bool(raw_json),
|
|
200
|
+
"require_migration": _bool(require_migration),
|
|
201
|
+
"allow_destructive_migration": _bool(allow_destructive_migration),
|
|
202
|
+
}
|
|
203
|
+
payload: Any = dict(documents) if isinstance(documents, Mapping) else [dict(d) for d in documents]
|
|
204
|
+
data = self._decode(await self._request("POST", f"/api/document/{self.resource_path}", params=params, json=payload))
|
|
205
|
+
return list(data or [])
|
|
206
|
+
|
|
207
|
+
async def replace_documents(
|
|
208
|
+
self,
|
|
209
|
+
documents: Mapping[str, Any] | Sequence[Mapping[str, Any]],
|
|
210
|
+
*,
|
|
211
|
+
author: str,
|
|
212
|
+
message: str,
|
|
213
|
+
graph_type: str = "instance",
|
|
214
|
+
create: bool = False,
|
|
215
|
+
require_migration: bool = False,
|
|
216
|
+
allow_destructive_migration: bool = False,
|
|
217
|
+
) -> Any:
|
|
218
|
+
params = {
|
|
219
|
+
"author": author,
|
|
220
|
+
"message": message,
|
|
221
|
+
"graph_type": graph_type,
|
|
222
|
+
"create": _bool(create),
|
|
223
|
+
"require_migration": _bool(require_migration),
|
|
224
|
+
"allow_destructive_migration": _bool(allow_destructive_migration),
|
|
225
|
+
}
|
|
226
|
+
payload: Any = dict(documents) if isinstance(documents, Mapping) else [dict(d) for d in documents]
|
|
227
|
+
return self._decode(await self._request("PUT", f"/api/document/{self.resource_path}", params=params, json=payload))
|
|
228
|
+
|
|
229
|
+
async def delete_documents(
|
|
230
|
+
self,
|
|
231
|
+
ids: str | Sequence[str],
|
|
232
|
+
*,
|
|
233
|
+
author: str,
|
|
234
|
+
message: str,
|
|
235
|
+
graph_type: str = "instance",
|
|
236
|
+
) -> Any:
|
|
237
|
+
params = {"author": author, "message": message, "graph_type": graph_type}
|
|
238
|
+
if isinstance(ids, str):
|
|
239
|
+
params["id"] = ids
|
|
240
|
+
response = await self._request("DELETE", f"/api/document/{self.resource_path}", params=params)
|
|
241
|
+
else:
|
|
242
|
+
response = await self._request("DELETE", f"/api/document/{self.resource_path}", params=params, json=list(ids))
|
|
243
|
+
return self._decode(response)
|
|
244
|
+
|
|
245
|
+
async def get_schema_documents(self) -> list[Json]:
|
|
246
|
+
return await self.get_documents(graph_type="schema")
|
|
247
|
+
|
|
248
|
+
async def bootstrap_schema(self, documents: Sequence[Mapping[str, Any]], *, author: str, message: str) -> list[str]:
|
|
249
|
+
return await self.insert_documents(
|
|
250
|
+
documents,
|
|
251
|
+
author=author,
|
|
252
|
+
message=message,
|
|
253
|
+
graph_type="schema",
|
|
254
|
+
full_replace=True,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def insert_schema_documents(self, documents: Sequence[Mapping[str, Any]], *, author: str, message: str) -> list[str]:
|
|
258
|
+
return await self.insert_documents(documents, author=author, message=message, graph_type="schema")
|
|
259
|
+
|
|
260
|
+
async def migrate(
|
|
261
|
+
self,
|
|
262
|
+
operations: Sequence[Mapping[str, Any]],
|
|
263
|
+
*,
|
|
264
|
+
author: str,
|
|
265
|
+
message: str,
|
|
266
|
+
dry_run: bool = False,
|
|
267
|
+
verbose: bool = False,
|
|
268
|
+
) -> Any:
|
|
269
|
+
payload = {"author": author, "message": message, "operations": [dict(op) for op in operations]}
|
|
270
|
+
params = {"dry_run": _bool(dry_run), "verbose": _bool(verbose)}
|
|
271
|
+
response = await self._request("POST", f"/api/migration/{self.resource_path}", params=params, json=payload)
|
|
272
|
+
return self._decode(response)
|
|
273
|
+
|
|
274
|
+
async def graphql(self, query: str, variables: Mapping[str, Any] | None = None) -> Any:
|
|
275
|
+
response = await self._request(
|
|
276
|
+
"POST",
|
|
277
|
+
f"/api/graphql/{self.database_path}",
|
|
278
|
+
json={"query": query, "variables": dict(variables or {})},
|
|
279
|
+
)
|
|
280
|
+
return self._decode(response)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _bool(value: bool) -> str:
|
|
284
|
+
return "true" if value else "false"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: terminusdb-async
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Small native async HTTP client for TerminusDB
|
|
5
|
+
Project-URL: Source, https://gitlab.com/ed-tech6370840/terminusdb-async
|
|
6
|
+
Project-URL: Issues, https://gitlab.com/ed-tech6370840/terminusdb-async/-/issues
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: httpx<1,>=0.27
|
|
10
|
+
|
|
11
|
+
# terminusdb-async
|
|
12
|
+
|
|
13
|
+
Native async TerminusDB HTTP client based on `httpx.AsyncClient`.
|
|
14
|
+
|
|
15
|
+
## Development
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv sync --group dev
|
|
19
|
+
uv run pytest
|
|
20
|
+
uv build --no-sources
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Dependencies are managed in `pyproject.toml` with uv.
|
|
24
|
+
|
|
25
|
+
## Release
|
|
26
|
+
|
|
27
|
+
PyPI publication uses GitLab OIDC Trusted Publishing. A version tag matching
|
|
28
|
+
`project.version` (for example `v0.1.0`) triggers `uv build` and `uv publish`.
|
|
29
|
+
|
|
30
|
+
PyPI publisher:
|
|
31
|
+
|
|
32
|
+
- Namespace: `ed-tech6370840`
|
|
33
|
+
- Repository: `terminusdb-async`
|
|
34
|
+
- Top-level pipeline: `.gitlab-ci.yml`
|
|
35
|
+
- Environment: `pypi`
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
terminusdb_async/__init__.py,sha256=l-LCsIa1iW9Jtwf91RK2EjN8AYeOogZm1w1A8QRSkkc,130
|
|
2
|
+
terminusdb_async/client.py,sha256=rvYFWyLwr7ft74Lu2lpnKSAUKNK6C019Gyvp4ZBi9OM,9969
|
|
3
|
+
terminusdb_async/errors.py,sha256=0forVNBC0XwLouXG53dd7p-NwyRyy0Eqz_hX_yd6RJI,229
|
|
4
|
+
terminusdb_async-0.1.0.dist-info/METADATA,sha256=0MRUv21rl3q0Dgz3eOJ0xWtUtfVnGc4il1STIVUI3-A,900
|
|
5
|
+
terminusdb_async-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
terminusdb_async-0.1.0.dist-info/top_level.txt,sha256=m-WwQInYYXW4LnW-9jjA_gYu82SSdymLQkxht341XHg,17
|
|
7
|
+
terminusdb_async-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
terminusdb_async
|