Skip to content

Convert morton_index, geometry and arrow docstrings to numpydoc - #141

Merged
espg merged 9 commits into
mainfrom
claude/135-numpydoc-sweep
Jul 25, 2026
Merged

Convert morton_index, geometry and arrow docstrings to numpydoc#141
espg merged 9 commits into
mainfrom
claude/135-numpydoc-sweep

Conversation

@espg

@espg espg commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Refs #135, Refs #68.

Deliberately not Closes on either — this is the first (largest) slice of the repo-wide sweep; tools.py, coverage.py, prefix_trie.py and linestring.py remain.

What this does

Converts every docstring in the three highest-payoff modules to numpydoc — one-line imperative summary, Extended Summary for narrative rationale, then Parameters / Returns / Raises. This is the format mkdocstrings renders under docstring_style: numpy; freeform prose publishes there as an undifferentiated block.

It then moves the pandas ExtensionArray classes into a new mortie/pandas.py so those newly-converted docstrings actually publish (see the section below) — the fix for question (1), which is now resolved rather than open.

It also links the docs site from the GitHub landing page (separate commits, so they can be reviewed or reverted on their own).

Nothing but docstrings and comments changed in the library in phases (1)–(3). Proven mechanically, not by eye: for each of the three modules the AST with all docstrings stripped is byte-identical to origin/main.

mortie/morton_index.py IDENTICAL
mortie/geometry.py     IDENTICAL
mortie/arrow.py        IDENTICAL

Six block comments were moved verbatim into the docstrings they sat under — five in morton_index.py (MortonIndexScalar.__format__, MortonIndexScalar.__reduce__, isna, _values_for_argsort, and the _cmp section banner) and one in arrow.py (__arrow_ext_serialize__). The rationale is unchanged; it is now attached to the function rather than buried in the body, and D105 is satisfied as a side effect. Each was re-checked to survive verbatim by a tokenize-level comment diff — note the AST-strip proof above is deliberately blind to comments, so it answers "did any logic change" and not "did any prose vanish"; the token-level check is the one that covers the second question.

Per-module before/after

Counting numpydoc Parameters blocks against def/class statements, and ruff check D-rule violations:

module Parameters before Parameters after defs ruff D before ruff D after
mortie/morton_index.py 1 27 59 8 0
mortie/geometry.py 2 31 32 7 0
mortie/arrow.py 0 7 17 2 0

Parameters count < def count is correct, not incomplete: many of these take no arguments (orders(), to_nested(), nbytes), and the constructors' Parameters live on the class docstring per numpydoc. The complementary counts: Returns 43 / 32 / 14, Raises 18 / 11 / 6.

Per the point made on issue #135, the ruff column is the weaker signal — ruff never requires a Parameters section to exist, so it went green long before the work was done. Completion here was judged by reading the docstrings, backed by two ast-walking audits: one static (defs with arguments and no Parameters block; defs with a return and no Returns block) and one against the live classes built by _build_classes(), which static analysis cannot reach.

Phase (8): the pandas ExtensionArray moves to mortie/pandas.py

This is the fix for question (1) below, and it is a placement refactor with zero change to dependency behaviour.

The problem

MortonIndexDtype / MortonIndexArray were defined inside the function morton_index._build_classes() and exposed through a module-level __getattr__. PEP 562 __getattr__ fires only on a missing attribute, so:

'MortonIndexArray' in vars(mortie.morton_index)   -> False
MortonIndexArray.__qualname__                     -> '_build_classes.<locals>.MortonIndexArray'

dir(), vars() and inspect.getmembers() never saw them, so mkdocstrings rendered nothing for them and the numpydoc docstrings converted in phase (1) published nowhere.

The nesting existed only because a class statement evaluates its base at definition time, so a module-level definition in morton_index.py would have forced pandas at import time.

The fix

The classes now live at module level in a new mortie/pandas.py, which imports pandas at its own top level. mortie itself still never imports pandas; the submodule is imported only when the classes are first requested (or once at import time when pandas is already installed, to register the dtype string). It parallels the existing mortie/arrow.py, which holds the pyarrow ExtensionType.

The apparent shadowing hazard does not exist: under Python 3 absolute imports, a submodule named pandas doing import pandas as pd resolves to the real pandas, and top-level import pandas is unaffected. Both are pinned by tests. One place it would shadow is from mortie import *, so 'pandas' is deliberately kept out of mortie.__all__ (unlike 'morton_index' / 'arrow'), with a test for that too.

Docstrings moved verbatim — proven, not asserted

Given that the self-review round already caught one silent loss of normalize's ring-winding caveat during this PR's conversion work, the move was checked mechanically at three levels:

MortonIndexArray: AST identical (indent-normalized) -> True
MortonIndexDtype: AST identical (indent-normalized) -> True
token streams identical -> True (2151 vs 2151 tokens)
MortonIndexArray: 46 docstrings compared, 0 changed []
MortonIndexDtype:  3 docstrings compared, 0 changed []

The token-stream comparison is the strongest of the three: the 2151-token stream of the old nested block (dedented) is character-for-character identical to the new module-level block, which covers comments as well as docstrings. The AST and docstring comparisons normalise indentation with inspect.cleandoc, because dedenting by 4 necessarily changes the literal text of every multi-line docstring without changing what Python or numpydoc renders. 49 docstrings compared, 0 changed.

Dependency behaviour is unchanged — verified, not assumed

All four hard requirements were checked in a subprocess with a sys.meta_path blocker that makes pandas genuinely unimportable (the pattern from test_decimal_parse.py::TestPublicSurface::test_parses_with_pandas_unavailable), because import mortie really does touch pandas when it is installed:

  • import mortie still works with pandas absent. 'pandas' not in sys.modules and 'mortie.pandas' not in sys.modules after import; decimal_to_word still callable.
  • Every existing import path resolves to the same object. mortie.MortonIndexArray, mortie.morton_index.MortonIndexArray (the load-bearing downstream path zagg imports) and mortie.pandas.MortonIndexArray are is-identical, as are the three MortonIndexDtype paths.
  • pd.Series(dtype="morton_index") still resolves. @register_extension_dtype runs at class creation, so the eager block at the bottom of morton_index.py now imports the submodule instead of calling _build_classes(). Pinned both in-process and in a fresh interpreter (where no earlier test can have registered it).
  • The friendly ImportError survives on both paths — see below.

The ImportError message is reworded (deliberate change)

The old text named two remedies without explaining how they differ:

the morton_index ExtensionArray requires pandas; install it with `pip install mortie[pandas]` (or `pip install pandas`)

It now reads:

the morton_index ExtensionArray requires pandas, which is not installed. Install it directly with `pip install pandas`, or declare it as a mortie extra with `pip install mortie[pandas]`, which pins pandas as a mortie dependency so it is loaded whenever mortie is imported.

There is exactly one definition of this string — morton_index._require_pandas() — which mortie/pandas.py calls at its own module level rather than doing a bare import pandas. A bare top-level import would have regressed the direct-import path to a plain ModuleNotFoundError; routing through the one helper means the attribute path and the direct import cannot drift. Verified identical across all three paths:

mortie.MortonIndexArray                        # -> ImportError, curated text
from mortie.morton_index import MortonIndexArray  # -> same text
from mortie.pandas import MortonIndexArray        # -> same text
len(set(msgs)) == 1

The wording quoted in docs/morton_index_datatype.md was updated to match. No test asserted the old wording; the new tests assert the distinctive substring pins pandas as a mortie dependency rather than the whole paragraph.

