v2 chunk 2b: cli format-notes subcommand and reformat_notes()

Wire the v2 note formatter into the CLI so it can be invoked on
real markdown drafts. The format-notes subcommand mirrors v1's
format subcommand: parser → formatter → reassemble, with concurrent
API calls.

src/cmos/cli.py:
- Import find_notes and format_note_entry alongside the existing
  v1 imports.
- Add reformat_notes(text, formatter, concurrency) that finds
  pandoc-style markdown footnote definitions via find_notes,
  formats each definition's text via the v2 note formatter (or
  an injected fake), and substitutes the formatted text back into
  the original line position. Non-definition lines preserved
  byte-for-byte. Returns text unchanged when no definitions found.
- Register the format-notes argparse subparser with the same
  --concurrency flag as v1's format.
- Dispatch args.command == "format-notes" to reformat_notes.
- Module docstring updated to document both subcommands.

tests/test_cli.py:
- 7 new tests for reformat_notes covering: in-place substitution,
  order preservation under concurrency, prose preservation,
  reference markers staying verbatim, no-op on empty input,
  trailing newline preservation.
- Extended test_python_dash_m_invocation_actually_runs_main to
  also assert "format-notes" appears in --help, catching accidental
  subcommand removal.

Path B integrity: formatter.py, note_formatter.py, parser.py,
linter.py, harness/score.py all unchanged. No LINTER_VERSION
bump. 96/96 v1 tests still passing. Total suite: 123/123.

