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/__init__.py +105 -0
- redblack/_generated/__init__.py +39 -0
- redblack/_generated/contract.py +212 -0
- redblack/_generated/operation_specs.py +5115 -0
- redblack/_version.py +4 -0
- redblack/auth.py +352 -0
- redblack/client.py +512 -0
- redblack/config.py +576 -0
- redblack/downloads.py +410 -0
- redblack/errors.py +483 -0
- redblack/models/__init__.py +105 -0
- redblack/models/base.py +150 -0
- redblack/models/generated.py +11736 -0
- redblack/odata.py +75 -0
- redblack/pagination.py +184 -0
- redblack/polling.py +163 -0
- redblack/py.typed +1 -0
- redblack/raw.py +102 -0
- redblack/resources/__init__.py +65 -0
- redblack/resources/_base.py +212 -0
- redblack/resources/_collections.py +80 -0
- redblack/resources/_exports.py +215 -0
- redblack/resources/accounts.py +132 -0
- redblack/resources/cash_actions.py +260 -0
- redblack/resources/equivalents.py +144 -0
- redblack/resources/households.py +138 -0
- redblack/resources/orders.py +118 -0
- redblack/resources/restrictions.py +146 -0
- redblack/resources/review_sets.py +889 -0
- redblack/resources/securities.py +175 -0
- redblack/resources/tasks.py +291 -0
- redblack/resources/users.py +88 -0
- redblack/response.py +118 -0
- redblack/transport.py +606 -0
- redblack_python_sdk-0.1.0.dist-info/METADATA +151 -0
- redblack_python_sdk-0.1.0.dist-info/RECORD +38 -0
- redblack_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- redblack_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
redblack/odata.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Small, explicit helpers for RedBlack's supported OData query options.
|
|
2
|
+
|
|
3
|
+
The SDK deliberately does not try to parse or validate OData expressions.
|
|
4
|
+
``filter`` and ``order_by`` remain strings so callers can use server features
|
|
5
|
+
that the SDK does not know about. This module only validates the paging
|
|
6
|
+
values and translates Python names to the exact wire names.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
DEFAULT_PAGE_SIZE: Final = 200
|
|
15
|
+
MAX_PAGE_SIZE: Final = 500
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _validate_non_negative_integer(name: str, value: int | None) -> None:
|
|
19
|
+
if value is None:
|
|
20
|
+
return
|
|
21
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
22
|
+
raise TypeError(f"{name} must be an integer")
|
|
23
|
+
if value < 0:
|
|
24
|
+
raise ValueError(f"{name} must be greater than or equal to zero")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_expression(name: str, value: str | None) -> None:
|
|
28
|
+
if value is None:
|
|
29
|
+
return
|
|
30
|
+
if not isinstance(value, str):
|
|
31
|
+
raise TypeError(f"{name} must be a string")
|
|
32
|
+
if not value.strip():
|
|
33
|
+
raise ValueError(f"{name} must not be empty")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class ODataQuery:
|
|
38
|
+
"""The OData options documented for RedBlack collection operations."""
|
|
39
|
+
|
|
40
|
+
filter: str | None = None
|
|
41
|
+
order_by: str | None = None
|
|
42
|
+
top: int | None = None
|
|
43
|
+
skip: int | None = None
|
|
44
|
+
count: bool | None = None
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
_validate_expression("filter", self.filter)
|
|
48
|
+
_validate_expression("order_by", self.order_by)
|
|
49
|
+
_validate_non_negative_integer("top", self.top)
|
|
50
|
+
_validate_non_negative_integer("skip", self.skip)
|
|
51
|
+
if self.top is not None and self.top > MAX_PAGE_SIZE:
|
|
52
|
+
raise ValueError(f"top must not exceed {MAX_PAGE_SIZE}")
|
|
53
|
+
if self.count is not None and not isinstance(self.count, bool):
|
|
54
|
+
raise TypeError("count must be a boolean")
|
|
55
|
+
|
|
56
|
+
def to_params(self) -> dict[str, str | int | bool]:
|
|
57
|
+
"""Return exact RedBlack/OData wire query names, omitting unset values."""
|
|
58
|
+
|
|
59
|
+
params: dict[str, str | int | bool] = {}
|
|
60
|
+
if self.top is not None:
|
|
61
|
+
params["$top"] = self.top
|
|
62
|
+
if self.filter is not None:
|
|
63
|
+
params["$filter"] = self.filter
|
|
64
|
+
if self.order_by is not None:
|
|
65
|
+
params["$orderby"] = self.order_by
|
|
66
|
+
if self.count is not None:
|
|
67
|
+
params["$count"] = self.count
|
|
68
|
+
if self.skip is not None:
|
|
69
|
+
params["$skip"] = self.skip
|
|
70
|
+
return params
|
|
71
|
+
|
|
72
|
+
def with_page(self, *, top: int, skip: int) -> ODataQuery:
|
|
73
|
+
"""Copy the query with paging values used for one request."""
|
|
74
|
+
|
|
75
|
+
return replace(self, top=top, skip=skip)
|
redblack/pagination.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Page values and bounded offset-pagination iterators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from typing import Any, Generic, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
from .odata import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, ODataQuery
|
|
10
|
+
from .response import HttpMetadata
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read_member(value: object, *names: str) -> Any:
|
|
16
|
+
if isinstance(value, Mapping):
|
|
17
|
+
for name in names:
|
|
18
|
+
if name in value:
|
|
19
|
+
return value[name]
|
|
20
|
+
return None
|
|
21
|
+
for name in names:
|
|
22
|
+
if hasattr(value, name):
|
|
23
|
+
return getattr(value, name)
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class Page(Generic[T]):
|
|
29
|
+
"""One RedBlack OData page plus safe HTTP metadata."""
|
|
30
|
+
|
|
31
|
+
items: list[T]
|
|
32
|
+
total_count: int | None = None
|
|
33
|
+
skip: int = 0
|
|
34
|
+
top: int | None = None
|
|
35
|
+
http: HttpMetadata | None = None
|
|
36
|
+
|
|
37
|
+
def __iter__(self) -> Iterator[T]:
|
|
38
|
+
return iter(self.items)
|
|
39
|
+
|
|
40
|
+
def __len__(self) -> int:
|
|
41
|
+
return len(self.items)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def value(self) -> list[T]:
|
|
45
|
+
"""Compatibility spelling used by the wire envelope."""
|
|
46
|
+
|
|
47
|
+
return self.items
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_data(
|
|
51
|
+
cls,
|
|
52
|
+
data: object,
|
|
53
|
+
*,
|
|
54
|
+
query: ODataQuery | None = None,
|
|
55
|
+
http: HttpMetadata | None = None,
|
|
56
|
+
) -> Page[Any]:
|
|
57
|
+
"""Coerce generated page models, mappings, or sequences into ``Page``."""
|
|
58
|
+
|
|
59
|
+
if isinstance(data, cls):
|
|
60
|
+
if data.http is None:
|
|
61
|
+
data.http = http
|
|
62
|
+
return data
|
|
63
|
+
|
|
64
|
+
raw_items = _read_member(data, "items", "value")
|
|
65
|
+
if raw_items is None and (
|
|
66
|
+
data is None
|
|
67
|
+
or isinstance(data, Mapping)
|
|
68
|
+
or not isinstance(data, Sequence)
|
|
69
|
+
or isinstance(data, str | bytes | bytearray)
|
|
70
|
+
):
|
|
71
|
+
raw_items = []
|
|
72
|
+
elif raw_items is None:
|
|
73
|
+
raw_items = data
|
|
74
|
+
|
|
75
|
+
if isinstance(raw_items, str | bytes | bytearray) or not isinstance(raw_items, Sequence):
|
|
76
|
+
raise TypeError("collection response did not contain a sequence of items")
|
|
77
|
+
|
|
78
|
+
raw_count = _read_member(data, "total_count", "odata_count", "@odata.count")
|
|
79
|
+
total_count: int | None
|
|
80
|
+
if raw_count is None:
|
|
81
|
+
total_count = None
|
|
82
|
+
elif isinstance(raw_count, bool) or not isinstance(raw_count, int):
|
|
83
|
+
raise TypeError("collection response count was not an integer")
|
|
84
|
+
else:
|
|
85
|
+
total_count = raw_count
|
|
86
|
+
|
|
87
|
+
active_query = query or ODataQuery()
|
|
88
|
+
return cls(
|
|
89
|
+
items=list(cast(Sequence[Any], raw_items)),
|
|
90
|
+
total_count=total_count,
|
|
91
|
+
skip=active_query.skip or 0,
|
|
92
|
+
top=active_query.top,
|
|
93
|
+
http=http,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(slots=True)
|
|
98
|
+
class Paginator(Generic[T]):
|
|
99
|
+
"""Fetch pages lazily without silently loading an unbounded collection."""
|
|
100
|
+
|
|
101
|
+
fetch_page: Callable[[ODataQuery], Page[T]]
|
|
102
|
+
query: ODataQuery = field(default_factory=ODataQuery)
|
|
103
|
+
page_size: int = DEFAULT_PAGE_SIZE
|
|
104
|
+
max_items: int | None = None
|
|
105
|
+
|
|
106
|
+
def __post_init__(self) -> None:
|
|
107
|
+
if (
|
|
108
|
+
isinstance(self.page_size, bool)
|
|
109
|
+
or not isinstance(self.page_size, int)
|
|
110
|
+
or not 1 <= self.page_size <= MAX_PAGE_SIZE
|
|
111
|
+
):
|
|
112
|
+
raise ValueError(f"page_size must be between 1 and {MAX_PAGE_SIZE}")
|
|
113
|
+
if self.max_items is not None:
|
|
114
|
+
if isinstance(self.max_items, bool) or not isinstance(self.max_items, int):
|
|
115
|
+
raise TypeError("max_items must be an integer")
|
|
116
|
+
if self.max_items < 0:
|
|
117
|
+
raise ValueError("max_items must be greater than or equal to zero")
|
|
118
|
+
|
|
119
|
+
def iter_pages(self) -> Iterator[Page[T]]:
|
|
120
|
+
if self.max_items == 0:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
skip = self.query.skip or 0
|
|
124
|
+
yielded = 0
|
|
125
|
+
|
|
126
|
+
while True:
|
|
127
|
+
remaining = None if self.max_items is None else self.max_items - yielded
|
|
128
|
+
if remaining is not None and remaining <= 0:
|
|
129
|
+
return
|
|
130
|
+
request_size = self.page_size if remaining is None else min(self.page_size, remaining)
|
|
131
|
+
request_query = self.query.with_page(top=request_size, skip=skip)
|
|
132
|
+
page = self.fetch_page(request_query)
|
|
133
|
+
|
|
134
|
+
# A non-conforming server should not make max_items ineffective.
|
|
135
|
+
if remaining is not None and len(page.items) > remaining:
|
|
136
|
+
page = replace(page, items=page.items[:remaining], top=request_size)
|
|
137
|
+
|
|
138
|
+
yield page
|
|
139
|
+
received = len(page.items)
|
|
140
|
+
yielded += received
|
|
141
|
+
|
|
142
|
+
if received == 0 or received < request_size:
|
|
143
|
+
return
|
|
144
|
+
if page.total_count is not None and skip + received >= page.total_count:
|
|
145
|
+
return
|
|
146
|
+
skip += received
|
|
147
|
+
|
|
148
|
+
def iter_items(self) -> Iterator[T]:
|
|
149
|
+
for page in self.iter_pages():
|
|
150
|
+
yield from page.items
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def iter_pages(
|
|
154
|
+
fetch_page: Callable[[ODataQuery], Page[T]],
|
|
155
|
+
*,
|
|
156
|
+
query: ODataQuery | None = None,
|
|
157
|
+
page_size: int = DEFAULT_PAGE_SIZE,
|
|
158
|
+
max_items: int | None = None,
|
|
159
|
+
) -> Iterator[Page[T]]:
|
|
160
|
+
"""Convenience wrapper around :class:`Paginator`."""
|
|
161
|
+
|
|
162
|
+
return Paginator(
|
|
163
|
+
fetch_page=fetch_page,
|
|
164
|
+
query=query or ODataQuery(),
|
|
165
|
+
page_size=page_size,
|
|
166
|
+
max_items=max_items,
|
|
167
|
+
).iter_pages()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def iter_all(
|
|
171
|
+
fetch_page: Callable[[ODataQuery], Page[T]],
|
|
172
|
+
*,
|
|
173
|
+
query: ODataQuery | None = None,
|
|
174
|
+
page_size: int = DEFAULT_PAGE_SIZE,
|
|
175
|
+
max_items: int | None = None,
|
|
176
|
+
) -> Iterator[T]:
|
|
177
|
+
"""Yield collection items one at a time."""
|
|
178
|
+
|
|
179
|
+
return Paginator(
|
|
180
|
+
fetch_page=fetch_page,
|
|
181
|
+
query=query or ODataQuery(),
|
|
182
|
+
page_size=page_size,
|
|
183
|
+
max_items=max_items,
|
|
184
|
+
).iter_items()
|
redblack/polling.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Deterministic, deadline-bounded polling for server-side workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from threading import Event
|
|
10
|
+
from typing import Generic, TypeVar
|
|
11
|
+
|
|
12
|
+
from .errors import (
|
|
13
|
+
OperationFailedError,
|
|
14
|
+
OperationNeedsInputError,
|
|
15
|
+
PollingError,
|
|
16
|
+
PollTimeoutError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PollOutcome(str, Enum):
|
|
23
|
+
"""A workflow classification independent of any one RedBlack enum."""
|
|
24
|
+
|
|
25
|
+
PENDING = "pending"
|
|
26
|
+
SUCCEEDED = "succeeded"
|
|
27
|
+
FAILED = "failed"
|
|
28
|
+
NEEDS_INPUT = "needs_input"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PollingCancelledError(PollingError):
|
|
32
|
+
def __init__(self, last_value: object | None) -> None:
|
|
33
|
+
super().__init__("local polling was stopped; the server-side workflow was not canceled")
|
|
34
|
+
self.last_value = last_value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class PollPolicy:
|
|
39
|
+
"""Timing policy for one synchronous wait operation."""
|
|
40
|
+
|
|
41
|
+
timeout: float | None = 300.0
|
|
42
|
+
initial_interval: float = 1.0
|
|
43
|
+
maximum_interval: float = 30.0
|
|
44
|
+
backoff_factor: float = 1.5
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if self.timeout is not None and self.timeout < 0:
|
|
48
|
+
raise ValueError("timeout must be greater than or equal to zero")
|
|
49
|
+
if self.initial_interval <= 0:
|
|
50
|
+
raise ValueError("initial_interval must be greater than zero")
|
|
51
|
+
if self.maximum_interval <= 0:
|
|
52
|
+
raise ValueError("maximum_interval must be greater than zero")
|
|
53
|
+
if self.maximum_interval < self.initial_interval:
|
|
54
|
+
raise ValueError("maximum_interval must not be below initial_interval")
|
|
55
|
+
if self.backoff_factor < 1:
|
|
56
|
+
raise ValueError("backoff_factor must be at least one")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class Poller(Generic[T]):
|
|
61
|
+
"""Refresh and classify a workflow until it reaches a terminal outcome."""
|
|
62
|
+
|
|
63
|
+
refresh: Callable[[], T]
|
|
64
|
+
classify: Callable[[T], PollOutcome]
|
|
65
|
+
suggested_delay: Callable[[T], float | None] | None = None
|
|
66
|
+
monotonic: Callable[[], float] = time.monotonic
|
|
67
|
+
sleep: Callable[[float], None] = time.sleep
|
|
68
|
+
refresh_with_timeout: Callable[[float | None], T] | None = None
|
|
69
|
+
|
|
70
|
+
def wait(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
timeout: float | None = 300.0,
|
|
74
|
+
poll_interval: float = 1.0,
|
|
75
|
+
max_interval: float = 30.0,
|
|
76
|
+
backoff_factor: float = 1.5,
|
|
77
|
+
stop_event: Event | None = None,
|
|
78
|
+
raise_on_failure: bool = True,
|
|
79
|
+
) -> T:
|
|
80
|
+
policy = PollPolicy(
|
|
81
|
+
timeout=timeout,
|
|
82
|
+
initial_interval=poll_interval,
|
|
83
|
+
maximum_interval=max_interval,
|
|
84
|
+
backoff_factor=backoff_factor,
|
|
85
|
+
)
|
|
86
|
+
started = self.monotonic()
|
|
87
|
+
deadline = None if policy.timeout is None else started + policy.timeout
|
|
88
|
+
attempt = 0
|
|
89
|
+
last_value: T | None = None
|
|
90
|
+
|
|
91
|
+
while True:
|
|
92
|
+
if stop_event is not None and stop_event.is_set():
|
|
93
|
+
raise PollingCancelledError(last_value)
|
|
94
|
+
now = self.monotonic()
|
|
95
|
+
if deadline is not None and now >= deadline:
|
|
96
|
+
raise PollTimeoutError(
|
|
97
|
+
f"workflow did not finish within {policy.timeout!r} seconds",
|
|
98
|
+
timeout=policy.timeout,
|
|
99
|
+
last_value=last_value,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
remaining = None if deadline is None else max(0.0, deadline - now)
|
|
103
|
+
try:
|
|
104
|
+
if self.refresh_with_timeout is None:
|
|
105
|
+
last_value = self.refresh()
|
|
106
|
+
else:
|
|
107
|
+
last_value = self.refresh_with_timeout(remaining)
|
|
108
|
+
except Exception:
|
|
109
|
+
if deadline is not None and self.monotonic() >= deadline:
|
|
110
|
+
raise PollTimeoutError(
|
|
111
|
+
f"workflow did not finish within {policy.timeout!r} seconds",
|
|
112
|
+
timeout=policy.timeout,
|
|
113
|
+
last_value=last_value,
|
|
114
|
+
) from None
|
|
115
|
+
raise
|
|
116
|
+
now = self.monotonic()
|
|
117
|
+
if deadline is not None and now >= deadline:
|
|
118
|
+
raise PollTimeoutError(
|
|
119
|
+
f"workflow did not finish within {policy.timeout!r} seconds",
|
|
120
|
+
timeout=policy.timeout,
|
|
121
|
+
last_value=last_value,
|
|
122
|
+
)
|
|
123
|
+
outcome = self.classify(last_value)
|
|
124
|
+
if outcome is PollOutcome.SUCCEEDED:
|
|
125
|
+
return last_value
|
|
126
|
+
if outcome is PollOutcome.FAILED:
|
|
127
|
+
if raise_on_failure:
|
|
128
|
+
raise OperationFailedError(
|
|
129
|
+
"server-side workflow reached a failed terminal state",
|
|
130
|
+
final_state=last_value,
|
|
131
|
+
)
|
|
132
|
+
return last_value
|
|
133
|
+
if outcome is PollOutcome.NEEDS_INPUT:
|
|
134
|
+
if raise_on_failure:
|
|
135
|
+
raise OperationNeedsInputError(
|
|
136
|
+
"server-side workflow stopped and requires caller input",
|
|
137
|
+
final_state=last_value,
|
|
138
|
+
)
|
|
139
|
+
return last_value
|
|
140
|
+
|
|
141
|
+
exponential_delay = min(
|
|
142
|
+
policy.maximum_interval,
|
|
143
|
+
policy.initial_interval * (policy.backoff_factor**attempt),
|
|
144
|
+
)
|
|
145
|
+
requested_delay = (
|
|
146
|
+
None if self.suggested_delay is None else self.suggested_delay(last_value)
|
|
147
|
+
)
|
|
148
|
+
if requested_delay is not None and requested_delay <= 0:
|
|
149
|
+
requested_delay = None
|
|
150
|
+
delay = (
|
|
151
|
+
exponential_delay
|
|
152
|
+
if requested_delay is None
|
|
153
|
+
else max(0.0, min(policy.maximum_interval, requested_delay))
|
|
154
|
+
)
|
|
155
|
+
if deadline is not None:
|
|
156
|
+
delay = min(delay, max(0.0, deadline - now))
|
|
157
|
+
|
|
158
|
+
if stop_event is not None:
|
|
159
|
+
if stop_event.wait(delay):
|
|
160
|
+
raise PollingCancelledError(last_value)
|
|
161
|
+
else:
|
|
162
|
+
self.sleep(delay)
|
|
163
|
+
attempt += 1
|
redblack/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
redblack/raw.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Constrained access to RedBlack endpoints not yet modeled by the SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
from .response import ApiResponse
|
|
10
|
+
|
|
11
|
+
_PROTECTED_HEADERS = frozenset(
|
|
12
|
+
{
|
|
13
|
+
"accept-version",
|
|
14
|
+
"authorization",
|
|
15
|
+
"client-id",
|
|
16
|
+
"cookie",
|
|
17
|
+
"host",
|
|
18
|
+
"proxy-authorization",
|
|
19
|
+
"x-api-key",
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
ExpectedStatuses = Sequence[int] | set[int] | frozenset[int]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _RawTransport(Protocol):
|
|
26
|
+
def request(
|
|
27
|
+
self,
|
|
28
|
+
method: str,
|
|
29
|
+
path: str,
|
|
30
|
+
*,
|
|
31
|
+
params: Mapping[str, object] | None = None,
|
|
32
|
+
headers: Mapping[str, str] | None = None,
|
|
33
|
+
content: bytes | str | None = None,
|
|
34
|
+
expected_statuses: ExpectedStatuses | None = None,
|
|
35
|
+
authenticated: bool = True,
|
|
36
|
+
allow_retry: bool | None = None,
|
|
37
|
+
api_version: str | None = None,
|
|
38
|
+
) -> ApiResponse[bytes]: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RawClient:
|
|
42
|
+
"""Send a same-origin request through the configured SDK transport.
|
|
43
|
+
|
|
44
|
+
This is an escape hatch for a future RedBlack operation. It deliberately
|
|
45
|
+
accepts only relative API paths and does not permit callers to replace
|
|
46
|
+
credential-bearing headers.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, transport: _RawTransport) -> None:
|
|
50
|
+
self._transport = transport
|
|
51
|
+
|
|
52
|
+
def request(
|
|
53
|
+
self,
|
|
54
|
+
method: str,
|
|
55
|
+
path: str,
|
|
56
|
+
*,
|
|
57
|
+
params: Mapping[str, object] | None = None,
|
|
58
|
+
headers: Mapping[str, str] | None = None,
|
|
59
|
+
content: bytes | str | None = None,
|
|
60
|
+
expected_statuses: ExpectedStatuses | None = None,
|
|
61
|
+
api_version: str | None = None,
|
|
62
|
+
authenticated: bool = True,
|
|
63
|
+
allow_retry: bool | None = None,
|
|
64
|
+
) -> ApiResponse[bytes]:
|
|
65
|
+
"""Send a raw request while retaining SDK auth, errors, and retries."""
|
|
66
|
+
|
|
67
|
+
_validate_relative_path(path)
|
|
68
|
+
request_headers = dict(headers or {})
|
|
69
|
+
overridden = sorted(
|
|
70
|
+
name for name in request_headers if name.strip().lower() in _PROTECTED_HEADERS
|
|
71
|
+
)
|
|
72
|
+
if overridden:
|
|
73
|
+
names = ", ".join(overridden)
|
|
74
|
+
raise ValueError(f"raw requests cannot override protected headers: {names}")
|
|
75
|
+
return self._transport.request(
|
|
76
|
+
method=method,
|
|
77
|
+
path=path,
|
|
78
|
+
params=params,
|
|
79
|
+
headers=request_headers,
|
|
80
|
+
content=content,
|
|
81
|
+
expected_statuses=expected_statuses,
|
|
82
|
+
authenticated=authenticated,
|
|
83
|
+
allow_retry=allow_retry,
|
|
84
|
+
api_version=api_version,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _validate_relative_path(path: str) -> None:
|
|
89
|
+
if not isinstance(path, str) or not path:
|
|
90
|
+
raise ValueError("raw request path must be a non-empty string")
|
|
91
|
+
parsed = urlsplit(path)
|
|
92
|
+
if (
|
|
93
|
+
parsed.scheme
|
|
94
|
+
or parsed.netloc
|
|
95
|
+
or not parsed.path.startswith("/")
|
|
96
|
+
or parsed.path.startswith("//")
|
|
97
|
+
or parsed.query
|
|
98
|
+
or parsed.fragment
|
|
99
|
+
):
|
|
100
|
+
raise ValueError("raw requests require a relative same-origin API path")
|
|
101
|
+
if "\\" in path or any(ord(character) < 32 for character in path):
|
|
102
|
+
raise ValueError("raw request path contains unsafe characters")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Public RedBlack endpoint groups and workflow handles."""
|
|
2
|
+
|
|
3
|
+
from ._base import ClientProtocol, VersionedResource
|
|
4
|
+
from ._exports import ExportJobHandle, WorkflowProtocolError
|
|
5
|
+
from .accounts import AccountsResource
|
|
6
|
+
from .cash_actions import (
|
|
7
|
+
CONFIRM_DELETE_ALL_CASH_ACTIONS,
|
|
8
|
+
CashActionsResource,
|
|
9
|
+
)
|
|
10
|
+
from .equivalents import EquivalentsResource
|
|
11
|
+
from .households import HouseholdsResource
|
|
12
|
+
from .orders import OrdersResource
|
|
13
|
+
from .restrictions import RestrictionsResource
|
|
14
|
+
from .review_sets import (
|
|
15
|
+
OrderAddSessionHandle,
|
|
16
|
+
ReleaseCriteria,
|
|
17
|
+
ReleaseSessionHandle,
|
|
18
|
+
ReviewSetsResource,
|
|
19
|
+
)
|
|
20
|
+
from .securities import SecuritiesResource
|
|
21
|
+
from .tasks import TasksResource
|
|
22
|
+
from .users import UsersResource
|
|
23
|
+
|
|
24
|
+
RESOURCE_CLASSES = (
|
|
25
|
+
AccountsResource,
|
|
26
|
+
HouseholdsResource,
|
|
27
|
+
OrdersResource,
|
|
28
|
+
RestrictionsResource,
|
|
29
|
+
EquivalentsResource,
|
|
30
|
+
CashActionsResource,
|
|
31
|
+
SecuritiesResource,
|
|
32
|
+
ReviewSetsResource,
|
|
33
|
+
UsersResource,
|
|
34
|
+
TasksResource,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
IMPLEMENTED_OPERATION_IDS = frozenset().union(
|
|
38
|
+
*(resource.OPERATION_IDS for resource in RESOURCE_CLASSES)
|
|
39
|
+
)
|
|
40
|
+
"""Every 2026.3.0 operation reached through a public resource or handle."""
|
|
41
|
+
|
|
42
|
+
if len(IMPLEMENTED_OPERATION_IDS) != 44: # pragma: no cover - import invariant
|
|
43
|
+
raise RuntimeError("resource operation inventory must contain exactly 44 unique operations")
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"AccountsResource",
|
|
47
|
+
"CONFIRM_DELETE_ALL_CASH_ACTIONS",
|
|
48
|
+
"CashActionsResource",
|
|
49
|
+
"ClientProtocol",
|
|
50
|
+
"EquivalentsResource",
|
|
51
|
+
"ExportJobHandle",
|
|
52
|
+
"HouseholdsResource",
|
|
53
|
+
"IMPLEMENTED_OPERATION_IDS",
|
|
54
|
+
"OrderAddSessionHandle",
|
|
55
|
+
"OrdersResource",
|
|
56
|
+
"ReleaseCriteria",
|
|
57
|
+
"ReleaseSessionHandle",
|
|
58
|
+
"RestrictionsResource",
|
|
59
|
+
"ReviewSetsResource",
|
|
60
|
+
"SecuritiesResource",
|
|
61
|
+
"TasksResource",
|
|
62
|
+
"UsersResource",
|
|
63
|
+
"VersionedResource",
|
|
64
|
+
"WorkflowProtocolError",
|
|
65
|
+
]
|