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