overture-schema-cli 0.1.1.dev0__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.
- overture/schema/cli/__init__.py +29 -0
- overture/schema/cli/__main__.py +6 -0
- overture/schema/cli/commands.py +930 -0
- overture/schema/cli/data_display.py +716 -0
- overture/schema/cli/docstrings.py +21 -0
- overture/schema/cli/error_formatting.py +587 -0
- overture/schema/cli/output.py +29 -0
- overture/schema/cli/py.typed +0 -0
- overture/schema/cli/tag_options.py +69 -0
- overture/schema/cli/type_analysis.py +448 -0
- overture/schema/cli/types.py +17 -0
- overture_schema_cli-0.1.1.dev0.dist-info/METADATA +38 -0
- overture_schema_cli-0.1.1.dev0.dist-info/RECORD +15 -0
- overture_schema_cli-0.1.1.dev0.dist-info/WHEEL +4 -0
- overture_schema_cli-0.1.1.dev0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Docstring utilities for extracting documentation from modules and models."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_theme_module_docstring(theme_name: str) -> str | None:
|
|
9
|
+
"""Get the docstring of a theme module if available."""
|
|
10
|
+
try:
|
|
11
|
+
# Try to import the theme module
|
|
12
|
+
module_name = f"overture.schema.{theme_name}"
|
|
13
|
+
module = __import__(module_name, fromlist=[""])
|
|
14
|
+
return inspect.getdoc(module)
|
|
15
|
+
except (ImportError, AttributeError):
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_model_docstring(model_class: type[BaseModel]) -> str | None:
|
|
20
|
+
"""Get a clean, formatted docstring from a model class."""
|
|
21
|
+
return inspect.getdoc(model_class)
|
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""Error formatting and grouping for validation errors."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from .data_display import (
|
|
9
|
+
create_feature_display,
|
|
10
|
+
extract_feature_data,
|
|
11
|
+
select_context_fields,
|
|
12
|
+
)
|
|
13
|
+
from .type_analysis import (
|
|
14
|
+
StructuralTuple,
|
|
15
|
+
UnionMetadata,
|
|
16
|
+
extract_discriminator_path,
|
|
17
|
+
get_item_index,
|
|
18
|
+
get_or_create_structural_tuple,
|
|
19
|
+
infer_model_from_error,
|
|
20
|
+
)
|
|
21
|
+
from .types import ErrorLocation, ValidationErrorDict
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def group_errors_by_discriminator(
|
|
25
|
+
errors: list[ValidationErrorDict],
|
|
26
|
+
metadata: UnionMetadata,
|
|
27
|
+
structural_cache: dict[ErrorLocation, StructuralTuple] | None = None,
|
|
28
|
+
) -> dict[ErrorLocation, list[ValidationErrorDict]]:
|
|
29
|
+
"""Group validation errors by their discriminator path.
|
|
30
|
+
|
|
31
|
+
Errors are grouped by which model variant they're associated with, as indicated
|
|
32
|
+
by their discriminator path. This allows the CLI to show only errors for the most
|
|
33
|
+
likely intended type, rather than overwhelming the user with errors from all
|
|
34
|
+
possible union variants.
|
|
35
|
+
|
|
36
|
+
Args
|
|
37
|
+
----
|
|
38
|
+
errors: List of Pydantic validation error dicts
|
|
39
|
+
metadata: Pre-computed UnionMetadata from introspect_union()
|
|
40
|
+
structural_cache: Optional cache for structural tuple computation
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
Dictionary mapping discriminator paths to lists of errors
|
|
45
|
+
|
|
46
|
+
Examples
|
|
47
|
+
--------
|
|
48
|
+
>>> from typing import Annotated, Union
|
|
49
|
+
>>> from pydantic import Field
|
|
50
|
+
>>> from overture.schema.buildings import Building, BuildingPart
|
|
51
|
+
>>> from overture.schema.cli.type_analysis import introspect_union
|
|
52
|
+
>>> BuildingUnion = Annotated[
|
|
53
|
+
... Union[Building, BuildingPart],
|
|
54
|
+
... Field(discriminator='type')
|
|
55
|
+
... ]
|
|
56
|
+
>>> # Errors from validating two buildings with different issues
|
|
57
|
+
>>> errors = [
|
|
58
|
+
... {'loc': (0, 'tagged-union[type]', 'building', 'height'), 'msg': 'Field required'},
|
|
59
|
+
... {'loc': (0, 'tagged-union[type]', 'building', 'id'), 'msg': 'Field required'},
|
|
60
|
+
... {'loc': (1, 'tagged-union[type]', 'building', 'geometry'), 'msg': 'Invalid geometry'},
|
|
61
|
+
... ]
|
|
62
|
+
>>> metadata = introspect_union(BuildingUnion)
|
|
63
|
+
>>> groups = group_errors_by_discriminator(errors, metadata)
|
|
64
|
+
>>> list(groups.keys())
|
|
65
|
+
[('building',)]
|
|
66
|
+
>>> len(groups[('building',)])
|
|
67
|
+
3
|
|
68
|
+
|
|
69
|
+
>>> # Errors from ambiguous data matching multiple types
|
|
70
|
+
>>> errors = [
|
|
71
|
+
... {'loc': ('tagged-union[type]', 'building', 'height'), 'msg': 'Field required'},
|
|
72
|
+
... {'loc': ('tagged-union[type]', 'building_part', 'building_id'), 'msg': 'Field required'},
|
|
73
|
+
... ]
|
|
74
|
+
>>> groups = group_errors_by_discriminator(errors, metadata)
|
|
75
|
+
>>> len(groups) # Two groups - one for each potential type
|
|
76
|
+
2
|
|
77
|
+
>>> ('building',) in groups
|
|
78
|
+
True
|
|
79
|
+
>>> ('building_part',) in groups
|
|
80
|
+
True
|
|
81
|
+
"""
|
|
82
|
+
groups: dict[ErrorLocation, list[ValidationErrorDict]] = {}
|
|
83
|
+
|
|
84
|
+
for error in errors:
|
|
85
|
+
loc = error["loc"]
|
|
86
|
+
try:
|
|
87
|
+
structural = get_or_create_structural_tuple(loc, metadata, structural_cache)
|
|
88
|
+
disc_path = extract_discriminator_path(loc, structural)
|
|
89
|
+
if disc_path not in groups:
|
|
90
|
+
groups[disc_path] = []
|
|
91
|
+
groups[disc_path].append(error)
|
|
92
|
+
except (KeyError, TypeError, IndexError):
|
|
93
|
+
# Structural analysis can fail for unexpected error path formats
|
|
94
|
+
# (e.g., new Pydantic union markers). Group under empty path as fallback.
|
|
95
|
+
if () not in groups:
|
|
96
|
+
groups[()] = []
|
|
97
|
+
groups[()].append(error)
|
|
98
|
+
|
|
99
|
+
return groups
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def analyze_collection_heterogeneity(
|
|
103
|
+
errors: list[ValidationErrorDict],
|
|
104
|
+
metadata: UnionMetadata,
|
|
105
|
+
structural_cache: dict[ErrorLocation, StructuralTuple] | None = None,
|
|
106
|
+
) -> tuple[dict[int, type[BaseModel] | None], bool]:
|
|
107
|
+
"""Analyze a collection to detect type heterogeneity.
|
|
108
|
+
|
|
109
|
+
Args
|
|
110
|
+
----
|
|
111
|
+
errors: List of Pydantic validation error dicts
|
|
112
|
+
metadata: Pre-computed UnionMetadata from introspect_union()
|
|
113
|
+
structural_cache: Optional cache for structural tuple computation
|
|
114
|
+
|
|
115
|
+
Returns
|
|
116
|
+
-------
|
|
117
|
+
Tuple of (item_types, is_heterogeneous) where:
|
|
118
|
+
- item_types: Dict mapping item index to inferred model type
|
|
119
|
+
- is_heterogeneous: True if collection contains multiple model types
|
|
120
|
+
"""
|
|
121
|
+
# Group errors by item index
|
|
122
|
+
item_errors: dict[int | None, list[ValidationErrorDict]] = {}
|
|
123
|
+
for error in errors:
|
|
124
|
+
item_idx = get_item_index(error["loc"])
|
|
125
|
+
if item_idx not in item_errors:
|
|
126
|
+
item_errors[item_idx] = []
|
|
127
|
+
item_errors[item_idx].append(error)
|
|
128
|
+
|
|
129
|
+
# Infer the most likely type for each item
|
|
130
|
+
# Use heuristic: type with FEWEST errors is most likely correct
|
|
131
|
+
item_types: dict[int, type[BaseModel] | None] = {}
|
|
132
|
+
for item_idx, item_error_list in item_errors.items():
|
|
133
|
+
if item_idx is None:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# Group this item's errors by inferred type
|
|
137
|
+
errors_by_type: dict[type[BaseModel], list[ValidationErrorDict]] = {}
|
|
138
|
+
for error in item_error_list:
|
|
139
|
+
inferred_type = infer_model_from_error(error, metadata, structural_cache)
|
|
140
|
+
if inferred_type is not None:
|
|
141
|
+
if inferred_type not in errors_by_type:
|
|
142
|
+
errors_by_type[inferred_type] = []
|
|
143
|
+
errors_by_type[inferred_type].append(error)
|
|
144
|
+
|
|
145
|
+
if errors_by_type:
|
|
146
|
+
# Select the type with the FEWEST errors (smallest edit distance)
|
|
147
|
+
item_types[item_idx] = min(
|
|
148
|
+
errors_by_type.keys(), key=lambda t: len(errors_by_type[t])
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
item_types[item_idx] = None
|
|
152
|
+
|
|
153
|
+
# Check if the collection is heterogeneous
|
|
154
|
+
unique_types = {t for t in item_types.values() if t is not None}
|
|
155
|
+
is_heterogeneous = len(unique_types) > 1
|
|
156
|
+
|
|
157
|
+
return item_types, is_heterogeneous
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _suppress_sibling_tag_noise(
|
|
161
|
+
error_groups: dict[ErrorLocation, list[ValidationErrorDict]],
|
|
162
|
+
) -> dict[ErrorLocation, list[ValidationErrorDict]]:
|
|
163
|
+
"""Drop `union_tag_not_found` noise for items that already matched a type.
|
|
164
|
+
|
|
165
|
+
When validating against a union that mixes a tagged-union with other
|
|
166
|
+
(also discriminated) members, an item that cleanly matches one branch still
|
|
167
|
+
produces a `union_tag_not_found` error from every *sibling* branch whose
|
|
168
|
+
discriminator it fails to satisfy. Those errors carry an empty discriminator
|
|
169
|
+
path `()`, forming a spurious group that can tie with the real match and
|
|
170
|
+
trigger a false "Ambiguous" warning.
|
|
171
|
+
|
|
172
|
+
This helper removes such noise for any list item that already has a concrete
|
|
173
|
+
(non-`union_tag_not_found`) error. Items whose *only* errors are
|
|
174
|
+
`union_tag_not_found` are left untouched, so genuinely undiscriminatable
|
|
175
|
+
input is still reported. Groups left empty after filtering are dropped.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
error_groups : dict[ErrorLocation, list[ValidationErrorDict]]
|
|
180
|
+
Mapping of discriminator paths to their error lists.
|
|
181
|
+
|
|
182
|
+
Returns
|
|
183
|
+
-------
|
|
184
|
+
dict[ErrorLocation, list[ValidationErrorDict]]
|
|
185
|
+
A new error-groups dict with sibling-branch tag noise removed.
|
|
186
|
+
|
|
187
|
+
Examples
|
|
188
|
+
--------
|
|
189
|
+
>>> # Item 0 matched 'building' but the sibling 'segment' branch emitted
|
|
190
|
+
>>> # a union_tag_not_found error, creating a spurious () group.
|
|
191
|
+
>>> error_groups = {
|
|
192
|
+
... ('building',): [
|
|
193
|
+
... {'loc': (0, 'tagged-union[type]', 'building', 'id'),
|
|
194
|
+
... 'msg': 'Field required', 'type': 'missing'},
|
|
195
|
+
... ],
|
|
196
|
+
... (): [
|
|
197
|
+
... {'loc': (0, 'tagged-union[subtype]'),
|
|
198
|
+
... 'msg': 'Unable to extract tag', 'type': 'union_tag_not_found'},
|
|
199
|
+
... ],
|
|
200
|
+
... }
|
|
201
|
+
>>> cleaned = _suppress_sibling_tag_noise(error_groups)
|
|
202
|
+
>>> list(cleaned.keys()) # the () noise group is gone
|
|
203
|
+
[('building',)]
|
|
204
|
+
|
|
205
|
+
>>> # An item whose ONLY error is union_tag_not_found is preserved.
|
|
206
|
+
>>> error_groups = {
|
|
207
|
+
... (): [
|
|
208
|
+
... {'loc': (2, 'tagged-union[type]'),
|
|
209
|
+
... 'msg': 'Unable to extract tag', 'type': 'union_tag_not_found'},
|
|
210
|
+
... ],
|
|
211
|
+
... }
|
|
212
|
+
>>> _suppress_sibling_tag_noise(error_groups) == error_groups
|
|
213
|
+
True
|
|
214
|
+
"""
|
|
215
|
+
# Identify which items have at least one concrete (non tag-not-found) error.
|
|
216
|
+
items_with_concrete_error: set[int | None] = set()
|
|
217
|
+
for errors in error_groups.values():
|
|
218
|
+
for error in errors:
|
|
219
|
+
if error.get("type") != "union_tag_not_found":
|
|
220
|
+
items_with_concrete_error.add(get_item_index(error["loc"]))
|
|
221
|
+
|
|
222
|
+
cleaned: dict[ErrorLocation, list[ValidationErrorDict]] = {}
|
|
223
|
+
for disc_path, errors in error_groups.items():
|
|
224
|
+
kept = [
|
|
225
|
+
error
|
|
226
|
+
for error in errors
|
|
227
|
+
if not (
|
|
228
|
+
error.get("type") == "union_tag_not_found"
|
|
229
|
+
and get_item_index(error["loc"]) in items_with_concrete_error
|
|
230
|
+
)
|
|
231
|
+
]
|
|
232
|
+
if kept:
|
|
233
|
+
cleaned[disc_path] = kept
|
|
234
|
+
|
|
235
|
+
return cleaned
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def select_most_likely_errors(
|
|
239
|
+
error_groups: dict[ErrorLocation, list[ValidationErrorDict]],
|
|
240
|
+
metadata: UnionMetadata | None = None,
|
|
241
|
+
all_errors: list[ValidationErrorDict] | None = None,
|
|
242
|
+
structural_cache: dict[ErrorLocation, StructuralTuple] | None = None,
|
|
243
|
+
) -> tuple[list[ValidationErrorDict], bool, bool, dict[int, type[BaseModel] | None]]:
|
|
244
|
+
"""Select the error group(s) most likely to be the intended model.
|
|
245
|
+
|
|
246
|
+
Uses heuristic: the group with the fewest errors is most likely correct,
|
|
247
|
+
as it requires the fewest changes to make the data valid.
|
|
248
|
+
|
|
249
|
+
When multiple groups have the same minimum error count (a tie), returns
|
|
250
|
+
all tied groups to indicate ambiguity to the user.
|
|
251
|
+
|
|
252
|
+
Before tie detection, sibling-branch `union_tag_not_found` noise is
|
|
253
|
+
suppressed for any item that already matched a concrete type (see
|
|
254
|
+
`_suppress_sibling_tag_noise`), so a clean match is not falsely
|
|
255
|
+
reported as ambiguous. Items whose only errors are `union_tag_not_found`
|
|
256
|
+
are left intact.
|
|
257
|
+
|
|
258
|
+
For heterogeneous collections, returns ALL errors since different items
|
|
259
|
+
may have different intended types.
|
|
260
|
+
|
|
261
|
+
Args
|
|
262
|
+
----
|
|
263
|
+
error_groups: Dictionary mapping discriminator paths to error lists
|
|
264
|
+
metadata: Optional UnionMetadata for heterogeneity detection
|
|
265
|
+
all_errors: Optional list of all errors for heterogeneity analysis
|
|
266
|
+
structural_cache: Optional cache for structural tuple computation
|
|
267
|
+
|
|
268
|
+
Returns
|
|
269
|
+
-------
|
|
270
|
+
Tuple of (errors_list, is_tied, is_heterogeneous, item_types) where:
|
|
271
|
+
- errors_list: List of errors to display
|
|
272
|
+
- is_tied: True if multiple groups had the same minimum error count
|
|
273
|
+
- is_heterogeneous: True if collection contains multiple model types
|
|
274
|
+
- item_types: Dict mapping item index to inferred model type
|
|
275
|
+
"""
|
|
276
|
+
if not error_groups:
|
|
277
|
+
return [], False, False, {}
|
|
278
|
+
|
|
279
|
+
# Check for heterogeneous collections
|
|
280
|
+
is_heterogeneous = False
|
|
281
|
+
_item_types: dict[int, type[BaseModel] | None] = {}
|
|
282
|
+
if metadata is not None and all_errors is not None:
|
|
283
|
+
_item_types, is_heterogeneous = analyze_collection_heterogeneity(
|
|
284
|
+
all_errors, metadata, structural_cache
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# For heterogeneous collections, return only errors matching each item's inferred type
|
|
288
|
+
if is_heterogeneous and all_errors is not None:
|
|
289
|
+
filtered_errors = []
|
|
290
|
+
for error in all_errors:
|
|
291
|
+
item_idx = get_item_index(error["loc"])
|
|
292
|
+
if item_idx is not None and item_idx in _item_types:
|
|
293
|
+
# Only include this error if it matches the inferred type for this item
|
|
294
|
+
if metadata is not None:
|
|
295
|
+
error_type = infer_model_from_error(
|
|
296
|
+
error, metadata, structural_cache
|
|
297
|
+
)
|
|
298
|
+
if error_type == _item_types[item_idx]:
|
|
299
|
+
filtered_errors.append(error)
|
|
300
|
+
else:
|
|
301
|
+
# Non-list errors or items without inferred type - include them
|
|
302
|
+
filtered_errors.append(error)
|
|
303
|
+
|
|
304
|
+
return filtered_errors, False, True, _item_types
|
|
305
|
+
|
|
306
|
+
# Suppress sibling-branch discriminator noise before tie detection.
|
|
307
|
+
error_groups = _suppress_sibling_tag_noise(error_groups)
|
|
308
|
+
if not error_groups:
|
|
309
|
+
return [], False, is_heterogeneous, _item_types
|
|
310
|
+
|
|
311
|
+
# Find the minimum error count
|
|
312
|
+
min_error_count = min(len(errors) for errors in error_groups.values())
|
|
313
|
+
|
|
314
|
+
# Get all groups with the minimum error count
|
|
315
|
+
tied_groups = [
|
|
316
|
+
errors for errors in error_groups.values() if len(errors) == min_error_count
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
# Flatten all errors from tied groups
|
|
320
|
+
flattened_errors = [error for group in tied_groups for error in group]
|
|
321
|
+
|
|
322
|
+
# Indicate if there was a tie
|
|
323
|
+
is_tied = len(tied_groups) > 1
|
|
324
|
+
|
|
325
|
+
return flattened_errors, is_tied, is_heterogeneous, _item_types
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def format_path(filtered_loc: list[str | int]) -> str:
|
|
329
|
+
"""Convert filtered location path to dot-separated string.
|
|
330
|
+
|
|
331
|
+
Args
|
|
332
|
+
----
|
|
333
|
+
filtered_loc: List of path components (strings and integers)
|
|
334
|
+
|
|
335
|
+
Returns
|
|
336
|
+
-------
|
|
337
|
+
Formatted path string (e.g., "properties.name" or "items[0].value")
|
|
338
|
+
"""
|
|
339
|
+
path_str = ""
|
|
340
|
+
for i, part in enumerate(filtered_loc):
|
|
341
|
+
if isinstance(part, str):
|
|
342
|
+
if i > 0:
|
|
343
|
+
path_str += "."
|
|
344
|
+
path_str += part
|
|
345
|
+
else:
|
|
346
|
+
path_str += f"[{part}]"
|
|
347
|
+
|
|
348
|
+
if not path_str:
|
|
349
|
+
path_str = "(root)"
|
|
350
|
+
|
|
351
|
+
return path_str
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def format_validation_errors_verbose(
|
|
355
|
+
errors: list[ValidationErrorDict],
|
|
356
|
+
console: Console,
|
|
357
|
+
metadata: UnionMetadata | None = None,
|
|
358
|
+
item_type: type[BaseModel] | None = None,
|
|
359
|
+
structural_cache: dict[ErrorLocation, StructuralTuple] | None = None,
|
|
360
|
+
original_data: dict[str, Any] | list[Any] | None = None,
|
|
361
|
+
item_index: int | None = None,
|
|
362
|
+
show_fields: list[str] | None = None,
|
|
363
|
+
) -> bool:
|
|
364
|
+
"""Format and display multiple validation errors for a single item in verbose mode.
|
|
365
|
+
|
|
366
|
+
Args
|
|
367
|
+
----
|
|
368
|
+
errors: List of validation errors for this item
|
|
369
|
+
console: Rich Console instance for output
|
|
370
|
+
metadata: Pre-computed UnionMetadata from introspect_union() (optional)
|
|
371
|
+
item_type: The inferred type for this item
|
|
372
|
+
structural_cache: Optional cache for structural tuple computation
|
|
373
|
+
original_data: Original input data for extracting feature details
|
|
374
|
+
item_index: Index of item in collection
|
|
375
|
+
show_fields: List of field names to display alongside errors
|
|
376
|
+
|
|
377
|
+
Returns
|
|
378
|
+
-------
|
|
379
|
+
True if errors were displayed, False otherwise
|
|
380
|
+
"""
|
|
381
|
+
if not errors or not original_data:
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
# Extract item index from first error if not provided
|
|
385
|
+
if item_index is None:
|
|
386
|
+
item_index = get_item_index(errors[0]["loc"])
|
|
387
|
+
|
|
388
|
+
# Extract flattened feature data
|
|
389
|
+
feature = extract_feature_data(original_data, item_index)
|
|
390
|
+
if not feature:
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
# Collect all error paths and messages
|
|
394
|
+
error_tuples: list[tuple[list[str | int], str]] = []
|
|
395
|
+
for error in errors:
|
|
396
|
+
loc = error["loc"]
|
|
397
|
+
msg = error["msg"]
|
|
398
|
+
|
|
399
|
+
# Extract actual error message from context if available
|
|
400
|
+
ctx = error.get("ctx", {})
|
|
401
|
+
if "error" in ctx:
|
|
402
|
+
msg = ctx["error"]
|
|
403
|
+
|
|
404
|
+
# Filter loc to remove union markers
|
|
405
|
+
if metadata is not None:
|
|
406
|
+
try:
|
|
407
|
+
structural = get_or_create_structural_tuple(
|
|
408
|
+
loc, metadata, structural_cache
|
|
409
|
+
)
|
|
410
|
+
filtered_loc = [
|
|
411
|
+
element
|
|
412
|
+
for element, struct_type in zip(loc, structural, strict=False)
|
|
413
|
+
if struct_type in ("list_index", "field")
|
|
414
|
+
]
|
|
415
|
+
except (KeyError, TypeError, IndexError):
|
|
416
|
+
# Fall back to unfiltered path on unexpected error formats
|
|
417
|
+
filtered_loc = list(loc)
|
|
418
|
+
else:
|
|
419
|
+
filtered_loc = list(loc)
|
|
420
|
+
|
|
421
|
+
# Strip out the list index since we've already extracted that feature
|
|
422
|
+
error_path = list(filtered_loc)
|
|
423
|
+
if error_path and isinstance(error_path[0], int):
|
|
424
|
+
error_path = error_path[1:]
|
|
425
|
+
|
|
426
|
+
error_tuples.append((error_path, msg))
|
|
427
|
+
|
|
428
|
+
# Select context fields for all errors
|
|
429
|
+
# Merge context from all error paths
|
|
430
|
+
context_size = 1
|
|
431
|
+
selected_fields: dict[str, Any] = {}
|
|
432
|
+
|
|
433
|
+
for error_path, _ in error_tuples:
|
|
434
|
+
context = select_context_fields(
|
|
435
|
+
feature, error_path, context_size=context_size, pinned_fields=show_fields
|
|
436
|
+
)
|
|
437
|
+
selected_fields.update(context)
|
|
438
|
+
|
|
439
|
+
if selected_fields:
|
|
440
|
+
# Create and display panel with all errors
|
|
441
|
+
# Get type name from item_type if available
|
|
442
|
+
type_name = item_type.__name__ if item_type else None
|
|
443
|
+
panel = create_feature_display(
|
|
444
|
+
selected_fields,
|
|
445
|
+
error_tuples,
|
|
446
|
+
item_index=item_index,
|
|
447
|
+
item_type=type_name,
|
|
448
|
+
show_fields=show_fields,
|
|
449
|
+
feature=feature,
|
|
450
|
+
)
|
|
451
|
+
console.print(panel)
|
|
452
|
+
console.print()
|
|
453
|
+
return True
|
|
454
|
+
else:
|
|
455
|
+
# No fields to display (e.g., root-level discriminator errors)
|
|
456
|
+
return False
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def format_validation_error(
|
|
460
|
+
error: ValidationErrorDict,
|
|
461
|
+
console: Console,
|
|
462
|
+
metadata: UnionMetadata | None = None,
|
|
463
|
+
show_model_hint: bool = False,
|
|
464
|
+
item_type: type[BaseModel] | None = None,
|
|
465
|
+
show_item_type: bool = False,
|
|
466
|
+
structural_cache: dict[ErrorLocation, StructuralTuple] | None = None,
|
|
467
|
+
original_data: dict[str, Any] | list[Any] | None = None,
|
|
468
|
+
show_feature_data: bool = False,
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Format and print a single validation error.
|
|
471
|
+
|
|
472
|
+
Args
|
|
473
|
+
----
|
|
474
|
+
error: Pydantic validation error dict
|
|
475
|
+
console: Rich Console instance for output
|
|
476
|
+
metadata: Pre-computed UnionMetadata from introspect_union() (optional)
|
|
477
|
+
show_model_hint: Show which model was selected for validation (first error only)
|
|
478
|
+
item_type: The inferred type for this item (always provided if available)
|
|
479
|
+
show_item_type: Whether to display the item type in the path (True for heterogeneous collections)
|
|
480
|
+
structural_cache: Optional cache for structural tuple computation
|
|
481
|
+
original_data: Original input data for extracting feature details (optional)
|
|
482
|
+
show_feature_data: Whether to display feature data with error (verbose mode)
|
|
483
|
+
"""
|
|
484
|
+
loc = error["loc"]
|
|
485
|
+
|
|
486
|
+
# Determine which model was selected for this error
|
|
487
|
+
selected_model = None
|
|
488
|
+
if metadata is not None and show_model_hint:
|
|
489
|
+
try:
|
|
490
|
+
structural = get_or_create_structural_tuple(loc, metadata, structural_cache)
|
|
491
|
+
|
|
492
|
+
# Look for discriminator value in the location path
|
|
493
|
+
for element, struct_type in zip(loc, structural, strict=False):
|
|
494
|
+
if struct_type == "discriminator" and isinstance(element, str):
|
|
495
|
+
selected_model = metadata.discriminator_to_model.get(element)
|
|
496
|
+
break
|
|
497
|
+
elif struct_type == "model" and isinstance(element, str):
|
|
498
|
+
selected_model = metadata.model_name_to_model.get(element)
|
|
499
|
+
break
|
|
500
|
+
except (KeyError, TypeError, IndexError):
|
|
501
|
+
# Structural analysis can fail for unexpected error path formats
|
|
502
|
+
pass
|
|
503
|
+
|
|
504
|
+
# Filter out union markers from the path using structural analysis
|
|
505
|
+
if metadata is not None:
|
|
506
|
+
try:
|
|
507
|
+
structural = get_or_create_structural_tuple(loc, metadata, structural_cache)
|
|
508
|
+
# Filter out 'union', 'model', and 'discriminator' markers
|
|
509
|
+
# Keep only 'list_index' and 'field' elements for display
|
|
510
|
+
filtered_loc = [
|
|
511
|
+
element
|
|
512
|
+
for element, struct_type in zip(loc, structural, strict=False)
|
|
513
|
+
if struct_type in ("list_index", "field")
|
|
514
|
+
]
|
|
515
|
+
except (KeyError, TypeError, IndexError):
|
|
516
|
+
# Fall back to original loc if structural analysis fails
|
|
517
|
+
filtered_loc = list(loc)
|
|
518
|
+
else:
|
|
519
|
+
filtered_loc = list(loc)
|
|
520
|
+
|
|
521
|
+
# Convert to dot-separated path
|
|
522
|
+
path_str = format_path(filtered_loc)
|
|
523
|
+
|
|
524
|
+
# Add item type annotation if requested (for heterogeneous collections)
|
|
525
|
+
if show_item_type and item_type is not None:
|
|
526
|
+
path_str = f"{path_str} [dim]({item_type.__name__})[/dim]"
|
|
527
|
+
|
|
528
|
+
# Show model hint if this is the first error in a group
|
|
529
|
+
if selected_model is not None:
|
|
530
|
+
model_name = selected_model.__name__
|
|
531
|
+
console.print(f" [dim]Probable type:[/dim] {model_name}", style="blue")
|
|
532
|
+
console.print()
|
|
533
|
+
|
|
534
|
+
# Format the error message
|
|
535
|
+
msg = error["msg"]
|
|
536
|
+
input_value = error.get("input")
|
|
537
|
+
|
|
538
|
+
ctx = error.get("ctx", {})
|
|
539
|
+
if "error" in ctx:
|
|
540
|
+
msg = ctx["error"]
|
|
541
|
+
input_value = None
|
|
542
|
+
|
|
543
|
+
# Skip error summary lines in verbose mode
|
|
544
|
+
if not show_feature_data:
|
|
545
|
+
console.print(f" {path_str}", style="cyan")
|
|
546
|
+
console.print(f" → {msg}", style="yellow")
|
|
547
|
+
|
|
548
|
+
# Show input value if present and not too large
|
|
549
|
+
if input_value is not None:
|
|
550
|
+
value_str = (
|
|
551
|
+
repr(input_value)
|
|
552
|
+
if not isinstance(input_value, str)
|
|
553
|
+
else f"'{input_value}'"
|
|
554
|
+
)
|
|
555
|
+
prefix = " → Got: "
|
|
556
|
+
if len(value_str) <= console.width - len(prefix):
|
|
557
|
+
console.print(f"{prefix}{value_str}", style="dim")
|
|
558
|
+
console.print()
|
|
559
|
+
|
|
560
|
+
# Show feature data in verbose mode
|
|
561
|
+
if show_feature_data and original_data is not None:
|
|
562
|
+
# Extract item index from error location
|
|
563
|
+
item_index = get_item_index(loc)
|
|
564
|
+
|
|
565
|
+
# Extract flattened feature data
|
|
566
|
+
feature = extract_feature_data(original_data, item_index)
|
|
567
|
+
|
|
568
|
+
if feature:
|
|
569
|
+
# Convert filtered_loc to error path format (list of str/int)
|
|
570
|
+
# Strip out the list index since we've already extracted that feature
|
|
571
|
+
error_path = list(filtered_loc)
|
|
572
|
+
if error_path and isinstance(error_path[0], int):
|
|
573
|
+
error_path = error_path[1:] # Remove list index
|
|
574
|
+
|
|
575
|
+
# Select context fields
|
|
576
|
+
selected_fields = select_context_fields(feature, error_path, context_size=1)
|
|
577
|
+
|
|
578
|
+
if selected_fields:
|
|
579
|
+
# Create and display panel (with item index for title)
|
|
580
|
+
panel = create_feature_display(
|
|
581
|
+
selected_fields,
|
|
582
|
+
[(error_path, msg)],
|
|
583
|
+
item_index=item_index,
|
|
584
|
+
)
|
|
585
|
+
# Print panel directly (it has its own borders, no extra indentation needed)
|
|
586
|
+
console.print(panel)
|
|
587
|
+
console.print()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Rich console output utilities."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def rewrap(text: str, console: Console, indent: int = 0, padding_right: int = 0) -> str:
|
|
8
|
+
"""Unwrap and re-wrap text at console width with indentation.
|
|
9
|
+
|
|
10
|
+
Args
|
|
11
|
+
----
|
|
12
|
+
text : str
|
|
13
|
+
The text to rewrap
|
|
14
|
+
console : Console
|
|
15
|
+
Rich Console instance for width and wrapping
|
|
16
|
+
indent : int
|
|
17
|
+
Number of spaces to indent (default: 0)
|
|
18
|
+
padding_right : int
|
|
19
|
+
Right padding to subtract from width (default: 0)
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
-------
|
|
23
|
+
str
|
|
24
|
+
Re-wrapped and indented text
|
|
25
|
+
"""
|
|
26
|
+
unwrapped = " ".join(text.split())
|
|
27
|
+
text_obj = Text(unwrapped)
|
|
28
|
+
wrapped_lines = text_obj.wrap(console, console.width - indent - padding_right)
|
|
29
|
+
return "\n".join(f"{' ' * indent}{line}" for line in wrapped_lines)
|
|
File without changes
|