Files
troupe-standup/wizard-work/spark/line_counter.py
T
ledger a979e630a4 spark: one text-statistics tool; 25 counter and verifier files retired (Q125)
line_counter.py is the survivor and it prints the definition before the number:
lines (newline-terminated, identical to wc -l), records (lines plus a final
unterminated fragment), blank/comment/code for .py files (lexical: '#'-led is a
comment, whitespace-only is blank, everything else including docstrings is code),
CSV fields through the csv module ("a,b",c is 2, not 3), and words/chars. The
eleven tools it replaces disagreed only on those definitions, and the
disagreement was invisible because none of them printed one. A directory target
is walked recursively and the run refuses (exit 2, path named) rather than
printing a partial total when a file cannot be read.

Retired, and where each behaviour went:

  count_python.py          file/line walk -> `line_counter.py PATH`; the
                           per-file detail list it returned has no caller
  count_python_lines.py    code/comment/blank breakdown -> PATH report
  py_loc.py                same breakdown, same key names
  loc_counter.py           same breakdown; its argparse surface goes with it
  linecount_pycount.py     totals -> lines/records keys, its code count -> `code`
  code_stats.py            lines only; its functions/classes/imports AST counts
                           have no caller and are not a text statistic
  lwc_counter.py           -> `--words` (lines, words, chars)
  text_stats.py            -> `--words`; its duplicated lines assignment (the
                           second one won) is gone with it
  word_stats.py            -> `--words`; unique/top-5 tables have no caller
  file_stats.py            -> `--words`
  file_analyzer.py         -> `--words`; its size_bytes is `wc -c`
  quick_stats.py           -> `--words`; bytes is `wc -c`; unique/top-5 retired
  wordcount.py             -> `--words` (its JSON envelope is not kept)
  word_counter.py          -> `--words`
  word_counter_simple.py   -> `--words`
  word-counter.py          -> `--words`
  wordfreq.py              -> `--words`; frequency table retired, no caller
  word_freq.py             -> `--words`; frequency table retired, no caller

Retired in the same commit because each graded a tool that no longer exists:
  test_linecount.py, test_text_stats.py, verify_lines.py, verify_lwc.py,
  verify_wordcount.py, verify_word_freq.sh, check_word_count_report.py

verify_line_counter.sh is repaired rather than deleted. It pinned
/root/.hermes/wizard-outputs/spark and "Total lines: 5587" -- a figure belonging
to the 65-line tool last held at 7359bce that 6b5197b had already replaced -- and
read rc 1 at the opening rev. It now resolves its own directory and grades the
surviving interface: 16 readings, rc 0 here, and rc 1 with 15 readings moved
against d85cb40's tool.

test_line_counter.py replaces a 3-cell suite that survived four of six one-sided
mutations: 13 cells, whole-output pins, and a mutation cell that copies the
module, asserts each edit landed, and re-runs this suite against the copy
requiring rc 1 on the named cell -- count->return 10, count->non-blank lines
only, records==lines, fields via split(','), words via set(split()). Five
mutants, five killed. Against d85cb40's tool the same suite reads 0/13, rc 1.

Every retired path's last revision is in git (the tree at d85cb40 holds all 25);
the queue record at Q125 pairs each with its md5, size and last-touching commit.
tools/path_claim_baseline.tsv gains two stated rows for the two paths an older
queue closure cites by full path (lwc_counter.py, verify_lwc.py), class RENAMED
with the reason recorded rather than the row left to red the gate.

Files: wizard-work/spark/line_counter.py, test_line_counter.py,
verify_line_counter.sh (repaired), README.md, VERIFICATION.md, 25 deletions,
tools/path_claim_baseline.tsv.
2026-10-29 00:09:30 +00:00

