datoon 1.7.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.
- datoon/__init__.py +31 -0
- datoon/_version.py +24 -0
- datoon/analyzer.py +89 -0
- datoon/cli.py +197 -0
- datoon/converter.py +165 -0
- datoon/mcp_server.py +140 -0
- datoon/models.py +65 -0
- datoon/py.typed +0 -0
- datoon/readers/__init__.py +98 -0
- datoon/readers/columnar.py +42 -0
- datoon/readers/csv.py +35 -0
- datoon/readers/excel.py +31 -0
- datoon/readers/jsonl.py +27 -0
- datoon/readers/numbers.py +30 -0
- datoon/readers/xml.py +58 -0
- datoon/readers/yaml.py +30 -0
- datoon-1.7.0.dist-info/METADATA +544 -0
- datoon-1.7.0.dist-info/RECORD +24 -0
- datoon-1.7.0.dist-info/WHEEL +5 -0
- datoon-1.7.0.dist-info/entry_points.txt +3 -0
- datoon-1.7.0.dist-info/licenses/LICENSE +21 -0
- datoon-1.7.0.dist-info/scm_file_list.json +117 -0
- datoon-1.7.0.dist-info/scm_version.json +8 -0
- datoon-1.7.0.dist-info/top_level.txt +1 -0
datoon/models.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Domain models for conversion configuration and results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
Decision = Literal["convert", "skip"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ConversionConfig:
|
|
13
|
+
"""Runtime configuration that governs auto-conversion behavior."""
|
|
14
|
+
|
|
15
|
+
min_savings_ratio: float = 0.15
|
|
16
|
+
max_depth: int = 6
|
|
17
|
+
min_uniform_rows: int = 3
|
|
18
|
+
force: bool = False
|
|
19
|
+
toon_cli_timeout: int = 30
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
"""Validate boundary values to avoid ambiguous runtime decisions."""
|
|
23
|
+
if not 0 <= self.min_savings_ratio <= 1:
|
|
24
|
+
raise ValueError("min_savings_ratio must be between 0 and 1.")
|
|
25
|
+
if self.max_depth < 1:
|
|
26
|
+
raise ValueError("max_depth must be at least 1.")
|
|
27
|
+
if self.min_uniform_rows < 2:
|
|
28
|
+
raise ValueError("min_uniform_rows must be at least 2.")
|
|
29
|
+
if self.toon_cli_timeout < 1:
|
|
30
|
+
raise ValueError("toon_cli_timeout must be at least 1.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class PayloadAnalysis:
|
|
35
|
+
"""Heuristic analysis of whether a JSON payload is TOON-friendly."""
|
|
36
|
+
|
|
37
|
+
is_candidate: bool
|
|
38
|
+
reason: str
|
|
39
|
+
max_depth: int
|
|
40
|
+
uniform_array_count: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ConversionReport:
|
|
45
|
+
"""Metadata report for a conversion decision and savings estimate."""
|
|
46
|
+
|
|
47
|
+
decision: Decision
|
|
48
|
+
reason: str
|
|
49
|
+
was_forced: bool
|
|
50
|
+
input_token_estimate: int
|
|
51
|
+
output_token_estimate: int
|
|
52
|
+
savings_ratio: float
|
|
53
|
+
analysis: PayloadAnalysis
|
|
54
|
+
|
|
55
|
+
def as_dict(self) -> dict[str, object]:
|
|
56
|
+
"""Return a JSON-serializable representation of the report."""
|
|
57
|
+
return asdict(self)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class ConversionOutcome:
|
|
62
|
+
"""Container holding final payload text and its conversion report."""
|
|
63
|
+
|
|
64
|
+
payload_text: str
|
|
65
|
+
report: ConversionReport
|
datoon/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Format readers that normalize structured data to list-of-dicts for TOON conversion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
BINARY_FORMATS: frozenset[str] = frozenset(
|
|
9
|
+
{"excel", "parquet", "orc", "avro", "numbers"}
|
|
10
|
+
)
|
|
11
|
+
TEXT_FORMATS: frozenset[str] = frozenset({"csv", "jsonl", "yaml", "xml"})
|
|
12
|
+
ALL_FORMATS: frozenset[str] = BINARY_FORMATS | TEXT_FORMATS
|
|
13
|
+
|
|
14
|
+
_EXTENSION_MAP: dict[str, str] = {
|
|
15
|
+
".csv": "csv",
|
|
16
|
+
".jsonl": "jsonl",
|
|
17
|
+
".ndjson": "jsonl",
|
|
18
|
+
".yaml": "yaml",
|
|
19
|
+
".yml": "yaml",
|
|
20
|
+
".xml": "xml",
|
|
21
|
+
".xlsx": "excel",
|
|
22
|
+
".xls": "excel",
|
|
23
|
+
".parquet": "parquet",
|
|
24
|
+
".avro": "avro",
|
|
25
|
+
".orc": "orc",
|
|
26
|
+
".numbers": "numbers",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detect_format(path: str | Path) -> str | None:
|
|
31
|
+
"""Return the format name for a path by extension, or None if unknown."""
|
|
32
|
+
return _EXTENSION_MAP.get(Path(path).suffix.lower())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_tabular(
|
|
36
|
+
fmt: str,
|
|
37
|
+
*,
|
|
38
|
+
text: str | None = None,
|
|
39
|
+
path: Path | None = None,
|
|
40
|
+
) -> list[dict[str, Any]]:
|
|
41
|
+
"""Read structured data and return rows as a list of dicts.
|
|
42
|
+
|
|
43
|
+
Text formats (csv, jsonl, yaml, xml) require ``text``.
|
|
44
|
+
Binary formats (excel, parquet, orc, avro, numbers) require ``path``.
|
|
45
|
+
"""
|
|
46
|
+
if fmt in BINARY_FORMATS:
|
|
47
|
+
if path is None:
|
|
48
|
+
raise ValueError(f"Format '{fmt}' requires a file path, not text input.")
|
|
49
|
+
return _read_binary(fmt, path)
|
|
50
|
+
if fmt in TEXT_FORMATS:
|
|
51
|
+
if text is None:
|
|
52
|
+
raise ValueError(f"Format '{fmt}' requires text input.")
|
|
53
|
+
return _read_text(fmt, text)
|
|
54
|
+
raise ValueError(f"Unknown format '{fmt}'. Supported: {sorted(ALL_FORMATS)}.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_text(fmt: str, text: str) -> list[dict[str, Any]]:
|
|
58
|
+
if fmt == "csv":
|
|
59
|
+
from datoon.readers.csv import read_csv
|
|
60
|
+
|
|
61
|
+
return read_csv(text)
|
|
62
|
+
if fmt == "jsonl":
|
|
63
|
+
from datoon.readers.jsonl import read_jsonl
|
|
64
|
+
|
|
65
|
+
return read_jsonl(text)
|
|
66
|
+
if fmt == "yaml":
|
|
67
|
+
from datoon.readers.yaml import read_yaml
|
|
68
|
+
|
|
69
|
+
return read_yaml(text)
|
|
70
|
+
if fmt == "xml":
|
|
71
|
+
from datoon.readers.xml import read_xml
|
|
72
|
+
|
|
73
|
+
return read_xml(text)
|
|
74
|
+
raise ValueError(f"Unknown text format: {fmt}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_binary(fmt: str, path: Path) -> list[dict[str, Any]]:
|
|
78
|
+
if fmt == "excel":
|
|
79
|
+
from datoon.readers.excel import read_excel
|
|
80
|
+
|
|
81
|
+
return read_excel(path)
|
|
82
|
+
if fmt == "parquet":
|
|
83
|
+
from datoon.readers.columnar import read_parquet
|
|
84
|
+
|
|
85
|
+
return read_parquet(path)
|
|
86
|
+
if fmt == "orc":
|
|
87
|
+
from datoon.readers.columnar import read_orc
|
|
88
|
+
|
|
89
|
+
return read_orc(path)
|
|
90
|
+
if fmt == "avro":
|
|
91
|
+
from datoon.readers.columnar import read_avro
|
|
92
|
+
|
|
93
|
+
return read_avro(path)
|
|
94
|
+
if fmt == "numbers":
|
|
95
|
+
from datoon.readers.numbers import read_numbers
|
|
96
|
+
|
|
97
|
+
return read_numbers(path)
|
|
98
|
+
raise ValueError(f"Unknown binary format: {fmt}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Columnar format readers: Parquet, ORC, Avro (requires datoon[columnar])."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_INSTALL_HINT = "pip install 'datoon[columnar]'"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_parquet(path: Path) -> list[dict[str, Any]]:
|
|
12
|
+
"""Read a Parquet file into a list of row dicts."""
|
|
13
|
+
try:
|
|
14
|
+
import pyarrow.parquet as pq # type: ignore
|
|
15
|
+
except ImportError as exc:
|
|
16
|
+
raise ImportError(
|
|
17
|
+
f"Parquet support requires the 'columnar' extra: {_INSTALL_HINT}"
|
|
18
|
+
) from exc
|
|
19
|
+
return pq.read_table(path).to_pylist()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_orc(path: Path) -> list[dict[str, Any]]:
|
|
23
|
+
"""Read an ORC file into a list of row dicts."""
|
|
24
|
+
try:
|
|
25
|
+
import pyarrow.orc as orc # type: ignore
|
|
26
|
+
except ImportError as exc:
|
|
27
|
+
raise ImportError(
|
|
28
|
+
f"ORC support requires the 'columnar' extra: {_INSTALL_HINT}"
|
|
29
|
+
) from exc
|
|
30
|
+
return orc.read_table(path).to_pylist()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_avro(path: Path) -> list[dict[str, Any]]:
|
|
34
|
+
"""Read an Avro file into a list of row dicts."""
|
|
35
|
+
try:
|
|
36
|
+
import fastavro # type: ignore
|
|
37
|
+
except ImportError as exc:
|
|
38
|
+
raise ImportError(
|
|
39
|
+
f"Avro support requires the 'columnar' extra: {_INSTALL_HINT}"
|
|
40
|
+
) from exc
|
|
41
|
+
with open(path, "rb") as f:
|
|
42
|
+
return list(fastavro.reader(f))
|
datoon/readers/csv.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""CSV reader with basic type coercion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _coerce(value: str) -> int | float | bool | str | None:
|
|
11
|
+
stripped = value.strip()
|
|
12
|
+
if stripped == "":
|
|
13
|
+
return None
|
|
14
|
+
low = stripped.lower()
|
|
15
|
+
if low == "true":
|
|
16
|
+
return True
|
|
17
|
+
if low == "false":
|
|
18
|
+
return False
|
|
19
|
+
try:
|
|
20
|
+
return int(stripped)
|
|
21
|
+
except ValueError:
|
|
22
|
+
pass
|
|
23
|
+
try:
|
|
24
|
+
return float(stripped)
|
|
25
|
+
except ValueError:
|
|
26
|
+
pass
|
|
27
|
+
return stripped
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_csv(text: str, *, coerce_types: bool = True) -> list[dict[str, Any]]:
|
|
31
|
+
"""Parse CSV text into a list of row dicts."""
|
|
32
|
+
reader = csv.DictReader(io.StringIO(text))
|
|
33
|
+
if coerce_types:
|
|
34
|
+
return [{k: _coerce(str(v)) for k, v in row.items()} for row in reader]
|
|
35
|
+
return [dict(row) for row in reader]
|
datoon/readers/excel.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Excel reader (requires openpyxl extra: pip install 'datoon[excel]')."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_excel(path: Path, *, sheet: int = 0) -> list[dict[str, Any]]:
|
|
10
|
+
"""Read the first sheet of an Excel file into a list of row dicts."""
|
|
11
|
+
try:
|
|
12
|
+
import openpyxl # type: ignore
|
|
13
|
+
except ImportError as exc:
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"Excel support requires the 'excel' extra: pip install 'datoon[excel]'"
|
|
16
|
+
) from exc
|
|
17
|
+
|
|
18
|
+
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
|
19
|
+
ws = wb.worksheets[sheet]
|
|
20
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
21
|
+
wb.close()
|
|
22
|
+
|
|
23
|
+
if not rows:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
headers = [str(h) if h is not None else f"col{i}" for i, h in enumerate(rows[0])]
|
|
27
|
+
return [
|
|
28
|
+
{h: v for h, v in zip(headers, row)}
|
|
29
|
+
for row in rows[1:]
|
|
30
|
+
if any(v is not None for v in row)
|
|
31
|
+
]
|
datoon/readers/jsonl.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""JSONL (newline-delimited JSON) reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from datoon.converter import DatoonError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_jsonl(text: str) -> list[dict[str, Any]]:
|
|
12
|
+
"""Parse JSONL text into a list of row dicts."""
|
|
13
|
+
rows: list[dict[str, Any]] = []
|
|
14
|
+
for i, line in enumerate(text.strip().splitlines(), start=1):
|
|
15
|
+
line = line.strip()
|
|
16
|
+
if not line:
|
|
17
|
+
continue
|
|
18
|
+
try:
|
|
19
|
+
obj = json.loads(line)
|
|
20
|
+
except json.JSONDecodeError as exc:
|
|
21
|
+
raise DatoonError(
|
|
22
|
+
f"Invalid JSONL at line {i}: {exc.msg} (pos={exc.pos})."
|
|
23
|
+
) from exc
|
|
24
|
+
if not isinstance(obj, dict):
|
|
25
|
+
raise DatoonError(f"JSONL line {i} is not a JSON object.")
|
|
26
|
+
rows.append(obj)
|
|
27
|
+
return rows
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Apple Numbers reader (requires numbers-parser extra: pip install 'datoon[numbers]')."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_numbers(path: Path, *, sheet: int = 0, table: int = 0) -> list[dict[str, Any]]:
|
|
10
|
+
"""Read an Apple Numbers file into a list of row dicts."""
|
|
11
|
+
try:
|
|
12
|
+
from numbers_parser import Document # type: ignore
|
|
13
|
+
except ImportError as exc:
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"Numbers support requires the 'numbers' extra: pip install 'datoon[numbers]'"
|
|
16
|
+
) from exc
|
|
17
|
+
|
|
18
|
+
doc = Document(str(path))
|
|
19
|
+
tbl = doc.sheets[sheet].tables[table]
|
|
20
|
+
rows = list(tbl.rows(values_only=True))
|
|
21
|
+
|
|
22
|
+
if not rows:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
headers = [str(h) if h is not None else f"col{i}" for i, h in enumerate(rows[0])]
|
|
26
|
+
return [
|
|
27
|
+
{h: v for h, v in zip(headers, row)}
|
|
28
|
+
for row in rows[1:]
|
|
29
|
+
if any(v is not None for v in row)
|
|
30
|
+
]
|
datoon/readers/xml.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""XML reader using stdlib xml.etree.ElementTree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_xml(text: str) -> list[dict[str, Any]]:
|
|
10
|
+
"""Parse XML into a list of row dicts from the dominant repeated child element."""
|
|
11
|
+
try:
|
|
12
|
+
root = ET.fromstring(text)
|
|
13
|
+
except ET.ParseError as exc:
|
|
14
|
+
raise ValueError(f"Invalid XML: {exc}") from exc
|
|
15
|
+
|
|
16
|
+
children = list(root)
|
|
17
|
+
if not children:
|
|
18
|
+
raise ValueError("XML root element has no children.")
|
|
19
|
+
|
|
20
|
+
tag_counts: dict[str, int] = {}
|
|
21
|
+
for child in children:
|
|
22
|
+
tag_counts[child.tag] = tag_counts.get(child.tag, 0) + 1
|
|
23
|
+
dominant_tag = max(tag_counts, key=lambda t: tag_counts[t])
|
|
24
|
+
items = [c for c in children if c.tag == dominant_tag]
|
|
25
|
+
|
|
26
|
+
return [_element_to_dict(item) for item in items]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _element_to_dict(element: ET.Element) -> dict[str, Any]:
|
|
30
|
+
result: dict[str, Any] = {k: _coerce(v) for k, v in element.attrib.items()}
|
|
31
|
+
for child in element:
|
|
32
|
+
if list(child):
|
|
33
|
+
result[child.tag] = _element_to_dict(child)
|
|
34
|
+
else:
|
|
35
|
+
result[child.tag] = _coerce(child.text or "")
|
|
36
|
+
if not list(element) and element.text and element.text.strip():
|
|
37
|
+
result["_text"] = element.text.strip()
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _coerce(value: str) -> int | float | bool | str | None:
|
|
42
|
+
stripped = value.strip()
|
|
43
|
+
if not stripped:
|
|
44
|
+
return None
|
|
45
|
+
low = stripped.lower()
|
|
46
|
+
if low == "true":
|
|
47
|
+
return True
|
|
48
|
+
if low == "false":
|
|
49
|
+
return False
|
|
50
|
+
try:
|
|
51
|
+
return int(stripped)
|
|
52
|
+
except ValueError:
|
|
53
|
+
pass
|
|
54
|
+
try:
|
|
55
|
+
return float(stripped)
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
return stripped
|
datoon/readers/yaml.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""YAML reader (requires pyyaml extra: pip install 'datoon[yaml]')."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_yaml(text: str) -> list[dict[str, Any]]:
|
|
9
|
+
"""Parse YAML text into a list of row dicts."""
|
|
10
|
+
try:
|
|
11
|
+
import yaml # type: ignore
|
|
12
|
+
except ImportError as exc:
|
|
13
|
+
raise ImportError(
|
|
14
|
+
"YAML support requires the 'yaml' extra: pip install 'datoon[yaml]'"
|
|
15
|
+
) from exc
|
|
16
|
+
|
|
17
|
+
data = yaml.safe_load(text)
|
|
18
|
+
return _normalize(data)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalize(data: Any) -> list[dict[str, Any]]:
|
|
22
|
+
if isinstance(data, list):
|
|
23
|
+
return data
|
|
24
|
+
if isinstance(data, dict):
|
|
25
|
+
lists = [v for v in data.values() if isinstance(v, list) and v]
|
|
26
|
+
if len(lists) == 1:
|
|
27
|
+
return lists[0]
|
|
28
|
+
raise ValueError(
|
|
29
|
+
"YAML must be a list of objects, or a mapping with exactly one list value."
|
|
30
|
+
)
|