redblack-python-sdk 0.1.0__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.
redblack/client.py ADDED
@@ -0,0 +1,512 @@
1
+ """Public synchronous RedBlack client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from collections.abc import Mapping
8
+ from decimal import Decimal
9
+ from enum import Enum
10
+ from types import TracebackType
11
+ from typing import Any, Literal, Self
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+
16
+ from ._generated.contract import (
17
+ encode_request,
18
+ get_operation_spec,
19
+ parse_response,
20
+ )
21
+ from ._version import SUPPORTED_API_VERSION
22
+ from .auth import TokenProvider
23
+ from .config import (
24
+ Environment,
25
+ PasswordCredentials,
26
+ RedBlackConfig,
27
+ RetryConfig,
28
+ TimeoutConfig,
29
+ VerifyValue,
30
+ )
31
+ from .errors import ConfigurationError, ResponseDecodeError
32
+ from .raw import RawClient
33
+ from .resources import (
34
+ AccountsResource,
35
+ CashActionsResource,
36
+ EquivalentsResource,
37
+ HouseholdsResource,
38
+ OrdersResource,
39
+ RestrictionsResource,
40
+ ReviewSetsResource,
41
+ SecuritiesResource,
42
+ TasksResource,
43
+ UsersResource,
44
+ )
45
+ from .response import ApiResponse
46
+ from .transport import HttpxTransport
47
+
48
+ _PATH_PARAMETER = re.compile(r"\{([^{}]+)\}")
49
+ _MANAGED_HEADERS = frozenset(
50
+ {"accept-version", "authorization", "client-id", "content-type", "x-api-key"}
51
+ )
52
+
53
+
54
+ class RedBlackClient:
55
+ """Synchronous, typed client for RedBlack API release 2026.3.0.
56
+
57
+ The normal constructor is intentionally explicit and reads neither process
58
+ variables nor dotenv files. Use :meth:`from_env` or :meth:`from_dotenv`
59
+ when configuration lookup is desired.
60
+ """
61
+
62
+ config: RedBlackConfig
63
+ accounts: AccountsResource[Literal[False]]
64
+ households: HouseholdsResource[Literal[False]]
65
+ restrictions: RestrictionsResource[Literal[False]]
66
+ equivalents: EquivalentsResource[Literal[False]]
67
+ cash_actions: CashActionsResource[Literal[False]]
68
+ securities: SecuritiesResource[Literal[False]]
69
+ review_sets: ReviewSetsResource[Literal[False]]
70
+ users: UsersResource[Literal[False]]
71
+ orders: OrdersResource[Literal[False]]
72
+ tasks: TasksResource[Literal[False]]
73
+ with_http_info: HttpInfoClient
74
+ raw: RawClient
75
+
76
+ def __init__(
77
+ self,
78
+ *,
79
+ environment: Environment | str,
80
+ api_key: str,
81
+ client_id: str,
82
+ api_version: str = SUPPORTED_API_VERSION,
83
+ credentials: PasswordCredentials | None = None,
84
+ access_token: str | None = None,
85
+ token_provider: TokenProvider | None = None,
86
+ base_url: str | None = None,
87
+ timeout: TimeoutConfig | None = None,
88
+ retry: RetryConfig | None = None,
89
+ verify: VerifyValue = True,
90
+ proxy: str | None = None,
91
+ trust_env: bool = False,
92
+ allow_insecure_transport: bool = False,
93
+ allow_unsupported_api_version: bool = False,
94
+ http_client: httpx.Client | None = None,
95
+ ) -> None:
96
+ if token_provider is not None and (credentials is not None or access_token is not None):
97
+ raise ConfigurationError(
98
+ "token_provider cannot be combined with credentials or access_token"
99
+ )
100
+ config = RedBlackConfig(
101
+ environment=environment,
102
+ api_key=api_key,
103
+ client_id=client_id,
104
+ api_version=api_version,
105
+ credentials=credentials,
106
+ access_token=access_token,
107
+ base_url=base_url,
108
+ timeout=timeout or TimeoutConfig(),
109
+ retry=retry or RetryConfig(),
110
+ verify=verify,
111
+ proxy=proxy,
112
+ trust_env=trust_env,
113
+ allow_insecure_transport=allow_insecure_transport,
114
+ allow_unsupported_api_version=allow_unsupported_api_version,
115
+ )
116
+ self._initialize(
117
+ config,
118
+ token_provider=token_provider,
119
+ http_client=http_client,
120
+ )
121
+
122
+ @classmethod
123
+ def from_env(
124
+ cls,
125
+ *,
126
+ environ: Mapping[str, str] | None = None,
127
+ token_provider: TokenProvider | None = None,
128
+ http_client: httpx.Client | None = None,
129
+ **overrides: Any,
130
+ ) -> Self:
131
+ """Build a client from explicit overrides, then process variables."""
132
+
133
+ config = RedBlackConfig.from_env(
134
+ environ=environ,
135
+ require_auth=token_provider is None,
136
+ **overrides,
137
+ )
138
+ return cls._from_config(
139
+ config,
140
+ token_provider=token_provider,
141
+ http_client=http_client,
142
+ )
143
+
144
+ @classmethod
145
+ def from_dotenv(
146
+ cls,
147
+ path: str | os.PathLike[str] | None = None,
148
+ *,
149
+ search_start: str | os.PathLike[str] | None = None,
150
+ environ: Mapping[str, str] | None = None,
151
+ token_provider: TokenProvider | None = None,
152
+ http_client: httpx.Client | None = None,
153
+ **overrides: Any,
154
+ ) -> Self:
155
+ """Build a client with explicit > environment > dotenv precedence."""
156
+
157
+ config = RedBlackConfig.from_dotenv(
158
+ path,
159
+ search_start=search_start,
160
+ environ=environ,
161
+ require_auth=token_provider is None,
162
+ **overrides,
163
+ )
164
+ return cls._from_config(
165
+ config,
166
+ token_provider=token_provider,
167
+ http_client=http_client,
168
+ )
169
+
170
+ @classmethod
171
+ def _from_config(
172
+ cls,
173
+ config: RedBlackConfig,
174
+ *,
175
+ token_provider: TokenProvider | None,
176
+ http_client: httpx.Client | None,
177
+ ) -> Self:
178
+ instance = cls.__new__(cls)
179
+ instance._initialize(
180
+ config,
181
+ token_provider=token_provider,
182
+ http_client=http_client,
183
+ )
184
+ return instance
185
+
186
+ def _initialize(
187
+ self,
188
+ config: RedBlackConfig,
189
+ *,
190
+ token_provider: TokenProvider | None,
191
+ http_client: httpx.Client | None,
192
+ ) -> None:
193
+ if token_provider is not None and config.auth_mode is not None:
194
+ raise ConfigurationError(
195
+ "token_provider cannot be combined with built-in authentication"
196
+ )
197
+
198
+ owns_token_provider = token_provider is None
199
+ resolved_provider = token_provider or config.build_token_provider(http_client=http_client)
200
+ try:
201
+ transport = HttpxTransport(
202
+ config,
203
+ resolved_provider,
204
+ client=http_client,
205
+ )
206
+ except BaseException:
207
+ if owns_token_provider:
208
+ close = getattr(resolved_provider, "close", None)
209
+ if callable(close):
210
+ close()
211
+ raise
212
+
213
+ self.config = config
214
+ self._token_provider = resolved_provider
215
+ self._owns_token_provider = owns_token_provider
216
+ self._transport = transport
217
+ self._closed = False
218
+ self.raw = RawClient(transport)
219
+ self.accounts = AccountsResource(self)
220
+ self.households = HouseholdsResource(self)
221
+ self.restrictions = RestrictionsResource(self)
222
+ self.equivalents = EquivalentsResource(self)
223
+ self.cash_actions = CashActionsResource(self)
224
+ self.securities = SecuritiesResource(self)
225
+ self.review_sets = ReviewSetsResource(self)
226
+ self.users = UsersResource(self)
227
+ self.orders = OrdersResource(self)
228
+ self.tasks = TasksResource(self)
229
+ self.with_http_info = HttpInfoClient(self)
230
+
231
+ def request(
232
+ self,
233
+ operation_id: str,
234
+ *,
235
+ path_params: Mapping[str, object] | None = None,
236
+ query: Mapping[str, object] | None = None,
237
+ headers: Mapping[str, str] | None = None,
238
+ json: object | None = None,
239
+ stream: bool = False,
240
+ timeout_budget: float | None = None,
241
+ ) -> ApiResponse[Any]:
242
+ """Execute one captured operation and return data with HTTP metadata.
243
+
244
+ Resource facades call this operation-level method. It is also useful to
245
+ advanced callers who know a RedBlack ``operationId`` but want to retain
246
+ generated validation and response parsing.
247
+
248
+ ``timeout_budget`` limits transport work for this call in seconds.
249
+ """
250
+
251
+ if self.config.api_version != SUPPORTED_API_VERSION:
252
+ raise ConfigurationError(
253
+ f"typed calls require API {SUPPORTED_API_VERSION}; "
254
+ "use client.raw for an explicitly unsupported version"
255
+ )
256
+ if not isinstance(stream, bool):
257
+ raise TypeError("stream must be a bool")
258
+
259
+ operation = get_operation_spec(operation_id)
260
+ path = _render_path(operation.path, operation.parameters, path_params)
261
+ params = _render_query(operation.parameters, query)
262
+ encoded = encode_request(operation_id, json)
263
+ request_headers = _render_headers(
264
+ operation.parameters,
265
+ headers,
266
+ has_body=encoded is not None,
267
+ )
268
+
269
+ raw_response = self._transport.request(
270
+ operation.method,
271
+ path,
272
+ params=params,
273
+ headers=request_headers,
274
+ content=encoded,
275
+ expected_statuses=operation.success_statuses,
276
+ authenticated=True,
277
+ operation_id=operation_id,
278
+ timeout_budget=timeout_budget,
279
+ )
280
+ content_type = raw_response.headers.get("content-type")
281
+ try:
282
+ parsed = parse_response(
283
+ operation_id,
284
+ raw_response.status_code,
285
+ raw_response.raw,
286
+ content_type,
287
+ )
288
+ except (TypeError, ValueError):
289
+ raise ResponseDecodeError(
290
+ "RedBlack success response did not match the captured contract",
291
+ status_code=raw_response.status_code,
292
+ method=operation.method,
293
+ path=operation_id,
294
+ request_id=raw_response.request_id,
295
+ raw_body=raw_response.raw,
296
+ ) from None
297
+
298
+ attach_http = getattr(parsed, "_attach_http", None)
299
+ if callable(attach_http):
300
+ parsed = attach_http(raw_response.http)
301
+ return raw_response.with_data(parsed)
302
+
303
+ def close(self) -> None:
304
+ """Close SDK-owned HTTP resources; caller-injected clients stay open."""
305
+
306
+ if self._closed:
307
+ return
308
+ self._closed = True
309
+ self._transport.close()
310
+ if self._owns_token_provider:
311
+ close = getattr(self._token_provider, "close", None)
312
+ if callable(close):
313
+ close()
314
+
315
+ def __enter__(self) -> Self:
316
+ return self
317
+
318
+ def __exit__(
319
+ self,
320
+ exc_type: type[BaseException] | None,
321
+ exc_value: BaseException | None,
322
+ traceback: TracebackType | None,
323
+ ) -> None:
324
+ del exc_type, exc_value, traceback
325
+ self.close()
326
+
327
+ def __repr__(self) -> str:
328
+ return f"RedBlackClient(config={self.config!r}, closed={self._closed!r})"
329
+
330
+
331
+ class HttpInfoClient:
332
+ """View whose direct resource requests return :class:`ApiResponse`.
333
+
334
+ Lazy iterator helpers still yield their documented pages or item values.
335
+ """
336
+
337
+ accounts: AccountsResource[Literal[True]]
338
+ households: HouseholdsResource[Literal[True]]
339
+ restrictions: RestrictionsResource[Literal[True]]
340
+ equivalents: EquivalentsResource[Literal[True]]
341
+ cash_actions: CashActionsResource[Literal[True]]
342
+ securities: SecuritiesResource[Literal[True]]
343
+ review_sets: ReviewSetsResource[Literal[True]]
344
+ users: UsersResource[Literal[True]]
345
+ orders: OrdersResource[Literal[True]]
346
+ tasks: TasksResource[Literal[True]]
347
+
348
+ def __init__(self, client: RedBlackClient) -> None:
349
+ self._client = client
350
+ self.accounts = AccountsResource(client, with_http_info=True)
351
+ self.households = HouseholdsResource(client, with_http_info=True)
352
+ self.restrictions = RestrictionsResource(client, with_http_info=True)
353
+ self.equivalents = EquivalentsResource(client, with_http_info=True)
354
+ self.cash_actions = CashActionsResource(client, with_http_info=True)
355
+ self.securities = SecuritiesResource(client, with_http_info=True)
356
+ self.review_sets = ReviewSetsResource(client, with_http_info=True)
357
+ self.users = UsersResource(client, with_http_info=True)
358
+ self.orders = OrdersResource(client, with_http_info=True)
359
+ self.tasks = TasksResource(client, with_http_info=True)
360
+
361
+ def request(
362
+ self,
363
+ operation_id: str,
364
+ *,
365
+ path_params: Mapping[str, object] | None = None,
366
+ query: Mapping[str, object] | None = None,
367
+ headers: Mapping[str, str] | None = None,
368
+ json: object | None = None,
369
+ stream: bool = False,
370
+ timeout_budget: float | None = None,
371
+ ) -> ApiResponse[Any]:
372
+ """Delegate an operation-level request to the owning client."""
373
+
374
+ return self._client.request(
375
+ operation_id,
376
+ path_params=path_params,
377
+ query=query,
378
+ headers=headers,
379
+ json=json,
380
+ stream=stream,
381
+ timeout_budget=timeout_budget,
382
+ )
383
+
384
+
385
+ def _render_path(
386
+ template: str,
387
+ parameters: tuple[Any, ...],
388
+ supplied: Mapping[str, object] | None,
389
+ ) -> str:
390
+ expected = _PATH_PARAMETER.findall(template)
391
+ values = dict(supplied or {})
392
+ aliases = {
393
+ parameter.python_name: parameter.wire_name
394
+ for parameter in parameters
395
+ if parameter.location == "path" and parameter.python_name != parameter.wire_name
396
+ }
397
+ for python_name, wire_name in aliases.items():
398
+ if python_name in values:
399
+ if wire_name in values:
400
+ raise TypeError(
401
+ f"path parameter {wire_name!r} was supplied by both wire and Python name"
402
+ )
403
+ values[wire_name] = values.pop(python_name)
404
+
405
+ missing = [name for name in expected if name not in values]
406
+ extra = sorted(set(values) - set(expected))
407
+ if missing:
408
+ raise TypeError(f"missing path parameter(s): {', '.join(missing)}")
409
+ if extra:
410
+ raise TypeError(f"unknown path parameter(s): {', '.join(extra)}")
411
+
412
+ def replacement(match: re.Match[str]) -> str:
413
+ name = match.group(1)
414
+ value = values[name]
415
+ if value is None:
416
+ raise TypeError(f"path parameter {name!r} cannot be None")
417
+ if isinstance(value, Enum):
418
+ value = value.value
419
+ return quote(str(value), safe="-._~")
420
+
421
+ return _PATH_PARAMETER.sub(replacement, template)
422
+
423
+
424
+ def _render_query(
425
+ parameters: tuple[Any, ...],
426
+ supplied: Mapping[str, object] | None,
427
+ ) -> dict[str, object]:
428
+ values = dict(supplied or {})
429
+ query_parameters = {
430
+ parameter.wire_name: parameter for parameter in parameters if parameter.location == "query"
431
+ }
432
+ aliases = {
433
+ parameter.python_name: parameter.wire_name
434
+ for parameter in query_parameters.values()
435
+ if parameter.python_name != parameter.wire_name
436
+ }
437
+ rendered: dict[str, object] = {}
438
+ for name, value in values.items():
439
+ wire_name = aliases.get(name, name)
440
+ if wire_name not in query_parameters:
441
+ raise TypeError(f"unknown query parameter: {name}")
442
+ if wire_name in rendered:
443
+ raise TypeError(f"query parameter {wire_name!r} was supplied twice")
444
+ if value is None:
445
+ continue
446
+ if isinstance(value, bool):
447
+ rendered[wire_name] = "true" if value else "false"
448
+ elif isinstance(value, Enum):
449
+ rendered[wire_name] = value.value
450
+ elif isinstance(value, Decimal):
451
+ rendered[wire_name] = str(value)
452
+ else:
453
+ rendered[wire_name] = value
454
+
455
+ missing = [
456
+ name
457
+ for name, parameter in query_parameters.items()
458
+ if parameter.required and name not in rendered
459
+ ]
460
+ if missing:
461
+ raise TypeError(f"missing query parameter(s): {', '.join(missing)}")
462
+ return rendered
463
+
464
+
465
+ def _render_headers(
466
+ parameters: tuple[Any, ...],
467
+ supplied: Mapping[str, str] | None,
468
+ *,
469
+ has_body: bool,
470
+ ) -> dict[str, str]:
471
+ header_parameters = {
472
+ parameter.wire_name.casefold(): parameter
473
+ for parameter in parameters
474
+ if parameter.location == "header"
475
+ }
476
+ aliases = {
477
+ parameter.python_name: parameter.wire_name
478
+ for parameter in header_parameters.values()
479
+ if parameter.python_name != parameter.wire_name
480
+ }
481
+ rendered: dict[str, str] = {}
482
+ rendered_names: set[str] = set()
483
+ for name, value in (supplied or {}).items():
484
+ if not isinstance(name, str) or not isinstance(value, str):
485
+ raise TypeError("header names and values must be strings")
486
+ if name.casefold() in _MANAGED_HEADERS:
487
+ raise ConfigurationError(f"request header {name!r} is managed by the SDK")
488
+ wire_name = aliases.get(name, name)
489
+ parameter = header_parameters.get(wire_name.casefold())
490
+ if parameter is None:
491
+ raise TypeError(f"unknown operation header: {name}")
492
+ normalized = parameter.wire_name.casefold()
493
+ if normalized in rendered_names:
494
+ raise TypeError(f"header {parameter.wire_name!r} was supplied twice")
495
+ rendered[parameter.wire_name] = value
496
+ rendered_names.add(normalized)
497
+
498
+ missing = [
499
+ parameter.wire_name
500
+ for parameter in header_parameters.values()
501
+ if parameter.required
502
+ and parameter.wire_name.casefold() not in _MANAGED_HEADERS
503
+ and parameter.wire_name.casefold() not in rendered_names
504
+ ]
505
+ if missing:
506
+ raise TypeError(f"missing operation header(s): {', '.join(missing)}")
507
+ if has_body:
508
+ rendered["Content-Type"] = "application/json"
509
+ return rendered
510
+
511
+
512
+ __all__ = ["HttpInfoClient", "RedBlackClient"]