One related consistency fix: mortie.pandas as an attribute previously would have raised AttributeError on a numpy-only install (the package __getattr__ did not know the name) while import mortie.pandas raised the curated ImportError. mortie/__init__.py now routes the name through importlib.import_module, so both spellings give the same curated error. import_module rather than from . import pandas specifically because the latter does a hasattr on the package first, which would re-enter __getattr__.

Docs: anchors before → after

The classes now render. Counted with the command from the acceptance criteria:

grep -o 'id="mortie\.[^"]*"' site/api/<page>/index.html | sort -u | wc -l
page before after
api/morton_index 7 7
api/pandas — (did not exist) 40
total unique 7 47

A note on the baseline number: the brief for this phase quoted 3, which was accurate at 4777493 but is stale as of d1662c2 ("publish MortonIndexScalar on the API page"), which added the four MortonIndexScalar anchors. Measured immediately before this commit the figure is 7. Either way the 40 new anchors — MortonIndexArray plus 36 of its members, and MortonIndexDtype plus 2 — are entirely new; nothing that rendered before stopped rendering.

mkdocs build --strict exits 0 with zero warnings (exit code read directly, not through a pipe).

Docs path: rendered under mortie.pandas, with the canonical import stated

Statically parsed, the classes document under mortie.pandas.MortonIndexArray, while the canonical user-facing import stays mortie.MortonIndexArray. I chose to render under the real path rather than alias the heading, on a new docs/api/pandas.md page, and to state the import situation explicitly in an admonition at the top of that page.

Reasoning: aliasing the heading to mortie.MortonIndexArray would put a path in the heading and the anchor that mkdocstrings itself cannot resolve — mortie/__init__.py exposes those names through __getattr__ too, so griffe cannot see them at the top level either, and the alias would be a hand-maintained fiction that the build could never check. Rendering under the definition site keeps every anchor backed by something static analysis verified. The acceptance constraint was that the page must not imply an import path that does not work; all three paths shown on the page (mortie, mortie.pandas, mortie.morton_index) genuinely resolve, and the admonition names the short one as canonical. If you would rather the reference read mortie.MortonIndexArray, that is a one-line heading: option on the page and I will switch it.

docs/api/morton_index.md now describes itself as the numpy-only surface and links across to the new page instead of explaining why the classes cannot be rendered.

Docs links on the README

Two badges plus a prose link, because a status badge and a link badge answer different questions:

  • Docs link badge (static shields.io) at the head of the badge block, pointing at — where the docs are. Same /badge/--.svg grammar as the python-3.10+-blue.svg badge already there.
  • Docs status badge (actions/workflows/docs.yml/badge.svg) immediately after the existing Tests badge, so the two workflow-status badges sit together — whether the docs build is passing. .github/workflows/docs.yml is present on main (workflow name: Docs) and has run green, so this resolves rather than rendering an error image.
  • A short ## Documentation section near the top with a prose link, just above the paragraph that points at the in-tree docs/*.md copies. A badge alone is easy to miss.

Expected, not a broken link: the site root currently serves the "Documentation has not been published yet…" placeholder. mike deploy dev writes /dev/ and versions.json but does not touch the root; a root redirect only appears when mike set-default runs, which happens automatically at the first release tag. The real docs are live at /dev/ today. The link deliberately targets the root anyway — it is the stable URL and starts resolving to real content at the first tag, whereas /dev/ is a version that gets superseded. Please don't file the placeholder as a broken link.

Scope: the other four modules are deliberately deferred

tools.py, coverage.py, prefix_trie.py and linestring.py are untouched. tools.py is being edited concurrently under #136 and by the unmerged PR #130 (which privatises heal_norm); converting it in this PR guarantees a conflict for no benefit. They are also the already mostly-converted modules (18/23 and 11/16 Parameters blocks), so the payoff is much smaller. They should land as a follow-up once #136 and #130 settle.

Phases

  • (1) mortie/morton_index.pyd637563
  • (2) mortie/geometry.py832c280
  • (3) mortie/arrow.py187f4fc
  • (4) docs link badge + ## Documentation section on README.md02b230c
  • (5) docs workflow status badge on README.mde68098e
  • (6) address adversarial self-review — 99ef2fc
  • (7) publish MortonIndexScalar on the API page — d1662c2
  • (8) move the pandas ExtensionArray to mortie/pandas.py so its docstrings publish — 8032cd0
  • (9) tools.py / coverage.py / prefix_trie.py / linestring.py — deferred, see above

Self-review round (99ef2fc)

The fresh-context review found one real regression, which is fixed:

The thin wrappers had become less informative than the prose they replaced. from_wkb / from_wkt previously said "See from_geometry for the parameters", which sent the reader to the complete text. My first pass replaced that with an abbreviated table that silently dropped normalize's winding contract — an apparently-complete table that omits the one thing a caller must act on is worse than the pointer was. They now document only the genuinely local parameter and defer the rest by name, cueing the caveat at the point of use and linking the full contract, with no second copy to drift:

order, moc, normalize, tolerance, max_cells : optional
    Forwarded to :func:`from_geometry` unchanged.  See there for the full
    contract — in particular that ``morton_coverage_moc`` has no
    orientation auto-correct, so with ``moc=True`` the ring winding is
    taken **as authored**.

Raises was also missing on all four wrappers; added, and verified reachable rather than inferred from the delegate:

>>> from_wkb(shapely.to_wkb(shapely.LineString([(0,0),(1,1)])), moc=True)
ValueError: moc / tolerance / max_cells apply only to polygonal geometry

Also: _decimal_to_word's summary had lost the word "deprecated" to the imperative rewrite, putting the one fact that matters most in the only place that does not render. Restored to the summary line.

How it was tested

  • pytest -v775 passed, 12 skipped. The pre-phase-(8) baseline was 757 passed, 12 skipped; the 18 new tests are all in mortie/tests/test_pandas_module.py and nothing that passed before was removed, skipped or weakened. Phases (1)–(3) had held the count byte-for-byte at 757, as a docs-only change must.
  • flake8 mortie --select=E9,F63,F7,F82 → clean.
  • ruff check mortie/pandas.py mortie/morton_index.py mortie/tests/test_pandas_module.pyAll checks passed. The two D200/D400 findings ruff check mortie reports on mortie/__init__.py are on line 1 (the pre-existing module docstring) — confirmed identical before and after this commit by re-running against the stashed tree.
  • Docstrings parsed with griffe in Parser.numpy mode with warn_unknown_params on → no warnings.
  • mkdocs build --strict → exit 0, zero warnings, with the 47 anchors above.
  • Verbatim-move proof and the pandas-absent subprocess checks as described in phase (8).
  • CodSpeed: 67 untouched benchmarks, no performance change.

Questions for review

(1) RESOLVED in phase (8). The MortonIndexArray / MortonIndexDtype methods are converted but do not publish today. The classes are now module-level in mortie/pandas.py and render as 40 anchors on api/pandas. Recorded here rather than deleted because it is what motivated the move. Note the resolution took neither of the two options I floated (mkdocstrings dynamic loading, or a TYPE_CHECKING shim) — moving the definition to a module that is allowed to import pandas keeps the docs statically verifiable and needs no docs-tool escape hatch.

