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/config.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""Explicit, secret-safe configuration for the RedBlack SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import ssl
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from math import isfinite
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
14
|
+
|
|
15
|
+
from ._version import SUPPORTED_API_VERSION
|
|
16
|
+
from .errors import ConfigurationError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from .auth import TokenProvider
|
|
22
|
+
|
|
23
|
+
_UNSET = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Environment(str, Enum):
|
|
27
|
+
"""A named RedBlack deployment.
|
|
28
|
+
|
|
29
|
+
No environment is selected implicitly. In particular, production is never
|
|
30
|
+
a default.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
UAT = "uat"
|
|
34
|
+
PRODUCTION = "production"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_ENVIRONMENT_URLS: dict[Environment, str] = {
|
|
38
|
+
Environment.UAT: "https://api.uat.rbrebal.com",
|
|
39
|
+
Environment.PRODUCTION: "https://api.rbrebal.com",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
44
|
+
class PasswordCredentials:
|
|
45
|
+
"""Credentials for RedBlack's documented OAuth password grant."""
|
|
46
|
+
|
|
47
|
+
client_secret: str
|
|
48
|
+
username: str
|
|
49
|
+
password: str
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
_require_nonempty_secret(self.client_secret, "client_secret")
|
|
53
|
+
_require_nonempty_secret(self.username, "username")
|
|
54
|
+
_require_nonempty_secret(self.password, "password")
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
return (
|
|
58
|
+
"PasswordCredentials(client_secret=<redacted>, "
|
|
59
|
+
"username=<redacted>, password=<redacted>)"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class TimeoutConfig:
|
|
65
|
+
"""Per-phase HTTP timeout values, in seconds."""
|
|
66
|
+
|
|
67
|
+
connect: float = 10.0
|
|
68
|
+
read: float = 30.0
|
|
69
|
+
write: float = 30.0
|
|
70
|
+
pool: float = 10.0
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
for name in ("connect", "read", "write", "pool"):
|
|
74
|
+
value = getattr(self, name)
|
|
75
|
+
if (
|
|
76
|
+
isinstance(value, bool)
|
|
77
|
+
or not isinstance(value, int | float)
|
|
78
|
+
or not isfinite(value)
|
|
79
|
+
or value <= 0
|
|
80
|
+
):
|
|
81
|
+
raise ConfigurationError(f"{name} timeout must be a positive number")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class RetryConfig:
|
|
86
|
+
"""Conservative retry settings for safe reads.
|
|
87
|
+
|
|
88
|
+
``max_attempts`` includes the initial request. Mutating methods are never
|
|
89
|
+
retried by the built-in transport.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
max_attempts: int = 3
|
|
93
|
+
base_delay: float = 0.5
|
|
94
|
+
max_delay: float = 8.0
|
|
95
|
+
retry_statuses: frozenset[int] = field(
|
|
96
|
+
default_factory=lambda: frozenset({429, 500, 502, 503, 504})
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
if (
|
|
101
|
+
isinstance(self.max_attempts, bool)
|
|
102
|
+
or not isinstance(self.max_attempts, int)
|
|
103
|
+
or self.max_attempts < 1
|
|
104
|
+
):
|
|
105
|
+
raise ConfigurationError("retry max_attempts must be at least 1")
|
|
106
|
+
if (
|
|
107
|
+
isinstance(self.base_delay, bool)
|
|
108
|
+
or not isinstance(self.base_delay, int | float)
|
|
109
|
+
or not isfinite(self.base_delay)
|
|
110
|
+
or self.base_delay < 0
|
|
111
|
+
):
|
|
112
|
+
raise ConfigurationError("retry base_delay must be a non-negative number")
|
|
113
|
+
if (
|
|
114
|
+
isinstance(self.max_delay, bool)
|
|
115
|
+
or not isinstance(self.max_delay, int | float)
|
|
116
|
+
or not isfinite(self.max_delay)
|
|
117
|
+
or self.max_delay < self.base_delay
|
|
118
|
+
):
|
|
119
|
+
raise ConfigurationError("retry max_delay must be at least base_delay")
|
|
120
|
+
invalid = [
|
|
121
|
+
status
|
|
122
|
+
for status in self.retry_statuses
|
|
123
|
+
if isinstance(status, bool) or not isinstance(status, int) or not 100 <= status <= 599
|
|
124
|
+
]
|
|
125
|
+
if invalid:
|
|
126
|
+
raise ConfigurationError("retry_statuses must contain valid HTTP status codes")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
VerifyValue = bool | str | ssl.SSLContext
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
133
|
+
class RedBlackConfig:
|
|
134
|
+
"""Resolved configuration used by authentication and HTTP transport.
|
|
135
|
+
|
|
136
|
+
Constructing this class reads neither process variables nor files. Use
|
|
137
|
+
:meth:`from_env` or :meth:`from_dotenv` when that behavior is wanted.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
environment: Environment | str
|
|
141
|
+
api_key: str
|
|
142
|
+
client_id: str
|
|
143
|
+
api_version: str = SUPPORTED_API_VERSION
|
|
144
|
+
credentials: PasswordCredentials | None = None
|
|
145
|
+
access_token: str | None = None
|
|
146
|
+
base_url: str | None = None
|
|
147
|
+
timeout: TimeoutConfig = field(default_factory=TimeoutConfig)
|
|
148
|
+
retry: RetryConfig = field(default_factory=RetryConfig)
|
|
149
|
+
verify: VerifyValue = True
|
|
150
|
+
proxy: str | None = None
|
|
151
|
+
trust_env: bool = False
|
|
152
|
+
allow_insecure_transport: bool = False
|
|
153
|
+
allow_unsupported_api_version: bool = False
|
|
154
|
+
|
|
155
|
+
def __post_init__(self) -> None:
|
|
156
|
+
environment = _coerce_environment(self.environment)
|
|
157
|
+
object.__setattr__(self, "environment", environment)
|
|
158
|
+
|
|
159
|
+
_require_header_value(self.api_key, "api_key")
|
|
160
|
+
_require_header_value(self.client_id, "client_id")
|
|
161
|
+
if not isinstance(self.api_version, str) or not self.api_version.strip():
|
|
162
|
+
raise ConfigurationError("api_version must be a non-empty string")
|
|
163
|
+
if "\r" in self.api_version or "\n" in self.api_version:
|
|
164
|
+
raise ConfigurationError("api_version must not contain newlines")
|
|
165
|
+
if self.api_version != SUPPORTED_API_VERSION and not self.allow_unsupported_api_version:
|
|
166
|
+
raise ConfigurationError(
|
|
167
|
+
f"typed models support API {SUPPORTED_API_VERSION}; "
|
|
168
|
+
"set allow_unsupported_api_version=True for an explicit raw-mode override"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
custom_base_url = self.base_url is not None
|
|
172
|
+
resolved_url = self.base_url or _ENVIRONMENT_URLS[environment]
|
|
173
|
+
normalized_url = _validate_base_url(
|
|
174
|
+
resolved_url,
|
|
175
|
+
custom=custom_base_url,
|
|
176
|
+
allow_insecure=self.allow_insecure_transport,
|
|
177
|
+
)
|
|
178
|
+
object.__setattr__(self, "base_url", normalized_url)
|
|
179
|
+
|
|
180
|
+
if not isinstance(self.timeout, TimeoutConfig):
|
|
181
|
+
raise ConfigurationError("timeout must be a TimeoutConfig")
|
|
182
|
+
if not isinstance(self.retry, RetryConfig):
|
|
183
|
+
raise ConfigurationError("retry must be a RetryConfig")
|
|
184
|
+
if not isinstance(self.trust_env, bool):
|
|
185
|
+
raise ConfigurationError("trust_env must be a bool")
|
|
186
|
+
if not isinstance(self.allow_insecure_transport, bool):
|
|
187
|
+
raise ConfigurationError("allow_insecure_transport must be a bool")
|
|
188
|
+
if not isinstance(self.allow_unsupported_api_version, bool):
|
|
189
|
+
raise ConfigurationError("allow_unsupported_api_version must be a bool")
|
|
190
|
+
|
|
191
|
+
if self.verify is False and (not self.allow_insecure_transport or not custom_base_url):
|
|
192
|
+
raise ConfigurationError(
|
|
193
|
+
"TLS verification can be disabled only for an explicit custom test URL "
|
|
194
|
+
"with allow_insecure_transport=True"
|
|
195
|
+
)
|
|
196
|
+
if not isinstance(self.verify, bool | str | ssl.SSLContext):
|
|
197
|
+
raise ConfigurationError("verify must be a bool, CA path, or SSLContext")
|
|
198
|
+
if isinstance(self.verify, str) and not self.verify.strip():
|
|
199
|
+
raise ConfigurationError("verify CA path must not be empty")
|
|
200
|
+
|
|
201
|
+
if self.proxy is not None:
|
|
202
|
+
_validate_proxy(self.proxy)
|
|
203
|
+
|
|
204
|
+
if self.credentials is not None and not isinstance(self.credentials, PasswordCredentials):
|
|
205
|
+
raise ConfigurationError("credentials must be PasswordCredentials")
|
|
206
|
+
if self.access_token is not None:
|
|
207
|
+
_require_header_value(self.access_token, "access_token")
|
|
208
|
+
if self.credentials is not None and self.access_token is not None:
|
|
209
|
+
raise ConfigurationError(
|
|
210
|
+
"authentication is ambiguous: choose credentials or access_token, not both"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def auth_mode(self) -> str | None:
|
|
215
|
+
"""Return the configured built-in authentication mode, if any."""
|
|
216
|
+
|
|
217
|
+
if self.credentials is not None:
|
|
218
|
+
return "password"
|
|
219
|
+
if self.access_token is not None:
|
|
220
|
+
return "static"
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
@classmethod
|
|
224
|
+
def from_env(
|
|
225
|
+
cls,
|
|
226
|
+
*,
|
|
227
|
+
environ: Mapping[str, str] | None = None,
|
|
228
|
+
require_auth: bool = True,
|
|
229
|
+
**overrides: Any,
|
|
230
|
+
) -> RedBlackConfig:
|
|
231
|
+
"""Resolve configuration from explicit values, then process variables.
|
|
232
|
+
|
|
233
|
+
This method never reads a dotenv file. ``environ`` exists for testing
|
|
234
|
+
and embedding; when omitted, a snapshot of :data:`os.environ` is used.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
process_values = dict(os.environ if environ is None else environ)
|
|
238
|
+
return cls._from_sources(
|
|
239
|
+
process_values=process_values,
|
|
240
|
+
dotenv_values={},
|
|
241
|
+
overrides=overrides,
|
|
242
|
+
require_auth=require_auth,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def from_dotenv(
|
|
247
|
+
cls,
|
|
248
|
+
path: str | os.PathLike[str] | None = None,
|
|
249
|
+
*,
|
|
250
|
+
search_start: str | os.PathLike[str] | None = None,
|
|
251
|
+
environ: Mapping[str, str] | None = None,
|
|
252
|
+
require_auth: bool = True,
|
|
253
|
+
**overrides: Any,
|
|
254
|
+
) -> RedBlackConfig:
|
|
255
|
+
"""Resolve explicit values, process variables, then one bounded dotenv.
|
|
256
|
+
|
|
257
|
+
When ``path`` is omitted, discovery never walks beyond the nearest
|
|
258
|
+
``pyproject.toml`` or ``.git`` project boundary.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
dotenv_path = (
|
|
262
|
+
_explicit_dotenv_path(path)
|
|
263
|
+
if path is not None
|
|
264
|
+
else _discover_project_dotenv(search_start)
|
|
265
|
+
)
|
|
266
|
+
file_values = _read_dotenv(dotenv_path) if dotenv_path is not None else {}
|
|
267
|
+
process_values = dict(os.environ if environ is None else environ)
|
|
268
|
+
return cls._from_sources(
|
|
269
|
+
process_values=process_values,
|
|
270
|
+
dotenv_values=file_values,
|
|
271
|
+
overrides=overrides,
|
|
272
|
+
require_auth=require_auth,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def _from_sources(
|
|
277
|
+
cls,
|
|
278
|
+
*,
|
|
279
|
+
process_values: Mapping[str, str],
|
|
280
|
+
dotenv_values: Mapping[str, str | None],
|
|
281
|
+
overrides: Mapping[str, Any],
|
|
282
|
+
require_auth: bool,
|
|
283
|
+
) -> RedBlackConfig:
|
|
284
|
+
if not isinstance(require_auth, bool):
|
|
285
|
+
raise ConfigurationError("require_auth must be a bool")
|
|
286
|
+
remaining = dict(overrides)
|
|
287
|
+
allowed = {
|
|
288
|
+
"environment",
|
|
289
|
+
"api_key",
|
|
290
|
+
"client_id",
|
|
291
|
+
"api_version",
|
|
292
|
+
"credentials",
|
|
293
|
+
"client_secret",
|
|
294
|
+
"username",
|
|
295
|
+
"password",
|
|
296
|
+
"access_token",
|
|
297
|
+
"base_url",
|
|
298
|
+
"timeout",
|
|
299
|
+
"retry",
|
|
300
|
+
"verify",
|
|
301
|
+
"proxy",
|
|
302
|
+
"trust_env",
|
|
303
|
+
"allow_insecure_transport",
|
|
304
|
+
"allow_unsupported_api_version",
|
|
305
|
+
}
|
|
306
|
+
unknown = sorted(set(remaining) - allowed)
|
|
307
|
+
if unknown:
|
|
308
|
+
raise ConfigurationError(f"unknown configuration argument(s): {', '.join(unknown)}")
|
|
309
|
+
|
|
310
|
+
def resolve(field_name: str, variable_name: str, default: Any = _UNSET) -> Any:
|
|
311
|
+
if field_name in remaining:
|
|
312
|
+
return remaining.pop(field_name)
|
|
313
|
+
if variable_name in process_values:
|
|
314
|
+
return process_values[variable_name]
|
|
315
|
+
if variable_name in dotenv_values:
|
|
316
|
+
return dotenv_values[variable_name]
|
|
317
|
+
if default is not _UNSET:
|
|
318
|
+
return default
|
|
319
|
+
raise ConfigurationError(f"missing required setting {variable_name}")
|
|
320
|
+
|
|
321
|
+
environment = resolve("environment", "REDBLACK_ENVIRONMENT")
|
|
322
|
+
api_key = resolve("api_key", "REDBLACK_API_KEY")
|
|
323
|
+
client_id = resolve("client_id", "REDBLACK_CLIENT_ID")
|
|
324
|
+
api_version = resolve("api_version", "REDBLACK_API_VERSION", SUPPORTED_API_VERSION)
|
|
325
|
+
access_token = resolve("access_token", "REDBLACK_ACCESS_TOKEN", None)
|
|
326
|
+
|
|
327
|
+
explicit_credentials = remaining.pop("credentials", _UNSET)
|
|
328
|
+
if explicit_credentials is not _UNSET:
|
|
329
|
+
if any(name in remaining for name in ("client_secret", "username", "password")):
|
|
330
|
+
raise ConfigurationError(
|
|
331
|
+
"pass credentials or individual password-grant fields, not both"
|
|
332
|
+
)
|
|
333
|
+
credentials = explicit_credentials
|
|
334
|
+
else:
|
|
335
|
+
client_secret = resolve("client_secret", "REDBLACK_CLIENT_SECRET", None)
|
|
336
|
+
username = resolve("username", "REDBLACK_USERNAME", None)
|
|
337
|
+
password = resolve("password", "REDBLACK_PASSWORD", None)
|
|
338
|
+
password_values = (client_secret, username, password)
|
|
339
|
+
if any(value is not None for value in password_values):
|
|
340
|
+
missing = [
|
|
341
|
+
name
|
|
342
|
+
for name, value in zip(
|
|
343
|
+
("REDBLACK_CLIENT_SECRET", "REDBLACK_USERNAME", "REDBLACK_PASSWORD"),
|
|
344
|
+
password_values,
|
|
345
|
+
strict=True,
|
|
346
|
+
)
|
|
347
|
+
if value is None
|
|
348
|
+
]
|
|
349
|
+
if missing:
|
|
350
|
+
raise ConfigurationError(
|
|
351
|
+
"incomplete password-grant settings; missing " + ", ".join(missing)
|
|
352
|
+
)
|
|
353
|
+
credentials = PasswordCredentials(
|
|
354
|
+
client_secret=client_secret,
|
|
355
|
+
username=username,
|
|
356
|
+
password=password,
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
credentials = None
|
|
360
|
+
|
|
361
|
+
keyword_defaults: dict[str, Any] = {
|
|
362
|
+
"base_url": None,
|
|
363
|
+
"timeout": TimeoutConfig(),
|
|
364
|
+
"retry": RetryConfig(),
|
|
365
|
+
"verify": True,
|
|
366
|
+
"proxy": None,
|
|
367
|
+
"trust_env": False,
|
|
368
|
+
"allow_insecure_transport": False,
|
|
369
|
+
"allow_unsupported_api_version": False,
|
|
370
|
+
}
|
|
371
|
+
keyword_values = {
|
|
372
|
+
name: remaining.pop(name, default) for name, default in keyword_defaults.items()
|
|
373
|
+
}
|
|
374
|
+
if remaining:
|
|
375
|
+
# Defensive: the allowlist and extraction above should make this unreachable.
|
|
376
|
+
raise ConfigurationError(
|
|
377
|
+
f"unhandled configuration argument(s): {', '.join(sorted(remaining))}"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
config = cls(
|
|
381
|
+
environment=environment,
|
|
382
|
+
api_key=api_key,
|
|
383
|
+
client_id=client_id,
|
|
384
|
+
api_version=api_version,
|
|
385
|
+
credentials=credentials,
|
|
386
|
+
access_token=access_token,
|
|
387
|
+
**keyword_values,
|
|
388
|
+
)
|
|
389
|
+
if require_auth and config.auth_mode is None:
|
|
390
|
+
raise ConfigurationError(
|
|
391
|
+
"missing authentication: configure REDBLACK_ACCESS_TOKEN or all of "
|
|
392
|
+
"REDBLACK_CLIENT_SECRET, REDBLACK_USERNAME, and REDBLACK_PASSWORD"
|
|
393
|
+
)
|
|
394
|
+
return config
|
|
395
|
+
|
|
396
|
+
def build_token_provider(self, *, http_client: httpx.Client | None = None) -> TokenProvider:
|
|
397
|
+
"""Build the configured built-in token provider.
|
|
398
|
+
|
|
399
|
+
Applications using a custom provider should construct it themselves
|
|
400
|
+
and leave ``credentials`` and ``access_token`` unset.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
from .auth import PasswordGrantTokenProvider, StaticTokenProvider
|
|
404
|
+
|
|
405
|
+
if self.access_token is not None:
|
|
406
|
+
return StaticTokenProvider(self.access_token, client_id=self.client_id)
|
|
407
|
+
if self.credentials is not None:
|
|
408
|
+
return PasswordGrantTokenProvider(self, self.credentials, client=http_client)
|
|
409
|
+
raise ConfigurationError(
|
|
410
|
+
"no built-in authentication mode is configured; provide a token provider"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def __repr__(self) -> str:
|
|
414
|
+
return (
|
|
415
|
+
"RedBlackConfig("
|
|
416
|
+
f"environment={self.environment!r}, "
|
|
417
|
+
f"api_version={self.api_version!r}, "
|
|
418
|
+
f"base_url={self.base_url!r}, "
|
|
419
|
+
"api_key=<redacted>, client_id=<redacted>, "
|
|
420
|
+
f"auth_mode={self.auth_mode!r}, "
|
|
421
|
+
f"timeout={self.timeout!r}, retry={self.retry!r}, "
|
|
422
|
+
f"verify={_safe_verify_repr(self.verify)}, proxy={_safe_proxy_repr(self.proxy)}, "
|
|
423
|
+
f"trust_env={self.trust_env!r})"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _coerce_environment(value: Environment | str) -> Environment:
|
|
428
|
+
if isinstance(value, Environment):
|
|
429
|
+
return value
|
|
430
|
+
if isinstance(value, str):
|
|
431
|
+
try:
|
|
432
|
+
return Environment(value.strip().lower())
|
|
433
|
+
except ValueError:
|
|
434
|
+
pass
|
|
435
|
+
raise ConfigurationError("environment must be explicitly set to 'uat' or 'production'")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _require_nonempty_secret(value: object, field_name: str) -> None:
|
|
439
|
+
if not isinstance(value, str) or not value.strip():
|
|
440
|
+
raise ConfigurationError(f"{field_name} must be a non-empty string")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _require_header_value(value: object, field_name: str) -> None:
|
|
444
|
+
_require_nonempty_secret(value, field_name)
|
|
445
|
+
if isinstance(value, str) and ("\r" in value or "\n" in value):
|
|
446
|
+
raise ConfigurationError(f"{field_name} must not contain newlines")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _validate_base_url(value: object, *, custom: bool, allow_insecure: bool) -> str:
|
|
450
|
+
if not isinstance(value, str) or not value.strip():
|
|
451
|
+
raise ConfigurationError("base_url must be a non-empty URL")
|
|
452
|
+
if any(ord(character) < 32 for character in value):
|
|
453
|
+
raise ConfigurationError("base_url must not contain control characters")
|
|
454
|
+
try:
|
|
455
|
+
split = urlsplit(value.strip())
|
|
456
|
+
_ = split.port
|
|
457
|
+
except ValueError as exc:
|
|
458
|
+
raise ConfigurationError("base_url is malformed") from exc
|
|
459
|
+
if split.username is not None or split.password is not None:
|
|
460
|
+
raise ConfigurationError("base_url must not contain user information")
|
|
461
|
+
if not split.hostname or split.query or split.fragment:
|
|
462
|
+
raise ConfigurationError("base_url must have a host and no query or fragment")
|
|
463
|
+
if split.scheme != "https":
|
|
464
|
+
local_hosts = {"localhost", "127.0.0.1", "::1"}
|
|
465
|
+
if not (
|
|
466
|
+
custom
|
|
467
|
+
and allow_insecure
|
|
468
|
+
and split.scheme == "http"
|
|
469
|
+
and split.hostname.lower() in local_hosts
|
|
470
|
+
):
|
|
471
|
+
raise ConfigurationError(
|
|
472
|
+
"base_url must use HTTPS; insecure HTTP is limited to an explicit "
|
|
473
|
+
"localhost test URL"
|
|
474
|
+
)
|
|
475
|
+
normalized_path = split.path.rstrip("/")
|
|
476
|
+
return urlunsplit((split.scheme, split.netloc, normalized_path, "", ""))
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _validate_proxy(value: object) -> None:
|
|
480
|
+
if not isinstance(value, str) or not value.strip():
|
|
481
|
+
raise ConfigurationError("proxy must be a non-empty URL")
|
|
482
|
+
try:
|
|
483
|
+
split = urlsplit(value)
|
|
484
|
+
_ = split.port
|
|
485
|
+
except ValueError as exc:
|
|
486
|
+
raise ConfigurationError("proxy URL is malformed") from exc
|
|
487
|
+
if split.scheme not in {"http", "https"} or not split.hostname:
|
|
488
|
+
raise ConfigurationError("proxy must be an HTTP or HTTPS URL")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _safe_proxy_repr(proxy: str | None) -> str:
|
|
492
|
+
if proxy is None:
|
|
493
|
+
return "None"
|
|
494
|
+
split = urlsplit(proxy)
|
|
495
|
+
safe = urlunsplit((split.scheme, split.hostname or "", split.path, "", ""))
|
|
496
|
+
return repr(safe)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _safe_verify_repr(verify: VerifyValue) -> str:
|
|
500
|
+
if isinstance(verify, ssl.SSLContext):
|
|
501
|
+
return "<SSLContext>"
|
|
502
|
+
return repr(verify)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _explicit_dotenv_path(path: str | os.PathLike[str]) -> Path:
|
|
506
|
+
resolved = Path(path).expanduser().resolve()
|
|
507
|
+
if not resolved.is_file():
|
|
508
|
+
raise ConfigurationError(f"dotenv file does not exist: {resolved}")
|
|
509
|
+
return resolved
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _discover_project_dotenv(
|
|
513
|
+
search_start: str | os.PathLike[str] | None,
|
|
514
|
+
) -> Path | None:
|
|
515
|
+
start = Path.cwd() if search_start is None else Path(search_start).expanduser()
|
|
516
|
+
try:
|
|
517
|
+
start = start.resolve(strict=True)
|
|
518
|
+
except FileNotFoundError as exc:
|
|
519
|
+
raise ConfigurationError(f"dotenv search_start does not exist: {start}") from exc
|
|
520
|
+
if start.is_file():
|
|
521
|
+
start = start.parent
|
|
522
|
+
|
|
523
|
+
ancestors = (start, *start.parents)
|
|
524
|
+
boundary: Path | None = None
|
|
525
|
+
for candidate in ancestors:
|
|
526
|
+
if (candidate / "pyproject.toml").exists() or (candidate / ".git").exists():
|
|
527
|
+
boundary = candidate
|
|
528
|
+
break
|
|
529
|
+
|
|
530
|
+
if boundary is None:
|
|
531
|
+
candidate = start / ".env"
|
|
532
|
+
return (
|
|
533
|
+
_resolve_discovered_dotenv(candidate, boundary=start) if candidate.is_file() else None
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
for candidate_dir in ancestors:
|
|
537
|
+
candidate = candidate_dir / ".env"
|
|
538
|
+
if candidate.is_file():
|
|
539
|
+
return _resolve_discovered_dotenv(candidate, boundary=boundary)
|
|
540
|
+
if candidate_dir == boundary:
|
|
541
|
+
break
|
|
542
|
+
return None
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _resolve_discovered_dotenv(candidate: Path, *, boundary: Path) -> Path:
|
|
546
|
+
"""Resolve a discovered dotenv without allowing a symlink outside its project."""
|
|
547
|
+
|
|
548
|
+
resolved = candidate.resolve(strict=True)
|
|
549
|
+
try:
|
|
550
|
+
resolved.relative_to(boundary)
|
|
551
|
+
except ValueError as exc:
|
|
552
|
+
raise ConfigurationError(
|
|
553
|
+
f"discovered dotenv file escapes the project boundary: {candidate}"
|
|
554
|
+
) from exc
|
|
555
|
+
return resolved
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _read_dotenv(path: Path) -> dict[str, str | None]:
|
|
559
|
+
try:
|
|
560
|
+
from dotenv import dotenv_values
|
|
561
|
+
except ImportError as exc: # pragma: no cover - declared runtime dependency
|
|
562
|
+
raise ConfigurationError("python-dotenv is required to use from_dotenv()") from exc
|
|
563
|
+
try:
|
|
564
|
+
return dict(dotenv_values(dotenv_path=path, encoding="utf-8"))
|
|
565
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
566
|
+
raise ConfigurationError(f"could not parse dotenv file: {path}") from exc
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
__all__ = [
|
|
570
|
+
"Environment",
|
|
571
|
+
"PasswordCredentials",
|
|
572
|
+
"RedBlackConfig",
|
|
573
|
+
"RetryConfig",
|
|
574
|
+
"TimeoutConfig",
|
|
575
|
+
"VerifyValue",
|
|
576
|
+
]
|