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,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
|
+
}
|