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,515 @@
|
|
|
1
|
+
"""CSV metadata extractor — file inspection + schema inference + envelope.
|
|
2
|
+
|
|
3
|
+
CLI entry point at the bottom of the file.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from .base import MetadataExtractor
|
|
15
|
+
from .sampler import sample_csv
|
|
16
|
+
from .warning_rules import (
|
|
17
|
+
MetadataWarning,
|
|
18
|
+
check_duplicate_column_name,
|
|
19
|
+
check_empty_file,
|
|
20
|
+
check_high_null_rate,
|
|
21
|
+
check_inconsistent_quoting,
|
|
22
|
+
check_latin1_fallback,
|
|
23
|
+
check_likely_date_column,
|
|
24
|
+
check_missing_header,
|
|
25
|
+
check_mixed_dtype_column,
|
|
26
|
+
check_numeric_column_quote_risk,
|
|
27
|
+
check_repeating_entity,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
ENCODING_LADDER: tuple[str, ...] = ("utf-8-sig", "utf-8", "latin-1")
|
|
31
|
+
SNIFF_SAMPLE_BYTES = 16 * 1024
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_numeric(value: str) -> bool:
|
|
35
|
+
"""Return True if value parses as int or float."""
|
|
36
|
+
try:
|
|
37
|
+
float(value)
|
|
38
|
+
return True
|
|
39
|
+
except ValueError:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _body_has_numeric_column(sample: str, delimiter: str) -> bool:
|
|
44
|
+
"""Return True if the body (rows after row 1) has any all-numeric column.
|
|
45
|
+
|
|
46
|
+
Used by sniff_dialect to disambiguate Sniffer's has_header verdict on
|
|
47
|
+
all-string CSVs.
|
|
48
|
+
"""
|
|
49
|
+
rows = list(csv.reader(sample.splitlines(), delimiter=delimiter))
|
|
50
|
+
if len(rows) < 2:
|
|
51
|
+
return False
|
|
52
|
+
body = rows[1:]
|
|
53
|
+
n_cols = len(rows[0])
|
|
54
|
+
for col_idx in range(n_cols):
|
|
55
|
+
col_values = [r[col_idx] for r in body if col_idx < len(r) and r[col_idx]]
|
|
56
|
+
if not col_values:
|
|
57
|
+
continue
|
|
58
|
+
if all(_is_numeric(v) for v in col_values):
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def detect_encoding(file_path: Path) -> tuple[str, list[str]]:
|
|
64
|
+
"""Return (chosen_encoding, list_of_encodings_attempted_before_success).
|
|
65
|
+
|
|
66
|
+
Tries the ladder utf-8-sig → utf-8 → latin-1. latin-1 always succeeds.
|
|
67
|
+
"""
|
|
68
|
+
attempted: list[str] = []
|
|
69
|
+
for enc in ENCODING_LADDER:
|
|
70
|
+
try:
|
|
71
|
+
with file_path.open("r", encoding=enc) as f:
|
|
72
|
+
f.read()
|
|
73
|
+
return enc, attempted
|
|
74
|
+
except UnicodeDecodeError:
|
|
75
|
+
attempted.append(enc)
|
|
76
|
+
# Should be unreachable: latin-1 cannot raise UnicodeDecodeError.
|
|
77
|
+
raise RuntimeError("encoding ladder exhausted (this should never happen)")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sniff_dialect(file_path: Path, *, encoding: str) -> dict[str, Any]:
|
|
81
|
+
"""Return delimiter / quote_char / has_header / inconsistent_quoting.
|
|
82
|
+
|
|
83
|
+
Falls back to comma + double-quote + assumed-header if Sniffer fails
|
|
84
|
+
(e.g. on very small or pathological files).
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
with file_path.open("r", encoding=encoding) as f:
|
|
88
|
+
sample = f.read(SNIFF_SAMPLE_BYTES)
|
|
89
|
+
except OSError:
|
|
90
|
+
sample = ""
|
|
91
|
+
|
|
92
|
+
if not sample:
|
|
93
|
+
return {
|
|
94
|
+
"delimiter": ",",
|
|
95
|
+
"quote_char": '"',
|
|
96
|
+
"has_header": False,
|
|
97
|
+
"inconsistent_quoting": False,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
sniffer = csv.Sniffer()
|
|
101
|
+
try:
|
|
102
|
+
dialect = sniffer.sniff(sample)
|
|
103
|
+
delimiter = dialect.delimiter
|
|
104
|
+
quote_char = dialect.quotechar
|
|
105
|
+
except csv.Error:
|
|
106
|
+
delimiter = ","
|
|
107
|
+
quote_char = '"'
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
sniff_says_header = sniffer.has_header(sample)
|
|
111
|
+
except csv.Error:
|
|
112
|
+
sniff_says_header = True
|
|
113
|
+
|
|
114
|
+
if sniff_says_header:
|
|
115
|
+
has_header = True
|
|
116
|
+
else:
|
|
117
|
+
# Sniffer's has_header heuristic relies on a type difference between
|
|
118
|
+
# row 1 and the body. On all-string CSVs it returns False even when
|
|
119
|
+
# row 1 is a header. Apply one fallback: trust Sniffer only when the
|
|
120
|
+
# body has at least one all-numeric column (strong type signal).
|
|
121
|
+
# Otherwise default to has_header=True.
|
|
122
|
+
has_header = not _body_has_numeric_column(sample, delimiter)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"delimiter": delimiter,
|
|
126
|
+
"quote_char": quote_char,
|
|
127
|
+
"has_header": has_header,
|
|
128
|
+
"inconsistent_quoting": False,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def count_data_rows(file_path: Path, *, encoding: str, has_header: bool) -> int:
|
|
133
|
+
"""Count non-blank data rows. Subtracts the header row if present."""
|
|
134
|
+
try:
|
|
135
|
+
with file_path.open("r", encoding=encoding) as f:
|
|
136
|
+
n = sum(1 for line in f if line.strip())
|
|
137
|
+
except OSError:
|
|
138
|
+
return 0
|
|
139
|
+
if has_header and n > 0:
|
|
140
|
+
n -= 1
|
|
141
|
+
return max(n, 0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
_BOOL_VALUES = {"true", "false", "True", "False", "0", "1", "yes", "no"}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_null(v: Any) -> bool:
|
|
148
|
+
if v is None:
|
|
149
|
+
return True
|
|
150
|
+
if isinstance(v, float) and pd.isna(v):
|
|
151
|
+
return True
|
|
152
|
+
if isinstance(v, str) and v == "":
|
|
153
|
+
return True
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _try_int(v: str) -> bool:
|
|
158
|
+
if not isinstance(v, str) or not v:
|
|
159
|
+
return False
|
|
160
|
+
# Reject leading zeros (e.g. "007" should stay string).
|
|
161
|
+
s = v.lstrip("-")
|
|
162
|
+
if len(s) > 1 and s.startswith("0"):
|
|
163
|
+
return False
|
|
164
|
+
try:
|
|
165
|
+
int(v)
|
|
166
|
+
return True
|
|
167
|
+
except ValueError:
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _try_float(v: str) -> bool:
|
|
172
|
+
if not isinstance(v, str) or not v:
|
|
173
|
+
return False
|
|
174
|
+
# Reject leading zeros (e.g. "007" should stay string).
|
|
175
|
+
# Check the integer part: if it has leading zeros, reject.
|
|
176
|
+
# Split on decimal point and check the left part.
|
|
177
|
+
parts = v.lstrip("-").split(".")
|
|
178
|
+
if parts:
|
|
179
|
+
int_part = parts[0]
|
|
180
|
+
if len(int_part) > 1 and int_part.startswith("0"):
|
|
181
|
+
return False
|
|
182
|
+
try:
|
|
183
|
+
float(v)
|
|
184
|
+
return True
|
|
185
|
+
except ValueError:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _try_bool(v: str) -> bool:
|
|
190
|
+
return isinstance(v, str) and v in _BOOL_VALUES
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _try_date_column(values: list[str]) -> str | None:
|
|
194
|
+
"""Return 'date', 'datetime', or None for the column as a whole."""
|
|
195
|
+
if not values:
|
|
196
|
+
return None
|
|
197
|
+
# Reject pure numbers with leading zeros (e.g. "007" should stay string).
|
|
198
|
+
# But allow dates like "01/15/2026" which have non-digit chars.
|
|
199
|
+
for v in values:
|
|
200
|
+
if isinstance(v, str) and v:
|
|
201
|
+
s = v.lstrip("-")
|
|
202
|
+
# If the whole thing is digits and starts with 0, reject.
|
|
203
|
+
if s.isdigit() and len(s) > 1 and s.startswith("0"):
|
|
204
|
+
return None
|
|
205
|
+
parsed = pd.to_datetime(pd.Series(values), errors="coerce", format="mixed")
|
|
206
|
+
nat_rate = parsed.isna().sum() / len(values)
|
|
207
|
+
if nat_rate >= 0.05:
|
|
208
|
+
return None
|
|
209
|
+
has_time = any(
|
|
210
|
+
(ts.hour, ts.minute, ts.second) != (0, 0, 0) for ts in parsed.dropna()
|
|
211
|
+
)
|
|
212
|
+
return "datetime" if has_time else "date"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def infer_column_dtype(values: list[str]) -> str:
|
|
216
|
+
"""Per spec §5.2: parser tagging then column-level resolution."""
|
|
217
|
+
non_null = [v for v in values if not _is_null(v)]
|
|
218
|
+
if not non_null:
|
|
219
|
+
return "string"
|
|
220
|
+
|
|
221
|
+
parsers = (
|
|
222
|
+
("integer", _try_int),
|
|
223
|
+
("float", _try_float),
|
|
224
|
+
("boolean", _try_bool),
|
|
225
|
+
)
|
|
226
|
+
accepts: dict[str, list[bool]] = {
|
|
227
|
+
name: [parser(v) for v in non_null] for name, parser in parsers
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
# First parser (priority order) accepting every non-null value.
|
|
231
|
+
for name, _ in parsers:
|
|
232
|
+
if all(accepts[name]):
|
|
233
|
+
return name
|
|
234
|
+
|
|
235
|
+
# Date detection is column-level (NaT rate < 5 %).
|
|
236
|
+
date_dtype = _try_date_column(non_null)
|
|
237
|
+
if date_dtype is not None:
|
|
238
|
+
return date_dtype
|
|
239
|
+
|
|
240
|
+
# Some typed parser accepted at least one value but not all → mixed.
|
|
241
|
+
if any(any(acc) for acc in accepts.values()):
|
|
242
|
+
return "mixed"
|
|
243
|
+
|
|
244
|
+
return "string"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def build_column_metadata(
|
|
248
|
+
*,
|
|
249
|
+
name: str,
|
|
250
|
+
values: list[str],
|
|
251
|
+
sample_values_per_column: int,
|
|
252
|
+
) -> dict[str, Any]:
|
|
253
|
+
"""Compute the per-column block of the schema field (§5.2)."""
|
|
254
|
+
null_count = sum(1 for v in values if _is_null(v))
|
|
255
|
+
non_null = [v for v in values if not _is_null(v)]
|
|
256
|
+
unique_count = len(set(non_null))
|
|
257
|
+
dtype = infer_column_dtype(values)
|
|
258
|
+
|
|
259
|
+
samples: list[Any] = list(dict.fromkeys(non_null))[:sample_values_per_column]
|
|
260
|
+
|
|
261
|
+
meta: dict[str, Any] = {
|
|
262
|
+
"name": name,
|
|
263
|
+
"dtype": dtype,
|
|
264
|
+
"null_count": null_count,
|
|
265
|
+
"unique_count": unique_count,
|
|
266
|
+
"sample_values": _coerce_samples(samples, dtype),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if dtype == "integer":
|
|
270
|
+
nums = [int(v) for v in non_null]
|
|
271
|
+
meta["min"] = min(nums)
|
|
272
|
+
meta["max"] = max(nums)
|
|
273
|
+
elif dtype == "float":
|
|
274
|
+
nums = [float(v) for v in non_null]
|
|
275
|
+
meta["min"] = min(nums)
|
|
276
|
+
meta["max"] = max(nums)
|
|
277
|
+
elif dtype == "string":
|
|
278
|
+
meta["max_length"] = max((len(v) for v in non_null), default=0)
|
|
279
|
+
|
|
280
|
+
return meta
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _coerce_samples(samples: list[str], dtype: str) -> list[Any]:
|
|
284
|
+
"""Cast sample values to the column's inferred dtype for JSON output."""
|
|
285
|
+
if dtype == "integer":
|
|
286
|
+
return [int(v) for v in samples]
|
|
287
|
+
if dtype == "float":
|
|
288
|
+
return [float(v) for v in samples]
|
|
289
|
+
if dtype == "boolean":
|
|
290
|
+
return [v.lower() in ("true", "1", "yes") for v in samples]
|
|
291
|
+
return list(samples)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class CSVExtractor(MetadataExtractor):
|
|
295
|
+
"""Concrete metadata extractor for CSV files (spec §4.2)."""
|
|
296
|
+
|
|
297
|
+
def __init__(
|
|
298
|
+
self,
|
|
299
|
+
head_n: int = 3,
|
|
300
|
+
middle_n: int = 1,
|
|
301
|
+
tail_n: int = 1,
|
|
302
|
+
sample_values_per_column: int = 3,
|
|
303
|
+
max_rows_for_inference: int = 200,
|
|
304
|
+
) -> None:
|
|
305
|
+
self.head_n = head_n
|
|
306
|
+
self.middle_n = middle_n
|
|
307
|
+
self.tail_n = tail_n
|
|
308
|
+
self.sample_values_per_column = sample_values_per_column
|
|
309
|
+
self.max_rows_for_inference = max_rows_for_inference
|
|
310
|
+
|
|
311
|
+
def supports(self, file_path: Path) -> bool:
|
|
312
|
+
return file_path.suffix.lower() == ".csv"
|
|
313
|
+
|
|
314
|
+
def extract(self, file_path: Path) -> dict[str, Any]:
|
|
315
|
+
warnings: list[MetadataWarning] = []
|
|
316
|
+
file_size = file_path.stat().st_size
|
|
317
|
+
|
|
318
|
+
encoding, attempted = detect_encoding(file_path)
|
|
319
|
+
_push(
|
|
320
|
+
warnings,
|
|
321
|
+
check_latin1_fallback(
|
|
322
|
+
final_encoding=encoding,
|
|
323
|
+
attempted=attempted,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Empty file short-circuit.
|
|
328
|
+
if file_size == 0:
|
|
329
|
+
_push(warnings, check_empty_file(row_count=0))
|
|
330
|
+
return self._envelope(
|
|
331
|
+
file_path,
|
|
332
|
+
file_size,
|
|
333
|
+
encoding,
|
|
334
|
+
schema={
|
|
335
|
+
"delimiter": ",",
|
|
336
|
+
"quote_char": '"',
|
|
337
|
+
"has_header": False,
|
|
338
|
+
"row_count": 0,
|
|
339
|
+
"columns": [],
|
|
340
|
+
},
|
|
341
|
+
samples={"head": [], "middle": [], "tail": []},
|
|
342
|
+
warnings=warnings,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
dialect = sniff_dialect(file_path, encoding=encoding)
|
|
346
|
+
_push(warnings, check_missing_header(has_header=dialect["has_header"]))
|
|
347
|
+
_push(
|
|
348
|
+
warnings,
|
|
349
|
+
check_inconsistent_quoting(
|
|
350
|
+
inconsistent=dialect["inconsistent_quoting"],
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
row_count = count_data_rows(
|
|
355
|
+
file_path,
|
|
356
|
+
encoding=encoding,
|
|
357
|
+
has_header=dialect["has_header"],
|
|
358
|
+
)
|
|
359
|
+
_push(warnings, check_empty_file(row_count=row_count))
|
|
360
|
+
|
|
361
|
+
if row_count == 0:
|
|
362
|
+
return self._envelope(
|
|
363
|
+
file_path,
|
|
364
|
+
file_size,
|
|
365
|
+
encoding,
|
|
366
|
+
schema={
|
|
367
|
+
**{
|
|
368
|
+
k: dialect[k] for k in ("delimiter", "quote_char", "has_header")
|
|
369
|
+
},
|
|
370
|
+
"row_count": 0,
|
|
371
|
+
"columns": [],
|
|
372
|
+
},
|
|
373
|
+
samples={"head": [], "middle": [], "tail": []},
|
|
374
|
+
warnings=warnings,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Read the raw header line first so we can detect duplicates BEFORE
|
|
378
|
+
# pandas silently auto-renames them to 'name', 'name.1', etc.
|
|
379
|
+
if dialect["has_header"]:
|
|
380
|
+
with file_path.open("r", encoding=encoding, newline="") as f:
|
|
381
|
+
raw_header = next(csv.reader(f, delimiter=dialect["delimiter"]), [])
|
|
382
|
+
_push(warnings, check_duplicate_column_name(raw_header=raw_header))
|
|
383
|
+
|
|
384
|
+
# Schema inference on a capped sample.
|
|
385
|
+
df = pd.read_csv(
|
|
386
|
+
file_path,
|
|
387
|
+
encoding=encoding,
|
|
388
|
+
sep=dialect["delimiter"],
|
|
389
|
+
quotechar=dialect["quote_char"],
|
|
390
|
+
header=0 if dialect["has_header"] else None,
|
|
391
|
+
nrows=self.max_rows_for_inference,
|
|
392
|
+
)
|
|
393
|
+
if not dialect["has_header"]:
|
|
394
|
+
df.columns = [f"c{i}" for i in range(len(df.columns))]
|
|
395
|
+
|
|
396
|
+
columns_meta: list[dict[str, Any]] = []
|
|
397
|
+
for col_name in df.columns:
|
|
398
|
+
raw_values = ["" if pd.isna(v) else str(v) for v in df[col_name].tolist()]
|
|
399
|
+
meta = build_column_metadata(
|
|
400
|
+
name=str(col_name),
|
|
401
|
+
values=raw_values,
|
|
402
|
+
sample_values_per_column=self.sample_values_per_column,
|
|
403
|
+
)
|
|
404
|
+
columns_meta.append(meta)
|
|
405
|
+
_push(
|
|
406
|
+
warnings,
|
|
407
|
+
check_repeating_entity(
|
|
408
|
+
column=meta,
|
|
409
|
+
row_count=row_count,
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
_push(warnings, check_numeric_column_quote_risk(column=meta))
|
|
413
|
+
_push(warnings, check_mixed_dtype_column(column=meta))
|
|
414
|
+
_push(
|
|
415
|
+
warnings,
|
|
416
|
+
check_high_null_rate(
|
|
417
|
+
column=meta,
|
|
418
|
+
row_count=row_count,
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
_push(warnings, check_likely_date_column(column=meta))
|
|
422
|
+
|
|
423
|
+
samples = sample_csv(
|
|
424
|
+
file_path,
|
|
425
|
+
total_rows=row_count,
|
|
426
|
+
encoding=encoding,
|
|
427
|
+
head_n=self.head_n,
|
|
428
|
+
middle_n=self.middle_n,
|
|
429
|
+
tail_n=self.tail_n,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
schema = {
|
|
433
|
+
"delimiter": dialect["delimiter"],
|
|
434
|
+
"quote_char": dialect["quote_char"],
|
|
435
|
+
"has_header": dialect["has_header"],
|
|
436
|
+
"row_count": row_count,
|
|
437
|
+
"columns": columns_meta,
|
|
438
|
+
}
|
|
439
|
+
return self._envelope(
|
|
440
|
+
file_path,
|
|
441
|
+
file_size,
|
|
442
|
+
encoding,
|
|
443
|
+
schema,
|
|
444
|
+
samples,
|
|
445
|
+
warnings,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def _envelope(
|
|
449
|
+
self,
|
|
450
|
+
file_path: Path,
|
|
451
|
+
file_size: int,
|
|
452
|
+
encoding: str,
|
|
453
|
+
schema: dict[str, Any],
|
|
454
|
+
samples: dict[str, list[dict[str, Any]]],
|
|
455
|
+
warnings: list[MetadataWarning],
|
|
456
|
+
) -> dict[str, Any]:
|
|
457
|
+
return {
|
|
458
|
+
"format": "csv",
|
|
459
|
+
"file_path": str(file_path),
|
|
460
|
+
"file_size_bytes": file_size,
|
|
461
|
+
"encoding": encoding,
|
|
462
|
+
"schema_version": self.SCHEMA_VERSION,
|
|
463
|
+
"schema": schema,
|
|
464
|
+
"samples": samples,
|
|
465
|
+
"warnings": [w.to_dict() for w in warnings],
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _push(bucket: list[MetadataWarning], maybe: MetadataWarning | None) -> None:
|
|
470
|
+
if maybe is not None:
|
|
471
|
+
bucket.append(maybe)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _main() -> int:
|
|
475
|
+
import argparse
|
|
476
|
+
import json
|
|
477
|
+
|
|
478
|
+
parser = argparse.ArgumentParser(
|
|
479
|
+
description="Extract CSV metadata for the data-morph pipeline.",
|
|
480
|
+
)
|
|
481
|
+
parser.add_argument("file", type=Path, help="Path to a .csv file")
|
|
482
|
+
parser.add_argument(
|
|
483
|
+
"--head-n",
|
|
484
|
+
type=int,
|
|
485
|
+
default=3,
|
|
486
|
+
help="Number of rows to sample from the head (default: 3)",
|
|
487
|
+
)
|
|
488
|
+
parser.add_argument(
|
|
489
|
+
"--middle-n",
|
|
490
|
+
type=int,
|
|
491
|
+
default=1,
|
|
492
|
+
help="Number of rows to sample from the middle (default: 1)",
|
|
493
|
+
)
|
|
494
|
+
parser.add_argument(
|
|
495
|
+
"--tail-n",
|
|
496
|
+
type=int,
|
|
497
|
+
default=1,
|
|
498
|
+
help="Number of rows to sample from the tail (default: 1)",
|
|
499
|
+
)
|
|
500
|
+
args = parser.parse_args()
|
|
501
|
+
|
|
502
|
+
extractor = CSVExtractor(
|
|
503
|
+
head_n=args.head_n,
|
|
504
|
+
middle_n=args.middle_n,
|
|
505
|
+
tail_n=args.tail_n,
|
|
506
|
+
)
|
|
507
|
+
envelope = extractor.extract(args.file)
|
|
508
|
+
rendered = json.dumps(envelope, indent=2, default=str)
|
|
509
|
+
print(rendered)
|
|
510
|
+
print(f"# rough token estimate: ~{len(rendered) // 4} (chars / 4)")
|
|
511
|
+
return 0
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
if __name__ == "__main__":
|
|
515
|
+
raise SystemExit(_main())
|