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