data-morph-gemma 0.1.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.
- data_morph_gemma-0.1.0.dist-info/METADATA +177 -0
- data_morph_gemma-0.1.0.dist-info/RECORD +39 -0
- data_morph_gemma-0.1.0.dist-info/WHEEL +4 -0
- data_morph_gemma-0.1.0.dist-info/entry_points.txt +2 -0
- data_morph_gemma-0.1.0.dist-info/licenses/LICENSE +25 -0
- datamorph/__init__.py +19 -0
- datamorph/cli.py +84 -0
- datamorph/convert.py +146 -0
- datamorph/data/__init__.py +1 -0
- datamorph/data/collect.py +221 -0
- datamorph/data/envelope.py +20 -0
- datamorph/data/generators/__init__.py +1 -0
- datamorph/data/generators/base.py +48 -0
- datamorph/data/generators/uc1_csv_to_json.py +64 -0
- datamorph/data/generators/uc2_json_to_csv.py +59 -0
- datamorph/data/generators/uc3_txt_log_to_csv.py +64 -0
- datamorph/data/generators/uc4_csv_to_txt_report.py +62 -0
- datamorph/data/generators/uc5_schema_migration.py +49 -0
- datamorph/data/sandbox.py +95 -0
- datamorph/data/teacher_script.py +114 -0
- datamorph/evaluation/__init__.py +0 -0
- datamorph/evaluation/metrics.py +264 -0
- datamorph/evaluation/output_cleanup.py +116 -0
- datamorph/evaluation/runner.py +218 -0
- datamorph/evaluation/teacher.py +193 -0
- datamorph/extractor/__init__.py +15 -0
- datamorph/extractor/base.py +26 -0
- datamorph/extractor/csv_extractor.py +515 -0
- datamorph/extractor/json_extractor.py +447 -0
- datamorph/extractor/json_walker.py +217 -0
- datamorph/extractor/sampler.py +68 -0
- datamorph/extractor/txt_extractor.py +199 -0
- datamorph/extractor/warning_rules.py +473 -0
- datamorph/features/__init__.py +1 -0
- datamorph/features/format_pairs.py +57 -0
- datamorph/model.py +63 -0
- datamorph/models/__init__.py +0 -0
- datamorph/models/gemma_mlx.py +163 -0
- datamorph/models/gemma_script_teacher.py +100 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""JSON metadata extractor — file inspection + path walker + envelope.
|
|
2
|
+
|
|
3
|
+
CLI entry point at the bottom of the file. See the spec at
|
|
4
|
+
docs/superpowers/specs/2026-05-20-json-metadata-extractor-design.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .base import MetadataExtractor
|
|
14
|
+
from .json_walker import PathStats, walk
|
|
15
|
+
from .warning_rules import (
|
|
16
|
+
MetadataWarning,
|
|
17
|
+
check_deeply_nested,
|
|
18
|
+
check_heterogeneous_array,
|
|
19
|
+
check_large_array,
|
|
20
|
+
check_likely_date_value,
|
|
21
|
+
check_mixed_type_path,
|
|
22
|
+
check_optional_key,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
|
26
|
+
DEFAULT_MAX_DEPTH_WARN = 6
|
|
27
|
+
DEFAULT_MAX_ARRAY_LEN_WARN = 10_000
|
|
28
|
+
OBJECT_ELEMENT_THRESHOLD = 0.8
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _classify_root(root: Any) -> str:
|
|
32
|
+
"""Return one of 'record_array', 'object', or 'unsupported' (spec §5.4)."""
|
|
33
|
+
if isinstance(root, dict):
|
|
34
|
+
return "object"
|
|
35
|
+
if isinstance(root, list):
|
|
36
|
+
if not root:
|
|
37
|
+
return "unsupported"
|
|
38
|
+
object_count = sum(1 for el in root if isinstance(el, dict))
|
|
39
|
+
if object_count / len(root) >= OBJECT_ELEMENT_THRESHOLD:
|
|
40
|
+
return "record_array"
|
|
41
|
+
return "unsupported"
|
|
42
|
+
return "unsupported"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_dtype(dtypes_seen: frozenset[str]) -> str:
|
|
46
|
+
"""Resolve a path's dtype per spec §5.5.
|
|
47
|
+
|
|
48
|
+
- All non-null contributions of one dtype → that dtype.
|
|
49
|
+
- Multiple non-null dtypes → 'mixed'.
|
|
50
|
+
- Only nulls → 'null'.
|
|
51
|
+
"""
|
|
52
|
+
real = dtypes_seen - {"null"}
|
|
53
|
+
if not real:
|
|
54
|
+
return "null"
|
|
55
|
+
if len(real) == 1:
|
|
56
|
+
return next(iter(real))
|
|
57
|
+
return "mixed"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _sample_record_array(
|
|
61
|
+
root: list[Any],
|
|
62
|
+
head_n: int,
|
|
63
|
+
middle_n: int,
|
|
64
|
+
tail_n: int,
|
|
65
|
+
) -> dict[str, list[Any]]:
|
|
66
|
+
"""Return {head, middle, tail} per spec §5.3. No element is duplicated."""
|
|
67
|
+
n = len(root)
|
|
68
|
+
if n < head_n + middle_n + tail_n:
|
|
69
|
+
return {"head": list(root), "middle": [], "tail": []}
|
|
70
|
+
head = root[:head_n]
|
|
71
|
+
tail = root[n - tail_n:] if tail_n > 0 else []
|
|
72
|
+
if middle_n > 0:
|
|
73
|
+
# Place middle in the center of the available space (between head and tail).
|
|
74
|
+
available_start = head_n
|
|
75
|
+
available_end = n - tail_n
|
|
76
|
+
mid_start = (available_start + available_end - middle_n) // 2
|
|
77
|
+
middle = root[mid_start : mid_start + middle_n]
|
|
78
|
+
else:
|
|
79
|
+
middle = []
|
|
80
|
+
return {"head": head, "middle": middle, "tail": tail}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _render_path(stats: PathStats) -> dict[str, Any]:
|
|
84
|
+
"""Render a PathStats into the envelope's per-path dict (spec §5.2)."""
|
|
85
|
+
dtype = _resolve_dtype(stats.dtypes_seen)
|
|
86
|
+
presence = (
|
|
87
|
+
stats.occurrence_count / stats.denominator if stats.denominator > 0 else 0.0
|
|
88
|
+
)
|
|
89
|
+
entry: dict[str, Any] = {
|
|
90
|
+
"path": stats.path,
|
|
91
|
+
"dtype": dtype,
|
|
92
|
+
"presence": round(presence, 4),
|
|
93
|
+
}
|
|
94
|
+
if dtype not in ("object", "array"):
|
|
95
|
+
entry["sample_values"] = list(stats.sample_values)
|
|
96
|
+
entry["unique_count"] = stats.unique_count
|
|
97
|
+
if dtype in ("integer", "float") and stats.min_value is not None:
|
|
98
|
+
entry["min"] = stats.min_value
|
|
99
|
+
entry["max"] = stats.max_value
|
|
100
|
+
if dtype == "string" and stats.max_length is not None:
|
|
101
|
+
entry["max_length"] = stats.max_length
|
|
102
|
+
return entry
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _minimal_envelope(
|
|
106
|
+
file_path: Path,
|
|
107
|
+
file_size: int,
|
|
108
|
+
warnings_: list[MetadataWarning],
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
"""Return a well-formed but empty envelope used by every short-circuit code."""
|
|
111
|
+
return {
|
|
112
|
+
"format": "json",
|
|
113
|
+
"file_path": str(file_path),
|
|
114
|
+
"file_size_bytes": file_size,
|
|
115
|
+
"encoding": "utf-8",
|
|
116
|
+
"schema_version": MetadataExtractor.SCHEMA_VERSION,
|
|
117
|
+
"schema": {"root_shape": "unsupported", "paths": []},
|
|
118
|
+
"samples": {},
|
|
119
|
+
"warnings": [w.to_dict() for w in warnings_],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _split_path(path: str) -> list[str]:
|
|
124
|
+
"""Split a PathStats path string into a sequence of hops.
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
"" -> []
|
|
128
|
+
"users" -> ["users"]
|
|
129
|
+
"[]" -> ["[]"]
|
|
130
|
+
"[].name" -> ["[]", "name"]
|
|
131
|
+
"users[].id" -> ["users", "[]", "id"]
|
|
132
|
+
"a.b.c" -> ["a", "b", "c"]
|
|
133
|
+
"""
|
|
134
|
+
if not path:
|
|
135
|
+
return []
|
|
136
|
+
out: list[str] = []
|
|
137
|
+
buf = ""
|
|
138
|
+
i = 0
|
|
139
|
+
while i < len(path):
|
|
140
|
+
ch = path[i]
|
|
141
|
+
if ch == "[" and path[i : i + 2] == "[]":
|
|
142
|
+
if buf:
|
|
143
|
+
out.append(buf)
|
|
144
|
+
buf = ""
|
|
145
|
+
out.append("[]")
|
|
146
|
+
i += 2
|
|
147
|
+
if i < len(path) and path[i] == ".":
|
|
148
|
+
i += 1
|
|
149
|
+
elif ch == ".":
|
|
150
|
+
if buf:
|
|
151
|
+
out.append(buf)
|
|
152
|
+
buf = ""
|
|
153
|
+
i += 1
|
|
154
|
+
else:
|
|
155
|
+
buf += ch
|
|
156
|
+
i += 1
|
|
157
|
+
if buf:
|
|
158
|
+
out.append(buf)
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _collect_array_element_signatures(
|
|
163
|
+
root: Any, target_path: str
|
|
164
|
+
) -> tuple[list[frozenset[str]], bool]:
|
|
165
|
+
"""Return (child_key_sets, mixed_element_types_flag) for one array path.
|
|
166
|
+
|
|
167
|
+
Walks `root` along `target_path` (using PathStats notation) and
|
|
168
|
+
collects element key sets at every visit of the array. Used only
|
|
169
|
+
by check_heterogeneous_array — too specialised to live in the walker.
|
|
170
|
+
"""
|
|
171
|
+
parts = _split_path(target_path)
|
|
172
|
+
|
|
173
|
+
key_sets: list[frozenset[str]] = []
|
|
174
|
+
mixed_types = {"object": False, "non_object": False}
|
|
175
|
+
|
|
176
|
+
def descend(node: Any, idx: int) -> None:
|
|
177
|
+
if idx >= len(parts):
|
|
178
|
+
# node is the array itself.
|
|
179
|
+
if not isinstance(node, list):
|
|
180
|
+
return
|
|
181
|
+
for el in node:
|
|
182
|
+
if isinstance(el, dict):
|
|
183
|
+
key_sets.append(frozenset(el.keys()))
|
|
184
|
+
mixed_types["object"] = True
|
|
185
|
+
else:
|
|
186
|
+
mixed_types["non_object"] = True
|
|
187
|
+
return
|
|
188
|
+
part = parts[idx]
|
|
189
|
+
if part == "[]":
|
|
190
|
+
if isinstance(node, list):
|
|
191
|
+
for el in node:
|
|
192
|
+
descend(el, idx + 1)
|
|
193
|
+
else:
|
|
194
|
+
if isinstance(node, dict) and part in node:
|
|
195
|
+
descend(node[part], idx + 1)
|
|
196
|
+
|
|
197
|
+
descend(root, 0)
|
|
198
|
+
mixed = mixed_types["object"] and mixed_types["non_object"]
|
|
199
|
+
return key_sets, mixed
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _collect_warnings(
|
|
203
|
+
path_stats: list[PathStats],
|
|
204
|
+
max_depth: int,
|
|
205
|
+
max_depth_warn: int,
|
|
206
|
+
max_array_len_warn: int,
|
|
207
|
+
root: Any,
|
|
208
|
+
) -> list[MetadataWarning]:
|
|
209
|
+
"""Apply the six pure check_* rules over walker output + scalars."""
|
|
210
|
+
warnings_: list[MetadataWarning] = []
|
|
211
|
+
|
|
212
|
+
# Per-path rules.
|
|
213
|
+
for s in path_stats:
|
|
214
|
+
for fn in (
|
|
215
|
+
check_optional_key,
|
|
216
|
+
check_mixed_type_path,
|
|
217
|
+
check_likely_date_value,
|
|
218
|
+
):
|
|
219
|
+
w = fn(stats=s)
|
|
220
|
+
if w is not None:
|
|
221
|
+
warnings_.append(w)
|
|
222
|
+
w = check_large_array(stats=s, threshold=max_array_len_warn)
|
|
223
|
+
if w is not None:
|
|
224
|
+
warnings_.append(w)
|
|
225
|
+
|
|
226
|
+
# Whole-envelope rule: deeply-nested.
|
|
227
|
+
w = check_deeply_nested(max_depth=max_depth, threshold=max_depth_warn)
|
|
228
|
+
if w is not None:
|
|
229
|
+
warnings_.append(w)
|
|
230
|
+
|
|
231
|
+
# Heterogeneous-array rule needs per-array child-key-sets.
|
|
232
|
+
for s in path_stats:
|
|
233
|
+
if "array" not in s.dtypes_seen:
|
|
234
|
+
continue
|
|
235
|
+
key_sets, mixed_types = _collect_array_element_signatures(root, s.path)
|
|
236
|
+
if not key_sets and not mixed_types:
|
|
237
|
+
continue
|
|
238
|
+
w = check_heterogeneous_array(
|
|
239
|
+
stats=s,
|
|
240
|
+
child_key_sets=key_sets,
|
|
241
|
+
mixed_element_types=mixed_types,
|
|
242
|
+
)
|
|
243
|
+
if w is not None:
|
|
244
|
+
warnings_.append(w)
|
|
245
|
+
|
|
246
|
+
return warnings_
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class JSONExtractor(MetadataExtractor):
|
|
250
|
+
"""Stage 1b — turns a JSON file into the shared metadata envelope.
|
|
251
|
+
|
|
252
|
+
See spec §4 for parameter semantics. All thresholds are configurable
|
|
253
|
+
via the constructor; the defaults match spec §6.2.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(
|
|
257
|
+
self,
|
|
258
|
+
head_n: int = 3,
|
|
259
|
+
middle_n: int = 1,
|
|
260
|
+
tail_n: int = 1,
|
|
261
|
+
sample_values_per_path: int = 3,
|
|
262
|
+
max_file_size_bytes: int = DEFAULT_MAX_FILE_SIZE,
|
|
263
|
+
max_depth_warn: int = DEFAULT_MAX_DEPTH_WARN,
|
|
264
|
+
max_array_len_warn: int = DEFAULT_MAX_ARRAY_LEN_WARN,
|
|
265
|
+
) -> None:
|
|
266
|
+
self.head_n = head_n
|
|
267
|
+
self.middle_n = middle_n
|
|
268
|
+
self.tail_n = tail_n
|
|
269
|
+
self.sample_values_per_path = sample_values_per_path
|
|
270
|
+
self.max_file_size_bytes = max_file_size_bytes
|
|
271
|
+
self.max_depth_warn = max_depth_warn
|
|
272
|
+
self.max_array_len_warn = max_array_len_warn
|
|
273
|
+
|
|
274
|
+
def supports(self, file_path: Path) -> bool:
|
|
275
|
+
return file_path.suffix.lower() == ".json"
|
|
276
|
+
|
|
277
|
+
def extract(self, file_path: Path) -> dict[str, Any]:
|
|
278
|
+
file_size = file_path.stat().st_size
|
|
279
|
+
|
|
280
|
+
# 1. File-size guard (cheapest check, before any I/O beyond stat).
|
|
281
|
+
if file_size > self.max_file_size_bytes:
|
|
282
|
+
return _minimal_envelope(
|
|
283
|
+
file_path,
|
|
284
|
+
file_size,
|
|
285
|
+
[
|
|
286
|
+
MetadataWarning(
|
|
287
|
+
code="FILE_TOO_LARGE",
|
|
288
|
+
severity="error",
|
|
289
|
+
message=(
|
|
290
|
+
f"File size {file_size} bytes exceeds cap "
|
|
291
|
+
f"{self.max_file_size_bytes} bytes."
|
|
292
|
+
),
|
|
293
|
+
context={
|
|
294
|
+
"file_size_bytes": file_size,
|
|
295
|
+
"max_file_size_bytes": self.max_file_size_bytes,
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
],
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# 2. Empty-file guard.
|
|
302
|
+
if file_size == 0:
|
|
303
|
+
return _minimal_envelope(
|
|
304
|
+
file_path,
|
|
305
|
+
file_size,
|
|
306
|
+
[
|
|
307
|
+
MetadataWarning(
|
|
308
|
+
code="EMPTY_FILE",
|
|
309
|
+
severity="error",
|
|
310
|
+
message="File is byte-empty.",
|
|
311
|
+
context={"file_size_bytes": 0},
|
|
312
|
+
)
|
|
313
|
+
],
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# 3. Decode (utf-8-sig handles BOM transparently).
|
|
317
|
+
try:
|
|
318
|
+
text = file_path.read_bytes().decode("utf-8-sig")
|
|
319
|
+
except UnicodeDecodeError as e:
|
|
320
|
+
return _minimal_envelope(
|
|
321
|
+
file_path,
|
|
322
|
+
file_size,
|
|
323
|
+
[
|
|
324
|
+
MetadataWarning(
|
|
325
|
+
code="MALFORMED_JSON",
|
|
326
|
+
severity="error",
|
|
327
|
+
message=f"File is not valid UTF-8: {e}",
|
|
328
|
+
context={"error": str(e)},
|
|
329
|
+
)
|
|
330
|
+
],
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# 4. Parse.
|
|
334
|
+
try:
|
|
335
|
+
root = json.loads(text)
|
|
336
|
+
except json.JSONDecodeError as e:
|
|
337
|
+
return _minimal_envelope(
|
|
338
|
+
file_path,
|
|
339
|
+
file_size,
|
|
340
|
+
[
|
|
341
|
+
MetadataWarning(
|
|
342
|
+
code="MALFORMED_JSON",
|
|
343
|
+
severity="error",
|
|
344
|
+
message=f"JSON parse failed: {e}",
|
|
345
|
+
context={
|
|
346
|
+
"error": str(e),
|
|
347
|
+
"line": e.lineno,
|
|
348
|
+
"column": e.colno,
|
|
349
|
+
},
|
|
350
|
+
)
|
|
351
|
+
],
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# 5. Empty-root guard (parsed [], {}, or null).
|
|
355
|
+
if root in (None, [], {}):
|
|
356
|
+
return _minimal_envelope(
|
|
357
|
+
file_path,
|
|
358
|
+
file_size,
|
|
359
|
+
[
|
|
360
|
+
MetadataWarning(
|
|
361
|
+
code="EMPTY_FILE",
|
|
362
|
+
severity="error",
|
|
363
|
+
message="Parsed root is empty / null.",
|
|
364
|
+
context={"parsed_type": type(root).__name__},
|
|
365
|
+
)
|
|
366
|
+
],
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# 6. Root-shape classification.
|
|
370
|
+
root_shape = _classify_root(root)
|
|
371
|
+
if root_shape == "unsupported":
|
|
372
|
+
return _minimal_envelope(
|
|
373
|
+
file_path,
|
|
374
|
+
file_size,
|
|
375
|
+
[
|
|
376
|
+
MetadataWarning(
|
|
377
|
+
code="ROOT_SHAPE_UNSUPPORTED",
|
|
378
|
+
severity="error",
|
|
379
|
+
message=(
|
|
380
|
+
f"JSON root is {type(root).__name__}; Phase 2 supports "
|
|
381
|
+
f"only object roots and record-array roots (≥80% object elements)."
|
|
382
|
+
),
|
|
383
|
+
context={"observed_root_type": type(root).__name__},
|
|
384
|
+
)
|
|
385
|
+
],
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# 7. Walk the root and render schema.paths.
|
|
389
|
+
path_stats = walk(root, sample_values_per_path=self.sample_values_per_path)
|
|
390
|
+
max_depth = max((s.max_depth_seen for s in path_stats), default=0)
|
|
391
|
+
schema: dict[str, Any] = {
|
|
392
|
+
"root_shape": root_shape,
|
|
393
|
+
"max_depth": max_depth,
|
|
394
|
+
"paths": [_render_path(s) for s in path_stats],
|
|
395
|
+
}
|
|
396
|
+
if root_shape == "record_array":
|
|
397
|
+
schema["root_array_length"] = len(root)
|
|
398
|
+
|
|
399
|
+
# 8. Sample records if root is a record_array.
|
|
400
|
+
if root_shape == "record_array":
|
|
401
|
+
samples = _sample_record_array(
|
|
402
|
+
root, self.head_n, self.middle_n, self.tail_n
|
|
403
|
+
)
|
|
404
|
+
else:
|
|
405
|
+
samples = {}
|
|
406
|
+
|
|
407
|
+
# 9. Apply warning rules over walker output.
|
|
408
|
+
warnings_list = _collect_warnings(
|
|
409
|
+
path_stats=path_stats,
|
|
410
|
+
max_depth=max_depth,
|
|
411
|
+
max_depth_warn=self.max_depth_warn,
|
|
412
|
+
max_array_len_warn=self.max_array_len_warn,
|
|
413
|
+
root=root,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
"format": "json",
|
|
418
|
+
"file_path": str(file_path),
|
|
419
|
+
"file_size_bytes": file_size,
|
|
420
|
+
"encoding": "utf-8",
|
|
421
|
+
"schema_version": MetadataExtractor.SCHEMA_VERSION,
|
|
422
|
+
"schema": schema,
|
|
423
|
+
"samples": samples,
|
|
424
|
+
"warnings": [w.to_dict() for w in warnings_list],
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _main() -> int:
|
|
429
|
+
import argparse
|
|
430
|
+
|
|
431
|
+
parser = argparse.ArgumentParser(
|
|
432
|
+
prog="python -m datamorph.extractor.json_extractor",
|
|
433
|
+
description="Extract metadata envelope from a JSON file.",
|
|
434
|
+
)
|
|
435
|
+
parser.add_argument("file", help="Path to a .json file")
|
|
436
|
+
args = parser.parse_args()
|
|
437
|
+
|
|
438
|
+
env = JSONExtractor().extract(Path(args.file))
|
|
439
|
+
text = json.dumps(env, indent=2, default=str, ensure_ascii=False)
|
|
440
|
+
print(text)
|
|
441
|
+
token_estimate = len(text) // 4
|
|
442
|
+
print(f"# rough token estimate: ~{token_estimate} (chars / 4)")
|
|
443
|
+
return 0
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
if __name__ == "__main__":
|
|
447
|
+
raise SystemExit(_main())
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Pure recursive walker for JSON values.
|
|
2
|
+
|
|
3
|
+
Given a parsed JSON node (the output of json.loads), walks the structure
|
|
4
|
+
and returns a list of PathStats — one entry per unique path observed.
|
|
5
|
+
|
|
6
|
+
The walker is pure: no I/O, no warnings. The extractor consumes the
|
|
7
|
+
PathStats list and derives warnings as a second pass.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class PathStats:
|
|
18
|
+
"""Aggregated facts about a single JSON path.
|
|
19
|
+
|
|
20
|
+
Path notation uses dotted keys with ``[]`` marking each array hop.
|
|
21
|
+
Examples: ``[].id`` (root-array element field), ``users[].name``
|
|
22
|
+
(key inside an object root), ``[].orders[].price`` (nested array).
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
path: Path string.
|
|
26
|
+
dtypes_seen: Set of dtype names observed at this path (e.g.
|
|
27
|
+
``{"integer"}`` or ``{"integer", "string"}`` for mixed).
|
|
28
|
+
``null`` is recorded separately and does NOT promote to mixed.
|
|
29
|
+
occurrence_count: Number of times this path appeared.
|
|
30
|
+
denominator: Number of times this path's parent existed
|
|
31
|
+
(used for presence = occurrence_count / denominator).
|
|
32
|
+
sample_values: First-seen-wins sample of values at this path,
|
|
33
|
+
capped at sample_values_per_path. Empty for container paths.
|
|
34
|
+
max_depth_seen: Depth at which this path lives. Root = 0.
|
|
35
|
+
array_lengths_seen: Lengths of arrays observed at this path.
|
|
36
|
+
Populated only when path ends in ``[]`` (i.e. the path itself
|
|
37
|
+
refers to an array). Empty tuple otherwise.
|
|
38
|
+
max_length: Max string length observed (string paths only).
|
|
39
|
+
min_value: Min numeric value observed (integer/float paths only).
|
|
40
|
+
max_value: Max numeric value observed (integer/float paths only).
|
|
41
|
+
unique_count: Number of distinct values within sample_values
|
|
42
|
+
(bounded by sample_values_per_path).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
path: str
|
|
46
|
+
dtypes_seen: frozenset[str]
|
|
47
|
+
occurrence_count: int
|
|
48
|
+
denominator: int
|
|
49
|
+
sample_values: tuple[Any, ...]
|
|
50
|
+
max_depth_seen: int
|
|
51
|
+
array_lengths_seen: tuple[int, ...]
|
|
52
|
+
max_length: int | None
|
|
53
|
+
min_value: float | int | None
|
|
54
|
+
max_value: float | int | None
|
|
55
|
+
unique_count: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class _Acc:
|
|
60
|
+
"""Mutable accumulator used during a walk. Converted to PathStats at the end."""
|
|
61
|
+
|
|
62
|
+
path: str
|
|
63
|
+
dtypes_seen: set[str] = field(default_factory=set)
|
|
64
|
+
occurrence_count: int = 0
|
|
65
|
+
denominator: int = 0
|
|
66
|
+
sample_values: list[Any] = field(default_factory=list)
|
|
67
|
+
max_depth_seen: int = 0
|
|
68
|
+
array_lengths_seen: list[int] = field(default_factory=list)
|
|
69
|
+
max_length: int | None = None
|
|
70
|
+
min_value: float | int | None = None
|
|
71
|
+
max_value: float | int | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _dtype_of(value: Any) -> str:
|
|
75
|
+
"""Map a JSON-decoded Python value to its dtype name (see spec §5.5)."""
|
|
76
|
+
if value is None:
|
|
77
|
+
return "null"
|
|
78
|
+
if isinstance(value, bool):
|
|
79
|
+
return "boolean"
|
|
80
|
+
if isinstance(value, int):
|
|
81
|
+
return "integer"
|
|
82
|
+
if isinstance(value, float):
|
|
83
|
+
return "float"
|
|
84
|
+
if isinstance(value, str):
|
|
85
|
+
return "string"
|
|
86
|
+
if isinstance(value, list):
|
|
87
|
+
return "array"
|
|
88
|
+
if isinstance(value, dict):
|
|
89
|
+
return "object"
|
|
90
|
+
return "unknown"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _record_leaf(acc: _Acc, value: Any, depth: int, cap: int) -> None:
|
|
94
|
+
"""Update an accumulator with a single observed leaf value."""
|
|
95
|
+
acc.occurrence_count += 1
|
|
96
|
+
acc.max_depth_seen = max(acc.max_depth_seen, depth)
|
|
97
|
+
acc.dtypes_seen.add(_dtype_of(value))
|
|
98
|
+
if value not in acc.sample_values and len(acc.sample_values) < cap:
|
|
99
|
+
acc.sample_values.append(value)
|
|
100
|
+
|
|
101
|
+
# Numeric extras — booleans are a subclass of int in Python, so guard.
|
|
102
|
+
if isinstance(value, bool):
|
|
103
|
+
return
|
|
104
|
+
if isinstance(value, (int, float)):
|
|
105
|
+
acc.min_value = value if acc.min_value is None else min(acc.min_value, value)
|
|
106
|
+
acc.max_value = value if acc.max_value is None else max(acc.max_value, value)
|
|
107
|
+
elif isinstance(value, str):
|
|
108
|
+
length = len(value)
|
|
109
|
+
acc.max_length = length if acc.max_length is None else max(acc.max_length, length)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _finalize(acc: _Acc) -> PathStats:
|
|
113
|
+
"""Freeze a mutable accumulator into an immutable PathStats."""
|
|
114
|
+
return PathStats(
|
|
115
|
+
path=acc.path,
|
|
116
|
+
dtypes_seen=frozenset(acc.dtypes_seen),
|
|
117
|
+
occurrence_count=acc.occurrence_count,
|
|
118
|
+
denominator=acc.denominator,
|
|
119
|
+
sample_values=tuple(acc.sample_values),
|
|
120
|
+
max_depth_seen=acc.max_depth_seen,
|
|
121
|
+
array_lengths_seen=tuple(acc.array_lengths_seen),
|
|
122
|
+
max_length=acc.max_length,
|
|
123
|
+
min_value=acc.min_value,
|
|
124
|
+
max_value=acc.max_value,
|
|
125
|
+
unique_count=len(set(acc.sample_values)),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def walk(root: Any, sample_values_per_path: int) -> list[PathStats]:
|
|
130
|
+
"""Walk a parsed JSON value and return per-path statistics.
|
|
131
|
+
|
|
132
|
+
For scalar / heterogeneous roots, returns an empty list — the caller
|
|
133
|
+
(JSONExtractor) is responsible for short-circuiting with
|
|
134
|
+
ROOT_SHAPE_UNSUPPORTED.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
root: A value as produced by json.loads (dict, list, str, int,
|
|
138
|
+
float, bool, or None).
|
|
139
|
+
sample_values_per_path: Cap on how many distinct values to keep
|
|
140
|
+
per path. Must be > 0.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Ordered list of PathStats (one per unique path observed).
|
|
144
|
+
"""
|
|
145
|
+
if not isinstance(root, (dict, list)):
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
accs: dict[str, _Acc] = {}
|
|
149
|
+
|
|
150
|
+
def get(path: str) -> _Acc:
|
|
151
|
+
if path not in accs:
|
|
152
|
+
accs[path] = _Acc(path=path)
|
|
153
|
+
return accs[path]
|
|
154
|
+
|
|
155
|
+
def visit_value(value: Any, path: str, depth: int, denom_increment: int) -> None:
|
|
156
|
+
acc = get(path)
|
|
157
|
+
acc.denominator += denom_increment
|
|
158
|
+
|
|
159
|
+
# Only record scalar leaves in sample_values; containers update
|
|
160
|
+
# occurrence_count + dtype + max_depth manually so their dtype
|
|
161
|
+
# ("object"/"array") is recorded without polluting sample_values.
|
|
162
|
+
if not isinstance(value, (dict, list)):
|
|
163
|
+
_record_leaf(acc, value, depth=depth, cap=sample_values_per_path)
|
|
164
|
+
else:
|
|
165
|
+
acc.occurrence_count += 1
|
|
166
|
+
acc.max_depth_seen = max(acc.max_depth_seen, depth)
|
|
167
|
+
acc.dtypes_seen.add(_dtype_of(value))
|
|
168
|
+
|
|
169
|
+
if isinstance(value, dict):
|
|
170
|
+
for key, child in value.items():
|
|
171
|
+
child_path = f"{path}.{key}" if path else key
|
|
172
|
+
visit_value(child, child_path, depth + 1, denom_increment=1)
|
|
173
|
+
elif isinstance(value, list):
|
|
174
|
+
acc.array_lengths_seen.append(len(value))
|
|
175
|
+
child_path = f"{path}[]"
|
|
176
|
+
for element in value:
|
|
177
|
+
if isinstance(element, dict):
|
|
178
|
+
for key, sub in element.items():
|
|
179
|
+
leaf_path = f"{child_path}.{key}"
|
|
180
|
+
visit_value(sub, leaf_path, depth + 2, denom_increment=1)
|
|
181
|
+
else:
|
|
182
|
+
visit_value(element, child_path, depth + 1, denom_increment=1)
|
|
183
|
+
|
|
184
|
+
if isinstance(root, list):
|
|
185
|
+
# Collect all keys seen across all dict elements so that every
|
|
186
|
+
# key-path gets a denominator equal to the number of dict elements
|
|
187
|
+
# (not just the elements that happen to contain that key).
|
|
188
|
+
dict_element_count = sum(1 for el in root if isinstance(el, dict))
|
|
189
|
+
for element in root:
|
|
190
|
+
if isinstance(element, dict):
|
|
191
|
+
# First, bump the denominator for every key seen across ALL
|
|
192
|
+
# elements so optional keys get a correct presence fraction.
|
|
193
|
+
pass # handled below after collecting the full key universe
|
|
194
|
+
# Non-dict elements at array root contribute to HETEROGENEOUS_ARRAY
|
|
195
|
+
# detection in the extractor, not here.
|
|
196
|
+
|
|
197
|
+
# Two-pass approach: collect full key universe, then visit values.
|
|
198
|
+
all_keys: set[str] = set()
|
|
199
|
+
for element in root:
|
|
200
|
+
if isinstance(element, dict):
|
|
201
|
+
all_keys.update(element.keys())
|
|
202
|
+
|
|
203
|
+
# Prime denominators for all known keys with the total dict-element count.
|
|
204
|
+
for key in all_keys:
|
|
205
|
+
acc = get(f"[].{key}")
|
|
206
|
+
acc.denominator += dict_element_count
|
|
207
|
+
|
|
208
|
+
# Now visit only present values (denom_increment=0 since already set).
|
|
209
|
+
for element in root:
|
|
210
|
+
if isinstance(element, dict):
|
|
211
|
+
for key, value in element.items():
|
|
212
|
+
visit_value(value, f"[].{key}", depth=1, denom_increment=0)
|
|
213
|
+
elif isinstance(root, dict):
|
|
214
|
+
for key, value in root.items():
|
|
215
|
+
visit_value(value, key, depth=1, denom_increment=1)
|
|
216
|
+
|
|
217
|
+
return [_finalize(acc) for acc in accs.values()]
|