Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 47 additions & 172 deletions .internal/pre_commit_tools/notebook_uniformity.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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 <img> (or other html) with no prose text of its own."""
if cell.cell_type != "markdown" or "<img" not in cell.source:
return False
return not re.search(r"[A-Za-z0-9]", re.sub(r"<[^>]+>", "", 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__":
Expand Down
1 change: 1 addition & 0 deletions .internal/pre_commit_tools/points
Loading