Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
4 changes: 2 additions & 2 deletions benchmarks/generate_morton_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import numpy as np

from mortie import tools
from mortie import convert

# Paths
TEST_DIR = Path(__file__).parent / "mortie" / "tests"
Expand Down Expand Up @@ -39,7 +39,7 @@
# Generate morton indices at order 18
print("\nGenerating morton indices at order=18...")
order = 18
morton_indices = tools.geo2mort(lats, lons, order=order)
morton_indices = convert.geo2mort(lats, lons, order=order)

print(f" Computed {len(morton_indices):,} morton indices")
print(f" Range: [{morton_indices.min()}, {morton_indices.max()}]")
Expand Down
352 changes: 352 additions & 0 deletions benchmarks/verify_pure_move.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,352 @@
"""Verify that a module split moved definitions verbatim (issue #159).

The domain split of ``mortie/tools.py`` and ``mortie/geometry.py`` claims to be
**pure moves plus import rewiring** — every top-level definition lands in its
new module byte-for-byte, and no public name changes. That claim was checked by
hand for the first slice of the same plan (`PR #160
<https://github.com/espg/mortie/pull/160>`_ extracted ``moc.py`` out of
``coverage.py``; its review AST-compared all 11 moved definitions against their
pre-move originals). This script is that check made re-runnable, so the
reviewer — and the next split — does not have to re-derive it.

Three claims are checked against a git base (``origin/main`` by default):

1. **Verbatim.** Every top-level definition in a destination module that also
exists in the source module at the base compares equal, both as an AST
(``ast.dump``, so formatting and line numbers are ignored) and as literal
source text (so comments *inside* a definition are covered too).
2. **Complete.** Every top-level definition the source module had at the base
lands in exactly one destination module — nothing silently lost or
duplicated — and no destination gains a definition that was not there
before. A top-level statement the scanner cannot name and compare (a
tuple-target assignment, an ``if TYPE_CHECKING:`` block, a ``try/except
ImportError`` shim, a loop, an ``__all__ +=``) is reported as a failure
rather than skipped, so this arm fails loud instead of open — see
``top_level_defs``.
3. **Public surface pinned.** ``set(mortie.__all__)`` equals the base's, and
every name in it still resolves as an attribute of ``mortie``. The base
package is extracted with ``git archive`` and imported in a subprocess (with
the built ``_rustie`` extension copied in), so this compares two real
imports rather than two guesses at what ``__init__.py`` evaluates to.

A split cut from a tree an earlier split already touched pins its own base in
``SPLIT_BASES`` rather than using ``--base``, so each arm stays a strict
verbatim check of its own move.

Run::

python benchmarks/verify_pure_move.py [--base origin/main]

Exit status is non-zero if any claim fails.

Known limitation: a comment block sitting *between* two top-level definitions
belongs to no definition's source segment, so it is not compared. Comments
inside a definition body are.
"""

import argparse
import ast
import json
import os
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile

# Each entry: source module at the base -> the modules its definitions moved
# into. A destination that is also the source (``geometry.py``) simply means
# part of it stayed put.
SPLITS = {
"mortie/tools.py": [
"mortie/convert.py",
"mortie/orders.py",
"mortie/buffer.py",
],
"mortie/geometry.py": [
"mortie/geometry.py",
"mortie/dissolve.py",
],
}

