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.
@@ -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
+ ]
@@ -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