#!/usr/bin/env python3 """ Append source files to a markdown report and save as a new file. Example: python3 Report/append_sources_to_report.py \ --input Report/zivro-open-project-report.md \ --output Report/zivro-open-project-report-with-code.md \ --base . """ from __future__ import annotations import argparse from pathlib import Path from typing import Iterable DEFAULT_EXTENSIONS = { ".zig", ".zon", ".json", ".toml", ".yaml", ".yml", ".md", ".txt", ".py", ".puml", } DEFAULT_EXCLUDE_DIRS = { ".git", "zig-out", "zig-cache", ".zig-cache", ".cursor", "mcps", } DEFAULT_EXCLUDE_FILES = { ".DS_Store", } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Adds source code listings to the end of a markdown report and writes " "the result to a new markdown file." ) ) parser.add_argument("--input", required=True, help="Path to source markdown report") parser.add_argument("--output", required=True, help="Path to output markdown report") parser.add_argument( "--base", default=".", help="Project root to scan for source files (default: current directory)", ) parser.add_argument( "--include", nargs="*", default=["src", "build.zig", "build.zig.zon"], help=( "Files/directories (relative to --base) to include in appendix scan. " "Default: src build.zig build.zig.zon" ), ) parser.add_argument( "--extensions", nargs="*", default=sorted(DEFAULT_EXTENSIONS), help=( "Allowed file extensions (e.g. .zig .md). " "If empty, all file extensions are allowed." ), ) parser.add_argument( "--exclude-dir", nargs="*", default=sorted(DEFAULT_EXCLUDE_DIRS), help="Directory names to exclude recursively", ) parser.add_argument( "--max-bytes", type=int, default=1_000_000, help="Skip files larger than this size in bytes (default: 1_000_000)", ) return parser.parse_args() def is_text_file(path: Path) -> bool: try: data = path.read_bytes() except OSError: return False if b"\x00" in data: return False return True def iter_files( base: Path, include_paths: Iterable[str], extensions: set[str], exclude_dirs: set[str], max_bytes: int, ) -> list[Path]: files: list[Path] = [] def add_file(path: Path) -> None: if not path.is_file(): return if path.name in DEFAULT_EXCLUDE_FILES: return if extensions and path.suffix.lower() not in extensions: return try: size = path.stat().st_size except OSError: return if size > max_bytes: return if not is_text_file(path): return files.append(path) for rel in include_paths: item = (base / rel).resolve() if not item.exists(): continue if item.is_file(): add_file(item) continue for path in item.rglob("*"): if any(part in exclude_dirs for part in path.parts): continue add_file(path) return sorted(set(files), key=lambda p: p.relative_to(base).as_posix()) def language_for(path: Path) -> str: ext = path.suffix.lower() if ext == ".zig": return "zig" if ext == ".py": return "python" if ext in {".yaml", ".yml"}: return "yaml" if ext == ".json": return "json" if ext == ".toml": return "toml" if ext == ".md": return "markdown" return "" def main() -> int: args = parse_args() input_path = Path(args.input).resolve() output_path = Path(args.output).resolve() base_path = Path(args.base).resolve() if not input_path.exists(): raise FileNotFoundError(f"Input report not found: {input_path}") if input_path == output_path: raise ValueError("--input and --output must be different files") report_text = input_path.read_text(encoding="utf-8") extensions = {e.lower() if e.startswith(".") else f".{e.lower()}" for e in args.extensions} exclude_dirs = set(args.exclude_dir) files = iter_files( base=base_path, include_paths=args.include, extensions=extensions, exclude_dirs=exclude_dirs, max_bytes=args.max_bytes, ) appendix_lines: list[str] = [] appendix_lines.append("") appendix_lines.append("---") appendix_lines.append("") appendix_lines.append("## Приложение A. Исходные тексты") appendix_lines.append("") appendix_lines.append( f"Сформировано автоматически скриптом `Report/append_sources_to_report.py` " f"(файлов: {len(files)})." ) appendix_lines.append("") for idx, path in enumerate(files, start=1): rel = path.relative_to(base_path).as_posix() lang = language_for(path) code = path.read_text(encoding="utf-8", errors="replace") appendix_lines.append(f"### A.{idx}. `{rel}`") appendix_lines.append("") appendix_lines.append(f"```{lang}") appendix_lines.append(code.rstrip("\n")) appendix_lines.append("```") appendix_lines.append("") output_text = report_text.rstrip() + "\n" + "\n".join(appendix_lines) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(output_text, encoding="utf-8") print(f"Created: {output_path}") print(f"Input report preserved: {input_path}") print(f"Attached files: {len(files)}") return 0 if __name__ == "__main__": raise SystemExit(main())