graphos-client 0.14.12__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.
- graphos_client/__init__.py +226 -0
- graphos_client/_auth.py +161 -0
- graphos_client/_client.py +2550 -0
- graphos_client/_drift.py +194 -0
- graphos_client/_errors.py +113 -0
- graphos_client/_models.py +1109 -0
- graphos_client/_pool.py +206 -0
- graphos_client/py.typed +0 -0
- graphos_client-0.14.12.dist-info/METADATA +227 -0
- graphos_client-0.14.12.dist-info/RECORD +14 -0
- graphos_client-0.14.12.dist-info/WHEEL +4 -0
- graphos_client-0.14.12.dist-info/licenses/LICENSE +201 -0
- graphos_client-0.14.12.dist-info/licenses/NOTICE +12 -0
- graphos_client-0.14.12.dist-info/licenses/THIRD_PARTY_NOTICES.md +133 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""graphos-client — async HTTP client for the graphos graph service.
|
|
2
|
+
|
|
3
|
+
- ``GraphosClient``: async client for one tenant; typed models mirror the
|
|
4
|
+
server's contracts.
|
|
5
|
+
- ``GraphosPool`` + ``TenantSchema``: one registered client per tenant, with
|
|
6
|
+
re-registration and a single retry when the server has lost a label.
|
|
7
|
+
- ``GraphosClient.ensure_tenant``: registration that first reports what was
|
|
8
|
+
changed in the tenant elsewhere; ``SchemaDriftError`` when told to fail on it.
|
|
9
|
+
- ``SchemaManagerNotApplied``: a registration naming a schema manager that the
|
|
10
|
+
server did not apply (an older server).
|
|
11
|
+
|
|
12
|
+
The package is async-only (``asyncio`` + ``httpx``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from graphos_client._auth import BearerAuth, PasswordAuth
|
|
16
|
+
from graphos_client._client import (
|
|
17
|
+
GraphosClient,
|
|
18
|
+
GraphosError,
|
|
19
|
+
GraphosNotReady,
|
|
20
|
+
read_dataset_header,
|
|
21
|
+
)
|
|
22
|
+
from graphos_client._errors import SchemaDriftError, SchemaManagerNotApplied
|
|
23
|
+
from graphos_client._models import (
|
|
24
|
+
AggregateRow,
|
|
25
|
+
AnalysisFreshness,
|
|
26
|
+
BulkEdgesResponse,
|
|
27
|
+
BulkNodesResponse,
|
|
28
|
+
ChangeItem,
|
|
29
|
+
ChangesHistory,
|
|
30
|
+
ChangesPage,
|
|
31
|
+
CommunitiesResponse,
|
|
32
|
+
CommunityHub,
|
|
33
|
+
CommunityLink,
|
|
34
|
+
CommunityMap,
|
|
35
|
+
CommunityNode,
|
|
36
|
+
CommunityRow,
|
|
37
|
+
CommunitySummary,
|
|
38
|
+
ComputeMetricsResponse,
|
|
39
|
+
CsvColumnPlan,
|
|
40
|
+
CsvImportResponse,
|
|
41
|
+
CsvIssue,
|
|
42
|
+
CypherResult,
|
|
43
|
+
DeleteByPropertyResponse,
|
|
44
|
+
DeleteEdgeResponse,
|
|
45
|
+
DeleteNodeResponse,
|
|
46
|
+
DeleteNodesResponse,
|
|
47
|
+
EdgeCheckItem,
|
|
48
|
+
EdgeCheckResult,
|
|
49
|
+
EdgeEndpoint,
|
|
50
|
+
EdgeGetResponse,
|
|
51
|
+
EdgeHit,
|
|
52
|
+
EdgesAggregateResponse,
|
|
53
|
+
EdgesCheckResponse,
|
|
54
|
+
EdgesReconcileResponse,
|
|
55
|
+
EdgesScanItem,
|
|
56
|
+
EdgesScanResponse,
|
|
57
|
+
EdgesUpsertResponse,
|
|
58
|
+
EdgeUpsert,
|
|
59
|
+
ExpandResponse,
|
|
60
|
+
GraphMap,
|
|
61
|
+
GraphologyEdge,
|
|
62
|
+
GraphologyExport,
|
|
63
|
+
GraphologyNode,
|
|
64
|
+
GraphPath,
|
|
65
|
+
HubNode,
|
|
66
|
+
HubsResponse,
|
|
67
|
+
InducedEdgesResponse,
|
|
68
|
+
Job,
|
|
69
|
+
JobError,
|
|
70
|
+
JobProgress,
|
|
71
|
+
LabelCheck,
|
|
72
|
+
LabelCount,
|
|
73
|
+
MapLabel,
|
|
74
|
+
MapLink,
|
|
75
|
+
MapLinkType,
|
|
76
|
+
Me,
|
|
77
|
+
Neighbor,
|
|
78
|
+
NeighborsResponse,
|
|
79
|
+
NodeAggregateRow,
|
|
80
|
+
NodeHit,
|
|
81
|
+
NodeRef,
|
|
82
|
+
NodesAggregateResponse,
|
|
83
|
+
NodesScanItem,
|
|
84
|
+
NodesScanResponse,
|
|
85
|
+
NodesUpsertResponse,
|
|
86
|
+
NodeUpsert,
|
|
87
|
+
PathEdge,
|
|
88
|
+
PathHit,
|
|
89
|
+
PathNode,
|
|
90
|
+
PathsResponse,
|
|
91
|
+
PropertiesCheckResponse,
|
|
92
|
+
PropertyDefinitionsResponse,
|
|
93
|
+
PropertyPolicy,
|
|
94
|
+
RecomputeCommunitiesResponse,
|
|
95
|
+
ReconcileEdge,
|
|
96
|
+
SampleEdgeHit,
|
|
97
|
+
SampleNodeHit,
|
|
98
|
+
SampleSubgraphResponse,
|
|
99
|
+
SchemaDropResult,
|
|
100
|
+
SchemaRename,
|
|
101
|
+
SchemaRenameResult,
|
|
102
|
+
SchemaSummaryResponse,
|
|
103
|
+
SearchHit,
|
|
104
|
+
SearchResponse,
|
|
105
|
+
SummaryDeprecated,
|
|
106
|
+
SummaryLabel,
|
|
107
|
+
SummaryLink,
|
|
108
|
+
SummaryProperty,
|
|
109
|
+
SummarySavedQuery,
|
|
110
|
+
SummarySearchIndex,
|
|
111
|
+
SummaryValueCount,
|
|
112
|
+
TenantInfo,
|
|
113
|
+
TenantSchemaInfo,
|
|
114
|
+
TenantStatsResponse,
|
|
115
|
+
UnregisteredTenant,
|
|
116
|
+
)
|
|
117
|
+
from graphos_client._pool import GraphosPool, TenantSchema, is_schema_drift
|
|
118
|
+
|
|
119
|
+
__version__ = "0.14.12"
|
|
120
|
+
|
|
121
|
+
__all__ = [
|
|
122
|
+
"AnalysisFreshness",
|
|
123
|
+
"CommunityHub",
|
|
124
|
+
"CommunityLink",
|
|
125
|
+
"CommunityMap",
|
|
126
|
+
"CommunityNode",
|
|
127
|
+
"GraphMap",
|
|
128
|
+
"HubNode",
|
|
129
|
+
"HubsResponse",
|
|
130
|
+
"MapLabel",
|
|
131
|
+
"MapLink",
|
|
132
|
+
"MapLinkType",
|
|
133
|
+
"GraphosClient",
|
|
134
|
+
"GraphosError",
|
|
135
|
+
"read_dataset_header",
|
|
136
|
+
"BearerAuth",
|
|
137
|
+
"PasswordAuth",
|
|
138
|
+
"GraphosNotReady",
|
|
139
|
+
"SchemaDriftError",
|
|
140
|
+
"SchemaManagerNotApplied",
|
|
141
|
+
"GraphosPool",
|
|
142
|
+
"TenantSchema",
|
|
143
|
+
"is_schema_drift",
|
|
144
|
+
# Models
|
|
145
|
+
"AggregateRow",
|
|
146
|
+
"BulkEdgesResponse",
|
|
147
|
+
"CsvColumnPlan",
|
|
148
|
+
"CsvImportResponse",
|
|
149
|
+
"CsvIssue",
|
|
150
|
+
"BulkNodesResponse",
|
|
151
|
+
"ChangeItem",
|
|
152
|
+
"ChangesHistory",
|
|
153
|
+
"ChangesPage",
|
|
154
|
+
"CommunitiesResponse",
|
|
155
|
+
"CommunityRow",
|
|
156
|
+
"CommunitySummary",
|
|
157
|
+
"DeleteByPropertyResponse",
|
|
158
|
+
"DeleteEdgeResponse",
|
|
159
|
+
"DeleteNodeResponse",
|
|
160
|
+
"DeleteNodesResponse",
|
|
161
|
+
"EdgeCheckItem",
|
|
162
|
+
"EdgesReconcileResponse",
|
|
163
|
+
"SchemaDropResult",
|
|
164
|
+
"SchemaRename",
|
|
165
|
+
"SchemaRenameResult",
|
|
166
|
+
"SchemaSummaryResponse",
|
|
167
|
+
"SummaryDeprecated",
|
|
168
|
+
"SummaryLabel",
|
|
169
|
+
"SummaryLink",
|
|
170
|
+
"SummaryProperty",
|
|
171
|
+
"SummarySavedQuery",
|
|
172
|
+
"SummarySearchIndex",
|
|
173
|
+
"SummaryValueCount",
|
|
174
|
+
"ReconcileEdge",
|
|
175
|
+
"EdgeCheckResult",
|
|
176
|
+
"EdgeGetResponse",
|
|
177
|
+
"EdgesCheckResponse",
|
|
178
|
+
"EdgeHit",
|
|
179
|
+
"EdgeUpsert",
|
|
180
|
+
"EdgesAggregateResponse",
|
|
181
|
+
"EdgesUpsertResponse",
|
|
182
|
+
"ExpandResponse",
|
|
183
|
+
"GraphPath",
|
|
184
|
+
"GraphologyEdge",
|
|
185
|
+
"GraphologyExport",
|
|
186
|
+
"GraphologyNode",
|
|
187
|
+
"InducedEdgesResponse",
|
|
188
|
+
"LabelCount",
|
|
189
|
+
"Neighbor",
|
|
190
|
+
"NeighborsResponse",
|
|
191
|
+
"NodeAggregateRow",
|
|
192
|
+
"NodeHit",
|
|
193
|
+
"NodeRef",
|
|
194
|
+
"NodeUpsert",
|
|
195
|
+
"PathEdge",
|
|
196
|
+
"PathNode",
|
|
197
|
+
"PathsResponse",
|
|
198
|
+
"EdgesScanItem",
|
|
199
|
+
"EdgesScanResponse",
|
|
200
|
+
"LabelCheck",
|
|
201
|
+
"NodesAggregateResponse",
|
|
202
|
+
"PropertiesCheckResponse",
|
|
203
|
+
"PropertyDefinitionsResponse",
|
|
204
|
+
"PropertyPolicy",
|
|
205
|
+
"NodesScanItem",
|
|
206
|
+
"NodesScanResponse",
|
|
207
|
+
"NodesUpsertResponse",
|
|
208
|
+
"Job",
|
|
209
|
+
"JobError",
|
|
210
|
+
"JobProgress",
|
|
211
|
+
"PathHit",
|
|
212
|
+
"ComputeMetricsResponse",
|
|
213
|
+
"RecomputeCommunitiesResponse",
|
|
214
|
+
"SampleEdgeHit",
|
|
215
|
+
"SampleNodeHit",
|
|
216
|
+
"SearchHit",
|
|
217
|
+
"SampleSubgraphResponse",
|
|
218
|
+
"SearchResponse",
|
|
219
|
+
"Me",
|
|
220
|
+
"TenantInfo",
|
|
221
|
+
"UnregisteredTenant",
|
|
222
|
+
"CypherResult",
|
|
223
|
+
"TenantSchemaInfo",
|
|
224
|
+
"EdgeEndpoint",
|
|
225
|
+
"TenantStatsResponse",
|
|
226
|
+
]
|
graphos_client/_auth.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Signing in to graphos: short-lived access tokens, renewed by the client.
|
|
2
|
+
|
|
3
|
+
graphos issues an access token (valid ``expires_in`` seconds, 15 minutes by
|
|
4
|
+
default) for a user name and password (``POST /v1/auth/login``). A service
|
|
5
|
+
account's password is its secret. :class:`PasswordAuth` signs in before the
|
|
6
|
+
first request, signs in again when 80% of the token's life has passed, and,
|
|
7
|
+
should a request still get ``401`` (the token was revoked, the server
|
|
8
|
+
restarted with a new key), signs in once more and sends the request again.
|
|
9
|
+
That retry is safe for writes: graphos checks the token before doing
|
|
10
|
+
anything, so a ``401`` request changed nothing.
|
|
11
|
+
|
|
12
|
+
The credentials are kept, not just the token, so renewal never needs a
|
|
13
|
+
person. Nothing is written to disk.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from graphos_client._errors import GraphosError
|
|
26
|
+
|
|
27
|
+
USER_ENV = "GRAPHOS_USER"
|
|
28
|
+
PASSWORD_ENV = "GRAPHOS_PASSWORD" # noqa: S105 — the variable's name, not a password
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PasswordAuth(httpx.Auth):
|
|
32
|
+
"""A graphos user name and password (or service account and secret).
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
user: the user or service account name.
|
|
36
|
+
password: its password or secret.
|
|
37
|
+
renew_after: the fraction of a token's lifetime after which it is
|
|
38
|
+
renewed before use.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, user: str, password: str, *, renew_after: float = 0.8) -> None:
|
|
42
|
+
if not user or not password:
|
|
43
|
+
raise ValueError("PasswordAuth needs a user and a password")
|
|
44
|
+
self.user = user
|
|
45
|
+
self._password = password
|
|
46
|
+
self._renew_after = renew_after
|
|
47
|
+
self._login_url: str | None = None
|
|
48
|
+
self._token: str | None = None
|
|
49
|
+
self._renew_at = 0.0
|
|
50
|
+
self._lock: asyncio.Lock | None = None
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str: # never the password or the token
|
|
53
|
+
return f"PasswordAuth(user={self.user!r})"
|
|
54
|
+
|
|
55
|
+
def bind(self, base_url: str) -> PasswordAuth:
|
|
56
|
+
"""Where to sign in: the client's base URL."""
|
|
57
|
+
self._login_url = f"{base_url.rstrip('/')}/v1/auth/login"
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def _stale(self) -> bool:
|
|
61
|
+
return self._token is None or time.monotonic() >= self._renew_at
|
|
62
|
+
|
|
63
|
+
def _login_request(self, request: httpx.Request) -> httpx.Request:
|
|
64
|
+
url = self._login_url or str(request.url.copy_with(path="/v1/auth/login", query=None))
|
|
65
|
+
return httpx.Request(
|
|
66
|
+
"POST", url, json={"user": self.user, "password": self._password},
|
|
67
|
+
headers={"Accept": "application/json"},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def _store(self, response: httpx.Response) -> None:
|
|
71
|
+
await response.aread()
|
|
72
|
+
if response.status_code != httpx.codes.OK:
|
|
73
|
+
try:
|
|
74
|
+
detail = response.json().get("detail", response.text)
|
|
75
|
+
except ValueError:
|
|
76
|
+
detail = response.text
|
|
77
|
+
self._token = None
|
|
78
|
+
raise GraphosError(response.status_code, f"sign-in failed: {detail}", "POST",
|
|
79
|
+
str(response.request.url))
|
|
80
|
+
body = response.json()
|
|
81
|
+
self._token = body["access_token"]
|
|
82
|
+
self._renew_at = time.monotonic() + float(body["expires_in"]) * self._renew_after
|
|
83
|
+
|
|
84
|
+
async def async_auth_flow(
|
|
85
|
+
self, request: httpx.Request,
|
|
86
|
+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
87
|
+
if self._lock is None:
|
|
88
|
+
self._lock = asyncio.Lock()
|
|
89
|
+
if self._stale():
|
|
90
|
+
# One sign-in at a time: concurrent requests wait for it.
|
|
91
|
+
async with self._lock:
|
|
92
|
+
if self._stale():
|
|
93
|
+
await self._store((yield self._login_request(request)))
|
|
94
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
95
|
+
response = yield request
|
|
96
|
+
if response.status_code == httpx.codes.UNAUTHORIZED:
|
|
97
|
+
# Revoked, or signed by a key the server no longer has: sign in
|
|
98
|
+
# again once and send the request again.
|
|
99
|
+
self._token = None
|
|
100
|
+
async with self._lock:
|
|
101
|
+
if self._stale():
|
|
102
|
+
await self._store((yield self._login_request(request)))
|
|
103
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
104
|
+
yield request
|
|
105
|
+
|
|
106
|
+
def sync_auth_flow(self, request: httpx.Request): # noqa: ANN201, ARG002
|
|
107
|
+
raise RuntimeError("PasswordAuth works with the async client only")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
TokenSource = Callable[[], Awaitable[tuple[str, float]]]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class BearerAuth(httpx.Auth):
|
|
114
|
+
"""Tokens a program obtains itself, typically from the identity provider
|
|
115
|
+
graphos trusts (client-credentials grant).
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
source: an async callable answering ``(access token, seconds valid)``;
|
|
119
|
+
called before the first request, again at ``renew_after`` of the
|
|
120
|
+
token's life, and once more on a ``401``.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, source: TokenSource, *, renew_after: float = 0.8) -> None:
|
|
124
|
+
self._source = source
|
|
125
|
+
self._renew_after = renew_after
|
|
126
|
+
self._token: str | None = None
|
|
127
|
+
self._renew_at = 0.0
|
|
128
|
+
self._lock: asyncio.Lock | None = None
|
|
129
|
+
|
|
130
|
+
def __repr__(self) -> str:
|
|
131
|
+
return "BearerAuth()"
|
|
132
|
+
|
|
133
|
+
async def _fresh(self, *, force: bool = False) -> str:
|
|
134
|
+
if self._lock is None:
|
|
135
|
+
self._lock = asyncio.Lock()
|
|
136
|
+
async with self._lock:
|
|
137
|
+
if force or self._token is None or time.monotonic() >= self._renew_at:
|
|
138
|
+
token, valid_s = await self._source()
|
|
139
|
+
self._token = token
|
|
140
|
+
self._renew_at = time.monotonic() + float(valid_s) * self._renew_after
|
|
141
|
+
return self._token
|
|
142
|
+
|
|
143
|
+
async def async_auth_flow(
|
|
144
|
+
self, request: httpx.Request,
|
|
145
|
+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
146
|
+
request.headers["Authorization"] = f"Bearer {await self._fresh()}"
|
|
147
|
+
response = yield request
|
|
148
|
+
if response.status_code == httpx.codes.UNAUTHORIZED:
|
|
149
|
+
request.headers["Authorization"] = f"Bearer {await self._fresh(force=True)}"
|
|
150
|
+
yield request
|
|
151
|
+
|
|
152
|
+
def sync_auth_flow(self, request: httpx.Request): # noqa: ANN201, ARG002
|
|
153
|
+
raise RuntimeError("BearerAuth works with the async client only")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def from_environment() -> PasswordAuth | None:
|
|
157
|
+
"""``PasswordAuth`` from ``GRAPHOS_USER`` / ``GRAPHOS_PASSWORD``, if both are set."""
|
|
158
|
+
user, password = os.environ.get(USER_ENV), os.environ.get(PASSWORD_ENV)
|
|
159
|
+
if user and password:
|
|
160
|
+
return PasswordAuth(user, password)
|
|
161
|
+
return None
|