Real-API end-to-end smoke test on a temp draft with 2 footnote
definitions: both reformatted byte-for-byte, prose and headings
preserved, ## Conclusion section after the notes preserved.
This commit is contained in:
Mark Eaton
2026-04-11 18:33:45 -04:00
parent d061f78b7f
commit 87cb70b4fb
2 changed files with 209 additions and 6 deletions
+83 -5
View File
@@ -1,8 +1,16 @@
"""Command-line entry point: ``cmos format path/to/draft.md``. """Command-line entry points for the cmos reformatter.
Orchestrates parser → formatter → reassemble. The CLI preserves everything Two subcommands:
outside the bibliography section byte-for-byte; only bibliography entries
are rewritten. - ``cmos format <path>`` (v1): rewrite the ``## Bibliography`` section of a
markdown draft, preserving everything outside the section byte-for-byte.
- ``cmos format-notes <path>`` (v2): rewrite pandoc-style markdown footnote
definitions ``[^marker]: text`` to CMOS 18 first-occurrence note form.
Substitutes reformatted definitions in place by line number; everything
else in the draft is preserved byte-for-byte.
Both subcommands orchestrate parser → formatter → reassemble.
""" """
from __future__ import annotations from __future__ import annotations
@@ -14,7 +22,8 @@ from pathlib import Path
from typing import Callable from typing import Callable
from cmos.formatter import format_bibliography_entry from cmos.formatter import format_bibliography_entry
from cmos.parser import split_bibliography from cmos.note_formatter import format_note_entry
from cmos.parser import find_notes, split_bibliography
FormatterFn = Callable[[str], str] FormatterFn = Callable[[str], str]
@@ -61,6 +70,56 @@ def reformat_draft(
return "\n".join(pieces) + "\n" return "\n".join(pieces) + "\n"
def reformat_notes(
text: str,
formatter: FormatterFn | None = None,
concurrency: int = DEFAULT_CONCURRENCY,
) -> str:
"""Rewrite pandoc-style markdown footnote definitions in ``text``.
Finds every ``[^marker]: definition text`` line via
``cmos.parser.find_notes``, formats each definition's text via
``formatter`` (the v2 note formatter by default), and substitutes
the reformatted text back into the same line position. All
non-definition lines are preserved byte-for-byte.
Definitions are reformatted concurrently using a thread pool of
size ``concurrency``, mirroring v1's ``reformat_draft``. The output
order is deterministic regardless of which API call finishes first.
If the document contains no footnote definitions, returns ``text``
unchanged — a draft with no notes is a valid (if uninteresting)
input rather than an error.
Scope of v2 chunk 2b (this version): single-line definitions only.
Multi-line definitions (where the body continues on indented
subsequent lines) are out of scope — only the first line is
reformatted, leaving any continuation lines untouched. Real drafts
that use multi-line definitions will need a future parser
extension before this CLI can handle them safely.
"""
fmt = formatter or format_note_entry
parsed = find_notes(text)
if not parsed.definitions:
return text
texts = [d.text for d in parsed.definitions]
if concurrency <= 1 or len(texts) <= 1:
rewritten = [fmt(t) for t in texts]
else:
with ThreadPoolExecutor(max_workers=concurrency) as pool:
rewritten = list(pool.map(fmt, texts))
lines = text.splitlines()
for definition, formatted in zip(parsed.definitions, rewritten):
lines[definition.line_number] = f"[^{definition.marker}]: {formatted}"
# Preserve trailing newline if the original had one — splitlines drops it.
trailing = "\n" if text.endswith("\n") else ""
return "\n".join(lines) + trailing
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="cmos", description=__doc__) ap = argparse.ArgumentParser(prog="cmos", description=__doc__)
sub = ap.add_subparsers(dest="command", required=True) sub = ap.add_subparsers(dest="command", required=True)
@@ -77,12 +136,31 @@ def main(argv: list[str] | None = None) -> int:
), ),
) )
fmt_notes = sub.add_parser(
"format-notes",
help="Reformat a draft's markdown footnote definitions to CMOS 18 note form.",
)
fmt_notes.add_argument("path", type=Path, help="Path to a markdown draft.")
fmt_notes.add_argument(
"--concurrency",
type=int,
default=DEFAULT_CONCURRENCY,
help=(
"Number of parallel formatter calls (default: "
f"{DEFAULT_CONCURRENCY}). Set to 1 for serial execution."
),
)
args = ap.parse_args(argv) args = ap.parse_args(argv)
if args.command == "format": if args.command == "format":
text = args.path.read_text(encoding="utf-8") text = args.path.read_text(encoding="utf-8")
sys.stdout.write(reformat_draft(text, concurrency=args.concurrency)) sys.stdout.write(reformat_draft(text, concurrency=args.concurrency))
return 0 return 0
if args.command == "format-notes":
text = args.path.read_text(encoding="utf-8")
sys.stdout.write(reformat_notes(text, concurrency=args.concurrency))
return 0
return 2 return 2
+126 -1
View File
@@ -3,6 +3,10 @@
The CLI orchestrates parser → formatter → reassemble. Tests inject a fake The CLI orchestrates parser → formatter → reassemble. Tests inject a fake
formatter so no API calls happen; the goal is to pin the glue logic, not the formatter so no API calls happen; the goal is to pin the glue logic, not the
LLM behavior. LLM behavior.
v1: ``reformat_draft`` rewrites the ``## Bibliography`` section.
v2: ``reformat_notes`` rewrites pandoc-style markdown footnote definitions
in place by line number, preserving everything else byte-for-byte.
""" """
import subprocess import subprocess
@@ -11,7 +15,7 @@ from pathlib import Path
import pytest import pytest
from cmos.cli import reformat_draft from cmos.cli import reformat_draft, reformat_notes
from cmos.parser import NoBibliographyError from cmos.parser import NoBibliographyError
@@ -80,6 +84,10 @@ def test_python_dash_m_invocation_actually_runs_main():
the worst kind of bug, since callers assume the pipeline ran. This the worst kind of bug, since callers assume the pipeline ran. This
test forces the guard to exist by invoking the CLI as a subprocess test forces the guard to exist by invoking the CLI as a subprocess
with --help and asserting argparse actually fired. with --help and asserting argparse actually fired.
Also acts as a smoke test for both subcommands appearing in the
top-level help: catches the case where a future refactor removes
a subcommand registration without anyone noticing.
""" """
result = subprocess.run( result = subprocess.run(
[sys.executable, "-m", "cmos.cli", "--help"], [sys.executable, "-m", "cmos.cli", "--help"],
@@ -98,9 +106,126 @@ def test_python_dash_m_invocation_actually_runs_main():
'`if __name__ == "__main__": sys.exit(main())` guard at the bottom.' '`if __name__ == "__main__": sys.exit(main())` guard at the bottom.'
) )
assert "format" in result.stdout assert "format" in result.stdout
assert "format-notes" in result.stdout
assert "usage" in result.stdout.lower() assert "usage" in result.stdout.lower()
# --- reformat_notes (v2 CLI plumbing) ----------------------------------------
def _fake_note_formatter(messy: str) -> str:
# Deterministic fake mirroring the v1 fake_formatter pattern.
return f"NOTE_FORMATTED({messy.strip()})"
def test_reformat_notes_substitutes_definition_in_place():
draft = """\
Some prose with a citation.[^1]
[^1]: yu, charles. interior chinatown. 2020. p 45.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert "Some prose with a citation.[^1]" in output
assert "[^1]: NOTE_FORMATTED(yu, charles. interior chinatown. 2020. p 45.)" in output
# The original messy definition line must be gone.
assert "[^1]: yu, charles" not in output
def test_reformat_notes_rewrites_multiple_definitions_in_order():
draft = """\
Prose.[^1] More.[^2] Again.[^3]
[^1]: first messy.
[^2]: second messy.
[^3]: third messy.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert "[^1]: NOTE_FORMATTED(first messy.)" in output
assert "[^2]: NOTE_FORMATTED(second messy.)" in output
assert "[^3]: NOTE_FORMATTED(third messy.)" in output
def test_reformat_notes_preserves_non_definition_lines():
draft = """\
# Title
Some prose with a citation.[^1]
More prose, no citation here.
[^1]: messy definition.
Conclusion paragraph.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert "# Title" in output
assert "Some prose with a citation.[^1]" in output
assert "More prose, no citation here." in output
assert "Conclusion paragraph." in output
assert "[^1]: NOTE_FORMATTED(messy definition.)" in output
def test_reformat_notes_preserves_reference_markers_in_prose():
"""The [^1] reference inside the prose must NOT be touched — only
the [^1]: definition line should be reformatted."""
draft = """\
Some prose with a citation.[^1] And another.[^2]
[^1]: first.
[^2]: second.
"""
output = reformat_notes(draft, formatter=_fake_note_formatter)
# References in prose stay verbatim
assert "Some prose with a citation.[^1] And another.[^2]" in output
# Definitions are reformatted
assert "[^1]: NOTE_FORMATTED(first.)" in output
assert "[^2]: NOTE_FORMATTED(second.)" in output
def test_reformat_notes_returns_unchanged_when_no_definitions():
draft = "Just prose with no footnote definitions.\n"
output = reformat_notes(draft, formatter=_fake_note_formatter)
assert output == draft
def test_reformat_notes_preserves_trailing_newline():
with_trailing = "[^1]: messy.\n"
without_trailing = "[^1]: messy."
assert reformat_notes(with_trailing, formatter=_fake_note_formatter).endswith("\n")
assert not reformat_notes(without_trailing, formatter=_fake_note_formatter).endswith("\n")
def test_reformat_notes_preserves_order_under_concurrency():
"""With concurrent execution the formatter is called on all definitions
in parallel; the output must reassemble them in input order regardless
of which call finishes first. Mirrors the v1 order-preservation test."""
import time
def slow_fake(messy: str) -> str:
# Earlier definitions sleep longer so they finish last under naive
# completion-order tracking.
n = int(messy.split()[-1])
time.sleep(0.05 * (5 - n))
return f"NOTE_FORMATTED({messy})"
draft = """\
Prose.
[^1]: definition 0
[^2]: definition 1
[^3]: definition 2
[^4]: definition 3
[^5]: definition 4
"""
output = reformat_notes(draft, formatter=slow_fake, concurrency=4)
# Check order: each marker should be paired with its correct definition.
assert "[^1]: NOTE_FORMATTED(definition 0)" in output
assert "[^2]: NOTE_FORMATTED(definition 1)" in output
assert "[^3]: NOTE_FORMATTED(definition 2)" in output
assert "[^4]: NOTE_FORMATTED(definition 3)" in output
assert "[^5]: NOTE_FORMATTED(definition 4)" in output
def test_reformat_draft_preserves_order_under_concurrency(): def test_reformat_draft_preserves_order_under_concurrency():
# With concurrent execution the formatter is called on all entries in # With concurrent execution the formatter is called on all entries in
# parallel; the CLI must reassemble them in input order regardless of # parallel; the CLI must reassemble them in input order regardless of