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/downloads.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Credential-free, validated downloads for short-lived export URLs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
10
|
+
from contextlib import ExitStack, contextmanager, suppress
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, BinaryIO, cast
|
|
14
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .errors import RedBlackError
|
|
19
|
+
|
|
20
|
+
_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
|
|
21
|
+
_SAFE_DOWNLOAD_HEADERS = {
|
|
22
|
+
"Accept": "text/csv, application/octet-stream;q=0.9",
|
|
23
|
+
# Pinned URLs use the IP address as the HTTPX connection-pool origin.
|
|
24
|
+
# Prevent a redirect to another hostname on that IP from reusing TLS with
|
|
25
|
+
# the first hostname's SNI identity.
|
|
26
|
+
"Connection": "close",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DownloadError(RedBlackError):
|
|
31
|
+
"""Base class for export-storage download failures."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DownloadSecurityError(DownloadError):
|
|
35
|
+
"""The supplied storage URL violates the download security policy."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DownloadExistsError(DownloadError):
|
|
39
|
+
"""The destination already exists and overwrite was not enabled."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
AddressResolver = Callable[[str, int], Iterable[str]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _default_resolver(host: str, port: int) -> Iterable[str]:
|
|
46
|
+
addresses: list[str] = []
|
|
47
|
+
seen: set[str] = set()
|
|
48
|
+
for item in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM):
|
|
49
|
+
address = cast(str, item[4][0])
|
|
50
|
+
if address not in seen:
|
|
51
|
+
seen.add(address)
|
|
52
|
+
addresses.append(address)
|
|
53
|
+
return addresses
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unsafe_address(address: str) -> bool:
|
|
57
|
+
parsed = ipaddress.ip_address(address)
|
|
58
|
+
return any(
|
|
59
|
+
(
|
|
60
|
+
parsed.is_private,
|
|
61
|
+
parsed.is_loopback,
|
|
62
|
+
parsed.is_link_local,
|
|
63
|
+
parsed.is_multicast,
|
|
64
|
+
parsed.is_reserved,
|
|
65
|
+
parsed.is_unspecified,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class StorageUrlPolicy:
|
|
72
|
+
"""Controls which unauthenticated storage URLs may be fetched."""
|
|
73
|
+
|
|
74
|
+
allowed_hosts: frozenset[str] | None = None
|
|
75
|
+
max_redirects: int = 3
|
|
76
|
+
resolve_hosts: bool = True
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
if self.max_redirects < 0:
|
|
80
|
+
raise ValueError("max_redirects must be greater than or equal to zero")
|
|
81
|
+
if self.allowed_hosts is not None:
|
|
82
|
+
normalized = frozenset(host.lower().rstrip(".") for host in self.allowed_hosts)
|
|
83
|
+
if not normalized or any(not host for host in normalized):
|
|
84
|
+
raise ValueError("allowed_hosts must contain valid host names")
|
|
85
|
+
object.__setattr__(self, "allowed_hosts", normalized)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class _ValidatedStorageTarget:
|
|
90
|
+
host: str
|
|
91
|
+
port: int
|
|
92
|
+
path: str
|
|
93
|
+
query: str
|
|
94
|
+
addresses: tuple[str, ...]
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def host_header(self) -> str:
|
|
98
|
+
host = f"[{self.host}]" if ":" in self.host else self.host
|
|
99
|
+
return f"{host}:{self.port}" if self.port != 443 else host
|
|
100
|
+
|
|
101
|
+
def pinned_url(self, address: str) -> str:
|
|
102
|
+
authority = f"[{address}]" if ":" in address else address
|
|
103
|
+
if self.port != 443:
|
|
104
|
+
authority = f"{authority}:{self.port}"
|
|
105
|
+
return urlunsplit(("https", authority, self.path, self.query, ""))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _canonical_host(host: str) -> str:
|
|
109
|
+
try:
|
|
110
|
+
return str(ipaddress.ip_address(host))
|
|
111
|
+
except ValueError:
|
|
112
|
+
pass
|
|
113
|
+
try:
|
|
114
|
+
return host.encode("idna").decode("ascii")
|
|
115
|
+
except UnicodeError as exc:
|
|
116
|
+
raise DownloadSecurityError("storage URL contains an invalid host") from exc
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _validate_storage_target(
|
|
120
|
+
url: str,
|
|
121
|
+
*,
|
|
122
|
+
policy: StorageUrlPolicy,
|
|
123
|
+
resolver: AddressResolver | None,
|
|
124
|
+
) -> _ValidatedStorageTarget:
|
|
125
|
+
if not isinstance(url, str) or not url:
|
|
126
|
+
raise DownloadSecurityError("storage URL is missing")
|
|
127
|
+
parsed = urlsplit(url)
|
|
128
|
+
if parsed.scheme.lower() != "https":
|
|
129
|
+
raise DownloadSecurityError("storage URL must use HTTPS")
|
|
130
|
+
if parsed.username is not None or parsed.password is not None:
|
|
131
|
+
raise DownloadSecurityError("storage URL must not contain user information")
|
|
132
|
+
if not parsed.hostname:
|
|
133
|
+
raise DownloadSecurityError("storage URL must include a host")
|
|
134
|
+
try:
|
|
135
|
+
parsed_port = parsed.port
|
|
136
|
+
except ValueError as exc:
|
|
137
|
+
raise DownloadSecurityError("storage URL contains an invalid port") from exc
|
|
138
|
+
port = 443 if parsed_port is None else parsed_port
|
|
139
|
+
if port == 0:
|
|
140
|
+
raise DownloadSecurityError("storage URL contains an invalid port")
|
|
141
|
+
|
|
142
|
+
source_host = parsed.hostname.lower().rstrip(".")
|
|
143
|
+
if not source_host:
|
|
144
|
+
raise DownloadSecurityError("storage URL must include a host")
|
|
145
|
+
host = _canonical_host(source_host)
|
|
146
|
+
if host == "localhost" or host.endswith(".localhost"):
|
|
147
|
+
raise DownloadSecurityError("local storage targets are not permitted")
|
|
148
|
+
if policy.allowed_hosts is not None and source_host not in policy.allowed_hosts:
|
|
149
|
+
raise DownloadSecurityError("storage host is not in the configured allowlist")
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
literal = ipaddress.ip_address(host)
|
|
153
|
+
except ValueError:
|
|
154
|
+
literal = None
|
|
155
|
+
if literal is not None:
|
|
156
|
+
if _unsafe_address(str(literal)):
|
|
157
|
+
raise DownloadSecurityError("private or local storage targets are not permitted")
|
|
158
|
+
addresses = (str(literal),)
|
|
159
|
+
elif policy.resolve_hosts:
|
|
160
|
+
resolve = resolver or _default_resolver
|
|
161
|
+
try:
|
|
162
|
+
resolved = tuple(resolve(source_host, port))
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
raise DownloadSecurityError("storage host could not be resolved") from exc
|
|
165
|
+
if not resolved:
|
|
166
|
+
raise DownloadSecurityError("storage host did not resolve to an address")
|
|
167
|
+
|
|
168
|
+
canonical_addresses: list[str] = []
|
|
169
|
+
seen_addresses: set[str] = set()
|
|
170
|
+
try:
|
|
171
|
+
for address in resolved:
|
|
172
|
+
canonical = str(ipaddress.ip_address(address))
|
|
173
|
+
if _unsafe_address(canonical):
|
|
174
|
+
raise DownloadSecurityError(
|
|
175
|
+
"storage host resolves to a private or local address"
|
|
176
|
+
)
|
|
177
|
+
if canonical not in seen_addresses:
|
|
178
|
+
seen_addresses.add(canonical)
|
|
179
|
+
canonical_addresses.append(canonical)
|
|
180
|
+
except ValueError as exc:
|
|
181
|
+
raise DownloadSecurityError("storage host resolved to an invalid address") from exc
|
|
182
|
+
addresses = tuple(canonical_addresses)
|
|
183
|
+
else:
|
|
184
|
+
addresses = ()
|
|
185
|
+
|
|
186
|
+
return _ValidatedStorageTarget(
|
|
187
|
+
host=host,
|
|
188
|
+
port=port,
|
|
189
|
+
path=parsed.path,
|
|
190
|
+
query=parsed.query,
|
|
191
|
+
addresses=addresses,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def validate_storage_url(
|
|
196
|
+
url: str,
|
|
197
|
+
*,
|
|
198
|
+
policy: StorageUrlPolicy | None = None,
|
|
199
|
+
resolver: AddressResolver | None = None,
|
|
200
|
+
) -> str:
|
|
201
|
+
"""Validate one storage URL and return it unchanged.
|
|
202
|
+
|
|
203
|
+
Every redirect target must be passed through this function again. This
|
|
204
|
+
function intentionally accepts only HTTPS URLs without embedded user info.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
_validate_storage_target(
|
|
208
|
+
url,
|
|
209
|
+
policy=policy or StorageUrlPolicy(),
|
|
210
|
+
resolver=resolver,
|
|
211
|
+
)
|
|
212
|
+
return url
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
|
216
|
+
target = name.casefold()
|
|
217
|
+
for key, value in headers.items():
|
|
218
|
+
if key.casefold() == target:
|
|
219
|
+
return value
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True, slots=True)
|
|
224
|
+
class DownloadResult:
|
|
225
|
+
path: Path
|
|
226
|
+
bytes_written: int
|
|
227
|
+
content_type: str | None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Downloader:
|
|
231
|
+
"""Use a separate HTTP client so RedBlack credentials cannot leak."""
|
|
232
|
+
|
|
233
|
+
def __init__(
|
|
234
|
+
self,
|
|
235
|
+
*,
|
|
236
|
+
policy: StorageUrlPolicy | None = None,
|
|
237
|
+
client: Any | None = None,
|
|
238
|
+
resolver: AddressResolver | None = None,
|
|
239
|
+
) -> None:
|
|
240
|
+
self._policy = policy or StorageUrlPolicy()
|
|
241
|
+
if client is not None:
|
|
242
|
+
self._validate_injected_client(client)
|
|
243
|
+
self._client = client
|
|
244
|
+
self._resolver = resolver
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _validate_injected_client(client: object) -> None:
|
|
248
|
+
"""Reject defaults that could be merged into a cross-origin request."""
|
|
249
|
+
|
|
250
|
+
sensitive = {
|
|
251
|
+
"accept-version",
|
|
252
|
+
"authorization",
|
|
253
|
+
"client-id",
|
|
254
|
+
"cookie",
|
|
255
|
+
"proxy-authorization",
|
|
256
|
+
"x-api-key",
|
|
257
|
+
}
|
|
258
|
+
headers = getattr(client, "headers", None)
|
|
259
|
+
if isinstance(headers, Mapping):
|
|
260
|
+
present = {str(name).casefold() for name in headers}
|
|
261
|
+
leaked = sensitive.intersection(present)
|
|
262
|
+
if leaked:
|
|
263
|
+
raise DownloadSecurityError(
|
|
264
|
+
"injected download client has credential-bearing default headers"
|
|
265
|
+
)
|
|
266
|
+
cookies = getattr(client, "cookies", None)
|
|
267
|
+
if cookies is not None:
|
|
268
|
+
try:
|
|
269
|
+
has_cookies = len(cookies) > 0
|
|
270
|
+
except (TypeError, AttributeError):
|
|
271
|
+
has_cookies = bool(cookies)
|
|
272
|
+
if has_cookies:
|
|
273
|
+
raise DownloadSecurityError("injected download client must not contain cookies")
|
|
274
|
+
configured_auth = getattr(client, "_auth", None)
|
|
275
|
+
if configured_auth is not None:
|
|
276
|
+
raise DownloadSecurityError(
|
|
277
|
+
"injected download client must not have default authentication"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
@contextmanager
|
|
281
|
+
def _pinned_response(
|
|
282
|
+
self,
|
|
283
|
+
client: Any,
|
|
284
|
+
target: _ValidatedStorageTarget,
|
|
285
|
+
) -> Iterator[Any]:
|
|
286
|
+
if not target.addresses:
|
|
287
|
+
raise DownloadSecurityError(
|
|
288
|
+
"storage downloads require resolve_hosts=True for hostname pinning"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
last_error: httpx.TransportError | None = None
|
|
292
|
+
for address in target.addresses:
|
|
293
|
+
headers = dict(_SAFE_DOWNLOAD_HEADERS)
|
|
294
|
+
headers["Host"] = target.host_header
|
|
295
|
+
with ExitStack() as stack:
|
|
296
|
+
try:
|
|
297
|
+
response = stack.enter_context(
|
|
298
|
+
client.stream(
|
|
299
|
+
"GET",
|
|
300
|
+
target.pinned_url(address),
|
|
301
|
+
headers=headers,
|
|
302
|
+
follow_redirects=False,
|
|
303
|
+
# HTTPcore uses this extension for both TLS SNI and
|
|
304
|
+
# certificate hostname verification while TCP is
|
|
305
|
+
# connected to the vetted IP address.
|
|
306
|
+
extensions={"sni_hostname": target.host},
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
except httpx.TransportError as exc:
|
|
310
|
+
last_error = exc
|
|
311
|
+
continue
|
|
312
|
+
yield response
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
raise DownloadError("storage download failed before receiving a response") from last_error
|
|
316
|
+
|
|
317
|
+
@contextmanager
|
|
318
|
+
def stream(
|
|
319
|
+
self, url: str, *, chunk_size: int = 64 * 1024
|
|
320
|
+
) -> Iterator[tuple[Iterator[bytes], str | None]]:
|
|
321
|
+
"""Yield a byte iterator and content type, closing all responses safely."""
|
|
322
|
+
|
|
323
|
+
if chunk_size <= 0:
|
|
324
|
+
raise ValueError("chunk_size must be greater than zero")
|
|
325
|
+
client = self._client or httpx.Client(
|
|
326
|
+
follow_redirects=False,
|
|
327
|
+
trust_env=False,
|
|
328
|
+
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
329
|
+
)
|
|
330
|
+
owns_client = self._client is None
|
|
331
|
+
current = url
|
|
332
|
+
try:
|
|
333
|
+
for redirect_count in range(self._policy.max_redirects + 1):
|
|
334
|
+
target = _validate_storage_target(
|
|
335
|
+
current,
|
|
336
|
+
policy=self._policy,
|
|
337
|
+
resolver=self._resolver,
|
|
338
|
+
)
|
|
339
|
+
with self._pinned_response(client, target) as response:
|
|
340
|
+
if response.status_code in _REDIRECT_STATUSES:
|
|
341
|
+
if redirect_count >= self._policy.max_redirects:
|
|
342
|
+
raise DownloadError("storage redirect limit exceeded")
|
|
343
|
+
location = _header(response.headers, "Location")
|
|
344
|
+
if not location:
|
|
345
|
+
raise DownloadError("storage redirect omitted Location")
|
|
346
|
+
current = urljoin(current, location)
|
|
347
|
+
continue
|
|
348
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
349
|
+
raise DownloadError(
|
|
350
|
+
f"storage download failed with HTTP {response.status_code}"
|
|
351
|
+
)
|
|
352
|
+
yield response.iter_bytes(chunk_size), _header(response.headers, "Content-Type")
|
|
353
|
+
return
|
|
354
|
+
raise DownloadError("storage redirect limit exceeded")
|
|
355
|
+
finally:
|
|
356
|
+
if owns_client:
|
|
357
|
+
client.close()
|
|
358
|
+
|
|
359
|
+
def download_to(
|
|
360
|
+
self,
|
|
361
|
+
url: str,
|
|
362
|
+
destination: str | os.PathLike[str],
|
|
363
|
+
*,
|
|
364
|
+
overwrite: bool = False,
|
|
365
|
+
chunk_size: int = 64 * 1024,
|
|
366
|
+
) -> DownloadResult:
|
|
367
|
+
"""Stream to a same-directory temporary file, then publish atomically."""
|
|
368
|
+
|
|
369
|
+
if chunk_size <= 0:
|
|
370
|
+
raise ValueError("chunk_size must be greater than zero")
|
|
371
|
+
target = Path(destination)
|
|
372
|
+
parent = target.parent
|
|
373
|
+
if not parent.exists():
|
|
374
|
+
raise FileNotFoundError(f"destination directory does not exist: {parent}")
|
|
375
|
+
if target.exists() and not overwrite:
|
|
376
|
+
raise DownloadExistsError(f"destination already exists: {target}")
|
|
377
|
+
|
|
378
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
379
|
+
dir=parent, prefix=f".{target.name}.", suffix=".part"
|
|
380
|
+
)
|
|
381
|
+
temporary = Path(temporary_name)
|
|
382
|
+
bytes_written = 0
|
|
383
|
+
content_type: str | None = None
|
|
384
|
+
try:
|
|
385
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
386
|
+
output_file: BinaryIO = output
|
|
387
|
+
with self.stream(url, chunk_size=chunk_size) as (chunks, content_type):
|
|
388
|
+
for chunk in chunks:
|
|
389
|
+
if not chunk:
|
|
390
|
+
continue
|
|
391
|
+
output_file.write(chunk)
|
|
392
|
+
bytes_written += len(chunk)
|
|
393
|
+
output_file.flush()
|
|
394
|
+
os.fsync(output_file.fileno())
|
|
395
|
+
|
|
396
|
+
if overwrite:
|
|
397
|
+
os.replace(temporary, target)
|
|
398
|
+
else:
|
|
399
|
+
try:
|
|
400
|
+
os.link(temporary, target)
|
|
401
|
+
except FileExistsError as exc:
|
|
402
|
+
raise DownloadExistsError(f"destination already exists: {target}") from exc
|
|
403
|
+
temporary.unlink()
|
|
404
|
+
return DownloadResult(
|
|
405
|
+
path=target, bytes_written=bytes_written, content_type=content_type
|
|
406
|
+
)
|
|
407
|
+
except BaseException:
|
|
408
|
+
with suppress(OSError):
|
|
409
|
+
temporary.unlink(missing_ok=True)
|
|
410
|
+
raise
|