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.
@@ -0,0 +1,212 @@
1
+ """Shared protocols and mechanics for hand-written resource facades."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import asdict, dataclass, is_dataclass
7
+ from typing import Any, Generic, Literal, Protocol, TypeAlias, TypeVar, cast, runtime_checkable
8
+
9
+ from ..odata import ODataQuery
10
+ from ..pagination import Page
11
+ from ..response import ApiResponse, HttpMetadata
12
+
13
+ T = TypeVar("T")
14
+ HttpInfoMode = TypeVar("HttpInfoMode", Literal[False], Literal[True])
15
+ ApiResponseProtocol: TypeAlias = ApiResponse[T]
16
+ HttpMetadataProtocol: TypeAlias = HttpMetadata
17
+
18
+
19
+ @runtime_checkable
20
+ class ClientProtocol(Protocol):
21
+ """The narrow client interface consumed by every resource group."""
22
+
23
+ def request(
24
+ self,
25
+ operation_id: str,
26
+ *,
27
+ path_params: Mapping[str, object] | None = None,
28
+ query: Mapping[str, object] | None = None,
29
+ headers: Mapping[str, str] | None = None,
30
+ json: object | None = None,
31
+ stream: bool = False,
32
+ timeout_budget: float | None = None,
33
+ ) -> ApiResponse[Any]: ...
34
+
35
+
36
+ def read_member(value: object, *names: str) -> Any:
37
+ if isinstance(value, Mapping):
38
+ for name in names:
39
+ if name in value:
40
+ return value[name]
41
+ return None
42
+ for name in names:
43
+ if hasattr(value, name):
44
+ return getattr(value, name)
45
+ return None
46
+
47
+
48
+ def enum_value(value: object, *field_names: str) -> int | None:
49
+ wrapper = read_member(value, *field_names)
50
+ raw = read_member(wrapper, "value") if wrapper is not None else None
51
+ if raw is None and isinstance(wrapper, int) and not isinstance(wrapper, bool):
52
+ raw = wrapper
53
+ if isinstance(raw, bool) or not isinstance(raw, int):
54
+ return None
55
+ return raw
56
+
57
+
58
+ def response_header(response: ApiResponse[Any], name: str) -> str | None:
59
+ if name.casefold() == "location":
60
+ location = getattr(response, "location", None)
61
+ if location:
62
+ return str(location)
63
+ location = getattr(response.http, "location", None)
64
+ if location:
65
+ return str(location)
66
+ if name.casefold() == "etag":
67
+ etag = getattr(response.http, "etag", None)
68
+ if etag:
69
+ return str(etag)
70
+ target = name.casefold()
71
+ for key, value in response.headers.items():
72
+ if key.casefold() == target:
73
+ return value
74
+ for key, value in response.http.headers.items():
75
+ if key.casefold() == target:
76
+ return value
77
+ return None
78
+
79
+
80
+ def body_mapping(value: object) -> dict[str, Any]:
81
+ """Convert a request model or mapping when a facade must add safe fields."""
82
+
83
+ if isinstance(value, Mapping):
84
+ return dict(value)
85
+ model_dump = getattr(value, "model_dump", None)
86
+ if callable(model_dump):
87
+ return dict(
88
+ cast(Mapping[str, Any], model_dump(by_alias=True, exclude_unset=True, mode="python"))
89
+ )
90
+ if is_dataclass(value) and not isinstance(value, type):
91
+ return asdict(value)
92
+ raise TypeError("request value must be a mapping, dataclass, or Pydantic model")
93
+
94
+
95
+ def body_value(value: object) -> object:
96
+ """Serialize nested request models without losing omitted/null semantics."""
97
+
98
+ if isinstance(value, Mapping):
99
+ return {key: body_value(item) for key, item in value.items()}
100
+ if isinstance(value, list | tuple):
101
+ return [body_value(item) for item in value]
102
+ model_dump = getattr(value, "model_dump", None)
103
+ if callable(model_dump):
104
+ return model_dump(by_alias=True, exclude_unset=True, mode="python")
105
+ if is_dataclass(value) and not isinstance(value, type):
106
+ return {key: body_value(item) for key, item in asdict(value).items()}
107
+ return value
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class VersionedResource(Generic[T]):
112
+ """A resource value paired with the exact server ETag used for writes."""
113
+
114
+ data: T
115
+ etag: str | None
116
+ http: HttpMetadataProtocol
117
+
118
+ def __getattr__(self, name: str) -> Any:
119
+ return getattr(self.data, name)
120
+
121
+ def __getitem__(self, key: object) -> Any:
122
+ return self.data[key] # type: ignore[index]
123
+
124
+
125
+ class Resource(Generic[HttpInfoMode]):
126
+ OPERATION_IDS: frozenset[str] = frozenset()
127
+
128
+ def __init__(self, client: ClientProtocol, *, with_http_info: bool = False) -> None:
129
+ self._client = client
130
+ self._with_http_info = with_http_info
131
+
132
+ def _request(
133
+ self,
134
+ operation_id: str,
135
+ *,
136
+ path_params: Mapping[str, object] | None = None,
137
+ query: Mapping[str, object] | None = None,
138
+ headers: Mapping[str, str] | None = None,
139
+ json: object | None = None,
140
+ stream: bool = False,
141
+ timeout_budget: float | None = None,
142
+ ) -> ApiResponse[Any]:
143
+ if timeout_budget is None:
144
+ return self._client.request(
145
+ operation_id,
146
+ path_params=path_params,
147
+ query=query,
148
+ headers=headers,
149
+ json=json,
150
+ stream=stream,
151
+ )
152
+ return self._client.request(
153
+ operation_id,
154
+ path_params=path_params,
155
+ query=query,
156
+ headers=headers,
157
+ json=json,
158
+ stream=stream,
159
+ timeout_budget=timeout_budget,
160
+ )
161
+
162
+ def _page(
163
+ self,
164
+ operation_id: str,
165
+ *,
166
+ odata: ODataQuery,
167
+ path_params: Mapping[str, object] | None = None,
168
+ extra_query: Mapping[str, object] | None = None,
169
+ ) -> Page[Any] | ApiResponse[Page[Any]]:
170
+ response = self._request(
171
+ operation_id,
172
+ path_params=path_params,
173
+ query={**odata.to_params(), **(extra_query or {})},
174
+ )
175
+ page = Page.from_data(response.data, query=odata, http=response.http)
176
+ return self._finish(response, page)
177
+
178
+ def _page_value(
179
+ self,
180
+ operation_id: str,
181
+ *,
182
+ odata: ODataQuery,
183
+ path_params: Mapping[str, object] | None = None,
184
+ extra_query: Mapping[str, object] | None = None,
185
+ ) -> Page[Any]:
186
+ """Return an internal page regardless of the public response view."""
187
+
188
+ response = self._request(
189
+ operation_id,
190
+ path_params=path_params,
191
+ query={**odata.to_params(), **(extra_query or {})},
192
+ )
193
+ return Page.from_data(response.data, query=odata, http=response.http)
194
+
195
+ def _finish(self, response: ApiResponse[Any], value: T) -> T | ApiResponse[T]:
196
+ if self._with_http_info:
197
+ return response.with_data(value)
198
+ return value
199
+
200
+
201
+ def require_positive_identifier(name: str, value: int) -> None:
202
+ if isinstance(value, bool) or not isinstance(value, int):
203
+ raise TypeError(f"{name} must be an integer")
204
+ if value <= 0:
205
+ raise ValueError(f"{name} must be greater than zero")
206
+
207
+
208
+ def require_etag(if_match: str) -> None:
209
+ if not isinstance(if_match, str):
210
+ raise TypeError("if_match must be a string")
211
+ if not if_match.strip():
212
+ raise ValueError("if_match must not be empty")
@@ -0,0 +1,80 @@
1
+ """Reusable collection method implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator, Mapping
6
+ from typing import Any
7
+
8
+ from ..odata import DEFAULT_PAGE_SIZE, ODataQuery
9
+ from ..pagination import Page, Paginator
10
+ from ._base import ApiResponseProtocol, HttpInfoMode, Resource
11
+
12
+
13
+ class CollectionResource(Resource[HttpInfoMode]):
14
+ LIST_OPERATION_ID: str
15
+
16
+ def _list(
17
+ self,
18
+ *,
19
+ filter: str | None = None,
20
+ order_by: str | None = None,
21
+ top: int | None = None,
22
+ skip: int | None = None,
23
+ count: bool | None = None,
24
+ path_params: Mapping[str, object] | None = None,
25
+ extra_query: Mapping[str, object] | None = None,
26
+ ) -> Page[Any] | ApiResponseProtocol[Page[Any]]:
27
+ query = ODataQuery(filter=filter, order_by=order_by, top=top, skip=skip, count=count)
28
+ return self._page(
29
+ self.LIST_OPERATION_ID, odata=query, path_params=path_params, extra_query=extra_query
30
+ )
31
+
32
+ def _iter_pages(
33
+ self,
34
+ *,
35
+ filter: str | None = None,
36
+ order_by: str | None = None,
37
+ count: bool | None = None,
38
+ start_skip: int = 0,
39
+ page_size: int = DEFAULT_PAGE_SIZE,
40
+ max_items: int | None = None,
41
+ path_params: Mapping[str, object] | None = None,
42
+ extra_query: Mapping[str, object] | None = None,
43
+ ) -> Iterator[Page[Any]]:
44
+ base = ODataQuery(filter=filter, order_by=order_by, skip=start_skip, count=count)
45
+ paginator = Paginator(
46
+ fetch_page=lambda query: self._page_value(
47
+ self.LIST_OPERATION_ID,
48
+ odata=query,
49
+ path_params=path_params,
50
+ extra_query=extra_query,
51
+ ),
52
+ query=base,
53
+ page_size=page_size,
54
+ max_items=max_items,
55
+ )
56
+ return paginator.iter_pages()
57
+
58
+ def _iter_all(
59
+ self,
60
+ *,
61
+ filter: str | None = None,
62
+ order_by: str | None = None,
63
+ count: bool | None = None,
64
+ start_skip: int = 0,
65
+ page_size: int = DEFAULT_PAGE_SIZE,
66
+ max_items: int | None = None,
67
+ path_params: Mapping[str, object] | None = None,
68
+ extra_query: Mapping[str, object] | None = None,
69
+ ) -> Iterator[Any]:
70
+ pages = self._iter_pages(
71
+ filter=filter,
72
+ order_by=order_by,
73
+ count=count,
74
+ start_skip=start_skip,
75
+ page_size=page_size,
76
+ max_items=max_items,
77
+ path_params=path_params,
78
+ extra_query=extra_query,
79
+ )
80
+ return (item for page in pages for item in page.items)
@@ -0,0 +1,215 @@
1
+ """Shared implementation for the four documented export operation families."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from os import PathLike
8
+ from threading import Event
9
+ from typing import Any, Generic, TypeVar
10
+
11
+ from ..downloads import (
12
+ AddressResolver,
13
+ Downloader,
14
+ DownloadResult,
15
+ StorageUrlPolicy,
16
+ validate_storage_url,
17
+ )
18
+ from ..errors import RedBlackError
19
+ from ..polling import Poller, PollOutcome
20
+ from ._base import (
21
+ ApiResponseProtocol,
22
+ ClientProtocol,
23
+ HttpInfoMode,
24
+ Resource,
25
+ enum_value,
26
+ read_member,
27
+ require_positive_identifier,
28
+ response_header,
29
+ )
30
+
31
+ T = TypeVar("T")
32
+
33
+
34
+ class WorkflowProtocolError(RedBlackError):
35
+ """A successful workflow response omitted data required by its contract."""
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ExportOperations:
40
+ create: str
41
+ status: str
42
+ file: str
43
+ body_required: bool
44
+
45
+
46
+ def _parse_datetime(value: object) -> datetime | None:
47
+ if isinstance(value, datetime):
48
+ return value
49
+ if not isinstance(value, str) or not value:
50
+ return None
51
+ normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
52
+ try:
53
+ return datetime.fromisoformat(normalized)
54
+ except ValueError:
55
+ return None
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class ExportJobHandle(Generic[T]):
60
+ """A client-bound handle for one RedBlack export job."""
61
+
62
+ _client: ClientProtocol
63
+ job_id: int
64
+ _operations: ExportOperations
65
+ latest: T | None = None
66
+
67
+ def __post_init__(self) -> None:
68
+ require_positive_identifier("job_id", self.job_id)
69
+
70
+ def _refresh_with_timeout(self, timeout_budget: float | None) -> T:
71
+ if timeout_budget is None:
72
+ return self.refresh()
73
+ response = self._client.request(
74
+ self._operations.status,
75
+ path_params={"jobId": self.job_id},
76
+ timeout_budget=timeout_budget,
77
+ )
78
+ self.latest = response.data
79
+ return response.data
80
+
81
+ def refresh(self) -> T:
82
+ response = self._client.request(self._operations.status, path_params={"jobId": self.job_id})
83
+ self.latest = response.data
84
+ return response.data
85
+
86
+ status = refresh
87
+
88
+ @staticmethod
89
+ def _classify(value: object) -> PollOutcome:
90
+ status = enum_value(value, "job_status", "jobStatus", "export_status", "exportStatus")
91
+ if status == 3:
92
+ return PollOutcome.SUCCEEDED
93
+ if status in {4, 5}:
94
+ return PollOutcome.FAILED
95
+ return PollOutcome.PENDING
96
+
97
+ @staticmethod
98
+ def _suggested_delay(value: object) -> float | None:
99
+ target = _parse_datetime(
100
+ read_member(value, "suggested_next_poll_time", "suggestedNextPollTime")
101
+ )
102
+ if target is None:
103
+ return None
104
+ if target.tzinfo is None:
105
+ target = target.replace(tzinfo=UTC)
106
+ return max(0.0, (target - datetime.now(UTC)).total_seconds())
107
+
108
+ def wait(
109
+ self,
110
+ *,
111
+ timeout: float | None = 300.0,
112
+ poll_interval: float = 1.0,
113
+ max_interval: float = 30.0,
114
+ backoff_factor: float = 1.5,
115
+ stop_event: Event | None = None,
116
+ raise_on_failure: bool = True,
117
+ ) -> T:
118
+ return Poller(
119
+ refresh=self.refresh,
120
+ classify=self._classify,
121
+ suggested_delay=self._suggested_delay,
122
+ refresh_with_timeout=self._refresh_with_timeout,
123
+ ).wait(
124
+ timeout=timeout,
125
+ poll_interval=poll_interval,
126
+ max_interval=max_interval,
127
+ backoff_factor=backoff_factor,
128
+ stop_event=stop_event,
129
+ raise_on_failure=raise_on_failure,
130
+ )
131
+
132
+ def file_response(self) -> ApiResponseProtocol[None]:
133
+ """Request the RedBlack file endpoint without following its redirect."""
134
+
135
+ return self._client.request(
136
+ self._operations.file,
137
+ path_params={"jobId": self.job_id},
138
+ stream=True,
139
+ )
140
+
141
+ def download_url(
142
+ self,
143
+ *,
144
+ policy: StorageUrlPolicy | None = None,
145
+ resolver: AddressResolver | None = None,
146
+ ) -> str:
147
+ """Obtain and validate the five-minute storage URL."""
148
+
149
+ response = self.file_response()
150
+ location = response_header(response, "Location")
151
+ if not location:
152
+ raise WorkflowProtocolError("export file response omitted Location")
153
+ return validate_storage_url(location, policy=policy, resolver=resolver)
154
+
155
+ def download_to(
156
+ self,
157
+ destination: str | PathLike[str],
158
+ *,
159
+ overwrite: bool = False,
160
+ policy: StorageUrlPolicy | None = None,
161
+ download_client: object | None = None,
162
+ resolver: AddressResolver | None = None,
163
+ ) -> DownloadResult:
164
+ """Download with a credential-free client and atomic file publication."""
165
+
166
+ active_policy = policy or StorageUrlPolicy()
167
+ url = self.download_url(policy=active_policy, resolver=resolver)
168
+ downloader = Downloader(policy=active_policy, client=download_client, resolver=resolver)
169
+ return downloader.download_to(url, destination, overwrite=overwrite)
170
+
171
+ @property
172
+ def has_failures(self) -> bool:
173
+ if self.latest is None:
174
+ return False
175
+ fault_reason = read_member(self.latest, "fault_reason", "faultReason")
176
+ return bool(fault_reason) or self._classify(self.latest) is PollOutcome.FAILED
177
+
178
+
179
+ class ExportResource(Resource[HttpInfoMode]):
180
+ EXPORT_OPERATIONS: ExportOperations
181
+
182
+ def _create_export(
183
+ self, *, filter: str | None = None
184
+ ) -> ExportJobHandle[Any] | ApiResponseProtocol[ExportJobHandle[Any]]:
185
+ if filter is not None:
186
+ if not isinstance(filter, str):
187
+ raise TypeError("filter must be a string")
188
+ if not filter.strip():
189
+ raise ValueError("filter must not be empty")
190
+ body: object | None = {"filter": filter}
191
+ elif self.EXPORT_OPERATIONS.body_required:
192
+ # Restriction/equivalent requestBody is required even though its
193
+ # only property is optional.
194
+ body = {}
195
+ else:
196
+ body = None
197
+ response = self._request(self.EXPORT_OPERATIONS.create, json=body)
198
+ job_id = read_member(response.data, "id")
199
+ if isinstance(job_id, bool) or not isinstance(job_id, int):
200
+ raise WorkflowProtocolError("export create response omitted integer id")
201
+ handle = ExportJobHandle(
202
+ self._client,
203
+ job_id,
204
+ self.EXPORT_OPERATIONS,
205
+ latest=response.data,
206
+ )
207
+ return self._finish(response, handle)
208
+
209
+ def _get_export(
210
+ self, job_id: int
211
+ ) -> ExportJobHandle[Any] | ApiResponseProtocol[ExportJobHandle[Any]]:
212
+ require_positive_identifier("job_id", job_id)
213
+ response = self._request(self.EXPORT_OPERATIONS.status, path_params={"jobId": job_id})
214
+ handle = ExportJobHandle(self._client, job_id, self.EXPORT_OPERATIONS, latest=response.data)
215
+ return self._finish(response, handle)
@@ -0,0 +1,132 @@
1
+ """Accounts resource operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Literal, overload
7
+
8
+ from ..models import Account, AccountExportJob
9
+ from ..odata import DEFAULT_PAGE_SIZE
10
+ from ..pagination import Page
11
+ from ._base import ApiResponseProtocol, HttpInfoMode
12
+ from ._collections import CollectionResource
13
+ from ._exports import ExportJobHandle, ExportOperations, ExportResource
14
+
15
+
16
+ class AccountsResource(CollectionResource[HttpInfoMode], ExportResource[HttpInfoMode]):
17
+ LIST_OPERATION_ID = "Accounts_QueryAccounts"
18
+ EXPORT_OPERATIONS = ExportOperations(
19
+ create="Accounts_Export",
20
+ status="Accounts_GetBulkExportSessionStatus",
21
+ file="Accounts_GetBulkExportFile",
22
+ body_required=False,
23
+ )
24
+ OPERATION_IDS = frozenset(
25
+ {
26
+ LIST_OPERATION_ID,
27
+ EXPORT_OPERATIONS.create,
28
+ EXPORT_OPERATIONS.status,
29
+ EXPORT_OPERATIONS.file,
30
+ }
31
+ )
32
+
33
+ @overload
34
+ def list(
35
+ self: AccountsResource[Literal[False]],
36
+ *,
37
+ filter: str | None = None,
38
+ order_by: str | None = None,
39
+ top: int | None = None,
40
+ skip: int | None = None,
41
+ count: bool | None = None,
42
+ ) -> Page[Account]: ...
43
+
44
+ @overload
45
+ def list(
46
+ self: AccountsResource[Literal[True]],
47
+ *,
48
+ filter: str | None = None,
49
+ order_by: str | None = None,
50
+ top: int | None = None,
51
+ skip: int | None = None,
52
+ count: bool | None = None,
53
+ ) -> ApiResponseProtocol[Page[Account]]: ...
54
+
55
+ def list(
56
+ self,
57
+ *,
58
+ filter: str | None = None,
59
+ order_by: str | None = None,
60
+ top: int | None = None,
61
+ skip: int | None = None,
62
+ count: bool | None = None,
63
+ ) -> Page[Account] | ApiResponseProtocol[Page[Account]]:
64
+ return self._list(filter=filter, order_by=order_by, top=top, skip=skip, count=count)
65
+
66
+ def iter_pages(
67
+ self,
68
+ *,
69
+ filter: str | None = None,
70
+ order_by: str | None = None,
71
+ count: bool | None = None,
72
+ start_skip: int = 0,
73
+ page_size: int = DEFAULT_PAGE_SIZE,
74
+ max_items: int | None = None,
75
+ ) -> Iterator[Page[Account]]:
76
+ return self._iter_pages(
77
+ filter=filter,
78
+ order_by=order_by,
79
+ count=count,
80
+ start_skip=start_skip,
81
+ page_size=page_size,
82
+ max_items=max_items,
83
+ )
84
+
85
+ def iter_all(
86
+ self,
87
+ *,
88
+ filter: str | None = None,
89
+ order_by: str | None = None,
90
+ count: bool | None = None,
91
+ start_skip: int = 0,
92
+ page_size: int = DEFAULT_PAGE_SIZE,
93
+ max_items: int | None = None,
94
+ ) -> Iterator[Account]:
95
+ return self._iter_all(
96
+ filter=filter,
97
+ order_by=order_by,
98
+ count=count,
99
+ start_skip=start_skip,
100
+ page_size=page_size,
101
+ max_items=max_items,
102
+ )
103
+
104
+ @overload
105
+ def create_export(
106
+ self: AccountsResource[Literal[False]], *, filter: str | None = None
107
+ ) -> ExportJobHandle[AccountExportJob]: ...
108
+
109
+ @overload
110
+ def create_export(
111
+ self: AccountsResource[Literal[True]], *, filter: str | None = None
112
+ ) -> ApiResponseProtocol[ExportJobHandle[AccountExportJob]]: ...
113
+
114
+ def create_export(
115
+ self, *, filter: str | None = None
116
+ ) -> ExportJobHandle[AccountExportJob] | ApiResponseProtocol[ExportJobHandle[AccountExportJob]]:
117
+ return self._create_export(filter=filter)
118
+
119
+ @overload
120
+ def get_export(
121
+ self: AccountsResource[Literal[False]], job_id: int
122
+ ) -> ExportJobHandle[AccountExportJob]: ...
123
+
124
+ @overload
125
+ def get_export(
126
+ self: AccountsResource[Literal[True]], job_id: int
127
+ ) -> ApiResponseProtocol[ExportJobHandle[AccountExportJob]]: ...
128
+
129
+ def get_export(
130
+ self, job_id: int
131
+ ) -> ExportJobHandle[AccountExportJob] | ApiResponseProtocol[ExportJobHandle[AccountExportJob]]:
132
+ return self._get_export(job_id)