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 ADDED
@@ -0,0 +1,31 @@
1
+ """Core package for datoon JSON-to-TOON conversion tooling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ from datoon.converter import DatoonError, convert_json_for_llm
8
+ from datoon.models import (
9
+ ConversionConfig,
10
+ ConversionOutcome,
11
+ ConversionReport,
12
+ PayloadAnalysis,
13
+ )
14
+ from datoon.readers import detect_format, read_tabular
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "convert_json_for_llm",
19
+ "read_tabular",
20
+ "detect_format",
21
+ "ConversionConfig",
22
+ "ConversionOutcome",
23
+ "ConversionReport",
24
+ "PayloadAnalysis",
25
+ "DatoonError",
26
+ ]
27
+
28
+ try:
29
+ __version__ = version("datoon")
30
+ except PackageNotFoundError:
31
+ __version__ = "0.0.0"
datoon/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.7.0'
22
+ __version_tuple__ = version_tuple = (1, 7, 0)
23
+
24
+ __commit_id__ = commit_id = None
datoon/analyzer.py ADDED
@@ -0,0 +1,89 @@
1
+ """Payload analysis helpers used to decide if TOON conversion is beneficial."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any
7
+
8
+ from datoon.models import ConversionConfig, PayloadAnalysis
9
+
10
+
11
+ def _max_depth(value: Any, depth: int = 1) -> int:
12
+ """Compute the maximum nested depth for JSON-like payloads."""
13
+ if isinstance(value, dict):
14
+ if not value:
15
+ return depth
16
+ return max(_max_depth(child, depth + 1) for child in value.values())
17
+
18
+ if isinstance(value, list):
19
+ if not value:
20
+ return depth
21
+ return max(_max_depth(child, depth + 1) for child in value)
22
+
23
+ return depth
24
+
25
+
26
+ def _iter_arrays(value: Any) -> Iterator[list[Any]]:
27
+ """Yield every list node found within a nested JSON-like structure."""
28
+ if isinstance(value, list):
29
+ yield value
30
+ for child in value:
31
+ yield from _iter_arrays(child)
32
+ return
33
+
34
+ if isinstance(value, dict):
35
+ for child in value.values():
36
+ yield from _iter_arrays(child)
37
+
38
+
39
+ def _is_uniform_object_array(items: list[Any], min_rows: int) -> bool:
40
+ """Check whether list entries are object rows with identical key sets."""
41
+ if len(items) < min_rows:
42
+ return False
43
+ if not all(isinstance(item, dict) for item in items):
44
+ return False
45
+
46
+ first_keys = list(items[0].keys())
47
+ if not first_keys:
48
+ return False
49
+
50
+ return all(list(item.keys()) == first_keys for item in items)
51
+
52
+
53
+ def analyze_payload(data: Any, config: ConversionConfig) -> PayloadAnalysis:
54
+ """Evaluate whether payload structure is a practical TOON candidate."""
55
+ max_depth = _max_depth(data)
56
+ uniform_array_count = sum(
57
+ 1
58
+ for items in _iter_arrays(data)
59
+ if _is_uniform_object_array(items, config.min_uniform_rows)
60
+ )
61
+
62
+ if uniform_array_count == 0:
63
+ return PayloadAnalysis(
64
+ is_candidate=False,
65
+ reason=(
66
+ "No uniform object arrays found with at least "
67
+ f"{config.min_uniform_rows} rows."
68
+ ),
69
+ max_depth=max_depth,
70
+ uniform_array_count=0,
71
+ )
72
+
73
+ if max_depth > config.max_depth:
74
+ return PayloadAnalysis(
75
+ is_candidate=False,
76
+ reason=f"Payload depth {max_depth} exceeds max_depth {config.max_depth}.",
77
+ max_depth=max_depth,
78
+ uniform_array_count=uniform_array_count,
79
+ )
80
+
81
+ return PayloadAnalysis(
82
+ is_candidate=True,
83
+ reason=(
84
+ f"Detected {uniform_array_count} uniform object array(s) "
85
+ f"within depth {max_depth}."
86
+ ),
87
+ max_depth=max_depth,
88
+ uniform_array_count=uniform_array_count,
89
+ )
datoon/cli.py ADDED
@@ -0,0 +1,197 @@
1
+ """Command-line interface for datoon JSON-to-TOON conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Sequence
10
+
11
+ from datoon import __version__
12
+ from datoon.converter import DatoonError, convert_json_for_llm
13
+ from datoon.models import ConversionConfig
14
+ from datoon.readers import ALL_FORMATS, BINARY_FORMATS, detect_format, read_tabular
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ """Create CLI parser with conversion policy and reporting options."""
19
+ parser = argparse.ArgumentParser(
20
+ prog="datoon",
21
+ description=(
22
+ "Convert structured data (JSON, CSV, YAML, XML, JSONL, Parquet, "
23
+ "Avro, ORC, Excel, Numbers) to TOON only when conversion is beneficial."
24
+ ),
25
+ )
26
+ parser.add_argument(
27
+ "--version",
28
+ action="version",
29
+ version=f"%(prog)s {__version__}",
30
+ )
31
+ parser.add_argument(
32
+ "input",
33
+ nargs="?",
34
+ help="Path to input file. If omitted, reads from stdin (JSON/CSV/YAML/XML/JSONL).",
35
+ )
36
+ parser.add_argument(
37
+ "-o",
38
+ "--output",
39
+ help="Path to write resulting payload. If omitted, writes to stdout.",
40
+ )
41
+ parser.add_argument(
42
+ "--format",
43
+ choices=sorted(ALL_FORMATS | {"json"}),
44
+ help=(
45
+ "Input format. Auto-detected from file extension when omitted; "
46
+ "defaults to 'json' for stdin."
47
+ ),
48
+ )
49
+ parser.add_argument(
50
+ "--force",
51
+ action="store_true",
52
+ help="Force TOON conversion and bypass gating thresholds.",
53
+ )
54
+ parser.add_argument(
55
+ "--min-savings",
56
+ type=float,
57
+ default=0.15,
58
+ help="Minimum relative token savings required for conversion (default: 0.15).",
59
+ )
60
+ parser.add_argument(
61
+ "--max-depth",
62
+ type=int,
63
+ default=6,
64
+ help="Maximum nested depth allowed for automatic conversion (default: 6).",
65
+ )
66
+ parser.add_argument(
67
+ "--min-uniform-rows",
68
+ type=int,
69
+ default=3,
70
+ help="Minimum row count for uniform object arrays (default: 3).",
71
+ )
72
+ parser.add_argument(
73
+ "--timeout",
74
+ type=int,
75
+ default=30,
76
+ help="Seconds to wait for the TOON CLI before aborting (default: 30).",
77
+ )
78
+ parser.add_argument(
79
+ "--report",
80
+ help="Optional path to write conversion report as JSON.",
81
+ )
82
+ parser.add_argument(
83
+ "--report-stdout",
84
+ action="store_true",
85
+ help="Print conversion report JSON to stderr.",
86
+ )
87
+ return parser
88
+
89
+
90
+ def _read_input(path: str | None) -> str:
91
+ """Read input payload from file path or stdin."""
92
+ if path is None:
93
+ return sys.stdin.read()
94
+ return Path(path).read_text(encoding="utf-8")
95
+
96
+
97
+ def _write_text(path: str | None, text: str) -> None:
98
+ """Write output payload to file path or stdout."""
99
+ if path is None:
100
+ sys.stdout.write(text)
101
+ return
102
+ Path(path).write_text(text, encoding="utf-8")
103
+
104
+
105
+ def _emit_report(
106
+ *, report_path: str | None, report_stdout: bool, payload: dict[str, object]
107
+ ) -> None:
108
+ """Emit conversion report to selected destinations."""
109
+ report_json = json.dumps(payload, ensure_ascii=False, indent=2)
110
+ if report_path:
111
+ Path(report_path).write_text(f"{report_json}\n", encoding="utf-8")
112
+ if report_stdout:
113
+ sys.stderr.write(f"{report_json}\n")
114
+
115
+
116
+ def _resolve_format(input_path: str | None, format_override: str | None) -> str:
117
+ """Determine input format from explicit flag, file extension, or default."""
118
+ if format_override:
119
+ return format_override
120
+ if input_path is not None:
121
+ detected = detect_format(input_path)
122
+ if detected is not None:
123
+ return detected
124
+ return "json"
125
+
126
+
127
+ def _run_mcp_server() -> int:
128
+ """Launch the bundled MCP server via the `datoon mcp` subcommand.
129
+
130
+ Kept as a thin wrapper so the official MCP Registry can launch datoon
131
+ through its package command (`uvx --from datoon[mcp] datoon mcp`) without a
132
+ separate distribution. The standalone `datoon-mcp` script remains available.
133
+ """
134
+ try:
135
+ from datoon.mcp_server import main as mcp_main
136
+ except ImportError:
137
+ sys.stderr.write(
138
+ "datoon error: MCP support requires the 'mcp' extra: "
139
+ "pip install 'datoon[mcp]'\n"
140
+ )
141
+ return 1
142
+ mcp_main()
143
+ return 0
144
+
145
+
146
+ def main(argv: Sequence[str] | None = None) -> int:
147
+ """CLI entrypoint that executes conversion and returns process exit code."""
148
+ raw_argv = list(sys.argv[1:] if argv is None else argv)
149
+ if raw_argv and raw_argv[0] == "mcp":
150
+ return _run_mcp_server()
151
+
152
+ parser = build_parser()
153
+ args = parser.parse_args(argv)
154
+
155
+ try:
156
+ config = ConversionConfig(
157
+ min_savings_ratio=args.min_savings,
158
+ max_depth=args.max_depth,
159
+ min_uniform_rows=args.min_uniform_rows,
160
+ force=args.force,
161
+ toon_cli_timeout=args.timeout,
162
+ )
163
+
164
+ fmt = _resolve_format(args.input, args.format)
165
+
166
+ if fmt == "json":
167
+ raw_input = _read_input(args.input)
168
+ outcome = convert_json_for_llm(raw_input, config)
169
+ elif fmt in BINARY_FORMATS:
170
+ if args.input is None:
171
+ sys.stderr.write(
172
+ f"datoon error: --format {fmt} requires a file path, not stdin.\n"
173
+ )
174
+ return 1
175
+ rows = read_tabular(fmt, path=Path(args.input))
176
+ json_text = json.dumps(rows, ensure_ascii=False, separators=(",", ":"))
177
+ outcome = convert_json_for_llm(json_text, config)
178
+ else:
179
+ raw_input = _read_input(args.input)
180
+ rows = read_tabular(fmt, text=raw_input)
181
+ json_text = json.dumps(rows, ensure_ascii=False, separators=(",", ":"))
182
+ outcome = convert_json_for_llm(json_text, config)
183
+
184
+ _write_text(args.output, outcome.payload_text)
185
+ _emit_report(
186
+ report_path=args.report,
187
+ report_stdout=args.report_stdout,
188
+ payload=outcome.report.as_dict(),
189
+ )
190
+ return 0
191
+ except (OSError, ValueError, DatoonError) as exc:
192
+ sys.stderr.write(f"datoon error: {exc}\n")
193
+ return 1
194
+
195
+
196
+ if __name__ == "__main__":
197
+ raise SystemExit(main())
datoon/converter.py ADDED
@@ -0,0 +1,165 @@
1
+ """Conversion service that transforms JSON payloads into TOON when beneficial."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import subprocess
8
+ from functools import lru_cache
9
+ from typing import Any
10
+
11
+ from datoon.analyzer import analyze_payload
12
+ from datoon.models import (
13
+ ConversionConfig,
14
+ ConversionOutcome,
15
+ ConversionReport,
16
+ PayloadAnalysis,
17
+ )
18
+
19
+ TOON_CLI_PACKAGE = "@toon-format/cli@2"
20
+
21
+
22
+ class DatoonError(RuntimeError):
23
+ """Raised when datoon cannot process the payload safely."""
24
+
25
+
26
+ def _normalize_json(raw_text: str) -> tuple[Any, str]:
27
+ """Parse and re-serialize JSON into a deterministic compact representation."""
28
+ try:
29
+ parsed = json.loads(raw_text)
30
+ except json.JSONDecodeError as exc:
31
+ raise DatoonError(f"Invalid JSON input: {exc.msg} (pos={exc.pos}).") from exc
32
+
33
+ normalized = json.dumps(parsed, ensure_ascii=False, separators=(",", ":"))
34
+ return parsed, normalized
35
+
36
+
37
+ @lru_cache(maxsize=1)
38
+ def _load_token_encoder() -> Any | None:
39
+ """Load a token encoder once, returning None when optional dependency is absent."""
40
+ try:
41
+ import tiktoken # type: ignore
42
+ except ImportError:
43
+ return None
44
+
45
+ return tiktoken.get_encoding("cl100k_base")
46
+
47
+
48
+ def estimate_tokens(text: str) -> int:
49
+ """Estimate token count with tiktoken when installed, else use a char heuristic."""
50
+ encoder = _load_token_encoder()
51
+ if encoder is not None:
52
+ return len(encoder.encode(text))
53
+
54
+ return max(1, math.ceil(len(text) / 3))
55
+
56
+
57
+ def _run_toon_cli(normalized_json: str, timeout: int = 30) -> str:
58
+ """Convert normalized JSON to TOON via the official TOON CLI."""
59
+ command = ["npx", "--yes", TOON_CLI_PACKAGE]
60
+
61
+ try:
62
+ result = subprocess.run(
63
+ command,
64
+ input=normalized_json,
65
+ capture_output=True,
66
+ text=True,
67
+ check=False,
68
+ timeout=timeout,
69
+ )
70
+ except FileNotFoundError as exc:
71
+ raise DatoonError(
72
+ "`npx` is not available in PATH. Install Node.js before TOON conversion."
73
+ ) from exc
74
+ except subprocess.TimeoutExpired as exc:
75
+ raise DatoonError(f"TOON CLI timed out after {timeout}s.") from exc
76
+
77
+ if result.returncode != 0:
78
+ stderr = result.stderr.strip() or "unknown TOON CLI error"
79
+ raise DatoonError(f"TOON CLI failed: {stderr}")
80
+
81
+ toon_text = result.stdout.strip()
82
+ if not toon_text:
83
+ raise DatoonError("TOON CLI returned empty output.")
84
+
85
+ return f"{toon_text}\n"
86
+
87
+
88
+ def _build_skip_outcome(
89
+ normalized_json: str,
90
+ *,
91
+ reason: str,
92
+ config: ConversionConfig,
93
+ input_tokens: int,
94
+ analysis: PayloadAnalysis,
95
+ ) -> ConversionOutcome:
96
+ """Build a standard skip outcome to keep reporting consistent."""
97
+ report = ConversionReport(
98
+ decision="skip",
99
+ reason=reason,
100
+ was_forced=config.force,
101
+ input_token_estimate=input_tokens,
102
+ output_token_estimate=input_tokens,
103
+ savings_ratio=0.0,
104
+ analysis=analysis,
105
+ )
106
+ return ConversionOutcome(payload_text=normalized_json, report=report)
107
+
108
+
109
+ def convert_json_for_llm(raw_text: str, config: ConversionConfig) -> ConversionOutcome:
110
+ """Convert JSON to TOON when structure and savings meet configured policy."""
111
+ parsed, normalized_json = _normalize_json(raw_text)
112
+ analysis = analyze_payload(parsed, config)
113
+ input_tokens = estimate_tokens(normalized_json)
114
+
115
+ if not analysis.is_candidate and not config.force:
116
+ return _build_skip_outcome(
117
+ normalized_json,
118
+ reason=analysis.reason,
119
+ config=config,
120
+ input_tokens=input_tokens,
121
+ analysis=analysis,
122
+ )
123
+
124
+ try:
125
+ toon_text = _run_toon_cli(normalized_json, timeout=config.toon_cli_timeout)
126
+ except DatoonError:
127
+ if config.force:
128
+ raise
129
+
130
+ return _build_skip_outcome(
131
+ normalized_json,
132
+ reason="TOON conversion dependency unavailable; falling back to JSON.",
133
+ config=config,
134
+ input_tokens=input_tokens,
135
+ analysis=analysis,
136
+ )
137
+
138
+ output_tokens = estimate_tokens(toon_text)
139
+ savings_ratio = (input_tokens - output_tokens) / input_tokens
140
+
141
+ if savings_ratio < config.min_savings_ratio and not config.force:
142
+ return _build_skip_outcome(
143
+ normalized_json,
144
+ reason=(
145
+ f"Estimated savings {savings_ratio:.2%} below threshold "
146
+ f"{config.min_savings_ratio:.2%}."
147
+ ),
148
+ config=config,
149
+ input_tokens=input_tokens,
150
+ analysis=analysis,
151
+ )
152
+
153
+ report = ConversionReport(
154
+ decision="convert",
155
+ reason=(
156
+ f"Estimated savings {savings_ratio:.2%} "
157
+ f"(threshold {config.min_savings_ratio:.2%})."
158
+ ),
159
+ was_forced=config.force,
160
+ input_token_estimate=input_tokens,
161
+ output_token_estimate=output_tokens,
162
+ savings_ratio=savings_ratio,
163
+ analysis=analysis,
164
+ )
165
+ return ConversionOutcome(payload_text=toon_text, report=report)
datoon/mcp_server.py ADDED
@@ -0,0 +1,140 @@
1
+ """MCP server exposing datoon conversion and analysis as tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ try:
9
+ from mcp.server.fastmcp import FastMCP
10
+ except ImportError as exc:
11
+ raise ImportError(
12
+ "MCP support requires the 'mcp' extra: pip install datoon[mcp]"
13
+ ) from exc
14
+
15
+ from datoon.analyzer import analyze_payload
16
+ from datoon.converter import DatoonError, convert_json_for_llm
17
+ from datoon.models import ConversionConfig
18
+ from datoon.readers import TEXT_FORMATS, read_tabular
19
+
20
+ mcp = FastMCP(
21
+ "datoon",
22
+ instructions=(
23
+ "Smart JSON-to-TOON gateway. "
24
+ "Use convert_json to compress structured payloads before sending to a model. "
25
+ "Use analyze_json to check if a payload is a good TOON candidate without converting."
26
+ ),
27
+ )
28
+
29
+
30
+ @mcp.tool()
31
+ def convert_json(
32
+ json_text: str,
33
+ force: bool = False,
34
+ min_savings: float = 0.15,
35
+ max_depth: int = 6,
36
+ min_uniform_rows: int = 3,
37
+ timeout: int = 30,
38
+ ) -> dict[str, Any]:
39
+ """Convert a JSON payload to TOON when structure and savings meet policy.
40
+
41
+ Returns the final payload text (TOON or original JSON) and a report
42
+ explaining the conversion decision.
43
+ """
44
+ try:
45
+ config = ConversionConfig(
46
+ min_savings_ratio=min_savings,
47
+ max_depth=max_depth,
48
+ min_uniform_rows=min_uniform_rows,
49
+ force=force,
50
+ toon_cli_timeout=timeout,
51
+ )
52
+ outcome = convert_json_for_llm(json_text, config)
53
+ return {
54
+ "payload": outcome.payload_text,
55
+ "report": outcome.report.as_dict(),
56
+ }
57
+ except DatoonError as exc:
58
+ return {"error": str(exc)}
59
+ except ValueError as exc:
60
+ return {"error": f"Invalid config: {exc}"}
61
+
62
+
63
+ @mcp.tool()
64
+ def analyze_json(
65
+ json_text: str,
66
+ max_depth: int = 6,
67
+ min_uniform_rows: int = 3,
68
+ ) -> dict[str, Any]:
69
+ """Analyze a JSON payload and report whether it is a TOON candidate.
70
+
71
+ Does not invoke the TOON CLI — safe to call without Node.js installed.
72
+ """
73
+ try:
74
+ parsed = json.loads(json_text)
75
+ except json.JSONDecodeError as exc:
76
+ return {"error": f"Invalid JSON: {exc.msg} (pos={exc.pos})"}
77
+
78
+ try:
79
+ config = ConversionConfig(
80
+ max_depth=max_depth, min_uniform_rows=min_uniform_rows
81
+ )
82
+ except ValueError as exc:
83
+ return {"error": f"Invalid config: {exc}"}
84
+
85
+ analysis = analyze_payload(parsed, config)
86
+ return {
87
+ "is_candidate": analysis.is_candidate,
88
+ "reason": analysis.reason,
89
+ "max_depth": analysis.max_depth,
90
+ "uniform_array_count": analysis.uniform_array_count,
91
+ }
92
+
93
+
94
+ @mcp.tool()
95
+ def convert_text(
96
+ text: str,
97
+ fmt: str = "csv",
98
+ force: bool = False,
99
+ min_savings: float = 0.15,
100
+ max_depth: int = 6,
101
+ min_uniform_rows: int = 3,
102
+ timeout: int = 30,
103
+ ) -> dict[str, Any]:
104
+ """Convert structured text (csv, yaml, xml, jsonl) to TOON with policy gating.
105
+
106
+ Supported formats: csv, yaml, xml, jsonl.
107
+ For JSON input use convert_json. Binary formats (parquet, excel, etc.) require the CLI.
108
+ """
109
+ if fmt not in TEXT_FORMATS:
110
+ return {
111
+ "error": (
112
+ f"Unsupported format '{fmt}' for text input. "
113
+ f"Supported: {sorted(TEXT_FORMATS)}."
114
+ )
115
+ }
116
+ try:
117
+ rows = read_tabular(fmt, text=text)
118
+ json_text = json.dumps(rows, ensure_ascii=False, separators=(",", ":"))
119
+ config = ConversionConfig(
120
+ min_savings_ratio=min_savings,
121
+ max_depth=max_depth,
122
+ min_uniform_rows=min_uniform_rows,
123
+ force=force,
124
+ toon_cli_timeout=timeout,
125
+ )
126
+ outcome = convert_json_for_llm(json_text, config)
127
+ return {
128
+ "payload": outcome.payload_text,
129
+ "report": outcome.report.as_dict(),
130
+ }
131
+ except (DatoonError, ValueError, ImportError) as exc:
132
+ return {"error": str(exc)}
133
+
134
+
135
+ def main() -> None:
136
+ mcp.run()
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()