sg-python-api 0.1.2__py3-none-any.whl → 0.2.1__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.2.dist-info → sg_python_api-0.2.1.dist-info}/METADATA +21 -24
- sg_python_api-0.2.1.dist-info/RECORD +58 -0
- shotgun_api3/__init__.py +4 -0
- shotgun_api3/_async_client.py +347 -281
- shotgun_api3/_async_driver.py +25 -55
- shotgun_api3/_async_httpx2.py +92 -59
- shotgun_api3/_async_transport.py +38 -3
- shotgun_api3/_capabilities.py +4 -3
- shotgun_api3/_client.py +296 -422
- shotgun_api3/_config.py +245 -120
- shotgun_api3/_connections.py +144 -0
- shotgun_api3/_crud.py +25 -250
- shotgun_api3/_driver.py +29 -54
- shotgun_api3/_execution.py +1 -3
- shotgun_api3/_files.py +193 -21
- shotgun_api3/_methods.py +59 -411
- shotgun_api3/_protocol.py +27 -53
- shotgun_api3/_transport.py +82 -407
- shotgun_api3/_version.py +7 -2
- shotgun_api3/lib/mockgun/__init__.py +20 -7
- shotgun_api3/lib/mockgun/_activity.py +510 -0
- shotgun_api3/lib/mockgun/_async_client.py +196 -0
- shotgun_api3/lib/mockgun/_dates.py +164 -0
- shotgun_api3/lib/mockgun/_errors.py +18 -0
- shotgun_api3/lib/mockgun/_events.py +1052 -0
- shotgun_api3/lib/mockgun/_follows.py +217 -0
- shotgun_api3/lib/mockgun/_handlers.py +553 -0
- shotgun_api3/lib/mockgun/_links.py +598 -0
- shotgun_api3/lib/mockgun/_new_client.py +92 -0
- shotgun_api3/lib/mockgun/_presets.py +492 -0
- shotgun_api3/lib/mockgun/_query.py +2205 -0
- shotgun_api3/lib/mockgun/_retirement.py +463 -0
- shotgun_api3/lib/mockgun/_schema.py +172 -0
- shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
- shotgun_api3/lib/mockgun/_search.py +378 -0
- shotgun_api3/lib/mockgun/_server.py +169 -0
- shotgun_api3/lib/mockgun/_sessions.py +206 -0
- shotgun_api3/lib/mockgun/_site.py +150 -0
- shotgun_api3/lib/mockgun/_site_data.py +584 -0
- shotgun_api3/lib/mockgun/_storage.py +208 -0
- shotgun_api3/lib/mockgun/_store.py +1559 -0
- shotgun_api3/lib/mockgun/_summaries.py +965 -0
- shotgun_api3/lib/mockgun/_sync_client.py +190 -0
- shotgun_api3/lib/mockgun/_threads.py +226 -0
- shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
- shotgun_api3/lib/mockgun/_transport.py +145 -0
- shotgun_api3/lib/mockgun/_uploads.py +417 -0
- shotgun_api3/lib/mockgun/_wire.py +120 -0
- shotgun_api3/lib/mockgun/mockgun.py +14 -0
- shotgun_api3/lib/sgtimezone.py +24 -1
- sg_python_api-0.1.2.dist-info/RECORD +0 -31
- shotgun_api3/_signatures.py +0 -53
- shotgun_api3/lib/mockgun/_async.py +0 -315
- shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/WHEEL +0 -0
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/licenses/LICENSE +0 -0
shotgun_api3/_config.py
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
"""Locked client configuration and immutable operation snapshots."""
|
|
2
2
|
|
|
3
|
-
# Same-file collaborators access configuration implementation state.
|
|
4
|
-
# pyright: reportPrivateUsage=false
|
|
5
|
-
|
|
6
3
|
import base64
|
|
7
4
|
import copy
|
|
8
5
|
import dataclasses
|
|
@@ -13,6 +10,7 @@ import os
|
|
|
13
10
|
import threading
|
|
14
11
|
import urllib.parse
|
|
15
12
|
from collections.abc import Callable, Mapping
|
|
13
|
+
from pathlib import Path
|
|
16
14
|
from types import MappingProxyType
|
|
17
15
|
from typing import TYPE_CHECKING, Final, Literal, TypeAlias, cast
|
|
18
16
|
|
|
@@ -21,7 +19,15 @@ from ._sentinel import Unset as _Unset
|
|
|
21
19
|
from ._transport import TimeoutConfig
|
|
22
20
|
from ._version import PACKAGE_VERSION
|
|
23
21
|
|
|
24
|
-
|
|
22
|
+
__all__ = [
|
|
23
|
+
'CompatibilityWarningEvent',
|
|
24
|
+
'ProgressEvent',
|
|
25
|
+
'RequestEvent',
|
|
26
|
+
'ResponseEvent',
|
|
27
|
+
'RetryEvent',
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
_CompatibilityWarningCode: TypeAlias = Literal['legacy-infinite-timeout']
|
|
25
31
|
EventRoute: TypeAlias = Literal[
|
|
26
32
|
'allocation',
|
|
27
33
|
'download',
|
|
@@ -32,10 +38,10 @@ EventRoute: TypeAlias = Literal[
|
|
|
32
38
|
'read',
|
|
33
39
|
'rpc',
|
|
34
40
|
]
|
|
35
|
-
|
|
41
|
+
_ObserverEvent: TypeAlias = (
|
|
36
42
|
'CompatibilityWarningEvent | ProgressEvent | RequestEvent | ResponseEvent | RetryEvent'
|
|
37
43
|
)
|
|
38
|
-
ObserverCallback: TypeAlias = Callable[[
|
|
44
|
+
ObserverCallback: TypeAlias = Callable[[_ObserverEvent], None]
|
|
39
45
|
|
|
40
46
|
Outcome: TypeAlias = Literal['error', 'retry', 'success']
|
|
41
47
|
|
|
@@ -69,6 +75,8 @@ _DEDICATED_AUTH_STRING_FIELDS = frozenset(
|
|
|
69
75
|
_DEFAULT_CONNECT_TIMEOUT = 15.0
|
|
70
76
|
_DEFAULT_PROXY_PORT = 8080
|
|
71
77
|
_DEFAULT_READ_TIMEOUT = 300.0
|
|
78
|
+
_KEEPALIVE_TARGET_MAX = 3600
|
|
79
|
+
_KEEPALIVE_TARGET_MIN = 6
|
|
72
80
|
_MAX_PORT = 65535
|
|
73
81
|
_OBSERVER_FIELDS = (
|
|
74
82
|
'on_compatibility_warning',
|
|
@@ -80,7 +88,7 @@ _OBSERVER_FIELDS = (
|
|
|
80
88
|
_ORIGIN_FIELDS = frozenset({'api_path', 'scheme', 'server'})
|
|
81
89
|
_ORIGIN_STRING_FIELDS = frozenset({'scheme', 'server'})
|
|
82
90
|
_UNSET: Final = _Unset()
|
|
83
|
-
_WARNING_MESSAGES: Mapping[
|
|
91
|
+
_WARNING_MESSAGES: Mapping[_CompatibilityWarningCode, str] = {
|
|
84
92
|
'legacy-infinite-timeout': 'Explicit None timeout enables legacy infinite waits.',
|
|
85
93
|
}
|
|
86
94
|
_PUBLIC_FIELDS = frozenset(
|
|
@@ -91,6 +99,7 @@ _PUBLIC_FIELDS = frozenset(
|
|
|
91
99
|
'api_ver',
|
|
92
100
|
'convert_datetimes_to_utc',
|
|
93
101
|
'localized',
|
|
102
|
+
'max_connection_idle_secs',
|
|
94
103
|
'max_rpc_attempts',
|
|
95
104
|
'page_prefetch',
|
|
96
105
|
'pool_maxsize',
|
|
@@ -104,6 +113,7 @@ _PUBLIC_FIELDS = frozenset(
|
|
|
104
113
|
'server',
|
|
105
114
|
'server_info',
|
|
106
115
|
'session_uuid',
|
|
116
|
+
'tcp_keepalive_timeout_secs',
|
|
107
117
|
'timeout_secs',
|
|
108
118
|
'user_agents',
|
|
109
119
|
}
|
|
@@ -111,12 +121,14 @@ _PUBLIC_FIELDS = frozenset(
|
|
|
111
121
|
_TRANSPORT_FIELDS = frozenset(
|
|
112
122
|
{
|
|
113
123
|
*_ORIGIN_FIELDS,
|
|
124
|
+
'max_connection_idle_secs',
|
|
114
125
|
'pool_maxsize',
|
|
115
126
|
'proxy_pass',
|
|
116
127
|
'proxy_port',
|
|
117
128
|
'proxy_server',
|
|
118
129
|
'proxy_user',
|
|
119
130
|
'raw_http_proxy',
|
|
131
|
+
'tcp_keepalive_timeout_secs',
|
|
120
132
|
}
|
|
121
133
|
)
|
|
122
134
|
|
|
@@ -130,7 +142,7 @@ class CompatibilityWarningEvent:
|
|
|
130
142
|
message: Fixed user-facing warning text.
|
|
131
143
|
"""
|
|
132
144
|
|
|
133
|
-
code:
|
|
145
|
+
code: _CompatibilityWarningCode
|
|
134
146
|
message: str
|
|
135
147
|
|
|
136
148
|
|
|
@@ -173,6 +185,10 @@ class RequestContext:
|
|
|
173
185
|
page_prefetch: Maximum concurrent page requests; zero keeps paging
|
|
174
186
|
serial.
|
|
175
187
|
pool_maxsize: Maximum transport connection-pool size.
|
|
188
|
+
max_connection_idle_secs: Effective idle expiry limit in seconds, or
|
|
189
|
+
`None` when expiry is off.
|
|
190
|
+
tcp_keepalive_timeout_secs: Effective TCP keepalive target in seconds,
|
|
191
|
+
or `None` when keepalive is off.
|
|
176
192
|
proxy_url: Normalized proxy URL, when configured.
|
|
177
193
|
proxy_headers: Frozen proxy authentication headers.
|
|
178
194
|
max_rpc_attempts: Maximum number of RPC attempts.
|
|
@@ -187,6 +203,7 @@ class RequestContext:
|
|
|
187
203
|
on_progress: Optional transfer-progress observer.
|
|
188
204
|
on_compatibility_warning: Optional compatibility-warning observer.
|
|
189
205
|
site_url: Displayable site URL, when configured.
|
|
206
|
+
in_process: Whether the transport stays inside this process.
|
|
190
207
|
"""
|
|
191
208
|
|
|
192
209
|
canonical_origin: str | None
|
|
@@ -202,6 +219,8 @@ class RequestContext:
|
|
|
202
219
|
records_per_page: int
|
|
203
220
|
page_prefetch: int
|
|
204
221
|
pool_maxsize: int
|
|
222
|
+
max_connection_idle_secs: float | None
|
|
223
|
+
tcp_keepalive_timeout_secs: int | None
|
|
205
224
|
proxy_url: str | None
|
|
206
225
|
proxy_headers: Mapping[str, str] = dataclasses.field(repr=False)
|
|
207
226
|
max_rpc_attempts: int
|
|
@@ -216,6 +235,7 @@ class RequestContext:
|
|
|
216
235
|
on_progress: ObserverCallback | None
|
|
217
236
|
on_compatibility_warning: ObserverCallback | None
|
|
218
237
|
site_url: str | None
|
|
238
|
+
in_process: bool = dataclasses.field(default=False, repr=False)
|
|
219
239
|
|
|
220
240
|
|
|
221
241
|
class Config:
|
|
@@ -226,11 +246,13 @@ class Config:
|
|
|
226
246
|
`session_uuid`, `sudo_as_login`, `user_login`, and `user_password`), origin
|
|
227
247
|
and API (`api_path`, `api_ver`, `scheme`, `server`, and `server_info`),
|
|
228
248
|
proxy (`proxy_pass`, `proxy_port`, `proxy_server`, `proxy_user`, and
|
|
229
|
-
`raw_http_proxy`), transport, retry, and pagination
|
|
230
|
-
`
|
|
231
|
-
|
|
232
|
-
and `
|
|
233
|
-
`
|
|
249
|
+
`raw_http_proxy`), transport, retry, and pagination
|
|
250
|
+
(`max_connection_idle_secs`, `max_rpc_attempts`, `page_prefetch`,
|
|
251
|
+
`pool_maxsize`, `rpc_attempt_interval`, `tcp_keepalive_timeout_secs`,
|
|
252
|
+
`timeout_secs`, and `user_agents`), conversion and localization
|
|
253
|
+
(`convert_datetimes_to_utc` and `localized`), and observers
|
|
254
|
+
(`on_compatibility_warning`, `on_progress`, `on_request`, `on_response`,
|
|
255
|
+
and `on_retry`).
|
|
234
256
|
|
|
235
257
|
Reads return detached values except that observer callbacks and the unset
|
|
236
258
|
sentinel retain their identities. Supported assignments are applied
|
|
@@ -242,6 +264,11 @@ class Config:
|
|
|
242
264
|
For `timeout_secs`, the unset sentinel selects 15-second connect and
|
|
243
265
|
300-second read limits, `None` disables both limits, and a finite positive
|
|
244
266
|
number at most `threading.TIMEOUT_MAX` sets both limits.
|
|
267
|
+
`max_connection_idle_secs` accepts a nonnegative finite number and uses
|
|
268
|
+
`None` or `0` to turn expiry off; see `COMPATIBILITY.md` for its meaning.
|
|
269
|
+
`tcp_keepalive_timeout_secs` accepts whole seconds from 6 to 3600 and uses
|
|
270
|
+
`None` or `0` to turn keepalive off; see `COMPATIBILITY.md` for its
|
|
271
|
+
meaning.
|
|
245
272
|
`rpc_attempt_interval` is in milliseconds. `page_prefetch=0` keeps paging
|
|
246
273
|
serial; a positive N permits N concurrent page requests with at most N-1
|
|
247
274
|
later pages fetched ahead. N=1 offers no processing lookahead.
|
|
@@ -254,13 +281,23 @@ class Config:
|
|
|
254
281
|
|
|
255
282
|
Attributes:
|
|
256
283
|
records_per_page: Effective server page limit, defaulting to 500.
|
|
284
|
+
lock: Lock protecting configuration storage and generation state.
|
|
285
|
+
values: Owned mutable configuration storage; direct access requires
|
|
286
|
+
holding `lock`.
|
|
287
|
+
auth_generation: Generation used to reject stale authentication
|
|
288
|
+
publication.
|
|
289
|
+
origin_generation: Generation identifying the configured origin.
|
|
290
|
+
transport_generation: Generation identifying transport configuration
|
|
291
|
+
and retirement changes.
|
|
292
|
+
in_process: Whether requests use an in-process transport.
|
|
257
293
|
"""
|
|
258
294
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
295
|
+
lock: threading.Lock
|
|
296
|
+
auth_generation: int
|
|
297
|
+
in_process: bool
|
|
298
|
+
origin_generation: int
|
|
299
|
+
transport_generation: int
|
|
300
|
+
values: dict[str, object]
|
|
264
301
|
|
|
265
302
|
api_key: str | None
|
|
266
303
|
api_path: str | None
|
|
@@ -269,6 +306,7 @@ class Config:
|
|
|
269
306
|
convert_datetimes_to_utc: bool
|
|
270
307
|
extra_auth_params: dict[str, object] | None
|
|
271
308
|
localized: bool
|
|
309
|
+
max_connection_idle_secs: float | None
|
|
272
310
|
max_rpc_attempts: int
|
|
273
311
|
on_compatibility_warning: ObserverCallback | None
|
|
274
312
|
on_progress: ObserverCallback | None
|
|
@@ -290,12 +328,13 @@ class Config:
|
|
|
290
328
|
session_token: str | None
|
|
291
329
|
session_uuid: str | None
|
|
292
330
|
sudo_as_login: str | None
|
|
331
|
+
tcp_keepalive_timeout_secs: int | None
|
|
293
332
|
timeout_secs: float | _Unset | None
|
|
294
333
|
user_agents: tuple[str, ...]
|
|
295
334
|
user_login: str | None
|
|
296
335
|
user_password: str | None
|
|
297
336
|
|
|
298
|
-
def __init__(self) -> None:
|
|
337
|
+
def __init__(self, *, _in_process: bool = False) -> None:
|
|
299
338
|
"""Initialize official configuration defaults and synchronization state.
|
|
300
339
|
|
|
301
340
|
Read `SHOTGUN_API_RETRY_INTERVAL` as an integer number of milliseconds,
|
|
@@ -304,16 +343,21 @@ class Config:
|
|
|
304
343
|
Raises:
|
|
305
344
|
ValueError: If the environment value is not a nonnegative integer.
|
|
306
345
|
"""
|
|
307
|
-
object.__setattr__(self, '
|
|
308
|
-
object.__setattr__(self, '
|
|
309
|
-
object.__setattr__(self, '
|
|
310
|
-
object.__setattr__(self, '
|
|
311
|
-
|
|
346
|
+
object.__setattr__(self, 'lock', threading.Lock())
|
|
347
|
+
object.__setattr__(self, 'auth_generation', 0)
|
|
348
|
+
object.__setattr__(self, 'in_process', _in_process)
|
|
349
|
+
object.__setattr__(self, 'origin_generation', 0)
|
|
350
|
+
object.__setattr__(self, 'transport_generation', 0)
|
|
351
|
+
interval_error = 'SHOTGUN_API_RETRY_INTERVAL must be a nonnegative integer of milliseconds'
|
|
352
|
+
try:
|
|
353
|
+
interval = int(os.environ.get('SHOTGUN_API_RETRY_INTERVAL', '3000'))
|
|
354
|
+
except ValueError:
|
|
355
|
+
raise ValueError(interval_error) from None
|
|
312
356
|
if interval < 0:
|
|
313
|
-
raise ValueError(
|
|
357
|
+
raise ValueError(interval_error)
|
|
314
358
|
object.__setattr__(
|
|
315
359
|
self,
|
|
316
|
-
'
|
|
360
|
+
'values',
|
|
317
361
|
{
|
|
318
362
|
'api_key': None,
|
|
319
363
|
'api_path': None,
|
|
@@ -322,6 +366,7 @@ class Config:
|
|
|
322
366
|
'convert_datetimes_to_utc': True,
|
|
323
367
|
'extra_auth_params': None,
|
|
324
368
|
'localized': False,
|
|
369
|
+
'max_connection_idle_secs': 60,
|
|
325
370
|
'max_rpc_attempts': 3,
|
|
326
371
|
'on_compatibility_warning': None,
|
|
327
372
|
'on_progress': None,
|
|
@@ -343,6 +388,7 @@ class Config:
|
|
|
343
388
|
'session_token': None,
|
|
344
389
|
'session_uuid': None,
|
|
345
390
|
'sudo_as_login': None,
|
|
391
|
+
'tcp_keepalive_timeout_secs': 60,
|
|
346
392
|
'timeout_secs': _UNSET,
|
|
347
393
|
'user_agents': _protocol.default_user_agent(PACKAGE_VERSION),
|
|
348
394
|
'user_login': None,
|
|
@@ -367,15 +413,34 @@ class Config:
|
|
|
367
413
|
"""
|
|
368
414
|
if name not in _PUBLIC_FIELDS:
|
|
369
415
|
raise AttributeError(name)
|
|
370
|
-
with self.
|
|
371
|
-
value = self.
|
|
416
|
+
with self.lock:
|
|
417
|
+
value = self.values[name]
|
|
372
418
|
if name in _OBSERVER_FIELDS:
|
|
373
419
|
return value
|
|
374
420
|
return value if value is _UNSET else copy.deepcopy(value)
|
|
375
421
|
|
|
376
422
|
def __setattr__(self, name: str, value: object) -> None:
|
|
377
|
-
"""Route direct official configuration writes through the lock.
|
|
378
|
-
|
|
423
|
+
"""Route direct official configuration writes through the lock.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
name: Official configuration name or internal storage
|
|
427
|
+
attribute.
|
|
428
|
+
value: Replacement value for that attribute.
|
|
429
|
+
|
|
430
|
+
Raises:
|
|
431
|
+
AttributeError: If an official configuration name is unknown.
|
|
432
|
+
TypeError: If an official value has an unsupported type.
|
|
433
|
+
ValueError: If an official value violates its supported bounds
|
|
434
|
+
or format.
|
|
435
|
+
"""
|
|
436
|
+
if name.startswith('_') or name in {
|
|
437
|
+
'lock',
|
|
438
|
+
'auth_generation',
|
|
439
|
+
'in_process',
|
|
440
|
+
'origin_generation',
|
|
441
|
+
'transport_generation',
|
|
442
|
+
'values',
|
|
443
|
+
}:
|
|
379
444
|
object.__setattr__(self, name, value)
|
|
380
445
|
return
|
|
381
446
|
self.update_fields(**{name: value})
|
|
@@ -390,13 +455,13 @@ class Config:
|
|
|
390
455
|
) -> None:
|
|
391
456
|
"""Advance locked state identities for the changed field groups."""
|
|
392
457
|
if origin_changed:
|
|
393
|
-
retired_values.append(self.
|
|
394
|
-
self.
|
|
395
|
-
self.
|
|
458
|
+
retired_values.append(self.values['server_info'])
|
|
459
|
+
self.values['server_info'] = {}
|
|
460
|
+
self.origin_generation += 1
|
|
396
461
|
if transport_changed:
|
|
397
|
-
self.
|
|
462
|
+
self.transport_generation += 1
|
|
398
463
|
if auth_changed:
|
|
399
|
-
self.
|
|
464
|
+
self.auth_generation += 1
|
|
400
465
|
|
|
401
466
|
def append_user_agent(self, agent: str) -> None:
|
|
402
467
|
"""Append one user-agent entry under the configuration lock.
|
|
@@ -410,13 +475,13 @@ class Config:
|
|
|
410
475
|
"""
|
|
411
476
|
owned_agent = _owned_string(agent)
|
|
412
477
|
previous_agents: object = None
|
|
413
|
-
with self.
|
|
414
|
-
agents = self.
|
|
478
|
+
with self.lock:
|
|
479
|
+
agents = self.values['user_agents']
|
|
415
480
|
if type(agents) is not tuple:
|
|
416
481
|
raise TypeError('user_agents must be a tuple')
|
|
417
482
|
typed_agents = cast('tuple[str, ...]', agents)
|
|
418
483
|
previous_agents = typed_agents
|
|
419
|
-
self.
|
|
484
|
+
self.values['user_agents'] = (*typed_agents, owned_agent)
|
|
420
485
|
del previous_agents
|
|
421
486
|
|
|
422
487
|
@property
|
|
@@ -428,8 +493,8 @@ class Config:
|
|
|
428
493
|
ShotgunError: If the server metadata contains an invalid page
|
|
429
494
|
limit.
|
|
430
495
|
"""
|
|
431
|
-
with self.
|
|
432
|
-
server_info = self.
|
|
496
|
+
with self.lock:
|
|
497
|
+
server_info = self.values['server_info']
|
|
433
498
|
if not isinstance(server_info, Mapping):
|
|
434
499
|
raise TypeError('server_info must be a mapping')
|
|
435
500
|
typed_server_info = cast('Mapping[str, object]', server_info)
|
|
@@ -438,9 +503,9 @@ class Config:
|
|
|
438
503
|
def reset_user_agent(self) -> None:
|
|
439
504
|
"""Restore the official-compatible default user-agent entries."""
|
|
440
505
|
previous_agents: object = None
|
|
441
|
-
with self.
|
|
442
|
-
previous_agents = self.
|
|
443
|
-
self.
|
|
506
|
+
with self.lock:
|
|
507
|
+
previous_agents = self.values['user_agents']
|
|
508
|
+
self.values['user_agents'] = _protocol.default_user_agent(PACKAGE_VERSION)
|
|
444
509
|
del previous_agents
|
|
445
510
|
|
|
446
511
|
def update_fields(self, **fields: object) -> None:
|
|
@@ -481,20 +546,20 @@ class Config:
|
|
|
481
546
|
if observer is not None and not callable(observer):
|
|
482
547
|
raise TypeError(f'{name} must be callable or None')
|
|
483
548
|
retired_values: list[object] = []
|
|
484
|
-
with self.
|
|
485
|
-
old_canonical_origin = _canonical_origin_or_none(self.
|
|
549
|
+
with self.lock:
|
|
550
|
+
old_canonical_origin = _canonical_origin_or_none(self.values)
|
|
486
551
|
for name, value, is_observer in prepared:
|
|
487
|
-
if not is_observer or self.
|
|
488
|
-
retired_values.append(self.
|
|
489
|
-
self.
|
|
490
|
-
new_canonical_origin = _canonical_origin_or_none(self.
|
|
552
|
+
if not is_observer or self.values[name] is not value:
|
|
553
|
+
retired_values.append(self.values[name])
|
|
554
|
+
self.values[name] = value
|
|
555
|
+
new_canonical_origin = _canonical_origin_or_none(self.values)
|
|
491
556
|
origin_boundary_crossed = bool({'scheme', 'server'} & copied.keys()) and (
|
|
492
557
|
old_canonical_origin is None
|
|
493
558
|
or new_canonical_origin is None
|
|
494
559
|
or old_canonical_origin != new_canonical_origin
|
|
495
560
|
)
|
|
496
561
|
if origin_boundary_crossed:
|
|
497
|
-
_retire_omitted_auth(self.
|
|
562
|
+
_retire_omitted_auth(self.values, copied, retired_values)
|
|
498
563
|
auth_changed = True
|
|
499
564
|
self._advance_generations(
|
|
500
565
|
auth_changed=auth_changed,
|
|
@@ -518,7 +583,7 @@ class Config:
|
|
|
518
583
|
Raises:
|
|
519
584
|
ValueError: If `base_url` has an unsupported or invalid origin.
|
|
520
585
|
"""
|
|
521
|
-
scheme, server, api_path =
|
|
586
|
+
scheme, server, api_path = _parse_server_params(base_url)
|
|
522
587
|
self.update_fields(scheme=scheme, server=server, api_path=api_path)
|
|
523
588
|
|
|
524
589
|
def set_session_uuid(self, session_uuid: str | None) -> None:
|
|
@@ -632,6 +697,34 @@ def _contains_ascii_control(value: str) -> bool:
|
|
|
632
697
|
)
|
|
633
698
|
|
|
634
699
|
|
|
700
|
+
def _effective_idle_limit(value: object) -> float | None:
|
|
701
|
+
"""Resolve the effective finite idle expiry limit."""
|
|
702
|
+
if value is None:
|
|
703
|
+
return None
|
|
704
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
705
|
+
raise TypeError('max_connection_idle_secs must be a number or None')
|
|
706
|
+
try:
|
|
707
|
+
effective = float(value)
|
|
708
|
+
except OverflowError:
|
|
709
|
+
raise ValueError(
|
|
710
|
+
'max_connection_idle_secs must be a finite number that is not negative'
|
|
711
|
+
) from None
|
|
712
|
+
if not math.isfinite(effective) or effective < 0:
|
|
713
|
+
raise ValueError('max_connection_idle_secs must be a finite number that is not negative')
|
|
714
|
+
return effective or None
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _effective_keepalive_target(value: object) -> int | None:
|
|
718
|
+
"""Resolve the effective whole-second TCP keepalive target."""
|
|
719
|
+
if value is None:
|
|
720
|
+
return None
|
|
721
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
722
|
+
raise TypeError('tcp_keepalive_timeout_secs must be a whole number of seconds or None')
|
|
723
|
+
if value != 0 and not _KEEPALIVE_TARGET_MIN <= value <= _KEEPALIVE_TARGET_MAX:
|
|
724
|
+
raise ValueError('tcp_keepalive_timeout_secs must be 0 or from 6 to 3600')
|
|
725
|
+
return value or None
|
|
726
|
+
|
|
727
|
+
|
|
635
728
|
def _effective_timeout(value: object) -> TimeoutConfig:
|
|
636
729
|
"""Resolve official timeout semantics from one locked value."""
|
|
637
730
|
if value is _UNSET:
|
|
@@ -639,7 +732,7 @@ def _effective_timeout(value: object) -> TimeoutConfig:
|
|
|
639
732
|
if value is None:
|
|
640
733
|
return TimeoutConfig(connect=None, read=None)
|
|
641
734
|
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
642
|
-
raise TypeError('timeout_secs must be a number
|
|
735
|
+
raise TypeError('timeout_secs must be a number or None')
|
|
643
736
|
if isinstance(value, float) and not math.isfinite(value):
|
|
644
737
|
raise ValueError('timeout_secs must be finite and positive')
|
|
645
738
|
if value <= 0 or value > threading.TIMEOUT_MAX:
|
|
@@ -825,6 +918,26 @@ def _copy_for_storage(name: str, value: object) -> object:
|
|
|
825
918
|
return copied
|
|
826
919
|
|
|
827
920
|
|
|
921
|
+
def _parse_server_params(base_url: str) -> tuple[str, str, str]:
|
|
922
|
+
"""Parse a base URL into normalized server parameters."""
|
|
923
|
+
parsed = urllib.parse.urlsplit(base_url)
|
|
924
|
+
scheme = parsed.scheme.lower()
|
|
925
|
+
if scheme not in {'http', 'https'}:
|
|
926
|
+
raise ValueError('base_url must be http or https')
|
|
927
|
+
if parsed.hostname is None or parsed.username is not None or parsed.password is not None:
|
|
928
|
+
raise ValueError('base_url must contain a host without credentials')
|
|
929
|
+
try:
|
|
930
|
+
port = parsed.port
|
|
931
|
+
except ValueError as exc:
|
|
932
|
+
raise ValueError('base_url contains an invalid port') from exc
|
|
933
|
+
host = parsed.hostname.encode('idna').decode('ascii').lower()
|
|
934
|
+
host_for_server = f'[{host}]' if ':' in host else host
|
|
935
|
+
server = f'{host_for_server}:{port}' if port is not None else host_for_server
|
|
936
|
+
prefix = parsed.path.rstrip('/')
|
|
937
|
+
api_path = f'{prefix}/api3/json' if prefix else '/api3/json'
|
|
938
|
+
return scheme, server, api_path
|
|
939
|
+
|
|
940
|
+
|
|
828
941
|
def _proxy_headers(values: Mapping[str, object]) -> Mapping[str, str]:
|
|
829
942
|
"""Build immutable Basic proxy authorization from captured credentials."""
|
|
830
943
|
user = _owned_optional_string(values['proxy_user'])
|
|
@@ -974,6 +1087,10 @@ def _validated_retry_value(name: str, value: object) -> int:
|
|
|
974
1087
|
|
|
975
1088
|
def _validate_assignment_values(copied: dict[str, object]) -> None:
|
|
976
1089
|
"""Validate independent field values before committing an assignment batch."""
|
|
1090
|
+
if 'max_connection_idle_secs' in copied:
|
|
1091
|
+
_effective_idle_limit(copied['max_connection_idle_secs'])
|
|
1092
|
+
if 'tcp_keepalive_timeout_secs' in copied:
|
|
1093
|
+
_effective_keepalive_target(copied['tcp_keepalive_timeout_secs'])
|
|
977
1094
|
for name in ('page_prefetch', 'pool_maxsize', 'proxy_port'):
|
|
978
1095
|
if name in copied:
|
|
979
1096
|
_integer(copied[name], name)
|
|
@@ -992,10 +1109,8 @@ def advance_transport_generation(config: Config) -> None:
|
|
|
992
1109
|
Args:
|
|
993
1110
|
config: Internal configuration whose transport identity is retiring.
|
|
994
1111
|
"""
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
generation = cast('int', object.__getattribute__(config, '_transport_generation'))
|
|
998
|
-
object.__setattr__(config, '_transport_generation', generation + 1)
|
|
1112
|
+
with config.lock:
|
|
1113
|
+
config.transport_generation += 1
|
|
999
1114
|
|
|
1000
1115
|
|
|
1001
1116
|
def auth_spec(config: Config) -> _protocol.AuthSpec:
|
|
@@ -1009,8 +1124,8 @@ def auth_spec(config: Config) -> _protocol.AuthSpec:
|
|
|
1009
1124
|
Field bindings are frozen, but additional authentication parameters
|
|
1010
1125
|
remain a mutable dictionary detached from this configuration.
|
|
1011
1126
|
"""
|
|
1012
|
-
with config.
|
|
1013
|
-
values = dict(config.
|
|
1127
|
+
with config.lock:
|
|
1128
|
+
values = dict(config.values)
|
|
1014
1129
|
return _auth_spec(values)
|
|
1015
1130
|
|
|
1016
1131
|
|
|
@@ -1025,7 +1140,7 @@ def compatibility_warning_events(
|
|
|
1025
1140
|
Returns:
|
|
1026
1141
|
Immutable compatibility-warning events in publication order.
|
|
1027
1142
|
"""
|
|
1028
|
-
codes: list[
|
|
1143
|
+
codes: list[_CompatibilityWarningCode] = []
|
|
1029
1144
|
if timeout.connect is None and timeout.read is None:
|
|
1030
1145
|
codes.append('legacy-infinite-timeout')
|
|
1031
1146
|
return tuple(
|
|
@@ -1033,6 +1148,35 @@ def compatibility_warning_events(
|
|
|
1033
1148
|
)
|
|
1034
1149
|
|
|
1035
1150
|
|
|
1151
|
+
def constructor_ca_certs(ca_certs: str | None) -> str | None:
|
|
1152
|
+
"""Resolve and validate the constructor's certificate-authority bundle.
|
|
1153
|
+
|
|
1154
|
+
Args:
|
|
1155
|
+
ca_certs: Bundle path, with a false value falling back to
|
|
1156
|
+
`SHOTGUN_API_CACERTS`.
|
|
1157
|
+
|
|
1158
|
+
Returns:
|
|
1159
|
+
The selected readable file path, or `None` for default trust roots.
|
|
1160
|
+
|
|
1161
|
+
Raises:
|
|
1162
|
+
ValueError: If the selected path is not a readable file.
|
|
1163
|
+
"""
|
|
1164
|
+
bundle = ca_certs or os.environ.get('SHOTGUN_API_CACERTS') or None
|
|
1165
|
+
if bundle is None:
|
|
1166
|
+
return None
|
|
1167
|
+
source = 'ca_certs' if ca_certs else 'SHOTGUN_API_CACERTS'
|
|
1168
|
+
message = f'{source} must name a readable CA bundle file: {bundle}'
|
|
1169
|
+
path = Path(bundle)
|
|
1170
|
+
try:
|
|
1171
|
+
if not path.is_file():
|
|
1172
|
+
raise ValueError(message)
|
|
1173
|
+
with path.open('rb'):
|
|
1174
|
+
pass
|
|
1175
|
+
except OSError:
|
|
1176
|
+
raise ValueError(message) from None
|
|
1177
|
+
return bundle
|
|
1178
|
+
|
|
1179
|
+
|
|
1036
1180
|
def constructor_http_proxy_fields(http_proxy: object) -> dict[str, object] | None:
|
|
1037
1181
|
"""Return configuration updates for one constructor proxy value.
|
|
1038
1182
|
|
|
@@ -1068,7 +1212,7 @@ def event_route(operation: _protocol.OperationDescriptor) -> EventRoute:
|
|
|
1068
1212
|
return operation.endpoint_role or 'rpc'
|
|
1069
1213
|
|
|
1070
1214
|
|
|
1071
|
-
def notify_observer(observer: ObserverCallback | None, event:
|
|
1215
|
+
def notify_observer(observer: ObserverCallback | None, event: _ObserverEvent) -> None:
|
|
1072
1216
|
"""Invoke an observer and suppress ordinary `Exception` failures.
|
|
1073
1217
|
|
|
1074
1218
|
Process-control exceptions propagate. Logged warnings identify only the
|
|
@@ -1084,36 +1228,6 @@ def notify_observer(observer: ObserverCallback | None, event: ObserverEvent) ->
|
|
|
1084
1228
|
observer(event)
|
|
1085
1229
|
|
|
1086
1230
|
|
|
1087
|
-
def parse_server_params(base_url: str) -> tuple[str, str, str]:
|
|
1088
|
-
"""Parse a base URL into normalized server parameters.
|
|
1089
|
-
|
|
1090
|
-
Args:
|
|
1091
|
-
base_url: HTTP or HTTPS site URL.
|
|
1092
|
-
|
|
1093
|
-
Returns:
|
|
1094
|
-
The normalized scheme, server authority, and API path.
|
|
1095
|
-
|
|
1096
|
-
Raises:
|
|
1097
|
-
ValueError: If `base_url` has an unsupported or invalid origin.
|
|
1098
|
-
"""
|
|
1099
|
-
parsed = urllib.parse.urlsplit(base_url)
|
|
1100
|
-
scheme = parsed.scheme.lower()
|
|
1101
|
-
if scheme not in {'http', 'https'}:
|
|
1102
|
-
raise ValueError('base_url must be http or https')
|
|
1103
|
-
if parsed.hostname is None or parsed.username is not None or parsed.password is not None:
|
|
1104
|
-
raise ValueError('base_url must contain a host without credentials')
|
|
1105
|
-
try:
|
|
1106
|
-
port = parsed.port
|
|
1107
|
-
except ValueError as exc:
|
|
1108
|
-
raise ValueError('base_url contains an invalid port') from exc
|
|
1109
|
-
host = parsed.hostname.encode('idna').decode('ascii').lower()
|
|
1110
|
-
host_for_server = f'[{host}]' if ':' in host else host
|
|
1111
|
-
server = f'{host_for_server}:{port}' if port is not None else host_for_server
|
|
1112
|
-
prefix = parsed.path.rstrip('/')
|
|
1113
|
-
api_path = f'{prefix}/api3/json' if prefix else '/api3/json'
|
|
1114
|
-
return scheme, server, api_path
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
1231
|
def publish_server_info(
|
|
1118
1232
|
config: Config,
|
|
1119
1233
|
context: RequestContext,
|
|
@@ -1137,14 +1251,14 @@ def publish_server_info(
|
|
|
1137
1251
|
raise TypeError('server_info must be a dictionary')
|
|
1138
1252
|
typed_copied = cast('dict[str, object]', copied)
|
|
1139
1253
|
previous_server_info: object = None
|
|
1140
|
-
with config.
|
|
1254
|
+
with config.lock:
|
|
1141
1255
|
if (
|
|
1142
|
-
context.origin_generation != config.
|
|
1143
|
-
or context.transport_generation != config.
|
|
1256
|
+
context.origin_generation != config.origin_generation
|
|
1257
|
+
or context.transport_generation != config.transport_generation
|
|
1144
1258
|
):
|
|
1145
1259
|
return False
|
|
1146
|
-
previous_server_info = config.
|
|
1147
|
-
config.
|
|
1260
|
+
previous_server_info = config.values['server_info']
|
|
1261
|
+
config.values['server_info'] = typed_copied
|
|
1148
1262
|
del previous_server_info
|
|
1149
1263
|
return True
|
|
1150
1264
|
|
|
@@ -1186,25 +1300,25 @@ def publish_session_token(config: Config, context: RequestContext, session_token
|
|
|
1186
1300
|
previous_session_token: object = None
|
|
1187
1301
|
previous_user_login: object = None
|
|
1188
1302
|
previous_user_password: object = None
|
|
1189
|
-
with config.
|
|
1303
|
+
with config.lock:
|
|
1190
1304
|
if (
|
|
1191
|
-
context.origin_generation != config.
|
|
1192
|
-
or context.transport_generation != config.
|
|
1193
|
-
or context.auth_generation != config.
|
|
1305
|
+
context.origin_generation != config.origin_generation
|
|
1306
|
+
or context.transport_generation != config.transport_generation
|
|
1307
|
+
or context.auth_generation != config.auth_generation
|
|
1194
1308
|
):
|
|
1195
1309
|
return False
|
|
1196
|
-
previous_session_token = config.
|
|
1197
|
-
config.
|
|
1310
|
+
previous_session_token = config.values['session_token']
|
|
1311
|
+
config.values['session_token'] = owned_session_token
|
|
1198
1312
|
if context.has_one_use_auth_token:
|
|
1199
|
-
previous_auth_token = config.
|
|
1200
|
-
previous_extra_auth_params = config.
|
|
1201
|
-
previous_user_login = config.
|
|
1202
|
-
previous_user_password = config.
|
|
1203
|
-
config.
|
|
1204
|
-
config.
|
|
1205
|
-
config.
|
|
1206
|
-
config.
|
|
1207
|
-
config.
|
|
1313
|
+
previous_auth_token = config.values['auth_token']
|
|
1314
|
+
previous_extra_auth_params = config.values['extra_auth_params']
|
|
1315
|
+
previous_user_login = config.values['user_login']
|
|
1316
|
+
previous_user_password = config.values['user_password']
|
|
1317
|
+
config.values['auth_token'] = None
|
|
1318
|
+
config.values['user_login'] = None
|
|
1319
|
+
config.values['user_password'] = None
|
|
1320
|
+
config.values['extra_auth_params'] = retained_extra_auth_params
|
|
1321
|
+
config.auth_generation += 1
|
|
1208
1322
|
del previous_auth_token
|
|
1209
1323
|
del previous_extra_auth_params
|
|
1210
1324
|
del previous_session_token
|
|
@@ -1223,14 +1337,14 @@ def readiness_identity(config: Config) -> tuple[str, int] | None:
|
|
|
1223
1337
|
Canonical origin and transport generation, or `None` when the origin is
|
|
1224
1338
|
unconfigured or invalid.
|
|
1225
1339
|
"""
|
|
1226
|
-
with config.
|
|
1340
|
+
with config.lock:
|
|
1227
1341
|
try:
|
|
1228
|
-
origin = _canonical_origin(config.
|
|
1342
|
+
origin = _canonical_origin(config.values)
|
|
1229
1343
|
except (TypeError, ValueError):
|
|
1230
1344
|
return None
|
|
1231
1345
|
if origin is None:
|
|
1232
1346
|
return None
|
|
1233
|
-
return origin, config.
|
|
1347
|
+
return origin, config.transport_generation
|
|
1234
1348
|
|
|
1235
1349
|
|
|
1236
1350
|
def readiness_key(context: RequestContext) -> tuple[str, int]:
|
|
@@ -1251,7 +1365,7 @@ def readiness_key(context: RequestContext) -> tuple[str, int]:
|
|
|
1251
1365
|
|
|
1252
1366
|
|
|
1253
1367
|
def require_secure_rpc(context: RequestContext, include_auth: bool) -> None:
|
|
1254
|
-
"""Allow captured authentication over HTTP only
|
|
1368
|
+
"""Allow captured authentication over HTTP only in process or on loopback.
|
|
1255
1369
|
|
|
1256
1370
|
Args:
|
|
1257
1371
|
context: Immutable request settings captured before operation I/O.
|
|
@@ -1261,7 +1375,12 @@ def require_secure_rpc(context: RequestContext, include_auth: bool) -> None:
|
|
|
1261
1375
|
ValueError: If authenticated HTTP uses a proxy or a non-loopback
|
|
1262
1376
|
origin.
|
|
1263
1377
|
"""
|
|
1264
|
-
if
|
|
1378
|
+
if (
|
|
1379
|
+
context.in_process
|
|
1380
|
+
or not include_auth
|
|
1381
|
+
or not context.auth
|
|
1382
|
+
or context.canonical_origin is None
|
|
1383
|
+
):
|
|
1265
1384
|
return
|
|
1266
1385
|
parsed = urllib.parse.urlsplit(context.canonical_origin)
|
|
1267
1386
|
if parsed.scheme != 'http':
|
|
@@ -1366,11 +1485,12 @@ def snapshot(config: Config, auth_spec: _protocol.AuthSpec | None = None) -> Req
|
|
|
1366
1485
|
ValueError: If stored URL, proxy, retry, or timeout values are invalid.
|
|
1367
1486
|
ShotgunError: If stored server metadata contains an invalid page limit.
|
|
1368
1487
|
"""
|
|
1369
|
-
with config.
|
|
1370
|
-
values = dict(config.
|
|
1371
|
-
auth_generation = config.
|
|
1372
|
-
|
|
1373
|
-
|
|
1488
|
+
with config.lock:
|
|
1489
|
+
values = dict(config.values)
|
|
1490
|
+
auth_generation = config.auth_generation
|
|
1491
|
+
in_process = config.in_process
|
|
1492
|
+
origin_generation = config.origin_generation
|
|
1493
|
+
transport_generation = config.transport_generation
|
|
1374
1494
|
if auth_spec is None:
|
|
1375
1495
|
copied_spec = _auth_spec(values)
|
|
1376
1496
|
else:
|
|
@@ -1417,6 +1537,10 @@ def snapshot(config: Config, auth_spec: _protocol.AuthSpec | None = None) -> Req
|
|
|
1417
1537
|
records_per_page=records_per_page,
|
|
1418
1538
|
page_prefetch=_integer(values['page_prefetch'], 'page_prefetch'),
|
|
1419
1539
|
pool_maxsize=_integer(values['pool_maxsize'], 'pool_maxsize'),
|
|
1540
|
+
max_connection_idle_secs=_effective_idle_limit(values['max_connection_idle_secs']),
|
|
1541
|
+
tcp_keepalive_timeout_secs=_effective_keepalive_target(
|
|
1542
|
+
values['tcp_keepalive_timeout_secs']
|
|
1543
|
+
),
|
|
1420
1544
|
proxy_url=_proxy_url(values),
|
|
1421
1545
|
proxy_headers=_proxy_headers(values),
|
|
1422
1546
|
max_rpc_attempts=_integer(values['max_rpc_attempts'], 'max_rpc_attempts'),
|
|
@@ -1434,6 +1558,7 @@ def snapshot(config: Config, auth_spec: _protocol.AuthSpec | None = None) -> Req
|
|
|
1434
1558
|
on_progress=_observer(values, 'on_progress'),
|
|
1435
1559
|
on_compatibility_warning=_observer(values, 'on_compatibility_warning'),
|
|
1436
1560
|
site_url=site_url,
|
|
1561
|
+
in_process=in_process,
|
|
1437
1562
|
)
|
|
1438
1563
|
|
|
1439
1564
|
|