280 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""line_counter.py -- the spark lane's one text-statistics tool.
WHY THIS EXISTS (Q125, 2026-10-28)
This lane accumulated eleven counters that each took a path, counted something
textual and printed a number, and none of which named another: line_counter.py,
count_python.py, count_python_lines.py, py_loc.py, lwc_counter.py,
linecount_pycount.py, text_stats.py, word_stats.py, file_stats.py,
code_stats.py, loc_counter.py. Their line definitions disagreed -- some
counted newline characters, some counted readlines(), some splitlines(), some
dropped blank lines -- so two of them given the same directory printed
different totals and neither said which question it had answered. The ten
others are retired; their last revisions are in git and the queue record at
Q125 pairs each path with the commit that removed it. What survives is this
file, and it prints the definition before it prints the number.
COUNTING DEFINITION -- stated here, in the README, and in the output's own key
names, because the old tools differed only in this and the difference was
invisible.
lines Newline-terminated lines: the number of b"\\n" bytes in the file.
Identical to `wc -l`. A file whose last row has no trailing
newline therefore has one fewer *line* than it has rows of text.
records lines plus one final unterminated fragment:
records = lines + (1 if the file is non-empty and its last
byte is not b"\\n" else 0)
The two figures differ by exactly the files that lack a trailing
newline. At `7359bce`, over the 113 .py files then in this
directory, that was 5,475 lines against 5,587 records -- 112 files
without a final newline.
blank / comment / code
Python sources (.py) only, and lexically, not by grammar:
blank is a line holding nothing but whitespace; comment is a line
whose first non-space character is '#'; code is every other line,
so a docstring interior and a backslash continuation count as code.
A target contributing no .py file prints "-" for all three (null in
--json) rather than a zero it did not count.
fields With --fields, the first record of the target is parsed as CSV with
the standard csv module, so a quoted separator is data:
`"a,b",c` is 2 fields, where str.split(',') reads 3. This is the
only place a comma count is meaningful; the tool no longer answers
"how many commas are on line 1".
words / chars
With --words: words is len(text.split()) -- whitespace-separated
tokens -- and chars is len(text) in characters of the decoded text,
not bytes (a byte count is `wc -c`, a different question). Bytes
are decoded UTF-8 with errors replaced and a leading BOM stripped,
so a latin-1 log reads as replacement characters instead of dying
with a UnicodeDecodeError, which is what the tool this replaces did
on the pipe's own measurement.
WHAT IT WILL NOT DO
It never reports a partial total. A file inside a directory walk that it
cannot read stops the run with exit 2 and names the path, rather than summing
the rest and printing a number that silently omits a file.
USAGE
python3 line_counter.py [--json] PATH lines/records, + Python breakdown
python3 line_counter.py --fields PATH CSV fields in PATH's first record
python3 line_counter.py --words PATH words and characters
python3 line_counter.py --json ... the same report as JSON
EXIT CODES
0 the report was produced
1 usage error (no target, two targets, unknown flag, two modes at once)
2 the target does not exist, is not the kind of path the mode needs, or holds
a file that cannot be read
"""
import csv
import io
import json
import os
import sys
USAGE = (
"usage: line_counter.py [--json] PATH\n"
" line_counter.py [--json] --fields PATH\n"
"\n"
" PATH a file or a directory (directories are walked recursively)\n"
" --fields count fields in PATH's first record with CSV quoting;\n"
" `\"a,b\",c` is 2 fields, not 3\n"
" --json emit the report as JSON\n"
"\n"
" keys: files, lines (wc -l), records (lines + a final unterminated\n"
" fragment), blank/comment/code (.py only, lexical; \"-\" if none)\n"
" exit: 0 report produced, 1 usage error, 2 unreadable target\n"
)
class ToolError(Exception):
"""A condition the caller must see as exit 2, not as a traceback."""
def count_text(data):
"""Return (lines, records) for one file's bytes.
lines -- newline-terminated lines, exactly `wc -l`.
records -- lines plus a final unterminated fragment.
"""
lines = data.count(b"\n")
records = lines + (1 if data and not data.endswith(b"\n") else 0)
return lines, records
def breakdown(text):
"""Return (blank, comment, code) for one Python source's text, lexically.
A line whose first non-space character is '#' is a comment; a line holding
only whitespace is blank; everything else is code, including docstring
interiors and continuations.
"""
blank = comment = code = 0
rows = text.split("\n")
if text.endswith("\n"):
rows.pop()
for row in rows:
stripped = row.strip()
if not stripped:
blank += 1
elif stripped.startswith("#"):
comment += 1
else:
code += 1
return blank, comment, code
def _read_bytes(path):
try:
with open(path, "rb") as handle:
return handle.read()
except OSError as exc:
raise ToolError(f"cannot read '{path}': {exc.strerror or exc}")
def _walk(path):
"""Return every regular file under `path`, in a stable order."""
found = []
for root, dirs, files in os.walk(path):
dirs.sort()
for name in sorted(files):
full = os.path.join(root, name)
if os.path.isfile(full):
found.append(full)
return found
def report(path):
"""Return the report dict for a file or directory target."""
if os.path.isdir(path):
targets = _walk(path)
elif os.path.isfile(path):
targets = [path]
else:
raise ToolError(f"no such file or directory: '{path}'")
total_files = 0
total_lines = 0
total_records = 0
blank = comment = code = 0
py_files = 0
for full in targets:
data = _read_bytes(full)
lines, records = count_text(data)
total_files += 1
total_lines += lines
total_records += records
if full.endswith(".py"):
text = data.decode("utf-8", "replace")
file_blank, file_comment, file_code = breakdown(text)
blank += file_blank
comment += file_comment
code += file_code
py_files += 1
return {
"path": path,
"files": total_files,
"lines": total_lines,
"records": total_records,
"blank": blank if py_files else None,
"comment": comment if py_files else None,
"code": code if py_files else None,
}
def count_fields(path):
"""Return the field count of the first CSV record in `path`."""
if os.path.isdir(path):
raise ToolError(f"--fields needs a file, not a directory: '{path}'")
if not os.path.isfile(path):
raise ToolError(f"no such file: '{path}'")
data = _read_bytes(path)
text = data.decode("utf-8-sig", "replace")
if not text.strip():
return 0
first = text.split("\n", 1)[0]
return len(next(csv.reader(io.StringIO(first))))
def word_report(path):
"""Return (files, words, chars) for a file or a directory target."""
if os.path.isdir(path):
targets = _walk(path)
elif os.path.isfile(path):
targets = [path]
else:
raise ToolError(f"no such file or directory: '{path}'")
words = 0
chars = 0
for full in targets:
text = _read_bytes(full).decode("utf-8-sig", "replace")
words += len(text.split())
chars += len(text)
return {"path": path, "files": len(targets), "words": words, "chars": chars}
ORDER = ("files", "lines", "records", "words", "chars",
"blank", "comment", "code", "fields")
def render(result, as_json):
"""Render a report dict as text or JSON, with the definition in the keys."""
if as_json:
return json.dumps(result, sort_keys=True) + "\n"
lines = []
for key in ORDER:
if key in result:
value = result[key]
lines.append(f"{key}: {'-' if value is None else value}")
return "\n".join(lines) + "\n"
def main(argv):
args = list(argv[1:])
as_json = False
mode = None
positional = []
for arg in args:
if arg == "--json":
as_json = True
elif arg in ("--fields", "--words"):
if mode is not None:
sys.stderr.write(f"--{mode} and {arg} are different questions\n\n")
sys.stderr.write(USAGE)
return 1
mode = arg[2:]
elif arg in ("-h", "--help"):
sys.stdout.write(USAGE)
return 0
elif arg.startswith("-") and arg != "-":
sys.stderr.write(f"unknown option: {arg}\n\n{USAGE}")
return 1
else:
positional.append(arg)
if len(positional) != 1:
sys.stderr.write(USAGE)
return 1
path = positional[0]
try:
if mode == "fields":
result = {"path": path, "fields": count_fields(path)}
elif mode == "words":
result = word_report(path)
else:
result = report(path)
except ToolError as exc:
sys.stderr.write(f"ERROR: {exc}\n")
return 2
sys.stdout.write(render(result, as_json))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))