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.
Files changed (56) hide show
  1. {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
  2. sg_python_api-0.2.0.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 +1031 -0
  26. shotgun_api3/lib/mockgun/_follows.py +217 -0
  27. shotgun_api3/lib/mockgun/_handlers.py +558 -0
  28. shotgun_api3/lib/mockgun/_links.py +440 -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 +2163 -0
  32. shotgun_api3/lib/mockgun/_retirement.py +368 -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 +1446 -0
  42. shotgun_api3/lib/mockgun/_summaries.py +964 -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.0.dist-info}/WHEEL +0 -0
  56. {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)