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,964 @@
1
+ """Validate summary requests and calculate supported values over selected rows."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from datetime import date, timedelta
6
+ from decimal import Decimal
7
+ from fractions import Fraction
8
+ from typing import cast
9
+
10
+ from ._errors import MockgunError, ServerFaultError
11
+ from ._links import derived_name, display_name, hidden
12
+ from ._query import ReadScope, check_collation, collation_key, project_rows, select
13
+ from ._schema import SiteSchema
14
+ from ._sessions import RequestContext
15
+ from ._store import FieldValue, Row, Store
16
+ from ._wire import parse_wire_datetime
17
+
18
+ _ASCII_LOWER = str.maketrans('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')
19
+ _BINARY_UNIT = Fraction(1, 2**53)
20
+ _BUCKET_SIZES = {
21
+ 'tens': 10,
22
+ 'hundreds': 100,
23
+ 'thousands': 1000,
24
+ 'tensofthousands': 10000,
25
+ 'hundredsofthousands': 100000,
26
+ 'millions': 1000000,
27
+ }
28
+ _CLUSTERED_ORDER = (
29
+ 'last_few_months',
30
+ 'last_week',
31
+ 'yesterday',
32
+ 'today',
33
+ 'tomorrow',
34
+ 'later_this_week',
35
+ 'next_week',
36
+ 'next_few_weeks',
37
+ 'next_few_months',
38
+ )
39
+ _DATE_GROUP_TYPES = ('exact', 'day', 'week', 'month', 'quarter', 'year', 'clustered_date')
40
+ _DECIMAL_PLACES = 4
41
+ _EXACT_INTEGER_LIMIT = 2**53
42
+ _FLOAT_GROUP_MAX = 1000
43
+ _FLOAT_GROUP_MIN = 0.0001
44
+ _GROUP_ENTITY_TYPES = {'Asset', 'Sequence', 'Shot'}
45
+ _GROUP_TYPES = {
46
+ 'checkbox': {'exact'},
47
+ 'date': set(_DATE_GROUP_TYPES),
48
+ 'date_time': {'exact', 'day', 'week', 'month', 'quarter', 'year'},
49
+ 'entity': {'exact', 'entitytype'},
50
+ 'float': {'exact'},
51
+ 'list': {'exact'},
52
+ 'multi_entity': {'exact'},
53
+ 'number': {
54
+ 'exact',
55
+ 'tens',
56
+ 'hundreds',
57
+ 'thousands',
58
+ 'tensofthousands',
59
+ 'hundredsofthousands',
60
+ 'millions',
61
+ },
62
+ 'status_list': {'exact'},
63
+ 'text': {'exact', 'firstletter'},
64
+ }
65
+ _HALF = Fraction(1, 2)
66
+ _MAX_GROUP_LEVELS = 3
67
+ _MIN_DEEP_PATH_PARTS = 3
68
+ _MONTHS = (
69
+ 'January',
70
+ 'February',
71
+ 'March',
72
+ 'April',
73
+ 'May',
74
+ 'June',
75
+ 'July',
76
+ 'August',
77
+ 'September',
78
+ 'October',
79
+ 'November',
80
+ 'December',
81
+ )
82
+ _QUARTER_MONTHS = 3
83
+ _SIGNIFICANT_DIGITS = 15
84
+ _SUMMARY_TYPES = {
85
+ 'calculated': ('record_count', 'count', 'sum', 'minimum', 'maximum', 'average'),
86
+ 'checkbox': ('record_count', 'checked', 'unchecked'),
87
+ 'date': ('record_count', 'count', 'earliest', 'latest'),
88
+ 'date_time': ('record_count', 'count', 'earliest', 'latest'),
89
+ 'duration': ('record_count', 'count', 'sum', 'minimum', 'maximum', 'average'),
90
+ 'entity': ('record_count', 'count'),
91
+ 'float': ('record_count', 'count', 'sum', 'maximum', 'minimum', 'average'),
92
+ 'list': ('record_count', 'count', 'percentage'),
93
+ 'multi_entity': ('record_count', 'count'),
94
+ 'number': ('record_count', 'count', 'sum', 'maximum', 'minimum', 'average'),
95
+ 'percent': ('record_count', 'count', 'sum', 'maximum', 'minimum', 'average'),
96
+ 'status_list': (
97
+ 'record_count',
98
+ 'status_list',
99
+ 'status_percentage',
100
+ 'status_percentage_as_float',
101
+ ),
102
+ 'text': ('record_count', 'count'),
103
+ 'url': ('record_count',),
104
+ 'pivot_column': (),
105
+ 'image': (),
106
+ 'summary': (),
107
+ 'serializable': (),
108
+ 'color': (),
109
+ }
110
+
111
+
112
+ @dataclass
113
+ class _Issues:
114
+ """Collect independent request errors and server faults."""
115
+
116
+ errors: list[str] = field(default_factory=list[str])
117
+ faults: list[str] = field(default_factory=list[str])
118
+
119
+
120
+ @dataclass
121
+ class _Bucket:
122
+ """Keep a group's identity, response values, sort key and candidate rows."""
123
+
124
+ key: str | int | float | tuple[str, int] | None
125
+ name: str | int
126
+ value: object
127
+ order: tuple[str, str, int | float] = ('', '', 0)
128
+ rows: list[Row] = field(default_factory=list[Row])
129
+
130
+
131
+ @dataclass
132
+ class _Grouping:
133
+ """Carry the read interfaces and summary entries through nested groups."""
134
+
135
+ store: Store
136
+ schema: SiteSchema
137
+ entity_type: str
138
+ scope: ReadScope
139
+ summaries: list[dict[str, object]]
140
+
141
+
142
+ def _check_archive_targets(ctx: _Grouping, targets: set[tuple[str, int]], message: str) -> None:
143
+ """Resolve linked target archive state through selection and projection only."""
144
+ if ctx.scope.include_archived_projects or not ctx.schema.declares('Project', 'archived'):
145
+ return
146
+ for kind in sorted({kind for kind, _ in targets}):
147
+ ids = {entity_id for target_type, entity_id in targets if target_type == kind}
148
+ tree: dict[str, object] = {
149
+ 'logical_operator': 'and',
150
+ 'conditions': [{'path': 'id', 'relation': 'in', 'values': sorted(ids)}],
151
+ }
152
+ field_name = 'archived' if kind == 'Project' else 'project.Project.archived'
153
+ fields = [field_name] if kind == 'Project' or ctx.schema.declares(kind, 'project') else []
154
+ found: set[int] = set()
155
+ for retired in (False, True):
156
+ rows = select(
157
+ ctx.store,
158
+ kind,
159
+ tree,
160
+ [],
161
+ scope=ReadScope(
162
+ retired=retired, context=ctx.scope.context, include_archived_projects=True
163
+ ),
164
+ )
165
+ projected = project_rows(ctx.store, ctx.schema, rows, fields)
166
+ found.update(cast('int', row['id']) for row in projected)
167
+ if any(row.get(field_name) is True for row in projected):
168
+ raise MockgunError(message)
169
+ if found != ids:
170
+ raise MockgunError(message)
171
+
172
+
173
+ def _check_group_names(ctx: _Grouping, path: str, names: list[str]) -> None:
174
+ """Reject ambiguous names within a single preceding-key tie group."""
175
+ check_collation(set(names), entity_type=ctx.entity_type, field_name=path)
176
+ if len({name.translate(_ASCII_LOWER) for name in names}) != len(names):
177
+ raise MockgunError(f'mockgun cannot summarize grouping field {path}: equal names')
178
+
179
+
180
+ def _check_hidden_groups(ctx: _Grouping, rows: list[Row], path: str, message: str) -> None:
181
+ """Use raw candidate links only to reject hidden group members."""
182
+ for row in rows:
183
+ value = row.get(path)
184
+ links = value if isinstance(value, list) else [value]
185
+ for link in links:
186
+ if isinstance(link, dict) and hidden(ctx.store, row, path, link):
187
+ raise MockgunError(message)
188
+
189
+
190
+ def _clustered_bucket(day: date, today: date, message: str) -> _Bucket:
191
+ """Name only measured UTC date clusters with an unambiguous label."""
192
+ start = today - timedelta(days=(today.weekday() + 1) % 7)
193
+ day_start = day - timedelta(days=(day.weekday() + 1) % 7)
194
+ yesterday = today - timedelta(days=1)
195
+ tomorrow = today + timedelta(days=1)
196
+ if day == yesterday and day_start == start - timedelta(days=7):
197
+ raise MockgunError(message)
198
+ if day == tomorrow and day_start == start + timedelta(days=7):
199
+ raise MockgunError(message)
200
+ if day == yesterday:
201
+ name = 'yesterday'
202
+ elif day == today:
203
+ name = 'today'
204
+ elif day == tomorrow:
205
+ name = 'tomorrow'
206
+ elif day_start == start - timedelta(days=7):
207
+ name = 'last_week'
208
+ elif day_start == start and day > tomorrow:
209
+ name = 'later_this_week'
210
+ elif day_start == start + timedelta(days=7):
211
+ name = 'next_week'
212
+ else:
213
+ raise MockgunError(message)
214
+ return _Bucket(name, name, name, (f'{_CLUSTERED_ORDER.index(name):02}', '', 0))
215
+
216
+
217
+ def _date_bucket(value: str, data_type: str, kind: str, message: str, *, today: date) -> _Bucket:
218
+ """Format supported UTC dates using locale-independent English month names."""
219
+ if kind == 'exact' and data_type == 'date_time':
220
+ text = parse_wire_datetime(value).strftime('%Y-%m-%d %H:%M:%S')
221
+ return _Bucket(text, text, text, (text, '', 0))
222
+ day = date.fromisoformat(value) if data_type == 'date' else parse_wire_datetime(value).date()
223
+ if kind == 'clustered_date':
224
+ return _clustered_bucket(day, today, message)
225
+ if kind == 'week':
226
+ day -= timedelta(days=(day.weekday() + 1) % 7)
227
+ end = day + timedelta(days=6)
228
+ name = (
229
+ f'{_MONTHS[day.month - 1]} {day.day:02}, {day.year:04} to '
230
+ f'{_MONTHS[end.month - 1]} {end.day:02}, {end.year:04}'
231
+ )
232
+ elif kind == 'exact':
233
+ name = f'{_MONTHS[day.month - 1][:3]} {day.day:02} {day.year % 100:02}'
234
+ elif kind == 'day':
235
+ name = f'{_MONTHS[day.month - 1]} {day.day:02}, {day.year:04}'
236
+ elif kind == 'month':
237
+ day = day.replace(day=1)
238
+ name = f'{_MONTHS[day.month - 1]} {day.year:04}'
239
+ elif kind == 'quarter':
240
+ quarter = (day.month - 1) // _QUARTER_MONTHS + 1
241
+ day = day.replace(month=(quarter - 1) * _QUARTER_MONTHS + 1, day=1)
242
+ name = f'Q{quarter} {day.year:04}'
243
+ else:
244
+ day = day.replace(month=1, day=1)
245
+ name = f'{day.year:04}'
246
+ text = day.isoformat()
247
+ return _Bucket(text, name, text, (text, '', 0))
248
+
249
+
250
+ def _date_value(
251
+ values: list[object], summary_type: str, data_type: str, message: str
252
+ ) -> str | None:
253
+ """Return supported date extrema in the summary wire format."""
254
+ if not values:
255
+ return None
256
+ present = cast('list[str]', [value for value in values if value is not None])
257
+ if not present or (data_type == 'date_time' and len(present) != len(values)):
258
+ raise MockgunError(message)
259
+ result = min(present) if summary_type == 'earliest' else max(present)
260
+ if data_type == 'date_time':
261
+ return parse_wire_datetime(result).strftime('%Y-%m-%d %H:%M:%S UTC')
262
+ return result
263
+
264
+
265
+ def _declared_field_type(
266
+ schema: SiteSchema, entity_type: str, path: str, issues: _Issues
267
+ ) -> str | None:
268
+ """Resolve a summary field while separating direct faults from unsettled paths."""
269
+ parts = path.split('.')
270
+ if len(parts) == 1:
271
+ if path not in {'id', 'type'} and not schema.declares(entity_type, path):
272
+ issues.faults.append(f"API summarize() {entity_type}.{path} doesn't exist")
273
+ return None
274
+ return schema.field_type(entity_type, path)
275
+ if len(parts) < _MIN_DEEP_PATH_PARTS or len(parts) % 2 == 0:
276
+ issues.errors.append(f'summarize field {path} has an unsupported path')
277
+ return None
278
+ current_type = entity_type
279
+ invalid_reason = None
280
+ for index in range(0, len(parts) - 1, 2):
281
+ link, target = parts[index : index + 2]
282
+ if not schema.declares(current_type, link):
283
+ invalid_reason = f'undeclared link {link}'
284
+ elif schema.field_type(current_type, link) != 'entity':
285
+ invalid_reason = f'cannot traverse {link}'
286
+ elif not schema.declares(target):
287
+ invalid_reason = f'undeclared type {target}'
288
+ if invalid_reason is not None:
289
+ issues.errors.append(f'summarize field {path} has {invalid_reason}')
290
+ return None
291
+ current_type = target
292
+ terminal = parts[-1]
293
+ if terminal not in {'id', 'type'} and not schema.declares(current_type, terminal):
294
+ issues.errors.append(f'summarize field {path} has undeclared terminal {terminal}')
295
+ return None
296
+ return schema.field_type(current_type, terminal)
297
+
298
+
299
+ def _field_type(schema: SiteSchema, entity_type: str, path: str, issues: _Issues) -> str | None:
300
+ """Reject per-user access summaries before resolving declared field types."""
301
+ if (entity_type == 'Project' and path == 'last_accessed_by_current_user') or path.endswith(
302
+ '.Project.last_accessed_by_current_user'
303
+ ):
304
+ issues.errors.append(
305
+ f'summarize Project.last_accessed_by_current_user via {path} is unsettled'
306
+ )
307
+ return None
308
+ return _declared_field_type(schema, entity_type, path, issues)
309
+
310
+
311
+ def _group_properties(schema: SiteSchema, entity_type: str, path: str) -> dict[str, object]:
312
+ """Read enum metadata for a direct or deep terminal field."""
313
+ parts = path.split('.')
314
+ terminal_type = parts[-2] if len(parts) > 1 else entity_type
315
+ metadata = cast('dict[str, object]', schema.fields[terminal_type][parts[-1]])
316
+ return cast('dict[str, object]', metadata.get('properties', {}))
317
+
318
+
319
+ def _check_enum_order(ctx: _Grouping, path: str, values: list[str], data_type: str) -> None:
320
+ """Accept enum orders only when code, rank and display candidates agree."""
321
+ _check_group_names(ctx, path, values)
322
+ ordered = sorted(values)
323
+ if sorted(values, key=collation_key) != ordered:
324
+ raise MockgunError(f'mockgun cannot summarize grouping field {path}: enum order')
325
+ properties = _group_properties(ctx.schema, ctx.entity_type, path)
326
+ valid = cast('dict[str, object]', properties.get('valid_values', {})).get('value')
327
+ if isinstance(valid, list):
328
+ valid = cast('list[str]', valid)
329
+ if (
330
+ any(value not in valid for value in values)
331
+ or sorted(values, key=valid.index) != ordered
332
+ ):
333
+ raise MockgunError(f'mockgun cannot summarize grouping field {path}: enum rank')
334
+ display = cast('dict[str, object]', properties.get('display_values', {})).get('value')
335
+ if data_type != 'status_list' or not isinstance(display, dict):
336
+ return
337
+ display = cast('dict[str, str]', display)
338
+ if any(value not in display for value in values):
339
+ raise MockgunError(f'mockgun cannot summarize grouping field {path}: enum display')
340
+ _check_group_names(ctx, path, [display[value] for value in values])
341
+ if (
342
+ sorted(values, key=display.__getitem__) != ordered
343
+ or sorted(values, key=lambda value: collation_key(display[value])) != ordered
344
+ ):
345
+ raise MockgunError(f'mockgun cannot summarize grouping field {path}: enum display order')
346
+
347
+
348
+ def _number_bucket(value: float, kind: str, message: str) -> _Bucket:
349
+ """Keep numeric ordering separate from the observed response string forms."""
350
+ if kind in _BUCKET_SIZES:
351
+ if value < 0:
352
+ raise MockgunError(message)
353
+ bucket = int(value) // _BUCKET_SIZES[kind] * _BUCKET_SIZES[kind]
354
+ return _Bucket(bucket, bucket, bucket, ('', '', bucket))
355
+ if isinstance(value, float) and (
356
+ repr(value) == '-0.0'
357
+ or (value != 0.0 and not _FLOAT_GROUP_MIN <= abs(value) < _FLOAT_GROUP_MAX)
358
+ ):
359
+ raise MockgunError(message)
360
+ text = str(value)
361
+ return _Bucket(value, text, text, ('', '', value))
362
+
363
+
364
+ def _raise_summary_fault(
365
+ store: Store, entity_type: str, tree: dict[str, object], scope: ReadScope, message: str
366
+ ) -> None:
367
+ """Check filter faults before returning a single summary fault."""
368
+ try:
369
+ select(store, entity_type, tree, [], scope=scope)
370
+ except ServerFaultError as error:
371
+ raise MockgunError(f'mockgun cannot summarize: {message}; {error}') from error
372
+ code = (
373
+ None
374
+ if message.startswith(
375
+ ('Uncaught exception!', "API summarize() invalid/missing string 'direction'")
376
+ )
377
+ else 103
378
+ )
379
+ raise ServerFaultError(message, error_code=code)
380
+
381
+
382
+ def _round_fraction(value: Fraction, places: int, *, half_even: bool) -> Fraction:
383
+ """Round exactly to a decimal place, with either observed tie convention."""
384
+ unit = Fraction(10) ** -places
385
+ scaled = abs(value) / unit
386
+ whole, remainder = divmod(scaled.numerator, scaled.denominator)
387
+ part = Fraction(remainder, scaled.denominator)
388
+ if part > _HALF or (part == _HALF and (not half_even or whole % 2)):
389
+ whole += 1
390
+ return whole * unit if value >= 0 else -whole * unit
391
+
392
+
393
+ def _percentage_value(
394
+ values: list[object], summary_type: str, selector: str, valid: object, message: str
395
+ ) -> int:
396
+ """Count exact enum matches with the supported denominator and rounding rules."""
397
+ if isinstance(valid, list) and selector not in valid:
398
+ raise MockgunError(message)
399
+ for value in values:
400
+ if value is None and summary_type != 'percentage':
401
+ raise MockgunError(message)
402
+ if isinstance(value, str) and value != selector and value.lower() == selector.lower():
403
+ raise MockgunError(message)
404
+ if not values:
405
+ return -1 if summary_type == 'status_percentage_as_float' else 0
406
+ percentage = Fraction(100 * values.count(selector), len(values))
407
+ if summary_type == 'status_percentage_as_float':
408
+ return percentage.numerator // percentage.denominator
409
+ if summary_type == 'percentage' and percentage % 1 == _HALF:
410
+ raise MockgunError(message)
411
+ return int(_round_fraction(percentage, 0, half_even=False))
412
+
413
+
414
+ def _scalar_bucket(
415
+ value: object, data_type: str, kind: str, message: str, *, today: date
416
+ ) -> _Bucket:
417
+ """Build a supported scalar bucket without inspecting any record storage."""
418
+ if data_type == 'checkbox':
419
+ if value is True:
420
+ return _Bucket('t', '[x]', 't', ('t', '', 0))
421
+ if value is not False:
422
+ raise MockgunError(message)
423
+ return _Bucket('f', '[ ]', 'f', ('f', '', 0))
424
+ if value is None:
425
+ return _Bucket(None, '', None)
426
+ if data_type in {'number', 'float'}:
427
+ return _number_bucket(cast('int | float', value), kind, message)
428
+ text = cast('str', value)
429
+ if data_type in {'date', 'date_time'}:
430
+ return _date_bucket(text, data_type, kind, message, today=today)
431
+ if kind == 'firstletter':
432
+ if (
433
+ not text
434
+ or text[0] not in 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_'
435
+ ):
436
+ raise MockgunError(message)
437
+ text = text[0].upper()
438
+ key = text.translate(_ASCII_LOWER) if data_type == 'text' else text
439
+ return _Bucket(key, text, text, (collation_key(text) if data_type == 'text' else text, '', 0))
440
+
441
+
442
+ def _significant_places(value: Fraction) -> int:
443
+ """Find the decimal place for fifteen significant digits without floating arithmetic."""
444
+ if value == 0:
445
+ return _SIGNIFICANT_DIGITS - 1
446
+ value = abs(value)
447
+ exponent = len(str(value.numerator)) - len(str(value.denominator))
448
+ if value < Fraction(10) ** exponent:
449
+ exponent -= 1
450
+ return _SIGNIFICANT_DIGITS - 1 - exponent
451
+
452
+
453
+ def _arithmetic_value(values: list[int | float], summary_type: str, message: str) -> float:
454
+ """Accept an arithmetic result only when all exact uncertainty bounds agree."""
455
+ decimal_sum = sum((Fraction(Decimal(repr(value))) for value in values), Fraction())
456
+ binary_sum = sum((Fraction(value) for value in values), Fraction())
457
+ absolute_sum = sum((abs(Fraction(value)) for value in values), Fraction())
458
+ count = len(values)
459
+ factor = (count - 1) * _BINARY_UNIT
460
+ if factor >= 1:
461
+ raise MockgunError(message)
462
+ error = factor / (1 - factor) * absolute_sum
463
+ if all(isinstance(value, int) for value in values) and absolute_sum <= _EXACT_INTEGER_LIMIT:
464
+ error = Fraction()
465
+ lower, upper = binary_sum - error, binary_sum + error
466
+ if summary_type == 'average':
467
+ candidate = decimal_sum / count
468
+ lower /= count
469
+ upper /= count
470
+ lower -= abs(lower) * _BINARY_UNIT
471
+ upper += abs(upper) * _BINARY_UNIT
472
+ candidates = {
473
+ _round_fraction(value, _DECIMAL_PLACES, half_even=half_even)
474
+ for value in (candidate, lower, upper)
475
+ for half_even in (False, True)
476
+ }
477
+ else:
478
+ candidates = {decimal_sum}
479
+ candidates.update(
480
+ _round_fraction(value, places, half_even=half_even)
481
+ for value in (lower, upper)
482
+ for places in (_DECIMAL_PLACES, _significant_places(value))
483
+ for half_even in (False, True)
484
+ )
485
+ if len(candidates) != 1:
486
+ raise MockgunError(message)
487
+ return float(candidates.pop())
488
+
489
+
490
+ def _numeric_value(
491
+ values: list[object], summary_type: str, data_type: str, message: str
492
+ ) -> int | float:
493
+ """Calculate supported numeric summaries, distinguishing empty and all-null rows."""
494
+ if data_type == 'percent' and (summary_type != 'average' or not values):
495
+ raise MockgunError(message)
496
+ present = cast('list[int | float]', [value for value in values if value is not None])
497
+ if not present:
498
+ if values:
499
+ raise MockgunError(message)
500
+ return 0.0 if data_type == 'float' or summary_type == 'average' else 0
501
+ if summary_type == 'average' or (summary_type == 'sum' and data_type == 'float'):
502
+ return _arithmetic_value(present, summary_type, message)
503
+ if summary_type == 'sum':
504
+ result = sum(present)
505
+ elif summary_type == 'maximum':
506
+ result = max(present)
507
+ else:
508
+ result = min(present)
509
+ return float(result) if data_type == 'float' else int(result)
510
+
511
+
512
+ def _summary_value(
513
+ schema: SiteSchema, entity_type: str, entry: dict[str, object], values: list[object]
514
+ ) -> object:
515
+ """Apply a validated summary to projected field values."""
516
+ name = cast('str', entry['field'])
517
+ summary_type = cast('str', entry['type'])
518
+ data_type = schema.field_type(entity_type, name)
519
+ message = f'mockgun cannot summarize summary type {summary_type!r} field {name} {data_type}'
520
+ if summary_type == 'count':
521
+ return sum(value is not None for value in values)
522
+ if summary_type in {'sum', 'maximum', 'minimum', 'average'}:
523
+ return _numeric_value(values, summary_type, data_type, message)
524
+ if summary_type in {'earliest', 'latest'}:
525
+ return _date_value(values, summary_type, data_type, message)
526
+ if summary_type in {'checked', 'unchecked'}:
527
+ if None in values:
528
+ raise MockgunError(message)
529
+ return values.count(summary_type == 'checked')
530
+ if summary_type == 'status_list':
531
+ if not values or values[0] is None or any(value != values[0] for value in values):
532
+ raise MockgunError(message)
533
+ return values[0]
534
+ parts = name.split('.')
535
+ terminal_type = parts[-2] if len(parts) > 1 else entity_type
536
+ metadata = cast('dict[str, object]', schema.fields[terminal_type][parts[-1]])
537
+ properties = cast('dict[str, object]', metadata.get('properties', {}))
538
+ valid = cast('dict[str, object]', properties.get('valid_values', {})).get('value')
539
+ return _percentage_value(values, summary_type, cast('str', entry['value']), valid, message)
540
+
541
+
542
+ def _summary_values(
543
+ store: Store,
544
+ schema: SiteSchema,
545
+ entity_type: str,
546
+ rows: list[Row],
547
+ entries: list[dict[str, object]],
548
+ ) -> dict[str, object]:
549
+ """Project only value-reading summaries and preserve the request's result order."""
550
+ fields = [cast('str', entry['field']) for entry in entries if entry['type'] != 'record_count']
551
+ projected = project_rows(store, schema, rows, fields) if fields else []
552
+ result: dict[str, object] = {}
553
+ for entry in entries:
554
+ name = cast('str', entry['field'])
555
+ if entry['type'] == 'record_count':
556
+ result[name] = len(rows)
557
+ continue
558
+ if any(name not in row for row in projected):
559
+ raise MockgunError(
560
+ f'mockgun cannot summarize summary type {entry["type"]!r} field '
561
+ f'{name} {schema.field_type(entity_type, name)}: projection omits field'
562
+ )
563
+ result[name] = _summary_value(schema, entity_type, entry, [row[name] for row in projected])
564
+ return result
565
+
566
+
567
+ def _type_display(schema: SiteSchema, entity_type: str) -> str | None:
568
+ """Read a declared entity display name without supplying a fallback."""
569
+ metadata = schema.entities.get(entity_type, {})
570
+ name = cast('dict[str, object]', metadata.get('name', {})).get('value')
571
+ return name if isinstance(name, str) else None
572
+
573
+
574
+ def _check_group_types(ctx: _Grouping, path: str, types: set[str]) -> None:
575
+ """Require the observed type order to agree with display-name candidates."""
576
+ if len(types) <= 1:
577
+ return
578
+ if not types <= _GROUP_ENTITY_TYPES:
579
+ raise MockgunError(f'mockgun cannot summarize grouping field {path}: mixed types')
580
+ names = [_type_display(ctx.schema, kind) for kind in sorted(types)]
581
+ if any(name is None for name in names):
582
+ raise MockgunError(f'mockgun cannot summarize grouping field {path}: unnamed types')
583
+ present = cast('list[str]', names)
584
+ _check_group_names(ctx, path, present)
585
+ for keys in (
586
+ present,
587
+ [name.translate(_ASCII_LOWER) for name in present],
588
+ [collation_key(name) for name in present],
589
+ ):
590
+ if keys != sorted(set(keys)):
591
+ raise MockgunError(f'mockgun cannot summarize grouping field {path}: type order')
592
+
593
+
594
+ def _entity_bucket(
595
+ ctx: _Grouping, link: dict[str, FieldValue], kind: str, message: str
596
+ ) -> _Bucket:
597
+ """Build one target or type bucket from a projected entity identity."""
598
+ target_type, target_id = cast('str', link['type']), cast('int', link['id'])
599
+ if kind == 'entitytype':
600
+ if _type_display(ctx.schema, target_type) != target_type:
601
+ raise MockgunError(message)
602
+ return _Bucket(target_type, target_type, target_type, (target_type, '', 0))
603
+ name = display_name(ctx.store, target_type, target_id)
604
+ if name is None or derived_name(ctx.store, target_type, target_id):
605
+ raise MockgunError(message)
606
+ value = {'type': target_type, 'id': target_id, 'name': name, 'valid': 'valid'}
607
+ return _Bucket((target_type, target_id), name, value, (target_type, collation_key(name), 0))
608
+
609
+
610
+ def _group_order(ctx: _Grouping, option: dict[str, str], buckets: list[_Bucket]) -> list[_Bucket]:
611
+ """Check only sibling groups and reverse non-null order for descending requests."""
612
+ path = option['field']
613
+ data_type = ctx.schema.field_type(ctx.entity_type, path)
614
+ present = [bucket for bucket in buckets if bucket.key is not None]
615
+ names = [cast('str', bucket.name) for bucket in present]
616
+ if data_type == 'entity':
617
+ types = {bucket.order[0] for bucket in present}
618
+ _check_group_types(ctx, path, types)
619
+ if option['type'] == 'exact':
620
+ for target_type in types:
621
+ _check_group_names(
622
+ ctx,
623
+ path,
624
+ [
625
+ cast('str', bucket.name)
626
+ for bucket in present
627
+ if bucket.order[0] == target_type
628
+ ],
629
+ )
630
+ elif data_type == 'multi_entity':
631
+ if len({bucket.order[0] for bucket in present}) > 1:
632
+ raise MockgunError(f'mockgun cannot summarize grouping field {path}: mixed members')
633
+ _check_group_names(ctx, path, names)
634
+ elif data_type == 'text':
635
+ _check_group_names(ctx, path, names)
636
+ elif data_type in {'list', 'status_list'}:
637
+ _check_enum_order(ctx, path, names, data_type)
638
+ return sorted(
639
+ present, key=lambda bucket: bucket.order, reverse=option['direction'] == 'desc'
640
+ ) + [bucket for bucket in buckets if bucket.key is None]
641
+
642
+
643
+ def _group_buckets(ctx: _Grouping, rows: list[Row], option: dict[str, str]) -> list[_Bucket]:
644
+ """Partition candidate rows by projected scalar values or individual link members."""
645
+ path, kind = option['field'], option['type']
646
+ data_type = ctx.schema.field_type(ctx.entity_type, path)
647
+ message = (
648
+ f'mockgun cannot summarize grouping option {kind!r} field '
649
+ f'{ctx.entity_type}.{path} {data_type}: bucket is unsettled'
650
+ )
651
+ is_link = data_type in {'entity', 'multi_entity'}
652
+ if is_link:
653
+ if '.' in path:
654
+ raise MockgunError(message)
655
+ _check_hidden_groups(ctx, rows, path, message)
656
+ projected = project_rows(ctx.store, ctx.schema, rows, [path])
657
+ buckets: dict[str | int | float | tuple[str, int] | None, _Bucket] = {}
658
+ targets: set[tuple[str, int]] = set()
659
+ for row, projection in zip(rows, projected, strict=True):
660
+ value = projection.get(path)
661
+ if path not in projection or (
662
+ value is not None
663
+ and ctx.scope.context.session_kind != 'script'
664
+ and (data_type == 'date_time' or kind == 'clustered_date')
665
+ ):
666
+ raise MockgunError(message)
667
+ values = (
668
+ (cast('list[FieldValue]', value) or [None]) if data_type == 'multi_entity' else [value]
669
+ )
670
+ for member in values:
671
+ if is_link and isinstance(member, dict):
672
+ member = cast('dict[str, FieldValue]', member)
673
+ targets.add((cast('str', member['type']), cast('int', member['id'])))
674
+ bucket = _entity_bucket(ctx, member, kind, message)
675
+ else:
676
+ bucket = _scalar_bucket(
677
+ member, data_type, kind, message, today=ctx.scope.context.now.date()
678
+ )
679
+ if data_type == 'multi_entity':
680
+ bucket.value = [] if member is None else [bucket.value]
681
+ existing = buckets.setdefault(bucket.key, bucket)
682
+ if existing.name != bucket.name:
683
+ raise MockgunError(message)
684
+ if not existing.rows or existing.rows[-1] is not row:
685
+ existing.rows.append(row)
686
+ _check_archive_targets(ctx, targets, message)
687
+ return _group_order(ctx, option, list(buckets.values()))
688
+
689
+
690
+ def _group_values(
691
+ ctx: _Grouping, rows: list[Row], options: list[dict[str, str]]
692
+ ) -> list[dict[str, object]]:
693
+ """Calculate each sibling's summaries and recurse only for non-leaf groups."""
694
+ result: list[dict[str, object]] = []
695
+ option = options[0]
696
+ path = option['field']
697
+ try:
698
+ buckets = _group_buckets(ctx, rows, option)
699
+ except MockgunError as error:
700
+ raise MockgunError(
701
+ f'mockgun cannot summarize grouping option {option["type"]!r} field '
702
+ f'{path} {ctx.schema.field_type(ctx.entity_type, path)}: {error}'
703
+ ) from error
704
+ for bucket in buckets:
705
+ group: dict[str, object] = {
706
+ 'group_name': bucket.name,
707
+ 'group_value': bucket.value,
708
+ 'summaries': _summary_values(
709
+ ctx.store, ctx.schema, ctx.entity_type, bucket.rows, ctx.summaries
710
+ ),
711
+ }
712
+ if len(options) > 1:
713
+ group['groups'] = _group_values(ctx, bucket.rows, options[1:])
714
+ result.append(group)
715
+ return result
716
+
717
+
718
+ def _validate_summary_value(
719
+ entry: dict[str, object],
720
+ field: str,
721
+ summary_type: str,
722
+ data_type: str,
723
+ issues: _Issues,
724
+ ) -> None:
725
+ """Check percentage selectors and reject unsupported value keys."""
726
+ if (
727
+ summary_type in {'status_percentage', 'status_percentage_as_float'}
728
+ and data_type == 'status_list'
729
+ ):
730
+ if 'values' in entry:
731
+ issues.faults.append(f'API summarize() unrecognized values key: {entry}')
732
+ if ('value' not in entry and 'values' not in entry) or isinstance(
733
+ entry.get('value'), list
734
+ ):
735
+ issues.faults.append(
736
+ 'API summarize() invalid/missing key value. Percentage-based summaries '
737
+ f'require a valid value key in the summary: {entry}'
738
+ )
739
+ elif 'value' in entry and not isinstance(entry['value'], str):
740
+ issues.errors.append(
741
+ f'summarize summary type {summary_type!r} field {field} {data_type}'
742
+ )
743
+ elif summary_type == 'percentage' and data_type == 'list':
744
+ if 'value' not in entry:
745
+ issues.faults.append(
746
+ 'API summarize() invalid/missing key value. Percentage-based summaries '
747
+ f'require a valid value key in the summary: {entry}'
748
+ )
749
+ elif not isinstance(entry['value'], str):
750
+ issues.errors.append(f'summarize summary type percentage field {field} {data_type}')
751
+ if 'values' in entry:
752
+ issues.errors.append(f'summarize summary type percentage field {field} {data_type}')
753
+ elif 'value' in entry or 'values' in entry:
754
+ issues.errors.append(f'summarize summary type {summary_type!r} field {field} {data_type}')
755
+
756
+
757
+ def _vocabulary(names: tuple[str, ...]) -> str:
758
+ """Render a server vocabulary in its observed order."""
759
+ return '[' + ', '.join(f':{name}' for name in names) + ']'
760
+
761
+
762
+ def _validate_group_option(
763
+ entity_type: str, field: str, grouping_type: str, data_type: str, issues: _Issues
764
+ ) -> None:
765
+ """Check a resolved grouping option against its observed vocabulary."""
766
+ if data_type == 'date' and '.' in field and grouping_type == 'clustered_date':
767
+ issues.errors.append(
768
+ f'summarize grouping option {grouping_type!r} field {field} {data_type}'
769
+ )
770
+ elif data_type == 'date' and '.' not in field and grouping_type not in _DATE_GROUP_TYPES:
771
+ issues.faults.append(
772
+ f"API summarize() grouping option '{grouping_type}' not valid for field "
773
+ f"{entity_type}.{field} with data type 'date'\n"
774
+ f'Valid grouping types:\n{_vocabulary(_DATE_GROUP_TYPES)}'
775
+ )
776
+ elif grouping_type not in _GROUP_TYPES.get(data_type, set()):
777
+ issues.errors.append(
778
+ f'summarize grouping option {grouping_type!r} field {field} {data_type}'
779
+ )
780
+
781
+
782
+ def _validate_grouping(
783
+ schema: SiteSchema,
784
+ entity_type: str,
785
+ entries: list[object],
786
+ issues: _Issues,
787
+ ) -> None:
788
+ """Validate grouping options before selecting rows."""
789
+ seen: set[str] = set()
790
+ for entry in entries:
791
+ if not isinstance(entry, dict):
792
+ issues.errors.append(f'summarize grouping entry {entry!r} has invalid shape')
793
+ continue
794
+ entry = cast('dict[str, object]', entry)
795
+ field = entry.get('field')
796
+ grouping_type = entry.get('type')
797
+ direction = entry.get('direction')
798
+ if not isinstance(field, str) or entry.keys() - {'field', 'type', 'direction'}:
799
+ issues.errors.append(f'summarize grouping entry {entry!r} has invalid shape')
800
+ continue
801
+ if field in seen:
802
+ data_type = _field_type(schema, entity_type, field, issues)
803
+ issues.errors.append(f'summarize grouping field {field} {data_type} repeats')
804
+ seen.add(field)
805
+ if 'type' not in entry:
806
+ issues.faults.append(
807
+ f"API summarize() invalid/missing string 'type' in grouping: {entry}"
808
+ )
809
+ elif not isinstance(grouping_type, str):
810
+ issues.errors.append(
811
+ f'summarize grouping field {field} has invalid type {grouping_type!r}'
812
+ )
813
+ if 'direction' not in entry or (
814
+ isinstance(direction, str) and direction not in {'asc', 'desc'}
815
+ ):
816
+ issues.faults.append(
817
+ f"API summarize() invalid/missing string 'direction' in grouping: {entry}. "
818
+ 'Valid direction values: asc, desc'
819
+ )
820
+ elif not isinstance(direction, str):
821
+ issues.errors.append(
822
+ f'summarize grouping field {field} has invalid direction {direction!r}'
823
+ )
824
+ data_type = _field_type(schema, entity_type, field, issues)
825
+ if data_type is None or not isinstance(grouping_type, str):
826
+ continue
827
+ _validate_group_option(entity_type, field, grouping_type, data_type, issues)
828
+
829
+
830
+ def _validate_summary_type(
831
+ entity_type: str,
832
+ field: str,
833
+ summary_type: str,
834
+ data_type: str,
835
+ issues: _Issues,
836
+ ) -> None:
837
+ """Check the type vocabulary and direct link count faults."""
838
+ if data_type not in _SUMMARY_TYPES:
839
+ issues.errors.append(f'summarize summary type {summary_type!r} field {field} {data_type}')
840
+ elif summary_type not in _SUMMARY_TYPES[data_type]:
841
+ issues.faults.append(
842
+ f"API summarize() summary type '{summary_type}' not valid for field "
843
+ f"{entity_type}.{field} with data type '{data_type}'\n"
844
+ f'Valid summary types:\n{_vocabulary(_SUMMARY_TYPES[data_type])}'
845
+ )
846
+ elif summary_type == 'count' and data_type in {'entity', 'multi_entity'}:
847
+ if '.' in field:
848
+ issues.errors.append(f'summarize summary type count field {field} {data_type}')
849
+ else:
850
+ issues.faults.append(f'Uncaught exception! Summary type not supported: {field} count')
851
+ if data_type == 'calculated' and summary_type != 'record_count':
852
+ issues.errors.append(f'summarize summary type {summary_type!r} field {field} calculated')
853
+
854
+
855
+ def _validate_summaries(
856
+ schema: SiteSchema,
857
+ entity_type: str,
858
+ entries: list[object],
859
+ issues: _Issues,
860
+ ) -> None:
861
+ """Validate every summary entry before selection or value calculation."""
862
+ seen: dict[str, str] = {}
863
+ for entry in entries:
864
+ if not isinstance(entry, dict):
865
+ issues.errors.append(f'summarize summary entry {entry!r} has invalid shape')
866
+ continue
867
+ entry = cast('dict[str, object]', entry)
868
+ field = entry.get('field')
869
+ summary_type = entry.get('type')
870
+ if (
871
+ not isinstance(field, str)
872
+ or not isinstance(summary_type, str)
873
+ or entry.keys() - {'field', 'type', 'value', 'values'}
874
+ ):
875
+ issues.errors.append(f'summarize summary entry {entry!r} has invalid shape')
876
+ continue
877
+ data_type = _field_type(schema, entity_type, field, issues)
878
+ if data_type is None:
879
+ continue
880
+ _validate_summary_type(entity_type, field, summary_type, data_type, issues)
881
+ _validate_summary_value(entry, field, summary_type, data_type, issues)
882
+ if field in seen:
883
+ if seen[field] == summary_type:
884
+ issues.errors.append(
885
+ f'summarize summary type {summary_type!r} field {field} {data_type}'
886
+ )
887
+ else:
888
+ issues.faults.append(
889
+ f'API summarize() field {entity_type}.{field} has more than one summary type'
890
+ )
891
+ seen[field] = summary_type
892
+
893
+
894
+ def summarize(
895
+ store: Store, schema: SiteSchema, params: Mapping[str, object], *, context: RequestContext
896
+ ) -> dict[str, object]:
897
+ """Calculate supported summaries and nested groups over selected rows.
898
+
899
+ Args:
900
+ store: Records to select and summarize.
901
+ schema: Entity and field definitions used to validate the request.
902
+ params: Wire entity type, filter tree, summary entries, and optional
903
+ grouping and archived-project controls.
904
+ context: Request identity and timestamp used during row selection.
905
+
906
+ Returns:
907
+ A mapping with field-keyed `summaries` and nested `groups`; `groups` is
908
+ empty when no grouping was requested.
909
+
910
+ Raises:
911
+ MockgunError: If the request needs summary behavior not yet emulated.
912
+ ServerFaultError: If supported validation or row selection produces a
913
+ server fault.
914
+ """
915
+ if params.keys() - {'type', 'filters', 'summaries', 'grouping', 'include_archived_projects'}:
916
+ raise MockgunError('mockgun cannot summarize unknown parameters')
917
+ entity_type = params.get('type')
918
+ tree = params.get('filters')
919
+ entries = params.get('summaries')
920
+ archived = params.get('include_archived_projects', True)
921
+ grouping = params.get('grouping')
922
+ if (
923
+ not isinstance(entity_type, str)
924
+ or not schema.declares(entity_type)
925
+ or not isinstance(tree, dict)
926
+ or not isinstance(entries, list)
927
+ or not isinstance(archived, bool)
928
+ or not entries
929
+ or (
930
+ 'grouping' in params
931
+ and (
932
+ not isinstance(grouping, list)
933
+ or not 1 <= len(cast('list[object]', grouping)) <= _MAX_GROUP_LEVELS
934
+ )
935
+ )
936
+ ):
937
+ raise MockgunError('mockgun cannot summarize this request yet')
938
+
939
+ issues = _Issues()
940
+ _validate_summaries(schema, entity_type, cast('list[object]', entries), issues)
941
+ if isinstance(grouping, list):
942
+ _validate_grouping(schema, entity_type, cast('list[object]', grouping), issues)
943
+ if issues.errors:
944
+ raise MockgunError(
945
+ 'mockgun cannot summarize: ' + '; '.join([*issues.errors, *issues.faults])
946
+ )
947
+ if len(issues.faults) > 1:
948
+ raise MockgunError('mockgun cannot summarize: ' + '; '.join(issues.faults))
949
+ scope = ReadScope(retired=False, context=context, include_archived_projects=archived)
950
+ if issues.faults:
951
+ _raise_summary_fault(
952
+ store, entity_type, cast('dict[str, object]', tree), scope, issues.faults[0]
953
+ )
954
+ rows = select(store, entity_type, cast('dict[str, object]', tree), [], scope=scope)
955
+ summary_entries = cast('list[dict[str, object]]', entries)
956
+ ctx = _Grouping(store, schema, entity_type, scope, summary_entries)
957
+ return {
958
+ 'summaries': _summary_values(
959
+ store, schema, entity_type, rows, cast('list[dict[str, object]]', entries)
960
+ ),
961
+ 'groups': _group_values(ctx, rows, cast('list[dict[str, str]]', grouping))
962
+ if grouping is not None
963
+ else [],
964
+ }