# A split whose source was already touched by an *earlier* split verifies
# against the commit it was actually cut from, not against ``--base``. Phase 2
# cut ``dissolve.py`` out of a ``geometry.py`` that phase 1 had already
# import-rewired (three function-local ``from .tools import`` lines became
# ``from .convert`` / ``from .orders``), so comparing it to ``origin/main``
# would report those three as differences and mask any real one. Pinning the
# base per split keeps every arm a strict verbatim check of its own move.
# The revision below is phase 1's head, review folded.
SPLIT_BASES = {
"mortie/geometry.py": "011816ca3553c3743c3e92fc5300ed23c6b3a514",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[low] The pinned base makes the arm strict, but nothing verifies the pinned base itself — a change introduced in phase 1 to a phase-2-moved function escapes both arms. Demonstrated; the seam is empirically empty today, so this is the mechanism, not a live defect.

The reasoning for SPLIT_BASES holds — allow-listing _per_cell_polygons / _boundary_rings_xyz / _reject_hemisphere_cover would have blinded the arm to any real change in three of the largest moved functions, and pinning is strictly better than that. But "strict" is relative to 011816c, and 011816c:mortie/geometry.py is compared against nothing:

  • Phase 1's arm never covered geometry.py. git show 011816c:benchmarks/verify_pure_move.py has SPLITS = {"mortie/tools.py": [...]} and no geometry entry, so phase 1 edited three geometry.py bodies with no arm watching them.
  • Phase 2's arm cannot see a difference that is present in both its base and its destination.

I built the seam to check it is real rather than argue it. In an isolated clone: take 011816c's tree, put a mutation inside _boundary_rings_xyz (a function phase 1 import-rewired and phase 2 moved), commit it as a synthetic phase-1 head, then apply the identical mutation to dissolve.py on 440a8fa and point SPLIT_BASES at that commit.

--- (B1) phase 1's own verifier (SPLITS = tools.py only) on that tree ---
mortie/tools.py: 32/32 definitions accounted for across mortie/convert.py, mortie/orders.py, mortie/buffer.py
__all__: 69 names, all resolvable, equal to origin/main's

Pure move verified.
EXIT=0
--- (B2) phase 2's verifier, base pinned to the synthetic head ---
148:    keep = starts != ends  # drop any degenerate zero-length edge  # MUT-P1
mortie/tools.py@origin/main: 32/32 definitions accounted for across ...
mortie/geometry.py@2a2ea90: 44/44 definitions accounted for across mortie/geometry.py, mortie/dissolve.py
__all__: 69 names, all resolvable, equal to origin/main's

Pure move verified.
EXIT=0

Both arms green, mutation shipped. So 44/44 verbatim reads as "verbatim since phase 1", not "verbatim since origin/main", and nothing in the script or the PR body says which.

The seam is empty on this branch — I closed it by hand rather than assume it. Indexing all 44 top-level definitions of origin/main:mortie/geometry.py and comparing each (ast.dump + ast.get_source_segment) against wherever it now lives:

origin/main geometry.py defs: 44 | new tree defs: 44
missing: [] added: []
SEAM: definitions differing from origin/main: ['_boundary_rings_xyz', '_per_cell_polygons', '_reject_hemisphere_cover']
--- _boundary_rings_xyz
-    from .tools import _rust_mort2nested
+    from .orders import _rust_mort2nested
--- _per_cell_polygons
-    from .tools import _rust_mort2nested, mort2polygon
+    from .convert import mort2polygon
+    from .orders import _rust_mort2nested
--- _reject_hemisphere_cover
-    from .tools import _rust_mort2nested
+    from .orders import _rust_mort2nested

Three functions, import statements only, nothing else — which matches git diff origin/main 011816c -- mortie/geometry.py (4 insertions, 3 deletions).

Cheapest way to make the script assert what I just asserted by hand: for every src_path in SPLIT_BASES, also index it at default_base and require each definition to match the pinned base after stripping top-level-of-body Import/ImportFrom nodes — anything that differs elsewhere is a failure. That turns the pin from a claim into a check, and it is the only arm where the base is trusted rather than verified.

Verified alongside: your DAG measurement is exact — walking all 17 definitions in dissolve.py, none references any of the 27 names that stayed and none imports .geometry; walking the 27 stayers, to_geometry is the only one that reaches _dissolved_polygons.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Fixed in b7fe794 — implemented exactly as suggested: each pinned split's source is now indexed at --base too, and every definition must be equal modulo body-level Import/ImportFrom nodes.

You were right that the pin was a claim rather than a check, and I reproduced your control before changing anything. Same isolated-clone setup — mutation inside _boundary_rings_xyz in a synthetic phase-1 head, mirrored into dissolve.py, SPLIT_BASES repointed:

=== (B2-OLD) shipped verifier (no seam arm), base pinned to synthetic head ===
mortie/tools.py@origin/main: 32/32 definitions accounted for across mortie/convert.py, mortie/orders.py, mortie/buffer.py
mortie/geometry.py@a53b7e5: 44/44 definitions accounted for across mortie/geometry.py, mortie/dissolve.py
__all__: 69 names, all resolvable, equal to origin/main's

Pure move verified.
EXIT=0

Your synthetic failure now exits 1, same tree, same pin, new arm:

=== (B2-NEW) fixed verifier, same synthetic seam ===
1 failure(s):
  - a53b7e524301821bcc874d85a8e010cf11dc5229:mortie/geometry.py: _boundary_rings_xyz differs from origin/main:mortie/geometry.py beyond its imports (source text — comments or formatting)
...
EXIT=1

The new arm is check_pinned_bases, and it prints what it tolerated rather than staying silent about it — which independently reproduces your hand-check (three functions, imports only):

mortie/geometry.py@011816c vs origin/main: 44 definitions equal modulo imports (3 import-rewired)

Implementation notes on the tolerance boundary, since "modulo imports" has edges:

  • Dropping is body-level onlygetattr(node, "body") direct children. An import nested inside an if or a try is still a difference (mutation-tested, caught via AST).
  • Both compares are kept, AST and source text, each with body-level import lines removed — an AST-only compare would have missed your own mutation, which was a trailing # MUT-P1 comment.
  • Dropping is by whole line, so a trailing comment on an import line goes with it; a blank line beside one does not, and surfaces as a source-text difference. Documented in modulo_body_imports's docstring.

Mutation-tested the way I tested the phase-2 move arm — 16 mutations, 14 expected-catches all caught, 2 expected-misses both by design, each planted in geometry.py at a synthetic pin and mirrored into its destination so check_moves stays green throughout. Caught: comment added inside a moved body; logic edit (AST); docstring reworded; blank line added; trailing whitespace; _DISSOLVE_SNAP changed; a stayer body edit (_per_cell_polygons) and a stayer constant change (_TYPE_MULTIPOLYGON) — worth naming that the arm covers all 44, not just the 17 movers; a definition added by the pin (... is not in origin/main:mortie/geometry.py — the pin is not a pure import rewire of it); a definition deleted by the pin (... is gone from the pin but present in origin/main:...); a re-indented continuation line; a nested import; and a body-level import added together with a blank line. Missed by design: a body-level import added with no blank-line change, and a comment appended to an import line. Tree byte-identical afterwards.

Also recorded your hand-check in the PR body as the headline it deserves: phases 1 and 2 together are a verified pure move of geometry.py against origin/main, not merely against each other — and the script now asserts that on every run instead of leaving it a one-off.

Thanks for the DAG confirmation too; that is recorded in the body as independently verified in both directions.

}

