from __future__ import annotations
import argparse
import contextlib
import difflib
import io
import os
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DOCS_ROOT = REPO_ROOT / "docs"
TRACKED_FIXTURES = (
"data/slider_depth/events.txt",
"data/prophesee/samples/evt2/80_balls.raw",
)
MARKER = "<!-- evlib:output -->"
BLOCK_START = "<!-- evlib:output:start -->"
BLOCK_END = "<!-- evlib:output:end -->"
PYTHON_FENCE = re.compile(r"^```python\s*$")
CLOSE_FENCE = re.compile(r"^```\s*$")
class MalformedMarkerError(Exception):
def _find_blocks(lines: list[str]) -> list[dict]:
blocks = []
i = 0
n = len(lines)
while i < n:
if not PYTHON_FENCE.match(lines[i]):
i += 1
continue
source_start = i + 1
j = source_start
while j < n and not CLOSE_FENCE.match(lines[j]):
j += 1
if j >= n:
break
source_end = j
k = source_end + 1
while k < n and lines[k].strip() == "":
k += 1
if k >= n or lines[k].strip() != MARKER:
i = source_end + 1
continue
marker_line = k
m = marker_line + 1
while m < n and lines[m].strip() == "":
m += 1
existing_range = None
if m < n and lines[m].strip() == BLOCK_START:
p = m + 1
while p < n and lines[p].strip() != BLOCK_END:
p += 1
if p >= n:
raise MalformedMarkerError(
f"{BLOCK_START} at line {m + 1} has no matching {BLOCK_END}"
)
existing_range = (m, p)
blocks.append(
{
"source_start": source_start,
"source_end": source_end,
"marker_line": marker_line,
"existing_range": existing_range,
}
)
i = source_end + 1
return blocks
@contextlib.contextmanager
def _repo_root_cwd():
previous = os.getcwd()
os.chdir(REPO_ROOT)
try:
yield
finally:
os.chdir(previous)
def _run_block(source: str, path: Path, source_line: int) -> str:
buffer = io.StringIO()
namespace = {"__name__": "__main__", "__file__": str(path)}
with _repo_root_cwd():
try:
with contextlib.redirect_stdout(buffer):
exec(compile(source, str(path), "exec"), namespace)
except Exception:
print(
f"evlib:output block failed: {path} (source starts at line {source_line})",
file=sys.stderr,
)
raise
return buffer.getvalue()
def process_file(path: Path) -> tuple[str, list[str]]:
original_text = path.read_text()
trailing_newline = original_text.endswith("\n")
lines = original_text.split("\n")
if trailing_newline:
lines = lines[:-1]
blocks = _find_blocks(lines)
notes = []
new_lines = list(lines)
offset = 0
for block in blocks:
source = "\n".join(lines[block["source_start"] : block["source_end"]])
if not any(fixture in source for fixture in TRACKED_FIXTURES):
notes.append(
f"{path}: skipping block at line {block['source_start'] + 1} "
"(no tracked-fixture reference)"
)
continue
padded_source = ("\n" * block["source_start"]) + source
output = _run_block(padded_source, path, block["source_start"] + 1)
content = output.split("\n")
if content and content[-1] == "":
content.pop()
replacement = [BLOCK_START, "```text"] + content + ["```", BLOCK_END]
if block["existing_range"] is not None:
start, end = block["existing_range"]
insert_at = start + offset
remove_count = end - start + 1
else:
insert_at = block["marker_line"] + 1 + offset
remove_count = 0
new_lines[insert_at : insert_at + remove_count] = replacement
offset += len(replacement) - remove_count
new_text = "\n".join(new_lines) + ("\n" if trailing_newline else "")
return new_text, notes
def _resolve_targets(paths: list[Path]) -> list[Path]:
if not paths:
return sorted(DOCS_ROOT.rglob("*.md"))
targets: set[Path] = set()
for raw in paths:
resolved = raw if raw.is_absolute() else REPO_ROOT / raw
if resolved.is_dir():
targets.update(resolved.rglob("*.md"))
else:
targets.add(resolved)
return sorted(targets)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="diff only, do not write; exit 1 if any file would change",
)
parser.add_argument(
"paths",
nargs="*",
type=Path,
help="markdown files or directories to scan (default: docs/)",
)
args = parser.parse_args(argv)
drifted = []
for path in _resolve_targets(args.paths):
new_text, notes = process_file(path)
for note in notes:
print(note, file=sys.stderr)
original_text = path.read_text()
if new_text == original_text:
continue
if args.check:
drifted.append(path)
diff = difflib.unified_diff(
original_text.splitlines(keepends=True),
new_text.splitlines(keepends=True),
fromfile=str(path),
tofile=f"{path} (generated)",
)
sys.stdout.writelines(diff)
else:
path.write_text(new_text)
try:
display = path.relative_to(REPO_ROOT)
except ValueError:
display = path
print(f"updated {display}")
if args.check and drifted:
print(
f"\n{len(drifted)} file(s) would change; run without --check to update.",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())