terminusdb-async 0.1.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,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,25 @@
1
+ # terminusdb-async
2
+
3
+ Native async TerminusDB HTTP client based on `httpx.AsyncClient`.
4
+
5
+ ## Development
6
+
7
+ ```bash
8
+ uv sync --group dev
9
+ uv run pytest
10
+ uv build --no-sources
11
+ ```
12
+
13
+ Dependencies are managed in `pyproject.toml` with uv.
14
+
15
+ ## Release
16
+
17
+ PyPI publication uses GitLab OIDC Trusted Publishing. A version tag matching
18
+ `project.version` (for example `v0.1.0`) triggers `uv build` and `uv publish`.
19
+
20
+ PyPI publisher:
21
+
22
+ - Namespace: `ed-tech6370840`
23
+ - Repository: `terminusdb-async`
24
+ - Top-level pipeline: `.gitlab-ci.yml`
25
+ - Environment: `pypi`
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "terminusdb-async"
7
+ version = "0.1.0"
8
+ description = "Small native async HTTP client for TerminusDB"
9
+ requires-python = ">=3.11"
10
+ readme = "README.md"
11
+ dependencies = [
12
+ "httpx>=0.27,<1",
13
+ ]
14
+
15
+ [project.urls]
16
+ Source = "https://gitlab.com/ed-tech6370840/terminusdb-async"
17
+ Issues = "https://gitlab.com/ed-tech6370840/terminusdb-async/-/issues"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=8",
22
+ "pytest-asyncio>=0.23",
23
+ ]
24
+
25
+ [tool.uv]
26
+ package = true
27
+ trusted-publishing = "always"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .client import AsyncTerminusClient
2
+ from .errors import TerminusDBError
3
+
4
+ __all__ = ["AsyncTerminusClient", "TerminusDBError"]
@@ -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,5 @@
1
+ class TerminusDBError(RuntimeError):
2
+ def __init__(self, message: str, *, status_code: int | None = None, body: object = None):
3
+ super().__init__(message)
4
+ self.status_code = status_code
5
+ self.body = body
@@ -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,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/terminusdb_async/__init__.py
4
+ src/terminusdb_async/client.py
5
+ src/terminusdb_async/errors.py
6
+ src/terminusdb_async.egg-info/PKG-INFO
7
+ src/terminusdb_async.egg-info/SOURCES.txt
8
+ src/terminusdb_async.egg-info/dependency_links.txt
9
+ src/terminusdb_async.egg-info/requires.txt
10
+ src/terminusdb_async.egg-info/top_level.txt
11
+ tests/test_async_client.py
@@ -0,0 +1 @@
1
+ terminusdb_async
@@ -0,0 +1,46 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from terminusdb_async import AsyncTerminusClient
7
+
8
+
9
+ @pytest.mark.asyncio
10
+ async def test_get_document_and_branch_path():
11
+ async def handler(request: httpx.Request) -> httpx.Response:
12
+ assert request.url.path == "/api/document/admin/edtech/local/branch/main"
13
+ assert request.url.params["id"] == "Discipline/M1"
14
+ return httpx.Response(200, json={"@id": "Discipline/M1", "@type": "Discipline", "name": "Math"})
15
+
16
+ transport = httpx.MockTransport(handler)
17
+ http = httpx.AsyncClient(base_url="http://test", transport=transport)
18
+ client = AsyncTerminusClient("http://test", organization="admin", database="edtech", http_client=http)
19
+ doc = await client.get_document("Discipline/M1")
20
+ assert doc["name"] == "Math"
21
+ await http.aclose()
22
+
23
+
24
+ @pytest.mark.asyncio
25
+ async def test_migration_payload():
26
+ seen = {}
27
+
28
+ async def handler(request: httpx.Request) -> httpx.Response:
29
+ seen["path"] = request.url.path
30
+ seen["params"] = dict(request.url.params)
31
+ seen["body"] = json.loads(request.content)
32
+ return httpx.Response(200, json={"@type": "api:MigrationResponse", "api:status": "api:success"})
33
+
34
+ transport = httpx.MockTransport(handler)
35
+ http = httpx.AsyncClient(base_url="http://test", transport=transport)
36
+ client = AsyncTerminusClient("http://test", organization="admin", database="edtech", http_client=http)
37
+ await client.migrate(
38
+ [{"@type": "ExpandEnum", "enum": "Level", "values": ["master"]}],
39
+ author="bot",
40
+ message="migrate",
41
+ dry_run=True,
42
+ )
43
+ assert seen["path"] == "/api/migration/admin/edtech/local/branch/main"
44
+ assert seen["params"]["dry_run"] == "true"
45
+ assert seen["body"]["author"] == "bot"
46
+ await http.aclose()