From 87cb70b4fb995060801384bd0e129c79151cf7de Mon Sep 17 00:00:00 2001 From: Mark Eaton Date: Sat, 11 Apr 2026 18:33:45 -0400 Subject: [PATCH] v2 chunk 2b: cli format-notes subcommand and reformat_notes() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/cmos/cli.py | 88 ++++++++++++++++++++++++++++++-- tests/test_cli.py | 127 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 209 insertions(+), 6 deletions(-) diff --git a/src/cmos/cli.py b/src/cmos/cli.py index beebc77..cf7fed5 100644 --- a/src/cmos/cli.py +++ b/src/cmos/cli.py @@ -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 -outside the bibliography section byte-for-byte; only bibliography entries -are rewritten. +Two subcommands: + +- ``cmos format `` (v1): rewrite the ``## Bibliography`` section of a + markdown draft, preserving everything outside the section byte-for-byte. + +- ``cmos format-notes `` (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 @@ -14,7 +22,8 @@ from pathlib import Path from typing import Callable 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] @@ -61,6 +70,56 @@ def reformat_draft( 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: ap = argparse.ArgumentParser(prog="cmos", description=__doc__) 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) if args.command == "format": text = args.path.read_text(encoding="utf-8") sys.stdout.write(reformat_draft(text, concurrency=args.concurrency)) 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 55baf5d..4d8f120 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,10 @@ 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 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 @@ -11,7 +15,7 @@ from pathlib import Path import pytest -from cmos.cli import reformat_draft +from cmos.cli import reformat_draft, reformat_notes 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 test forces the guard to exist by invoking the CLI as a subprocess 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( [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.' ) assert "format" in result.stdout + assert "format-notes" in result.stdout 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(): # With concurrent execution the formatter is called on all entries in # parallel; the CLI must reassemble them in input order regardless of