# Definitions legitimately introduced by the split rather than moved. Empty is
# the goal; anything listed here must be justified in the PR body.
EXPECTED_NEW = {}

REPO = pathlib.Path(__file__).resolve().parent.parent


def git_show(base, path):
"""Read a repository file as of a git revision.

Parameters
----------
base : str
Git revision to read from, e.g. ``origin/main``.
path : str
Repository-relative path of the file.

Returns
-------
str
The file's contents at that revision.
"""
return subprocess.run(
["git", "show", f"{base}:{path}"],
cwd=REPO, check=True, capture_output=True, text=True,
).stdout


def top_level_defs(source):
"""Index a module's top-level named definitions by name.

Statements that bind no comparable name — a tuple-target assignment, an
``if``/``try`` block, a loop, an augmented assignment — are *not* skipped.
Skipping them would drop them from both sides of the comparison at once, so
a definition could be lost or altered while the run still reported
``N/N accounted for``. They come back as ``unhandled`` for the caller to
raise as a failure, which turns "the scanner does not know about this
construct" into a loud stop rather than a silent pass. Imports and the
module docstring are the two exceptions: the split rewrites both by design.

Parameters
----------
source : str
Python source text of one module.

Returns
-------
dict
Name -> ``(ast node, source text)`` for every top-level function,
class, and simple-name assignment (annotated or not).
list of str
One ``"<StatementKind> at line N"`` entry per top-level statement the
scanner cannot name and compare.

Raises
------
ValueError
If the module binds the same top-level name twice, which would make
the comparison ambiguous.
"""
tree = ast.parse(source)
found = {}
unhandled = []
body = tree.body
if ast.get_docstring(tree) is not None:
body = body[1:]
for node in body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
names = [node.name]
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
names = [t.id for t in targets if isinstance(t, ast.Name)]
if len(names) != len(targets):
# a tuple/list/attribute/subscript target: no single name to key on
unhandled.append(f"{type(node).__name__} at line {node.lineno}")
continue
elif isinstance(node, (ast.Import, ast.ImportFrom)):
continue
else:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[low] top_level_defs silently drops annotated assignments, tuple-target assignments and every non-def top-level statement — those are neither compared nor reported missing, so the "complete" claim has a hole that fails open.

