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/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
+ ]