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:
+83
-5
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user