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.
Files changed (39) hide show
  1. data_morph_gemma-0.1.0.dist-info/METADATA +177 -0
  2. data_morph_gemma-0.1.0.dist-info/RECORD +39 -0
  3. data_morph_gemma-0.1.0.dist-info/WHEEL +4 -0
  4. data_morph_gemma-0.1.0.dist-info/entry_points.txt +2 -0
  5. data_morph_gemma-0.1.0.dist-info/licenses/LICENSE +25 -0
  6. datamorph/__init__.py +19 -0
  7. datamorph/cli.py +84 -0
  8. datamorph/convert.py +146 -0
  9. datamorph/data/__init__.py +1 -0
  10. datamorph/data/collect.py +221 -0
  11. datamorph/data/envelope.py +20 -0
  12. datamorph/data/generators/__init__.py +1 -0
  13. datamorph/data/generators/base.py +48 -0
  14. datamorph/data/generators/uc1_csv_to_json.py +64 -0
  15. datamorph/data/generators/uc2_json_to_csv.py +59 -0
  16. datamorph/data/generators/uc3_txt_log_to_csv.py +64 -0
  17. datamorph/data/generators/uc4_csv_to_txt_report.py +62 -0
  18. datamorph/data/generators/uc5_schema_migration.py +49 -0
  19. datamorph/data/sandbox.py +95 -0
  20. datamorph/data/teacher_script.py +114 -0
  21. datamorph/evaluation/__init__.py +0 -0
  22. datamorph/evaluation/metrics.py +264 -0
  23. datamorph/evaluation/output_cleanup.py +116 -0
  24. datamorph/evaluation/runner.py +218 -0
  25. datamorph/evaluation/teacher.py +193 -0
  26. datamorph/extractor/__init__.py +15 -0
  27. datamorph/extractor/base.py +26 -0
  28. datamorph/extractor/csv_extractor.py +515 -0
  29. datamorph/extractor/json_extractor.py +447 -0
  30. datamorph/extractor/json_walker.py +217 -0
  31. datamorph/extractor/sampler.py +68 -0
  32. datamorph/extractor/txt_extractor.py +199 -0
  33. datamorph/extractor/warning_rules.py +473 -0
  34. datamorph/features/__init__.py +1 -0
  35. datamorph/features/format_pairs.py +57 -0
  36. datamorph/model.py +63 -0
  37. datamorph/models/__init__.py +0 -0
  38. datamorph/models/gemma_mlx.py +163 -0
  39. 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())