bmad 6.11
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Tests for word_metrics.py."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from word_metrics import section_metrics, word_count
|
||||
|
||||
DOC = """Intro line before any heading.
|
||||
|
||||
# Title
|
||||
|
||||
Two words here indeed.
|
||||
|
||||
## Section A
|
||||
|
||||
Alpha beta gamma.
|
||||
|
||||
```
|
||||
# not a heading
|
||||
fenced words ignored as headings
|
||||
```
|
||||
|
||||
## Section B
|
||||
|
||||
Delta epsilon.
|
||||
"""
|
||||
|
||||
|
||||
class WordMetricsTest(unittest.TestCase):
|
||||
def test_word_count(self):
|
||||
self.assertEqual(word_count("one two three\nfour"), 4)
|
||||
self.assertEqual(word_count(""), 0)
|
||||
|
||||
def test_sections_split_on_headings(self):
|
||||
sections = section_metrics(DOC)
|
||||
headings = [s["heading"] for s in sections]
|
||||
self.assertEqual(headings, ["(preamble)", "Title", "Section A", "Section B"])
|
||||
|
||||
def test_fenced_heading_not_a_section(self):
|
||||
sections = section_metrics(DOC)
|
||||
self.assertNotIn("not a heading", [s["heading"] for s in sections])
|
||||
|
||||
def test_section_words_counted(self):
|
||||
sections = {s["heading"]: s["words"] for s in section_metrics(DOC)}
|
||||
self.assertEqual(sections["Section B"], 2)
|
||||
# Section A body includes the fenced block's tokens
|
||||
self.assertGreater(sections["Section A"], 3)
|
||||
|
||||
def test_empty_preamble_dropped(self):
|
||||
sections = section_metrics("# Only\n\nwords here\n")
|
||||
self.assertEqual([s["heading"] for s in sections], ["Only"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Exact word counts for a document, as JSON.
|
||||
|
||||
Emits the document's total word count and a per-heading-section breakdown so
|
||||
an editorial review can ground word-impact estimates and reduction
|
||||
percentages in real numbers instead of guessing. Sections are delimited by
|
||||
markdown headings (# through ######); heading markers inside fenced code
|
||||
blocks are ignored (fences pair CommonMark-style: a fence closes only on a
|
||||
run of the same character at least as long, so ```` fences may embed ```
|
||||
examples). A word is any whitespace-separated token, plus one word per CJK
|
||||
character since those scripts do not space-delimit words. For non-markdown
|
||||
input the result is a single section holding the full text.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HEADING = re.compile(r"^(#{1,6})\s+(\S.*)$")
|
||||
FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
||||
CJK = re.compile(r"[-ヿ㐀-䶿一-鿿豈-가-ヲ-゚]")
|
||||
|
||||
|
||||
def word_count(text: str) -> int:
|
||||
cjk = len(CJK.findall(text))
|
||||
return cjk + len(CJK.sub(" ", text).split())
|
||||
|
||||
|
||||
def section_metrics(text: str) -> list[dict]:
|
||||
sections = []
|
||||
current = {"heading": "(preamble)", "level": 0, "body": []}
|
||||
open_fence = None # (char, length) while inside a fenced block
|
||||
for line in text.splitlines():
|
||||
fence = FENCE.match(line)
|
||||
if fence:
|
||||
marker = fence.group(1)
|
||||
if open_fence is None:
|
||||
open_fence = (marker[0], len(marker))
|
||||
elif marker[0] == open_fence[0] and len(marker) >= open_fence[1] and line.strip() == marker:
|
||||
open_fence = None
|
||||
current["body"].append(line)
|
||||
continue
|
||||
match = None if open_fence else HEADING.match(line)
|
||||
if match:
|
||||
sections.append(current)
|
||||
current = {
|
||||
"heading": match.group(2).strip(),
|
||||
"level": len(match.group(1)),
|
||||
"body": [],
|
||||
}
|
||||
else:
|
||||
current["body"].append(line)
|
||||
sections.append(current)
|
||||
|
||||
out = []
|
||||
for section in sections:
|
||||
words = word_count("\n".join(section["body"]))
|
||||
if section["heading"] == "(preamble)" and words == 0:
|
||||
continue
|
||||
out.append(
|
||||
{"heading": section["heading"], "level": section["level"], "words": words}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def metrics(path: Path) -> dict:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
return {
|
||||
"file": str(path),
|
||||
"total_words": word_count(text),
|
||||
"sections": section_metrics(text),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8") # JSON is UTF-8 regardless of locale code page
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("path", help="document to measure")
|
||||
parser.add_argument("-o", "--output", help="write JSON here (default: stdout)")
|
||||
args = parser.parse_args()
|
||||
|
||||
path = Path(args.path)
|
||||
if not path.is_file():
|
||||
print(f"error: not a readable file: {path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = json.dumps(metrics(path), indent=2, ensure_ascii=False)
|
||||
if args.output:
|
||||
Path(args.output).write_text(result + "\n", encoding="utf-8")
|
||||
else:
|
||||
print(result)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user