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,206 @@
|
|
|
1
|
+
"""Resolve fake-site credentials and preserve their actors across token exchanges."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Literal, cast
|
|
7
|
+
|
|
8
|
+
from ._errors import MockgunError, ServerFaultError
|
|
9
|
+
from ._schema import SiteSchema
|
|
10
|
+
from ._store import Store
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class _Actor:
|
|
15
|
+
"""An authenticated identity scoped to one fake site."""
|
|
16
|
+
|
|
17
|
+
entity_type: Literal['ApiUser', 'HumanUser']
|
|
18
|
+
entity_id: int
|
|
19
|
+
key: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class RequestContext:
|
|
24
|
+
"""The actor and timestamp shared by one server request.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
actor: Resolved identity, or `None` during bootstrap or anonymous
|
|
28
|
+
calls.
|
|
29
|
+
now: Time captured once when the request entered the server.
|
|
30
|
+
session_uuid: Optional caller-supplied event session identifier.
|
|
31
|
+
sudo: Whether script credentials selected a human actor.
|
|
32
|
+
actual: Script identity behind sudo, otherwise `None`.
|
|
33
|
+
session_kind: Sudo, script, or human classification; anonymous calls
|
|
34
|
+
use the human classification.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
actor: _Actor | None
|
|
38
|
+
now: datetime
|
|
39
|
+
session_uuid: str | None = None
|
|
40
|
+
sudo: bool = False
|
|
41
|
+
actual: _Actor | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def session_kind(self) -> Literal['script', 'sudo', 'human']:
|
|
45
|
+
"""Classify sudo first, then script actors, with anonymous calls treated as human."""
|
|
46
|
+
if self.sudo:
|
|
47
|
+
return 'sudo'
|
|
48
|
+
if self.actor is not None and self.actor.entity_type == 'ApiUser':
|
|
49
|
+
return 'script'
|
|
50
|
+
return 'human'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Sessions:
|
|
54
|
+
"""Site-owned credentials and tokens resolved under the server's request lock."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, store: Store, schema: SiteSchema) -> None:
|
|
57
|
+
"""Bind identity resolution to a site's records and schema declarations."""
|
|
58
|
+
self._store = store
|
|
59
|
+
self._schema = schema
|
|
60
|
+
self._admitted_logins: set[str] = set()
|
|
61
|
+
self._created_humans: dict[str, _Actor] = {}
|
|
62
|
+
self._created_scripts: dict[str, _Actor] = {}
|
|
63
|
+
self._internal: dict[Literal['ApiUser', 'HumanUser'], dict[str, _Actor]] = {
|
|
64
|
+
'ApiUser': {},
|
|
65
|
+
'HumanUser': {},
|
|
66
|
+
}
|
|
67
|
+
self._tokens: dict[str, _Actor] = {}
|
|
68
|
+
self._token_counter = 0
|
|
69
|
+
self._unknown_token_counter = 0
|
|
70
|
+
|
|
71
|
+
def _existing_human(self, login: str) -> _Actor | None:
|
|
72
|
+
"""Match an existing HumanUser by login, including retired records."""
|
|
73
|
+
if not self._schema.declares('HumanUser'):
|
|
74
|
+
return self._internal['HumanUser'].get(login)
|
|
75
|
+
if not self._schema.declares('HumanUser', 'login'):
|
|
76
|
+
return self._created_humans.get(login)
|
|
77
|
+
for retired in (False, True):
|
|
78
|
+
for row in self._store.rows('HumanUser', retired=retired):
|
|
79
|
+
if row.get('login') == login:
|
|
80
|
+
return _Actor('HumanUser', cast('int', row['id']), login)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def _internal_actor(self, entity_type: Literal['ApiUser', 'HumanUser'], key: str) -> _Actor:
|
|
84
|
+
"""Return or allocate an identity without adding a schema record."""
|
|
85
|
+
identities = self._internal[entity_type]
|
|
86
|
+
if key not in identities:
|
|
87
|
+
identities[key] = _Actor(entity_type, len(identities) + 1, key)
|
|
88
|
+
return identities[key]
|
|
89
|
+
|
|
90
|
+
def _human(self, login: str, *, now: datetime) -> _Actor:
|
|
91
|
+
"""Find or create a HumanUser using only declared optional fields."""
|
|
92
|
+
existing = self._existing_human(login)
|
|
93
|
+
if existing is not None:
|
|
94
|
+
return existing
|
|
95
|
+
if not self._schema.declares('HumanUser'):
|
|
96
|
+
return self._internal_actor('HumanUser', login)
|
|
97
|
+
fields: dict[str, object] = {}
|
|
98
|
+
fields.update(
|
|
99
|
+
{
|
|
100
|
+
name: value
|
|
101
|
+
for name, value in {
|
|
102
|
+
'login': login,
|
|
103
|
+
'name': login,
|
|
104
|
+
'sg_status_list': 'act',
|
|
105
|
+
}.items()
|
|
106
|
+
if self._schema.declares('HumanUser', name)
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
row = self._store.create('HumanUser', fields, context=RequestContext(None, now))
|
|
110
|
+
actor = _Actor('HumanUser', cast('int', row['id']), login)
|
|
111
|
+
self._store.record_actor(actor.entity_type, actor.entity_id, actor.key)
|
|
112
|
+
if not self._schema.declares('HumanUser', 'login'):
|
|
113
|
+
self._created_humans[login] = actor
|
|
114
|
+
return actor
|
|
115
|
+
|
|
116
|
+
def _from_token(self, token: str, *, now: datetime) -> _Actor:
|
|
117
|
+
"""Reuse a known token actor or create its stable placeholder human."""
|
|
118
|
+
if token not in self._tokens:
|
|
119
|
+
self._unknown_token_counter += 1
|
|
120
|
+
self._tokens[token] = self._human(
|
|
121
|
+
f'mockgun-session-user-{self._unknown_token_counter}', now=now
|
|
122
|
+
)
|
|
123
|
+
return self._tokens[token]
|
|
124
|
+
|
|
125
|
+
def _script(self, script: str, *, now: datetime) -> _Actor:
|
|
126
|
+
"""Find or create an ApiUser for a script when the schema declares it."""
|
|
127
|
+
if not self._schema.declares('ApiUser'):
|
|
128
|
+
return self._internal_actor('ApiUser', script)
|
|
129
|
+
if self._schema.declares('ApiUser', 'firstname'):
|
|
130
|
+
for retired in (False, True):
|
|
131
|
+
for row in self._store.rows('ApiUser', retired=retired):
|
|
132
|
+
if row.get('firstname') == script:
|
|
133
|
+
return _Actor('ApiUser', cast('int', row['id']), script)
|
|
134
|
+
else:
|
|
135
|
+
existing = self._created_scripts.get(script)
|
|
136
|
+
if existing is not None:
|
|
137
|
+
return existing
|
|
138
|
+
fields: dict[str, object] = {}
|
|
139
|
+
if self._schema.declares('ApiUser', 'firstname'):
|
|
140
|
+
fields['firstname'] = script
|
|
141
|
+
row = self._store.create('ApiUser', fields, context=RequestContext(None, now))
|
|
142
|
+
actor = _Actor('ApiUser', cast('int', row['id']), script)
|
|
143
|
+
self._store.record_actor(actor.entity_type, actor.entity_id, actor.key)
|
|
144
|
+
if not self._schema.declares('ApiUser', 'firstname'):
|
|
145
|
+
self._created_scripts[script] = actor
|
|
146
|
+
return actor
|
|
147
|
+
|
|
148
|
+
def admit_login(self, login: str) -> None:
|
|
149
|
+
"""Allow constructor credentials to create this login on first use."""
|
|
150
|
+
self._admitted_logins.add(login)
|
|
151
|
+
|
|
152
|
+
def issue_token(self, actor: _Actor) -> str:
|
|
153
|
+
"""Issue a site-local token that retains the supplied actor's identity."""
|
|
154
|
+
while True:
|
|
155
|
+
self._token_counter += 1
|
|
156
|
+
token = f'mockgun-session-{self._token_counter}'
|
|
157
|
+
if token not in self._tokens:
|
|
158
|
+
self._tokens[token] = actor
|
|
159
|
+
return token
|
|
160
|
+
|
|
161
|
+
def resolve(self, auth: Mapping[str, object], *, now: datetime) -> _Actor:
|
|
162
|
+
"""Resolve complete credentials, accepting arbitrary password and key values.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
ServerFaultError: If credentials are incomplete, the login is
|
|
166
|
+
unknown, or sudo accompanies a session token.
|
|
167
|
+
MockgunError: If sudo accompanies human credentials.
|
|
168
|
+
"""
|
|
169
|
+
script = auth.get('script_name')
|
|
170
|
+
sudo = auth.get('sudo_as_login')
|
|
171
|
+
if isinstance(script, str) and isinstance(auth.get('script_key'), str):
|
|
172
|
+
if isinstance(sudo, str):
|
|
173
|
+
return self._human(sudo, now=now)
|
|
174
|
+
return self._script(script, now=now)
|
|
175
|
+
if sudo is not None and isinstance(auth.get('session_token'), str):
|
|
176
|
+
raise ServerFaultError(
|
|
177
|
+
'The sudo_as_login option can only be used with script authentication.',
|
|
178
|
+
error_code=102,
|
|
179
|
+
)
|
|
180
|
+
if sudo is not None:
|
|
181
|
+
raise MockgunError('mockgun does not emulate sudo with human credentials yet')
|
|
182
|
+
login = auth.get('user_login')
|
|
183
|
+
if isinstance(login, str) and isinstance(auth.get('user_password'), str):
|
|
184
|
+
if login in self._admitted_logins or self._existing_human(login) is not None:
|
|
185
|
+
return self._human(login, now=now)
|
|
186
|
+
raise ServerFaultError('Authentication failed.', error_code=102)
|
|
187
|
+
token = auth.get('session_token')
|
|
188
|
+
if isinstance(token, str):
|
|
189
|
+
return self._from_token(token, now=now)
|
|
190
|
+
raise ServerFaultError('Authentication failed.', error_code=102)
|
|
191
|
+
|
|
192
|
+
def script_actor(self, auth: Mapping[str, object], *, now: datetime) -> _Actor | None:
|
|
193
|
+
"""Resolve the script behind complete script credentials, if present.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
auth: Request credentials in their wire representation.
|
|
197
|
+
now: Request instant used when creating the script's ApiUser.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
The existing or newly created script actor, or `None` for other
|
|
201
|
+
credential forms.
|
|
202
|
+
"""
|
|
203
|
+
script = auth.get('script_name')
|
|
204
|
+
if isinstance(script, str) and isinstance(auth.get('script_key'), str):
|
|
205
|
+
return self._script(script, now=now)
|
|
206
|
+
return None
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Expose test controls for the in-memory site behind a mockgun client."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
from ._errors import ServerFaultError
|
|
8
|
+
from ._server import FakeServer
|
|
9
|
+
from ._site_data import json_copy
|
|
10
|
+
|
|
11
|
+
_DAYS_PER_WEEK = 7
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Site:
|
|
15
|
+
"""The in-memory site behind one mockgun client.
|
|
16
|
+
|
|
17
|
+
The site holds the records, schema, sessions, clock and server metadata
|
|
18
|
+
that the client reaches through its in-memory transport. Each directly
|
|
19
|
+
constructed client has its own site.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, server: FakeServer) -> None:
|
|
23
|
+
"""Bind a facade to its server; tests obtain it from `client.site`."""
|
|
24
|
+
self._server = server
|
|
25
|
+
|
|
26
|
+
def now(self) -> datetime:
|
|
27
|
+
"""Return the site's current time as an aware UTC datetime in whole seconds."""
|
|
28
|
+
return self._server.now()
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def server_metadata(self) -> dict[str, object]:
|
|
32
|
+
"""A copy of the metadata the site returns from `info`."""
|
|
33
|
+
return self._server.server_metadata()
|
|
34
|
+
|
|
35
|
+
def set_canned_fault(
|
|
36
|
+
self, method: str, params: Mapping[str, object], message: str, code: int | None = None
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Configure a fault for exactly matching navigation or export parameters.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
method: `nav_expand`, `nav_search_string`, `nav_search_entity` or
|
|
42
|
+
`export_page`.
|
|
43
|
+
params: Exact wire parameters, detached through JSON; key order is
|
|
44
|
+
ignored.
|
|
45
|
+
message: Fault message returned by the server.
|
|
46
|
+
code: Optional wire error code. A known code selects the matching
|
|
47
|
+
`Fault` subclass (for example, 102 selects
|
|
48
|
+
`AuthenticationFault`); the code is not otherwise exposed.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If the method is unsupported.
|
|
52
|
+
TypeError: If parameters, message or code have invalid types.
|
|
53
|
+
"""
|
|
54
|
+
with self._server.site_data_update() as data:
|
|
55
|
+
if type(message) is not str or (code is not None and type(code) is not int):
|
|
56
|
+
raise TypeError('canned fault needs a string message and integer or null code')
|
|
57
|
+
data.set_canned(method, params, None, ServerFaultError(message, error_code=code))
|
|
58
|
+
|
|
59
|
+
def set_canned_response(
|
|
60
|
+
self, method: str, params: Mapping[str, object], response: object
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Configure an exact response for navigation or page export.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
method: `nav_expand`, `nav_search_string`, `nav_search_entity` or
|
|
66
|
+
`export_page`.
|
|
67
|
+
params: Exact wire parameters, compared after JSON decoding without
|
|
68
|
+
key order.
|
|
69
|
+
response: JSON response value, detached from the caller.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If the method is unsupported.
|
|
73
|
+
TypeError: If parameters or response cannot be represented as JSON.
|
|
74
|
+
"""
|
|
75
|
+
with self._server.site_data_update() as data:
|
|
76
|
+
data.set_canned(method, params, response)
|
|
77
|
+
|
|
78
|
+
def set_preferences(self, values: Mapping[str, object]) -> None:
|
|
79
|
+
"""Merge explicitly known preferences into the site settings.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
values: String preference names and JSON-serializable values,
|
|
83
|
+
deep-copied.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
TypeError: If a key is not a string or a value is not
|
|
87
|
+
JSON-serializable.
|
|
88
|
+
"""
|
|
89
|
+
with self._server.site_data_update() as data:
|
|
90
|
+
if any(type(key) is not str for key in values):
|
|
91
|
+
raise TypeError('preference keys must be strings')
|
|
92
|
+
data.preferences.update(cast('dict[str, object]', json_copy(dict(values))))
|
|
93
|
+
|
|
94
|
+
def set_subscriptions(self, values: Mapping[int, str]) -> None:
|
|
95
|
+
"""Replace configured HumanUser subscriptions in the supplied order.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
values: Positive integer HumanUser IDs (excluding booleans) and
|
|
99
|
+
string values.
|
|
100
|
+
Record existence is checked when subscriptions are read.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
TypeError: If an ID or subscription has an unsupported type or
|
|
104
|
+
value.
|
|
105
|
+
"""
|
|
106
|
+
with self._server.site_data_update() as data:
|
|
107
|
+
if any(
|
|
108
|
+
type(key) is not int or key <= 0 or type(value) is not str
|
|
109
|
+
for key, value in values.items()
|
|
110
|
+
):
|
|
111
|
+
raise TypeError('subscriptions require positive integer IDs and string values')
|
|
112
|
+
data.subscriptions = {
|
|
113
|
+
int(key): value
|
|
114
|
+
for key, value in cast('dict[str, str]', json_copy(dict(values))).items()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
def set_time(self, when: datetime | None) -> None:
|
|
118
|
+
"""Freeze the clock at `when`, or follow real UTC time again with `None`.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
when: A timezone-aware instant to freeze, or `None` to resume the
|
|
122
|
+
real UTC clock.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: If `when` is naive.
|
|
126
|
+
"""
|
|
127
|
+
self._server.set_time(when)
|
|
128
|
+
|
|
129
|
+
def set_work_week(self, working: Sequence[bool]) -> None:
|
|
130
|
+
"""Replace the default studio working week from Monday to Sunday.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
working: Exactly seven booleans, deep-copied into the site.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ValueError: If the input does not contain exactly seven booleans.
|
|
137
|
+
"""
|
|
138
|
+
with self._server.site_data_update() as data:
|
|
139
|
+
if len(working) != _DAYS_PER_WEEK or any(type(value) is not bool for value in working):
|
|
140
|
+
raise ValueError('work week requires seven booleans from Monday to Sunday')
|
|
141
|
+
data.work_week = cast('list[bool]', json_copy(list(working)))
|
|
142
|
+
|
|
143
|
+
def update_server_metadata(self, values: Mapping[str, object]) -> None:
|
|
144
|
+
"""Merge values into the metadata the site returns from `info`.
|
|
145
|
+
|
|
146
|
+
Clients cache metadata at readiness, as they do with a real server. A
|
|
147
|
+
change reaches a client at its next readiness: its first request if it
|
|
148
|
+
is not yet ready, or an explicit `info()` if it is already ready.
|
|
149
|
+
"""
|
|
150
|
+
self._server.update_server_metadata(values)
|