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/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]
@@ -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
+ ]
@@ -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
+ )