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/__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()
|