(2) The second clause of the new ImportError message describes both remedies, not just one — flagging rather than editing it. The message says pip install mortie[pandas] "pins pandas as a mortie dependency so it is loaded whenever mortie is imported." The first half is the real distinction and is accurate. The second half ("so it is loaded whenever mortie is imported") is equally true after a plain pip install pandas, because the eager registration probe keys off whether pandas is importable, not off how it was installed — so as written it may read as implying the bare pip install pandas route leaves the ExtensionArray unavailable, which it does not. I applied the wording exactly as specified rather than quietly rewriting it; if you want the sharper distinction, something like "records pandas in mortie's own dependency metadata, so environments that reinstall mortie get it automatically" is the difference that actually holds. One-line change either way.

(3) mortie/pandas.py is 1002 lines, marginally over the ~1000-line guideline. Raising it per the convention rather than acting. It is a pure move — 953 of those lines are the two class bodies verbatim, and the net across the pair is an improvement (morton_index.py 1319 → 343, plus 1002 new). Splitting the dtype and the array into separate modules is possible but would break the "moved verbatim" property that makes this diff cheap to review, so I did not. Happy to split as a follow-up if you want it under the line.

(4) Two adjacent documentation badges — your call. The block now carries Documentation (static link, line 4) and Docs (workflow status, line 6), separated only by Tests. They answer different questions and both were asked for, and rendered they look less alike than the source does (docs | espg.github.io/mortie versus Docs | passing). The self-review suggested dropping the static one now that the workflow publishes a real site. I did not, because that would undo an explicit instruction rather than fix a defect — but it is a one-line change if you prefer it.

(5) Sphinx roles render literally on the new page. The moved docstrings use :meth:/:func: roles, which mkdocstrings under docstring_style: numpy emits as literal text (:meth: from_nested). This is pre-existing and repo-wide — api/morton_index already renders 10 such roles today — and the move made it visible on 40 more anchors rather than introducing it. Not fixed here because it is a cross-cutting choice (either strip the roles or add a cross-reference extension), and doing it inside a "moved verbatim" commit would destroy that guarantee. Worth its own issue.

(6) Rationale I found hard to place. Two spots, both resolved by keeping the prose rather than tabulating it:

  • _require_shapely carries a standing open question ("Whether to invest in a spherely introspection shim is an open question for the issue thread (see the PR's 'Questions for review')"). It refers to another PR's review section, which is no longer reachable from the code. Kept verbatim in the Extended Summary, but it is a dangling reference and probably wants either a real issue number or deletion.
  • _stitch_segments' explanation of when the pole argument is reachable ("it is only ever reached when the segments are genuinely unbalanced, so a non-pole cover never touches it") is a contract statement about the argument, so it moved into the pole entry under Parameters; the GeoJSON-convention paragraph stayed prose.

(7) One place the existing prose looks wrong, not merely unstructured — surfacing rather than silently rewriting. MortonIndexArray.hive_path documented only ValueError "on any empty / invalid word", but the body also raises ValueError for a point id:

for s in self.decimal_repr():
    if s.endswith("p"):
        raise ValueError(f"hive_path is undefined for point ids ({s!r}): ...")

The docstring never mentioned it. from_hive_path has the same gap twice over — it documented the mis-filed-leaf ValueError but neither the point-suffix rejection nor the "leaf does not end with suffix" rejection. I documented all three in Raises, since the behaviour is unambiguous in the code and matches spec section 2 / issue #120 — but the docstrings were previously incomplete, so this is a documentation-accuracy fix riding along and worth a second pair of eyes.

(8) Missing defaults on the geometry parameter tables. from_geometry documents moc / normalize without stating their defaults (False / True). Pre-existing, not introduced here, and I left it rather than fixing it only on the wrappers — that would make the delegate look like the less complete of the two. Worth a separate consistency pass across the module.

(9) Minor. base_cell, order, is_fixed_order, _dissolved_rings_py and a few others had noun-phrase summaries ("The single shared order, or raise if…"), which D401 rejects. Rewriting them to imperative mood ("Return the single shared order of a fixed-order array.") changes the summary wording; the "or raise" half moved to Raises. No meaning lost, but the summaries do read differently from before.

@espg espg added the implement label Jul 25, 2026
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.57430% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.90%. Comparing base (4c67ed1) to head (8032cd0).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
mortie/pandas.py 93.36% 16 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #141      +/-   ##
==========================================
+ Coverage   94.41%   94.90%   +0.49%     
==========================================
  Files           9       10       +1     
  Lines        1378     1434      +56     
==========================================
+ Hits         1301     1361      +60     
+ Misses         77       73       -4     
Flag Coverage Δ
unittests 94.90% <93.57%> (+0.49%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
mortie/__init__.py 88.46% <100.00%> (+1.50%) ⬆️
mortie/arrow.py 100.00% <ø> (+7.05%) ⬆️
mortie/geometry.py 95.18% <ø> (ø)
mortie/morton_index.py 93.44% <100.00%> (+0.04%) ⬆️
mortie/pandas.py 93.36% <93.36%> (ø)

... and 1 file with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 4c67ed1...8032cd0. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@codspeed-hq

codspeed-hq Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 67 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing claude/135-numpydoc-sweep (8032cd0) with main (71807bf)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

@espg espg left a comment

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)

Fresh-context adversarial review of e68098e (the head as of this review — the branch gained 02b230c and e68098e mid-review, both included below).

Verdict: the conversion is faithful. I went looking specifically for dropped rationale and did not find any in the three modules. Four comments posted, one of which is a real content loss (from_wkb / from_wkt), one a stale contradiction between the PR body and the head commit, and two minor.

What I verified independently, not by reading the summary

  • Docstring-only claim — re-derived, holds. Parsed both revisions with ast, stripped every module/class/function docstring, compared ast.dump. All three modules IDENTICAL. Separately skimmed the raw diff for changed logic, defaults, or runtime string literals: none. But the AST check is blind to comments — see the morton_index.py comment on the six (not two) removed comment blocks. All six survive in the docstrings that replaced them; only the body's count is wrong.
  • Lost rationale — the priority item. Extracted every docstring from both revisions keyed by qualified name and diffed them pairwise, then ran a token-level check for words present in the old text and absent from the new. Every hit but one resolved to a faithful paraphrase or a move into a structured section. Spot-confirmed the load-bearing cross-references all survive: #35/#58 (both module docstrings), #48 (from_legacy, decimal_repr, to_decimal, to_legacy_i64), #93 (from_arrow), #104 (MortonIndexScalar, __getitem__, hive_path, _word_repr), #114 (the four parse functions), #120 (the p kind-suffix rule in five places), #62 (hive_path), #71 (from_geometry, to_geometry). The _require_shapely spherely open question and the _stitch_segments pole-reachability contract are both intact, as the body claims. The one genuine loss is from_wkb / from_wkt.
  • Examples that do not run — vacuous. No Examples section and no >>> line anywhere in the diff, so there was nothing to execute.
  • Docstrings that lie — none found. Wrote an ast walker comparing every Parameters entry against the real signature across all three files: zero missing, zero extra, zero renamed, defaults all correct. (The only "no Parameters section" hits are MortonIndexArray.__init__, whose parameters correctly live on the class docstring per numpydoc, and the six one-line comparison dunders.) Then exercised ~30 Returns / Raises claims against the built extension rather than reading them — order()/base_cell() returning None on empty, the mixed-order ValueError, the 2-D ctor ValueError, points=True with order != 29, lat/lon shape mismatch, to_legacy_i64 above order 18, all three from_hive_path rejections, the point-id hive_path rejection, decimal_to_word's dtype TypeError, decimals_to_words on a bare str, decompose on a Point and on POLYGON EMPTY, _spherical_signed_area on a 2-vertex ring returning 0.0, _tangent_azimuth on parallel vectors returning 0.0, __getitem__ scalar-vs-slice return types. All matched. Including the subtle one — "%d" % key really does bypass __format__ and emit 10403315139225845764 while f"{key}" gives -31123, exactly as the promoted __format__ docstring claims.
  • Behaviour change — none. Test suite is byte-identical to baseline.

