diff --git a/.internal/pre_commit_tools/notebook_uniformity.py b/.internal/pre_commit_tools/notebook_uniformity.py index 4b33f4e66..049574795 100755 --- a/.internal/pre_commit_tools/notebook_uniformity.py +++ b/.internal/pre_commit_tools/notebook_uniformity.py @@ -1,34 +1,26 @@ #!/usr/bin/env python3 -"""Pre-commit hook: auto-fix simple notebook-uniformity conventions. - -This is the enforcement counterpart to the read-only audit in -``.internal/conventions/report.py`` — a home for the uniformity rules -that are simple and safe enough to fix automatically. - -Each rule edits the notebook in place when ``auto_fix`` is set and returns a -short message describing the violation (or ``NO_ERROR`` when the notebook already -conforms). Add a rule by writing such a function and appending it to -``UNIFORMITY_RULES``. - -Currently enforced: - - a references section heading is plural ("References", not "Reference"). - - execution results are parsed via .result_value(), not .result()[0].value. - - a circuit is shown via show(qprog), not the qprog.show() method form. - - a notebook opens with an H1 title (optionally after a logo/banner cell). - - a notebook has exactly one H1 (later H1 headings are demoted to H2). +"""Pre-commit hook: enforce the auto-fixable convention points. + +Single source of truth: every convention lives in `.internal/conventions/points/` +as a `Point` (see `points/_model.py`). This hook applies the `fix()` of each point +marked `enforced`, then reports any enforced point that still fails (e.g. opens_h1, +which has no auto-fix). The points are reached through the `points` symlink to +`../conventions/points` — no sys.path juggling. + +To add or change an enforced rule, edit its point file; nothing here changes. """ -import re +import importlib import sys -from collections.abc import Callable, Iterable +from collections.abc import Iterable +from pathlib import Path import nbformat -NO_ERROR = "" +from _common import PROJECT_ROOT +from points._model import Notebook -# Edits `nb` in place when `auto_fix`; returns a message describing the -# violation/fix, or NO_ERROR when the notebook already conforms. -UniformityRule = Callable[[nbformat.NotebookNode, bool], str] +_POINTS_DIR = Path(__file__).resolve().parent / "points" class Config: @@ -38,165 +30,48 @@ class Config: SHOULD_AUTO_FIX: bool = True -_SINGULAR_REFERENCES_HEADING = re.compile( - r"^(#{1,6}[ \t]+)([Rr])eference([ \t]*)$", re.MULTILINE -) +def enforced_points() -> list: + """Every `enforced` Point, discovered from the points/ directory.""" + points = [] + for module_path in sorted(_POINTS_DIR.glob("point_*.py")): + point = importlib.import_module(f"points.{module_path.stem}").POINT + if point.enforced: + points.append(point) + return points -def references_heading_is_plural(nb: nbformat.NotebookNode, auto_fix: bool) -> str: - """A references section heading should read "References", never "Reference".""" - found: list[str] = [] - - def to_plural(match: re.Match) -> str: - found.append(match.group(0).strip()) - return f"{match.group(1)}{match.group(2)}eferences{match.group(3)}" - - for cell in nb.cells: - if cell.cell_type != "markdown": - continue - fixed_source = _SINGULAR_REFERENCES_HEADING.sub(to_plural, cell.source) - if auto_fix: - cell.source = fixed_source - - if not found: - return NO_ERROR - return f"singular heading {found} — use the plural 'References'" - - -_OLD_RESULT_PARSE = re.compile(r"\.result\(\)\s*\[\s*0\s*\]\s*\.value") - - -def results_use_result_value(nb: nbformat.NotebookNode, auto_fix: bool) -> str: - """Parse execution results via `.result_value()`, not `.result()[0].value`.""" - count = 0 - for cell in nb.cells: - if cell.cell_type != "code": - continue - if not (hits := len(_OLD_RESULT_PARSE.findall(cell.source))): - continue - count += hits - if auto_fix: - cell.source = _OLD_RESULT_PARSE.sub(".result_value()", cell.source) - if not count: - return NO_ERROR - return f".result()[0].value should be .result_value() ({count} occurrence(s))" - - -_CIRCUIT_SHOW_METHOD = re.compile(r"\b(qprog\w*|quantum_program\w*|qp)\.show\(\)") - - -def show_uses_function_form(nb: nbformat.NotebookNode, auto_fix: bool) -> str: - """Show a circuit with show(qprog), not the qprog.show() method form.""" - count = 0 - for cell in nb.cells: - if cell.cell_type != "code": - continue - if not (hits := _CIRCUIT_SHOW_METHOD.findall(cell.source)): - continue - count += len(hits) - if auto_fix: - cell.source = _CIRCUIT_SHOW_METHOD.sub(r"show(\1)", cell.source) - if not count: - return NO_ERROR - return f"qprog.show() should be show(qprog) ({count} occurrence(s))" - - -_H1_HEADING = re.compile(r"^\s*#[ \t]+\S") - - -def _is_h1_cell(cell: nbformat.NotebookNode) -> bool: - return cell.cell_type == "markdown" and bool(_H1_HEADING.match(cell.source)) - - -def _is_logo_cell(cell: nbformat.NotebookNode) -> bool: - """A banner/logo cell: an (or other html) with no prose text of its own.""" - if cell.cell_type != "markdown" or "]+>", "", cell.source)) - - -def opens_with_h1_title(nb: nbformat.NotebookNode, auto_fix: bool) -> str: - """A notebook opens with an H1 title, optionally after one logo/banner cell. - - Not auto-fixable (a missing title can't be invented) — reports only. - """ - cells = nb.cells - opens_ok = bool(cells) and ( - _is_h1_cell(cells[0]) - or (_is_logo_cell(cells[0]) and len(cells) > 1 and _is_h1_cell(cells[1])) - ) - if opens_ok: - return NO_ERROR - return ( - "does not open with an H1 title " - "('# Title' in the first cell, optionally after a logo/banner cell)" - ) - - -def single_h1_title(nb: nbformat.NotebookNode, auto_fix: bool) -> str: - """Exactly one H1 (the title); demote any later H1 headings to H2. - - The first H1 encountered is kept as the title; every subsequent `# ` heading - is demoted to `## `. Fenced code blocks are skipped so a `#` comment inside - ``` ``` ``` isn't mistaken for a heading. - """ - seen_h1 = False - demoted: list[str] = [] - for cell in nb.cells: - if cell.cell_type != "markdown": - continue - lines = cell.source.split("\n") - in_fence = False - for i, line in enumerate(lines): - if re.match(r"^\s*```", line): - in_fence = not in_fence - continue - if in_fence or not re.match(r"^#[ \t]+\S", line): - continue - if seen_h1: - demoted.append(line.strip()) - if auto_fix: - lines[i] = "#" + line # H1 -> H2 - else: - seen_h1 = True - if auto_fix: - cell.source = "\n".join(lines) - if not demoted: - return NO_ERROR - return f"multiple H1 headings — demoted {len(demoted)} to H2 (keep one H1 title): {demoted}" - - -UNIFORMITY_RULES: list[UniformityRule] = [ - references_heading_is_plural, - results_use_result_value, - show_uses_function_form, - opens_with_h1_title, - single_h1_title, -] +def _is_documented(nb: Notebook, point) -> bool: + return any(fragment in nb.rel for fragment, _reason in point.exceptions) def main(full_file_paths: Iterable[str], auto_fix: bool) -> bool: if Config.IS_DISABLED: return True - result = True - for path in full_file_paths: - result &= check_notebook(path, auto_fix) - return result + points = enforced_points() + return all([check_notebook(path, points, auto_fix) for path in full_file_paths]) -def check_notebook(notebook_path: str, auto_fix: bool) -> bool: - nb = nbformat.read(notebook_path, as_version=4) - messages = [msg for rule in UNIFORMITY_RULES if (msg := rule(nb, auto_fix))] - if not messages: - return True +def check_notebook(notebook_path: str, points: list, auto_fix: bool) -> bool: + abs_path = PROJECT_ROOT / notebook_path + nb = nbformat.read(str(abs_path), as_version=4) - if auto_fix: - nbformat.write(nb, notebook_path) - header = "auto-fixed (please `git add`)" if auto_fix else "violations" - print(f"{notebook_path}: {header}") - for message in messages: - print(f"\t{message}") - return False + fixed = [p.title for p in points if p.fix and auto_fix and p.fix(nb.cells)] + if fixed: + nbformat.write(nb, str(abs_path)) + + model = Notebook.load(abs_path, PROJECT_ROOT) + unfixed = [ + p.title for p in points if p.detect(model) and not _is_documented(model, p) + ] + + if not fixed and not unfixed: + return True + print(f"{notebook_path}:") + for title in fixed: + print(f"\tformat {title}: auto-fixed — please `git add`") + for title in unfixed: + print(f"\tcheck {title}: needs a manual fix") + return not (fixed or unfixed) if __name__ == "__main__": diff --git a/.internal/pre_commit_tools/points b/.internal/pre_commit_tools/points new file mode 120000 index 000000000..f29733b50 --- /dev/null +++ b/.internal/pre_commit_tools/points @@ -0,0 +1 @@ +../conventions/points \ No newline at end of file