From 2b495d9439b9da41331df37d4be7fcce8fe0fbaa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 00:49:15 +0000 Subject: [PATCH 1/7] phase 1 of issue #104 --- mortie/morton_index.py | 46 ++++++++++++++++++++----- mortie/tests/test_morton_index.py | 56 +++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/mortie/morton_index.py b/mortie/morton_index.py index 771bb35..b331765 100644 --- a/mortie/morton_index.py +++ b/mortie/morton_index.py @@ -24,12 +24,45 @@ # ``MortonIndexDtype`` / ``MortonIndexArray`` are provided via module-level # ``__getattr__`` (built lazily so a numpy-only install can import this module), # so they are intentionally not named in ``__all__`` here. -__all__ = [] +__all__ = ["MortonIndexScalar"] # HEALPix orders this datatype reaches (0 = base cell, 29 = max resolution). MAX_ORDER = 29 +class MortonIndexScalar(np.uint64): + """A packed ``morton_index`` word that displays as its decimal string. + + Element access and iteration on a ``MortonIndexArray`` yield this type + (issue #104), so a downstream ``f"{shard_key}"`` prints the decimal Morton + id (``-31123`` style) rather than the raw packed word. It subclasses + ``numpy.uint64``: comparisons, hashing, and ``int()`` (the packed word) + behave exactly like the word itself; only ``str``/``repr`` differ. The + empty sentinel renders ``""``; a word with an invalid prefix renders + ``""`` rather than raising (a repr must never raise). + """ + + def __str__(self): + word = int(self) + if word == 0: + return "" + try: + return _rustie.rust_mi_decimal_repr( + np.asarray([word], dtype=np.uint64) + )[0] + except ValueError: + return f"" + + __repr__ = __str__ + + def __format__(self, spec): + # numpy's numeric __format__ would print the packed word; the display + # form of a morton_index is its decimal string, so f"{shard_key}" (and + # any string spec, e.g. ">10") formats that instead. int(self) remains + # the escape hatch to format the raw word numerically. + return format(str(self), spec) + + def _require_pandas(): """Import pandas lazily, raising a clear error if it is absent. @@ -272,7 +305,7 @@ def __len__(self): def __getitem__(self, item): result = self._data[item] if np.isscalar(result) or isinstance(result, np.integer): - return np.uint64(result) + return MortonIndexScalar(result) return type(self)(result) def __setitem__(self, key, value): @@ -435,13 +468,8 @@ def decimal_repr(self): # -- repr ------------------------------------------------------------ def _word_repr(self, word): - """Compact ``base/order`` label for one packed word.""" - w = np.asarray([word], dtype=np.uint64) - if word == int(self._SENTINEL): - return "" - base = int(_rustie.rust_mi_base_cell_of(w)[0]) - order = int(_rustie.rust_mi_order_of(w)[0]) - return f"base={base} order={order}" + """Decimal-string label for one packed word (issue #104).""" + return str(MortonIndexScalar(word)) def __repr__(self): n = len(self) diff --git a/mortie/tests/test_morton_index.py b/mortie/tests/test_morton_index.py index fc187bb..7fc325c 100644 --- a/mortie/tests/test_morton_index.py +++ b/mortie/tests/test_morton_index.py @@ -371,6 +371,62 @@ def test_concat_and_getitem(self): assert isinstance(c[:2], MIA) +# --------------------------------------------------------------------------- +# decimal-string display layer (issue #104) +# --------------------------------------------------------------------------- + + +class TestDecimalDisplay: + """Element display is the decode-through-kernel decimal string.""" + + def test_element_repr_is_decimal_string(self): + a = MIA.from_legacy(np.array([-31123, 41123], dtype=np.int64)) + assert a._word_repr(int(a._data[0])) == "-31123" + assert a._word_repr(int(a._data[1])) == "41123" + + def test_array_repr_decimal_elements_with_summary_header(self): + a = MIA.from_legacy(np.array([-31123, 41123], dtype=np.int64)) + r = repr(a) + assert "-31123" in r and "41123" in r + assert "len=2" in r and "order=4" in r + assert "base=" not in r # the old per-element label is gone + + def test_scalar_wrapper_str_repr_int(self): + from mortie.morton_index import MortonIndexScalar + + a = MIA.from_legacy(np.array([-31123], dtype=np.int64)) + s = a[0] + assert isinstance(s, MortonIndexScalar) + assert isinstance(s, np.uint64) # still a word for compute paths + assert str(s) == "-31123" + assert repr(s) == "-31123" + assert f"{s}" == "-31123" + assert int(s) == int(a._data[0]) + assert s == a._data[0] # comparisons stay word-valued + + def test_scalar_wrapper_na_and_invalid_never_raise(self): + from mortie.morton_index import MortonIndexScalar + + assert str(MortonIndexScalar(0)) == "" + # prefix 15 is outside the valid 1..=12 range; repr must not raise + assert str(MortonIndexScalar(0xF000000000000000)).startswith(" Date: Fri, 10 Jul 2026 00:51:04 +0000 Subject: [PATCH 2/7] phase 2 of issue #104 --- mortie/morton_index.py | 39 ++++++++++++++++++++++- mortie/tests/test_morton_index.py | 52 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/mortie/morton_index.py b/mortie/morton_index.py index b331765..be8b687 100644 --- a/mortie/morton_index.py +++ b/mortie/morton_index.py @@ -461,10 +461,47 @@ def decimal_repr(self): produced by *decoding* each word (the canonical render-only repr; backward-compatible with the legacy ``str(legacy_i64)`` for orders 0..=18, the natural extension for 19..=29). Raises ``ValueError`` on - any empty / invalid word. + any empty / invalid word. Note the repr is not injective across + kinds: an order-29 *point* renders identically to the order-29 + *area* on the same path (the point/area flag is not part of the + decimal form). """ return _rustie.rust_mi_decimal_repr(self._data) + def to_decimal(self): + """Vectorized decimal-string emit as a fixed-width numpy array. + + The always-strings interchange convention from issue #48: returns + the decode-through-kernel decimal strings as a ``" 18: + raise ValueError( + f"to_legacy_i64 is capped at order 18 (array holds order " + f"{int(orders.max())}); use to_decimal() for orders 19-29" + ) + return np.asarray([int(s) for s in strings], dtype=np.int64) + # -- repr ------------------------------------------------------------ def _word_repr(self, word): diff --git a/mortie/tests/test_morton_index.py b/mortie/tests/test_morton_index.py index 7fc325c..878b8c4 100644 --- a/mortie/tests/test_morton_index.py +++ b/mortie/tests/test_morton_index.py @@ -416,6 +416,58 @@ def test_series_repr_prints_decimal(self): text = repr(pd.Series(a)) assert "-31123" in text and "41123" in text + def test_to_decimal_fixed_width(self): + a = MIA.from_legacy(np.array([-31123, 41123], dtype=np.int64)) + out = a.to_decimal() + assert out.dtype == np.dtype(" Date: Fri, 10 Jul 2026 00:52:46 +0000 Subject: [PATCH 3/7] phase 3 of issue #104 --- mortie/morton_index.py | 91 +++++++++++++++++++++++++++++++ mortie/tests/test_morton_index.py | 68 +++++++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/mortie/morton_index.py b/mortie/morton_index.py index be8b687..5a1d4f4 100644 --- a/mortie/morton_index.py +++ b/mortie/morton_index.py @@ -30,6 +30,40 @@ MAX_ORDER = 29 +def _decimal_to_word(s): + """Parse a decimal Morton string back to its packed word (issue #104). + + The inverse of the decode-through-kernel repr: sign + leading base digit + (``1..6``) then one ``1..4`` digit per order. Raises ``ValueError`` on any + malformed id. Order-29 strings parse to the *area* word -- the point/area + flag is not part of the decimal form, so the point word is unreachable + from its repr (the documented non-injectivity). + """ + body = s[1:] if s.startswith("-") else s + if not (body.isdigit() and body.isascii()): + raise ValueError(f"malformed decimal Morton id {s!r}") + lead, digits = int(body[0]), body[1:] + if not 1 <= lead <= 6: + raise ValueError( + f"decimal Morton id {s!r}: base digit {lead} outside 1..6" + ) + order = len(digits) + if order > MAX_ORDER: + raise ValueError( + f"decimal Morton id {s!r}: order {order} exceeds {MAX_ORDER}" + ) + within = 0 + for d in digits: + if not "1" <= d <= "4": + raise ValueError( + f"decimal Morton id {s!r}: digit {d} outside 1..4" + ) + within = (within << 2) | (int(d) - 1) + base = lead + 5 if s.startswith("-") else lead - 1 + nested = np.asarray([(base << (2 * order)) | within], dtype=np.uint64) + return int(_rustie.rust_mi_from_nested(nested, order)[0]) + + class MortonIndexScalar(np.uint64): """A packed ``morton_index`` word that displays as its decimal string. @@ -502,6 +536,63 @@ def to_legacy_i64(self): ) return np.asarray([int(s) for s in strings], dtype=np.int64) + def hive_path(self, root="", suffix=".zarr"): + """Hive-layout path per element (issue #104; spec lands on #62). + + The ``morton-hive/1`` convention (zagg's sparse-coverage design + record): one decimal digit per directory level, the full id as the + leaf inside its own node -- + ``{root}/{sign+base}/{d1}/.../{d_order}/{full_id}{suffix}``, e.g. + ``-31123`` -> ``-3/1/1/2/3/-31123.zarr`` and the order-0 ``-3`` -> + ``-3/-3.zarr``. Every order is a node, so mixed shard orders nest + naturally: a coarser shard's leaf sits in the same directory its + finer siblings descend through. Returns a list of ``str``; raises + ``ValueError`` on any empty / invalid word. + """ + prefix = root.rstrip("/") + "/" if root else "" + paths = [] + for s in self.decimal_repr(): + head = 2 if s.startswith("-") else 1 + levels = "/".join([s[:head], *s[head:]]) + paths.append(f"{prefix}{levels}/{s}{suffix}") + return paths + + @classmethod + def from_hive_path(cls, paths, suffix=".zarr"): + """Parse hive-layout paths back to words (inverse of hive_path). + + ``paths`` is a single path (``str``) or an iterable of them. The + leaf basename carries the full decimal id; when the path also + carries the digit directories, they are checked against the leaf + and a mis-filed leaf raises ``ValueError`` (a shorter path -- a + bare ``{full_id}.zarr`` or one under an arbitrary root -- skips + the check for the components it does not have). Order-29 ids + parse to the *area* word (see :func:`_decimal_to_word`). + """ + if isinstance(paths, str): + paths = [paths] + words = [] + for p in paths: + parts = str(p).rstrip("/").split("/") + leaf = parts[-1] + if suffix and not leaf.endswith(suffix): + raise ValueError( + f"hive leaf {leaf!r} does not end with {suffix!r}" + ) + dec = leaf[: len(leaf) - len(suffix)] if suffix else leaf + word = _decimal_to_word(dec) + head = 2 if dec.startswith("-") else 1 + levels = [dec[:head], *dec[head:]] + if len(parts) - 1 >= len(levels): + got = parts[-1 - len(levels):-1] + if got != levels: + raise ValueError( + f"hive path {p!r} directories {'/'.join(got)!r} " + f"do not match leaf id {dec!r}" + ) + words.append(word) + return cls(np.asarray(words, dtype=np.uint64)) + # -- repr ------------------------------------------------------------ def _word_repr(self, word): diff --git a/mortie/tests/test_morton_index.py b/mortie/tests/test_morton_index.py index 878b8c4..c5cd6a5 100644 --- a/mortie/tests/test_morton_index.py +++ b/mortie/tests/test_morton_index.py @@ -479,6 +479,74 @@ def test_display_matches_kernel_across_orders(self): assert str(a[0]) == expect +class TestHivePath: + """hive_path / from_hive_path round-trips (issue #104; spec on #62).""" + + def test_layout_one_digit_per_level_full_id_leaf(self): + a = MIA.from_legacy(np.array([-31123, 41123], dtype=np.int64)) + assert a.hive_path() == [ + "-3/1/1/2/3/-31123.zarr", + "4/1/1/2/3/41123.zarr", + ] + + def test_order_zero_leaf_sits_in_base_node(self): + a = MIA.from_legacy(np.array([-3, 4], dtype=np.int64)) + assert a.hive_path() == ["-3/-3.zarr", "4/4.zarr"] + + def test_root_and_suffix(self): + a = MIA.from_legacy(np.array([-31123], dtype=np.int64)) + assert a.hive_path(root="s3://bucket/store/") == [ + "s3://bucket/store/-3/1/1/2/3/-31123.zarr" + ] + assert a.hive_path(suffix="") == ["-3/1/1/2/3/-31123"] + + def test_mixed_orders_nest(self): + # the order-3 shard's leaf sits in the directory its order-4 + # sibling descends through + a = MIA.from_legacy(np.array([-3112, -31123], dtype=np.int64)) + coarse, fine = a.hive_path() + assert coarse == "-3/1/1/2/-3112.zarr" + assert fine.startswith("-3/1/1/2/3/") + + def test_round_trip(self): + a = MIA.from_legacy(np.array([-31123, 41123, -3], dtype=np.int64)) + back = MIA.from_hive_path(a.hive_path(root="s3://bucket/x")) + np.testing.assert_array_equal(back._data, a._data) + + def test_round_trip_high_order(self): + deep = MIA.from_nested( + np.array([11 << (2 * MAX_ORDER), 5], dtype=np.uint64), MAX_ORDER + ) + back = MIA.from_hive_path(deep.hive_path()) + np.testing.assert_array_equal(back._data, deep._data) + + def test_single_path_and_bare_leaf(self): + a = MIA.from_legacy(np.array([-31123], dtype=np.int64)) + one = MIA.from_hive_path("-3/1/1/2/3/-31123.zarr") + assert len(one) == 1 and int(one._data[0]) == int(a._data[0]) + # a bare leaf (no digit directories) parses too + bare = MIA.from_hive_path("-31123.zarr") + assert int(bare._data[0]) == int(a._data[0]) + + def test_misfiled_leaf_raises(self): + with pytest.raises(ValueError, match="do not match leaf"): + MIA.from_hive_path("-3/1/1/2/4/-31123.zarr") + + def test_malformed_ids_raise(self): + from mortie.morton_index import _decimal_to_word + + for bad in ("", "-", "0123", "7123", "31023", "3125", "x123", + "3" + "1" * 30): + with pytest.raises(ValueError): + _decimal_to_word(bad) + with pytest.raises(ValueError, match="does not end with"): + MIA.from_hive_path("-3/1/-31.parquet") + + def test_sentinel_raises(self): + with pytest.raises(ValueError): + MIA(np.array([0], dtype=np.uint64)).hive_path() + + # --------------------------------------------------------------------------- # encode / decode bindings # --------------------------------------------------------------------------- From d0ec61ad0f91841d64b2d5528b29f1d67fae5e5b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 00:54:30 +0000 Subject: [PATCH 4/7] phase 4 of issue #104 --- mortie/tests/test_packed_golden.py | 87 ++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/mortie/tests/test_packed_golden.py b/mortie/tests/test_packed_golden.py index 9a356d7..621d1cc 100644 --- a/mortie/tests/test_packed_golden.py +++ b/mortie/tests/test_packed_golden.py @@ -224,3 +224,90 @@ def test_morton_index_array_legacy_and_repr(): ) arr = MIA.from_legacy(legacy) assert arr.decimal_repr() == [str(int(x)) for x in legacy] + + +# --------------------------------------------------------------------------- +# string-layer golden pins (issue #104): the display / emit / hive-path +# surface renders the same pinned decimal strings, so it joins the frozen 1.x +# contract through the same fixture. +# --------------------------------------------------------------------------- + + +def _string_layer_array(): + import pytest + + pytest.importorskip("pandas") + from mortie import MortonIndexArray as MIA + + records = _load_fixture()["records"] + words = np.asarray([r["word"] for r in records], dtype=np.uint64) + return MIA(words), [r["decimal_repr"] for r in records], records + + +def test_golden_string_emit_layer(): + """to_decimal / _word_repr / scalar str all pin to the fixture strings.""" + arr, reprs, _ = _string_layer_array() + out = arr.to_decimal() + assert out.dtype == np.dtype(" Date: Fri, 10 Jul 2026 01:03:31 +0000 Subject: [PATCH 5/7] phase 5 of issue #104 --- mortie/morton_index.py | 38 +++++++++++++++++++++-------- mortie/tests/test_morton_index.py | 40 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/mortie/morton_index.py b/mortie/morton_index.py index 5a1d4f4..827791c 100644 --- a/mortie/morton_index.py +++ b/mortie/morton_index.py @@ -17,6 +17,8 @@ clear ``ImportError`` is raised if the ExtensionArray is touched without pandas. """ +import os + import numpy as np from . import _rustie @@ -93,9 +95,16 @@ def __format__(self, spec): # numpy's numeric __format__ would print the packed word; the display # form of a morton_index is its decimal string, so f"{shard_key}" (and # any string spec, e.g. ">10") formats that instead. int(self) remains - # the escape hatch to format the raw word numerically. + # the escape hatch to format the raw word numerically. Old-style + # "%d" % key bypasses __format__ entirely and emits the raw word. return format(str(self), spec) + def __reduce__(self): + # numpy scalars pickle through multiarray.scalar, which rebuilds the + # bare np.uint64 and would silently drop the decimal display on any + # process boundary (multiprocessing/dask); rebuild the wrapper instead. + return (type(self), (int(self),)) + def _require_pandas(): """Import pandas lazily, raising a clear error if it is absent. @@ -561,15 +570,17 @@ def hive_path(self, root="", suffix=".zarr"): def from_hive_path(cls, paths, suffix=".zarr"): """Parse hive-layout paths back to words (inverse of hive_path). - ``paths`` is a single path (``str``) or an iterable of them. The - leaf basename carries the full decimal id; when the path also - carries the digit directories, they are checked against the leaf - and a mis-filed leaf raises ``ValueError`` (a shorter path -- a - bare ``{full_id}.zarr`` or one under an arbitrary root -- skips - the check for the components it does not have). Order-29 ids - parse to the *area* word (see :func:`_decimal_to_word`). + ``paths`` is a single path (``str`` / ``os.PathLike``) or an + iterable of them. The leaf basename carries the full decimal id; + when the path also carries the digit directories -- recognized by + the ``{sign+base}`` component sitting at its slot above the leaf + -- the whole chain is checked against the leaf and a mis-filed + leaf raises ``ValueError``. A bare ``{full_id}.zarr``, or one + under an arbitrary root without the digit chain, skips the check. + Order-29 ids parse to the *area* word (see + :func:`_decimal_to_word`). """ - if isinstance(paths, str): + if isinstance(paths, (str, os.PathLike)): paths = [paths] words = [] for p in paths: @@ -583,7 +594,14 @@ def from_hive_path(cls, paths, suffix=".zarr"): word = _decimal_to_word(dec) head = 2 if dec.startswith("-") else 1 levels = [dec[:head], *dec[head:]] - if len(parts) - 1 >= len(levels): + # Enforce the directory cross-check only when the chain is + # anchored: the {sign+base} component at its expected slot. + # (Root components are indistinguishable from digit dirs by + # count alone, so an unanchored tail is treated as root.) + if ( + len(parts) - 1 >= len(levels) + and parts[-1 - len(levels)] == levels[0] + ): got = parts[-1 - len(levels):-1] if got != levels: raise ValueError( diff --git a/mortie/tests/test_morton_index.py b/mortie/tests/test_morton_index.py index c5cd6a5..fda67b7 100644 --- a/mortie/tests/test_morton_index.py +++ b/mortie/tests/test_morton_index.py @@ -404,6 +404,29 @@ def test_scalar_wrapper_str_repr_int(self): assert int(s) == int(a._data[0]) assert s == a._data[0] # comparisons stay word-valued + def test_scalar_wrapper_format_specs(self): + a = MIA.from_legacy(np.array([-31123], dtype=np.int64)) + s = a[0] + # string specs format the decimal string + assert f"{s:>10}" == " -31123" + # numeric specs raise: the display form is a string; int(s) is the + # escape hatch for formatting the raw word numerically + with pytest.raises(ValueError): + f"{s:d}" + # old-style %-formatting bypasses __format__ and emits the raw word + assert ("%d" % s) == str(int(s)) + + def test_scalar_wrapper_pickles_as_itself(self): + import pickle + + from mortie.morton_index import MortonIndexScalar + + a = MIA.from_legacy(np.array([-31123], dtype=np.int64)) + s = pickle.loads(pickle.dumps(a[0])) + assert isinstance(s, MortonIndexScalar) + assert str(s) == "-31123" + assert int(s) == int(a._data[0]) + def test_scalar_wrapper_na_and_invalid_never_raise(self): from mortie.morton_index import MortonIndexScalar @@ -528,9 +551,26 @@ def test_single_path_and_bare_leaf(self): bare = MIA.from_hive_path("-31123.zarr") assert int(bare._data[0]) == int(a._data[0]) + def test_pathlike_input(self): + from pathlib import Path + + a = MIA.from_legacy(np.array([-31123], dtype=np.int64)) + one = MIA.from_hive_path(Path("-3/1/1/2/3/-31123.zarr")) + assert int(one._data[0]) == int(a._data[0]) + + def test_bare_leaf_under_root_skips_dir_check(self): + # root components are not digit directories: the check only engages + # when the {sign+base} anchor sits at its slot above the leaf + a = MIA.from_legacy(np.array([-3], dtype=np.int64)) + for p in ("s3://bucket/-3.zarr", "x/-3.zarr", "a/b/c/-3.zarr"): + assert int(MIA.from_hive_path(p)._data[0]) == int(a._data[0]) + def test_misfiled_leaf_raises(self): with pytest.raises(ValueError, match="do not match leaf"): MIA.from_hive_path("-3/1/1/2/4/-31123.zarr") + # anchored but wrong descent raises too + with pytest.raises(ValueError, match="do not match leaf"): + MIA.from_hive_path("-3/x/1/2/3/-31123.zarr") def test_malformed_ids_raise(self): from mortie.morton_index import _decimal_to_word From ca9f0b910fc1f1458c3bfcf419435013a220397d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 01:09:56 +0000 Subject: [PATCH 6/7] phase 6 of issue #104 --- mortie/morton_index.py | 29 +++++++++++++++++------------ mortie/tests/test_morton_index.py | 12 ++++++++++++ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/mortie/morton_index.py b/mortie/morton_index.py index 827791c..ea84816 100644 --- a/mortie/morton_index.py +++ b/mortie/morton_index.py @@ -570,12 +570,13 @@ def hive_path(self, root="", suffix=".zarr"): def from_hive_path(cls, paths, suffix=".zarr"): """Parse hive-layout paths back to words (inverse of hive_path). - ``paths`` is a single path (``str`` / ``os.PathLike``) or an - iterable of them. The leaf basename carries the full decimal id; - when the path also carries the digit directories -- recognized by - the ``{sign+base}`` component sitting at its slot above the leaf - -- the whole chain is checked against the leaf and a mis-filed - leaf raises ``ValueError``. A bare ``{full_id}.zarr``, or one + ``paths`` is a single slash-separated path (``str`` / + ``os.PathLike``) or an iterable of them. The leaf basename + carries the full decimal id; when the path also carries the digit + directories -- recognized by a ``{sign+base}``-shaped component + sitting at its slot above the leaf -- the whole chain is checked + against the leaf and a mis-filed leaf (wrong base cell, wrong + descent) raises ``ValueError``. A bare ``{full_id}.zarr``, or one under an arbitrary root without the digit chain, skips the check. Order-29 ids parse to the *area* word (see :func:`_decimal_to_word`). @@ -595,13 +596,17 @@ def from_hive_path(cls, paths, suffix=".zarr"): head = 2 if dec.startswith("-") else 1 levels = [dec[:head], *dec[head:]] # Enforce the directory cross-check only when the chain is - # anchored: the {sign+base} component at its expected slot. - # (Root components are indistinguishable from digit dirs by - # count alone, so an unanchored tail is treated as root.) - if ( + # anchored: a {sign+base}-shaped component (optional "-" plus + # one digit 1..6) at its expected slot. Anchoring on shape -- + # not equality with the leaf's own sign+base -- keeps a + # mis-filed wrong-base leaf detectable while still treating + # an arbitrary root as skippable (root components are + # indistinguishable from digit dirs by count alone). + anchor = parts[-1 - len(levels)] if ( len(parts) - 1 >= len(levels) - and parts[-1 - len(levels)] == levels[0] - ): + ) else "" + body = anchor[1:] if anchor.startswith("-") else anchor + if len(body) == 1 and "1" <= body <= "6": got = parts[-1 - len(levels):-1] if got != levels: raise ValueError( diff --git a/mortie/tests/test_morton_index.py b/mortie/tests/test_morton_index.py index fda67b7..c21d60f 100644 --- a/mortie/tests/test_morton_index.py +++ b/mortie/tests/test_morton_index.py @@ -571,6 +571,18 @@ def test_misfiled_leaf_raises(self): # anchored but wrong descent raises too with pytest.raises(ValueError, match="do not match leaf"): MIA.from_hive_path("-3/x/1/2/3/-31123.zarr") + # a wrong *base* directory is still base-shaped, so it anchors the + # check and raises rather than being mistaken for a root component + with pytest.raises(ValueError, match="do not match leaf"): + MIA.from_hive_path("-4/1/1/2/3/-31123.zarr") + with pytest.raises(ValueError, match="do not match leaf"): + MIA.from_hive_path("2/1/1/2/3/41123.zarr") + + def test_root_ending_in_digit_chain_still_parses(self): + # the anchor slot holding the id's own sign+base under a real root + a = MIA.from_legacy(np.array([-31123], dtype=np.int64)) + p = "data/2023/-3/1/1/2/3/-31123.zarr" + assert int(MIA.from_hive_path(p)._data[0]) == int(a._data[0]) def test_malformed_ids_raise(self): from mortie.morton_index import _decimal_to_word From 41b3bac8286cb475d26100a3d2eea3e56779e1e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 01:11:15 +0000 Subject: [PATCH 7/7] phase 7 of issue #104 --- mortie/morton_index.py | 9 ++++++++- mortie/tests/test_morton_index.py | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/mortie/morton_index.py b/mortie/morton_index.py index ea84816..1686efa 100644 --- a/mortie/morton_index.py +++ b/mortie/morton_index.py @@ -581,11 +581,18 @@ def from_hive_path(cls, paths, suffix=".zarr"): Order-29 ids parse to the *area* word (see :func:`_decimal_to_word`). """ + import pathlib + if isinstance(paths, (str, os.PathLike)): paths = [paths] words = [] for p in paths: - parts = str(p).rstrip("/").split("/") + if isinstance(p, pathlib.PurePath): + # honor the path's own flavor: a WindowsPath splits on + # backslashes too, which a raw "/"-split would not + parts = list(p.parts) + else: + parts = str(p).rstrip("/").split("/") leaf = parts[-1] if suffix and not leaf.endswith(suffix): raise ValueError( diff --git a/mortie/tests/test_morton_index.py b/mortie/tests/test_morton_index.py index c21d60f..929bdff 100644 --- a/mortie/tests/test_morton_index.py +++ b/mortie/tests/test_morton_index.py @@ -552,11 +552,14 @@ def test_single_path_and_bare_leaf(self): assert int(bare._data[0]) == int(a._data[0]) def test_pathlike_input(self): - from pathlib import Path + from pathlib import Path, PureWindowsPath a = MIA.from_legacy(np.array([-31123], dtype=np.int64)) one = MIA.from_hive_path(Path("-3/1/1/2/3/-31123.zarr")) assert int(one._data[0]) == int(a._data[0]) + # a Windows-flavored path splits on its own separators + win = MIA.from_hive_path(PureWindowsPath(r"-3\1\1\2\3\-31123.zarr")) + assert int(win._data[0]) == int(a._data[0]) def test_bare_leaf_under_root_skips_dir_check(self): # root components are not digit directories: the check only engages