The else: continue on line 115 means the scanner only ever sees FunctionDef / AsyncFunctionDef / ClassDef / Assign-with-Name-targets. Everything else vanishes from both sides of the comparison at once, so it does not surface as a loss:

>>> import verify_pure_move as v
>>> v.top_level_defs("X: int = 1\nA, B = 1, 2\nfor i in range(3):\n    pass\n")
{}

A moved MAX_ORDER: int = 29, an A, B = ... pair, an if TYPE_CHECKING: block or a try: ... except ImportError: import shim would be invisible in the base and in the destination, so the run still prints N/N definitions accounted for and Pure move verified. while the definition could have been dropped, duplicated or altered.

This is inert for phase 1 — I confirmed origin/main:mortie/tools.py has zero top-level statements outside the four handled kinds (32 named defs, 0 other), which is why my own independent pass agrees at 32/32. It is also inert for phase 2: mortie/geometry.py is {Expr: 1, Import: 2, Assign: 7, FunctionDef: 37} with every Assign on a plain Name target. So nothing is wrong today; the concern is that the script is explicitly built to be the reusable gate for the next split ("Phase 2 extends it by one line"), and this arm fails silently rather than loudly.

Cheapest fix that closes it: add ast.AnnAssign to the recognised kinds, and count everything else in tree.body that is not an Import/ImportFrom/docstring Expr — asserting that count is equal on both sides, or just failing if it is non-zero. The six mutations I ran against the rest of the script (body character, default argument value, deleted definition, duplicated definition, __all__ addition, __all__ removal) were all caught with exit 1, so this is the only arm I found that can pass under mutation.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Fixed in 889cf97. Agreed on the diagnosis and on the timing: this is the one arm that can pass under mutation, it is inert today, and it is worth closing before phase 2 rather than after — the whole point of the script is to be the reusable gate for the next split, and geometry.py has until then to grow a statement it cannot see.

Closed slightly wider than the suggestion:

  • ast.AnnAssign with a Name target is now indexed and compared like a plain Assign, so a moved MAX_ORDER: int = 29 is covered rather than invisible.
  • Every other top-level statement — tuple/attribute/subscript assignment targets, if/try blocks, loops, AugAssign — is collected as unhandled and raised by check_moves as a failure on either side, base or destination. Imports and the module docstring are the two exemptions, since the split rewrites both by design.

Failing rather than counting-and-comparing is deliberate: equal counts on both sides would still not tell you the statement moved verbatim, so "the scanner does not know about this construct — extend it before trusting this run" is the honest outcome.

The example from this comment now reads:

>>> v.top_level_defs('"""doc."""\nimport os\nX: int = 1\nA, B = 1, 2\nfor i in range(3):\n    pass\n__all__ = ["X"]\n__all__ += ["Y"]\n')
({'X': (<ast.AnnAssign ...>, 'X: int = 1'),
  '__all__': (<ast.Assign ...>, '__all__ = ["X"]')},
 ['Assign at line 4', 'For at line 5', 'AugAssign at line 8'])