Gates, re-run at e68098e

ruff check mortie/{morton_index,geometry,arrow}.py   All checks passed!
flake8 mortie --select=E9,F63,F7,F82                 clean
flake8 <the three> --max-line-length=88              clean
pytest -q                                            757 passed, 12 skipped

I also reproduced the rendered-docs check independently, which is now easier than when the body was written: mkdocs.yml and docs/ have since landed on main (c901d63..1fa5bfa, issue #133), so no cross-branch assembly is needed. Building main's docs tree against this branch's mortie with mkdocs build --strict (which mkdocs.yml also sets internally) → built clean, 14 rendered doc sections on api/geometry and 13 on api/arrow, matching the body's numbers. api/morton_index renders 6, consistent with Questions item (1).

On the open questions

  • (1) _build_classes() nesting. Confirmed — griffe cannot reach the nested classes, so the ~45-object runtime surface publishes as 6. Converting them anyway was the right call: they are what help() and an IDE show. Agreed this is a design call for a follow-up, not something to fold in here.
  • (3) hive_path / from_hive_path Raises gaps. Verified all three additions against the running code (transcripts in the from_wkb comment's style). The new entries are accurate and the previous docstrings were genuinely incomplete. Documenting observed behaviour is the right resolution — no behaviour changed.
  • (4) Reworded summaries. Reviewed each. The base_cell / order / is_fixed_order rewrites lose nothing (the "or raise" half has a real Raises section to land in). _decimal_to_word is the exception — see that comment. Two other summaries shed a word without a home: to_decimal dropped "Vectorized" (which was what distinguished it from decimal_repr) and _word_repr went from "issue #104" to "#104", inconsistent with the file's usual spelling. Both trivial, mentioned only for completeness.

Scope respected: tools.py, coverage.py, prefix_trie.py, linestring.py untouched, and I confirmed the #136 / #130 deferral reasoning holds — no commit on main since the merge-base touches any of the three converted modules, so there is no revert risk from the 10 commits this branch is behind.


Generated by Claude Code

Comment thread README.md

[![Documentation](https://img.shields.io/badge/docs-espg.github.io%2Fmortie-blue.svg)](https://espg.github.io/mortie/)
[![Tests](https://github.com/espg/mortie/actions/workflows/test.yml/badge.svg)](https://github.com/espg/mortie/actions/workflows/test.yml)
[![Docs](https://github.com/espg/mortie/actions/workflows/docs.yml/badge.svg)](https://github.com/espg/mortie/actions/workflows/docs.yml)

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)

The PR body argues against this exact line. Under "Docs link on the README" it says:

Deliberately not a workflow status badge (actions/workflows/docs.yml/badge.svg): .github/workflows/docs.yml does not exist on main yet, so that badge would render as a permanent broken image.

Head commit e68098e adds precisely that badge. The badge itself is fine — the premise went stale, it did not stay true. .github/workflows/docs.yml does exist on main now, added by 4777493 ("adding docs workflow"), one of the 10 commits main has gained since this branch's merge-base 9c7ac70. I confirmed the file is on origin/main and that its name: Docs matches the badge's label, so it will resolve and render a real status rather than an error image.

What needs fixing is the prose, not the badge:

  1. Those two paragraphs now contradict the diff. A reviewer reading the body will believe no status badge was added, and will believe docs.yml is still missing.
  2. The Phases checklist has no entry for e68098e — phase (4) is checked off against 02b230c alone, so the fifth commit is unaccounted for.

Secondary, and a judgement call rather than a defect: the badge block now carries two documentation badges back to back — Documentation (static shields.io link, line 4) and Docs (workflow status, line 6). They are genuinely different things (a link vs. CI health), but in a nine-badge row they read as duplicates. Either drop the static one now that the workflow publishes a real site, or move the status badge down next to the other CI badges so the row groups link-badges and status-badges separately.

Checks on the two README commits that came back clean:

  • Badge URL form. /badge/docs-espg.github.io%2Fmortie-blue.svg splits on - into label docs, message espg.github.io%2Fmortie (rendering as espg.github.io/mortie), color blue, format svg. No literal dash falls inside a field, so no -- escaping is needed, and the dots are not separators. Structurally identical to the python-3.10+-blue.svg badge already in the block. Well-formed.
  • Existing badges undisturbed. Both commits are pure insertions — no reordering, no dropped badge, no altered URL on any pre-existing line.
  • No effect on the docs build. mkdocs.yml (now on main) leaves docs_dir at its default docs/ and its nav lists only docs/*.md, so the root README.md is outside the build entirely. Confirmed by building: see the review summary.
  • Root vs /dev/. Linking the site root is documented as deliberate and I agree with it — /dev/ is a version that will be superseded. Worth stating the one consequence out loud, since it lands on the repo's front page: until the first release tag runs mike set-default, both the badge and the prose link resolve to the "Documentation has not been published yet…" placeholder. Acceptable if that window is short.

Generated by Claude Code

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

The contradiction was real but is already gone — you read a stale snapshot of the body. It was rewritten at 22:52, three minutes before this comment, in the same pass that added the badge; the current body describes both badges under "Docs links on the README" and the Phases checklist does carry (5) docs workflow status badge on README.md — e68098e. Nothing to change there. Your read of why the badge is now correct matches mine exactly, including 4777493 and the name: Docs match.

On the two adjacent doc badges — keeping the current arrangement, and this one is not mine to change. Both badges are there on an explicit maintainer instruction: a status badge and a link badge "answer different questions and both are worth having," with placement to match the existing block. Dropping the static one, which is your first option, would undo that directive, so it is a side-effecting scope change rather than diff-scoped feedback — flagging it for a human rather than acting on it.

Your second option is already the layout: the status badge sits on line 6, immediately after Tests on line 5, so the two workflow-status badges are adjacent. What is left is that the link badge on line 4 is separated from them by one line. Rendered, the two are less alike than the source suggests — docs | espg.github.io/mortie versus Docs | passing — so I do not think a reader conflates them. Happy to reorder if preferred.

One thing you got right that I got wrong, and I have corrected the PR body for it: my claim of "two block comments" moved into docstrings was an undercount. A tokenize-level diff finds six blocks — 14 comment lines in morton_index.py (__format__, __reduce__, isna, _values_for_argsort, and the _cmp section banner) plus one in arrow.py (__arrow_ext_serialize__). I re-verified each survives verbatim in its replacing docstring, so your conclusion holds: only the count was wrong, no rationale was lost. Worth stating plainly that the AST-strip proof is blind to comments — it is the right tool for "did any logic change" and the wrong one for "did any prose vanish," which is why the token-level check is the one that caught this.

On the placeholder window: agreed it is worth saying out loud, and it is already called out in the body under "Expected, not a broken link." Its length is a maintainer decision — it closes at the first release tag.


Generated by Claude Code

Comment thread mortie/geometry.py Outdated
moc : bool, optional
Polygonal only: return a compact MOC instead of a flat cover.
normalize : bool, optional
Flat polygon cover only: auto-correct ring orientation at ingest.

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)

This is the one place in the three modules where the conversion actually loses a caveat at the point of use.

from_geometry documents normalize in full:

normalize : bool, optional
    Flat polygon cover only: auto-correct ring orientation at ingest
    (see :func:`mortie.morton_coverage`).  Ignored when ``moc=True`` and for
    linear geometry.  Note ``morton_coverage_moc`` has no orientation
    auto-correct, so with ``moc=True`` the ring winding is taken **as
    authored** — for hemisphere-plus polygons wind exteriors CCW / holes CW.

The new from_wkb (this line) and from_wkt (line 456) reduce that to a single clause and drop the winding contract — the part a caller actually has to act on. Before this PR neither function carried a parameter table at all; they said "See :func:from_geometry for the parameters", which sent the reader to the complete text. Now they present an apparently-complete table that silently omits it, which is a worse outcome than the pointer was: the reader has no cue that anything is missing, and the two copies will drift.

Two smaller instances of the same shape:

  1. Neither from_wkb nor from_wkt has a Raises section, although from_geometry documents ValueError for moc / tolerance / max_cells on linear geometry — and from_wkb reaches it. Verified against the built extension:

    >>> from_geometry(shapely.LineString([(0, 0), (1, 1)]), moc=True)
    ValueError: moc / tolerance / max_cells apply only to polygonal geometry
    
  2. to_wkb (line 1209) and to_wkt (line 1237) have the same gap against to_geometry, which this PR gives a NotImplementedError entry (backend-not-shapely, and the hole-nests-into-no-exterior case). Both wrappers call straight into it.

Also note the duplicated entries drop the defaults that the signature carries (moc=False, normalize=True), which from_geometry also omits — pre-existing there, so not introduced here, but now replicated into two more places.

Either direction resolves it, and both are consistent with §7's "narrative rationale belongs in the Extended Summary":

  • (1) Keep the wrapper tables to the parameters that are genuinely local (data / text / srid), and let See Also carry the rest — closest to the pre-change behaviour, with the numpydoc cross-link the old prose lacked.
  • (2) Mirror the delegate's text verbatim, including the winding caveat and a Raises section.

(1) is the lower-maintenance option given these are described in their own summaries as thin wrappers.


Generated by Claude Code

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

Agreed, and fixed in 99ef2fc — this was a real regression, not a style preference. Took option (1).

from_wkb / from_wkt now keep a full entry only for the genuinely local parameter and defer the rest by name, so there is no second copy to drift and no silently-truncated caveat:

order, moc, normalize, tolerance, max_cells : optional
    Forwarded to :func:`from_geometry` unchanged.  See there for the full
    contract — in particular that ``morton_coverage_moc`` has no
    orientation auto-correct, so with ``moc=True`` the ring winding is
    taken **as authored**.

The winding contract is now cued at the point of use and the reader is sent to the complete text, which is strictly better than the pre-PR pointer.

Raises added to all four wrappers. I verified the from_wkb path reaches it rather than assuming it from from_geometry:

>>> g.from_wkb(shapely.to_wkb(shapely.LineString([(0,0),(1,1)])), moc=True)
ValueError: moc / tolerance / max_cells apply only to polygonal geometry

to_wkb / to_wkt got the same treatment against to_geometry, with NotImplementedError covering both the non-shapely-backend and the hole-nests-into-no-exterior cases.

On the missing defaults: leaving those alone deliberately. They are absent on from_geometry too, so adding them only to the wrappers would make the delegate look like the less complete of the two. Worth a separate pass across the module rather than a partial fix here.

pytest still 757 passed / 12 skipped; ruff and both flake8 passes clean; the docstring-stripped AST is still identical to origin/main.


Generated by Claude Code

Comment thread mortie/morton_index.py Outdated

def _decimal_to_word(s):
"""Deprecated private alias for :func:`decimal_to_word` (issue #114).
"""Parse one decimal Morton string into a Python ``int`` (issue #114).

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)

The imperative rewrite here costs the deprecation marker its position. Old summary:

Deprecated private alias for :func:`decimal_to_word` (issue #114).

New summary is Parse one decimal Morton string into a Python ``int`` (issue #114)., with "Deprecated private alias for :func:decimal_to_word." demoted into the Extended Summary. The summary line is what gets rendered as the object's one-liner (in listings, in help()'s first line, in an IDE hover), so the single most important fact about this function — that it is on a deprecation cycle and new code should not call it — is now the one fact that does not appear there.

This is the same class of change as the summaries called out under "Questions for review" item (4), but unlike base_cell / order / is_fixed_order (where the dropped half genuinely moved to Raises and nothing was lost), here the dropped word has no structured section to move to.

Cheapest fix that keeps D401 happy is to put it back in the summary:

Parse one decimal Morton string into a Python ``int`` (deprecated; issue #114).

Alternatively use the directive, which is what tooling actually keys on and which mkdocstrings renders as a callout:

.. deprecated::
    Use :func:`decimal_to_word` instead.

Minor and non-blocking either way — the extended summary does still say it plainly.


Generated by Claude Code

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

Good catch — fixed in 99ef2fc. The summary is now:

Parse a decimal Morton string to ``int`` (deprecated; issue #114).

You are right that this is a different class from the base_cell / order / is_fixed_order rewrites in item (4): there the dropped half had a structured section to land in, here it had none, so demoting it to the Extended Summary genuinely buried the one fact a caller needs. Keeping it in the summary line costs four characters and D401 is still satisfied.

Went with the inline marker rather than the .. deprecated:: directive on purpose. The directive is the better long-term answer, but this is a private alias — it is not in __all__ and mkdocstrings will not render it, so the callout would never be seen; the summary line is what actually surfaces in help() and IDE hover, which is where zagg's parse boundary will encounter it. If heal_norm's privatisation in PR #130 ends up establishing a house pattern for deprecation markers, this should follow it then.


Generated by Claude Code

Comment thread mortie/morton_index.py Outdated
# The empty sentinel (all-zero word, prefix 0) is the missing value.
"""Return a boolean mask of the missing elements.

The empty sentinel (all-zero word, prefix 0) is the missing value.

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)

The docstring-only proof has one blind spot, and the body's account of it undercounts.

The AST-with-docstrings-stripped comparison is the right check and I re-derived it independently — all three modules are byte-identical to origin/main once docstrings are removed. But comments are not AST nodes, so that check cannot see comment removals at all. The body covers this with:

Two block comments in morton_index.py (MortonIndexScalar.__format__, MortonIndexScalar.__reduce__) were moved verbatim into the docstrings they sat under

A tokenize-level diff of COMMENT tokens between origin/main and e68098e finds six removed comment blocks across two files, not two in one:

file comment
mortie/arrow.py # No parameters to carry; the extension name is the whole identity.
mortie/morton_index.py the MortonIndexScalar.__format__ block
mortie/morton_index.py the MortonIndexScalar.__reduce__ block
mortie/morton_index.py # The empty sentinel (all-zero word, prefix 0) is the missing value. (isna, this line)
mortie/morton_index.py the _values_for_argsort Z-order / bit-63 block
mortie/morton_index.py the _cmp Z-order / bit-identity block

mortie/geometry.py's comments are untouched.

The good news, and the reason this is not a defect: I checked all six individually and every one's text survives in the docstring that replaced it, in some cases verbatim. Nothing was lost — this is the same promotion the body describes, just applied in six places rather than two. The arrow.py one lands in __arrow_ext_serialize__'s Returns; the three extra morton_index.py ones land in isna, _values_for_argsort and _cmp respectively.

So the only ask is to correct the count in the body. It matters because that sentence is a reviewer's only notice that the diff removes non-docstring lines, and it is the one part of the change the mechanical proof does not cover — a reader who trusts "two, both in MortonIndexScalar" has no reason to look at arrow.py or at _cmp.

Worth adding the tokenize comment diff alongside the AST check if this sweep continues into tools.py / coverage.py / prefix_trie.py / linestring.py in phase (5) — those modules carry considerably more inline commentary than these three, so the same promotion pattern has more room to drop something there.


Generated by Claude Code

@espg espg added the waiting label Jul 25, 2026
Comment thread mortie/__init__.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ [ruff] <D200> reported by reviewdog 🐶
One-line docstring should fit on one line

"""
mortie: a library for generating morton indices
"""

Comment thread mortie/__init__.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ [ruff] <D400> reported by reviewdog 🐶
First line should end with a period

"""
mortie: a library for generating morton indices
"""

@espg

espg commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

🤖 from Claude

Phase (8) is pushed as 8032cd0 — the pandas ExtensionArray classes are now genuine module-level definitions in a new mortie/pandas.py. This closes out question (1), which had been open since the phase (1) conversion: the numpydoc docstrings written there now actually publish.

Anchors on the API reference, before → after (counted with grep -o 'id="mortie\.[^"]*"' site/api/<page>/index.html | sort -u | wc -l):

page before after
api/morton_index 7 7
api/pandas 40
total unique 7 47

The brief for this phase quoted a baseline of 3, which was right at 4777493 but stale as of d1662c2 (the MortonIndexScalar publish added four anchors). Measured immediately before this commit it is 7. Nothing that rendered before stopped rendering.

Docstrings moved verbatim — checked mechanically, given that the self-review round already caught one silent loss of normalize's ring-winding caveat during this PR's conversion work:

token streams identical -> True (2151 vs 2151 tokens)
MortonIndexArray: 46 docstrings compared, 0 changed []
MortonIndexDtype:  3 docstrings compared, 0 changed []

The token comparison is the strong one: character-for-character identity of the dedented block, covering comments as well as docstrings. The AST/docstring comparisons normalise indentation with inspect.cleandoc, since dedenting by 4 necessarily rewrites every multi-line docstring literal without changing what numpydoc renders.

Dependency behaviour is unchanged, verified in a subprocess with a sys.meta_path blocker rather than assumed — import mortie genuinely touches pandas when it is installed, so sys.modules alone proves nothing. import mortie still succeeds with pandas unimportable; all three class import paths (mortie., mortie.morton_index., mortie.pandas.) are is-identical; pd.Series(dtype="morton_index") still resolves, pinned in a fresh interpreter so no earlier test can have registered it for us.

pytest 775 passed / 12 skipped (baseline 757/12; the 18 new tests are all in mortie/tests/test_pandas_module.py). mkdocs build --strict exit 0, zero warnings. flake8 mortie --select=E9,F63,F7,F82 clean; ruff check clean on every file touched.

Three things worth your eyes, all written up in the body:

(1) The reworded ImportError message is in, with a single definition in _require_pandas() that both the attribute path and the direct from mortie.pandas import … route through, so the two cannot drift. I did flag one accuracy point in the body rather than editing the wording myself: the clause "so it is loaded whenever mortie is imported" is true of a plain pip install pandas too, so it does not actually distinguish the two remedies and could read as implying the bare install leaves the ExtensionArray unavailable. Applied as specified; a sharper alternative is proposed in question (2) if you want it.

(2) The docs-path decision. I render under the true mortie.pandas path rather than aliasing the heading to mortie.MortonIndexArray, and state the canonical import in an admonition at the top of the page. An aliased heading would be a hand-maintained path that mkdocstrings cannot verify — the top-level names go through __getattr__ too, so griffe cannot see them there either. All three paths shown on the page resolve. One-line switch if you disagree.

(3) mortie/pandas.py is 1002 lines, marginally over the ~1000 guideline — raising it per the convention rather than acting on it. It is a pure move (953 lines are the two class bodies verbatim), and the pair nets out better than before: morton_index.py goes 1319 → 343. Splitting would break the verbatim property that makes this diff cheap to review.

Leaving the waiting label on: phase (9) is deferred behind #136 and #130 as before, and questions (2) and (3) above want a decision.


Generated by Claude Code

@espg
espg marked this pull request as ready for review July 25, 2026 23:45
@espg
espg merged commit 3f306a8 into main Jul 25, 2026
23 checks passed
@espg
espg deleted the claude/135-numpydoc-sweep branch July 25, 2026 23:46

@espg espg left a comment

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)

Adversarial review of 8032cd0 only (the ExtensionArray move); the earlier docstring-conversion commits on this PR were out of scope.

Verdict: the two things the maintainer asked to be checked by name both come back clean. No docstring content was lost, and no import path broke. Seven findings below, none of them blocking — the highest-severity one is a false claim in a user-facing error string.

Verified correct (independently, not taken on trust)

(1) The move really is verbatim — zero lost docstrings, caveats or comments. I did not diff by eye; I parsed both files and compared raw source. For each class I took ast.get_source_segment(parent, node, padded=True), textwrap.dedent-ed it, and compared it to the new source segment as a string — so comments, blank lines and every docstring character are in scope, not just the AST:

===== MortonIndexDtype: raw-source (dedented) identical? True
===== MortonIndexArray: raw-source (dedented) identical? True

I then walked every line of _build_classes that is outside the two class bodies, to catch anything dropped in the scaffolding — lines 297-324 and 1278-1280 of d1662c2:mortie/morton_index.py — and it contains no comments, only the builder docstring and the cache plumbing, both intentionally removed. Runtime cross-check: 47 members with docstrings on MortonIndexArray on both the old and the new commit. Given the earlier normalize ring-winding loss on this PR, I went looking hard; there is nothing this time.

(2) Every import path resolves, and they are all the same object. Fresh interpreter per case, 13 cases:

  • mortie.MortonIndexArray / mortie.MortonIndexDtype
  • mortie.morton_index.MortonIndexArray ✅ (the load-bearing zagg path)
  • import mortie.morton_index standalone, then the attribute ✅
  • from mortie.morton_index import MortonIndexArray, MortonIndexDtype
  • import mortie.pandas / from mortie.pandas import ...
  • mortie.pandas.MortonIndexArray, from mortie import pandas
  • identity: array same: True, dtype same: True across all three paths, for both classes
  • mortie/arrow.py's in-function from .morton_index import MortonIndexDtype / MortonIndexArray ✅ (PEP 562 __getattr__ serves from X import Y); whole suite green: 775 passed, 12 skipped

pd.Series(dtype="morton_index") resolves from all three entry orders — after import mortie, after import mortie.morton_index alone, and after import mortie.pandas alone.

(3) numpy-only import, proved with a real sys.meta_path blocker in a subprocess (not a sys.modules check). import mortie, import mortie.morton_index and import mortie.arrow all succeed with pandas genuinely unimportable, and all six touch-points raise the identical curated ImportError.

(4) Circular import: no order deadlocks or partially-initialises. import mortie.pandas first in a totally fresh interpreter works — mortie/__init__morton_index runs to its last lines → mortie.pandas does from .morton_index import ... against a module whose four needed names (MAX_ORDER, MortonIndexScalar, _require_pandas, decimals_to_words) are all bound well above the eager block. It is a genuine cycle that works by definition ordering, but every order I tried resolves.

(5) No shadowing of the real pandas, anywhere. mortie.pandas is not pandas; sys.modules['pandas'] still points at site-packages; mortie.pandas.pd is pandas; and from mortie import * does not bind pandas (it does bind morton_index and MortonIndexArray) — the __all__ omission works as documented.

(6) mortie/pandas.py ships in the wheel. I did not reason about maturin's file discovery, I built one: maturin build --releasemortie-0.9.1-cp310-abi3-manylinux_2_34_x86_64.whl, and mortie/pandas.py is in the archive alongside the other modules. No silent packaging regression.

(7) Docs build clean and render what they claim. mkdocs build --strict exits 0 with no WARNING lines; strict: true is already set in mkdocs.yml so mike's plain build is covered too. The new page renders both classes with all members (51 hits for from_latlon/coarsen/hive_path in the output HTML), api/morton_index/ still renders MortonIndexScalar, and the deep link #optional-dependencies-numpy-stays-the-only-runtime-dep matches a real id= in the built page. The three import paths advertised on docs/api/pandas.md all work as claimed.

(8) from mortie import * fails on a numpy-only install — but it already did. Worth stating explicitly since it looks alarming: __all__ names the two class names, star-import getattrs them, and that raises. Identical on the parent commit, so not a regression of this commit and I did not file it.

(9) Tests are capable of failing. Nothing tautological or mocked; test_classes_are_in_module_vars and the qualname assertions would all have failed on d1662c2, and every subprocess test asserts returncode == 0 with stderr in the assertion message. One small caveat: neither _run_without_pandas nor test_dtype_string_registers_in_a_fresh_interpreter passes cwd=, so they import whatever mortie the pytest process' cwd resolves — same as the prior art they cite, and the fresh-interpreter test would fail loudly rather than silently if it picked up an installed copy, so I left it as a note rather than a finding.

Findings (all non-blocking)

  1. Mediummortie/morton_index.py:289: the reworded ImportError claims pip install mortie[pandas] "pins pandas … so it is loaded whenever mortie is imported". The extra is pandas>=2.0 (not a pin), and eager loading is identical for plain pip install pandas — the clause invents a difference between the two options it offers. Now quoted verbatim in the docs and asserted in three tests, so it takes a coordinated edit to fix.
  2. Medium-lowmortie/morton_index.py:342: the eager except ImportError: pass now covers a whole module's import block. Demonstrated: a bug in mortie/pandas.pyimport mortie succeeds, dtype silently unregistered, user sees TypeError: data type 'morton_index' not understood. Shape unchanged from the parent, so not a regression; three-line else: narrowing suggested.
  3. Low-mediummortie/__init__.py:162: the import_module reasoning is correct (I reproduced the RecursionError with the from . import pandas variant), but raising ImportError from __getattr__ means hasattr(mortie, "pandas") went from False to raising. Defensible; just unstated and untested.
  4. Lowdocs/api/morton_index.md:4 and mortie/morton_index.py:18 both say the module does not import pandas. It does, eagerly, at the bottom.
  5. Lowmortie/pandas.py is 1002 lines, just past the CLAUDE.md §4 threshold. Raising it as the rule requires; "accept as-is" looks like the right answer.
  6. Low — pickling now works where it previously raised AttributeError: Can't pickle local object. A real fix, untested and unmentioned in the PR body.
  7. Nits — two new flake8 style-pass warnings: E302 at mortie/pandas.py:116 (artifact of the verbatim move) and F811 at mortie/morton_index.py:327 (pyflakes scope false positive; ruff is clean).

Nothing here blocks the next phase.


Generated by Claude Code

Comment thread mortie/morton_index.py
try:
import pandas as pd
except ImportError as exc: # pragma: no cover - exercised via message only
raise ImportError(

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)

Medium — the reworded message makes a claim that is not true.

"the morton_index ExtensionArray requires pandas, which is not "
"installed. Install it directly with `pip install pandas`, or "
"declare it as a mortie extra with `pip install mortie[pandas]`, "
"which pins pandas as a mortie dependency so it is loaded whenever "
"mortie is imported."

Two problems with the trailing clause:

  1. "pins pandas" — the extra is pandas = ["pandas>=2.0"] (pyproject.toml, [project.optional-dependencies]). That is a lower bound, not a pin, and an extra recorded at install time is not re-enforced afterwards.
  2. "so it is loaded whenever mortie is imported" — this is offered as the thing that distinguishes option (2) from option (1), and it does not distinguish them. The eager probe at the bottom of this file (import pandas as _pd / from . import pandas) fires whenever pandas is importable, however it got installed. I ran both branches; import mortie loads pandas identically after a plain pip install pandas:
import mortie cost: is pandas loaded?
   OLD d1662c2: pandas in sys.modules after import mortie: True
   NEW 8032cd0: pandas in sys.modules after import mortie: True

There is a third oddity: eager loading of pandas is a cost of the current design, not a benefit of using the extra, so recommending an option because it causes eager loading reads backwards.

This matters more than usual because the wording is now load-bearing in three places that all have to move together: docs/morton_index_datatype.md:34-37 quotes it verbatim, and mortie/tests/test_pandas_module.py asserts on the substring "pins pandas as a mortie dependency" at lines 190, 213 and 242.

Suggested replacement — accurate, and shorter than the old one was long:

"the morton_index ExtensionArray requires pandas; install it with "
"`pip install mortie[pandas]` (or `pip install pandas`)"

i.e. keep the pre-existing wording. If the goal was specifically to spell out that both commands work, "install it with pip install pandas, or via the extra: pip install mortie[pandas]" says that without asserting anything false.


Generated by Claude Code

Comment thread mortie/morton_index.py

_build_classes()
from . import pandas as _pandas_ext # noqa: F401
except ImportError:

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)

Medium-low — this except ImportError: pass now covers a whole module's import block, not just _build_classes().

try:
    import pandas as _pd  # noqa: F401

    from . import pandas as _pandas_ext  # noqa: F401
except ImportError:
    pass

The shape is unchanged from d1662c2 (which wrapped _build_classes() the same way), so this is not a regression — but the covered surface grew: mortie/pandas.py now has its own top-level import block (from . import _rustie, from .morton_index import (...)), and any future ImportError from any of it lands in this pass.

I demonstrated the failure mode on a throwaway copy of the package, by injecting one bad top-level import into mortie/pandas.py:

import mortie (bug present):        import mortie SUCCEEDED, bug swallowed
pd.Series(dtype='morton_index'):    TypeError: data type 'morton_index' not understood
mortie.MortonIndexArray:            ImportError: cannot import name 'a_name_that_does_not_exist' ...

So a real bug in mortie/pandas.py presents to the user as pandas' opaque TypeError on the dtype string, and the true cause only surfaces if they happen to touch the class attribute.

The narrowing is three lines — keep the try/except around the probe, put the real import in the else:

try:
    import pandas as _pd  # noqa: F401
except ImportError:
    pass
else:
    from . import pandas as _pandas_ext  # noqa: F401

Same numpy-only behaviour (verified: _require_pandas is what raises there, and it can only raise if import pandas already failed), but a genuine defect in the new module fails loudly at import mortie instead of silently unregistering the dtype.


Generated by Claude Code

Comment thread mortie/__init__.py
# where `mortie.pandas` must raise the curated ImportError rather than a
# bare AttributeError. `import_module`, not `from . import pandas`: the
# latter does a `hasattr` on this package first, re-entering __getattr__.
return import_module(f"{__name__}.pandas")

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-medium — the import_module reasoning is right (I reproduced it), but this branch changes hasattr semantics and nothing pins that.

First, the part that checks out. The comment's claim that from . import pandas here would re-enter __getattr__ is correct — I built a two-file throwaway package with exactly that variant and ran it:

RESULT: RecursionError -- the comment is right

(_handle_fromlist does hasattr(module, 'pandas') before importing the submodule, which calls this __getattr__ again.) import_module is the right fix, and I also confirmed the "reached only on a numpy-only install" claim: with pandas present, 'pandas' in dir(mortie) is True, i.e. the submodule import at morton_index.py:341 binds the attribute directly and this branch never runs.

The finding is the side effect. Raising ImportError (not AttributeError) from a module __getattr__ breaks the two idioms that are defined to swallow only AttributeError:

hasattr(mortie,'pandas') on a numpy-only install
   OLD d1662c2: hasattr -> False
   NEW 8032cd0: RAISED ImportError

getattr(mortie, "pandas", None) blows up the same way rather than returning the default. Since mortie.pandas is a brand-new name this breaks no existing caller, and matching what import mortie.pandas does is a defensible choice — but it is a real behaviour difference in a commit described as pure placement, and mortie.pandas is exactly the name someone would feature-probe.

Two things to do, both cheap:

  1. Say in the comment that this deliberately trades hasattr for the curated error, so the next reader does not "fix" it back to AttributeError.
  2. Pin whichever semantics you want with a test in TestPandasAbsent — right now neither is covered, so a future refactor can flip it silently.

Generated by Claude Code

Comment thread docs/api/morton_index.md
analysis — it is documented narratively in
[Morton index datatype](../morton_index_datatype.md) instead.
The numpy-only surface: the packed-word scalar and the decimal parse functions.
Nothing here imports pandas.

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 — "Nothing here imports pandas" is literally false, and the same claim is in the module docstring.

mortie/morton_index.py ends with:

try:
    import pandas as _pd  # noqa: F401

    from . import pandas as _pandas_ext  # noqa: F401
except ImportError:
    pass

so on any install that has pandas, importing mortie.morton_index imports pandas — eagerly, at module scope. mortie/morton_index.py:18 says the same thing ("Nothing in *this* module touches pandas"), and _require_pandas is still defined in this module too.

What is actually true is the weaker, still-useful claim: nothing here requires pandas, and no pandas symbol is used at module level, so the module imports fine in a numpy-only environment. Suggest wording both spots that way — e.g. "This module imports cleanly with only numpy installed; it touches pandas only through the optional registration probe at the bottom." As written a reader could reasonably conclude import mortie.morton_index avoids the pandas import cost, and it does not.


Generated by Claude Code

assert mp.MortonIndexArray.__qualname__ == "MortonIndexArray"
assert mp.MortonIndexDtype.__qualname__ == "MortonIndexDtype"
assert mp.MortonIndexArray.__module__ == "mortie.pandas"
assert mp.MortonIndexDtype.__module__ == "mortie.pandas"

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 most user-visible behaviour change in this commit is untested and unmentioned: MortonIndexArray is now picklable.

The old __qualname__ was _build_classes.<locals>.MortonIndexArray, and pickle cannot save a class by reference from a function local. I ran the same script against d1662c2 and 8032cd0:

pickle array
   OLD d1662c2: pickle RAISED AttributeError Can't pickle local object '_build_classes.<locals>.MortonIndexArray'
   NEW 8032cd0: pickle OK
pickle Series
   OLD d1662c2: series pickle RAISED AttributeError Can't pickle local object '_build_classes.<locals>.MortonIndexArray'
   NEW 8032cd0: series pickle OK

That is a genuine fix — a pd.Series of morton_index could not survive to_pickle, joblib, multiprocessing or dask's serializer before, and now it can. But it means the PR's "pure placement refactor, zero behaviour change" framing is not quite right, and the property falls straight out of __qualname__/__module__, so it can silently regress if anyone ever moves the classes again.

This test class already asserts the qualname; two more lines next to it would lock in the thing the qualname is for:

def test_array_round_trips_through_pickle(self):
    import pickle
    arr = mortie.MortonIndexArray.from_words(np.array([1, 2], dtype=np.uint64))
    assert pickle.loads(pickle.dumps(arr)).tolist() == arr.tolist()

Worth a line in the PR body too — it is the one thing a downstream user might notice.


Generated by Claude Code

Comment thread mortie/pandas.py
@@ -0,0 +1,1002 @@
"""mortie's pandas extension: the ``morton_index`` ExtensionArray.

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 — needs a maintainer decision: this file lands at 1002 lines, just over the CLAUDE.md §4 threshold.

$ wc -l mortie/pandas.py mortie/morton_index.py
 1002 mortie/pandas.py
  343 mortie/morton_index.py

§4 says "A module should not exceed ~1000 lines without prior discussion. If a file is heading past that, stop and raise it (issue comment) before splitting it or continuing." Raising it, as the rule asks — not proposing a split. Two of the three plausible answers need no work:

  1. Accept as-is. The file is one cohesive dtype/array pair and it shrank the repo's largest module from 1319 to 343; ~1002 is within "~1000" by any reasonable reading. Most likely the right call.
  2. Trim rather than split. ~60% of the file is docstrings, which is the point of the PR, so there is nothing to trim without losing what issue Repo-wide numpydoc conversion sweep for docstrings #135 is trying to publish.
  3. Split (e.g. dtype vs array, or the hive-path/arrow interop helpers into their own module) — this would fragment what mkdocstrings renders as one page, so it seems like a net loss.

Flagging so the threshold is crossed deliberately and on the record rather than by accident.


Generated by Claude Code

Comment thread mortie/pandas.py
return MortonIndexArray._concat_same_type(parts)
return to_morton_index(array)

class MortonIndexArray(ExtensionArray):

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)

Nit — one new flake8 warning, an artifact of the verbatim move.

Inside _build_classes a single blank line between the two classes was correct; at module level PEP 8 wants two:

$ .venv/bin/python -m flake8 mortie --max-line-length=88
mortie/pandas.py:116:1: E302 expected 2 blank lines, found 1

This is CI's non-blocking style pass, and ruff check mortie/pandas.py is clean (ruff's E3 whitespace rules are preview-only, so it does not see this). One blank line to add. Mentioning it mainly because it is the only new warning in this file and it is trivially removable — otherwise it will sit in the style-pass output indefinitely.


Generated by Claude Code

Comment thread mortie/morton_index.py
if name in ("MortonIndexDtype", "MortonIndexArray"):
dtype_cls, array_cls = _build_classes()
return dtype_cls if name == "MortonIndexDtype" else array_cls
from . import pandas as _pandas_ext

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)

Nit — new flake8 F811, caused by reusing the alias _pandas_ext in two scopes.

$ .venv/bin/python -m flake8 mortie --max-line-length=88
mortie/morton_index.py:327:9: F811 redefinition of unused '_pandas_ext' from line 341

pyflakes is wrong on the merits (line 327 is a function local, line 341 is module scope — ruff check correctly does not flag it), but it is a new line in the non-blocking style pass and it costs one character to remove: rename the local, e.g.

        from . import pandas as _ext

        return getattr(_ext, name)

Fix-or-reply either way per §2 if the ruff bot echoes it.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants