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.
shotgun_api3/_files.py ADDED
@@ -0,0 +1,1094 @@
1
+ """Pure file-transfer routing helpers and observed endpoint paths."""
2
+
3
+ import copy
4
+ import dataclasses
5
+ import datetime
6
+ import http.cookiejar
7
+ import os
8
+ import tempfile
9
+ import urllib.parse
10
+ import urllib.request
11
+ import uuid
12
+ from collections.abc import Callable, Generator, Iterator, Mapping, Sequence
13
+ from contextlib import ExitStack, contextmanager
14
+ from pathlib import Path
15
+ from typing import BinaryIO, Literal, TypeAlias, TypedDict, cast
16
+
17
+ from . import _protocol
18
+ from ._config import RequestContext
19
+ from ._errors import ShotgunError, ShotgunFileDownloadError
20
+ from ._transport import TransportRequest
21
+
22
+ COMPLETE_MULTIPART = '/upload/api_complete_multipart_upload'
23
+ GET_PART_LINK = '/upload/api_get_upload_link_for_part'
24
+ GET_UPLOAD_LINK_INFO = '/upload/api_get_upload_link_info'
25
+ LINK_FILE = '/upload/api_link_file'
26
+ SHARE_THUMBNAIL = '/upload/share_thumbnail'
27
+ SHARE_THUMBNAIL_OPERATION = _protocol.OperationDescriptor(
28
+ category='upload',
29
+ replay_safe=False,
30
+ body_restartable=True,
31
+ endpoint_role=None,
32
+ )
33
+ _FILE_SERVE_ATTACHMENT = '/file_serve/attachment/'
34
+ _MULTIPART_CHUNK_SIZE = 20_000_000
35
+ _Origin: TypeAlias = tuple[str, str, int]
36
+ _PART_LINK_FIELD_COUNT = 2
37
+ _UPLOAD_FILE = '/upload/upload_file'
38
+
39
+
40
+ @dataclasses.dataclass(frozen=True)
41
+ class StorageRange:
42
+ """Describe one restartable file range for a storage PUT.
43
+
44
+ Attributes:
45
+ length: Number of bytes in the range.
46
+ offset: Zero-based file offset.
47
+ path: Source file path.
48
+ """
49
+
50
+ length: int
51
+ offset: int
52
+ path: str
53
+
54
+
55
+ @dataclasses.dataclass(frozen=True)
56
+ class _ThumbnailShare:
57
+ """Carry validated detached thumbnail-sharing inputs."""
58
+
59
+ entities: list[Mapping[str, object]]
60
+ filmstrip_thumbnail: bool
61
+ source_entity: Mapping[str, object]
62
+ thumbnail_path: str | None
63
+ source_entity_type: str
64
+ source_entity_id: int
65
+
66
+
67
+ class ThumbnailUploadKeywordArguments(TypedDict, total=False):
68
+ """Describe optional upload arguments accepted by thumbnail wrappers.
69
+
70
+ Attributes:
71
+ display_name: Optional Attachment display name.
72
+ tag_list: Optional comma-delimited tags.
73
+ created_at: Optional Attachment creation timestamp.
74
+ """
75
+
76
+ display_name: str | None
77
+ tag_list: str | None
78
+ created_at: datetime.datetime | None
79
+
80
+
81
+ @dataclasses.dataclass(frozen=True)
82
+ class UploadArguments:
83
+ """Collect the exact public upload arguments.
84
+
85
+ Attributes:
86
+ entity_type: Entity type receiving the upload.
87
+ entity_id: Numeric identifier of the receiving entity.
88
+ path: Source file path; byte values are decoded during preparation.
89
+ field_name: Optional file field.
90
+ display_name: Optional Attachment display name.
91
+ tag_list: Optional comma-delimited tags.
92
+ created_at: Optional Attachment creation timestamp.
93
+ """
94
+
95
+ entity_type: str
96
+ entity_id: int
97
+ path: str
98
+ field_name: str | None = None
99
+ display_name: str | None = None
100
+ tag_list: str | None = None
101
+ created_at: datetime.datetime | None = None
102
+
103
+
104
+ @dataclasses.dataclass(frozen=True)
105
+ class UploadExchange:
106
+ """Describe one upload exchange and its fresh body factory.
107
+
108
+ Attributes:
109
+ body_factory: Build a fresh request body for every attempt.
110
+ headers: Request headers copied at the send boundary.
111
+ method: HTTP request method.
112
+ operation: Replay and route classification.
113
+ url: Complete request URL.
114
+ """
115
+
116
+ body_factory: Callable[[], bytes | Iterator[bytes] | None]
117
+ headers: dict[str, str]
118
+ method: str
119
+ operation: _protocol.OperationDescriptor
120
+ url: str
121
+
122
+
123
+ @dataclasses.dataclass(frozen=True, init=False)
124
+ class UploadLinkInfo:
125
+ """Preserve and expose one observed upload-allocation response.
126
+
127
+ Attributes:
128
+ encoded: Original space-delimited allocation response.
129
+ timestamp: Parsed allocation timestamp.
130
+ upload_id: Upload allocation identifier.
131
+ upload_type: Upload allocation type.
132
+ upload_url: Upload destination URL.
133
+ """
134
+
135
+ encoded: str
136
+ timestamp: str
137
+ upload_id: str
138
+ upload_type: str
139
+ upload_url: str
140
+
141
+ def __init__(self, encoded: str, multipart: bool) -> None:
142
+ """Parse one upload-allocation response.
143
+
144
+ Args:
145
+ encoded: Space-delimited allocation response from the server.
146
+ multipart: Whether the allocation is for a multipart upload.
147
+
148
+ Raises:
149
+ ShotgunError: If the allocation response is unsuccessful or
150
+ malformed.
151
+ """
152
+ parts = encoded.split()
153
+ expected_count = 6 if multipart else 5
154
+ try:
155
+ success = int(parts[0])
156
+ except (IndexError, ValueError):
157
+ success = 0
158
+ if len(parts) != expected_count or success != 1:
159
+ raise ShotgunError('upload allocation returned an invalid response')
160
+ object.__setattr__(self, 'encoded', encoded)
161
+ object.__setattr__(self, 'upload_url', parts[1])
162
+ object.__setattr__(self, 'timestamp', parts[2])
163
+ object.__setattr__(self, 'upload_type', parts[3])
164
+ object.__setattr__(self, 'upload_id', parts[4])
165
+
166
+
167
+ @dataclasses.dataclass(frozen=True)
168
+ class UploadOptions:
169
+ """Collect one upload call and its thumbnail-routing state.
170
+
171
+ Attributes:
172
+ arguments: Bound public upload arguments.
173
+ upload_type: Attachment or thumbnail endpoint type.
174
+ filmstrip: Whether the upload is a filmstrip thumbnail.
175
+ """
176
+
177
+ arguments: UploadArguments
178
+ upload_type: Literal['Attachment', 'Thumbnail']
179
+ filmstrip: bool
180
+
181
+
182
+ @dataclasses.dataclass(frozen=True)
183
+ class PreparedUpload:
184
+ """Carry validated file metadata with one upload call.
185
+
186
+ Attributes:
187
+ filename: File basename used by upload endpoints.
188
+ file_size: Positive file size captured before network I/O.
189
+ options: Immutable public-call and thumbnail-routing options.
190
+ """
191
+
192
+ filename: str
193
+ file_size: int
194
+ options: 'UploadOptions'
195
+
196
+
197
+ class _ExactOriginCookiePolicy(http.cookiejar.DefaultCookiePolicy):
198
+ """Restrict session-cookie returns to one canonical HTTP origin."""
199
+
200
+ def __init__(self, origin: _Origin) -> None:
201
+ """Initialize a cookie policy for one exact origin."""
202
+ super().__init__()
203
+ self._origin = origin
204
+
205
+ def domain_return_ok(
206
+ self,
207
+ domain: str,
208
+ request: urllib.request.Request,
209
+ ) -> bool:
210
+ """Return whether a cookie domain may serve the request origin.
211
+
212
+ Args:
213
+ domain: Candidate cookie-jar domain.
214
+ request: Stdlib request receiving cookie headers.
215
+
216
+ Returns:
217
+ Whether exact-origin matching and configured domain lists allow the
218
+ domain.
219
+ """
220
+ return (
221
+ domain == self._origin[1]
222
+ and needs_session_cookie(request.get_full_url(), self._origin)
223
+ and not self.is_blocked(domain)
224
+ and not self.is_not_allowed(domain)
225
+ )
226
+
227
+ def return_ok_domain(
228
+ self,
229
+ cookie: http.cookiejar.Cookie,
230
+ request: urllib.request.Request,
231
+ ) -> bool:
232
+ """Return whether a cookie belongs to the exact request origin.
233
+
234
+ Args:
235
+ cookie: Candidate cookie from the jar.
236
+ request: Stdlib request receiving cookie headers.
237
+
238
+ Returns:
239
+ Whether the canonical cookie domain and request origin match the
240
+ captured origin.
241
+ """
242
+ return cookie.domain == self._origin[1] and needs_session_cookie(
243
+ request.get_full_url(),
244
+ self._origin,
245
+ )
246
+
247
+
248
+ def _build_form_upload_fields(prepared: PreparedUpload) -> dict[str, str]:
249
+ """Build the non-authenticated legacy form-upload fields."""
250
+ arguments = prepared.options.arguments
251
+ return {
252
+ 'entity_type': arguments.entity_type,
253
+ 'entity_id': str(arguments.entity_id),
254
+ 'field_name': arguments.field_name or '',
255
+ 'display_name': arguments.display_name or prepared.filename,
256
+ }
257
+
258
+
259
+ def _entity_reference(entity: Mapping[str, object]) -> str:
260
+ """Serialize one entity as the upload endpoint reference grammar."""
261
+ entity_type = entity.get('type')
262
+ entity_id = entity.get('id')
263
+ if not isinstance(entity_type, str) or not entity_type:
264
+ raise ShotgunError('thumbnail entities must contain a nonempty string type')
265
+ if not isinstance(entity_id, int) or isinstance(entity_id, bool):
266
+ raise ShotgunError('thumbnail entities must contain an integer id')
267
+ return f'{entity_type}_{entity_id}'
268
+
269
+
270
+ def _iter_file(
271
+ path: str,
272
+ chunk_size: int = 65536,
273
+ *,
274
+ offset: int = 0,
275
+ length: int | None = None,
276
+ ) -> Generator[bytes, None, None]:
277
+ """Stream a bounded file range while owning its handle."""
278
+ if chunk_size <= 0:
279
+ raise ValueError('file chunk size must be positive')
280
+ if offset < 0:
281
+ raise ValueError('file offset must be nonnegative')
282
+ if length is not None and length < 0:
283
+ raise ValueError('file length must be nonnegative')
284
+ remaining = length
285
+ with Path(path).open('rb') as handle:
286
+ handle.seek(offset)
287
+ while remaining is None or remaining > 0:
288
+ read_size = chunk_size if remaining is None else min(chunk_size, remaining)
289
+ chunk = handle.read(read_size)
290
+ if not chunk:
291
+ break
292
+ yield chunk
293
+ if remaining is not None:
294
+ remaining -= len(chunk)
295
+
296
+
297
+ def _multipart_content_length(
298
+ plan: _protocol.MultipartPlan,
299
+ file_sizes: Mapping[str, int],
300
+ ) -> int:
301
+ """Return the exact byte length of a multipart plan."""
302
+ return sum(
303
+ file_sizes[segment.name] if isinstance(segment, _protocol.FileSlot) else len(segment)
304
+ for segment in plan.segments
305
+ )
306
+
307
+
308
+ def _pump_multipart(
309
+ plan: _protocol.MultipartPlan,
310
+ opened: Mapping[str, BinaryIO],
311
+ chunk_size: int = 65536,
312
+ ) -> Generator[bytes, None, None]:
313
+ """Yield multipart framing while reading file slots incrementally."""
314
+ if chunk_size <= 0:
315
+ raise ValueError('multipart chunk size must be positive')
316
+ for segment in plan.segments:
317
+ if isinstance(segment, _protocol.FileSlot):
318
+ handle = opened[segment.name]
319
+ while chunk := handle.read(chunk_size):
320
+ yield chunk
321
+ else:
322
+ yield segment
323
+
324
+
325
+ def _pump_multipart_from_disk(
326
+ plan: _protocol.MultipartPlan,
327
+ paths: Mapping[str, str],
328
+ chunk_size: int = 65536,
329
+ ) -> Generator[bytes, None, None]:
330
+ """Pump multipart framing through lazily opened disk files."""
331
+ with ExitStack() as stack:
332
+ opened: dict[str, BinaryIO] = {}
333
+ for segment in plan.segments:
334
+ if isinstance(segment, _protocol.FileSlot) and segment.name not in opened:
335
+ opened[segment.name] = stack.enter_context(Path(paths[segment.name]).open('rb'))
336
+ yield from _pump_multipart(plan, opened, chunk_size)
337
+
338
+
339
+ def _requires_direct_upload(
340
+ server_info: Mapping[str, object],
341
+ entity_type: str,
342
+ field_name: str | None,
343
+ ) -> bool:
344
+ """Return whether server capabilities route a field to direct storage."""
345
+ if not server_info.get('s3_direct_uploads_enabled'):
346
+ return False
347
+
348
+ raw_allowed_types = server_info.get('s3_enabled_upload_types')
349
+ if isinstance(raw_allowed_types, Mapping):
350
+ allowed_types = cast('Mapping[object, object]', raw_allowed_types)
351
+ for key in (entity_type, '*'):
352
+ allowed_fields = allowed_types.get(key)
353
+ if allowed_fields == '*':
354
+ return True
355
+ if isinstance(allowed_fields, list) and (
356
+ '*' in allowed_fields or field_name in allowed_fields
357
+ ):
358
+ return True
359
+
360
+ return entity_type == 'Version' and field_name == 'sg_uploaded_movie'
361
+
362
+
363
+ def add_auth_fields(
364
+ fields: Mapping[str, str],
365
+ auth: Mapping[str, object],
366
+ ) -> dict[str, str]:
367
+ """Copy form fields and append captured authentication values.
368
+
369
+ Args:
370
+ fields: Endpoint-specific form fields.
371
+ auth: Immutable authentication snapshot.
372
+
373
+ Returns:
374
+ Independent form fields with non-`None` auth values appended.
375
+ """
376
+ result = dict(fields)
377
+ result.update({name: str(value) for name, value in auth.items() if value is not None})
378
+ return result
379
+
380
+
381
+ def build_auth_cookie_handler(
382
+ token: str,
383
+ origin: _Origin,
384
+ ) -> urllib.request.HTTPCookieProcessor:
385
+ """Build a stdlib cookie handler restricted to one captured origin.
386
+
387
+ Args:
388
+ token: Captured session token.
389
+ origin: Canonical scheme, hostname, and effective port.
390
+
391
+ Returns:
392
+ Handler containing one host-only `_session_id` cookie.
393
+ """
394
+ scheme, hostname, _port = origin
395
+ cookie = http.cookiejar.Cookie(
396
+ version=0,
397
+ name='_session_id',
398
+ value=token,
399
+ port=None,
400
+ port_specified=False,
401
+ domain=hostname,
402
+ domain_specified=False,
403
+ domain_initial_dot=False,
404
+ path='/',
405
+ path_specified=True,
406
+ secure=scheme == 'https',
407
+ expires=None,
408
+ discard=True,
409
+ comment=None,
410
+ comment_url=None,
411
+ rest={},
412
+ rfc2109=False,
413
+ )
414
+ jar = http.cookiejar.LWPCookieJar(policy=_ExactOriginCookiePolicy(origin))
415
+ jar.set_cookie(cookie)
416
+ return urllib.request.HTTPCookieProcessor(jar)
417
+
418
+
419
+ def build_complete_fields(
420
+ upload_info: UploadLinkInfo,
421
+ filename: str,
422
+ etags: Sequence[str],
423
+ ) -> dict[str, str]:
424
+ """Build the observed multipart-finalization form fields.
425
+
426
+ Args:
427
+ upload_info: Validated multipart allocation response.
428
+ filename: Uploaded file basename.
429
+ etags: Storage ETags in part order.
430
+
431
+ Returns:
432
+ Independent multipart-finalization fields.
433
+ """
434
+ return {
435
+ 'etags': ','.join(etags),
436
+ 'filename': filename,
437
+ 'timestamp': upload_info.timestamp,
438
+ 'upload_id': upload_info.upload_id,
439
+ 'upload_type': upload_info.upload_type,
440
+ }
441
+
442
+
443
+ def build_download_request(
444
+ context: RequestContext,
445
+ request_url: str,
446
+ request_headers: Mapping[str, str],
447
+ ) -> TransportRequest:
448
+ """Build a fresh GET request using captured timeout and detached headers."""
449
+ return TransportRequest(
450
+ method='GET',
451
+ url=request_url,
452
+ headers=dict(request_headers),
453
+ body=None,
454
+ timeout=context.timeout,
455
+ token=uuid.uuid4().hex,
456
+ )
457
+
458
+
459
+ def build_link_entity_fields(prepared: PreparedUpload) -> dict[str, str]:
460
+ """Build entity metadata for an upload-link completion.
461
+
462
+ Args:
463
+ prepared: Validated upload metadata and arguments.
464
+
465
+ Returns:
466
+ Independent entity fields excluding opaque allocation information.
467
+ """
468
+ options = prepared.options
469
+ arguments = options.arguments
470
+ fields = {
471
+ 'entity_type': arguments.entity_type,
472
+ 'entity_id': str(arguments.entity_id),
473
+ }
474
+ if options.upload_type == 'Attachment':
475
+ if arguments.field_name is not None:
476
+ fields['field_name'] = arguments.field_name
477
+ fields['display_name'] = arguments.display_name or prepared.filename
478
+ elif arguments.display_name is not None:
479
+ fields['display_name'] = arguments.display_name
480
+ if arguments.tag_list is not None:
481
+ fields['tag_list'] = arguments.tag_list
482
+ if arguments.created_at is not None:
483
+ fields['created_at'] = str(arguments.created_at)
484
+ if options.filmstrip:
485
+ fields['filmstrip'] = 'true'
486
+ return fields
487
+
488
+
489
+ def build_link_file_fields(
490
+ entity_fields: Mapping[str, str],
491
+ encoded_upload_link_info: str,
492
+ ) -> dict[str, str]:
493
+ """Build link-completion fields without expanding allocation data.
494
+
495
+ Args:
496
+ entity_fields: Entity, display, and optional metadata fields.
497
+ encoded_upload_link_info: Original allocation response text.
498
+
499
+ Returns:
500
+ A copy with the single opaque `upload_link_info` field appended.
501
+ """
502
+ return {**entity_fields, 'upload_link_info': encoded_upload_link_info}
503
+
504
+
505
+ def build_part_fields(
506
+ upload_info: UploadLinkInfo,
507
+ filename: str,
508
+ part_number: int,
509
+ ) -> dict[str, str]:
510
+ """Build the observed multipart part-link form fields.
511
+
512
+ Args:
513
+ upload_info: Validated multipart allocation response.
514
+ filename: Uploaded file basename.
515
+ part_number: One-based part number.
516
+
517
+ Returns:
518
+ Independent part-link fields.
519
+ """
520
+ return {
521
+ 'filename': filename,
522
+ 'part_number': str(part_number),
523
+ 'timestamp': upload_info.timestamp,
524
+ 'upload_id': upload_info.upload_id,
525
+ 'upload_type': upload_info.upload_type,
526
+ }
527
+
528
+
529
+ def build_share_thumbnail_fields(
530
+ entities: Sequence[Mapping[str, object]],
531
+ source_entity: Mapping[str, object],
532
+ filmstrip_thumbnail: bool,
533
+ ) -> dict[str, str]:
534
+ """Build the current source-sharing form fields.
535
+
536
+ Args:
537
+ entities: Target entities receiving the source thumbnail.
538
+ source_entity: Entity whose thumbnail is shared.
539
+ filmstrip_thumbnail: Whether to share the filmstrip thumbnail.
540
+
541
+ Returns:
542
+ Exact endpoint fields without authentication values.
543
+
544
+ Raises:
545
+ ShotgunError: If an entity lacks a valid type or integer ID.
546
+ """
547
+ return {
548
+ 'entities': ','.join(_entity_reference(entity) for entity in entities),
549
+ 'filmstrip_thumbnail': '1' if filmstrip_thumbnail else 'False',
550
+ 'source_entity': _entity_reference(source_entity),
551
+ }
552
+
553
+
554
+ def build_upload_link_fields(
555
+ filename: str,
556
+ upload_type: Literal['Attachment', 'Thumbnail'],
557
+ multipart: bool,
558
+ created_at: datetime.datetime | None = None,
559
+ ) -> dict[str, str]:
560
+ """Build the observed allocation fields before authentication.
561
+
562
+ Args:
563
+ filename: Uploaded file basename.
564
+ upload_type: Attachment or thumbnail allocation type.
565
+ multipart: Whether the allocation is multipart.
566
+ created_at: Optional caller-supplied creation timestamp.
567
+
568
+ Returns:
569
+ Independent upload-allocation fields.
570
+ """
571
+ fields = {
572
+ 'filename': filename,
573
+ 'upload_type': upload_type,
574
+ 'multipart_upload': str(multipart).lower(),
575
+ }
576
+ if created_at is not None:
577
+ fields['created_at'] = created_at.isoformat()
578
+ return fields
579
+
580
+
581
+ def build_storage_upload_exchange(
582
+ user_agent: str,
583
+ operation: _protocol.OperationDescriptor,
584
+ url: str,
585
+ storage_range: StorageRange,
586
+ chunk_size: int = 65536,
587
+ ) -> UploadExchange:
588
+ """Plan one restartable streamed storage PUT.
589
+
590
+ Args:
591
+ user_agent: Captured user-agent header value.
592
+ operation: Replay and route classification.
593
+ url: Signed storage destination URL.
594
+ storage_range: File range to stream for every attempt.
595
+ chunk_size: Maximum file bytes read per iteration.
596
+
597
+ Returns:
598
+ Complete restartable storage exchange.
599
+ """
600
+ return UploadExchange(
601
+ body_factory=lambda: _iter_file(
602
+ storage_range.path,
603
+ chunk_size,
604
+ offset=storage_range.offset,
605
+ length=storage_range.length,
606
+ ),
607
+ headers={
608
+ 'content-length': str(storage_range.length),
609
+ 'content-type': 'application/octet-stream',
610
+ 'user-agent': user_agent,
611
+ },
612
+ method='PUT',
613
+ operation=operation,
614
+ url=url,
615
+ )
616
+
617
+
618
+ def canonical_origin(url: str) -> _Origin:
619
+ """Return the canonical HTTP origin for a URL.
620
+
621
+ Args:
622
+ url: Absolute HTTP or HTTPS URL without user information.
623
+
624
+ Returns:
625
+ Lowercase scheme, IDNA hostname, and effective port.
626
+
627
+ Raises:
628
+ ShotgunError: If the URL does not contain a safe HTTP origin.
629
+ """
630
+ try:
631
+ parsed = urllib.parse.urlsplit(url)
632
+ port = parsed.port
633
+ except (UnicodeError, ValueError) as exc:
634
+ raise ShotgunError('URL must contain a valid HTTP origin') from exc
635
+ scheme = parsed.scheme.lower()
636
+ if (
637
+ scheme not in {'http', 'https'}
638
+ or parsed.hostname is None
639
+ or parsed.username is not None
640
+ or parsed.password is not None
641
+ ):
642
+ raise ShotgunError('URL must contain a valid HTTP origin')
643
+ try:
644
+ hostname = parsed.hostname.encode('idna').decode('ascii').lower()
645
+ except UnicodeError as exc:
646
+ raise ShotgunError('URL must contain a valid HTTP origin') from exc
647
+ return scheme, hostname, port if port is not None else (443 if scheme == 'https' else 80)
648
+
649
+
650
+ def _build_urlencoded_upload_exchange(
651
+ canonical_origin: str,
652
+ user_agent: str,
653
+ operation: _protocol.OperationDescriptor,
654
+ route: str,
655
+ fields: Mapping[str, str],
656
+ ) -> UploadExchange:
657
+ """Plan one restartable URL-encoded upload-endpoint exchange."""
658
+ body = urllib.parse.urlencode(dict(fields)).encode()
659
+ return UploadExchange(
660
+ body_factory=lambda: body,
661
+ headers={
662
+ 'content-length': str(len(body)),
663
+ 'content-type': 'application/x-www-form-urlencoded',
664
+ 'user-agent': user_agent,
665
+ },
666
+ method='POST',
667
+ operation=operation,
668
+ url=f'{canonical_origin}{route}',
669
+ )
670
+
671
+
672
+ def build_download_redirect(
673
+ context: RequestContext,
674
+ response_headers: Mapping[str, str],
675
+ ) -> TransportRequest:
676
+ """Validate a redirect location and build a credential-free download hop.
677
+
678
+ Only the captured user-agent is forwarded to the redirect destination.
679
+ The `location` key is looked up in lowercase or in a case-insensitive
680
+ response header mapping.
681
+
682
+ Raises:
683
+ ShotgunFileDownloadError: If the response has no valid redirect
684
+ location.
685
+ """
686
+ location = response_headers.get('location')
687
+ if location is None:
688
+ raise ShotgunFileDownloadError('attachment download failed')
689
+ try:
690
+ canonical_origin(location)
691
+ except ShotgunError:
692
+ raise ShotgunFileDownloadError('attachment download failed') from None
693
+ return build_download_request(context, location, {'user-agent': context.user_agent})
694
+
695
+
696
+ def build_form_upload_exchange(
697
+ canonical_origin: str,
698
+ user_agent: str,
699
+ prepared: PreparedUpload,
700
+ auth: Mapping[str, object],
701
+ chunk_size: int = 65536,
702
+ ) -> UploadExchange:
703
+ """Plan one restartable legacy multipart form upload.
704
+
705
+ The body can be rebuilt, but the operation is not replay-safe after
706
+ transmission.
707
+
708
+ Args:
709
+ canonical_origin: Captured site origin.
710
+ user_agent: Captured user-agent header value.
711
+ prepared: Validated upload metadata and arguments.
712
+ auth: Captured authentication form fields.
713
+ chunk_size: Maximum file bytes read per iteration.
714
+
715
+ Returns:
716
+ Complete restartable form-upload exchange.
717
+ """
718
+ fields = add_auth_fields(_build_form_upload_fields(prepared), auth)
719
+ slot = _protocol.FileSlot(
720
+ name='file',
721
+ filename=prepared.filename,
722
+ content_type='application/octet-stream',
723
+ )
724
+ plan = _protocol.build_multipart_plan(fields, [slot])
725
+ path = prepared.options.arguments.path
726
+ return UploadExchange(
727
+ body_factory=lambda: _pump_multipart_from_disk(
728
+ plan,
729
+ {'file': path},
730
+ chunk_size,
731
+ ),
732
+ headers={
733
+ 'content-length': str(_multipart_content_length(plan, {'file': prepared.file_size})),
734
+ 'content-type': plan.content_type,
735
+ 'user-agent': user_agent,
736
+ },
737
+ method='POST',
738
+ operation=SHARE_THUMBNAIL_OPERATION,
739
+ url=f'{canonical_origin}{_UPLOAD_FILE}',
740
+ )
741
+
742
+
743
+ def build_upload_endpoint_exchange(
744
+ canonical_origin: str | None,
745
+ user_agent: str,
746
+ operation: _protocol.OperationDescriptor,
747
+ route: str,
748
+ fields: Mapping[str, str],
749
+ ) -> UploadExchange:
750
+ """Plan one configured URL-encoded upload-endpoint exchange.
751
+
752
+ Args:
753
+ canonical_origin: Captured site origin, or `None` when unconfigured.
754
+ user_agent: Captured user-agent header value.
755
+ operation: Replay and route classification.
756
+ route: Upload endpoint path.
757
+ fields: Complete request form fields.
758
+
759
+ Returns:
760
+ Complete restartable upload-endpoint exchange.
761
+
762
+ Raises:
763
+ ValueError: If server parameters are not configured.
764
+ """
765
+ if canonical_origin is None:
766
+ raise ValueError('server parameters must be configured before upload I/O')
767
+ return _build_urlencoded_upload_exchange(
768
+ canonical_origin,
769
+ user_agent,
770
+ operation,
771
+ route,
772
+ fields,
773
+ )
774
+
775
+
776
+ def choose_upload_path(
777
+ server_info: Mapping[str, object],
778
+ entity_type: str,
779
+ field_name: str | None,
780
+ file_size: int,
781
+ ) -> Literal['form', 'direct', 'multipart']:
782
+ """Choose the observed upload path for a file.
783
+
784
+ Args:
785
+ server_info: Immutable server capability data.
786
+ entity_type: Entity type receiving the file.
787
+ field_name: Target field, if the upload specifies one.
788
+ file_size: File size in bytes.
789
+
790
+ Returns:
791
+ `form`, `direct`, or `multipart` for the selected upload path.
792
+ """
793
+ if not _requires_direct_upload(server_info, entity_type, field_name):
794
+ return 'form'
795
+ if file_size <= _MULTIPART_CHUNK_SIZE:
796
+ return 'direct'
797
+ return 'multipart'
798
+
799
+
800
+ @contextmanager
801
+ def download_temporary(destination: Path) -> Generator[BinaryIO]:
802
+ """Yield an adjacent named binary temporary file without deleting it on close."""
803
+ with tempfile.NamedTemporaryFile(
804
+ dir=destination.parent,
805
+ prefix=f'.{destination.name}.',
806
+ suffix='.tmp',
807
+ delete=False,
808
+ ) as handle:
809
+ yield cast('BinaryIO', handle)
810
+
811
+
812
+ def multipart_ranges(prepared: PreparedUpload) -> tuple[StorageRange, ...]:
813
+ """Plan ordered storage ranges for one multipart upload.
814
+
815
+ Args:
816
+ prepared: Validated upload metadata and arguments.
817
+
818
+ Returns:
819
+ Ordered contiguous ranges covering the complete file.
820
+ """
821
+ path = prepared.options.arguments.path
822
+ return tuple(
823
+ StorageRange(
824
+ length=min(_MULTIPART_CHUNK_SIZE, prepared.file_size - offset),
825
+ offset=offset,
826
+ path=path,
827
+ )
828
+ for offset in range(0, prepared.file_size, _MULTIPART_CHUNK_SIZE)
829
+ )
830
+
831
+
832
+ def needs_session_cookie(url: str, origin: _Origin) -> bool:
833
+ """Return whether a URL exactly matches the captured session origin.
834
+
835
+ Args:
836
+ url: Candidate attachment URL.
837
+ origin: Captured configured origin.
838
+
839
+ Returns:
840
+ Whether scheme, normalized hostname, and effective port all match.
841
+ """
842
+ try:
843
+ candidate = canonical_origin(url)
844
+ except ShotgunError:
845
+ return False
846
+ return candidate == origin
847
+
848
+
849
+ def parse_link_completion(encoded: str) -> int:
850
+ """Parse an Attachment ID from the observed link success envelope.
851
+
852
+ Args:
853
+ encoded: Link-completion response text.
854
+
855
+ Returns:
856
+ Created Attachment ID.
857
+
858
+ Raises:
859
+ ShotgunError: If the response does not match the observed grammar.
860
+ """
861
+ marker, separator, payload = encoded.partition(':')
862
+ parts = payload.split()
863
+ if marker != '1' or separator != ':' or not parts:
864
+ raise ShotgunError('upload link returned an invalid response')
865
+ try:
866
+ return int(parts[0])
867
+ except ValueError:
868
+ raise ShotgunError('upload link returned an invalid response') from None
869
+
870
+
871
+ def parse_multipart_completion(encoded: str) -> int:
872
+ """Parse the observed integer-like multipart completion payload.
873
+
874
+ Args:
875
+ encoded: Multipart-completion response text.
876
+
877
+ Returns:
878
+ Parsed integer payload.
879
+
880
+ Raises:
881
+ ShotgunError: If the response is not integer-like.
882
+ """
883
+ try:
884
+ return int(encoded.strip())
885
+ except ValueError:
886
+ raise ShotgunError('multipart completion returned an invalid response') from None
887
+
888
+
889
+ def parse_part_etag(_text: str, headers: Mapping[str, str]) -> str:
890
+ """Return the required ETag from one multipart part response.
891
+
892
+ Args:
893
+ _text: Unused response body retained for upload-parser compatibility.
894
+ headers: Response headers from the storage PUT.
895
+
896
+ Returns:
897
+ Multipart part ETag.
898
+
899
+ Raises:
900
+ ShotgunError: If the response omits its ETag.
901
+ """
902
+ etag = headers.get('Etag')
903
+ if etag is None:
904
+ raise ShotgunError('multipart part response did not include an ETag')
905
+ return etag
906
+
907
+
908
+ def parse_part_link(encoded: str) -> str:
909
+ """Parse a signed URL from the observed part-link response.
910
+
911
+ Args:
912
+ encoded: Part-link response text.
913
+
914
+ Returns:
915
+ Signed storage URL.
916
+
917
+ Raises:
918
+ ShotgunError: If the response does not match the observed grammar.
919
+ """
920
+ parts = encoded.split()
921
+ try:
922
+ success = int(parts[0])
923
+ except (IndexError, ValueError):
924
+ success = 0
925
+ if len(parts) != _PART_LINK_FIELD_COUNT or success != 1:
926
+ raise ShotgunError('multipart part link returned an invalid response')
927
+ return parts[1]
928
+
929
+
930
+ def prepare_thumbnail_share(
931
+ entities: object,
932
+ thumbnail_path: str | None,
933
+ source_entity: object,
934
+ filmstrip_thumbnail: bool,
935
+ ) -> _ThumbnailShare:
936
+ """Validate and detach one thumbnail-sharing request.
937
+
938
+ Supply exactly one of `thumbnail_path` and `source_entity`.
939
+
940
+ Args:
941
+ entities: Nonempty list of target entity mappings with a nonempty
942
+ string `type` and an integer, non-boolean `id`.
943
+ thumbnail_path: Optional local image path.
944
+ source_entity: Optional source entity mapping with the same identity
945
+ requirements as a target.
946
+ filmstrip_thumbnail: Whether the filmstrip image is shared.
947
+
948
+ Returns:
949
+ Detached validated sharing inputs. For a local path, the first target
950
+ is selected as the source entity.
951
+
952
+ Raises:
953
+ ShotgunError: If source selection or entity values are invalid.
954
+ """
955
+ if (thumbnail_path is None) == (source_entity is None):
956
+ raise ShotgunError('exactly one of thumbnail_path and source_entity is required')
957
+ if not isinstance(entities, list) or not entities:
958
+ raise ShotgunError('entities must be a nonempty list of entity dictionaries')
959
+ owned_entities = copy.deepcopy(cast('list[object]', entities))
960
+ if any(not isinstance(entity, Mapping) for entity in owned_entities):
961
+ raise ShotgunError('entities must be a nonempty list of entity dictionaries')
962
+ typed_entities = cast('list[Mapping[str, object]]', owned_entities)
963
+ if source_entity is None:
964
+ owned_source = typed_entities[0]
965
+ else:
966
+ if not isinstance(source_entity, Mapping):
967
+ raise ShotgunError('source_entity must be an entity dictionary')
968
+ typed_source = cast('Mapping[str, object]', source_entity)
969
+ owned_source = copy.deepcopy(typed_source)
970
+ build_share_thumbnail_fields(
971
+ typed_entities,
972
+ owned_source,
973
+ filmstrip_thumbnail,
974
+ )
975
+ return _ThumbnailShare(
976
+ entities=typed_entities,
977
+ filmstrip_thumbnail=filmstrip_thumbnail,
978
+ source_entity=owned_source,
979
+ thumbnail_path=thumbnail_path,
980
+ source_entity_type=cast('str', owned_source['type']),
981
+ source_entity_id=cast('int', owned_source['id']),
982
+ )
983
+
984
+
985
+ def prepare_upload(options: UploadOptions) -> PreparedUpload:
986
+ """Validate an upload path and capture its file metadata.
987
+
988
+ Args:
989
+ options: Bound upload and thumbnail-routing options.
990
+
991
+ Returns:
992
+ Captured filename, size, and options for one upload.
993
+
994
+ Raises:
995
+ ShotgunError: If the path does not name a nonempty file or `created_at`
996
+ is not a datetime.
997
+ """
998
+ created_at = cast('object', options.arguments.created_at)
999
+ if created_at is not None and not isinstance(created_at, datetime.datetime):
1000
+ raise ShotgunError('created_at must be a datetime.datetime instance')
1001
+ raw_path = options.arguments.path
1002
+ if isinstance(raw_path, bytes):
1003
+ options = dataclasses.replace(
1004
+ options,
1005
+ arguments=dataclasses.replace(options.arguments, path=os.fsdecode(raw_path)),
1006
+ )
1007
+ path = Path(options.arguments.path)
1008
+ try:
1009
+ file_size = path.stat().st_size if path.is_file() else 0
1010
+ except OSError:
1011
+ file_size = 0
1012
+ if file_size <= 0:
1013
+ raise ShotgunError('File must exist and be non-empty')
1014
+ return PreparedUpload(filename=path.name, file_size=file_size, options=options)
1015
+
1016
+
1017
+ def resolve_attachment_url(attachment: object, base_url: str) -> str | None:
1018
+ """Resolve a supported attachment reference to its download URL.
1019
+
1020
+ Args:
1021
+ attachment: Attachment ID, Attachment dictionary, URL dictionary, or
1022
+ `None`.
1023
+ base_url: Configured site base URL.
1024
+
1025
+ Returns:
1026
+ The supplied URL, a site attachment URL, or `None`.
1027
+
1028
+ Raises:
1029
+ TypeError: If `attachment` is a string.
1030
+ ValueError: If an entity dictionary references a different type.
1031
+ ShotgunError: If another `attachment` value is unsupported.
1032
+ """
1033
+ if attachment is None:
1034
+ return None
1035
+ if isinstance(attachment, str):
1036
+ raise TypeError('attachment must be an id or dictionary')
1037
+ if isinstance(attachment, Mapping):
1038
+ mapping = cast('Mapping[object, object]', attachment)
1039
+ url = mapping.get('url')
1040
+ if isinstance(url, str):
1041
+ return url
1042
+ attachment_id = mapping.get('id')
1043
+ if type(attachment_id) is int:
1044
+ if mapping.get('type') != 'Attachment':
1045
+ raise ValueError('attachment dictionary must reference an Attachment')
1046
+ return f'{base_url.rstrip("/")}{_FILE_SERVE_ATTACHMENT}{attachment_id}'
1047
+ elif type(attachment) is int:
1048
+ return f'{base_url.rstrip("/")}{_FILE_SERVE_ATTACHMENT}{attachment}'
1049
+ raise ShotgunError('attachment must be an id or dictionary')
1050
+
1051
+
1052
+ def thumbnail_source_ready(
1053
+ result: object,
1054
+ field_name: Literal['filmstrip_image', 'image'],
1055
+ ) -> bool:
1056
+ """Validate and classify one thumbnail-readiness result.
1057
+
1058
+ Args:
1059
+ result: Transformed `read` RPC result.
1060
+ field_name: Thumbnail field selected for readiness polling.
1061
+
1062
+ Returns:
1063
+ Whether the first thumbnail is present and no longer transient.
1064
+
1065
+ Raises:
1066
+ ShotgunError: If the result has an invalid response shape.
1067
+ """
1068
+ if not isinstance(result, dict):
1069
+ raise ShotgunError('thumbnail readiness read did not return a dictionary')
1070
+ raw_entities = cast('dict[str, object]', result).get('entities')
1071
+ if not isinstance(raw_entities, list):
1072
+ raise ShotgunError('thumbnail readiness read did not return an entities list')
1073
+ if not raw_entities:
1074
+ return False
1075
+ first = cast('list[object]', raw_entities)[0]
1076
+ if not isinstance(first, dict):
1077
+ raise ShotgunError('thumbnail readiness read returned a non-dictionary entity')
1078
+ value = cast('dict[str, object]', first).get(field_name)
1079
+ return isinstance(value, str) and '/images/status/transient/' not in value
1080
+
1081
+
1082
+ def validate_download_url(url: str) -> None:
1083
+ """Reject unusable attachment URLs before download I/O.
1084
+
1085
+ Args:
1086
+ url: Resolved attachment URL to validate.
1087
+
1088
+ Raises:
1089
+ ShotgunFileDownloadError: If the URL has no safe HTTP origin.
1090
+ """
1091
+ try:
1092
+ canonical_origin(url)
1093
+ except ShotgunError:
1094
+ raise ShotgunFileDownloadError('attachment download failed') from None