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.
Files changed (56) hide show
  1. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/METADATA +21 -24
  2. sg_python_api-0.2.1.dist-info/RECORD +58 -0
  3. shotgun_api3/__init__.py +4 -0
  4. shotgun_api3/_async_client.py +347 -281
  5. shotgun_api3/_async_driver.py +25 -55
  6. shotgun_api3/_async_httpx2.py +92 -59
  7. shotgun_api3/_async_transport.py +38 -3
  8. shotgun_api3/_capabilities.py +4 -3
  9. shotgun_api3/_client.py +296 -422
  10. shotgun_api3/_config.py +245 -120
  11. shotgun_api3/_connections.py +144 -0
  12. shotgun_api3/_crud.py +25 -250
  13. shotgun_api3/_driver.py +29 -54
  14. shotgun_api3/_execution.py +1 -3
  15. shotgun_api3/_files.py +193 -21
  16. shotgun_api3/_methods.py +59 -411
  17. shotgun_api3/_protocol.py +27 -53
  18. shotgun_api3/_transport.py +82 -407
  19. shotgun_api3/_version.py +7 -2
  20. shotgun_api3/lib/mockgun/__init__.py +20 -7
  21. shotgun_api3/lib/mockgun/_activity.py +510 -0
  22. shotgun_api3/lib/mockgun/_async_client.py +196 -0
  23. shotgun_api3/lib/mockgun/_dates.py +164 -0
  24. shotgun_api3/lib/mockgun/_errors.py +18 -0
  25. shotgun_api3/lib/mockgun/_events.py +1052 -0
  26. shotgun_api3/lib/mockgun/_follows.py +217 -0
  27. shotgun_api3/lib/mockgun/_handlers.py +553 -0
  28. shotgun_api3/lib/mockgun/_links.py +598 -0
  29. shotgun_api3/lib/mockgun/_new_client.py +92 -0
  30. shotgun_api3/lib/mockgun/_presets.py +492 -0
  31. shotgun_api3/lib/mockgun/_query.py +2205 -0
  32. shotgun_api3/lib/mockgun/_retirement.py +463 -0
  33. shotgun_api3/lib/mockgun/_schema.py +172 -0
  34. shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
  35. shotgun_api3/lib/mockgun/_search.py +378 -0
  36. shotgun_api3/lib/mockgun/_server.py +169 -0
  37. shotgun_api3/lib/mockgun/_sessions.py +206 -0
  38. shotgun_api3/lib/mockgun/_site.py +150 -0
  39. shotgun_api3/lib/mockgun/_site_data.py +584 -0
  40. shotgun_api3/lib/mockgun/_storage.py +208 -0
  41. shotgun_api3/lib/mockgun/_store.py +1559 -0
  42. shotgun_api3/lib/mockgun/_summaries.py +965 -0
  43. shotgun_api3/lib/mockgun/_sync_client.py +190 -0
  44. shotgun_api3/lib/mockgun/_threads.py +226 -0
  45. shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
  46. shotgun_api3/lib/mockgun/_transport.py +145 -0
  47. shotgun_api3/lib/mockgun/_uploads.py +417 -0
  48. shotgun_api3/lib/mockgun/_wire.py +120 -0
  49. shotgun_api3/lib/mockgun/mockgun.py +14 -0
  50. shotgun_api3/lib/sgtimezone.py +24 -1
  51. sg_python_api-0.1.2.dist-info/RECORD +0 -31
  52. shotgun_api3/_signatures.py +0 -53
  53. shotgun_api3/lib/mockgun/_async.py +0 -315
  54. shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
  55. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.1.dist-info}/WHEEL +0 -0
  56. {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
- CompatibilityWarningCode: TypeAlias = Literal['legacy-infinite-timeout']
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
- ObserverEvent: TypeAlias = (
41
+ _ObserverEvent: TypeAlias = (
36
42
  'CompatibilityWarningEvent | ProgressEvent | RequestEvent | ResponseEvent | RetryEvent'
37
43
  )
38
- ObserverCallback: TypeAlias = Callable[[ObserverEvent], None]
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[CompatibilityWarningCode, str] = {
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: CompatibilityWarningCode
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 (`max_rpc_attempts`,
230
- `page_prefetch`, `pool_maxsize`, `rpc_attempt_interval`, `timeout_secs`,
231
- and `user_agents`), conversion and localization (`convert_datetimes_to_utc`
232
- and `localized`), and observers (`on_compatibility_warning`, `on_progress`,
233
- `on_request`, `on_response`, and `on_retry`).
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
- _lock: threading.Lock
260
- _auth_generation: int
261
- _origin_generation: int
262
- _transport_generation: int
263
- _values: dict[str, object]
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, '_lock', threading.Lock())
308
- object.__setattr__(self, '_auth_generation', 0)
309
- object.__setattr__(self, '_origin_generation', 0)
310
- object.__setattr__(self, '_transport_generation', 0)
311
- interval = int(os.environ.get('SHOTGUN_API_RETRY_INTERVAL', '3000'))
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('rpc_attempt_interval must be >= 0')
357
+ raise ValueError(interval_error)
314
358
  object.__setattr__(
315
359
  self,
316
- '_values',
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._lock:
371
- value = self._values[name]
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
- if name.startswith('_'):
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._values['server_info'])
394
- self._values['server_info'] = {}
395
- self._origin_generation += 1
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._transport_generation += 1
462
+ self.transport_generation += 1
398
463
  if auth_changed:
399
- self._auth_generation += 1
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._lock:
414
- agents = self._values['user_agents']
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._values['user_agents'] = (*typed_agents, owned_agent)
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._lock:
432
- server_info = self._values['server_info']
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._lock:
442
- previous_agents = self._values['user_agents']
443
- self._values['user_agents'] = _protocol.default_user_agent(PACKAGE_VERSION)
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._lock:
485
- old_canonical_origin = _canonical_origin_or_none(self._values)
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._values[name] is not value:
488
- retired_values.append(self._values[name])
489
- self._values[name] = value
490
- new_canonical_origin = _canonical_origin_or_none(self._values)
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._values, copied, retired_values)
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 = parse_server_params(base_url)
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, None, or UNSET')
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
- lock = cast('threading.Lock', object.__getattribute__(config, '_lock'))
996
- with lock:
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._lock:
1013
- values = dict(config._values)
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[CompatibilityWarningCode] = []
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: ObserverEvent) -> None:
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._lock:
1254
+ with config.lock:
1141
1255
  if (
1142
- context.origin_generation != config._origin_generation
1143
- or context.transport_generation != config._transport_generation
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._values['server_info']
1147
- config._values['server_info'] = typed_copied
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._lock:
1303
+ with config.lock:
1190
1304
  if (
1191
- context.origin_generation != config._origin_generation
1192
- or context.transport_generation != config._transport_generation
1193
- or context.auth_generation != config._auth_generation
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._values['session_token']
1197
- config._values['session_token'] = owned_session_token
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._values['auth_token']
1200
- previous_extra_auth_params = config._values['extra_auth_params']
1201
- previous_user_login = config._values['user_login']
1202
- previous_user_password = config._values['user_password']
1203
- config._values['auth_token'] = None
1204
- config._values['user_login'] = None
1205
- config._values['user_password'] = None
1206
- config._values['extra_auth_params'] = retained_extra_auth_params
1207
- config._auth_generation += 1
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._lock:
1340
+ with config.lock:
1227
1341
  try:
1228
- origin = _canonical_origin(config._values)
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._transport_generation
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 for direct loopback connections.
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 not include_auth or not context.auth or context.canonical_origin is None:
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._lock:
1370
- values = dict(config._values)
1371
- auth_generation = config._auth_generation
1372
- origin_generation = config._origin_generation
1373
- transport_generation = config._transport_generation
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