and mutation-checked end to end — appending a top-level for to mortie/buffer.py:

1 failure(s):
  - mortie/buffer.py: For at line 123 is not comparable — extend top_level_defs before trusting this run

exit 1. Reverted; git diff --stat mortie/buffer.py clean. Unmutated, the script still exits 0 at 32/32 and 69 names, and ruff / flake8 --max-line-length=88 / numpydoc lint are clean on it.

unhandled.append(f"{type(node).__name__} at line {node.lineno}")
continue
for name in names:
if name in found:
raise ValueError(f"top-level name bound twice: {name}")
found[name] = (node, ast.get_source_segment(source, node))
return found, unhandled


def check_moves(default_base):
"""Compare every moved definition against its pre-move original.

Parameters
----------
default_base : str
Git revision holding the pre-move source, for every split that
``SPLIT_BASES`` does not pin to one of its own.

Returns
-------
list of str
One message per failure; empty when every definition is verbatim,
accounted for, and unduplicated.
"""
failures = []
for src_path, dst_paths in SPLITS.items():
base = SPLIT_BASES.get(src_path, default_base)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude (review)

[low] An unresolvable pinned sha crashes with a raw CalledProcessError instead of reporting a failure — the same fail-with-a-traceback class the fold already closed in 011816c — and the squash-merge caveat the PR body says is "noted in the script" is not in the script.

base comes straight from SPLIT_BASES into git_show, which is subprocess.run(..., check=True). The PR body names the exact condition that makes this fire — "The pinned sha is a branch commit: if this PR is squash-merged the arm needs repointing" — so the first person to hit it is whoever runs the script on main after merge. Repointing SPLIT_BASES to a sha this clone does not have:

Traceback (most recent call last):
  File ".../benchmarks/verify_pure_move.py", line 340, in main
    failures = check_moves(args.base)
  File ".../benchmarks/verify_pure_move.py", line 191, in check_moves
    old, old_unhandled = top_level_defs(git_show(base, src_path))
  File ".../benchmarks/verify_pure_move.py", line 107, in git_show
    return subprocess.run(
subprocess.CalledProcessError: Command '['git', 'show',
  'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef:mortie/geometry.py']' returned non-zero exit status 128

Exit status is 1 either way, so the gate holds — this is diagnostics. But it is the same shape as the finding folded in 011816c ("a lost public definition surfaces as an ImportError traceback"): the reader gets a stack instead of the pinned base for mortie/geometry.py is no longer reachable — repoint SPLIT_BASES, which is the one sentence that says what to do. A try/except subprocess.CalledProcessError around the git_show in check_moves, returning a failure entry, is symmetric with what check_public_surface now does for ImportError.

Two smaller things on the same line:

  1. The script does not carry the squash-merge caveat. The PR body says "which is noted in the script", but the SPLIT_BASES comment block ends at "The revision below is phase 1's head, review folded", and the module docstring's new paragraph only explains why a split pins its own base. Nothing anywhere in the file tells a future reader that the sha dies on squash-merge, and the file outlives the PR body. Worth one comment line next to the sha.

  2. --base is silently partial once SPLIT_BASES is non-empty. python benchmarks/verify_pure_move.py --base <anything> still verifies geometry.py against 011816c; only the tools.py arm and the __all__ arm move. The docstring covers it in prose ("pins its own base in SPLIT_BASES rather than using --base"), and the printed mortie/geometry.py@011816c label — a good addition — makes it visible in the output, so this is a note rather than a defect. But --help still says "git revision holding the pre-split source" with no qualifier.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 from Claude

Fixed in cbb1a49, all three parts — and you were right that the body's "noted in the script" claim was false; it was nowhere in the file.

(1) Unresolvable revision → failure entry, not a traceback. Symmetric with what check_public_surface does for ImportError, and applied at all three places a revision is read — check_moves, the new check_pinned_bases, and check_public_surface's git archive. Repointing the pin at a sha this clone does not have:

