sg-python-api 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.
@@ -0,0 +1,1463 @@
1
+ """Locked client configuration and immutable operation snapshots."""
2
+
3
+ # Same-file collaborators access configuration implementation state.
4
+ # pyright: reportPrivateUsage=false
5
+
6
+ import base64
7
+ import copy
8
+ import dataclasses
9
+ import ipaddress
10
+ import logging
11
+ import math
12
+ import os
13
+ import threading
14
+ import urllib.parse
15
+ from collections.abc import Callable, Mapping
16
+ from types import MappingProxyType
17
+ from typing import TYPE_CHECKING, Final, Literal, TypeAlias, cast
18
+
19
+ from . import _errors, _protocol
20
+ from ._sentinel import Unset as _Unset
21
+ from ._transport import TimeoutConfig
22
+ from ._version import PACKAGE_VERSION
23
+
24
+ CompatibilityWarningCode: TypeAlias = Literal[
25
+ 'deprecated-entity-types-alias',
26
+ 'deprecated-schema-alias',
27
+ 'legacy-infinite-timeout',
28
+ ]
29
+ EventRoute: TypeAlias = Literal[
30
+ 'allocation',
31
+ 'download',
32
+ 'finalize',
33
+ 'link',
34
+ 'object_put',
35
+ 'part_put',
36
+ 'read',
37
+ 'rpc',
38
+ ]
39
+ ObserverEvent: TypeAlias = (
40
+ 'CompatibilityWarningEvent | ProgressEvent | RequestEvent | ResponseEvent | RetryEvent'
41
+ )
42
+ ObserverCallback: TypeAlias = Callable[[ObserverEvent], None]
43
+
44
+ Outcome: TypeAlias = Literal['error', 'retry', 'success']
45
+
46
+ _LOG = logging.getLogger('shotgun_api3')
47
+
48
+ _ASCII_CONTROL_LIMIT = 32
49
+ _ASCII_DELETE = 127
50
+ _AUTH_FIELDS = (
51
+ 'api_key',
52
+ 'auth_token',
53
+ 'extra_auth_params',
54
+ 'script_name',
55
+ 'session_token',
56
+ 'session_uuid',
57
+ 'sudo_as_login',
58
+ 'user_login',
59
+ 'user_password',
60
+ )
61
+ _DEDICATED_AUTH_STRING_FIELDS = frozenset(
62
+ {
63
+ 'api_key',
64
+ 'auth_token',
65
+ 'script_name',
66
+ 'session_token',
67
+ 'session_uuid',
68
+ 'sudo_as_login',
69
+ 'user_login',
70
+ 'user_password',
71
+ }
72
+ )
73
+ _DEFAULT_CONNECT_TIMEOUT = 15.0
74
+ _DEFAULT_PROXY_PORT = 8080
75
+ _DEFAULT_READ_TIMEOUT = 300.0
76
+ _MAX_PORT = 65535
77
+ _OBSERVER_FIELDS = (
78
+ 'on_compatibility_warning',
79
+ 'on_progress',
80
+ 'on_request',
81
+ 'on_response',
82
+ 'on_retry',
83
+ )
84
+ _ORIGIN_FIELDS = frozenset({'api_path', 'scheme', 'server'})
85
+ _ORIGIN_STRING_FIELDS = frozenset({'scheme', 'server'})
86
+ _UNSET: Final = _Unset()
87
+ _WARNING_MESSAGES: Mapping[CompatibilityWarningCode, str] = {
88
+ 'deprecated-entity-types-alias': 'The entity_types alias is deprecated.',
89
+ 'deprecated-schema-alias': 'The schema alias is deprecated.',
90
+ 'legacy-infinite-timeout': 'Explicit None timeout enables legacy infinite waits.',
91
+ }
92
+ _PUBLIC_FIELDS = frozenset(
93
+ {
94
+ *_AUTH_FIELDS,
95
+ *_OBSERVER_FIELDS,
96
+ 'api_path',
97
+ 'api_ver',
98
+ 'convert_datetimes_to_utc',
99
+ 'localized',
100
+ 'max_rpc_attempts',
101
+ 'page_prefetch',
102
+ 'pool_maxsize',
103
+ 'proxy_pass',
104
+ 'proxy_port',
105
+ 'proxy_server',
106
+ 'proxy_user',
107
+ 'raw_http_proxy',
108
+ 'rpc_attempt_interval',
109
+ 'scheme',
110
+ 'server',
111
+ 'server_info',
112
+ 'session_uuid',
113
+ 'timeout_secs',
114
+ 'user_agents',
115
+ }
116
+ )
117
+ _TRANSPORT_FIELDS = frozenset(
118
+ {
119
+ *_ORIGIN_FIELDS,
120
+ 'pool_maxsize',
121
+ 'proxy_pass',
122
+ 'proxy_port',
123
+ 'proxy_server',
124
+ 'proxy_user',
125
+ 'raw_http_proxy',
126
+ }
127
+ )
128
+
129
+
130
+ @dataclasses.dataclass(frozen=True)
131
+ class CompatibilityWarningEvent:
132
+ """Report sanitized compatibility behavior through a stable code.
133
+
134
+ Attributes:
135
+ code: Stable compatibility warning code.
136
+ message: Fixed user-facing warning text.
137
+ """
138
+
139
+ code: CompatibilityWarningCode
140
+ message: str
141
+
142
+
143
+ @dataclasses.dataclass(frozen=True)
144
+ class ProgressEvent:
145
+ """Report byte consumption for one attempt.
146
+
147
+ Attributes:
148
+ operation: Logical operation category.
149
+ route: Transport route being measured.
150
+ attempt: One-based attempt number.
151
+ bytes_transferred: Bytes consumed so far.
152
+ total_bytes: Expected total bytes, when known.
153
+ """
154
+
155
+ operation: _protocol.OperationCategory
156
+ route: EventRoute
157
+ attempt: int
158
+ bytes_transferred: int
159
+ total_bytes: int | None
160
+
161
+
162
+ @dataclasses.dataclass(frozen=True)
163
+ class RequestContext:
164
+ """Capture every operation-visible setting before I/O begins.
165
+
166
+ Attributes:
167
+ canonical_origin: Normalized site origin, when configured.
168
+ api_url: Complete RPC endpoint URL, when configured.
169
+ auth: Frozen authentication payload.
170
+ extra_auth_params: Frozen additional authentication values.
171
+ user_agent: Complete request user-agent value.
172
+ session_token: Captured session token, when configured.
173
+ session_uuid: Captured session UUID, when configured.
174
+ timeout: Effective transport timeout limits.
175
+ localized: Whether localized display names are requested.
176
+ convert_datetimes_to_utc: Whether datetime values are normalized to
177
+ UTC.
178
+ records_per_page: Effective server page size.
179
+ page_prefetch: Maximum concurrent page requests; zero keeps paging
180
+ serial.
181
+ pool_maxsize: Maximum transport connection-pool size.
182
+ proxy_url: Normalized proxy URL, when configured.
183
+ proxy_headers: Frozen proxy authentication headers.
184
+ max_rpc_attempts: Maximum number of RPC attempts.
185
+ rpc_attempt_interval: Base retry interval in milliseconds.
186
+ origin_generation: Captured origin-state generation.
187
+ transport_generation: Captured transport-state generation.
188
+ auth_generation: Captured authentication generation, when shared.
189
+ has_one_use_auth_token: Whether the snapshot carries a one-use token.
190
+ on_request: Optional request observer.
191
+ on_response: Optional response observer.
192
+ on_retry: Optional retry observer.
193
+ on_progress: Optional transfer-progress observer.
194
+ on_compatibility_warning: Optional compatibility-warning observer.
195
+ site_url: Displayable site URL, when configured.
196
+ """
197
+
198
+ canonical_origin: str | None
199
+ api_url: str | None
200
+ auth: Mapping[str, object] = dataclasses.field(repr=False)
201
+ extra_auth_params: Mapping[str, object] = dataclasses.field(repr=False)
202
+ user_agent: str
203
+ session_token: str | None = dataclasses.field(repr=False)
204
+ session_uuid: str | None = dataclasses.field(repr=False)
205
+ timeout: TimeoutConfig
206
+ localized: bool
207
+ convert_datetimes_to_utc: bool
208
+ records_per_page: int
209
+ page_prefetch: int
210
+ pool_maxsize: int
211
+ proxy_url: str | None
212
+ proxy_headers: Mapping[str, str] = dataclasses.field(repr=False)
213
+ max_rpc_attempts: int
214
+ rpc_attempt_interval: int
215
+ origin_generation: int
216
+ transport_generation: int
217
+ auth_generation: int | None
218
+ has_one_use_auth_token: bool
219
+ on_request: ObserverCallback | None
220
+ on_response: ObserverCallback | None
221
+ on_retry: ObserverCallback | None
222
+ on_progress: ObserverCallback | None
223
+ on_compatibility_warning: ObserverCallback | None
224
+ site_url: str | None
225
+
226
+
227
+ class Config:
228
+ """Provide official configuration names with coherent locked snapshots.
229
+
230
+ Writable attributes are grouped as credentials and session (`api_key`,
231
+ `auth_token`, `extra_auth_params`, `script_name`, `session_token`,
232
+ `session_uuid`, `sudo_as_login`, `user_login`, and `user_password`), origin
233
+ and API (`api_path`, `api_ver`, `scheme`, `server`, and `server_info`),
234
+ proxy (`proxy_pass`, `proxy_port`, `proxy_server`, `proxy_user`, and
235
+ `raw_http_proxy`), transport, retry, and pagination (`max_rpc_attempts`,
236
+ `page_prefetch`, `pool_maxsize`, `rpc_attempt_interval`, `timeout_secs`,
237
+ and `user_agents`), conversion and localization (`convert_datetimes_to_utc`
238
+ and `localized`), and observers (`on_compatibility_warning`, `on_progress`,
239
+ `on_request`, `on_response`, and `on_retry`).
240
+
241
+ Reads return detached values except that observer callbacks and the unset
242
+ sentinel retain their identities. Supported assignments are applied
243
+ atomically; some combined settings are validated when a snapshot is taken.
244
+ Changing the canonical origin clears omitted authentication and session
245
+ values. Use `update_fields()` to supply an origin and replacement
246
+ credentials atomically.
247
+
248
+ For `timeout_secs`, the unset sentinel selects 15-second connect and
249
+ 300-second read limits, `None` disables both limits, and a finite positive
250
+ number at most `threading.TIMEOUT_MAX` sets both limits.
251
+ `rpc_attempt_interval` is in milliseconds. `page_prefetch=0` keeps paging
252
+ serial; a positive N permits N concurrent page requests with at most N-1
253
+ later pages fetched ahead. N=1 offers no processing lookahead.
254
+
255
+ Observer callbacks run synchronously on the calling thread, a prefetch
256
+ worker thread, or the async client's event loop. They are not awaited.
257
+ Ordinary callback exceptions are suppressed with a sanitized warning.
258
+ Callbacks must not reenter an operation that waits on their own in-progress
259
+ readiness check.
260
+
261
+ Attributes:
262
+ records_per_page: Effective server page limit, defaulting to 500.
263
+ """
264
+
265
+ _lock: threading.Lock
266
+ _auth_generation: int
267
+ _origin_generation: int
268
+ _transport_generation: int
269
+ _values: dict[str, object]
270
+
271
+ api_key: str | None
272
+ api_path: str | None
273
+ api_ver: str
274
+ auth_token: str | None
275
+ convert_datetimes_to_utc: bool
276
+ extra_auth_params: dict[str, object] | None
277
+ localized: bool
278
+ max_rpc_attempts: int
279
+ on_compatibility_warning: ObserverCallback | None
280
+ on_progress: ObserverCallback | None
281
+ on_request: ObserverCallback | None
282
+ on_response: ObserverCallback | None
283
+ on_retry: ObserverCallback | None
284
+ page_prefetch: int
285
+ pool_maxsize: int
286
+ proxy_pass: str | None
287
+ proxy_port: int
288
+ proxy_server: str | None
289
+ proxy_user: str | None
290
+ raw_http_proxy: str | None
291
+ rpc_attempt_interval: int
292
+ scheme: str | None
293
+ script_name: str | None
294
+ server: str | None
295
+ server_info: dict[str, object]
296
+ session_token: str | None
297
+ session_uuid: str | None
298
+ sudo_as_login: str | None
299
+ timeout_secs: float | _Unset | None
300
+ user_agents: tuple[str, ...]
301
+ user_login: str | None
302
+ user_password: str | None
303
+
304
+ def __init__(self) -> None:
305
+ """Initialize official configuration defaults and synchronization state.
306
+
307
+ Read `SHOTGUN_API_RETRY_INTERVAL` as an integer number of milliseconds,
308
+ defaulting to 3000.
309
+
310
+ Raises:
311
+ ValueError: If the environment value is not a nonnegative integer.
312
+ """
313
+ object.__setattr__(self, '_lock', threading.Lock())
314
+ object.__setattr__(self, '_auth_generation', 0)
315
+ object.__setattr__(self, '_origin_generation', 0)
316
+ object.__setattr__(self, '_transport_generation', 0)
317
+ interval = int(os.environ.get('SHOTGUN_API_RETRY_INTERVAL', '3000'))
318
+ if interval < 0:
319
+ raise ValueError('rpc_attempt_interval must be >= 0')
320
+ object.__setattr__(
321
+ self,
322
+ '_values',
323
+ {
324
+ 'api_key': None,
325
+ 'api_path': None,
326
+ 'api_ver': 'api3',
327
+ 'auth_token': None,
328
+ 'convert_datetimes_to_utc': True,
329
+ 'extra_auth_params': None,
330
+ 'localized': False,
331
+ 'max_rpc_attempts': 3,
332
+ 'on_compatibility_warning': None,
333
+ 'on_progress': None,
334
+ 'on_request': None,
335
+ 'on_response': None,
336
+ 'on_retry': None,
337
+ 'page_prefetch': 0,
338
+ 'pool_maxsize': 10,
339
+ 'proxy_pass': None,
340
+ 'proxy_port': 8080,
341
+ 'proxy_server': None,
342
+ 'proxy_user': None,
343
+ 'raw_http_proxy': None,
344
+ 'rpc_attempt_interval': interval,
345
+ 'scheme': None,
346
+ 'script_name': None,
347
+ 'server': None,
348
+ 'server_info': {},
349
+ 'session_token': None,
350
+ 'session_uuid': None,
351
+ 'sudo_as_login': None,
352
+ 'timeout_secs': _UNSET,
353
+ 'user_agents': _protocol.default_user_agent(PACKAGE_VERSION),
354
+ 'user_login': None,
355
+ 'user_password': None,
356
+ },
357
+ )
358
+
359
+ if not TYPE_CHECKING:
360
+
361
+ def __getattr__(self, name: str) -> object:
362
+ """Read configuration, preserving observer and unset-sentinel identities.
363
+
364
+ Args:
365
+ name: Supported configuration field name.
366
+
367
+ Returns:
368
+ The observer or unset sentinel itself, or a detached field
369
+ value.
370
+
371
+ Raises:
372
+ AttributeError: If the field name is unsupported.
373
+ """
374
+ if name not in _PUBLIC_FIELDS:
375
+ raise AttributeError(name)
376
+ with self._lock:
377
+ value = self._values[name]
378
+ if name in _OBSERVER_FIELDS:
379
+ return value
380
+ return value if value is _UNSET else copy.deepcopy(value)
381
+
382
+ def __setattr__(self, name: str, value: object) -> None:
383
+ """Route direct official configuration writes through the lock."""
384
+ if name.startswith('_'):
385
+ object.__setattr__(self, name, value)
386
+ return
387
+ self.update_fields(**{name: value})
388
+
389
+ def _advance_generations(
390
+ self,
391
+ *,
392
+ auth_changed: bool,
393
+ origin_changed: bool,
394
+ retired_values: list[object],
395
+ transport_changed: bool,
396
+ ) -> None:
397
+ """Advance locked state identities for the changed field groups."""
398
+ if origin_changed:
399
+ retired_values.append(self._values['server_info'])
400
+ self._values['server_info'] = {}
401
+ self._origin_generation += 1
402
+ if transport_changed:
403
+ self._transport_generation += 1
404
+ if auth_changed:
405
+ self._auth_generation += 1
406
+
407
+ def append_user_agent(self, agent: str) -> None:
408
+ """Append one user-agent entry under the configuration lock.
409
+
410
+ Args:
411
+ agent: User-agent fragment to append.
412
+
413
+ Raises:
414
+ TypeError: If `agent` is not a string or stored agents are
415
+ malformed.
416
+ """
417
+ owned_agent = _owned_string(agent)
418
+ previous_agents: object = None
419
+ with self._lock:
420
+ agents = self._values['user_agents']
421
+ if type(agents) is not tuple:
422
+ raise TypeError('user_agents must be a tuple')
423
+ typed_agents = cast('tuple[str, ...]', agents)
424
+ previous_agents = typed_agents
425
+ self._values['user_agents'] = (*typed_agents, owned_agent)
426
+ del previous_agents
427
+
428
+ @property
429
+ def records_per_page(self) -> int:
430
+ """Return the server page limit, defaulting to 500.
431
+
432
+ Raises:
433
+ TypeError: If stored server metadata is not a mapping.
434
+ ShotgunError: If the server metadata contains an invalid page
435
+ limit.
436
+ """
437
+ with self._lock:
438
+ server_info = self._values['server_info']
439
+ if not isinstance(server_info, Mapping):
440
+ raise TypeError('server_info must be a mapping')
441
+ typed_server_info = cast('Mapping[str, object]', server_info)
442
+ return server_page_limit(typed_server_info)
443
+
444
+ def reset_user_agent(self) -> None:
445
+ """Restore the official-compatible default user-agent entries."""
446
+ previous_agents: object = None
447
+ with self._lock:
448
+ previous_agents = self._values['user_agents']
449
+ self._values['user_agents'] = _protocol.default_user_agent(PACKAGE_VERSION)
450
+ del previous_agents
451
+
452
+ def update_fields(self, **fields: object) -> None:
453
+ """Atomically update supported configuration fields.
454
+
455
+ Changing the canonical scheme, host, or effective port clears
456
+ origin-scoped authentication and session values omitted from this call.
457
+ Supplying replacement credentials in the same call retains those
458
+ replacements. An `api_path`-only change retains authentication.
459
+ Setting an unconfigured or invalid scheme/server combination also
460
+ retires omitted authentication.
461
+
462
+ Args:
463
+ **fields: Supported field names and replacement values.
464
+
465
+ Raises:
466
+ AttributeError: If a field name is not a supported configuration
467
+ knob.
468
+ TypeError: If a field value has an unsupported type.
469
+ ValueError: If a retry or timeout setting is outside its supported
470
+ range.
471
+ """
472
+ normalized_fields = {_owned_string(name): value for name, value in fields.items()}
473
+ unknown = normalized_fields.keys() - _PUBLIC_FIELDS
474
+ if unknown:
475
+ raise AttributeError(next(iter(sorted(unknown))))
476
+ copied = {
477
+ name: _copy_for_storage(name, value) for name, value in normalized_fields.items()
478
+ }
479
+ _validate_assignment_values(copied)
480
+ auth_changed = any(name in _AUTH_FIELDS for name in copied)
481
+ origin_changed = any(name in _ORIGIN_FIELDS for name in copied)
482
+ transport_changed = any(name in _TRANSPORT_FIELDS for name in copied)
483
+ prepared = [(name, value, name in _OBSERVER_FIELDS) for name, value in copied.items()]
484
+ for name, observer, is_observer in prepared:
485
+ if not is_observer:
486
+ continue
487
+ if observer is not None and not callable(observer):
488
+ raise TypeError(f'{name} must be callable or None')
489
+ retired_values: list[object] = []
490
+ with self._lock:
491
+ old_canonical_origin = _canonical_origin_or_none(self._values)
492
+ for name, value, is_observer in prepared:
493
+ if not is_observer or self._values[name] is not value:
494
+ retired_values.append(self._values[name])
495
+ self._values[name] = value
496
+ new_canonical_origin = _canonical_origin_or_none(self._values)
497
+ origin_boundary_crossed = bool({'scheme', 'server'} & copied.keys()) and (
498
+ old_canonical_origin is None
499
+ or new_canonical_origin is None
500
+ or old_canonical_origin != new_canonical_origin
501
+ )
502
+ if origin_boundary_crossed:
503
+ _retire_omitted_auth(self._values, copied, retired_values)
504
+ auth_changed = True
505
+ self._advance_generations(
506
+ auth_changed=auth_changed,
507
+ origin_changed=origin_changed,
508
+ retired_values=retired_values,
509
+ transport_changed=transport_changed,
510
+ )
511
+ retired_values.clear()
512
+
513
+ def set_server_params(self, base_url: str) -> None:
514
+ """Atomically set the canonical server tuple from a base URL.
515
+
516
+ Changing the scheme, normalized host, or effective port clears
517
+ origin-scoped authentication and session values. A path-only change
518
+ retains them. Use `update_fields()` when the new origin and replacement
519
+ credentials must be supplied atomically.
520
+
521
+ Args:
522
+ base_url: HTTP or HTTPS site URL.
523
+
524
+ Raises:
525
+ ValueError: If `base_url` has an unsupported or invalid origin.
526
+ """
527
+ scheme, server, api_path = parse_server_params(base_url)
528
+ self.update_fields(scheme=scheme, server=server, api_path=api_path)
529
+
530
+ def set_session_uuid(self, session_uuid: str | None) -> None:
531
+ """Set the session UUID through the locked mutation path.
532
+
533
+ Args:
534
+ session_uuid: Session UUID or `None` to clear it.
535
+ """
536
+ self.update_fields(session_uuid=session_uuid)
537
+
538
+
539
+ @dataclasses.dataclass(frozen=True)
540
+ class RequestEvent:
541
+ """Report allowlisted metadata before one transport attempt.
542
+
543
+ Attributes:
544
+ operation: Logical operation category.
545
+ route: Transport route selected for the attempt.
546
+ http_method: HTTP method sent by the attempt.
547
+ attempt: One-based attempt number.
548
+ """
549
+
550
+ operation: _protocol.OperationCategory
551
+ route: EventRoute
552
+ http_method: str
553
+ attempt: int
554
+
555
+
556
+ @dataclasses.dataclass(frozen=True)
557
+ class ResponseEvent:
558
+ """Report allowlisted metadata after one transport attempt.
559
+
560
+ Attributes:
561
+ operation: Logical operation category.
562
+ route: Transport route used by the attempt.
563
+ attempt: One-based attempt number.
564
+ latency: Attempt duration in seconds.
565
+ status: HTTP status, when a response was received.
566
+ outcome: Stable attempt outcome category.
567
+ correlation_id: Server correlation identifier, when supplied.
568
+ """
569
+
570
+ operation: _protocol.OperationCategory
571
+ route: EventRoute
572
+ attempt: int
573
+ latency: float
574
+ status: int | None
575
+ outcome: Outcome
576
+ correlation_id: str | None
577
+
578
+
579
+ @dataclasses.dataclass(frozen=True)
580
+ class RetryEvent:
581
+ """Report a retry decision after attempt cleanup.
582
+
583
+ Attributes:
584
+ operation: Logical operation category.
585
+ route: Transport route used by the failed attempt.
586
+ attempt: One-based failed attempt number.
587
+ delay: Delay before the next attempt in seconds.
588
+ status: HTTP status, when a response was received.
589
+ """
590
+
591
+ operation: _protocol.OperationCategory
592
+ route: EventRoute
593
+ attempt: int
594
+ delay: float
595
+ status: int | None
596
+
597
+
598
+ @dataclasses.dataclass(frozen=True)
599
+ class _ObserverBoundary:
600
+ """Suppress and sanitively log ordinary observer exceptions."""
601
+
602
+ event_type: str
603
+
604
+ def __enter__(self) -> None:
605
+ """Enter the observer exception boundary."""
606
+
607
+ def __exit__(
608
+ self,
609
+ exception_type: type[BaseException] | None,
610
+ _exception: BaseException | None,
611
+ _traceback: object,
612
+ ) -> bool:
613
+ """Suppress ordinary callback failures without formatting their values."""
614
+ if exception_type is None or not issubclass(exception_type, Exception):
615
+ return False
616
+ _LOG.warning('observer callback failed for %s', self.event_type)
617
+ return True
618
+
619
+
620
+ def _canonical_server(server: str, scheme: str) -> tuple[str, int]:
621
+ """Return an IDNA host and effective port from a configured server."""
622
+ parsed = urllib.parse.urlsplit(f'//{server}')
623
+ if parsed.hostname is None or parsed.username is not None or parsed.password is not None:
624
+ raise ValueError('server must contain a host without credentials')
625
+ try:
626
+ port = parsed.port
627
+ except ValueError as exc:
628
+ raise ValueError('server contains an invalid port') from exc
629
+ host = parsed.hostname.encode('idna').decode('ascii').lower()
630
+ return host, port if port is not None else (443 if scheme == 'https' else 80)
631
+
632
+
633
+ def _contains_ascii_control(value: str) -> bool:
634
+ """Return whether a string contains an ASCII control character."""
635
+ return any(
636
+ ord(character) < _ASCII_CONTROL_LIMIT or ord(character) == _ASCII_DELETE
637
+ for character in value
638
+ )
639
+
640
+
641
+ def _effective_timeout(value: object) -> TimeoutConfig:
642
+ """Resolve official timeout semantics from one locked value."""
643
+ if value is _UNSET:
644
+ return TimeoutConfig(connect=_DEFAULT_CONNECT_TIMEOUT, read=_DEFAULT_READ_TIMEOUT)
645
+ if value is None:
646
+ return TimeoutConfig(connect=None, read=None)
647
+ if isinstance(value, bool) or not isinstance(value, int | float):
648
+ raise TypeError('timeout_secs must be a number, None, or UNSET')
649
+ if isinstance(value, float) and not math.isfinite(value):
650
+ raise ValueError('timeout_secs must be finite and positive')
651
+ if value <= 0 or value > threading.TIMEOUT_MAX:
652
+ raise ValueError('timeout_secs must be positive and at most threading.TIMEOUT_MAX')
653
+ return TimeoutConfig(connect=value, read=value)
654
+
655
+
656
+ def _freeze(value: object) -> object:
657
+ """Recursively copy mutable values into immutable containers."""
658
+ if isinstance(value, Mapping):
659
+ mapping = cast('Mapping[object, object]', value)
660
+ return MappingProxyType({key: _freeze(item) for key, item in mapping.items()})
661
+ if isinstance(value, list | tuple):
662
+ sequence = cast('list[object] | tuple[object, ...]', value)
663
+ return tuple(_freeze(item) for item in sequence)
664
+ if isinstance(value, set | frozenset):
665
+ members = cast('set[object] | frozenset[object]', value)
666
+ return frozenset(_freeze(item) for item in members)
667
+ return copy.deepcopy(value)
668
+
669
+
670
+ def _integer(value: object, name: str) -> int:
671
+ """Validate one integer configuration value."""
672
+ if not isinstance(value, int):
673
+ raise TypeError(f'{name} must be an integer')
674
+ return value
675
+
676
+
677
+ def _observer(values: Mapping[str, object], name: str) -> ObserverCallback | None:
678
+ """Return one exact observer object from captured values."""
679
+ return cast('ObserverCallback | None', values[name])
680
+
681
+
682
+ def _origin_components(values: Mapping[str, object]) -> tuple[str, str, int] | None:
683
+ """Validate shared origin fields and return scheme, URL host, and effective port."""
684
+ scheme_value = values['scheme']
685
+ server_value = values['server']
686
+ if scheme_value is None or server_value is None:
687
+ return None
688
+ if not isinstance(scheme_value, str) or scheme_value.lower() not in {'http', 'https'}:
689
+ raise ValueError('scheme must be http or https')
690
+ if not isinstance(server_value, str):
691
+ raise TypeError('server must be a string')
692
+ scheme = scheme_value.lower()
693
+ host, port = _canonical_server(server_value, scheme)
694
+ host_for_url = f'[{host}]' if ':' in host else host
695
+ return scheme, host_for_url, port
696
+
697
+
698
+ def _canonical_origin(values: Mapping[str, object]) -> str | None:
699
+ """Build a canonical effective-port origin without the API path."""
700
+ components = _origin_components(values)
701
+ if components is None:
702
+ return None
703
+ scheme, host_for_url, port = components
704
+ return f'{scheme}://{host_for_url}:{port}'
705
+
706
+
707
+ def _canonical_origin_or_none(values: Mapping[str, object]) -> str | None:
708
+ """Return a canonical origin, treating invalid state as unconfigured."""
709
+ try:
710
+ return _canonical_origin(values)
711
+ except (TypeError, ValueError):
712
+ return None
713
+
714
+
715
+ def _origin(values: Mapping[str, object]) -> tuple[str | None, str | None]:
716
+ """Build a canonical effective-port origin and API URL."""
717
+ api_path_value = values['api_path']
718
+ canonical_origin = _canonical_origin(values)
719
+ if canonical_origin is None or api_path_value is None:
720
+ return None, None
721
+ if not isinstance(api_path_value, str):
722
+ raise TypeError('api_path must be a string')
723
+ api_path = f'/{api_path_value.lstrip("/")}'
724
+ return canonical_origin, f'{canonical_origin}{api_path}'
725
+
726
+
727
+ def _owned_plain_value(value: object) -> object:
728
+ """Recursively normalize caller containers into owned built-in values."""
729
+ if isinstance(value, Mapping):
730
+ mapping = cast('Mapping[object, object]', value)
731
+ return {copy.deepcopy(key): _owned_plain_value(item) for key, item in mapping.items()}
732
+ if isinstance(value, list):
733
+ sequence = cast('list[object]', value)
734
+ return [_owned_plain_value(item) for item in sequence]
735
+ if isinstance(value, tuple):
736
+ sequence = cast('tuple[object, ...]', value)
737
+ return tuple(_owned_plain_value(item) for item in sequence)
738
+ if isinstance(value, set):
739
+ members = cast('set[object]', value)
740
+ return {_owned_plain_value(item) for item in members}
741
+ if isinstance(value, frozenset):
742
+ members = cast('frozenset[object]', value)
743
+ return frozenset(_owned_plain_value(item) for item in members)
744
+ return copy.deepcopy(value)
745
+
746
+
747
+ def _owned_extra_auth(value: object) -> dict[str, object] | None:
748
+ """Validate and normalize extra authentication into an owned plain dict."""
749
+ if value is None:
750
+ return None
751
+ if not isinstance(value, dict):
752
+ raise TypeError('extra_auth_params must be a dictionary or None')
753
+ typed_value = cast('dict[object, object]', value)
754
+ owned = _owned_plain_value(typed_value)
755
+ if not isinstance(owned, dict):
756
+ raise TypeError('extra_auth_params must be a dictionary or None')
757
+ return cast('dict[str, object]', owned)
758
+
759
+
760
+ def _owned_string(value: str) -> str:
761
+ """Normalize a string subclass into an exact owned built-in string."""
762
+ owned = str.__str__(value)
763
+ return owned if type(owned) is str else f'{owned}'
764
+
765
+
766
+ def _owned_optional_string(value: object) -> str | None:
767
+ """Normalize an optional string into exact owned built-in state."""
768
+ if value is None:
769
+ return None
770
+ if not isinstance(value, str):
771
+ raise TypeError('authentication values must be strings or None')
772
+ return _owned_string(value)
773
+
774
+
775
+ def _auth_spec(values: Mapping[str, object]) -> _protocol.AuthSpec:
776
+ """Build a copied authentication specification from captured values."""
777
+ return _protocol.AuthSpec(
778
+ script_name=_owned_optional_string(values['script_name']),
779
+ api_key=_owned_optional_string(values['api_key']),
780
+ user_login=_owned_optional_string(values['user_login']),
781
+ user_password=_owned_optional_string(values['user_password']),
782
+ session_token=_owned_optional_string(values['session_token']),
783
+ auth_token=_owned_optional_string(values['auth_token']),
784
+ sudo_as_login=_owned_optional_string(values['sudo_as_login']),
785
+ session_uuid=_owned_optional_string(values['session_uuid']),
786
+ extra_auth_params=_owned_extra_auth(values['extra_auth_params']),
787
+ )
788
+
789
+
790
+ def _owned_server_info(value: object) -> dict[str, object]:
791
+ """Normalize server metadata into a detached dict with string keys."""
792
+ if not isinstance(value, Mapping):
793
+ raise TypeError('server_info must be a mapping')
794
+ mapping = cast('Mapping[object, object]', value)
795
+ owned: dict[str, object] = {}
796
+ for key, item in mapping.items():
797
+ if not isinstance(key, str):
798
+ raise TypeError('server_info keys must be strings')
799
+ owned[_owned_string(key)] = _owned_plain_value(item)
800
+ return owned
801
+
802
+
803
+ def _owned_user_agents(value: object) -> tuple[str, ...]:
804
+ """Normalize user-agent entries into an exact built-in tuple of strings."""
805
+ if not isinstance(value, tuple):
806
+ raise TypeError('user_agents must be a tuple')
807
+ entries = cast('tuple[object, ...]', value)
808
+ if not all(isinstance(entry, str) for entry in entries):
809
+ raise TypeError('user_agents entries must be strings')
810
+ typed_entries = cast('tuple[str, ...]', entries)
811
+ return tuple(_owned_string(entry) for entry in typed_entries)
812
+
813
+
814
+ def _copy_for_storage(name: str, value: object) -> object:
815
+ """Copy mutable caller-owned configuration before storing it."""
816
+ if name in _OBSERVER_FIELDS:
817
+ return value
818
+ if name in _DEDICATED_AUTH_STRING_FIELDS:
819
+ return _owned_optional_string(value)
820
+ if name == 'extra_auth_params':
821
+ return _owned_extra_auth(value)
822
+ if name in _ORIGIN_STRING_FIELDS:
823
+ copied_origin = value if isinstance(value, str) else copy.deepcopy(value)
824
+ return _owned_string(copied_origin) if isinstance(copied_origin, str) else copied_origin
825
+ if name == 'user_agents':
826
+ copied = _owned_user_agents(value)
827
+ elif name == 'server_info':
828
+ copied = _owned_server_info(value)
829
+ else:
830
+ copied = value if name == 'timeout_secs' and value is _UNSET else copy.deepcopy(value)
831
+ return copied
832
+
833
+
834
+ def _proxy_headers(values: Mapping[str, object]) -> Mapping[str, str]:
835
+ """Build immutable Basic proxy authorization from captured credentials."""
836
+ user = _owned_optional_string(values['proxy_user'])
837
+ password = _owned_optional_string(values['proxy_pass'])
838
+ if user is None and password is None:
839
+ return MappingProxyType({})
840
+ if user is None or password is None:
841
+ raise ValueError('proxy_user and proxy_pass must be configured together')
842
+ try:
843
+ credentials = f'{user}:{password}'.encode('latin-1')
844
+ except UnicodeEncodeError:
845
+ raise ValueError('proxy credentials must contain only ISO-8859-1 characters') from None
846
+ token = base64.b64encode(credentials).decode('ascii')
847
+ return MappingProxyType({'proxy-authorization': f'Basic {token}'})
848
+
849
+
850
+ def _proxy_url(values: Mapping[str, object]) -> str | None:
851
+ """Build a credential-free proxy URL from one captured configuration."""
852
+ server = _owned_optional_string(values['proxy_server'])
853
+ if server is None:
854
+ return None
855
+ port = _integer(values['proxy_port'], 'proxy_port')
856
+ if not 1 <= port <= _MAX_PORT:
857
+ raise ValueError('proxy_port must be between 1 and 65535')
858
+ return f'http://{server}:{port}'
859
+
860
+
861
+ def _retire_omitted_auth(
862
+ values: dict[str, object],
863
+ copied: Mapping[str, object],
864
+ retired_values: list[object],
865
+ ) -> None:
866
+ """Retire origin-scoped values absent from one atomic update."""
867
+ for name in _AUTH_FIELDS:
868
+ if name in copied:
869
+ continue
870
+ retired_values.append(values[name])
871
+ values[name] = {} if name == 'extra_auth_params' else None
872
+
873
+
874
+ def _site_url(values: Mapping[str, object]) -> str | None:
875
+ """Derive the configured site spelling from captured origin values."""
876
+ components = _origin_components(values)
877
+ if components is None:
878
+ return None
879
+ scheme, host_for_url, port = components
880
+ server_value = values['server']
881
+ configured_port = urllib.parse.urlsplit(f'//{server_value}').port
882
+ server = f'{host_for_url}:{port}' if configured_port is not None else host_for_url
883
+ base = f'{scheme}://{server}'
884
+ api_path = str(values['api_path'])
885
+ suffix = '/api3/json'
886
+ if api_path.endswith(suffix):
887
+ prefix = api_path[: -len(suffix)].rstrip('/')
888
+ return f'{base}/{prefix.lstrip("/")}' if prefix else base
889
+ return base
890
+
891
+
892
+ def _split_proxy_authority(
893
+ http_proxy: str,
894
+ ) -> tuple[str, str | None, str | None]:
895
+ """Validate proxy credentials and return the remaining authority."""
896
+ if _contains_ascii_control(http_proxy):
897
+ raise ValueError('http_proxy must not contain ASCII control characters')
898
+ credentials, marker, authority = http_proxy.rpartition('@')
899
+ if not marker:
900
+ return http_proxy, None, None
901
+ user, separator, password = credentials.partition(':')
902
+ if not separator or not user or not password:
903
+ raise ValueError('http_proxy must be [user:pass@]host[:port]')
904
+ return authority, user, password
905
+
906
+
907
+ def _parse_proxy_settings(
908
+ http_proxy: str,
909
+ ) -> tuple[str, int, str | None, str | None]:
910
+ """Validate and split an official proxy authority."""
911
+ authority, user, password = _split_proxy_authority(http_proxy)
912
+ if not authority or authority.endswith(':'):
913
+ raise ValueError('http_proxy must be [user:pass@]host[:port]')
914
+ try:
915
+ parsed = urllib.parse.urlsplit(f'//{authority}')
916
+ port = parsed.port
917
+ except (UnicodeError, ValueError):
918
+ raise ValueError('http_proxy must be [user:pass@]host[:port]') from None
919
+ hostname = parsed.hostname
920
+ if (
921
+ hostname is None
922
+ or parsed.username is not None
923
+ or parsed.password is not None
924
+ or parsed.path
925
+ or parsed.query
926
+ or parsed.fragment
927
+ or any(character.isspace() for character in hostname)
928
+ ):
929
+ raise ValueError('http_proxy must be [user:pass@]host[:port]')
930
+ effective_port = port if port is not None else _DEFAULT_PROXY_PORT
931
+ if not 1 <= effective_port <= _MAX_PORT:
932
+ raise ValueError('http_proxy must use a port from 1 to 65535')
933
+ if ':' in hostname:
934
+ server = f'[{hostname.lower()}]'
935
+ else:
936
+ try:
937
+ server = hostname.encode('idna').decode('ascii').lower()
938
+ except UnicodeError:
939
+ raise ValueError('http_proxy contains an invalid host') from None
940
+ if any(not (character.isalnum() or character in '.-') for character in server):
941
+ raise ValueError('http_proxy contains an invalid host')
942
+ return server, effective_port, user, password
943
+
944
+
945
+ def _parse_http_proxy(http_proxy: str) -> dict[str, object]:
946
+ """Parse an official proxy authority into configuration fields."""
947
+ server, port, user, password = _parse_proxy_settings(http_proxy)
948
+ return {
949
+ 'proxy_pass': password,
950
+ 'proxy_port': port,
951
+ 'proxy_server': server,
952
+ 'proxy_user': user,
953
+ 'raw_http_proxy': http_proxy,
954
+ }
955
+
956
+
957
+ def _thaw(value: object) -> object:
958
+ """Convert immutable snapshot containers into fresh JSON containers."""
959
+ if isinstance(value, Mapping):
960
+ mapping = cast('Mapping[object, object]', value)
961
+ return {key: _thaw(item) for key, item in mapping.items()}
962
+ if isinstance(value, tuple):
963
+ sequence = cast('tuple[object, ...]', value)
964
+ return [_thaw(item) for item in sequence]
965
+ if isinstance(value, frozenset):
966
+ members = cast('frozenset[object]', value)
967
+ return [_thaw(item) for item in members]
968
+ return copy.deepcopy(value)
969
+
970
+
971
+ def _validated_retry_value(name: str, value: object) -> int:
972
+ """Validate and return one retry configuration integer."""
973
+ if isinstance(value, bool) or not isinstance(value, int):
974
+ raise TypeError(f'{name} must be an integer')
975
+ minimum = 1 if name == 'max_rpc_attempts' else 0
976
+ if value < minimum:
977
+ raise ValueError(f'{name} must be at least {minimum}')
978
+ return value
979
+
980
+
981
+ def _validate_assignment_values(copied: dict[str, object]) -> None:
982
+ """Validate independent field values before committing an assignment batch."""
983
+ for name in ('page_prefetch', 'pool_maxsize', 'proxy_port'):
984
+ if name in copied:
985
+ _integer(copied[name], name)
986
+ if 'server_info' in copied and not isinstance(copied['server_info'], Mapping):
987
+ raise TypeError('server_info must be a mapping')
988
+ for name in ('max_rpc_attempts', 'rpc_attempt_interval'):
989
+ if name in copied:
990
+ copied[name] = _validated_retry_value(name, copied[name])
991
+ if 'timeout_secs' in copied:
992
+ _effective_timeout(copied['timeout_secs'])
993
+
994
+
995
+ def advance_transport_generation(config: Config) -> None:
996
+ """Retire the transport identity captured by earlier snapshots.
997
+
998
+ Args:
999
+ config: Internal configuration whose transport identity is retiring.
1000
+ """
1001
+ lock = cast('threading.Lock', object.__getattribute__(config, '_lock'))
1002
+ with lock:
1003
+ generation = cast('int', object.__getattribute__(config, '_transport_generation'))
1004
+ object.__setattr__(config, '_transport_generation', generation + 1)
1005
+
1006
+
1007
+ def auth_spec(config: Config) -> _protocol.AuthSpec:
1008
+ """Return a detached authentication specification.
1009
+
1010
+ Args:
1011
+ config: Client configuration to capture.
1012
+
1013
+ Returns:
1014
+ Authentication values captured under the configuration lock.
1015
+ Field bindings are frozen, but additional authentication parameters
1016
+ remain a mutable dictionary detached from this configuration.
1017
+ """
1018
+ with config._lock:
1019
+ values = dict(config._values)
1020
+ return _auth_spec(values)
1021
+
1022
+
1023
+ def compatibility_warning_events(
1024
+ warning_codes: tuple[CompatibilityWarningCode, ...],
1025
+ timeout: TimeoutConfig,
1026
+ ) -> tuple[CompatibilityWarningEvent, ...]:
1027
+ """Build fixed-message compatibility warnings for one operation.
1028
+
1029
+ Args:
1030
+ warning_codes: Sanitized compatibility notices from the client.
1031
+ timeout: Captured operation timeout semantics.
1032
+
1033
+ Returns:
1034
+ Immutable compatibility-warning events in publication order.
1035
+ """
1036
+ codes = list(warning_codes)
1037
+ if timeout.connect is None and timeout.read is None:
1038
+ codes.append('legacy-infinite-timeout')
1039
+ return tuple(
1040
+ CompatibilityWarningEvent(code=code, message=_WARNING_MESSAGES[code]) for code in codes
1041
+ )
1042
+
1043
+
1044
+ def constructor_http_proxy_fields(http_proxy: object) -> dict[str, object] | None:
1045
+ """Return configuration updates for one constructor proxy value.
1046
+
1047
+ Args:
1048
+ http_proxy: Optional proxy constructor value.
1049
+
1050
+ Returns:
1051
+ Proxy field updates, or `None` when no update is required.
1052
+
1053
+ Raises:
1054
+ TypeError: If `http_proxy` is not a string or `None`.
1055
+ ValueError: If a non-empty proxy authority is malformed.
1056
+ """
1057
+ if http_proxy is None:
1058
+ return None
1059
+ if not isinstance(http_proxy, str):
1060
+ raise TypeError('http_proxy must be a string or None')
1061
+ owned_http_proxy = _owned_string(http_proxy)
1062
+ if str.__len__(owned_http_proxy) == 0:
1063
+ return {'raw_http_proxy': owned_http_proxy}
1064
+ return _parse_http_proxy(owned_http_proxy)
1065
+
1066
+
1067
+ def event_route(operation: _protocol.OperationDescriptor) -> EventRoute:
1068
+ """Map an operation descriptor to an allowlisted observer route.
1069
+
1070
+ Args:
1071
+ operation: Shared operation descriptor.
1072
+
1073
+ Returns:
1074
+ Endpoint role or the common RPC route.
1075
+ """
1076
+ return operation.endpoint_role or 'rpc'
1077
+
1078
+
1079
+ def notify_observer(observer: ObserverCallback | None, event: ObserverEvent) -> None:
1080
+ """Invoke an observer and suppress ordinary `Exception` failures.
1081
+
1082
+ Process-control exceptions propagate. Logged warnings identify only the
1083
+ event type without formatting callback values.
1084
+
1085
+ Args:
1086
+ observer: Optional synchronous event callback.
1087
+ event: Sanitized immutable event to publish.
1088
+ """
1089
+ if observer is None:
1090
+ return
1091
+ with _ObserverBoundary(type(event).__name__):
1092
+ observer(event)
1093
+
1094
+
1095
+ def parse_server_params(base_url: str) -> tuple[str, str, str]:
1096
+ """Parse a base URL into normalized server parameters.
1097
+
1098
+ Args:
1099
+ base_url: HTTP or HTTPS site URL.
1100
+
1101
+ Returns:
1102
+ The normalized scheme, server authority, and API path.
1103
+
1104
+ Raises:
1105
+ ValueError: If `base_url` has an unsupported or invalid origin.
1106
+ """
1107
+ parsed = urllib.parse.urlsplit(base_url)
1108
+ scheme = parsed.scheme.lower()
1109
+ if scheme not in {'http', 'https'}:
1110
+ raise ValueError('base_url must be http or https')
1111
+ if parsed.hostname is None or parsed.username is not None or parsed.password is not None:
1112
+ raise ValueError('base_url must contain a host without credentials')
1113
+ try:
1114
+ port = parsed.port
1115
+ except ValueError as exc:
1116
+ raise ValueError('base_url contains an invalid port') from exc
1117
+ host = parsed.hostname.encode('idna').decode('ascii').lower()
1118
+ host_for_server = f'[{host}]' if ':' in host else host
1119
+ server = f'{host_for_server}:{port}' if port is not None else host_for_server
1120
+ prefix = parsed.path.rstrip('/')
1121
+ api_path = f'{prefix}/api3/json' if prefix else '/api3/json'
1122
+ return scheme, server, api_path
1123
+
1124
+
1125
+ def publish_server_info(
1126
+ config: Config,
1127
+ context: RequestContext,
1128
+ server_info: dict[str, object],
1129
+ ) -> bool:
1130
+ """Publish origin metadata only for its captured transport generation.
1131
+
1132
+ Args:
1133
+ config: Client configuration to capture or update.
1134
+ context: Request state that produced the metadata.
1135
+ server_info: Validated server metadata to publish.
1136
+
1137
+ Returns:
1138
+ Whether the metadata was published.
1139
+
1140
+ Raises:
1141
+ TypeError: If `server_info` cannot be copied as a dictionary.
1142
+ """
1143
+ copied = _owned_plain_value(server_info)
1144
+ if not isinstance(copied, dict):
1145
+ raise TypeError('server_info must be a dictionary')
1146
+ typed_copied = cast('dict[str, object]', copied)
1147
+ previous_server_info: object = None
1148
+ with config._lock:
1149
+ if (
1150
+ context.origin_generation != config._origin_generation
1151
+ or context.transport_generation != config._transport_generation
1152
+ ):
1153
+ return False
1154
+ previous_server_info = config._values['server_info']
1155
+ config._values['server_info'] = typed_copied
1156
+ del previous_server_info
1157
+ return True
1158
+
1159
+
1160
+ def publish_session_token(config: Config, context: RequestContext, session_token: str) -> bool:
1161
+ """Atomically publish a token for an unchanged captured request state.
1162
+
1163
+ Publishing a one-use authentication exchange also clears the stored login,
1164
+ password, and authentication token, and removes `auth_token` from the extra
1165
+ authentication parameters.
1166
+
1167
+ Args:
1168
+ config: Client configuration to capture or update.
1169
+ context: Request state that produced the token.
1170
+ session_token: Session token to publish.
1171
+
1172
+ Returns:
1173
+ Whether the token was published. Returns `False` without changing
1174
+ configuration when the captured origin, transport, or authentication
1175
+ generation is stale.
1176
+
1177
+ Raises:
1178
+ TypeError: If the token or captured authentication values are
1179
+ malformed.
1180
+ """
1181
+ owned_session_token = _owned_optional_string(session_token)
1182
+ if owned_session_token is None:
1183
+ raise TypeError('session_token must be a string')
1184
+ retained_extra_auth_params: dict[str, object] | None = None
1185
+ if context.has_one_use_auth_token:
1186
+ retained = _owned_plain_value(context.extra_auth_params)
1187
+ if not isinstance(retained, dict):
1188
+ raise TypeError('captured extra authentication must be a mapping')
1189
+ retained_extra_auth_params = cast('dict[str, object]', retained)
1190
+ retained_extra_auth_params.pop('auth_token', None)
1191
+
1192
+ previous_auth_token: object = None
1193
+ previous_extra_auth_params: object = None
1194
+ previous_session_token: object = None
1195
+ previous_user_login: object = None
1196
+ previous_user_password: object = None
1197
+ with config._lock:
1198
+ if (
1199
+ context.origin_generation != config._origin_generation
1200
+ or context.transport_generation != config._transport_generation
1201
+ or context.auth_generation != config._auth_generation
1202
+ ):
1203
+ return False
1204
+ previous_session_token = config._values['session_token']
1205
+ config._values['session_token'] = owned_session_token
1206
+ if context.has_one_use_auth_token:
1207
+ previous_auth_token = config._values['auth_token']
1208
+ previous_extra_auth_params = config._values['extra_auth_params']
1209
+ previous_user_login = config._values['user_login']
1210
+ previous_user_password = config._values['user_password']
1211
+ config._values['auth_token'] = None
1212
+ config._values['user_login'] = None
1213
+ config._values['user_password'] = None
1214
+ config._values['extra_auth_params'] = retained_extra_auth_params
1215
+ config._auth_generation += 1
1216
+ del previous_auth_token
1217
+ del previous_extra_auth_params
1218
+ del previous_session_token
1219
+ del previous_user_login
1220
+ del previous_user_password
1221
+ return True
1222
+
1223
+
1224
+ def readiness_identity(config: Config) -> tuple[str, int] | None:
1225
+ """Return the current readiness identity without full validation.
1226
+
1227
+ Args:
1228
+ config: Client configuration to capture.
1229
+
1230
+ Returns:
1231
+ Canonical origin and transport generation, or `None` when the origin is
1232
+ unconfigured or invalid.
1233
+ """
1234
+ with config._lock:
1235
+ try:
1236
+ origin = _canonical_origin(config._values)
1237
+ except (TypeError, ValueError):
1238
+ return None
1239
+ if origin is None:
1240
+ return None
1241
+ return origin, config._transport_generation
1242
+
1243
+
1244
+ def readiness_key(context: RequestContext) -> tuple[str, int]:
1245
+ """Return the capability and transport readiness identity.
1246
+
1247
+ Args:
1248
+ context: Immutable configuration snapshot for one operation.
1249
+
1250
+ Returns:
1251
+ Canonical origin and transport generation for the snapshot.
1252
+
1253
+ Raises:
1254
+ ValueError: If server parameters have not been configured.
1255
+ """
1256
+ if context.canonical_origin is None:
1257
+ raise ValueError('server parameters must be configured before readiness')
1258
+ return context.canonical_origin, context.transport_generation
1259
+
1260
+
1261
+ def require_secure_rpc(context: RequestContext, include_auth: bool) -> None:
1262
+ """Allow captured authentication over HTTP only for direct loopback connections.
1263
+
1264
+ Args:
1265
+ context: Immutable request settings captured before operation I/O.
1266
+ include_auth: Whether the RPC would include captured authentication.
1267
+
1268
+ Raises:
1269
+ ValueError: If authenticated HTTP uses a proxy or a non-loopback
1270
+ origin.
1271
+ """
1272
+ if not include_auth or not context.auth or context.canonical_origin is None:
1273
+ return
1274
+ parsed = urllib.parse.urlsplit(context.canonical_origin)
1275
+ if parsed.scheme != 'http':
1276
+ return
1277
+ host = parsed.hostname
1278
+ if context.proxy_url is None and host is not None:
1279
+ if host.casefold() == 'localhost':
1280
+ return
1281
+ try:
1282
+ address = ipaddress.ip_address(host)
1283
+ except ValueError:
1284
+ pass
1285
+ else:
1286
+ if address.is_loopback and (
1287
+ isinstance(address, ipaddress.IPv4Address)
1288
+ or address == ipaddress.IPv6Address('::1')
1289
+ ):
1290
+ return
1291
+ raise ValueError('authenticated RPCs require HTTPS or a direct literal loopback HTTP origin')
1292
+
1293
+
1294
+ def response_correlation_id(headers: Mapping[str, str]) -> str | None:
1295
+ """Extract only the allowlisted response correlation identifier.
1296
+
1297
+ Args:
1298
+ headers: Read-only response headers.
1299
+
1300
+ Returns:
1301
+ Correlation identifier when present.
1302
+ """
1303
+ return headers.get('x-sg-correlation-id')
1304
+
1305
+
1306
+ def server_host(context: RequestContext) -> str:
1307
+ """Return the configured host spelling for capability metadata.
1308
+
1309
+ Args:
1310
+ context: Immutable configuration snapshot for one operation.
1311
+
1312
+ Returns:
1313
+ The host, including a non-default port when configured.
1314
+
1315
+ Raises:
1316
+ ValueError: If the snapshot has no valid configured origin.
1317
+ """
1318
+ if context.canonical_origin is None:
1319
+ raise ValueError('server parameters must be configured before readiness')
1320
+ parsed = urllib.parse.urlsplit(context.canonical_origin)
1321
+ hostname = parsed.hostname
1322
+ if hostname is None:
1323
+ raise ValueError('configured origin must contain a host')
1324
+ host = f'[{hostname}]' if ':' in hostname else hostname
1325
+ port = parsed.port
1326
+ if port is not None and port != (443 if parsed.scheme == 'https' else 80):
1327
+ return f'{host}:{port}'
1328
+ return host
1329
+
1330
+
1331
+ def server_page_limit(server_info: Mapping[str, object]) -> int:
1332
+ """Return the server page limit, defaulting to 500.
1333
+
1334
+ Args:
1335
+ server_info: Server capability metadata.
1336
+
1337
+ Returns:
1338
+ Configured page limit or the compatibility default.
1339
+
1340
+ Raises:
1341
+ ShotgunError: If the server returned an invalid page limit.
1342
+ """
1343
+ value = server_info.get('api_max_entities_per_page', 500)
1344
+ if not value:
1345
+ return 500
1346
+ if isinstance(value, bool):
1347
+ raise _errors.ShotgunError('The server returned an invalid page limit.')
1348
+ if isinstance(value, int):
1349
+ limit = value
1350
+ elif isinstance(value, str) and value.isdecimal():
1351
+ try:
1352
+ limit = int(value)
1353
+ except ValueError:
1354
+ raise _errors.ShotgunError('The server returned an invalid page limit.') from None
1355
+ else:
1356
+ raise _errors.ShotgunError('The server returned an invalid page limit.')
1357
+ if limit <= 0:
1358
+ raise _errors.ShotgunError('The server returned an invalid page limit.')
1359
+ return limit
1360
+
1361
+
1362
+ def snapshot(config: Config, auth_spec: _protocol.AuthSpec | None = None) -> RequestContext:
1363
+ """Capture one immutable coherent operation context.
1364
+
1365
+ Args:
1366
+ config: Client configuration to capture or update.
1367
+ auth_spec: Optional operation-local authentication override.
1368
+
1369
+ Returns:
1370
+ A frozen snapshot isolated from later configuration writes.
1371
+
1372
+ Raises:
1373
+ TypeError: If stored configuration values have invalid types.
1374
+ ValueError: If stored URL, proxy, retry, or timeout values are invalid.
1375
+ ShotgunError: If stored server metadata contains an invalid page limit.
1376
+ """
1377
+ with config._lock:
1378
+ values = dict(config._values)
1379
+ auth_generation = config._auth_generation
1380
+ origin_generation = config._origin_generation
1381
+ transport_generation = config._transport_generation
1382
+ if auth_spec is None:
1383
+ copied_spec = _auth_spec(values)
1384
+ else:
1385
+ copied_spec = dataclasses.replace(
1386
+ auth_spec,
1387
+ session_uuid=(
1388
+ auth_spec.session_uuid
1389
+ if auth_spec.session_uuid is not None
1390
+ else _owned_optional_string(values['session_uuid'])
1391
+ ),
1392
+ extra_auth_params=_owned_extra_auth(auth_spec.extra_auth_params),
1393
+ )
1394
+ auth = _freeze(_protocol.build_auth_params(copied_spec))
1395
+ extra = _freeze(copied_spec.extra_auth_params or {})
1396
+ canonical_origin, api_url = _origin(values)
1397
+ server_info = values['server_info']
1398
+ if not isinstance(auth, Mapping) or not isinstance(extra, Mapping):
1399
+ raise TypeError('frozen authentication parameters must be mappings')
1400
+ if not isinstance(server_info, Mapping):
1401
+ raise TypeError('server_info must be a mapping')
1402
+ typed_auth = cast('Mapping[str, object]', auth)
1403
+ typed_extra = cast('Mapping[str, object]', extra)
1404
+ typed_server_info = cast('Mapping[str, object]', server_info)
1405
+ records_per_page = server_page_limit(typed_server_info)
1406
+ user_agents = values['user_agents']
1407
+ if not isinstance(user_agents, tuple):
1408
+ raise TypeError('user_agents must be a tuple')
1409
+ object_agents = cast('tuple[object, ...]', user_agents)
1410
+ if not all(isinstance(agent, str) for agent in object_agents):
1411
+ raise TypeError('user_agents entries must be strings')
1412
+ typed_user_agents = cast('tuple[str, ...]', user_agents)
1413
+ site_url = _site_url(values)
1414
+ return RequestContext(
1415
+ canonical_origin=canonical_origin,
1416
+ api_url=api_url,
1417
+ auth=typed_auth,
1418
+ extra_auth_params=typed_extra,
1419
+ user_agent='; '.join(typed_user_agents),
1420
+ session_token=copied_spec.session_token,
1421
+ session_uuid=copied_spec.session_uuid,
1422
+ timeout=_effective_timeout(values['timeout_secs']),
1423
+ localized=bool(values['localized']),
1424
+ convert_datetimes_to_utc=bool(values['convert_datetimes_to_utc']),
1425
+ records_per_page=records_per_page,
1426
+ page_prefetch=_integer(values['page_prefetch'], 'page_prefetch'),
1427
+ pool_maxsize=_integer(values['pool_maxsize'], 'pool_maxsize'),
1428
+ proxy_url=_proxy_url(values),
1429
+ proxy_headers=_proxy_headers(values),
1430
+ max_rpc_attempts=_integer(values['max_rpc_attempts'], 'max_rpc_attempts'),
1431
+ rpc_attempt_interval=_integer(
1432
+ values['rpc_attempt_interval'],
1433
+ 'rpc_attempt_interval',
1434
+ ),
1435
+ origin_generation=origin_generation,
1436
+ transport_generation=transport_generation,
1437
+ auth_generation=auth_generation if auth_spec is None else None,
1438
+ has_one_use_auth_token=typed_auth.get('auth_token') is not None,
1439
+ on_request=_observer(values, 'on_request'),
1440
+ on_response=_observer(values, 'on_response'),
1441
+ on_retry=_observer(values, 'on_retry'),
1442
+ on_progress=_observer(values, 'on_progress'),
1443
+ on_compatibility_warning=_observer(values, 'on_compatibility_warning'),
1444
+ site_url=site_url,
1445
+ )
1446
+
1447
+
1448
+ def thaw_mapping(value: Mapping[str, object]) -> dict[str, object]:
1449
+ """Thaw one immutable authentication mapping.
1450
+
1451
+ Args:
1452
+ value: Recursively frozen authentication snapshot.
1453
+
1454
+ Returns:
1455
+ A detached dictionary containing JSON-compatible built-in containers.
1456
+
1457
+ Raises:
1458
+ TypeError: If the snapshot does not thaw to a dictionary.
1459
+ """
1460
+ thawed = _thaw(value)
1461
+ if not isinstance(thawed, dict):
1462
+ raise TypeError('authentication snapshot must thaw to a dictionary')
1463
+ return cast('dict[str, object]', thawed)