bpp-format 0.3.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.
- bpp/__init__.py +36 -0
- bpp/__main__.py +7 -0
- bpp/cli.py +239 -0
- bpp/decoder.py +402 -0
- bpp/encoder.py +388 -0
- bpp/estimate.py +35 -0
- bpp/formats.py +205 -0
- bpp/lexer.py +241 -0
- bpp/markdown.py +160 -0
- bpp/tokens.py +185 -0
- bpp_format-0.3.0.dist-info/METADATA +327 -0
- bpp_format-0.3.0.dist-info/RECORD +16 -0
- bpp_format-0.3.0.dist-info/WHEEL +5 -0
- bpp_format-0.3.0.dist-info/entry_points.txt +2 -0
- bpp_format-0.3.0.dist-info/licenses/LICENSE +21 -0
- bpp_format-0.3.0.dist-info/top_level.txt +1 -0
bpp/lexer.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Scalar, key and inline-value syntax shared by the encoder and decoder (SPEC §2-§4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
NUM_RE = re.compile(r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?")
|
|
10
|
+
SPECIAL = {"null": None, "true": True, "false": False,
|
|
11
|
+
"NaN": math.nan, "Infinity": math.inf, "-Infinity": -math.inf}
|
|
12
|
+
_CTRL = re.compile(r"[\x00-\x1f\x7f]")
|
|
13
|
+
BARE_KEY_RE = re.compile(r"[^\s\"\[\]{}=,:?>\x00-\x1f\x7f]+")
|
|
14
|
+
BARE_SEG_RE = re.compile(r"[^\s\"\[\]{}=,:?>.\x00-\x1f\x7f]+") # a column-path segment
|
|
15
|
+
_KV_START = re.compile(r"(?:[^\s\"\[\]{}=,:?>]+|\"(?:[^\"\\]|\\.)*\")=")
|
|
16
|
+
_DECODER = json.JSONDecoder()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BppError(ValueError):
|
|
20
|
+
"""Raised for malformed .bpp input."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, msg: str, line: int | None = None):
|
|
23
|
+
super().__init__(f"line {line}: {msg}" if line else msg)
|
|
24
|
+
self.line = line
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Ref:
|
|
28
|
+
"""Placeholder for a dictionary reference (`*n`) produced by the encoder."""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("idx",)
|
|
31
|
+
|
|
32
|
+
def __init__(self, idx: int):
|
|
33
|
+
self.idx = idx
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_scalar(v) -> bool:
|
|
37
|
+
return v is None or isinstance(v, (str, bool, int, float, Ref))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def jstr(s: str) -> str:
|
|
41
|
+
return json.dumps(s, ensure_ascii=False)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------- formatting
|
|
45
|
+
|
|
46
|
+
def fmt_number(v) -> str:
|
|
47
|
+
if isinstance(v, int):
|
|
48
|
+
return str(v)
|
|
49
|
+
if math.isnan(v):
|
|
50
|
+
return "NaN"
|
|
51
|
+
if math.isinf(v):
|
|
52
|
+
return "Infinity" if v > 0 else "-Infinity"
|
|
53
|
+
return repr(v)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def needs_quote(s: str, ctx: str = "value", strmode: bool = False) -> bool:
|
|
57
|
+
"""Minimum-quoting rule (SPEC §2.1). ctx: value|cell|list|pos|item|last."""
|
|
58
|
+
if (not s or s != s.strip() or _CTRL.search(s) or s[0] in '"[{*&#'
|
|
59
|
+
or s in SPECIAL):
|
|
60
|
+
return True
|
|
61
|
+
if not strmode and NUM_RE.fullmatch(s):
|
|
62
|
+
return True
|
|
63
|
+
if ctx == "cell":
|
|
64
|
+
return "," in s
|
|
65
|
+
if ctx == "list":
|
|
66
|
+
return "," in s or "]" in s
|
|
67
|
+
if ctx == "pos":
|
|
68
|
+
return " " in s or s == "-"
|
|
69
|
+
if ctx == "item":
|
|
70
|
+
return " " in s or "[" in s or "{" in s
|
|
71
|
+
if ctx == "last":
|
|
72
|
+
return _KV_START.match(s) is not None
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fmt_scalar(v, ctx: str = "value", strmode: bool = False) -> str:
|
|
77
|
+
if isinstance(v, Ref):
|
|
78
|
+
return f"*{v.idx}"
|
|
79
|
+
if v is None:
|
|
80
|
+
return "null"
|
|
81
|
+
if v is True:
|
|
82
|
+
return "true"
|
|
83
|
+
if v is False:
|
|
84
|
+
return "false"
|
|
85
|
+
if isinstance(v, (int, float)):
|
|
86
|
+
return fmt_number(v)
|
|
87
|
+
return jstr(v) if needs_quote(v, ctx, strmode) else v
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def fmt_key(k: str) -> str:
|
|
91
|
+
if BARE_KEY_RE.fullmatch(k) and k[0] not in "-#&*":
|
|
92
|
+
return k
|
|
93
|
+
return jstr(k)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fmt_seg(k: str) -> str:
|
|
97
|
+
"""A key inside a table header, where '.' separates path segments."""
|
|
98
|
+
if BARE_SEG_RE.fullmatch(k) and k[0] not in "-#&*":
|
|
99
|
+
return k
|
|
100
|
+
return jstr(k)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fmt_inline(v, ctx: str = "value", strmode: bool = False) -> str | None:
|
|
104
|
+
"""Single-line form of v, or None if v needs a block."""
|
|
105
|
+
if is_scalar(v):
|
|
106
|
+
return fmt_scalar(v, ctx, strmode)
|
|
107
|
+
if isinstance(v, dict):
|
|
108
|
+
return "{}" if not v else None
|
|
109
|
+
if isinstance(v, list):
|
|
110
|
+
if all(is_scalar(x) for x in v):
|
|
111
|
+
return "[" + ",".join(fmt_scalar(x, "list", strmode) for x in v) + "]"
|
|
112
|
+
return None
|
|
113
|
+
raise TypeError(f"unsupported type {type(v).__name__}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------- parsing
|
|
117
|
+
|
|
118
|
+
def parse_bare(tok: str, strmode: bool = False):
|
|
119
|
+
"""Interpret an unquoted token (no surrounding whitespace)."""
|
|
120
|
+
if strmode:
|
|
121
|
+
return None if tok == "null" else tok
|
|
122
|
+
if tok in SPECIAL:
|
|
123
|
+
return SPECIAL[tok]
|
|
124
|
+
if NUM_RE.fullmatch(tok):
|
|
125
|
+
if "." in tok or "e" in tok or "E" in tok:
|
|
126
|
+
return float(tok)
|
|
127
|
+
return int(tok)
|
|
128
|
+
return tok
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Cursor:
|
|
132
|
+
"""Scans one line of text; `refs` resolves `*n`."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, text: str, refs: list, line: int | None):
|
|
135
|
+
self.s, self.i, self.refs, self.line = text, 0, refs, line
|
|
136
|
+
|
|
137
|
+
def err(self, msg):
|
|
138
|
+
raise BppError(msg, self.line)
|
|
139
|
+
|
|
140
|
+
def eof(self):
|
|
141
|
+
return self.i >= len(self.s)
|
|
142
|
+
|
|
143
|
+
def peek(self):
|
|
144
|
+
return self.s[self.i] if self.i < len(self.s) else ""
|
|
145
|
+
|
|
146
|
+
def expect(self, ch):
|
|
147
|
+
if self.peek() != ch:
|
|
148
|
+
self.err(f"expected {ch!r} at column {self.i + 1}")
|
|
149
|
+
self.i += 1
|
|
150
|
+
|
|
151
|
+
def jstring(self) -> str:
|
|
152
|
+
try:
|
|
153
|
+
val, end = _DECODER.raw_decode(self.s, self.i)
|
|
154
|
+
except json.JSONDecodeError as e:
|
|
155
|
+
self.err(f"bad quoted string: {e.msg}")
|
|
156
|
+
if not isinstance(val, str):
|
|
157
|
+
self.err("expected string")
|
|
158
|
+
self.i = end
|
|
159
|
+
return val
|
|
160
|
+
|
|
161
|
+
def key(self) -> str:
|
|
162
|
+
if self.peek() == '"':
|
|
163
|
+
return self.jstring()
|
|
164
|
+
m = BARE_KEY_RE.match(self.s, self.i)
|
|
165
|
+
if not m or self.s[self.i] in "-#&*":
|
|
166
|
+
self.err("expected key")
|
|
167
|
+
self.i = m.end()
|
|
168
|
+
return m.group()
|
|
169
|
+
|
|
170
|
+
def segment(self) -> str:
|
|
171
|
+
if self.peek() == '"':
|
|
172
|
+
return self.jstring()
|
|
173
|
+
m = BARE_SEG_RE.match(self.s, self.i)
|
|
174
|
+
if not m or self.s[self.i] in "-#&*":
|
|
175
|
+
self.err("expected key")
|
|
176
|
+
self.i = m.end()
|
|
177
|
+
return m.group()
|
|
178
|
+
|
|
179
|
+
def path(self, dotted: bool) -> tuple:
|
|
180
|
+
"""A column path `a.b.c` (bpp3); in bpp1/2 headers a column is one key."""
|
|
181
|
+
if not dotted:
|
|
182
|
+
return (self.key(),)
|
|
183
|
+
out = [self.segment()]
|
|
184
|
+
while self.peek() == ".":
|
|
185
|
+
self.i += 1
|
|
186
|
+
out.append(self.segment())
|
|
187
|
+
return tuple(out)
|
|
188
|
+
|
|
189
|
+
def resolve(self, tok: str, strmode: bool):
|
|
190
|
+
if tok.startswith("*") and tok[1:].isdigit():
|
|
191
|
+
idx = int(tok[1:])
|
|
192
|
+
if idx >= len(self.refs):
|
|
193
|
+
self.err(f"undefined reference {tok}")
|
|
194
|
+
return self.refs[idx]
|
|
195
|
+
return parse_bare(tok, strmode)
|
|
196
|
+
|
|
197
|
+
def token(self, stops: str, strmode: bool = False):
|
|
198
|
+
"""A scalar ending before any char in `stops` (or end of line)."""
|
|
199
|
+
if self.peek() == '"':
|
|
200
|
+
return self.jstring()
|
|
201
|
+
j = self.i
|
|
202
|
+
while j < len(self.s) and self.s[j] not in stops:
|
|
203
|
+
j += 1
|
|
204
|
+
tok = self.s[self.i:j]
|
|
205
|
+
if not tok:
|
|
206
|
+
self.err(f"empty value at column {self.i + 1}")
|
|
207
|
+
self.i = j
|
|
208
|
+
return self.resolve(tok, strmode)
|
|
209
|
+
|
|
210
|
+
def inline_list(self, strmode: bool = False) -> list:
|
|
211
|
+
self.expect("[")
|
|
212
|
+
out = []
|
|
213
|
+
if self.peek() == "]":
|
|
214
|
+
self.i += 1
|
|
215
|
+
return out
|
|
216
|
+
while True:
|
|
217
|
+
out.append(self.token(",]", strmode))
|
|
218
|
+
c = self.peek()
|
|
219
|
+
self.i += 1
|
|
220
|
+
if c == "]":
|
|
221
|
+
return out
|
|
222
|
+
if c != ",":
|
|
223
|
+
self.err("unterminated inline list")
|
|
224
|
+
|
|
225
|
+
def value(self, stops: str = "", strmode: bool = False):
|
|
226
|
+
"""Inline value: scalar, [list], {} (for `key value` stops is empty -> rest of line)."""
|
|
227
|
+
c = self.peek()
|
|
228
|
+
if c == "[":
|
|
229
|
+
return self.inline_list(strmode)
|
|
230
|
+
if self.s.startswith("{}", self.i):
|
|
231
|
+
self.i += 2
|
|
232
|
+
return {}
|
|
233
|
+
if not stops:
|
|
234
|
+
if c == '"':
|
|
235
|
+
return self.jstring()
|
|
236
|
+
tok = self.s[self.i:]
|
|
237
|
+
if not tok:
|
|
238
|
+
self.err("missing value")
|
|
239
|
+
self.i = len(self.s)
|
|
240
|
+
return self.resolve(tok, strmode)
|
|
241
|
+
return self.token(stops, strmode)
|
bpp/markdown.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Markdown plans <-> tree (SPEC §7).
|
|
2
|
+
|
|
3
|
+
Headings and nested lists become a tree of steps:
|
|
4
|
+
|
|
5
|
+
{"title": "...", "note": "...", "steps": [
|
|
6
|
+
{"title": "...", "status": "done", "note": "...", "steps": [...]}]}
|
|
7
|
+
|
|
8
|
+
* A single level-1 heading becomes the document title.
|
|
9
|
+
* `- [ ]`, `- [x]`, `- [/]`, `- [-]` set status todo / done / doing / cancelled
|
|
10
|
+
(also allowed right after the `#`s of a heading).
|
|
11
|
+
* Paragraphs, code blocks and other text go to the `note` of the heading or
|
|
12
|
+
list item they belong to.
|
|
13
|
+
|
|
14
|
+
The conversion keeps structure, not formatting: `tree_to_md` writes a
|
|
15
|
+
normalized Markdown document (top-level steps as `##`, deeper steps as `-`
|
|
16
|
+
lists) which parses back to the same tree.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
_HEADING = re.compile(r"(#{1,6})\s+(.*?)\s*#*\s*$")
|
|
24
|
+
_ITEM = re.compile(r"( *)([-*+]|\d{1,9}[.)])(?: +(.*))?$")
|
|
25
|
+
_BOX = re.compile(r"\[([ xX/-])\] +")
|
|
26
|
+
_FENCE = re.compile(r"(```|~~~)")
|
|
27
|
+
STATUS = {" ": "todo", "x": "done", "X": "done", "/": "doing", "-": "cancelled"}
|
|
28
|
+
BOX = {"todo": " ", "done": "x", "doing": "/", "cancelled": "-"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _node(text: str) -> dict:
|
|
32
|
+
m = _BOX.match(text)
|
|
33
|
+
node: dict = {"title": (text[m.end():] if m else text).strip()}
|
|
34
|
+
if m:
|
|
35
|
+
node["status"] = STATUS[m.group(1)]
|
|
36
|
+
return node
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _add_note(node: dict, line: str):
|
|
40
|
+
node.setdefault("_note", []).append(line)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def md_to_tree(text: str) -> dict:
|
|
44
|
+
root: dict = {"steps": []}
|
|
45
|
+
headings: list[tuple[int, dict]] = [(0, root)]
|
|
46
|
+
items: list[tuple[int, int, dict]] = [] # (indent, content column, node)
|
|
47
|
+
fence = None # (marker, target node, strip column)
|
|
48
|
+
h1_nodes: list[dict] = []
|
|
49
|
+
|
|
50
|
+
for raw in text.replace("\r\n", "\n").split("\n"):
|
|
51
|
+
line = raw.expandtabs(4)
|
|
52
|
+
stripped = line.lstrip(" ")
|
|
53
|
+
indent = len(line) - len(stripped)
|
|
54
|
+
|
|
55
|
+
if fence:
|
|
56
|
+
marker, node, col = fence
|
|
57
|
+
_add_note(node, line[min(col, indent):] if stripped else "")
|
|
58
|
+
if stripped.startswith(marker):
|
|
59
|
+
fence = None
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if not stripped:
|
|
63
|
+
target = items[-1][2] if items else headings[-1][1]
|
|
64
|
+
if target.get("_note") and target["_note"][-1] != "":
|
|
65
|
+
_add_note(target, "")
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
m = _HEADING.match(stripped) if indent < 4 else None
|
|
69
|
+
if m:
|
|
70
|
+
level = len(m.group(1))
|
|
71
|
+
node = _node(m.group(2))
|
|
72
|
+
if level == 1:
|
|
73
|
+
h1_nodes.append(node)
|
|
74
|
+
while headings[-1][0] >= level:
|
|
75
|
+
headings.pop()
|
|
76
|
+
headings[-1][1].setdefault("steps", []).append(node)
|
|
77
|
+
headings.append((level, node))
|
|
78
|
+
items = []
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
m = _ITEM.match(line)
|
|
82
|
+
if m:
|
|
83
|
+
while items and items[-1][0] >= indent:
|
|
84
|
+
items.pop()
|
|
85
|
+
node = _node(m.group(3) or "")
|
|
86
|
+
parent = items[-1][2] if items else headings[-1][1]
|
|
87
|
+
parent.setdefault("steps", []).append(node)
|
|
88
|
+
items.append((indent, indent + len(m.group(2)) + 1, node))
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# text: goes to the innermost list item it is indented under
|
|
92
|
+
while items and indent < items[-1][1] and indent <= items[-1][0]:
|
|
93
|
+
items.pop()
|
|
94
|
+
if items:
|
|
95
|
+
node, col = items[-1][2], items[-1][1]
|
|
96
|
+
else:
|
|
97
|
+
node, col = headings[-1][1], 0
|
|
98
|
+
f = _FENCE.match(stripped)
|
|
99
|
+
if f:
|
|
100
|
+
fence = (f.group(1), node, col)
|
|
101
|
+
_add_note(node, line[min(col, indent):])
|
|
102
|
+
|
|
103
|
+
def finish(node: dict):
|
|
104
|
+
note = node.pop("_note", None)
|
|
105
|
+
while note and note[-1] == "":
|
|
106
|
+
note.pop()
|
|
107
|
+
if note:
|
|
108
|
+
node["note"] = "\n".join(note)
|
|
109
|
+
for child in node.get("steps", []):
|
|
110
|
+
finish(child)
|
|
111
|
+
return node
|
|
112
|
+
|
|
113
|
+
finish(root)
|
|
114
|
+
doc: dict = {}
|
|
115
|
+
top = root.get("steps", [])
|
|
116
|
+
if len(h1_nodes) == 1 and "status" not in h1_nodes[0]:
|
|
117
|
+
# The only '# ' heading is the document title. Anything that came
|
|
118
|
+
# before it (text, list items, lower headings) stays in front.
|
|
119
|
+
t = h1_nodes[0]
|
|
120
|
+
doc["title"] = t["title"]
|
|
121
|
+
notes = [n for n in (root.get("note"), t.get("note")) if n]
|
|
122
|
+
if notes:
|
|
123
|
+
doc["note"] = "\n\n".join(notes)
|
|
124
|
+
doc["steps"] = [n for n in top if n is not t] + t.get("steps", [])
|
|
125
|
+
return doc
|
|
126
|
+
if root.get("note"):
|
|
127
|
+
doc["note"] = root["note"]
|
|
128
|
+
doc["steps"] = top
|
|
129
|
+
return doc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def tree_to_md(doc: dict) -> str:
|
|
133
|
+
if not isinstance(doc, dict) or not isinstance(doc.get("steps", []), list):
|
|
134
|
+
raise ValueError("Markdown output needs a plan tree: {title?, note?, steps: [...]}")
|
|
135
|
+
out: list[str] = []
|
|
136
|
+
if doc.get("title") is not None:
|
|
137
|
+
out += [f"# {doc['title']}", ""]
|
|
138
|
+
if doc.get("note"):
|
|
139
|
+
out += [doc["note"], ""]
|
|
140
|
+
for node in doc.get("steps", []):
|
|
141
|
+
box = f"[{BOX[node['status']]}] " if node.get("status") in BOX else ""
|
|
142
|
+
out.append(f"## {box}{node.get('title', '')}")
|
|
143
|
+
out.append("")
|
|
144
|
+
if node.get("note"):
|
|
145
|
+
out += [node["note"], ""]
|
|
146
|
+
_items(node.get("steps", []), 0, out)
|
|
147
|
+
if node.get("steps"):
|
|
148
|
+
out.append("")
|
|
149
|
+
return "\n".join(out).rstrip("\n") + "\n"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _items(nodes, depth, out):
|
|
153
|
+
pad = " " * depth
|
|
154
|
+
for node in nodes:
|
|
155
|
+
box = f"[{BOX[node['status']]}] " if node.get("status") in BOX else ""
|
|
156
|
+
out.append(f"{pad}- {box}{node.get('title', '')}".rstrip())
|
|
157
|
+
if node.get("note"):
|
|
158
|
+
for ln in str(node["note"]).split("\n"):
|
|
159
|
+
out.append(f"{pad} {ln}" if ln else "")
|
|
160
|
+
_items(node.get("steps", []), depth + 1, out)
|
bpp/tokens.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Token counters used by `bpp stats` and the benchmarks.
|
|
2
|
+
|
|
3
|
+
Three counters, all optional:
|
|
4
|
+
|
|
5
|
+
* ``o200k`` - tiktoken ``o200k_base`` (modern OpenAI BPE; a rough proxy).
|
|
6
|
+
* ``claude2`` - the legacy Claude tokenizer Anthropic published in the
|
|
7
|
+
``@anthropic-ai/tokenizer`` npm package (Claude 2 era; a second proxy that
|
|
8
|
+
behaves more like Claude on non-English text).
|
|
9
|
+
* ``anthropic`` - the real ``messages.count_tokens`` endpoint. Used only when
|
|
10
|
+
the ``anthropic`` SDK is installed and ``ANTHROPIC_API_KEY`` is set.
|
|
11
|
+
|
|
12
|
+
Neither proxy is exact for current Claude models; relative comparisons between
|
|
13
|
+
formats are what matter here.
|
|
14
|
+
|
|
15
|
+
tiktoken normally downloads its rank files from a blob store that some
|
|
16
|
+
networks block. If that fails we fetch the same ranks from the npm registry
|
|
17
|
+
(``js-tiktoken`` ships them), verify the published SHA-256, and drop the file
|
|
18
|
+
into tiktoken's own cache so later calls work offline.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import hashlib
|
|
25
|
+
import io
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import tarfile
|
|
29
|
+
import tempfile
|
|
30
|
+
import urllib.request
|
|
31
|
+
from functools import lru_cache
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Callable
|
|
34
|
+
|
|
35
|
+
O200K_URL = "https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken"
|
|
36
|
+
O200K_SHA256 = "446a9538cb6c348e3516120d7c08b09f57c36495e2acfffe59a5bf8b0cfb1a2d"
|
|
37
|
+
JS_TIKTOKEN_TGZ = "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz"
|
|
38
|
+
CLAUDE_TGZ = "https://registry.npmjs.org/@anthropic-ai/tokenizer/-/tokenizer-0.0.4.tgz"
|
|
39
|
+
|
|
40
|
+
ANTHROPIC_MODEL = os.environ.get("BPP_ANTHROPIC_MODEL", "claude-opus-5")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cache_dir() -> Path:
|
|
44
|
+
d = os.environ.get("BPP_CACHE_DIR") or os.path.join(
|
|
45
|
+
os.path.expanduser("~"), ".cache", "bpp"
|
|
46
|
+
)
|
|
47
|
+
Path(d).mkdir(parents=True, exist_ok=True)
|
|
48
|
+
return Path(d)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _tiktoken_cache_dir() -> Path:
|
|
52
|
+
# Mirrors tiktoken.load.read_file_cached
|
|
53
|
+
if "TIKTOKEN_CACHE_DIR" in os.environ:
|
|
54
|
+
d = os.environ["TIKTOKEN_CACHE_DIR"]
|
|
55
|
+
elif "DATA_GYM_CACHE_DIR" in os.environ:
|
|
56
|
+
d = os.environ["DATA_GYM_CACHE_DIR"]
|
|
57
|
+
else:
|
|
58
|
+
d = os.path.join(tempfile.gettempdir(), "data-gym-cache")
|
|
59
|
+
Path(d).mkdir(parents=True, exist_ok=True)
|
|
60
|
+
return Path(d)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fetch_tgz_member(url: str, member_suffix: str) -> bytes:
|
|
64
|
+
with urllib.request.urlopen(url, timeout=60) as r:
|
|
65
|
+
data = r.read()
|
|
66
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
|
|
67
|
+
for m in tf.getmembers():
|
|
68
|
+
if m.name.endswith(member_suffix):
|
|
69
|
+
f = tf.extractfile(m)
|
|
70
|
+
assert f is not None
|
|
71
|
+
return f.read()
|
|
72
|
+
raise FileNotFoundError(f"{member_suffix} not in {url}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_js_ranks(text: str) -> dict:
|
|
76
|
+
"""Parse js-tiktoken's compressed rank format: '! <offset> <b64> <b64> ...'."""
|
|
77
|
+
if not text.lstrip().startswith("{"):
|
|
78
|
+
text = text[text.index("{") :].rstrip().rstrip(";")
|
|
79
|
+
return json.loads(text)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _expand_ranks(bpe_ranks: str) -> dict[bytes, int]:
|
|
83
|
+
ranks: dict[bytes, int] = {}
|
|
84
|
+
for line in bpe_ranks.split("\n"):
|
|
85
|
+
if not line:
|
|
86
|
+
continue
|
|
87
|
+
_, offset, *toks = line.split(" ")
|
|
88
|
+
for i, t in enumerate(toks):
|
|
89
|
+
ranks[base64.b64decode(t)] = int(offset) + i
|
|
90
|
+
return ranks
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def bootstrap_o200k() -> None:
|
|
94
|
+
"""Populate tiktoken's cache with o200k_base fetched via npm."""
|
|
95
|
+
path = _tiktoken_cache_dir() / hashlib.sha1(O200K_URL.encode()).hexdigest()
|
|
96
|
+
if path.exists():
|
|
97
|
+
return
|
|
98
|
+
d = _parse_js_ranks(
|
|
99
|
+
_fetch_tgz_member(JS_TIKTOKEN_TGZ, "dist/ranks/o200k_base.js").decode()
|
|
100
|
+
)
|
|
101
|
+
ranks = _expand_ranks(d["bpe_ranks"])
|
|
102
|
+
body = "".join(
|
|
103
|
+
base64.b64encode(b).decode() + " " + str(r) + "\n"
|
|
104
|
+
for b, r in sorted(ranks.items(), key=lambda kv: kv[1])
|
|
105
|
+
).encode()
|
|
106
|
+
if hashlib.sha256(body).hexdigest() != O200K_SHA256:
|
|
107
|
+
raise ValueError("o200k_base rebuilt from npm does not match tiktoken's hash")
|
|
108
|
+
path.write_bytes(body)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@lru_cache(maxsize=None)
|
|
112
|
+
def _o200k():
|
|
113
|
+
import tiktoken
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
return tiktoken.get_encoding("o200k_base")
|
|
117
|
+
except Exception:
|
|
118
|
+
bootstrap_o200k()
|
|
119
|
+
return tiktoken.get_encoding("o200k_base")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@lru_cache(maxsize=None)
|
|
123
|
+
def _claude2():
|
|
124
|
+
import tiktoken
|
|
125
|
+
|
|
126
|
+
path = _cache_dir() / "claude.json"
|
|
127
|
+
if not path.exists():
|
|
128
|
+
path.write_bytes(_fetch_tgz_member(CLAUDE_TGZ, "package/claude.json"))
|
|
129
|
+
d = json.loads(path.read_text(encoding="utf-8"))
|
|
130
|
+
return tiktoken.Encoding(
|
|
131
|
+
name="claude2",
|
|
132
|
+
pat_str=d["pat_str"],
|
|
133
|
+
mergeable_ranks=_expand_ranks(d["bpe_ranks"]),
|
|
134
|
+
special_tokens=d["special_tokens"],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@lru_cache(maxsize=None)
|
|
139
|
+
def _anthropic_client():
|
|
140
|
+
import anthropic
|
|
141
|
+
|
|
142
|
+
return anthropic.Anthropic()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@lru_cache(maxsize=4096)
|
|
146
|
+
def _count_anthropic(text: str) -> int:
|
|
147
|
+
resp = _anthropic_client().messages.count_tokens(
|
|
148
|
+
model=ANTHROPIC_MODEL, messages=[{"role": "user", "content": text}]
|
|
149
|
+
)
|
|
150
|
+
return resp.input_tokens
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _count_anthropic_net(text: str) -> int:
|
|
154
|
+
# count_tokens includes a fixed per-message overhead; subtract it so that
|
|
155
|
+
# the numbers are comparable to the local tokenizers.
|
|
156
|
+
return _count_anthropic(text) - _count_anthropic("x") + 1
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def available_counters() -> dict[str, Callable[[str], int]]:
|
|
160
|
+
"""Return the counters that work in this environment, name -> fn(text)."""
|
|
161
|
+
out: dict[str, Callable[[str], int]] = {}
|
|
162
|
+
try:
|
|
163
|
+
enc = _o200k()
|
|
164
|
+
out["o200k"] = lambda s: len(enc.encode_ordinary(s))
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
try:
|
|
168
|
+
enc2 = _claude2()
|
|
169
|
+
out["claude2"] = lambda s: len(enc2.encode_ordinary(s))
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
173
|
+
try:
|
|
174
|
+
_count_anthropic("x")
|
|
175
|
+
out["anthropic"] = _count_anthropic_net
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
if not out:
|
|
179
|
+
# Last resort so `bpp stats` still prints something useful: the
|
|
180
|
+
# encoder's own estimator (ranks formats like the real tokenizers do;
|
|
181
|
+
# a plain chars/4 gets the direction wrong for Turkish text).
|
|
182
|
+
from .estimate import est_tokens
|
|
183
|
+
|
|
184
|
+
out["estimate"] = est_tokens
|
|
185
|
+
return out
|