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,584 @@
1
+ """Emulate explicitly configured preferences, schedules, subscriptions and navigation."""
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass
7
+ from datetime import date, timedelta
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ from ._activity import taint_activity
11
+ from ._errors import MockgunError, ServerFaultError
12
+ from ._links import display_name
13
+ from ._sessions import RequestContext
14
+ from ._store import Row, Store
15
+
16
+ if TYPE_CHECKING:
17
+ from ._server import FakeServer
18
+
19
+ _RECORDED_PREFERENCES = (
20
+ 'format_date_fields',
21
+ 'date_component_order',
22
+ 'format_time_hour_fields',
23
+ 'format_currency_fields_display_dollar_sign',
24
+ 'format_currency_fields_decimal_options',
25
+ 'format_currency_fields_negative_options',
26
+ 'format_number_fields',
27
+ 'format_float_fields',
28
+ 'format_float_fields_rounding',
29
+ 'format_footage_fields',
30
+ 'support_local_storage',
31
+ 'view_master_settings',
32
+ 'duration_units',
33
+ 'hours_per_day',
34
+ 'enable_rv_integration',
35
+ 'enable_shotgun_review_for_rv',
36
+ 'creative_review_settings',
37
+ )
38
+
39
+ _CANNED_METHODS = {'nav_expand', 'nav_search_string', 'nav_search_entity', 'export_page'}
40
+ _RULE_FIELDS = ('start_date', 'end_date', 'working', 'description', 'project', 'user')
41
+
42
+
43
+ class SiteData:
44
+ """Explicit site settings, independent of record transaction rollback.
45
+
46
+ Attributes:
47
+ preferences: Configured values; absent recorded preferences remain
48
+ unknown.
49
+ subscriptions: HumanUser IDs and string subscriptions in insertion
50
+ order.
51
+ work_week: Seven working flags from Monday to Sunday.
52
+ """
53
+
54
+ def __init__(self) -> None:
55
+ """Start with no configured values and the observed weekday work week."""
56
+ self.preferences: dict[str, object] = {}
57
+ self.subscriptions: dict[int, str] = {}
58
+ self.work_week = [True] * 5 + [False] * 2
59
+ self._canned: list[_Canned] = []
60
+
61
+ def canned(self, method: str, params: dict[str, object]) -> object:
62
+ """Return a detached configured answer for exactly matching RPC parameters.
63
+
64
+ Args:
65
+ method: Wire RPC name, including the shared `nav_search` name.
66
+ params: Validated wire parameters.
67
+
68
+ Raises:
69
+ MockgunError: If no configured entry matches.
70
+ ServerFaultError: If the matching entry specifies a fault.
71
+ """
72
+ for entry in self._canned:
73
+ if entry.method == method and entry.params == params:
74
+ if entry.fault is not None:
75
+ raise entry.fault
76
+ return deepcopy(entry.response)
77
+ raise MockgunError(f'mockgun cannot {method} {params!r}: no canned response is configured')
78
+
79
+ def set_canned(
80
+ self,
81
+ method: str,
82
+ params: Mapping[str, object],
83
+ response: object,
84
+ fault: ServerFaultError | None = None,
85
+ ) -> None:
86
+ """Replace a canned entry after detaching its exact wire parameters.
87
+
88
+ Args:
89
+ method: One of the four supported public navigation/export methods.
90
+ params: Exact RPC parameters, compared without regard to key order.
91
+ response: JSON response value, unused for a fault.
92
+ fault: Optional wire fault in place of a response.
93
+
94
+ Raises:
95
+ ValueError: If the public method is unsupported.
96
+ TypeError: If parameters or response are not JSON-serializable.
97
+ """
98
+ if method not in _CANNED_METHODS:
99
+ raise ValueError(f'unsupported canned method {method!r}')
100
+ rpc = 'nav_search' if method.startswith('nav_search_') else method
101
+ entry = _Canned(rpc, json_copy(dict(params)), json_copy(response), fault)
102
+ self._canned = [
103
+ old for old in self._canned if (old.method, old.params) != (rpc, entry.params)
104
+ ]
105
+ self._canned.append(entry)
106
+
107
+
108
+ @dataclass
109
+ class _Canned:
110
+ """A detached response or fault matched by method and parameter equality."""
111
+
112
+ method: str
113
+ params: object
114
+ response: object
115
+ fault: ServerFaultError | None
116
+
117
+
118
+ def _active(store: Store, rpc: str, kind: str, identity: int) -> Row:
119
+ """Require an active declared target for a site operation."""
120
+ row = store.row(kind, identity) if store.schema.declares(kind) else None
121
+ if row is None or row['__retired']:
122
+ raise MockgunError(f'mockgun cannot {rpc} {kind} {identity}: missing or retired record')
123
+ return row
124
+
125
+
126
+ def _params(rpc: str, value: object, required: set[str], optional: set[str]) -> dict[str, object]:
127
+ """Require an exact parameter dictionary for a site RPC."""
128
+ if not isinstance(value, dict):
129
+ raise MockgunError(f'mockgun cannot {rpc} params: expected a dictionary')
130
+ params = cast('dict[str, object]', value)
131
+ missing = required - params.keys()
132
+ extra = params.keys() - required - optional
133
+ if missing or extra:
134
+ key = min(missing or extra)
135
+ raise MockgunError(f'mockgun cannot {rpc} {key}: missing or extra parameter')
136
+ return params
137
+
138
+
139
+ def _rules(store: Store) -> list[Row]:
140
+ """Read rules only from the complete declared scheduling schema."""
141
+ if not all(store.schema.declares('WorkDayRule', field) for field in _RULE_FIELDS):
142
+ return []
143
+ return store.rows('WorkDayRule', retired=False)
144
+
145
+
146
+ def _schedule_day(store: Store, day: str, project: object) -> Row | None:
147
+ """Resolve a single supported exception, rejecting ambiguous covering rules."""
148
+ matches: list[Row] = []
149
+ for row in _rules(store):
150
+ for field in _RULE_FIELDS:
151
+ store.check_known(row, field)
152
+ start, end = row.get('start_date'), row.get('end_date')
153
+ if not isinstance(start, str) or not isinstance(end, str):
154
+ raise MockgunError(
155
+ f'mockgun cannot work_schedule_read WorkDayRule {row["id"]}: '
156
+ 'date coverage is unsettled'
157
+ )
158
+ if not start <= day <= end:
159
+ continue
160
+ if row.get('user') is not None or row.get('project') is None:
161
+ raise MockgunError(
162
+ f'mockgun cannot work_schedule_read WorkDayRule {row["id"]}: '
163
+ 'user or studio rule is unsettled'
164
+ )
165
+ if row.get('project') == project:
166
+ if start != day or end != day:
167
+ raise MockgunError(
168
+ f'mockgun cannot work_schedule_read WorkDayRule {row["id"]}: '
169
+ 'spanning rule is unsettled'
170
+ )
171
+ matches.append(row)
172
+ if len(matches) > 1:
173
+ raise MockgunError(
174
+ f'mockgun cannot work_schedule_read WorkDayRule {day}: '
175
+ 'multiple matching rules are unsettled'
176
+ )
177
+ return matches[0] if matches else None
178
+
179
+
180
+ def _typed(rpc: str, params: dict[str, object], key: str, kind: type) -> object:
181
+ """Require the exact JSON type of a present parameter."""
182
+ value = params[key]
183
+ if type(value) is not kind:
184
+ raise MockgunError(f'mockgun cannot {rpc} {key}: invalid parameter type')
185
+ return value
186
+
187
+
188
+ def _day(rpc: str, params: dict[str, object], key: str) -> date:
189
+ """Require a canonical calendar date string."""
190
+ value = cast('str', _typed(rpc, params, key, str))
191
+ try:
192
+ result = date.fromisoformat(value)
193
+ except ValueError as exc:
194
+ raise MockgunError(f'mockgun cannot {rpc} {key}: invalid date {value!r}') from exc
195
+ if result.isoformat() != value:
196
+ raise MockgunError(f'mockgun cannot {rpc} {key}: expected YYYY-MM-DD')
197
+ return result
198
+
199
+
200
+ def _link(rpc: str, params: dict[str, object], key: str, kind: str) -> dict[str, object]:
201
+ """Require a typed identity link in an optional restriction."""
202
+ link = cast('dict[str, object]', _typed(rpc, params, key, dict))
203
+ if link.keys() != {'type', 'id'} or link['type'] != kind or type(link['id']) is not int:
204
+ raise MockgunError(f'mockgun cannot {rpc} {key}: expected a {kind} identity link')
205
+ return link
206
+
207
+
208
+ def _navigation_params(rpc: str, value: object) -> dict[str, object]:
209
+ """Validate navigation and export parameters before canned lookup."""
210
+ if rpc == 'export_page':
211
+ params = _params(rpc, value, {'page_id', 'format'}, {'layout_name'})
212
+ _typed(rpc, params, 'page_id', int)
213
+ _typed(rpc, params, 'format', str)
214
+ if 'layout_name' in params:
215
+ _typed(rpc, params, 'layout_name', str)
216
+ return params
217
+ required = {'path'} if rpc == 'nav_expand' else {'root_path', 'search_criteria'}
218
+ optional = (
219
+ {'seed_entity_field', 'entity_fields'} if rpc == 'nav_expand' else {'seed_entity_field'}
220
+ )
221
+ params = _params(rpc, value, required, optional)
222
+ _typed(rpc, params, 'path' if rpc == 'nav_expand' else 'root_path', str)
223
+ if 'seed_entity_field' in params:
224
+ _typed(rpc, params, 'seed_entity_field', str)
225
+ if 'entity_fields' in params:
226
+ fields = cast('dict[str, object]', _typed(rpc, params, 'entity_fields', dict))
227
+ if any(
228
+ not isinstance(values, list)
229
+ or any(not isinstance(item, str) for item in cast('list[object]', values))
230
+ for values in fields.values()
231
+ ):
232
+ raise MockgunError(f'mockgun cannot {rpc} entity_fields: expected lists of names')
233
+ if rpc == 'nav_search':
234
+ criteria = cast('dict[str, object]', _typed(rpc, params, 'search_criteria', dict))
235
+ if criteria.keys() == {'search_string'}:
236
+ _typed(rpc, criteria, 'search_string', str)
237
+ elif criteria.keys() == {'entity'}:
238
+ entity = cast('dict[str, object]', _typed(rpc, criteria, 'entity', dict))
239
+ if (
240
+ entity.keys() != {'type', 'id'}
241
+ or not isinstance(entity['type'], str)
242
+ or type(entity['id']) is not int
243
+ ):
244
+ raise MockgunError(
245
+ f'mockgun cannot {rpc} search_criteria.entity: invalid identity link'
246
+ )
247
+ else:
248
+ raise MockgunError(
249
+ f'mockgun cannot {rpc} search_criteria: expected search_string or entity'
250
+ )
251
+ return params
252
+
253
+
254
+ def _schedule_params(rpc: str, value: object, *, update: bool) -> dict[str, object]:
255
+ """Validate schedule restrictions before inspecting records."""
256
+ required = {'date', 'working'} if update else {'start_date', 'end_date'}
257
+ optional = (
258
+ {'project', 'user', 'description', 'recalculate_field'} if update else {'project', 'user'}
259
+ )
260
+ params = _params(rpc, value, required, optional)
261
+ for key, kind in (('project', 'Project'), ('user', 'HumanUser')):
262
+ if key in params:
263
+ _link(rpc, params, key, kind)
264
+ if 'user' in params or 'recalculate_field' in params:
265
+ key = 'user' if 'user' in params else 'recalculate_field'
266
+ raise MockgunError(f'mockgun cannot {rpc} {key}: this scheduling scope is unsettled')
267
+ return params
268
+
269
+
270
+ def export_page(server: 'FakeServer', params: object, *, context: RequestContext) -> object:
271
+ """Return the exact configured export result or fault.
272
+
273
+ Args:
274
+ server: Site state and explicit canned responses.
275
+ params: Wire page ID, format and optional layout name.
276
+ context: Request identity and timestamp.
277
+
278
+ Raises:
279
+ MockgunError: If no configured response matches the request.
280
+ ServerFaultError: If the matching entry specifies a canned fault.
281
+ """
282
+ del context
283
+ return server.site_data.canned('export_page', _navigation_params('export_page', params))
284
+
285
+
286
+ def json_copy(value: object) -> object:
287
+ """Detach a site control value through its JSON representation.
288
+
289
+ Args:
290
+ value: A JSON-serializable control value.
291
+
292
+ Returns:
293
+ The decoded, detached JSON value.
294
+
295
+ Raises:
296
+ TypeError: If the value cannot be represented as JSON.
297
+ """
298
+ try:
299
+ return json.loads(json.dumps(value, allow_nan=False))
300
+ except (ValueError, TypeError) as exc:
301
+ raise TypeError('site control values must be JSON-serializable') from exc
302
+
303
+
304
+ def nav_expand(server: 'FakeServer', params: object, *, context: RequestContext) -> object:
305
+ """Return an explicitly configured navigation expansion.
306
+
307
+ Args:
308
+ server: Site state and explicit canned responses.
309
+ params: Wire navigation path and optional field selections.
310
+ context: Request identity and timestamp.
311
+
312
+ Raises:
313
+ MockgunError: If no configured response matches the request.
314
+ ServerFaultError: If the matching entry specifies a canned fault.
315
+ """
316
+ del context
317
+ return server.site_data.canned('nav_expand', _navigation_params('nav_expand', params))
318
+
319
+
320
+ def nav_search(server: 'FakeServer', params: object, *, context: RequestContext) -> object:
321
+ """Return an explicitly configured entity or string navigation search.
322
+
323
+ Args:
324
+ server: Site state and explicit canned responses.
325
+ params: Wire root path, search criteria and optional seed field.
326
+ context: Request identity and timestamp.
327
+
328
+ Raises:
329
+ MockgunError: If no configured response matches the request.
330
+ ServerFaultError: If the matching entry specifies a canned fault.
331
+ """
332
+ del context
333
+ return server.site_data.canned('nav_search', _navigation_params('nav_search', params))
334
+
335
+
336
+ def preferences_read(server: 'FakeServer', params: object, *, context: RequestContext) -> object:
337
+ """Read configured preferences in recorded or requested order (C6 P4-56).
338
+
339
+ Args:
340
+ server: Site state and explicit preference values.
341
+ params: A `prefs` list; empty requests all recorded keys.
342
+ context: Request identity and timestamp.
343
+
344
+ Returns:
345
+ A mapping of requested or recorded names to detached configured values,
346
+ omitting unconfigured names outside the recorded keys.
347
+
348
+ Raises:
349
+ MockgunError: If a recorded key has no configured value.
350
+ """
351
+ del context
352
+ values = _params('preferences_read', params, {'prefs'}, set())
353
+ names = cast('list[object]', _typed('preferences_read', values, 'prefs', list))
354
+ if any(not isinstance(name, str) for name in names):
355
+ raise MockgunError('mockgun cannot preferences_read prefs: expected string names')
356
+ result: dict[str, object] = {}
357
+ for name in cast('list[str]', names) or _RECORDED_PREFERENCES:
358
+ if name in server.site_data.preferences:
359
+ result[name] = deepcopy(server.site_data.preferences[name])
360
+ elif name in _RECORDED_PREFERENCES:
361
+ raise MockgunError(
362
+ f'mockgun cannot preferences_read {name}: site value is unconfigured'
363
+ )
364
+ return result
365
+
366
+
367
+ def update_project_last_accessed_by_current_user(
368
+ server: 'FakeServer', params: object, *, context: RequestContext
369
+ ) -> object:
370
+ """Record an actor's Project access instant and taint its side effects.
371
+
372
+ Args:
373
+ server: Site records and journaled per-actor access times.
374
+ params: Project ID and optional attributed HumanUser ID.
375
+ context: Request identity and timestamp.
376
+
377
+ Returns:
378
+ `True` after recording the timestamp.
379
+
380
+ Raises:
381
+ MockgunError: If the Project or attributed HumanUser is unavailable, or
382
+ no request actor is available.
383
+ """
384
+ rpc = 'update_project_last_accessed_by_current_user'
385
+ values = _params(rpc, params, {'project_id'}, {'user_id'})
386
+ identity = cast('int', _typed(rpc, values, 'project_id', int))
387
+ project = _active(server.store, rpc, 'Project', identity)
388
+ actor = context.actor
389
+ if 'user_id' in values:
390
+ user_id = cast('int', _typed(rpc, values, 'user_id', int))
391
+ _active(server.store, rpc, 'HumanUser', user_id)
392
+ kind = 'HumanUser'
393
+ elif actor is not None:
394
+ kind, user_id = actor.entity_type, actor.entity_id
395
+ else:
396
+ raise MockgunError(f'mockgun cannot {rpc} Project {identity}: actor is unavailable')
397
+ with server.store.transaction():
398
+ server.store.side_set('last_access', (identity, kind, user_id), context.now)
399
+ server.store.taint_events(f'{rpc} Project {identity}: event rows are unsettled')
400
+ taint_activity(server.store, [project], f'{rpc} Project {identity}: activity is unsettled')
401
+ return True
402
+
403
+
404
+ def user_subscriptions_create(
405
+ server: 'FakeServer', params: object, *, context: RequestContext
406
+ ) -> object:
407
+ """Accept only observed no-op subscription writes.
408
+
409
+ Args:
410
+ server: Site state and explicit subscriptions.
411
+ params: Mapping of decimal HumanUser ID strings to subscription values.
412
+ Only values equal to the configured subscriptions are accepted as
413
+ modeled writes.
414
+ context: Request identity and timestamp.
415
+
416
+ Returns:
417
+ A mapping of unchanged IDs to `None`.
418
+
419
+ Raises:
420
+ ServerFaultError: If a single requested HumanUser does not exist.
421
+ MockgunError: If a requested HumanUser is otherwise unavailable, or a
422
+ subscription is unconfigured or would change.
423
+ """
424
+ del context
425
+ rpc = 'user_subscriptions_create'
426
+ if not isinstance(params, dict):
427
+ raise MockgunError(f'mockgun cannot {rpc} params: expected a dictionary')
428
+ values = cast('dict[str, object]', params)
429
+ result: dict[str, None] = {}
430
+ for key, value in values.items():
431
+ if not key.isascii() or not key.isdecimal():
432
+ raise MockgunError(f'mockgun cannot {rpc} {key}: expected a decimal HumanUser ID')
433
+ if (
434
+ value is not None
435
+ and not isinstance(value, str)
436
+ and not (
437
+ isinstance(value, list)
438
+ and all(isinstance(item, str) for item in cast('list[object]', value))
439
+ )
440
+ ):
441
+ raise MockgunError(f'mockgun cannot {rpc} {key}: invalid subscription type')
442
+ identity = int(key)
443
+ row = (
444
+ server.store.row('HumanUser', identity)
445
+ if server.schema.declares('HumanUser')
446
+ else None
447
+ )
448
+ if row is None and len(values) == 1:
449
+ raise ServerFaultError(
450
+ f'API user_subscriptions_create() failed! Invalid humanUserId {key}'
451
+ )
452
+ _active(server.store, rpc, 'HumanUser', identity)
453
+ if (
454
+ identity not in server.site_data.subscriptions
455
+ or value != server.site_data.subscriptions[identity]
456
+ ):
457
+ raise MockgunError(
458
+ f'mockgun cannot {rpc} HumanUser {identity}: changed subscription is unsettled'
459
+ )
460
+ result[key] = None
461
+ return result
462
+
463
+
464
+ def user_subscriptions_read(
465
+ server: 'FakeServer', params: object, *, context: RequestContext
466
+ ) -> object:
467
+ """Read configured active HumanUser subscriptions in insertion order.
468
+
469
+ Args:
470
+ server: Site state and explicit subscriptions.
471
+ params: Absent wire parameters, represented by `None`.
472
+ context: Request identity and timestamp.
473
+
474
+ Returns:
475
+ Subscription strings keyed by string HumanUser IDs, in configured
476
+ insertion order.
477
+
478
+ Raises:
479
+ MockgunError: If a configured HumanUser is missing, retired, or
480
+ undeclared.
481
+ """
482
+ del context
483
+ if params is not None:
484
+ raise MockgunError(
485
+ 'mockgun cannot user_subscriptions_read params: expected absent parameters'
486
+ )
487
+ result: dict[str, str] = {}
488
+ for identity, value in server.site_data.subscriptions.items():
489
+ _active(server.store, 'user_subscriptions_read', 'HumanUser', identity)
490
+ result[str(identity)] = value
491
+ return result
492
+
493
+
494
+ def work_schedule_read(server: 'FakeServer', params: object, *, context: RequestContext) -> object:
495
+ """Read the studio week and supported single-date Project exceptions.
496
+
497
+ Args:
498
+ server: Site settings and WorkDayRule records.
499
+ params: Inclusive start and end dates, with an optional Project
500
+ restriction.
501
+ context: Request identity and timestamp.
502
+
503
+ Returns:
504
+ Date-keyed working flags, descriptions and observed reason names, in
505
+ chronological order.
506
+
507
+ Raises:
508
+ MockgunError: If the requested scheduling scope is unsupported, the
509
+ date range is invalid, or relevant rules have unknown, overlapping,
510
+ spanning, or otherwise unsettled coverage.
511
+ """
512
+ del context
513
+ rpc = 'work_schedule_read'
514
+ values = _schedule_params(rpc, params, update=False)
515
+ start, end = _day(rpc, values, 'start_date'), _day(rpc, values, 'end_date')
516
+ if start > end:
517
+ raise MockgunError(f'mockgun cannot {rpc} start_date: later than end_date')
518
+ result: dict[str, object] = {}
519
+ for offset in range((end - start).days + 1):
520
+ day = start + timedelta(days=offset)
521
+ rule = _schedule_day(server.store, day.isoformat(), values.get('project'))
522
+ result[day.isoformat()] = {
523
+ 'working': rule['working'] if rule else server.site_data.work_week[day.weekday()],
524
+ 'description': rule['description'] if rule else None,
525
+ 'reason': 'PROJECT_EXCEPTION' if rule else 'STUDIO_WORK_WEEK',
526
+ }
527
+ return result
528
+
529
+
530
+ def work_schedule_update(
531
+ server: 'FakeServer', params: object, *, context: RequestContext
532
+ ) -> object:
533
+ """Replace a Project's single-date exception transactionally (C6 P4-60/61).
534
+
535
+ Args:
536
+ server: Site settings and WorkDayRule records.
537
+ params: Date, working flag, optional description and required Project.
538
+ context: Request identity and timestamp.
539
+
540
+ Returns:
541
+ The supplied date and values with a named Project link and null user.
542
+ """
543
+ rpc = 'work_schedule_update'
544
+ values = _schedule_params(rpc, params, update=True)
545
+ day = _day(rpc, values, 'date').isoformat()
546
+ working = _typed(rpc, values, 'working', bool)
547
+ if 'description' in values:
548
+ _typed(rpc, values, 'description', str)
549
+ if 'project' not in values:
550
+ raise MockgunError(f'mockgun cannot {rpc} project: studio-wide updates are unsettled')
551
+ project = cast('dict[str, object]', values['project'])
552
+ identity = cast('int', project['id'])
553
+ target = _active(server.store, rpc, 'Project', identity)
554
+ if not all(server.schema.declares('WorkDayRule', field) for field in _RULE_FIELDS):
555
+ raise MockgunError(f'mockgun cannot {rpc} WorkDayRule: scheduling schema is undeclared')
556
+ fields = {
557
+ 'start_date': day,
558
+ 'end_date': day,
559
+ 'working': working,
560
+ 'description': values.get('description'),
561
+ 'project': project,
562
+ 'user': None,
563
+ }
564
+ project_name = display_name(server.store, 'Project', identity)
565
+ with server.store.transaction():
566
+ for rule in _rules(server.store):
567
+ if (
568
+ rule.get('project') == project
569
+ and rule.get('start_date') == rule.get('end_date') == day
570
+ ):
571
+ server.store.retire('WorkDayRule', cast('int', rule['id']), context=context)
572
+ row = server.store.create('WorkDayRule', fields, context=context)
573
+ for field in ('precedence', 'day_of_week'):
574
+ if server.schema.declares('WorkDayRule', field):
575
+ server.store.mark_unknown(row, field, f'{rpc} server-computed value is unsettled')
576
+ server.store.taint_events(f'{rpc} WorkDayRule {row["id"]}: event rows are unsettled')
577
+ taint_activity(server.store, [target], f'{rpc} Project {identity}: activity is unsettled')
578
+ return {
579
+ 'date': day,
580
+ 'working': working,
581
+ 'description': values.get('description'),
582
+ 'project': {**project, 'name': project_name},
583
+ 'user': None,
584
+ }