aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/changes.py65
-rw-r--r--tools/surface.py99
2 files changed, 164 insertions, 0 deletions
diff --git a/tools/changes.py b/tools/changes.py
new file mode 100644
index 0000000..0c3e0bf
--- /dev/null
+++ b/tools/changes.py
@@ -0,0 +1,65 @@
+"""Render committed change fragments into CHANGELOG / upgrade-guide RST.
+
+Stdlib only, no external changelog tooling. Reads every ``changes/*.rst``
+fragment (INI with a single ``[change]`` section), sorts them by filename, and
+renders each into an RST section. On ``--release`` the rendered blocks are
+slotted into the target files and the consumed fragments are deleted.
+"""
+
+from __future__ import annotations
+
+import configparser
+import sys
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).resolve().parent.parent
+_CHANGES_DIR = _REPO_ROOT / "changes"
+
+
+def collect_fragments(changes_dir: Path) -> list[Path]:
+ """Return the fragment files under ``changes_dir`` in fragment-id order."""
+ fragments = [path for path in changes_dir.glob("*.rst") if path.name != "README.rst"]
+ return sorted(fragments, key=lambda path: path.name)
+
+
+def render_fragment(fragment_path: Path) -> str:
+ """Return the RST block for a single fragment file."""
+ parser = configparser.ConfigParser()
+ parser.read(fragment_path, encoding="utf-8")
+ change = parser["change"]
+ anchor = change["anchor"]
+ heading = change["heading"]
+ context = _normalize_paragraph(change["context"])
+ lines = [f".. _{anchor}:", "", heading, "-" * len(heading), "", context, ""]
+ before = change.get("before")
+ after = change.get("after")
+ if before is not None and after is not None:
+ lines.extend(["", ".. code-block:: text", "", f" {before}", f" {after}", ""])
+ return "\n".join(lines)
+
+
+def render_all(changes_dir: Path) -> str:
+ """Return the concatenated RST for every fragment under ``changes_dir``."""
+ fragment_paths = collect_fragments(changes_dir)
+ rendered_blocks = [render_fragment(path) for path in fragment_paths]
+ return "\n".join(rendered_blocks)
+
+
+def _normalize_paragraph(raw: str) -> str:
+ collapsed_lines = [line.strip() for line in raw.splitlines()]
+ non_empty = [line for line in collapsed_lines if line]
+ return " ".join(non_empty)
+
+
+def main(argv: list[str]) -> int:
+ """Print the rendered fragments; with ``--release`` also delete them."""
+ rendered = render_all(_CHANGES_DIR)
+ sys.stdout.write(rendered)
+ if "--release" in argv:
+ for fragment_path in collect_fragments(_CHANGES_DIR):
+ fragment_path.unlink()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/tools/surface.py b/tools/surface.py
new file mode 100644
index 0000000..2677d95
--- /dev/null
+++ b/tools/surface.py
@@ -0,0 +1,99 @@
+"""Compute Edify's public API surface as a stable, diffable text snapshot.
+
+Walks ``edify`` and its documented public submodules, and emits one line per
+public symbol: its dotted path plus, for callables, a normalized signature. The
+output is deterministic (sorted) so a diff against the committed
+``.public-surface`` file shows exactly what a PR added, removed, or changed.
+"""
+
+from __future__ import annotations
+
+import importlib
+import inspect
+import pkgutil
+import sys
+from pathlib import Path
+
+_REPO_ROOT = Path(__file__).resolve().parent.parent
+_SURFACE_PATH = _REPO_ROOT / ".public-surface"
+_ROOT_PACKAGE = "edify"
+
+
+def compute_surface() -> str:
+ """Return the full public-surface snapshot text for the ``edify`` package."""
+ module_names = _public_module_names()
+ entries: list[str] = []
+ for module_name in module_names:
+ module = importlib.import_module(module_name)
+ entries.extend(_module_entries(module_name, module))
+ unique_sorted = sorted(set(entries))
+ return "\n".join(unique_sorted) + "\n"
+
+
+def _public_module_names() -> list[str]:
+ root_module = importlib.import_module(_ROOT_PACKAGE)
+ root_path = root_module.__path__
+ names = [_ROOT_PACKAGE]
+ for module_info in pkgutil.walk_packages(root_path, prefix=f"{_ROOT_PACKAGE}."):
+ if _is_private_path(module_info.name):
+ continue
+ names.append(module_info.name)
+ return names
+
+
+def _is_private_path(dotted_name: str) -> bool:
+ parts = dotted_name.split(".")
+ return any(part.startswith("_") for part in parts)
+
+
+def _module_entries(module_name: str, module: object) -> list[str]:
+ exported_names = getattr(module, "__all__", None)
+ if exported_names is None:
+ return []
+ entries: list[str] = []
+ for symbol_name in exported_names:
+ if symbol_name.startswith("_"):
+ continue
+ value = getattr(module, symbol_name, None)
+ entries.append(_render_symbol(module_name, symbol_name, value))
+ return entries
+
+
+def _render_symbol(module_name: str, symbol_name: str, value: object) -> str:
+ dotted = f"{module_name}.{symbol_name}"
+ signature = _signature_or_empty(value)
+ if signature:
+ return f"{dotted}{signature}"
+ return dotted
+
+
+def _signature_or_empty(value: object) -> str:
+ if not callable(value):
+ return ""
+ try:
+ return str(inspect.signature(value))
+ except (ValueError, TypeError):
+ return ""
+
+
+def main(argv: list[str]) -> int:
+ """Print the surface; with ``--write`` overwrite the committed snapshot file."""
+ surface = compute_surface()
+ if "--write" in argv:
+ _SURFACE_PATH.write_text(surface, encoding="utf-8")
+ return 0
+ if "--check" in argv:
+ committed = _SURFACE_PATH.read_text(encoding="utf-8")
+ if committed != surface:
+ sys.stderr.write(
+ "public surface drift: run `python tools/surface.py --write` and commit "
+ ".public-surface, adding a changes/ fragment for the change.\n"
+ )
+ return 1
+ return 0
+ sys.stdout.write(surface)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))