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
17 changes: 14 additions & 3 deletions graphblas/core/automethods.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,11 +366,22 @@ def __ixor__(self, other):


# End auto-generated code
def _main():
def _main(base_dir=None, callblack=True):
# base_dir lets `scripts/autogenerate.py --check` redirect reads and writes to a
# scratch copy of the tree; when None we regenerate the real files in place.
# callblack=False keeps that check hermetic: black is optional here, and letting it
# run would make the scratch output depend on whether it is installed.
import functools
from pathlib import Path

from .utils import _autogenerate_code

if not callblack:
_autogenerate_code = functools.partial(_autogenerate_code, callblack=False)

if base_dir is None:
base_dir = Path(__file__).parent

common = {
"_name_html",
"_nvals",
Expand Down Expand Up @@ -488,7 +499,7 @@ def _main():
f' raise TypeError(f"{name!r} not supported for {{type(self).__name__}}")\n\n'
)

_autogenerate_code(Path(__file__), "\n".join(lines))
_autogenerate_code(base_dir / "automethods.py", "\n".join(lines))

# Copy to scalar.py and infix.py
lines = []
Expand All @@ -509,7 +520,7 @@ def _main():
continue
lines.append(f" {name} = automethods.{name}")

thisdir = Path(__file__).parent
thisdir = base_dir
infix_exclude = {"_get_value"}

def get_name(line):
Expand Down
14 changes: 12 additions & 2 deletions graphblas/core/infixmethods.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,11 @@ def __itruediv__(self, other):
# End auto-generated code


def _main():
def _main(base_dir=None, callblack=True):
# base_dir lets `scripts/autogenerate.py --check` redirect the write to a scratch
# copy of the tree; when None we regenerate infixmethods.py in place.
# callblack=False keeps that check hermetic: black is optional here, and letting it
# run would make the scratch output depend on whether it is installed.
# Run via `python -m graphblas.core.infixmethods`
comparisons = {
"lt": "lt",
Expand Down Expand Up @@ -426,11 +430,17 @@ def _main():
" setattr(VectorIndexExpr, name, val)\n"
" setattr(MatrixIndexExpr, name, val)\n"
)
import functools
from pathlib import Path

from .utils import _autogenerate_code

_autogenerate_code(Path(__file__), "\n".join(lines))
if not callblack:
_autogenerate_code = functools.partial(_autogenerate_code, callblack=False)

if base_dir is None:
base_dir = Path(__file__).parent
_autogenerate_code(base_dir / "infixmethods.py", "\n".join(lines))


if __name__ == "__main__":
Expand Down
62 changes: 62 additions & 0 deletions graphblas/tests/test_autogenerate_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Guard the auto-generated code blocks against undetected drift.

``test_automethods.py`` guards the *names* on the generated expression surface.
Nothing there guards the generated *content*: hand-edit a block between the
``# Begin auto-generated code`` / ``# End auto-generated code`` markers, or edit
the name sets in ``automethods._main`` without rerunning the generator, and the
on-disk blocks go stale while CI stays green.

``scripts/autogenerate.py --check`` closes that gap. It regenerates every
generated file into a scratch tree and compares each against the file on disk,
exiting nonzero (and naming the drifted files) on any mismatch. The comparison is
on parsed syntax, so the check needs no formatter and runs everywhere rather than
skipping in every job that lacks one.

Scope, since the two guards are easy to confuse. The generator emits from the
literal name sets in ``automethods._main``, not by introspecting the classes, so
adding a method to Matrix/Vector/Scalar does not change its output and cannot
show up here. That case is ``test_automethods.py``'s: it compares the concrete
types against the generated surface and fails when a new public attribute is
neither auto-computed nor opted out. This module covers the other half, the
generated files no longer matching the generator that claims to produce them.
"""

import subprocess
import sys
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]
AUTOGEN_SCRIPT = REPO_ROOT / "scripts" / "autogenerate.py"
PACKAGE_LINE = "graphblas package: "


def test_autogenerated_code_is_in_sync():
if not AUTOGEN_SCRIPT.exists(): # pragma: no cover (source checkout only)
pytest.skip("scripts/autogenerate.py not present (installed package, not a checkout)")
result = subprocess.run(
[sys.executable, str(AUTOGEN_SCRIPT), "--check"],
capture_output=True,
text=True,
check=False,
)
report = f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
checked = [
line[len(PACKAGE_LINE) :].strip()
for line in result.stdout.splitlines()
if line.startswith(PACKAGE_LINE)
]

# Assert the positive post-condition before the exit code. A check that ran against a
# different `graphblas` reports success about a tree nobody asked about: sys.path[0]
# for a script is the script's own directory, so that is what happens whenever an
# editable install points elsewhere, as in a git worktree.
assert checked, f"--check never reported a package, so it failed before starting.\n{report}"
expected = str(REPO_ROOT / "graphblas")
assert checked == [expected], f"--check validated {checked}, not {expected}.\n{report}"

assert result.returncode == 0, (
"Auto-generated code is out of date. Run `python scripts/autogenerate.py` and "
f"commit the result.\n{report}"
)
Loading