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,716 @@
|
|
|
1
|
+
"""Data display utilities for verbose error output."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rich import box
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
# Display configuration constants
|
|
10
|
+
DEFAULT_FIELD_VALUE_MAX_LENGTH = 50
|
|
11
|
+
DEFAULT_CONTEXT_SIZE = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _format_nested_path(error_path: list[str | int]) -> str:
|
|
15
|
+
"""Format error path as displayable nested key.
|
|
16
|
+
|
|
17
|
+
Converts paths like ["sources", 0, "confidence"] into "sources[0].confidence".
|
|
18
|
+
|
|
19
|
+
Args
|
|
20
|
+
----
|
|
21
|
+
error_path: List of field names (str) and array indices (int)
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
Formatted path string with dots and brackets
|
|
26
|
+
"""
|
|
27
|
+
parts: list[str] = []
|
|
28
|
+
for element in error_path:
|
|
29
|
+
if isinstance(element, str):
|
|
30
|
+
# Add dot separator before field names (except first, and not right after opening)
|
|
31
|
+
if parts and not parts[-1].endswith("]"):
|
|
32
|
+
parts.append(".")
|
|
33
|
+
elif parts and parts[-1].endswith("]"):
|
|
34
|
+
# Add dot after array index before field name
|
|
35
|
+
parts.append(".")
|
|
36
|
+
parts.append(element)
|
|
37
|
+
elif isinstance(element, int):
|
|
38
|
+
# Add array index in brackets
|
|
39
|
+
parts.append(f"[{element}]")
|
|
40
|
+
return "".join(parts)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _has_nested_context(error_path: list[str | int]) -> bool:
|
|
44
|
+
"""Check if error path represents nested context.
|
|
45
|
+
|
|
46
|
+
Returns True if the path has multiple fields or contains array indices.
|
|
47
|
+
|
|
48
|
+
Args
|
|
49
|
+
----
|
|
50
|
+
error_path: Path to error field
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
True if path is nested (multiple fields or has array index)
|
|
55
|
+
"""
|
|
56
|
+
string_elements = sum(1 for e in error_path if isinstance(e, str))
|
|
57
|
+
has_array_index = any(isinstance(e, int) for e in error_path)
|
|
58
|
+
return string_elements > 1 or has_array_index
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_feature_data(
|
|
62
|
+
data: dict[str, Any] | list[Any] | Any, # noqa: ANN401
|
|
63
|
+
item_index: int | None,
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
"""Extract and flatten a feature from various input formats.
|
|
66
|
+
|
|
67
|
+
Handles single features, lists of features, and GeoJSON FeatureCollections.
|
|
68
|
+
Flattens GeoJSON format (properties nested under 'properties' key) to
|
|
69
|
+
flat format (properties at top level).
|
|
70
|
+
|
|
71
|
+
Args
|
|
72
|
+
----
|
|
73
|
+
data: Input data (dict, list, or FeatureCollection)
|
|
74
|
+
item_index: Index of item in list/collection, or None for single feature
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
Flattened feature dict, or empty dict if extraction fails
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
# Handle list of features
|
|
82
|
+
if isinstance(data, list):
|
|
83
|
+
if item_index is None or item_index < 0 or item_index >= len(data):
|
|
84
|
+
return {}
|
|
85
|
+
feature = data[item_index]
|
|
86
|
+
# Handle FeatureCollection
|
|
87
|
+
elif isinstance(data, dict) and data.get("type") == "FeatureCollection":
|
|
88
|
+
features = data.get("features", [])
|
|
89
|
+
if item_index is None or item_index < 0 or item_index >= len(features):
|
|
90
|
+
return {}
|
|
91
|
+
feature = features[item_index]
|
|
92
|
+
# Handle single feature
|
|
93
|
+
elif isinstance(data, dict):
|
|
94
|
+
feature = data
|
|
95
|
+
else:
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
# Flatten properties if in GeoJSON format
|
|
99
|
+
if isinstance(feature, dict) and "properties" in feature:
|
|
100
|
+
# Start with top-level fields (id, geometry, etc.)
|
|
101
|
+
flattened = {}
|
|
102
|
+
if "id" in feature:
|
|
103
|
+
flattened["id"] = feature["id"]
|
|
104
|
+
if "geometry" in feature:
|
|
105
|
+
flattened["geometry"] = feature["geometry"]
|
|
106
|
+
|
|
107
|
+
# Add properties at top level
|
|
108
|
+
properties = feature.get("properties", {})
|
|
109
|
+
if isinstance(properties, dict):
|
|
110
|
+
flattened.update(properties)
|
|
111
|
+
|
|
112
|
+
return flattened
|
|
113
|
+
elif isinstance(feature, dict):
|
|
114
|
+
# Already flat format
|
|
115
|
+
return feature
|
|
116
|
+
else:
|
|
117
|
+
return {}
|
|
118
|
+
|
|
119
|
+
except (TypeError, KeyError, AttributeError):
|
|
120
|
+
# Handle malformed or unexpected data structures gracefully
|
|
121
|
+
return {}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _select_context_for_array_index_error(
|
|
125
|
+
feature: dict[str, Any],
|
|
126
|
+
error_path: list[str | int],
|
|
127
|
+
context_size: int,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
"""Handle error paths where the target is an array index.
|
|
130
|
+
|
|
131
|
+
When the error path ends with an array index (e.g., ["data_download_url", 0]),
|
|
132
|
+
we can't select "sibling fields" because array items don't have siblings.
|
|
133
|
+
Instead, show the parent array field with context.
|
|
134
|
+
|
|
135
|
+
Args
|
|
136
|
+
----
|
|
137
|
+
feature: Flattened feature dict
|
|
138
|
+
error_path: Path ending with an array index
|
|
139
|
+
context_size: Number of neighboring fields to include
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
Dict of selected fields showing context around the array
|
|
144
|
+
"""
|
|
145
|
+
# Find the path up to the last string field (the array field itself)
|
|
146
|
+
array_field_path: list[str | int] = []
|
|
147
|
+
for element in error_path:
|
|
148
|
+
array_field_path.append(element)
|
|
149
|
+
if isinstance(element, str):
|
|
150
|
+
# Keep going until we hit an int, but remember the last string position
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
# Find the last string element's index
|
|
154
|
+
last_string_idx = -1
|
|
155
|
+
for i, element in enumerate(error_path):
|
|
156
|
+
if isinstance(element, str):
|
|
157
|
+
last_string_idx = i
|
|
158
|
+
|
|
159
|
+
if last_string_idx < 0:
|
|
160
|
+
# No string elements at all - return empty
|
|
161
|
+
return {}
|
|
162
|
+
|
|
163
|
+
# Truncate path to end at the array field (last string element)
|
|
164
|
+
array_path = list(error_path[: last_string_idx + 1])
|
|
165
|
+
|
|
166
|
+
# Navigate to get the array value
|
|
167
|
+
current: Any = feature
|
|
168
|
+
for element in array_path[:-1]: # Navigate to parent of array field
|
|
169
|
+
if isinstance(current, dict) and isinstance(element, str):
|
|
170
|
+
current = current.get(element, {})
|
|
171
|
+
elif isinstance(current, list) and isinstance(element, int):
|
|
172
|
+
if 0 <= element < len(current):
|
|
173
|
+
current = current[element]
|
|
174
|
+
else:
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
# Now current should be the dict containing the array field
|
|
178
|
+
array_field_name = array_path[-1]
|
|
179
|
+
if not isinstance(array_field_name, str):
|
|
180
|
+
return {}
|
|
181
|
+
|
|
182
|
+
# Build the display with nested path notation
|
|
183
|
+
selected: dict[str, Any] = {}
|
|
184
|
+
|
|
185
|
+
# Format the full path including the array index
|
|
186
|
+
full_path_str = _format_nested_path(error_path)
|
|
187
|
+
|
|
188
|
+
if isinstance(current, dict):
|
|
189
|
+
# Get array value and the specific item
|
|
190
|
+
array_value = current.get(array_field_name)
|
|
191
|
+
|
|
192
|
+
# Find the array index in the error path
|
|
193
|
+
array_indices = [
|
|
194
|
+
e for e in error_path[last_string_idx + 1 :] if isinstance(e, int)
|
|
195
|
+
]
|
|
196
|
+
if array_indices and isinstance(array_value, list):
|
|
197
|
+
idx = array_indices[0]
|
|
198
|
+
if 0 <= idx < len(array_value):
|
|
199
|
+
# Show the specific array item
|
|
200
|
+
selected[full_path_str] = array_value[idx]
|
|
201
|
+
else:
|
|
202
|
+
selected[full_path_str] = None
|
|
203
|
+
else:
|
|
204
|
+
# Show the whole array
|
|
205
|
+
selected[full_path_str] = array_value
|
|
206
|
+
|
|
207
|
+
# Add context fields from the parent dict
|
|
208
|
+
if len(array_path) > 1:
|
|
209
|
+
# Navigate to parent to get sibling fields
|
|
210
|
+
parent_path = array_path[:-1]
|
|
211
|
+
prefix = _format_nested_path(parent_path)
|
|
212
|
+
parent_fields = list(current.keys())
|
|
213
|
+
|
|
214
|
+
if array_field_name in parent_fields:
|
|
215
|
+
target_idx = parent_fields.index(array_field_name)
|
|
216
|
+
start = max(0, target_idx - context_size)
|
|
217
|
+
end = min(len(parent_fields), target_idx + context_size + 1)
|
|
218
|
+
|
|
219
|
+
for i in range(start, end):
|
|
220
|
+
field = parent_fields[i]
|
|
221
|
+
if field != array_field_name:
|
|
222
|
+
key = f"{prefix}.{field}" if prefix else field
|
|
223
|
+
selected[key] = current.get(field)
|
|
224
|
+
else:
|
|
225
|
+
# Fallback: just show the path
|
|
226
|
+
selected[full_path_str] = None
|
|
227
|
+
|
|
228
|
+
return selected
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def select_context_fields(
|
|
232
|
+
feature: dict[str, Any],
|
|
233
|
+
error_path: list[str | int],
|
|
234
|
+
context_size: int = DEFAULT_CONTEXT_SIZE,
|
|
235
|
+
pinned_fields: list[str] | None = None,
|
|
236
|
+
) -> dict[str, Any]:
|
|
237
|
+
"""Select relevant fields for display around an error location.
|
|
238
|
+
|
|
239
|
+
Selects the error field plus neighboring fields for context. For nested
|
|
240
|
+
error paths (e.g., ["geometry", "type"] or ["sources", 0, "confidence"]),
|
|
241
|
+
navigates into the structure and selects the specific nested value.
|
|
242
|
+
|
|
243
|
+
Args
|
|
244
|
+
----
|
|
245
|
+
feature: Flattened feature dict
|
|
246
|
+
error_path: Path to error field (may include array indices)
|
|
247
|
+
context_size: Number of neighboring fields to include on each side
|
|
248
|
+
pinned_fields: List of field names to always include (even outside context window)
|
|
249
|
+
|
|
250
|
+
Returns
|
|
251
|
+
-------
|
|
252
|
+
Dict of selected fields with their values
|
|
253
|
+
"""
|
|
254
|
+
if not error_path:
|
|
255
|
+
return {}
|
|
256
|
+
|
|
257
|
+
# Navigate to the value at error_path
|
|
258
|
+
# Special case: don't navigate into geometry (it's treated as opaque)
|
|
259
|
+
current: Any = feature # Type can change as we navigate through nested structures
|
|
260
|
+
navigated_path = []
|
|
261
|
+
successfully_navigated = True
|
|
262
|
+
|
|
263
|
+
for i, element in enumerate(error_path):
|
|
264
|
+
navigated_path.append(element)
|
|
265
|
+
|
|
266
|
+
# Stop navigation if we encounter 'geometry' as a field name
|
|
267
|
+
if isinstance(element, str) and element == "geometry":
|
|
268
|
+
# Include geometry but don't navigate deeper
|
|
269
|
+
if isinstance(current, dict) and element in current:
|
|
270
|
+
current = current[element]
|
|
271
|
+
# Check if there are more elements after geometry
|
|
272
|
+
if i + 1 < len(error_path):
|
|
273
|
+
successfully_navigated = False
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
if isinstance(current, dict) and isinstance(element, str):
|
|
277
|
+
if element in current:
|
|
278
|
+
current = current[element]
|
|
279
|
+
else:
|
|
280
|
+
# Field doesn't exist - mark as None and stop navigating
|
|
281
|
+
current = None
|
|
282
|
+
successfully_navigated = False
|
|
283
|
+
break
|
|
284
|
+
elif isinstance(current, list) and isinstance(element, int):
|
|
285
|
+
if 0 <= element < len(current):
|
|
286
|
+
current = current[element]
|
|
287
|
+
else:
|
|
288
|
+
# Index out of range
|
|
289
|
+
current = None
|
|
290
|
+
successfully_navigated = False
|
|
291
|
+
break
|
|
292
|
+
else:
|
|
293
|
+
# Can't navigate further (e.g., trying to access a field on a non-dict)
|
|
294
|
+
# Keep current at the last successful level
|
|
295
|
+
successfully_navigated = False
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
# Determine if we're dealing with a nested path (has array index or multiple fields)
|
|
299
|
+
has_nested = _has_nested_context(error_path)
|
|
300
|
+
|
|
301
|
+
# If path contains an array index, include the entire array at top level
|
|
302
|
+
has_array_index = any(isinstance(e, int) for e in error_path)
|
|
303
|
+
|
|
304
|
+
# Initialize selected fields dict (populated in different branches below)
|
|
305
|
+
selected: dict[str, Any] = {}
|
|
306
|
+
|
|
307
|
+
# Check if navigation failed only on the last element (missing field case)
|
|
308
|
+
# In this case, we should still generate nested paths
|
|
309
|
+
navigation_failed_on_last = (
|
|
310
|
+
not successfully_navigated
|
|
311
|
+
and len(navigated_path) == len(error_path)
|
|
312
|
+
and current is None
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if has_nested and (successfully_navigated or navigation_failed_on_last):
|
|
316
|
+
# For nested paths like ["sources", 0, "confidence"], show:
|
|
317
|
+
# 1. Top-level context around the parent field (e.g., fields around "sources")
|
|
318
|
+
# 2. Nested context within the parent object (e.g., fields around "confidence" in sources[0])
|
|
319
|
+
|
|
320
|
+
parent_path = error_path[:-1] # All but the last element
|
|
321
|
+
target_field = error_path[-1] # Last element (e.g., "confidence")
|
|
322
|
+
|
|
323
|
+
if not isinstance(target_field, str):
|
|
324
|
+
# Last element is an array index - show context around the parent array
|
|
325
|
+
# Navigate to find the deepest string field to use as context
|
|
326
|
+
return _select_context_for_array_index_error(
|
|
327
|
+
feature, error_path, context_size
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Get top-level field name (first string in path)
|
|
331
|
+
top_level_field = None
|
|
332
|
+
for element in error_path:
|
|
333
|
+
if isinstance(element, str):
|
|
334
|
+
top_level_field = element
|
|
335
|
+
break
|
|
336
|
+
|
|
337
|
+
if not top_level_field:
|
|
338
|
+
return {}
|
|
339
|
+
|
|
340
|
+
# First, add top-level context (fields around the parent field)
|
|
341
|
+
field_names = list(feature.keys())
|
|
342
|
+
if top_level_field in field_names:
|
|
343
|
+
target_index = field_names.index(top_level_field)
|
|
344
|
+
else:
|
|
345
|
+
field_names.append(top_level_field)
|
|
346
|
+
target_index = len(field_names) - 1
|
|
347
|
+
|
|
348
|
+
start_index = max(0, target_index - context_size)
|
|
349
|
+
end_index = min(len(field_names), target_index + context_size + 1)
|
|
350
|
+
|
|
351
|
+
# Add top-level elision marker if needed
|
|
352
|
+
if start_index > 0 and context_size > 0:
|
|
353
|
+
selected["..."] = "..."
|
|
354
|
+
|
|
355
|
+
# Add top-level context fields (but not the nested field itself, we'll add that with nested keys)
|
|
356
|
+
for i in range(start_index, end_index):
|
|
357
|
+
field_name = field_names[i]
|
|
358
|
+
if field_name != top_level_field:
|
|
359
|
+
selected[field_name] = feature.get(field_name)
|
|
360
|
+
|
|
361
|
+
# Add top-level elision marker at end if needed
|
|
362
|
+
if end_index < len(field_names) and context_size > 0:
|
|
363
|
+
selected["... "] = "..."
|
|
364
|
+
|
|
365
|
+
# Now navigate to parent and add nested context
|
|
366
|
+
parent: Any = feature # Type changes as we navigate through nested structures
|
|
367
|
+
for element in parent_path:
|
|
368
|
+
if isinstance(parent, dict) and isinstance(element, str):
|
|
369
|
+
parent = parent.get(element)
|
|
370
|
+
elif isinstance(parent, list) and isinstance(element, int):
|
|
371
|
+
if 0 <= element < len(parent):
|
|
372
|
+
parent = parent[element]
|
|
373
|
+
else:
|
|
374
|
+
return selected
|
|
375
|
+
else:
|
|
376
|
+
return selected
|
|
377
|
+
|
|
378
|
+
# Apply context selection within the parent object
|
|
379
|
+
if not isinstance(parent, dict):
|
|
380
|
+
return selected
|
|
381
|
+
|
|
382
|
+
parent_fields = list(parent.keys())
|
|
383
|
+
if target_field in parent_fields:
|
|
384
|
+
nested_target_index = parent_fields.index(target_field)
|
|
385
|
+
else:
|
|
386
|
+
parent_fields.append(target_field)
|
|
387
|
+
nested_target_index = len(parent_fields) - 1
|
|
388
|
+
|
|
389
|
+
nested_start = max(0, nested_target_index - context_size)
|
|
390
|
+
nested_end = min(len(parent_fields), nested_target_index + context_size + 1)
|
|
391
|
+
|
|
392
|
+
# Build path prefix once for reuse (without trailing dot)
|
|
393
|
+
prefix_str = _format_nested_path(parent_path)
|
|
394
|
+
|
|
395
|
+
# Add nested elision marker at start if needed
|
|
396
|
+
if nested_start > 0 and context_size > 0:
|
|
397
|
+
selected[f"{prefix_str}...."] = (
|
|
398
|
+
"..." # Dot + "..." for nested elision: sources[0]....
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Add nested fields with full paths
|
|
402
|
+
for i in range(nested_start, nested_end):
|
|
403
|
+
field_name = parent_fields[i]
|
|
404
|
+
full_key = f"{prefix_str}.{field_name}"
|
|
405
|
+
selected[full_key] = parent.get(field_name) # parent is verified dict above
|
|
406
|
+
|
|
407
|
+
# Add nested elision marker at end if needed
|
|
408
|
+
if nested_end < len(parent_fields) and context_size > 0:
|
|
409
|
+
selected[f"{prefix_str}.... "] = (
|
|
410
|
+
"..." # Space differentiates from start marker
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
return selected
|
|
414
|
+
|
|
415
|
+
# Get the top-level field name (first string element)
|
|
416
|
+
top_level_field = None
|
|
417
|
+
for element in error_path:
|
|
418
|
+
if isinstance(element, str):
|
|
419
|
+
top_level_field = element
|
|
420
|
+
break
|
|
421
|
+
|
|
422
|
+
if top_level_field is None:
|
|
423
|
+
return {}
|
|
424
|
+
|
|
425
|
+
# Get all field names in order
|
|
426
|
+
field_names = list(feature.keys())
|
|
427
|
+
|
|
428
|
+
# Find the target field
|
|
429
|
+
if top_level_field in field_names:
|
|
430
|
+
target_index = field_names.index(top_level_field)
|
|
431
|
+
else:
|
|
432
|
+
# Field is missing - still include it with None
|
|
433
|
+
field_names.append(top_level_field)
|
|
434
|
+
target_index = len(field_names) - 1
|
|
435
|
+
|
|
436
|
+
# Select fields within context window
|
|
437
|
+
start_index = max(0, target_index - context_size)
|
|
438
|
+
end_index = min(len(field_names), target_index + context_size + 1)
|
|
439
|
+
|
|
440
|
+
# Add "..." marker if fields were elided at the start (only if context_size > 0)
|
|
441
|
+
if start_index > 0 and context_size > 0:
|
|
442
|
+
selected["..."] = "..."
|
|
443
|
+
|
|
444
|
+
for i in range(start_index, end_index):
|
|
445
|
+
field_name = field_names[i]
|
|
446
|
+
if field_name in feature:
|
|
447
|
+
# For the error field with nested path, check if we should show the whole field or navigate
|
|
448
|
+
if field_name == top_level_field and len(error_path) > 1:
|
|
449
|
+
# If path has array index, include entire array/field
|
|
450
|
+
if has_array_index:
|
|
451
|
+
selected[field_name] = feature[field_name]
|
|
452
|
+
# Special case: if field is 'geometry', just show it without nested path
|
|
453
|
+
# since geometry is opaque
|
|
454
|
+
elif field_name == "geometry":
|
|
455
|
+
selected[field_name] = current
|
|
456
|
+
else:
|
|
457
|
+
# Build nested key with array indices: ["sources", 0, "confidence"] -> "sources[0].confidence"
|
|
458
|
+
nested_key = _format_nested_path(error_path)
|
|
459
|
+
selected[nested_key] = current
|
|
460
|
+
else:
|
|
461
|
+
selected[field_name] = feature[field_name]
|
|
462
|
+
else:
|
|
463
|
+
# Missing field - include with None
|
|
464
|
+
selected[field_name] = None
|
|
465
|
+
|
|
466
|
+
# Add "..." marker if fields were elided at the end (only if context_size > 0)
|
|
467
|
+
if end_index < len(field_names) and context_size > 0:
|
|
468
|
+
selected["... "] = (
|
|
469
|
+
"..." # Use "... " (with space) to distinguish from start marker
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Add pinned fields that aren't already in selected
|
|
473
|
+
# We need to maintain field order, so rebuild the dict
|
|
474
|
+
if pinned_fields:
|
|
475
|
+
# Build ordered dict with all fields (selected + pinned) in natural order
|
|
476
|
+
ordered_selected: dict[str, Any] = {}
|
|
477
|
+
|
|
478
|
+
# Add start elision marker
|
|
479
|
+
if "..." in selected:
|
|
480
|
+
ordered_selected["..."] = selected["..."]
|
|
481
|
+
|
|
482
|
+
# Add all fields in feature order (including pinned fields)
|
|
483
|
+
for field_name in field_names:
|
|
484
|
+
# Include if already selected OR if it's a pinned field
|
|
485
|
+
if field_name in selected:
|
|
486
|
+
ordered_selected[field_name] = selected[field_name]
|
|
487
|
+
elif field_name in pinned_fields:
|
|
488
|
+
ordered_selected[field_name] = feature.get(field_name)
|
|
489
|
+
|
|
490
|
+
# Add pinned fields that don't exist in the feature (at end with None)
|
|
491
|
+
for pinned_field in pinned_fields:
|
|
492
|
+
if pinned_field not in field_names and pinned_field not in ordered_selected:
|
|
493
|
+
ordered_selected[pinned_field] = None
|
|
494
|
+
|
|
495
|
+
# Add end elision marker
|
|
496
|
+
if "... " in selected:
|
|
497
|
+
ordered_selected["... "] = selected["... "]
|
|
498
|
+
|
|
499
|
+
return ordered_selected
|
|
500
|
+
|
|
501
|
+
return selected
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def format_field_value(
|
|
505
|
+
value: object,
|
|
506
|
+
max_length: int = DEFAULT_FIELD_VALUE_MAX_LENGTH,
|
|
507
|
+
) -> str:
|
|
508
|
+
"""Format a field value for display.
|
|
509
|
+
|
|
510
|
+
Args
|
|
511
|
+
----
|
|
512
|
+
value: Field value to format
|
|
513
|
+
max_length: Maximum length before truncation
|
|
514
|
+
|
|
515
|
+
Returns
|
|
516
|
+
-------
|
|
517
|
+
Formatted string representation
|
|
518
|
+
"""
|
|
519
|
+
# Handle None (missing fields)
|
|
520
|
+
if value is None:
|
|
521
|
+
return "<missing>"
|
|
522
|
+
|
|
523
|
+
# Handle empty collections
|
|
524
|
+
if value == []:
|
|
525
|
+
return "[]"
|
|
526
|
+
if value == {}:
|
|
527
|
+
return "{}"
|
|
528
|
+
|
|
529
|
+
# Handle geometry objects
|
|
530
|
+
if isinstance(value, dict) and "type" in value:
|
|
531
|
+
geom_type = value.get("type")
|
|
532
|
+
if geom_type in {
|
|
533
|
+
"Point",
|
|
534
|
+
"LineString",
|
|
535
|
+
"Polygon",
|
|
536
|
+
"MultiPoint",
|
|
537
|
+
"MultiLineString",
|
|
538
|
+
"MultiPolygon",
|
|
539
|
+
"GeometryCollection",
|
|
540
|
+
}:
|
|
541
|
+
return str(geom_type)
|
|
542
|
+
|
|
543
|
+
# Handle nested objects - extract key info
|
|
544
|
+
if isinstance(value, dict):
|
|
545
|
+
# Try to extract primary field
|
|
546
|
+
if "primary" in value:
|
|
547
|
+
primary = value["primary"]
|
|
548
|
+
# Quote strings in nested display
|
|
549
|
+
if isinstance(primary, str):
|
|
550
|
+
return f'primary: "{primary}"'
|
|
551
|
+
return f"primary: {primary}"
|
|
552
|
+
# Otherwise show first few keys
|
|
553
|
+
keys = list(value.keys())[:3]
|
|
554
|
+
return "{" + ", ".join(f"{k}: ..." for k in keys) + "}"
|
|
555
|
+
|
|
556
|
+
# Handle arrays
|
|
557
|
+
if isinstance(value, list):
|
|
558
|
+
return f"[...{len(value)} items]"
|
|
559
|
+
|
|
560
|
+
# Handle strings - add quotes
|
|
561
|
+
if isinstance(value, str):
|
|
562
|
+
result = f'"{value}"'
|
|
563
|
+
# Truncate if too long (accounting for quotes)
|
|
564
|
+
if len(result) > max_length + 2:
|
|
565
|
+
return '"' + value[:max_length] + '..."'
|
|
566
|
+
return result
|
|
567
|
+
|
|
568
|
+
# Handle other primitives
|
|
569
|
+
result = str(value)
|
|
570
|
+
|
|
571
|
+
# Truncate if too long
|
|
572
|
+
if len(result) > max_length:
|
|
573
|
+
return result[:max_length] + "..."
|
|
574
|
+
|
|
575
|
+
return result
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def create_feature_display(
|
|
579
|
+
fields: dict[str, Any],
|
|
580
|
+
errors: list[tuple[list[str | int], str]],
|
|
581
|
+
item_index: int | None = None,
|
|
582
|
+
item_type: str | None = None,
|
|
583
|
+
show_fields: list[str] | None = None,
|
|
584
|
+
feature: dict[str, Any] | None = None,
|
|
585
|
+
) -> Panel:
|
|
586
|
+
"""Create a Rich Panel with table displaying feature fields with error annotations.
|
|
587
|
+
|
|
588
|
+
Creates a borderless table with four columns: field names, values, arrows, and errors,
|
|
589
|
+
wrapped in a Panel with rounded borders. All error fields are annotated with
|
|
590
|
+
arrows and error messages, and highlighted in bright red.
|
|
591
|
+
|
|
592
|
+
Args
|
|
593
|
+
----
|
|
594
|
+
fields: Dict of field names to values (in desired display order)
|
|
595
|
+
errors: List of (error_path, error_msg) tuples for all errors in this feature
|
|
596
|
+
item_index: Optional index of item in collection (for panel title)
|
|
597
|
+
item_type: Optional type name to display in panel title (e.g., "Building")
|
|
598
|
+
show_fields: List of field names to display in header
|
|
599
|
+
feature: Full feature dict for extracting show_fields values
|
|
600
|
+
|
|
601
|
+
Returns
|
|
602
|
+
-------
|
|
603
|
+
Rich Panel containing the table, ready to print
|
|
604
|
+
"""
|
|
605
|
+
# Create table without borders (for alignment only)
|
|
606
|
+
table = Table(show_header=False, show_edge=False, box=None, padding=(0, 0, 0, 1))
|
|
607
|
+
|
|
608
|
+
# Add columns: Field (right-aligned) | Value (right-aligned) | Arrow | Error message
|
|
609
|
+
table.add_column("Field", no_wrap=True, justify="right")
|
|
610
|
+
table.add_column("Value", justify="right")
|
|
611
|
+
table.add_column("Arrow", no_wrap=True) # Single arrow column
|
|
612
|
+
table.add_column("Error")
|
|
613
|
+
|
|
614
|
+
# Build mapping from field display name to error messages
|
|
615
|
+
error_map: dict[str, list[str]] = {}
|
|
616
|
+
for error_path, error_msg in errors:
|
|
617
|
+
# Determine which field has the error (use first string in path)
|
|
618
|
+
error_field: str | None = None
|
|
619
|
+
for element in error_path:
|
|
620
|
+
if isinstance(element, str):
|
|
621
|
+
error_field = element
|
|
622
|
+
break
|
|
623
|
+
|
|
624
|
+
# Skip if no field name found (shouldn't happen in practice)
|
|
625
|
+
if error_field is None:
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
# Format nested path for display
|
|
629
|
+
if len(error_path) > 1:
|
|
630
|
+
# Special case: geometry is opaque, don't show nested path
|
|
631
|
+
if error_field == "geometry":
|
|
632
|
+
error_field_display = "geometry"
|
|
633
|
+
else:
|
|
634
|
+
# Handle nested paths like ["sources", 0, "confidence"] -> "sources[0].confidence"
|
|
635
|
+
error_field_display = _format_nested_path(error_path)
|
|
636
|
+
else:
|
|
637
|
+
error_field_display = error_field
|
|
638
|
+
|
|
639
|
+
# Add to error map
|
|
640
|
+
if error_field_display not in error_map:
|
|
641
|
+
error_map[error_field_display] = []
|
|
642
|
+
error_map[error_field_display].append(error_msg)
|
|
643
|
+
|
|
644
|
+
# Add rows for each field
|
|
645
|
+
for field_name, field_value in fields.items():
|
|
646
|
+
# Check if this is an elision marker (handles both simple "..." and nested "sources[0]...")
|
|
647
|
+
is_elision = field_value == "..." and ("..." in field_name)
|
|
648
|
+
|
|
649
|
+
if is_elision:
|
|
650
|
+
# Display dimmed field name with blank value (4 columns now)
|
|
651
|
+
table.add_row(f"[dim]{field_name}[/dim]", "", "", "")
|
|
652
|
+
continue
|
|
653
|
+
|
|
654
|
+
formatted_value = format_field_value(field_value)
|
|
655
|
+
|
|
656
|
+
# Check if this field has errors
|
|
657
|
+
if field_name in error_map:
|
|
658
|
+
# Add error annotation with arrow in separate column
|
|
659
|
+
field_name_styled = f"[bold bright_yellow]{field_name}[/bold bright_yellow]"
|
|
660
|
+
value_styled = f"[bright_red]{formatted_value}[/bright_red]"
|
|
661
|
+
# Join multiple error messages with newlines (without arrows)
|
|
662
|
+
error_messages = error_map[field_name]
|
|
663
|
+
error_text = "\n".join(f"[blue]{msg}[/blue]" for msg in error_messages)
|
|
664
|
+
# Arrow goes in its own column
|
|
665
|
+
arrow = "[cyan]←[/cyan]"
|
|
666
|
+
table.add_row(field_name_styled, value_styled, arrow, error_text)
|
|
667
|
+
else:
|
|
668
|
+
# Apply cyan style to non-error fields, dim the value for context
|
|
669
|
+
field_name_styled = f"[cyan]{field_name}[/cyan]"
|
|
670
|
+
value_styled = f"[dim]{formatted_value}[/dim]"
|
|
671
|
+
table.add_row(field_name_styled, value_styled, "", "")
|
|
672
|
+
|
|
673
|
+
# Wrap table in a Panel with rounded borders
|
|
674
|
+
# Add title: "Validation Failed" for single features, or item info for collections
|
|
675
|
+
if item_index is not None:
|
|
676
|
+
if item_type:
|
|
677
|
+
title = f"[{item_index}] ({item_type})"
|
|
678
|
+
else:
|
|
679
|
+
title = f"[{item_index}]"
|
|
680
|
+
else:
|
|
681
|
+
title = "[bright_red]Validation Failed[/bright_red]"
|
|
682
|
+
|
|
683
|
+
# Add show_fields to title if provided
|
|
684
|
+
if show_fields and feature:
|
|
685
|
+
field_parts = []
|
|
686
|
+
max_field_length = 30 # Truncate long values in header
|
|
687
|
+
for field_name in show_fields:
|
|
688
|
+
field_value = feature.get(field_name)
|
|
689
|
+
if field_value is None:
|
|
690
|
+
formatted = "<missing>"
|
|
691
|
+
elif isinstance(field_value, str):
|
|
692
|
+
# Truncate long strings
|
|
693
|
+
if len(field_value) > max_field_length:
|
|
694
|
+
formatted = field_value[:max_field_length] + "..."
|
|
695
|
+
else:
|
|
696
|
+
formatted = field_value
|
|
697
|
+
else:
|
|
698
|
+
# For non-strings, convert to string and truncate
|
|
699
|
+
str_value = str(field_value)
|
|
700
|
+
if len(str_value) > max_field_length:
|
|
701
|
+
formatted = str_value[:max_field_length] + "..."
|
|
702
|
+
else:
|
|
703
|
+
formatted = str_value
|
|
704
|
+
field_parts.append(f"{field_name}={formatted}")
|
|
705
|
+
|
|
706
|
+
if field_parts:
|
|
707
|
+
title = f"{title} {' '.join(field_parts)}"
|
|
708
|
+
|
|
709
|
+
return Panel(
|
|
710
|
+
table,
|
|
711
|
+
box=box.HORIZONTALS,
|
|
712
|
+
border_style="bright_black",
|
|
713
|
+
expand=True,
|
|
714
|
+
title=title,
|
|
715
|
+
title_align="left",
|
|
716
|
+
)
|