$ python benchmarks/verify_pure_move.py
1 failure(s):
  - deadbeef...:mortie/geometry.py is not reachable in this clone — repoint or drop its SPLIT_BASES entry — a squash-merge rewrites a branch sha
mortie/tools.py@origin/main: 32/32 definitions accounted for across mortie/convert.py, mortie/orders.py, mortie/buffer.py
__all__: 69 names, all resolvable, equal to origin/main's
EXIT=1

and an unresolvable --base, which previously would have crashed in git archive even after check_moves reported cleanly:

$ python benchmarks/verify_pure_move.py --base deadbeef...
3 failure(s):
  - deadbeef...:mortie/tools.py is not reachable in this clone — check --base
  - deadbeef...:mortie/geometry.py is not reachable in this clone — check --base
  - deadbeef...:the package tree is not reachable in this clone — check --base
mortie/geometry.py@011816c: 44/44 definitions accounted for across mortie/geometry.py, mortie/dissolve.py
__all__: NOT CHECKED — deadbeef... is not reachable in this clone
EXIT=1

Two details that only showed up once I ran it: the hint keys on SPLIT_BASES.get(path) == base, not on path in SPLIT_BASES, or an unresolvable --base would tell you to repoint a pin that is fine; and check_pinned_bases stays silent when the pin is unreachable, because check_moves has already reported it and the first draft printed the identical line twice.

(2) The caveat is in the script now. You were right — the SPLIT_BASES comment block ended at "The revision below is phase 1's head, review folded", and nothing in the file said the sha dies on squash-merge. It is now a CAVEAT: block immediately above the dict, naming both remedies (repoint at the squashed commit, or delete the entry with its SPLITS arm once the move has landed). I also corrected the false claim in the PR body rather than leaving it standing — it now says the caveat was not in the file and now is.

(3) --help says --base is partial. Agreed it was a note rather than a defect, but the qualifier is one line:

  --base BASE  git revision holding the pre-split source. Partial once
               SPLIT_BASES is non-empty: a pinned split compares its own move
               against its pin, and only the pin itself against this revision

One more thing folded from your review of the neighbouring thread, as d470bc4: the script's stated limitations now record that only the moves are checked, not the import rewiring — deleting geometry.py's from .dissolve import _dissolved_polygons leaves the verifier at exit 0, and pytest is what catches it (test_emit_dissolve_is_the_defaultNameError: name '_dissolved_polygons' is not defined, confirmed locally). Documented rather than fixed, so the next reader knows a green run here is half the gate.

old, old_unhandled = top_level_defs(git_show(base, src_path))
failures += [f"{base}:{src_path}: {what} is not comparable — extend "
"top_level_defs before trusting this run"
for what in old_unhandled]
landed = {}
for dst_path in dst_paths:
new, new_unhandled = top_level_defs((REPO / dst_path).read_text())
failures += [f"{dst_path}: {what} is not comparable — extend "
"top_level_defs before trusting this run"
for what in new_unhandled]
for name, (node, text) in new.items():
if name not in old:
if EXPECTED_NEW.get(dst_path, {}).get(name):
continue
failures.append(
f"{dst_path}: {name} is not a move — no such definition "
f"in {base}:{src_path}")
continue
if name in landed:
failures.append(
f"{name}: defined in both {landed[name]} and {dst_path}")
continue
landed[name] = dst_path
old_node, old_text = old[name]
if ast.dump(node) != ast.dump(old_node):
failures.append(
f"{dst_path}: {name} differs from {base}:{src_path} (AST)")
elif text != old_text:
failures.append(
f"{dst_path}: {name} differs from {base}:{src_path} "
"(source text — comments or formatting)")
for name in old:
if name not in landed:
failures.append(
f"{base}:{src_path}: {name} landed in none of "
f"{', '.join(dst_paths)}")
label = base[:7] if re.fullmatch(r"[0-9a-f]{40}", base) else base
print(f"{src_path}@{label}: {len(landed)}/{len(old)} definitions "
f"accounted for across {', '.join(dst_paths)}")
return failures


