test_count_tokens.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. #!/usr/bin/env python3
  2. """Tests for count_tokens.py.
  3. Covers the output schema, the tiktoken path and the forced-fallback path
  4. agreeing within tolerance, the CLI over a file and over stdin, and argument
  5. guards. Run with: python3 -m pytest test_count_tokens.py
  6. (or plain `python3 test_count_tokens.py` to run a lightweight self-check).
  7. """
  8. import builtins
  9. import importlib.util
  10. import json
  11. import subprocess
  12. import sys
  13. from pathlib import Path
  14. SCRIPT = Path(__file__).resolve().parent.parent / "count_tokens.py"
  15. SAMPLE = (
  16. "The builder is platform-agnostic. Nothing assumes a single runtime, and no "
  17. "model list is ever hardcoded. Token counts replace line counts as the one "
  18. "length metric, with a chars-over-four fallback when tiktoken is absent.\n"
  19. ) * 8
  20. def _load_module():
  21. spec = importlib.util.spec_from_file_location("count_tokens", SCRIPT)
  22. mod = importlib.util.module_from_spec(spec)
  23. spec.loader.exec_module(mod)
  24. return mod
  25. def test_tiktoken_path():
  26. mod = _load_module()
  27. try:
  28. import tiktoken # noqa: F401
  29. except Exception:
  30. # No tiktoken in this interpreter; the real path can't be exercised here.
  31. tokens, method = mod.count_tokens(SAMPLE)
  32. assert method == "fallback"
  33. assert tokens == len(SAMPLE) // 4
  34. return
  35. tokens, method = mod.count_tokens(SAMPLE)
  36. assert method == "tiktoken"
  37. assert isinstance(tokens, int)
  38. assert tokens > 0
  39. def test_fallback_path_when_import_blocked():
  40. """Force the import of tiktoken to fail and confirm the fallback fires."""
  41. mod = _load_module()
  42. real_import = builtins.__import__
  43. def blocked_import(name, *args, **kwargs):
  44. if name == "tiktoken" or name.startswith("tiktoken."):
  45. raise ImportError("blocked for test")
  46. return real_import(name, *args, **kwargs)
  47. builtins.__import__ = blocked_import
  48. try:
  49. tokens, method = mod.count_tokens(SAMPLE)
  50. finally:
  51. builtins.__import__ = real_import
  52. assert method == "fallback"
  53. assert tokens == len(SAMPLE) // 4
  54. def test_paths_agree_within_tolerance():
  55. """tiktoken and chars//4 should be in the same order of magnitude.
  56. Skipped when tiktoken is not installed (nothing to compare against).
  57. """
  58. mod = _load_module()
  59. try:
  60. import tiktoken # noqa: F401
  61. except Exception:
  62. return
  63. real_tokens, real_method = mod.count_tokens(SAMPLE)
  64. assert real_method == "tiktoken"
  65. fallback_tokens = len(SAMPLE) // 4
  66. # The chars//4 heuristic is a rough proxy; require it within +/-50% of the
  67. # real count so the fallback stays a usable budget gate, not a wild guess.
  68. lower = real_tokens * 0.5
  69. upper = real_tokens * 1.5
  70. assert lower <= fallback_tokens <= upper, (
  71. f"fallback {fallback_tokens} not within 50% of tiktoken {real_tokens}"
  72. )
  73. def test_cli_file_output_schema(tmp_path):
  74. f = tmp_path / "sample.md"
  75. f.write_text(SAMPLE, encoding="utf-8")
  76. out = subprocess.run(
  77. [sys.executable, str(SCRIPT), str(f)],
  78. capture_output=True, text=True, check=True,
  79. ).stdout
  80. data = json.loads(out)
  81. assert set(data.keys()) == {"tokens", "method"}
  82. assert isinstance(data["tokens"], int)
  83. assert data["method"] in ("tiktoken", "fallback")
  84. assert data["tokens"] > 0
  85. def test_cli_stdin_output_schema():
  86. out = subprocess.run(
  87. [sys.executable, str(SCRIPT), "--stdin"],
  88. input=SAMPLE, capture_output=True, text=True, check=True,
  89. ).stdout
  90. data = json.loads(out)
  91. assert set(data.keys()) == {"tokens", "method"}
  92. assert isinstance(data["tokens"], int)
  93. assert data["method"] in ("tiktoken", "fallback")
  94. def test_cli_file_and_stdin_agree():
  95. """The CLI over a file and over stdin produce the same count for same text."""
  96. import tempfile, os
  97. fd, name = tempfile.mkstemp(suffix=".md")
  98. try:
  99. with os.fdopen(fd, "w", encoding="utf-8") as fh:
  100. fh.write(SAMPLE)
  101. file_out = json.loads(subprocess.run(
  102. [sys.executable, str(SCRIPT), name],
  103. capture_output=True, text=True, check=True,
  104. ).stdout)
  105. finally:
  106. os.unlink(name)
  107. stdin_out = json.loads(subprocess.run(
  108. [sys.executable, str(SCRIPT), "--stdin"],
  109. input=SAMPLE, capture_output=True, text=True, check=True,
  110. ).stdout)
  111. assert file_out == stdin_out
  112. def test_cli_requires_an_input():
  113. """No file and no --stdin is a usage error (exit 2 from argparse)."""
  114. res = subprocess.run(
  115. [sys.executable, str(SCRIPT)],
  116. capture_output=True, text=True,
  117. )
  118. assert res.returncode != 0
  119. def _run_all():
  120. import tempfile
  121. failures = 0
  122. tests = [
  123. test_tiktoken_path,
  124. test_fallback_path_when_import_blocked,
  125. test_paths_agree_within_tolerance,
  126. test_cli_stdin_output_schema,
  127. test_cli_file_and_stdin_agree,
  128. test_cli_requires_an_input,
  129. ]
  130. for t in tests:
  131. try:
  132. t()
  133. print(f"PASS {t.__name__}")
  134. except AssertionError as e:
  135. failures += 1
  136. print(f"FAIL {t.__name__}: {e}")
  137. except Exception as e:
  138. failures += 1
  139. print(f"ERROR {t.__name__}: {e}")
  140. # tmp_path-based test handled separately
  141. with tempfile.TemporaryDirectory() as d:
  142. try:
  143. test_cli_file_output_schema(Path(d))
  144. print("PASS test_cli_file_output_schema")
  145. except Exception as e:
  146. failures += 1
  147. print(f"FAIL test_cli_file_output_schema: {e}")
  148. return failures
  149. if __name__ == "__main__":
  150. sys.exit(1 if _run_all() else 0)