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.
- sg_python_api-0.1.0.dist-info/METADATA +177 -0
- sg_python_api-0.1.0.dist-info/RECORD +31 -0
- sg_python_api-0.1.0.dist-info/WHEEL +4 -0
- sg_python_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- shotgun_api3/__init__.py +68 -0
- shotgun_api3/_async_client.py +2906 -0
- shotgun_api3/_async_driver.py +945 -0
- shotgun_api3/_async_httpx2.py +662 -0
- shotgun_api3/_async_transport.py +162 -0
- shotgun_api3/_capabilities.py +165 -0
- shotgun_api3/_client.py +2682 -0
- shotgun_api3/_config.py +1463 -0
- shotgun_api3/_crud.py +916 -0
- shotgun_api3/_driver.py +879 -0
- shotgun_api3/_errors.py +49 -0
- shotgun_api3/_execution.py +273 -0
- shotgun_api3/_files.py +1094 -0
- shotgun_api3/_methods.py +1205 -0
- shotgun_api3/_protocol.py +1347 -0
- shotgun_api3/_sentinel.py +5 -0
- shotgun_api3/_sg_timezone.py +5 -0
- shotgun_api3/_signatures.py +53 -0
- shotgun_api3/_transport.py +901 -0
- shotgun_api3/_version.py +3 -0
- shotgun_api3/lib/__init__.py +1 -0
- shotgun_api3/lib/mockgun/__init__.py +16 -0
- shotgun_api3/lib/mockgun/_async.py +315 -0
- shotgun_api3/lib/mockgun/_mockgun.py +1666 -0
- shotgun_api3/lib/sgtimezone.py +209 -0
- shotgun_api3/py.typed +0 -0
- shotgun_api3/shotgun.py +58 -0
shotgun_api3/_config.py
ADDED
|
@@ -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)
|