def base_public_surface(base):
"""Import the package as of ``base`` and return its ``__all__``.

The tree is extracted with ``git archive`` into a temporary directory and
the built ``_rustie`` extension is copied in, so the import is real rather
than a static reading of ``__init__.py``.

Parameters
----------
base : str
Git revision to import.

Returns
-------
list of str
``mortie.__all__`` as evaluated at that revision.

Raises
------
RuntimeError
If the subprocess imported a ``mortie`` from outside the extracted
tree, which would silently compare the working tree against itself.
"""
with tempfile.TemporaryDirectory() as tmp:
archive = subprocess.run(
["git", "archive", base, "mortie"],
cwd=REPO, check=True, capture_output=True,
).stdout
subprocess.run(["tar", "-x", "-C", tmp], input=archive, check=True)
for ext in (REPO / "mortie").glob("_rustie*"):
shutil.copy2(ext, pathlib.Path(tmp) / "mortie" / ext.name)
out = subprocess.run(
[sys.executable, "-c",
"import json, mortie; "
"print(json.dumps([mortie.__file__, sorted(set(mortie.__all__))]))"],
cwd=tmp, check=True, capture_output=True, text=True,
env={**os.environ, "PYTHONPATH": tmp},
).stdout
where, names = json.loads(out.strip().splitlines()[-1])
# realpath both: on macOS the temp dir is reached via a /var -> /private/var
# symlink, so the raw prefix compare would reject a correct import.
if not os.path.realpath(where).startswith(os.path.realpath(tmp)):
raise RuntimeError(
f"the {base} import resolved to {where}, not the extracted tree")
return names


def check_public_surface(base):
"""Pin ``mortie.__all__`` and the resolvability of every name in it.

A definition lost in the move usually breaks ``mortie/__init__.py``'s
re-export, so the import below is exactly what fails first. It is caught
and turned into a failure entry rather than allowed to propagate: the
diagnosis a reader wants is ``check_moves``'s "X landed in none of ...",
not an import traceback that discards it.

Parameters
----------
base : str
Git revision to compare the working tree against.

Returns
-------
list of str
One message per failure; empty when the surface is unchanged.
"""
try:
import mortie
except ImportError as exc:
print("__all__: NOT CHECKED — the working tree does not import")
return [f"importing the working-tree package failed: {exc}"]

failures = []
if not pathlib.Path(mortie.__file__).is_relative_to(REPO):
failures.append(
f"the working-tree import resolved to {mortie.__file__}, outside {REPO}")
return failures

old = set(base_public_surface(base))
new = set(mortie.__all__)
for name in sorted(old - new):
failures.append(f"__all__: {name} was dropped")
for name in sorted(new - old):
failures.append(f"__all__: {name} was added")
for name in sorted(new):
if not hasattr(mortie, name):
failures.append(f"mortie.{name} does not resolve")
print(f"__all__: {len(new)} names, all resolvable, equal to {base}'s"
if not failures else "__all__: MISMATCH")
return failures


def main():
"""Run every check and exit non-zero on the first failing claim.

Returns
-------
int
Process exit status: 0 when the split is a pure move, 1 otherwise.
"""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--base", default="origin/main",
help="git revision holding the pre-split source")
args = parser.parse_args()

# bound separately so the move findings are collected — and reported —
# even when the surface check cannot run
failures = check_moves(args.base)
failures += check_public_surface(args.base)
if failures:
print(f"\n{len(failures)} failure(s):", file=sys.stderr)
for line in failures:
print(f" - {line}", file=sys.stderr)
return 1
print("\nPure move verified.")
return 0


if __name__ == "__main__":
sys.exit(main())
12 changes: 12 additions & 0 deletions docs/api/buffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# mortie.buffer

Cell-set dilation: morton indices in, morton indices out. Split out of
`mortie.tools` by domain (issue #159) so the Python surface mirrors the Rust
tree (`buffer.rs`); the names stay flat on the package
(`mortie.morton_buffer`).

::: mortie.buffer
options:
members:
- morton_buffer
- morton_buffer_meters
Loading
Loading