dataceen-client 0.0.1__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.
- dataceen_client/__init__.py +48 -0
- dataceen_client/config.py +105 -0
- dataceen_client/dataceen_client.py +230 -0
- dataceen_client/errors.py +36 -0
- dataceen_client/http_transport.py +137 -0
- dataceen_client/py.typed +0 -0
- dataceen_client/runtime/__init__.py +48 -0
- dataceen_client/runtime/aggregations.py +54 -0
- dataceen_client/runtime/fields.py +78 -0
- dataceen_client/runtime/filter.py +130 -0
- dataceen_client/runtime/operations.py +199 -0
- dataceen_client/runtime/order_by.py +29 -0
- dataceen_client/subscriptions/__init__.py +36 -0
- dataceen_client/subscriptions/_pb/__init__.py +15 -0
- dataceen_client/subscriptions/_pb/dataceenevent_pb2.py +52 -0
- dataceen_client/subscriptions/_pb/dataceenevent_pb2.pyi +130 -0
- dataceen_client/subscriptions/_pb/dataceenevent_pb2_grpc.py +100 -0
- dataceen_client/subscriptions/grpc_client.py +75 -0
- dataceen_client/subscriptions/subscription.py +75 -0
- dataceen_client/subscriptions/subscription_manager.py +367 -0
- dataceen_client/subscriptions/types.py +103 -0
- dataceen_client/token_provider.py +68 -0
- dataceen_client/types.py +62 -0
- dataceen_client-0.0.1.dist-info/METADATA +160 -0
- dataceen_client-0.0.1.dist-info/RECORD +28 -0
- dataceen_client-0.0.1.dist-info/WHEEL +5 -0
- dataceen_client-0.0.1.dist-info/licenses/LICENSE +16 -0
- dataceen_client-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""dataceen-client — Python port of the Dataceen GraphQL/subscription client.
|
|
2
|
+
|
|
3
|
+
Public surface (phase 1):
|
|
4
|
+
- DataceenConfig, DataceenClientOptions, load_config_from_env
|
|
5
|
+
- DataceenClient
|
|
6
|
+
- DataceenError
|
|
7
|
+
- Result types: FindResult, SearchResult, MutationResult, …
|
|
8
|
+
- Runtime helpers via `dataceen_client.runtime`
|
|
9
|
+
"""
|
|
10
|
+
from .config import (
|
|
11
|
+
DataceenClientOptions,
|
|
12
|
+
DataceenConfig,
|
|
13
|
+
Logger,
|
|
14
|
+
console_logger,
|
|
15
|
+
load_config_from_env,
|
|
16
|
+
silent_logger,
|
|
17
|
+
)
|
|
18
|
+
from .dataceen_client import DataceenClient
|
|
19
|
+
from .errors import DataceenError
|
|
20
|
+
from .types import (
|
|
21
|
+
FindByIdResult,
|
|
22
|
+
FindResult,
|
|
23
|
+
MutationResult,
|
|
24
|
+
QueryResult,
|
|
25
|
+
RequestResult,
|
|
26
|
+
SearchResult,
|
|
27
|
+
SingleMutationResult,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"DataceenClient",
|
|
32
|
+
"DataceenClientOptions",
|
|
33
|
+
"DataceenConfig",
|
|
34
|
+
"DataceenError",
|
|
35
|
+
"FindByIdResult",
|
|
36
|
+
"FindResult",
|
|
37
|
+
"Logger",
|
|
38
|
+
"MutationResult",
|
|
39
|
+
"QueryResult",
|
|
40
|
+
"RequestResult",
|
|
41
|
+
"SearchResult",
|
|
42
|
+
"SingleMutationResult",
|
|
43
|
+
"console_logger",
|
|
44
|
+
"load_config_from_env",
|
|
45
|
+
"silent_logger",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Configuration types and helpers.
|
|
2
|
+
|
|
3
|
+
Mirrors `DataceenClientTs/src/config.ts`.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class DataceenConfig:
|
|
15
|
+
"""Configuration for a DataceenClient instance.
|
|
16
|
+
|
|
17
|
+
Mirrors values under e.g. "AllClient" in the C# example's appsettings.json.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
tenant_id: str
|
|
21
|
+
client_id: str
|
|
22
|
+
client_secret: str
|
|
23
|
+
|
|
24
|
+
domain: str
|
|
25
|
+
model: str
|
|
26
|
+
scope: str
|
|
27
|
+
|
|
28
|
+
api_url: str
|
|
29
|
+
api_path: str
|
|
30
|
+
api_scope: str
|
|
31
|
+
|
|
32
|
+
subscription_url: str
|
|
33
|
+
subscription_scope: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Logger(Protocol):
|
|
37
|
+
def log(self, level: str, message: str, error: BaseException | None = None) -> None: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _StdlibLogger:
|
|
41
|
+
"""Default logger using stdlib `logging` with prefix `[dataceen-client]`."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, name: str = "dataceen-client") -> None:
|
|
44
|
+
self._log = logging.getLogger(name)
|
|
45
|
+
|
|
46
|
+
def log(self, level: str, message: str, error: BaseException | None = None) -> None:
|
|
47
|
+
lvl = {
|
|
48
|
+
"debug": logging.DEBUG,
|
|
49
|
+
"info": logging.INFO,
|
|
50
|
+
"warn": logging.WARNING,
|
|
51
|
+
"error": logging.ERROR,
|
|
52
|
+
}.get(level, logging.INFO)
|
|
53
|
+
if error is not None:
|
|
54
|
+
self._log.log(lvl, "%s", message, exc_info=error)
|
|
55
|
+
else:
|
|
56
|
+
self._log.log(lvl, "%s", message)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _SilentLogger:
|
|
60
|
+
def log(self, level: str, message: str, error: BaseException | None = None) -> None:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
console_logger: Logger = _StdlibLogger()
|
|
65
|
+
silent_logger: Logger = _SilentLogger()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class DataceenClientOptions:
|
|
70
|
+
logger: Logger | None = None
|
|
71
|
+
http_timeout_s: float = 60.0
|
|
72
|
+
max_transient_retries: int = 3
|
|
73
|
+
transient_retry_base_s: float = 0.2
|
|
74
|
+
slow_handler_threshold_ms: int = 500
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_config_from_env(
|
|
78
|
+
*, prefix: str = "DATACEEN_", config: dict[str, str] | None = None
|
|
79
|
+
) -> DataceenConfig:
|
|
80
|
+
"""Load `DataceenConfig` from environment variables (or a passed-in dict).
|
|
81
|
+
|
|
82
|
+
Variable names mirror `.env.example`: `DATACEEN_TENANT_ID`, `DATACEEN_API_URL`, …
|
|
83
|
+
"""
|
|
84
|
+
src = config if config is not None else os.environ
|
|
85
|
+
|
|
86
|
+
def get(key: str) -> str:
|
|
87
|
+
full = f"{prefix}{key}"
|
|
88
|
+
value = src.get(full)
|
|
89
|
+
if value is None or value == "":
|
|
90
|
+
raise ValueError(f"Missing required env var {full}")
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
return DataceenConfig(
|
|
94
|
+
tenant_id=get("TENANT_ID"),
|
|
95
|
+
client_id=get("CLIENT_ID"),
|
|
96
|
+
client_secret=get("CLIENT_SECRET"),
|
|
97
|
+
domain=get("DOMAIN"),
|
|
98
|
+
model=get("MODEL"),
|
|
99
|
+
scope=get("SCOPE"),
|
|
100
|
+
api_url=get("API_URL"),
|
|
101
|
+
api_path=get("API_PATH"),
|
|
102
|
+
api_scope=get("API_SCOPE"),
|
|
103
|
+
subscription_url=get("SUBSCRIPTION_URL"),
|
|
104
|
+
subscription_scope=get("SUBSCRIPTION_SCOPE"),
|
|
105
|
+
)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""DataceenClient — entry point for GraphQL queries and mutations.
|
|
2
|
+
|
|
3
|
+
Mirrors `DataceenClientTs/src/client/dataceen-client.ts`. Subscriptions are
|
|
4
|
+
deferred to phase 4 — the surface here is GraphQL only.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .config import (
|
|
12
|
+
DataceenClientOptions,
|
|
13
|
+
DataceenConfig,
|
|
14
|
+
Logger,
|
|
15
|
+
console_logger,
|
|
16
|
+
)
|
|
17
|
+
from .errors import DataceenError
|
|
18
|
+
from .http_transport import GqlRequest, HttpTransport, HttpTransportOptions
|
|
19
|
+
from .token_provider import TokenProvider, TokenProviderConfig, TokenProviderLike
|
|
20
|
+
|
|
21
|
+
if False: # TYPE_CHECKING — avoid hard import (subscriptions are optional)
|
|
22
|
+
from .subscriptions import Subscription
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DataceenClient:
|
|
26
|
+
"""Async low-level client. Codegen (phase 3) will layer typed per-entity
|
|
27
|
+
methods on top of this surface.
|
|
28
|
+
|
|
29
|
+
Use as an async context manager so the underlying httpx.AsyncClient is
|
|
30
|
+
closed cleanly:
|
|
31
|
+
|
|
32
|
+
async with DataceenClient(config) as client:
|
|
33
|
+
await client.execute_query(...)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
config: DataceenConfig,
|
|
39
|
+
options: DataceenClientOptions | None = None,
|
|
40
|
+
*,
|
|
41
|
+
token_provider: TokenProviderLike | None = None,
|
|
42
|
+
transport: HttpTransport | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
opts = options or DataceenClientOptions()
|
|
45
|
+
self.config = config
|
|
46
|
+
self._logger: Logger = opts.logger or console_logger
|
|
47
|
+
self._slow_handler_threshold_ms = opts.slow_handler_threshold_ms
|
|
48
|
+
|
|
49
|
+
if token_provider is None:
|
|
50
|
+
token_provider = TokenProvider(
|
|
51
|
+
TokenProviderConfig(
|
|
52
|
+
tenant_id=config.tenant_id,
|
|
53
|
+
client_id=config.client_id,
|
|
54
|
+
client_secret=config.client_secret,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
self._token_provider = token_provider
|
|
58
|
+
|
|
59
|
+
if transport is None:
|
|
60
|
+
api_path = (
|
|
61
|
+
f"{config.api_path}/{config.domain}/{config.model}/{config.scope}"
|
|
62
|
+
)
|
|
63
|
+
transport = HttpTransport(
|
|
64
|
+
HttpTransportOptions(
|
|
65
|
+
api_url=config.api_url,
|
|
66
|
+
api_path=api_path,
|
|
67
|
+
api_scope=config.api_scope,
|
|
68
|
+
token_provider=token_provider,
|
|
69
|
+
logger=self._logger,
|
|
70
|
+
http_timeout_s=opts.http_timeout_s,
|
|
71
|
+
max_transient_retries=opts.max_transient_retries,
|
|
72
|
+
transient_retry_base_s=opts.transient_retry_base_s,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
self._transport = transport
|
|
76
|
+
self._subscription_counter = 0
|
|
77
|
+
self._slow_handler_threshold_ms_value = opts.slow_handler_threshold_ms
|
|
78
|
+
|
|
79
|
+
def create_subscription(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
topics: list[str],
|
|
83
|
+
baseload_topics: list[str] | None = None,
|
|
84
|
+
ids: list[str] | None = None,
|
|
85
|
+
start_mode: str = "POSITION_BEGINNING",
|
|
86
|
+
position: str = "",
|
|
87
|
+
include_delta: bool = False,
|
|
88
|
+
include_complete: bool = False,
|
|
89
|
+
baseload_continue_object_type: str = "",
|
|
90
|
+
position_before_baseload: str = "",
|
|
91
|
+
domain: str | None = None,
|
|
92
|
+
model: str | None = None,
|
|
93
|
+
scope: str | None = None,
|
|
94
|
+
) -> "Subscription":
|
|
95
|
+
"""Create a Subscription. Fills in domain/model/scope from config when omitted."""
|
|
96
|
+
from .subscriptions import Subscription, SubscriptionRequest
|
|
97
|
+
|
|
98
|
+
self._subscription_counter += 1
|
|
99
|
+
request = SubscriptionRequest(
|
|
100
|
+
domain=domain or self.config.domain,
|
|
101
|
+
model=model or self.config.model,
|
|
102
|
+
scope=scope or self.config.scope,
|
|
103
|
+
topics=list(topics),
|
|
104
|
+
baseload_topics=list(baseload_topics or []),
|
|
105
|
+
ids=list(ids or []),
|
|
106
|
+
start_mode=start_mode, # type: ignore[arg-type]
|
|
107
|
+
position=position,
|
|
108
|
+
include_delta=include_delta,
|
|
109
|
+
include_complete=include_complete,
|
|
110
|
+
baseload_continue_object_type=baseload_continue_object_type,
|
|
111
|
+
position_before_baseload=position_before_baseload,
|
|
112
|
+
)
|
|
113
|
+
return Subscription(f"sub-{self._subscription_counter}", request)
|
|
114
|
+
|
|
115
|
+
async def subscribe(
|
|
116
|
+
self,
|
|
117
|
+
subscription: "Subscription",
|
|
118
|
+
**overrides: Any,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Open the gRPC stream and dispatch events.
|
|
121
|
+
|
|
122
|
+
Returns when the subscription is cancelled or the stream closes.
|
|
123
|
+
Raises if reconnects are exhausted.
|
|
124
|
+
"""
|
|
125
|
+
from .subscriptions import (
|
|
126
|
+
SubscriptionManagerOptions,
|
|
127
|
+
run_subscription,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
opts = SubscriptionManagerOptions(
|
|
131
|
+
subscription_url=self.config.subscription_url,
|
|
132
|
+
subscription_scope=self.config.subscription_scope,
|
|
133
|
+
token_provider=self._token_provider,
|
|
134
|
+
logger=self._logger,
|
|
135
|
+
slow_handler_threshold_ms=self._slow_handler_threshold_ms_value,
|
|
136
|
+
**overrides,
|
|
137
|
+
)
|
|
138
|
+
await run_subscription(subscription, opts)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def transport(self) -> HttpTransport:
|
|
142
|
+
return self._transport
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def token_provider(self) -> TokenProviderLike:
|
|
146
|
+
return self._token_provider
|
|
147
|
+
|
|
148
|
+
async def __aenter__(self) -> "DataceenClient":
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
152
|
+
await self.aclose()
|
|
153
|
+
|
|
154
|
+
async def aclose(self) -> None:
|
|
155
|
+
await self._transport.aclose()
|
|
156
|
+
|
|
157
|
+
async def execute_query(
|
|
158
|
+
self,
|
|
159
|
+
operation_name: str,
|
|
160
|
+
query: str,
|
|
161
|
+
*,
|
|
162
|
+
retry_on_transient: bool = True,
|
|
163
|
+
) -> Any:
|
|
164
|
+
"""Execute a GraphQL query. Default: retries on transient errors."""
|
|
165
|
+
req = GqlRequest(operation_name=operation_name, query=query)
|
|
166
|
+
text = await self._transport.post_graphql(req, retry_on_transient)
|
|
167
|
+
return _parse_graphql_response(text, req)
|
|
168
|
+
|
|
169
|
+
async def execute_mutation(
|
|
170
|
+
self,
|
|
171
|
+
operation_name: str,
|
|
172
|
+
query: str,
|
|
173
|
+
*,
|
|
174
|
+
retry_on_transient: bool = False,
|
|
175
|
+
) -> Any:
|
|
176
|
+
"""Execute a GraphQL mutation. Default: NO retry (not idempotent)."""
|
|
177
|
+
req = GqlRequest(operation_name=operation_name, query=query)
|
|
178
|
+
text = await self._transport.post_graphql(req, retry_on_transient)
|
|
179
|
+
return _parse_graphql_response(text, req)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_graphql_response(text: str, req: GqlRequest) -> Any:
|
|
183
|
+
"""Envelope-parse a GraphQL response; throw DataceenError on any failure."""
|
|
184
|
+
try:
|
|
185
|
+
envelope = json.loads(text)
|
|
186
|
+
except json.JSONDecodeError as ex:
|
|
187
|
+
raise DataceenError(
|
|
188
|
+
f"Failed to parse response as JSON: {ex}. Raw: {text[:500]}",
|
|
189
|
+
-1,
|
|
190
|
+
req.query,
|
|
191
|
+
cause=ex,
|
|
192
|
+
) from ex
|
|
193
|
+
|
|
194
|
+
if not isinstance(envelope, dict):
|
|
195
|
+
raise DataceenError(
|
|
196
|
+
f"Response root is not a JSON object. Raw: {text[:500]}",
|
|
197
|
+
-1,
|
|
198
|
+
req.query,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
errors = envelope.get("errors")
|
|
202
|
+
if errors:
|
|
203
|
+
messages = "; ".join(
|
|
204
|
+
e.get("message", "?") if isinstance(e, dict) else str(e) for e in errors
|
|
205
|
+
)
|
|
206
|
+
raise DataceenError(f"GraphQL errors: {messages}", -1, req.query)
|
|
207
|
+
|
|
208
|
+
data = envelope.get("data")
|
|
209
|
+
if not isinstance(data, dict):
|
|
210
|
+
raise DataceenError(
|
|
211
|
+
f"Response does not contain a `data` object. Raw: {text[:500]}",
|
|
212
|
+
-1,
|
|
213
|
+
req.query,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
payload = data.get(req.operation_name)
|
|
217
|
+
if payload is None:
|
|
218
|
+
raise DataceenError(
|
|
219
|
+
f"Response does not contain data.{req.operation_name}. Raw: {text[:500]}",
|
|
220
|
+
-1,
|
|
221
|
+
req.query,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if isinstance(payload, dict):
|
|
225
|
+
code = payload.get("ResponseCode")
|
|
226
|
+
if code is not None and code != 0:
|
|
227
|
+
message = payload.get("Message") or f"ResponseCode={code}"
|
|
228
|
+
raise DataceenError(message, code, req.query)
|
|
229
|
+
|
|
230
|
+
return payload
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""DataceenError — single error type for the whole client pipeline.
|
|
2
|
+
|
|
3
|
+
Mirrors C# `DataceenException` (HResult/SourceQuery) and TS `DataceenError`.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DataceenError(Exception):
|
|
9
|
+
"""Raised for any failure inside the Dataceen client pipeline.
|
|
10
|
+
|
|
11
|
+
Covers HTTP non-success responses, GraphQL errors, server-side
|
|
12
|
+
`ResponseCode != 0`, JSON parse failures, and transport errors that
|
|
13
|
+
exhausted retries.
|
|
14
|
+
|
|
15
|
+
`response_code` is the server-side ResponseCode if available, the negated
|
|
16
|
+
HTTP status as a fallback, or -1 for client-side failures.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
response_code: int,
|
|
23
|
+
source_query: str | None = None,
|
|
24
|
+
cause: BaseException | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.response_code = response_code
|
|
28
|
+
self.source_query = source_query
|
|
29
|
+
if cause is not None:
|
|
30
|
+
self.__cause__ = cause
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return (
|
|
34
|
+
f"DataceenError(response_code={self.response_code}, "
|
|
35
|
+
f"message={self.args[0]!r})"
|
|
36
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""HttpTransport — POST GraphQL with 401-refresh + transient retry.
|
|
2
|
+
|
|
3
|
+
Mirrors `DataceenClientTs/src/client/http-transport.ts`.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, Awaitable, Callable
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .config import Logger, console_logger
|
|
14
|
+
from .errors import DataceenError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .token_provider import TokenProviderLike
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class GqlRequest:
|
|
22
|
+
operation_name: str
|
|
23
|
+
query: str
|
|
24
|
+
|
|
25
|
+
def to_body(self) -> dict[str, str]:
|
|
26
|
+
# GraphQL HTTP wire format uses `operationName`, not `operation_name`.
|
|
27
|
+
return {"operationName": self.operation_name, "query": self.query}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class HttpTransportOptions:
|
|
32
|
+
api_url: str
|
|
33
|
+
api_path: str # full path including domain/model/scope suffix
|
|
34
|
+
api_scope: str
|
|
35
|
+
token_provider: "TokenProviderLike"
|
|
36
|
+
logger: Logger = console_logger
|
|
37
|
+
http_timeout_s: float = 60.0
|
|
38
|
+
max_transient_retries: int = 3
|
|
39
|
+
transient_retry_base_s: float = 0.2
|
|
40
|
+
|
|
41
|
+
# Allows tests to inject a fake httpx.AsyncClient. Default factory creates
|
|
42
|
+
# one with the configured timeout.
|
|
43
|
+
client_factory: Callable[[], httpx.AsyncClient] | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HttpTransport:
|
|
47
|
+
"""Owns the HTTP client and the resilience policy for GraphQL POSTs."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, opts: HttpTransportOptions) -> None:
|
|
50
|
+
self._opts = opts
|
|
51
|
+
self._endpoint = f"{opts.api_url}{opts.api_path}"
|
|
52
|
+
if opts.client_factory is not None:
|
|
53
|
+
self._client = opts.client_factory()
|
|
54
|
+
else:
|
|
55
|
+
self._client = httpx.AsyncClient(timeout=opts.http_timeout_s)
|
|
56
|
+
|
|
57
|
+
async def aclose(self) -> None:
|
|
58
|
+
await self._client.aclose()
|
|
59
|
+
|
|
60
|
+
async def post_graphql(self, req: GqlRequest, retry_on_transient: bool) -> str:
|
|
61
|
+
"""POST the GraphQL request and return the raw response body text."""
|
|
62
|
+
body = req.to_body()
|
|
63
|
+
max_transient = self._opts.max_transient_retries if retry_on_transient else 0
|
|
64
|
+
transient_attempt = 0
|
|
65
|
+
tried_auth_refresh = False
|
|
66
|
+
delay_s = self._opts.transient_retry_base_s
|
|
67
|
+
|
|
68
|
+
while True:
|
|
69
|
+
token = await self._opts.token_provider.get_access_token(
|
|
70
|
+
[self._opts.api_scope], tried_auth_refresh
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
response = await self._client.post(
|
|
75
|
+
self._endpoint,
|
|
76
|
+
json=body,
|
|
77
|
+
headers={
|
|
78
|
+
"Authorization": f"Bearer {token}",
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
except (httpx.TransportError, httpx.TimeoutException) as ex:
|
|
83
|
+
if transient_attempt < max_transient:
|
|
84
|
+
transient_attempt += 1
|
|
85
|
+
self._opts.logger.log(
|
|
86
|
+
"warn",
|
|
87
|
+
f"Transient HTTP error (attempt {transient_attempt}/"
|
|
88
|
+
f"{max_transient}): {ex}. Retrying in "
|
|
89
|
+
f"{int(delay_s * 1000)}ms.",
|
|
90
|
+
)
|
|
91
|
+
await asyncio.sleep(delay_s)
|
|
92
|
+
delay_s *= 2
|
|
93
|
+
continue
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
status = response.status_code
|
|
97
|
+
|
|
98
|
+
if status == 401 and not tried_auth_refresh:
|
|
99
|
+
tried_auth_refresh = True
|
|
100
|
+
self._opts.logger.log(
|
|
101
|
+
"warn",
|
|
102
|
+
"401 Unauthorized — forcing token refresh and retrying once.",
|
|
103
|
+
)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
if (
|
|
107
|
+
retry_on_transient
|
|
108
|
+
and not _is_ok(status)
|
|
109
|
+
and _is_transient(status)
|
|
110
|
+
and transient_attempt < max_transient
|
|
111
|
+
):
|
|
112
|
+
transient_attempt += 1
|
|
113
|
+
self._opts.logger.log(
|
|
114
|
+
"warn",
|
|
115
|
+
f"Transient HTTP status {status} (attempt {transient_attempt}/"
|
|
116
|
+
f"{max_transient}). Retrying in {int(delay_s * 1000)}ms.",
|
|
117
|
+
)
|
|
118
|
+
await asyncio.sleep(delay_s)
|
|
119
|
+
delay_s *= 2
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
text = response.text
|
|
123
|
+
if not _is_ok(status):
|
|
124
|
+
raise DataceenError(
|
|
125
|
+
f"HTTP {status}: {text[:500]}",
|
|
126
|
+
-status,
|
|
127
|
+
req.query,
|
|
128
|
+
)
|
|
129
|
+
return text
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_ok(status: int) -> bool:
|
|
133
|
+
return 200 <= status < 300
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _is_transient(status: int) -> bool:
|
|
137
|
+
return status == 408 or status == 429 or status >= 500
|
dataceen_client/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Runtime helpers shared by all generated entity clients."""
|
|
2
|
+
from .aggregations import (
|
|
3
|
+
DEFAULT_AGGREGATION_DEPTH,
|
|
4
|
+
aggregation_fragment,
|
|
5
|
+
)
|
|
6
|
+
from .fields import collect_fields
|
|
7
|
+
from .filter import (
|
|
8
|
+
BooleanOps,
|
|
9
|
+
DateOps,
|
|
10
|
+
NumberOps,
|
|
11
|
+
StringOps,
|
|
12
|
+
filter_to_graphql,
|
|
13
|
+
)
|
|
14
|
+
from .operations import (
|
|
15
|
+
RelationRef,
|
|
16
|
+
do_create,
|
|
17
|
+
do_create_bulk,
|
|
18
|
+
do_create_relation,
|
|
19
|
+
do_create_relation_bulk,
|
|
20
|
+
do_delete,
|
|
21
|
+
do_find,
|
|
22
|
+
do_search,
|
|
23
|
+
do_update,
|
|
24
|
+
do_update_bulk,
|
|
25
|
+
)
|
|
26
|
+
from .order_by import order_by_to_graphql
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"BooleanOps",
|
|
30
|
+
"DEFAULT_AGGREGATION_DEPTH",
|
|
31
|
+
"DateOps",
|
|
32
|
+
"NumberOps",
|
|
33
|
+
"StringOps",
|
|
34
|
+
"aggregation_fragment",
|
|
35
|
+
"collect_fields",
|
|
36
|
+
"filter_to_graphql",
|
|
37
|
+
"order_by_to_graphql",
|
|
38
|
+
"RelationRef",
|
|
39
|
+
"do_create",
|
|
40
|
+
"do_create_bulk",
|
|
41
|
+
"do_create_relation",
|
|
42
|
+
"do_create_relation_bulk",
|
|
43
|
+
"do_delete",
|
|
44
|
+
"do_find",
|
|
45
|
+
"do_search",
|
|
46
|
+
"do_update",
|
|
47
|
+
"do_update_bulk",
|
|
48
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Aggregation types and result-fragment expansion.
|
|
2
|
+
|
|
3
|
+
Mirrors `DataceenClientTs/src/runtime/aggregations.ts`. The server's
|
|
4
|
+
introspection does not advertise the Aggregations field on
|
|
5
|
+
`<Entity>SearchResult`, but it returns it when the selection asks for it.
|
|
6
|
+
We expand the fragment at a fixed depth so callers can nest up to N levels.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Literal, NotRequired, TypedDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
AggregationType = Literal["Bucket_Terms", "Metrics_Sum"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AggregationDetails(TypedDict, total=False):
|
|
17
|
+
"""Per-field aggregation request shape. Matches server's
|
|
18
|
+
`Search<Entity>AggregationDetails`."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
size: int
|
|
22
|
+
type: AggregationType
|
|
23
|
+
aggregations: list[Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Bucket(TypedDict):
|
|
27
|
+
Key: Any
|
|
28
|
+
Count: int
|
|
29
|
+
Aggregations: NotRequired[list["Aggregation"]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Aggregation(TypedDict):
|
|
33
|
+
Name: str
|
|
34
|
+
SumOtherDocCount: NotRequired[int]
|
|
35
|
+
DocCountErrorUpperBound: NotRequired[int]
|
|
36
|
+
Buckets: list[Bucket]
|
|
37
|
+
Value: NotRequired[float]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
DEFAULT_AGGREGATION_DEPTH = 3
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def aggregation_fragment(depth: int) -> str:
|
|
44
|
+
"""Build a GraphQL selection fragment for the Aggregations result, expanded
|
|
45
|
+
to the given nesting depth. Deeper buckets are returned by the server but
|
|
46
|
+
silently dropped by the selection."""
|
|
47
|
+
scalar_fields = "Name SumOtherDocCount DocCountErrorUpperBound Value"
|
|
48
|
+
fragment = f"Aggregations {{ {scalar_fields} Buckets {{ Key Count }} }}"
|
|
49
|
+
for _ in range(depth - 1):
|
|
50
|
+
fragment = (
|
|
51
|
+
f"Aggregations {{ {scalar_fields} "
|
|
52
|
+
f"Buckets {{ Key Count {fragment} }} }}"
|
|
53
|
+
)
|
|
54
|
+
return fragment
|