sg-python-api 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
- sg_python_api-0.2.0.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 +1031 -0
- shotgun_api3/lib/mockgun/_follows.py +217 -0
- shotgun_api3/lib/mockgun/_handlers.py +558 -0
- shotgun_api3/lib/mockgun/_links.py +440 -0
- shotgun_api3/lib/mockgun/_new_client.py +92 -0
- shotgun_api3/lib/mockgun/_presets.py +492 -0
- shotgun_api3/lib/mockgun/_query.py +2163 -0
- shotgun_api3/lib/mockgun/_retirement.py +368 -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 +1446 -0
- shotgun_api3/lib/mockgun/_summaries.py +964 -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.0.dist-info}/WHEEL +0 -0
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Retain issued upload allocations and bytes independently of record transactions."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from ._errors import MockgunError
|
|
10
|
+
from ._wire import format_wire_datetime
|
|
11
|
+
|
|
12
|
+
STORAGE_HOST = 'mockgun-storage.invalid'
|
|
13
|
+
_LETTERS = str.maketrans('0123456789abcdef', 'abcdefghijklmnop')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Allocation:
|
|
18
|
+
"""One issued allocation whose encoded response is the link-step credential.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
upload_id: Site-local upload identity, independent of record IDs.
|
|
22
|
+
filename: Client-supplied basename.
|
|
23
|
+
upload_type: Attachment or thumbnail allocation kind.
|
|
24
|
+
multipart: Whether the allocation requires part completion.
|
|
25
|
+
key: Opaque storage path component.
|
|
26
|
+
encoded: Exact allocation answer sent back by the link step.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
upload_id: str
|
|
30
|
+
filename: str
|
|
31
|
+
upload_type: str
|
|
32
|
+
multipart: bool
|
|
33
|
+
key: str
|
|
34
|
+
encoded: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Storage:
|
|
38
|
+
"""An in-memory byte store whose uploads survive failed record transactions.
|
|
39
|
+
|
|
40
|
+
Only issued URLs accept writes. Attachment bindings identify completed
|
|
41
|
+
files; reading a URL ignores its query token.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
"""Create empty allocations, bytes, parts and Attachment bindings."""
|
|
46
|
+
self._allocations: dict[str, Allocation] = {}
|
|
47
|
+
self._encoded: dict[str, Allocation] = {}
|
|
48
|
+
self._blobs: dict[str, bytes] = {}
|
|
49
|
+
self._parts: dict[str, tuple[str, int]] = {}
|
|
50
|
+
self._etags: dict[str, str] = {}
|
|
51
|
+
self._etag_serial = 0
|
|
52
|
+
self._bindings: dict[int, str] = {}
|
|
53
|
+
|
|
54
|
+
def allocate(
|
|
55
|
+
self, filename: str, upload_type: str, *, multipart: bool, now: datetime
|
|
56
|
+
) -> Allocation:
|
|
57
|
+
"""Issue an upload URL and retain its exact encoded answer.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
filename: Basename supplied by the client.
|
|
61
|
+
upload_type: Allocation kind sent by the client.
|
|
62
|
+
multipart: Whether parts must be completed before linking.
|
|
63
|
+
now: Aware request instant used for the opaque timestamp.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The retained allocation, including its upload URL and exact encoded
|
|
67
|
+
link-step credential.
|
|
68
|
+
"""
|
|
69
|
+
key = _letters()
|
|
70
|
+
upload_id = f'mockgun-upload-{len(self._allocations) + 1}'
|
|
71
|
+
fields = [
|
|
72
|
+
'1',
|
|
73
|
+
f'https://{STORAGE_HOST}/{key}',
|
|
74
|
+
format_wire_datetime(now),
|
|
75
|
+
upload_type,
|
|
76
|
+
upload_id,
|
|
77
|
+
]
|
|
78
|
+
if multipart:
|
|
79
|
+
fields.append('mockgun-multipart')
|
|
80
|
+
allocation = Allocation(
|
|
81
|
+
upload_id, filename, upload_type, multipart, key, '\n'.join(fields)
|
|
82
|
+
)
|
|
83
|
+
self._allocations[upload_id] = allocation
|
|
84
|
+
self._encoded[allocation.encoded] = allocation
|
|
85
|
+
return allocation
|
|
86
|
+
|
|
87
|
+
def allocation(self, encoded: str) -> Allocation:
|
|
88
|
+
"""Resolve the exact answer returned by allocation.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
MockgunError: If the link form names an unissued allocation.
|
|
92
|
+
"""
|
|
93
|
+
allocation = self._encoded.get(encoded)
|
|
94
|
+
if allocation is None:
|
|
95
|
+
raise MockgunError(
|
|
96
|
+
'mockgun POST /upload/api_link_file upload_link_info: allocation was not issued'
|
|
97
|
+
)
|
|
98
|
+
return allocation
|
|
99
|
+
|
|
100
|
+
def bind(self, attachment_id: int, key: str) -> None:
|
|
101
|
+
"""Associate an Attachment identity with its uploaded storage key."""
|
|
102
|
+
self._bindings[attachment_id] = key
|
|
103
|
+
|
|
104
|
+
def bound_key(self, attachment_id: int) -> str | None:
|
|
105
|
+
"""Return the Attachment's storage key, or `None` when unbound."""
|
|
106
|
+
return self._bindings.get(attachment_id)
|
|
107
|
+
|
|
108
|
+
def complete(self, upload_id: str, etags: Sequence[str]) -> int:
|
|
109
|
+
"""Join contiguous uploaded parts after checking their ordered ETags.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
upload_id: Identity issued by a multipart allocation.
|
|
113
|
+
etags: Quoted ETags in one-based part order.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The number of completed parts.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
MockgunError: If the allocation, parts or ETags cannot be
|
|
120
|
+
validated.
|
|
121
|
+
"""
|
|
122
|
+
allocation = self._allocations.get(upload_id)
|
|
123
|
+
parts = sorted(
|
|
124
|
+
(number, path)
|
|
125
|
+
for path, (identity, number) in self._parts.items()
|
|
126
|
+
if identity == upload_id
|
|
127
|
+
)
|
|
128
|
+
if (
|
|
129
|
+
allocation is None
|
|
130
|
+
or not allocation.multipart
|
|
131
|
+
or not parts
|
|
132
|
+
or [number for number, _ in parts] != list(range(1, len(parts) + 1))
|
|
133
|
+
or [self._etags.get(path) for _, path in parts] != list(etags)
|
|
134
|
+
or any(path not in self._blobs for _, path in parts)
|
|
135
|
+
):
|
|
136
|
+
raise MockgunError(
|
|
137
|
+
f'mockgun POST /upload/api_complete_multipart_upload upload_id {upload_id}: '
|
|
138
|
+
'parts or ETags are unsettled'
|
|
139
|
+
)
|
|
140
|
+
self._blobs['/' + allocation.key] = b''.join(self._blobs[path] for _, path in parts)
|
|
141
|
+
return len(parts)
|
|
142
|
+
|
|
143
|
+
def has_bytes(self, key: str) -> bool:
|
|
144
|
+
"""Return whether a completed file exists under the allocation key."""
|
|
145
|
+
return '/' + key in self._blobs
|
|
146
|
+
|
|
147
|
+
def image_url(self, key: str) -> str:
|
|
148
|
+
"""Return the key's URL with a fresh opaque query token."""
|
|
149
|
+
return f'https://{STORAGE_HOST}/{key}?{_letters()[:16]}'
|
|
150
|
+
|
|
151
|
+
def part_url(self, upload_id: str, part_number: int) -> str:
|
|
152
|
+
"""Issue a storage URL for a positive, one-based multipart part.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
MockgunError: If the upload is not multipart or the part is
|
|
156
|
+
invalid.
|
|
157
|
+
"""
|
|
158
|
+
allocation = self._allocations.get(upload_id)
|
|
159
|
+
if allocation is None or not allocation.multipart or part_number < 1:
|
|
160
|
+
raise MockgunError(
|
|
161
|
+
f'mockgun POST /upload/api_get_upload_link_for_part upload_id {upload_id}: '
|
|
162
|
+
f'part_number {part_number} was not issued'
|
|
163
|
+
)
|
|
164
|
+
for path, identity in self._parts.items():
|
|
165
|
+
if identity == (upload_id, part_number):
|
|
166
|
+
return f'https://{STORAGE_HOST}{path}'
|
|
167
|
+
path = f'/{allocation.key}/part-{_letters()}'
|
|
168
|
+
self._parts[path] = (upload_id, part_number)
|
|
169
|
+
return f'https://{STORAGE_HOST}{path}'
|
|
170
|
+
|
|
171
|
+
def put(self, url: str, data: bytes) -> str | None:
|
|
172
|
+
"""Store bytes at an issued URL, returning a quoted ETag for a part.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
MockgunError: If the URL was not issued for storage writes.
|
|
176
|
+
"""
|
|
177
|
+
parsed = urlsplit(url)
|
|
178
|
+
path = parsed.path
|
|
179
|
+
direct = any(
|
|
180
|
+
'/' + item.key == path and not item.multipart for item in self._allocations.values()
|
|
181
|
+
)
|
|
182
|
+
if parsed.hostname != STORAGE_HOST or (path not in self._parts and not direct):
|
|
183
|
+
raise MockgunError(f'mockgun does not emulate PUT {path}: storage URL was not issued')
|
|
184
|
+
self._blobs[path] = data
|
|
185
|
+
if path not in self._parts:
|
|
186
|
+
return None
|
|
187
|
+
self._etag_serial += 1
|
|
188
|
+
etag = f'"mockgun-etag-{self._etag_serial}"'
|
|
189
|
+
self._etags[path] = etag
|
|
190
|
+
return etag
|
|
191
|
+
|
|
192
|
+
def read(self, url: str) -> bytes:
|
|
193
|
+
"""Return uploaded bytes, ignoring a storage URL's query token.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
MockgunError: If the URL has no stored bytes.
|
|
197
|
+
"""
|
|
198
|
+
parsed = urlsplit(url)
|
|
199
|
+
if parsed.hostname != STORAGE_HOST or parsed.path not in self._blobs:
|
|
200
|
+
raise MockgunError(
|
|
201
|
+
f'mockgun does not emulate GET {parsed.path}: storage bytes are unknown'
|
|
202
|
+
)
|
|
203
|
+
return self._blobs[parsed.path]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _letters() -> str:
|
|
207
|
+
"""Return an opaque path component containing no decimal digits."""
|
|
208
|
+
return uuid4().hex.translate(_LETTERS)
|