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/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