datoon 1.8.0__tar.gz → 1.9.0__tar.gz

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 (130) hide show
  1. {datoon-1.8.0 → datoon-1.9.0}/CHANGELOG.md +7 -0
  2. {datoon-1.8.0 → datoon-1.9.0}/PKG-INFO +7 -7
  3. {datoon-1.8.0 → datoon-1.9.0}/README.md +6 -6
  4. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/_version.py +3 -3
  5. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/cli.py +11 -2
  6. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/converter.py +11 -11
  7. datoon-1.9.0/src/datoon/errors.py +20 -0
  8. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/mcp_server.py +5 -1
  9. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/models.py +7 -0
  10. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/readers/jsonl.py +1 -1
  11. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/readers/xml.py +4 -3
  12. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/readers/yaml.py +16 -6
  13. {datoon-1.8.0 → datoon-1.9.0}/src/datoon.egg-info/PKG-INFO +7 -7
  14. {datoon-1.8.0 → datoon-1.9.0}/src/datoon.egg-info/SOURCES.txt +4 -1
  15. {datoon-1.8.0 → datoon-1.9.0}/src/datoon.egg-info/scm_file_list.json +3 -0
  16. {datoon-1.8.0 → datoon-1.9.0}/src/datoon.egg-info/scm_version.json +2 -2
  17. {datoon-1.8.0 → datoon-1.9.0}/tests/unit/test_cli.py +44 -8
  18. {datoon-1.8.0 → datoon-1.9.0}/tests/unit/test_converter_policy.py +2 -2
  19. datoon-1.9.0/tests/unit/test_errors.py +39 -0
  20. {datoon-1.8.0 → datoon-1.9.0}/tests/unit/test_mcp_server.py +12 -4
  21. {datoon-1.8.0 → datoon-1.9.0}/tests/unit/test_readers.py +38 -4
  22. datoon-1.9.0/tests/unit/test_tokens.py +43 -0
  23. {datoon-1.8.0 → datoon-1.9.0}/.agents/plugins/marketplace.json +0 -0
  24. {datoon-1.8.0 → datoon-1.9.0}/.claude-plugin/marketplace.json +0 -0
  25. {datoon-1.8.0 → datoon-1.9.0}/.claude-plugin/plugin.json +0 -0
  26. {datoon-1.8.0 → datoon-1.9.0}/.editorconfig +0 -0
  27. {datoon-1.8.0 → datoon-1.9.0}/.gitattributes +0 -0
  28. {datoon-1.8.0 → datoon-1.9.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  29. {datoon-1.8.0 → datoon-1.9.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  30. {datoon-1.8.0 → datoon-1.9.0}/.github/dependabot.yml +0 -0
  31. {datoon-1.8.0 → datoon-1.9.0}/.github/workflows/package-installer-smoke.yml +0 -0
  32. {datoon-1.8.0 → datoon-1.9.0}/.github/workflows/pages.yml +0 -0
  33. {datoon-1.8.0 → datoon-1.9.0}/.github/workflows/pre-commit.yml +0 -0
  34. {datoon-1.8.0 → datoon-1.9.0}/.github/workflows/publish-mcp.yml +0 -0
  35. {datoon-1.8.0 → datoon-1.9.0}/.github/workflows/publish.yml +0 -0
  36. {datoon-1.8.0 → datoon-1.9.0}/.github/workflows/release.yml +0 -0
  37. {datoon-1.8.0 → datoon-1.9.0}/.github/workflows/tests.yml +0 -0
  38. {datoon-1.8.0 → datoon-1.9.0}/.github/workflows/validate-artifacts.yml +0 -0
  39. {datoon-1.8.0 → datoon-1.9.0}/.github/workflows/workflow-lint.yml +0 -0
  40. {datoon-1.8.0 → datoon-1.9.0}/.gitignore +0 -0
  41. {datoon-1.8.0 → datoon-1.9.0}/.pre-commit-config.yaml +0 -0
  42. {datoon-1.8.0 → datoon-1.9.0}/.releaserc.yaml +0 -0
  43. {datoon-1.8.0 → datoon-1.9.0}/CLAUDE.md +0 -0
  44. {datoon-1.8.0 → datoon-1.9.0}/CONTRIBUTING.md +0 -0
  45. {datoon-1.8.0 → datoon-1.9.0}/INSTALL.md +0 -0
  46. {datoon-1.8.0 → datoon-1.9.0}/LICENSE +0 -0
  47. {datoon-1.8.0 → datoon-1.9.0}/MARKETPLACES.md +0 -0
  48. {datoon-1.8.0 → datoon-1.9.0}/SECURITY.md +0 -0
  49. {datoon-1.8.0 → datoon-1.9.0}/SKILL.md +0 -0
  50. {datoon-1.8.0 → datoon-1.9.0}/assets/datoon-banner.svg +0 -0
  51. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/REPORT.md +0 -0
  52. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/agent_results.json +0 -0
  53. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/expected_answers.json +0 -0
  54. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/generate_payloads.py +0 -0
  55. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/payloads/1_large.json +0 -0
  56. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/payloads/1_medium.json +0 -0
  57. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/payloads/1_small.json +0 -0
  58. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/payloads/2_large.json +0 -0
  59. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/payloads/2_medium.json +0 -0
  60. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/payloads/2_small.json +0 -0
  61. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/payloads/3_large.json +0 -0
  62. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/payloads/3_medium.json +0 -0
  63. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/payloads/3_small.json +0 -0
  64. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/reports/1_large.report.json +0 -0
  65. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/reports/1_medium.report.json +0 -0
  66. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/reports/1_small.report.json +0 -0
  67. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/reports/2_large.report.json +0 -0
  68. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/reports/2_medium.report.json +0 -0
  69. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/reports/2_small.report.json +0 -0
  70. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/reports/3_large.report.json +0 -0
  71. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/reports/3_medium.report.json +0 -0
  72. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/reports/3_small.report.json +0 -0
  73. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/scored_agent_results.json +0 -0
  74. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/toon/1_large.toon +0 -0
  75. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/toon/1_medium.toon +0 -0
  76. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/toon/1_small.toon +0 -0
  77. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/toon/2_large.toon +0 -0
  78. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/toon/2_medium.toon +0 -0
  79. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/toon/2_small.toon +0 -0
  80. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/toon/3_large.toon +0 -0
  81. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/toon/3_medium.toon +0 -0
  82. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/agent_skill_eval/toon/3_small.toon +0 -0
  83. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/payloads.json +0 -0
  84. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/results/.gitkeep +0 -0
  85. {datoon-1.8.0 → datoon-1.9.0}/benchmarks/run.py +0 -0
  86. {datoon-1.8.0 → datoon-1.9.0}/datoon/SKILL.md +0 -0
  87. {datoon-1.8.0 → datoon-1.9.0}/datoon.skill +0 -0
  88. {datoon-1.8.0 → datoon-1.9.0}/docs/.nojekyll +0 -0
  89. {datoon-1.8.0 → datoon-1.9.0}/docs/app.js +0 -0
  90. {datoon-1.8.0 → datoon-1.9.0}/docs/assets/datoon-banner.svg +0 -0
  91. {datoon-1.8.0 → datoon-1.9.0}/docs/index.html +0 -0
  92. {datoon-1.8.0 → datoon-1.9.0}/docs/styles.css +0 -0
  93. {datoon-1.8.0 → datoon-1.9.0}/glama.json +0 -0
  94. {datoon-1.8.0 → datoon-1.9.0}/install.sh +0 -0
  95. {datoon-1.8.0 → datoon-1.9.0}/plugins/datoon/.codex-plugin/plugin.json +0 -0
  96. {datoon-1.8.0 → datoon-1.9.0}/plugins/datoon/skills/datoon/SKILL.md +0 -0
  97. {datoon-1.8.0 → datoon-1.9.0}/pyproject.toml +0 -0
  98. {datoon-1.8.0 → datoon-1.9.0}/scripts/ab_skill_benchmark.py +0 -0
  99. {datoon-1.8.0 → datoon-1.9.0}/scripts/install.py +0 -0
  100. {datoon-1.8.0 → datoon-1.9.0}/scripts/summarize_agent_skill_eval.py +0 -0
  101. {datoon-1.8.0 → datoon-1.9.0}/scripts/validate_plugin_metadata.py +0 -0
  102. {datoon-1.8.0 → datoon-1.9.0}/scripts/validate_skill_sync.py +0 -0
  103. {datoon-1.8.0 → datoon-1.9.0}/server.json +0 -0
  104. {datoon-1.8.0 → datoon-1.9.0}/setup.cfg +0 -0
  105. {datoon-1.8.0 → datoon-1.9.0}/setup.sh +0 -0
  106. {datoon-1.8.0 → datoon-1.9.0}/skills/datoon/README.md +0 -0
  107. {datoon-1.8.0 → datoon-1.9.0}/skills/datoon/SKILL.md +0 -0
  108. {datoon-1.8.0 → datoon-1.9.0}/smithery.yaml +0 -0
  109. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/__init__.py +0 -0
  110. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/analyzer.py +0 -0
  111. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/py.typed +0 -0
  112. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/readers/__init__.py +0 -0
  113. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/readers/_coerce.py +0 -0
  114. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/readers/_tabular.py +0 -0
  115. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/readers/columnar.py +0 -0
  116. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/readers/csv.py +0 -0
  117. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/readers/excel.py +0 -0
  118. {datoon-1.8.0 → datoon-1.9.0}/src/datoon/readers/numbers.py +0 -0
  119. {datoon-1.8.0 → datoon-1.9.0}/src/datoon.egg-info/dependency_links.txt +0 -0
  120. {datoon-1.8.0 → datoon-1.9.0}/src/datoon.egg-info/entry_points.txt +0 -0
  121. {datoon-1.8.0 → datoon-1.9.0}/src/datoon.egg-info/requires.txt +0 -0
  122. {datoon-1.8.0 → datoon-1.9.0}/src/datoon.egg-info/top_level.txt +0 -0
  123. {datoon-1.8.0 → datoon-1.9.0}/tests/fixtures/users.expected.toon +0 -0
  124. {datoon-1.8.0 → datoon-1.9.0}/tests/fixtures/users.json +0 -0
  125. {datoon-1.8.0 → datoon-1.9.0}/tests/integration/test_toon_cli_integration.py +0 -0
  126. {datoon-1.8.0 → datoon-1.9.0}/tests/unit/test_analyzer.py +0 -0
  127. {datoon-1.8.0 → datoon-1.9.0}/tests/unit/test_installer.py +0 -0
  128. {datoon-1.8.0 → datoon-1.9.0}/tests/unit/test_models.py +0 -0
  129. {datoon-1.8.0 → datoon-1.9.0}/tests/unit/test_tabular_rows.py +0 -0
  130. {datoon-1.8.0 → datoon-1.9.0}/uv.lock +0 -0
@@ -1,3 +1,10 @@
1
+ # [1.9.0](https://github.com/andrii-su/datoon/compare/v1.8.0...v1.9.0) (2026-07-07)
2
+
3
+
4
+ ### Features
5
+
6
+ * **tokens:** make token encoding configurable, default to o200k_base ([#46](https://github.com/andrii-su/datoon/issues/46)) ([8483eb7](https://github.com/andrii-su/datoon/commit/8483eb7aca27c97df2827d552e0086cf9bfbe157))
7
+
1
8
  # [1.8.0](https://github.com/andrii-su/datoon/compare/v1.7.4...v1.8.0) (2026-07-07)
2
9
 
3
10
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datoon
3
- Version: 1.8.0
3
+ Version: 1.9.0
4
4
  Summary: Smart structured-data-to-TOON gateway with pragmatic auto-gating for LLM prompts.
5
5
  Author-email: Andrii Suruhov <andrii.suruhov@gmail.com>
6
6
  License: MIT License
@@ -451,12 +451,12 @@ Auto mode avoids low-benefit and high-risk payloads (`orders-nested`, `mixed-non
451
451
 
452
452
  | Dataset | JSON | TOON (forced) | Raw Saved | Auto | Auto Tokens | Auto Saved |
453
453
  |---|---:|---:|---:|---|---:|---:|
454
- | users-small | 56 | 31 | 44.6% | convert | 31 | 44.6% |
455
- | events-medium | 198 | 111 | 43.9% | convert | 111 | 43.9% |
456
- | orders-nested | 93 | 91 | 2.2% | skip | 93 | 0.0% |
457
- | mixed-non-uniform | 35 | 37 | -5.7% | skip | 35 | 0.0% |
458
- | metrics-wide | 133 | 63 | 52.6% | convert | 63 | 52.6% |
459
- | **Average** | **103** | **67** | **27.5%** | **3/5 convert** | **67** | **28.2%** |
454
+ | users-small | 54 | 40 | 25.9% | convert | 40 | 25.9% |
455
+ | events-medium | 219 | 162 | 26.0% | convert | 162 | 26.0% |
456
+ | orders-nested | 106 | 116 | -9.4% | skip | 106 | 0.0% |
457
+ | mixed-non-uniform | 35 | 47 | -34.3% | skip | 35 | 0.0% |
458
+ | metrics-wide | 142 | 103 | 27.5% | convert | 103 | 27.5% |
459
+ | **Average** | **111** | **94** | **7.1%** | **3/5 convert** | **89** | **15.9%** |
460
460
 
461
461
  *Forced conversion succeeded for 5/5 payloads.*
462
462
 
@@ -391,12 +391,12 @@ Auto mode avoids low-benefit and high-risk payloads (`orders-nested`, `mixed-non
391
391
 
392
392
  | Dataset | JSON | TOON (forced) | Raw Saved | Auto | Auto Tokens | Auto Saved |
393
393
  |---|---:|---:|---:|---|---:|---:|
394
- | users-small | 56 | 31 | 44.6% | convert | 31 | 44.6% |
395
- | events-medium | 198 | 111 | 43.9% | convert | 111 | 43.9% |
396
- | orders-nested | 93 | 91 | 2.2% | skip | 93 | 0.0% |
397
- | mixed-non-uniform | 35 | 37 | -5.7% | skip | 35 | 0.0% |
398
- | metrics-wide | 133 | 63 | 52.6% | convert | 63 | 52.6% |
399
- | **Average** | **103** | **67** | **27.5%** | **3/5 convert** | **67** | **28.2%** |
394
+ | users-small | 54 | 40 | 25.9% | convert | 40 | 25.9% |
395
+ | events-medium | 219 | 162 | 26.0% | convert | 162 | 26.0% |
396
+ | orders-nested | 106 | 116 | -9.4% | skip | 106 | 0.0% |
397
+ | mixed-non-uniform | 35 | 47 | -34.3% | skip | 35 | 0.0% |
398
+ | metrics-wide | 142 | 103 | 27.5% | convert | 103 | 27.5% |
399
+ | **Average** | **111** | **94** | **7.1%** | **3/5 convert** | **89** | **15.9%** |
400
400
 
401
401
  *Forced conversion succeeded for 5/5 payloads.*
402
402
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.8.0'
22
- __version_tuple__ = version_tuple = (1, 8, 0)
21
+ __version__ = version = '1.9.0'
22
+ __version_tuple__ = version_tuple = (1, 9, 0)
23
23
 
24
- __commit_id__ = commit_id = 'gacb329fb8'
24
+ __commit_id__ = commit_id = 'g412011b90'
@@ -5,12 +5,12 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import json
7
7
  import sys
8
+ from collections.abc import Sequence
8
9
  from pathlib import Path
9
- from typing import Sequence
10
10
 
11
11
  from datoon import __version__
12
12
  from datoon.converter import DatoonError, convert_json_for_llm
13
- from datoon.models import ConversionConfig
13
+ from datoon.models import DEFAULT_TOKEN_ENCODING, ConversionConfig
14
14
  from datoon.readers import ALL_FORMATS, BINARY_FORMATS, detect_format, read_tabular
15
15
 
16
16
 
@@ -87,6 +87,14 @@ def build_parser() -> argparse.ArgumentParser:
87
87
  default=0,
88
88
  help="Table index within a Numbers sheet (default: 0).",
89
89
  )
90
+ parser.add_argument(
91
+ "--encoding",
92
+ default=DEFAULT_TOKEN_ENCODING,
93
+ help=(
94
+ "tiktoken encoding for token estimates "
95
+ f"(default: {DEFAULT_TOKEN_ENCODING})."
96
+ ),
97
+ )
90
98
  parser.add_argument(
91
99
  "--report",
92
100
  help="Optional path to write conversion report as JSON.",
@@ -171,6 +179,7 @@ def main(argv: Sequence[str] | None = None) -> int:
171
179
  min_uniform_rows=args.min_uniform_rows,
172
180
  force=args.force,
173
181
  toon_cli_timeout=args.timeout,
182
+ token_encoding=args.encoding,
174
183
  )
175
184
 
176
185
  fmt = _resolve_format(args.input, args.format)
@@ -9,7 +9,9 @@ from functools import lru_cache
9
9
  from typing import Any
10
10
 
11
11
  from datoon.analyzer import analyze_payload
12
+ from datoon.errors import DatoonError
12
13
  from datoon.models import (
14
+ DEFAULT_TOKEN_ENCODING,
13
15
  ConversionConfig,
14
16
  ConversionOutcome,
15
17
  ConversionReport,
@@ -18,9 +20,7 @@ from datoon.models import (
18
20
 
19
21
  TOON_CLI_PACKAGE = "@toon-format/cli@2"
20
22
 
21
-
22
- class DatoonError(RuntimeError):
23
- """Raised when datoon cannot process the payload safely."""
23
+ __all__ = ["DatoonError", "convert_json_for_llm", "estimate_tokens"]
24
24
 
25
25
 
26
26
  def _reject_non_finite(constant: str) -> Any:
@@ -43,20 +43,20 @@ def _normalize_json(raw_text: str) -> tuple[Any, str]:
43
43
  return parsed, normalized
44
44
 
45
45
 
46
- @lru_cache(maxsize=1)
47
- def _load_token_encoder() -> Any | None:
48
- """Load a token encoder once, returning None when optional dependency is absent."""
46
+ @lru_cache(maxsize=8)
47
+ def _load_token_encoder(encoding: str) -> Any | None:
48
+ """Load a token encoder once per name, None when the optional dep is absent."""
49
49
  try:
50
50
  import tiktoken # type: ignore
51
51
  except ImportError:
52
52
  return None
53
53
 
54
- return tiktoken.get_encoding("cl100k_base")
54
+ return tiktoken.get_encoding(encoding)
55
55
 
56
56
 
57
- def estimate_tokens(text: str) -> int:
57
+ def estimate_tokens(text: str, encoding: str = DEFAULT_TOKEN_ENCODING) -> int:
58
58
  """Estimate token count with tiktoken when installed, else use a char heuristic."""
59
- encoder = _load_token_encoder()
59
+ encoder = _load_token_encoder(encoding)
60
60
  if encoder is not None:
61
61
  return len(encoder.encode(text))
62
62
 
@@ -119,7 +119,7 @@ def convert_json_for_llm(raw_text: str, config: ConversionConfig) -> ConversionO
119
119
  """Convert JSON to TOON when structure and savings meet configured policy."""
120
120
  parsed, normalized_json = _normalize_json(raw_text)
121
121
  analysis = analyze_payload(parsed, config)
122
- input_tokens = estimate_tokens(normalized_json)
122
+ input_tokens = estimate_tokens(normalized_json, config.token_encoding)
123
123
 
124
124
  if not analysis.is_candidate and not config.force:
125
125
  return _build_skip_outcome(
@@ -144,7 +144,7 @@ def convert_json_for_llm(raw_text: str, config: ConversionConfig) -> ConversionO
144
144
  analysis=analysis,
145
145
  )
146
146
 
147
- output_tokens = estimate_tokens(toon_text)
147
+ output_tokens = estimate_tokens(toon_text, config.token_encoding)
148
148
  savings_ratio = (input_tokens - output_tokens) / input_tokens
149
149
 
150
150
  if savings_ratio < config.min_savings_ratio and not config.force:
@@ -0,0 +1,20 @@
1
+ """Canonical exception types for datoon.
2
+
3
+ Kept in a leaf module so any layer — readers, converter, CLI, MCP — can raise
4
+ the datoon error taxonomy without importing a higher-level module.
5
+
6
+ Convention:
7
+
8
+ * ``DatoonError`` — the user's *payload* is malformed or unsuitable (invalid
9
+ JSON/XML/YAML, non-object rows, a refused DTD). These are the errors surfaced
10
+ to end users of the CLI/MCP.
11
+ * ``ValueError`` — a *usage* error in how datoon was called (unknown format
12
+ name, text passed where a file path is required). Programmer-facing.
13
+ * ``ImportError`` — an optional extra needed for a format is not installed.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+
19
+ class DatoonError(RuntimeError):
20
+ """Raised when datoon cannot process the payload safely."""
@@ -15,7 +15,7 @@ except ImportError as exc:
15
15
  from datoon import __version__
16
16
  from datoon.analyzer import analyze_payload
17
17
  from datoon.converter import DatoonError, convert_json_for_llm
18
- from datoon.models import ConversionConfig
18
+ from datoon.models import DEFAULT_TOKEN_ENCODING, ConversionConfig
19
19
  from datoon.readers import TEXT_FORMATS, read_tabular
20
20
 
21
21
  mcp = FastMCP(
@@ -40,6 +40,7 @@ def convert_json(
40
40
  max_depth: int = 6,
41
41
  min_uniform_rows: int = 3,
42
42
  timeout: int = 30,
43
+ encoding: str = DEFAULT_TOKEN_ENCODING,
43
44
  ) -> dict[str, Any]:
44
45
  """Convert a JSON payload to TOON when structure and savings meet policy.
45
46
 
@@ -53,6 +54,7 @@ def convert_json(
53
54
  min_uniform_rows=min_uniform_rows,
54
55
  force=force,
55
56
  toon_cli_timeout=timeout,
57
+ token_encoding=encoding,
56
58
  )
57
59
  outcome = convert_json_for_llm(json_text, config)
58
60
  return {
@@ -105,6 +107,7 @@ def convert_text(
105
107
  max_depth: int = 6,
106
108
  min_uniform_rows: int = 3,
107
109
  timeout: int = 30,
110
+ encoding: str = DEFAULT_TOKEN_ENCODING,
108
111
  ) -> dict[str, Any]:
109
112
  """Convert structured text (csv, yaml, xml, jsonl) to TOON with policy gating.
110
113
 
@@ -127,6 +130,7 @@ def convert_text(
127
130
  min_uniform_rows=min_uniform_rows,
128
131
  force=force,
129
132
  toon_cli_timeout=timeout,
133
+ token_encoding=encoding,
130
134
  )
131
135
  outcome = convert_json_for_llm(json_text, config)
132
136
  return {
@@ -7,6 +7,10 @@ from typing import Literal
7
7
 
8
8
  Decision = Literal["convert", "skip"]
9
9
 
10
+ # Default tiktoken encoding for token estimates. o200k_base matches GPT-4o and
11
+ # the o-series; it is closer to modern targets than the older cl100k_base.
12
+ DEFAULT_TOKEN_ENCODING = "o200k_base"
13
+
10
14
 
11
15
  @dataclass(frozen=True, slots=True)
12
16
  class ConversionConfig:
@@ -17,6 +21,7 @@ class ConversionConfig:
17
21
  min_uniform_rows: int = 3
18
22
  force: bool = False
19
23
  toon_cli_timeout: int = 30
24
+ token_encoding: str = DEFAULT_TOKEN_ENCODING
20
25
 
21
26
  def __post_init__(self) -> None:
22
27
  """Validate boundary values to avoid ambiguous runtime decisions."""
@@ -28,6 +33,8 @@ class ConversionConfig:
28
33
  raise ValueError("min_uniform_rows must be at least 2.")
29
34
  if self.toon_cli_timeout < 1:
30
35
  raise ValueError("toon_cli_timeout must be at least 1.")
36
+ if not self.token_encoding:
37
+ raise ValueError("token_encoding must be a non-empty encoding name.")
31
38
 
32
39
 
33
40
  @dataclass(frozen=True, slots=True)
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  from typing import Any
7
7
 
8
- from datoon.converter import DatoonError
8
+ from datoon.errors import DatoonError
9
9
 
10
10
 
11
11
  def read_jsonl(text: str) -> list[dict[str, Any]]:
@@ -13,6 +13,7 @@ import re
13
13
  import xml.etree.ElementTree as ET
14
14
  from typing import Any
15
15
 
16
+ from datoon.errors import DatoonError
16
17
  from datoon.readers._coerce import coerce_scalar as _coerce
17
18
 
18
19
  _DOCTYPE_RE = re.compile(r"<!DOCTYPE", re.IGNORECASE)
@@ -21,18 +22,18 @@ _DOCTYPE_RE = re.compile(r"<!DOCTYPE", re.IGNORECASE)
21
22
  def read_xml(text: str) -> list[dict[str, Any]]:
22
23
  """Parse XML into a list of row dicts from the dominant repeated child element."""
23
24
  if _DOCTYPE_RE.search(text):
24
- raise ValueError(
25
+ raise DatoonError(
25
26
  "XML with a DOCTYPE/DTD is not allowed (entity-expansion risk)."
26
27
  )
27
28
 
28
29
  try:
29
30
  root = ET.fromstring(text)
30
31
  except ET.ParseError as exc:
31
- raise ValueError(f"Invalid XML: {exc}") from exc
32
+ raise DatoonError(f"Invalid XML: {exc}") from exc
32
33
 
33
34
  children = list(root)
34
35
  if not children:
35
- raise ValueError("XML root element has no children.")
36
+ raise DatoonError("XML root element has no children.")
36
37
 
37
38
  tag_counts: dict[str, int] = {}
38
39
  for child in children:
@@ -4,6 +4,12 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
6
 
7
+ from datoon.errors import DatoonError
8
+
9
+ _SHAPE_ERROR = (
10
+ "YAML must be a list of objects, or a mapping with exactly one list value."
11
+ )
12
+
7
13
 
8
14
  def read_yaml(text: str) -> list[dict[str, Any]]:
9
15
  """Parse YAML text into a list of row dicts."""
@@ -19,12 +25,16 @@ def read_yaml(text: str) -> list[dict[str, Any]]:
19
25
 
20
26
 
21
27
  def _normalize(data: Any) -> list[dict[str, Any]]:
28
+ rows: list[Any] | None = None
22
29
  if isinstance(data, list):
23
- return data
24
- if isinstance(data, dict):
30
+ rows = data
31
+ elif isinstance(data, dict):
25
32
  lists = [v for v in data.values() if isinstance(v, list) and v]
26
33
  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
- )
34
+ rows = lists[0]
35
+
36
+ if rows is None:
37
+ raise DatoonError(_SHAPE_ERROR)
38
+ if not all(isinstance(item, dict) for item in rows):
39
+ raise DatoonError(_SHAPE_ERROR)
40
+ return rows
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datoon
3
- Version: 1.8.0
3
+ Version: 1.9.0
4
4
  Summary: Smart structured-data-to-TOON gateway with pragmatic auto-gating for LLM prompts.
5
5
  Author-email: Andrii Suruhov <andrii.suruhov@gmail.com>
6
6
  License: MIT License
@@ -451,12 +451,12 @@ Auto mode avoids low-benefit and high-risk payloads (`orders-nested`, `mixed-non
451
451
 
452
452
  | Dataset | JSON | TOON (forced) | Raw Saved | Auto | Auto Tokens | Auto Saved |
453
453
  |---|---:|---:|---:|---|---:|---:|
454
- | users-small | 56 | 31 | 44.6% | convert | 31 | 44.6% |
455
- | events-medium | 198 | 111 | 43.9% | convert | 111 | 43.9% |
456
- | orders-nested | 93 | 91 | 2.2% | skip | 93 | 0.0% |
457
- | mixed-non-uniform | 35 | 37 | -5.7% | skip | 35 | 0.0% |
458
- | metrics-wide | 133 | 63 | 52.6% | convert | 63 | 52.6% |
459
- | **Average** | **103** | **67** | **27.5%** | **3/5 convert** | **67** | **28.2%** |
454
+ | users-small | 54 | 40 | 25.9% | convert | 40 | 25.9% |
455
+ | events-medium | 219 | 162 | 26.0% | convert | 162 | 26.0% |
456
+ | orders-nested | 106 | 116 | -9.4% | skip | 106 | 0.0% |
457
+ | mixed-non-uniform | 35 | 47 | -34.3% | skip | 35 | 0.0% |
458
+ | metrics-wide | 142 | 103 | 27.5% | convert | 103 | 27.5% |
459
+ | **Average** | **111** | **94** | **7.1%** | **3/5 convert** | **89** | **15.9%** |
460
460
 
461
461
  *Forced conversion succeeded for 5/5 payloads.*
462
462
 
@@ -91,6 +91,7 @@ src/datoon/_version.py
91
91
  src/datoon/analyzer.py
92
92
  src/datoon/cli.py
93
93
  src/datoon/converter.py
94
+ src/datoon/errors.py
94
95
  src/datoon/mcp_server.py
95
96
  src/datoon/models.py
96
97
  src/datoon/py.typed
@@ -118,8 +119,10 @@ tests/integration/test_toon_cli_integration.py
118
119
  tests/unit/test_analyzer.py
119
120
  tests/unit/test_cli.py
120
121
  tests/unit/test_converter_policy.py
122
+ tests/unit/test_errors.py
121
123
  tests/unit/test_installer.py
122
124
  tests/unit/test_mcp_server.py
123
125
  tests/unit/test_models.py
124
126
  tests/unit/test_readers.py
125
- tests/unit/test_tabular_rows.py
127
+ tests/unit/test_tabular_rows.py
128
+ tests/unit/test_tokens.py
@@ -39,6 +39,7 @@
39
39
  "src/datoon/models.py",
40
40
  "src/datoon/converter.py",
41
41
  "src/datoon/cli.py",
42
+ "src/datoon/errors.py",
42
43
  "src/datoon/readers/xml.py",
43
44
  "src/datoon/readers/_coerce.py",
44
45
  "src/datoon/readers/__init__.py",
@@ -63,7 +64,9 @@
63
64
  "tests/unit/test_mcp_server.py",
64
65
  "tests/unit/test_tabular_rows.py",
65
66
  "tests/unit/test_analyzer.py",
67
+ "tests/unit/test_tokens.py",
66
68
  "tests/unit/test_installer.py",
69
+ "tests/unit/test_errors.py",
67
70
  "tests/unit/test_cli.py",
68
71
  ".claude-plugin/plugin.json",
69
72
  ".claude-plugin/marketplace.json",
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "1.8.0",
2
+ "tag": "1.9.0",
3
3
  "distance": 0,
4
- "node": "gacb329fb8ab013fc074f180d839e72bc2381dad3",
4
+ "node": "g412011b90239e0e07c401c668d255671348628c2",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-07-07"
@@ -53,7 +53,9 @@ def test_main_converts_json_from_stdin(
53
53
  lambda _, **kw: "rows[3]{id,v}:\n 1,a\n 2,b\n 3,c\n",
54
54
  )
55
55
  token_seq = iter([100, 40])
56
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
56
+ monkeypatch.setattr(
57
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
58
+ )
57
59
 
58
60
  rc = main([])
59
61
  assert rc == 0
@@ -88,7 +90,9 @@ def test_main_converts_csv_via_flag(
88
90
  lambda _, **kw: "rows[3]{id,name,role}:\n 1,Ada,admin\n",
89
91
  )
90
92
  token_seq = iter([100, 30])
91
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
93
+ monkeypatch.setattr(
94
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
95
+ )
92
96
 
93
97
  rc = main([str(csv_file), "--format", "csv"])
94
98
  assert rc == 0
@@ -107,7 +111,9 @@ def test_main_auto_detects_csv_from_extension(
107
111
  lambda _, **kw: "rows[3]{id,name,role}:\n 1,Ada,admin\n",
108
112
  )
109
113
  token_seq = iter([100, 30])
110
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
114
+ monkeypatch.setattr(
115
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
116
+ )
111
117
 
112
118
  rc = main([str(csv_file)])
113
119
  assert rc == 0
@@ -134,7 +140,9 @@ def test_main_converts_jsonl_from_stdin(
134
140
  lambda _, **kw: "rows[3]{id,name,role}:\n 1,Ada,admin\n",
135
141
  )
136
142
  token_seq = iter([100, 30])
137
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
143
+ monkeypatch.setattr(
144
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
145
+ )
138
146
 
139
147
  rc = main(["--format", "jsonl"])
140
148
  assert rc == 0
@@ -153,7 +161,9 @@ def test_main_auto_detects_jsonl_from_extension(
153
161
  lambda _, **kw: "rows[3]{id,name,role}:\n 1,Ada,admin\n",
154
162
  )
155
163
  token_seq = iter([100, 30])
156
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
164
+ monkeypatch.setattr(
165
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
166
+ )
157
167
 
158
168
  rc = main([str(f)])
159
169
  assert rc == 0
@@ -182,7 +192,9 @@ def test_main_converts_xml_via_flag(
182
192
  lambda _, **kw: "rows[3]{id,name,role}:\n 1,Ada,admin\n",
183
193
  )
184
194
  token_seq = iter([100, 30])
185
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
195
+ monkeypatch.setattr(
196
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
197
+ )
186
198
 
187
199
  rc = main([str(f), "--format", "xml"])
188
200
  assert rc == 0
@@ -223,7 +235,9 @@ def test_main_csv_report_stdout(
223
235
  lambda _, **kw: "rows[3]{id,name,role}:\n 1,Ada,admin\n",
224
236
  )
225
237
  token_seq = iter([100, 30])
226
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
238
+ monkeypatch.setattr(
239
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
240
+ )
227
241
 
228
242
  rc = main([str(csv_file), "--report-stdout"])
229
243
  assert rc == 0
@@ -244,7 +258,9 @@ def test_main_csv_skips_when_savings_insufficient(
244
258
  lambda _, **kw: "rows[3]{id,name,role}:\n 1,Ada,admin\n",
245
259
  )
246
260
  token_seq = iter([100, 98])
247
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
261
+ monkeypatch.setattr(
262
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
263
+ )
248
264
 
249
265
  rc = main([str(csv_file), "--report-stdout"])
250
266
  assert rc == 0
@@ -315,3 +331,23 @@ def test_main_forwards_sheet_and_table_for_binary(
315
331
  )
316
332
  assert rc == 0
317
333
  assert captured == {"sheet": 2, "table": 1}
334
+
335
+
336
+ def test_main_forwards_encoding_to_estimate(
337
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
338
+ ) -> None:
339
+ payload = '{"rows":[{"id":1,"v":"a"},{"id":2,"v":"b"},{"id":3,"v":"c"}]}'
340
+ monkeypatch.setattr("sys.stdin", __import__("io").StringIO(payload))
341
+ monkeypatch.setattr("datoon.converter._run_toon_cli", lambda _, **kw: "x\n")
342
+
343
+ seen: list[str] = []
344
+
345
+ def fake_estimate(text: str, encoding: str = "o200k_base") -> int:
346
+ seen.append(encoding)
347
+ return 50 if len(seen) == 1 else 10
348
+
349
+ monkeypatch.setattr("datoon.converter.estimate_tokens", fake_estimate)
350
+
351
+ rc = main(["--force", "--encoding", "cl100k_base"])
352
+ assert rc == 0
353
+ assert seen and all(enc == "cl100k_base" for enc in seen)
@@ -107,7 +107,7 @@ def test_policy_converts_when_savings_above_threshold(
107
107
 
108
108
  token_sequence = iter([100, 60]) # input, output
109
109
  monkeypatch.setattr(
110
- "datoon.converter.estimate_tokens", lambda _: next(token_sequence)
110
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_sequence)
111
111
  )
112
112
 
113
113
  outcome = convert_json_for_llm(payload, ConversionConfig(min_savings_ratio=0.15))
@@ -127,7 +127,7 @@ def test_policy_skips_when_savings_below_threshold(
127
127
 
128
128
  token_sequence = iter([100, 95]) # input, output
129
129
  monkeypatch.setattr(
130
- "datoon.converter.estimate_tokens", lambda _: next(token_sequence)
130
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_sequence)
131
131
  )
132
132
 
133
133
  outcome = convert_json_for_llm(payload, ConversionConfig(min_savings_ratio=0.10))
@@ -0,0 +1,39 @@
1
+ """Tests for the datoon.errors module and its use across layers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+
7
+ import pytest
8
+
9
+
10
+ def test_datoon_error_lives_in_errors_module() -> None:
11
+ from datoon.errors import DatoonError
12
+
13
+ assert issubclass(DatoonError, RuntimeError)
14
+
15
+
16
+ def test_converter_reexports_the_same_error_object() -> None:
17
+ # Backward compatibility: `from datoon.converter import DatoonError` must
18
+ # keep working and refer to the single canonical class.
19
+ from datoon.converter import DatoonError as FromConverter
20
+ from datoon.errors import DatoonError as Canonical
21
+
22
+ assert FromConverter is Canonical
23
+
24
+
25
+ def test_jsonl_reader_does_not_depend_on_converter() -> None:
26
+ # The low-level reader must not import the high-level conversion service
27
+ # just for an exception type (layering inversion).
28
+ import datoon.readers.jsonl as jsonl
29
+
30
+ source = inspect.getsource(jsonl)
31
+ assert "datoon.converter" not in source
32
+
33
+
34
+ def test_jsonl_raises_canonical_error() -> None:
35
+ from datoon.errors import DatoonError
36
+ from datoon.readers.jsonl import read_jsonl
37
+
38
+ with pytest.raises(DatoonError, match="line 2"):
39
+ read_jsonl('{"a":1}\nbad json\n')
@@ -92,7 +92,9 @@ def test_convert_json_converts_when_savings_sufficient(
92
92
  lambda _, **kw: "rows[3]{id,v}:\n 1,a\n 2,b\n 3,c\n",
93
93
  )
94
94
  token_seq = iter([100, 60])
95
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
95
+ monkeypatch.setattr(
96
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
97
+ )
96
98
 
97
99
  result = convert_json(payload, min_savings=0.15)
98
100
  assert "error" not in result
@@ -122,7 +124,9 @@ def test_convert_text_csv_converts(monkeypatch: pytest.MonkeyPatch) -> None:
122
124
  **kw: "rows[3]{id,name,role}:\n 1,Ada,admin\n 2,Lin,analyst\n 3,Grace,viewer\n",
123
125
  )
124
126
  token_seq = iter([100, 40])
125
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
127
+ monkeypatch.setattr(
128
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
129
+ )
126
130
 
127
131
  result = convert_text(csv_text, fmt="csv")
128
132
  assert "error" not in result
@@ -141,7 +145,9 @@ def test_convert_text_jsonl_converts(monkeypatch: pytest.MonkeyPatch) -> None:
141
145
  lambda _, **kw: "rows[3]{id,name,role}:\n 1,Ada,admin\n",
142
146
  )
143
147
  token_seq = iter([100, 40])
144
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
148
+ monkeypatch.setattr(
149
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
150
+ )
145
151
 
146
152
  result = convert_text(jsonl_text, fmt="jsonl")
147
153
  assert "error" not in result
@@ -162,7 +168,9 @@ def test_convert_text_xml_converts(monkeypatch: pytest.MonkeyPatch) -> None:
162
168
  lambda _, **kw: "rows[3]{id,name}:\n 1,Ada\n",
163
169
  )
164
170
  token_seq = iter([100, 40])
165
- monkeypatch.setattr("datoon.converter.estimate_tokens", lambda _: next(token_seq))
171
+ monkeypatch.setattr(
172
+ "datoon.converter.estimate_tokens", lambda *_a, **_k: next(token_seq)
173
+ )
166
174
 
167
175
  result = convert_text(xml_text, fmt="xml")
168
176
  assert "error" not in result
@@ -8,6 +8,7 @@ from unittest.mock import MagicMock
8
8
 
9
9
  import pytest
10
10
 
11
+ from datoon.errors import DatoonError
11
12
  from datoon.readers import (
12
13
  ALL_FORMATS,
13
14
  BINARY_FORMATS,
@@ -215,6 +216,39 @@ def test_read_yaml_missing_dep(monkeypatch: pytest.MonkeyPatch) -> None:
215
216
  yaml_reader.read_yaml("- id: 1")
216
217
 
217
218
 
219
+ def test_read_yaml_rejects_list_of_scalars(monkeypatch: pytest.MonkeyPatch) -> None:
220
+ yaml_mock = MagicMock()
221
+ yaml_mock.safe_load.return_value = [1, 2, 3]
222
+ monkeypatch.setitem(sys.modules, "yaml", yaml_mock)
223
+
224
+ from datoon.readers.yaml import read_yaml
225
+
226
+ with pytest.raises(DatoonError, match="list of objects"):
227
+ read_yaml("- 1\n- 2\n")
228
+
229
+
230
+ def test_read_yaml_rejects_mixed_list(monkeypatch: pytest.MonkeyPatch) -> None:
231
+ yaml_mock = MagicMock()
232
+ yaml_mock.safe_load.return_value = [{"id": 1}, "oops", {"id": 3}]
233
+ monkeypatch.setitem(sys.modules, "yaml", yaml_mock)
234
+
235
+ from datoon.readers.yaml import read_yaml
236
+
237
+ with pytest.raises(DatoonError, match="list of objects"):
238
+ read_yaml("- id: 1\n- oops\n")
239
+
240
+
241
+ def test_read_yaml_rejects_bad_shape(monkeypatch: pytest.MonkeyPatch) -> None:
242
+ yaml_mock = MagicMock()
243
+ yaml_mock.safe_load.return_value = 42
244
+ monkeypatch.setitem(sys.modules, "yaml", yaml_mock)
245
+
246
+ from datoon.readers.yaml import read_yaml
247
+
248
+ with pytest.raises(DatoonError, match="list of objects"):
249
+ read_yaml("42\n")
250
+
251
+
218
252
  # ---------------------------------------------------------------------------
219
253
  # XML reader
220
254
  # ---------------------------------------------------------------------------
@@ -258,7 +292,7 @@ def test_read_xml_rejects_dtd_billion_laughs() -> None:
258
292
  '<!ENTITY lol2 "&lol;&lol;&lol;">]>'
259
293
  "<rows><r>&lol2;</r></rows>"
260
294
  )
261
- with pytest.raises(ValueError, match="DOCTYPE"):
295
+ with pytest.raises(DatoonError, match="DOCTYPE"):
262
296
  read_xml(payload)
263
297
 
264
298
 
@@ -267,17 +301,17 @@ def test_read_xml_rejects_external_entity_dtd() -> None:
267
301
  '<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>'
268
302
  "<rows><r>&xxe;</r></rows>"
269
303
  )
270
- with pytest.raises(ValueError, match="DOCTYPE"):
304
+ with pytest.raises(DatoonError, match="DOCTYPE"):
271
305
  read_xml(payload)
272
306
 
273
307
 
274
308
  def test_read_xml_invalid_raises() -> None:
275
- with pytest.raises(ValueError, match="Invalid XML"):
309
+ with pytest.raises(DatoonError, match="Invalid XML"):
276
310
  read_xml("<not closed>")
277
311
 
278
312
 
279
313
  def test_read_xml_empty_root_raises() -> None:
280
- with pytest.raises(ValueError, match="no children"):
314
+ with pytest.raises(DatoonError, match="no children"):
281
315
  read_xml("<root/>")
282
316
 
283
317
 
@@ -0,0 +1,43 @@
1
+ """Tests for configurable token-encoding estimation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from datoon.converter import estimate_tokens
8
+ from datoon.models import DEFAULT_TOKEN_ENCODING, ConversionConfig
9
+
10
+
11
+ def test_default_encoding_is_o200k() -> None:
12
+ assert DEFAULT_TOKEN_ENCODING == "o200k_base"
13
+ assert ConversionConfig().token_encoding == "o200k_base"
14
+
15
+
16
+ def test_config_rejects_empty_encoding() -> None:
17
+ with pytest.raises(ValueError, match="token_encoding"):
18
+ ConversionConfig(token_encoding="")
19
+
20
+
21
+ def test_estimate_tokens_respects_encoding() -> None:
22
+ tiktoken = pytest.importorskip("tiktoken")
23
+ text = '{"rows":[{"id":1,"name":"Ada","role":"admin"}]}'
24
+
25
+ for name in ("o200k_base", "cl100k_base"):
26
+ expected = len(tiktoken.get_encoding(name).encode(text))
27
+ assert estimate_tokens(text, encoding=name) == expected
28
+
29
+
30
+ def test_estimate_tokens_falls_back_to_heuristic_without_tiktoken(
31
+ monkeypatch: pytest.MonkeyPatch,
32
+ ) -> None:
33
+ import sys
34
+
35
+ import datoon.converter as conv
36
+
37
+ conv._load_token_encoder.cache_clear()
38
+ monkeypatch.setitem(sys.modules, "tiktoken", None) # type: ignore[arg-type]
39
+ try:
40
+ # 30 chars -> ceil(30 / 3) == 10 by the char heuristic.
41
+ assert estimate_tokens("x" * 30, encoding="o200k_base") == 10
42
+ finally:
43
+ conv._load_token_encoder.cache_clear()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes