py_hamt

 1from .encryption_hamt_store import SimpleEncryptedZarrHAMTStore
 2from .hamt import HAMT, blake3_hashfn
 3from .hamt_to_sharded_converter import convert_hamt_to_sharded, sharded_converter_cli
 4from .sharded_zarr_store import (
 5    ShardedZarrStore,
 6    ShardedZarrV1DeprecationWarning,
 7    ShardReadMode,
 8)
 9from .store_httpx import (
10    ContentAddressedStore,
11    GatewayContentMismatch,
12    GatewayContentUnverifiable,
13    InMemoryCAS,
14    KuboCAS,
15)
16from .zarr_hamt_store import ZarrHAMTStore
17
18__all__ = [
19    "blake3_hashfn",
20    "HAMT",
21    "ContentAddressedStore",
22    "GatewayContentMismatch",
23    "GatewayContentUnverifiable",
24    "InMemoryCAS",
25    "KuboCAS",
26    "ZarrHAMTStore",
27    "SimpleEncryptedZarrHAMTStore",
28    "ShardedZarrStore",
29    "ShardReadMode",
30    "ShardedZarrV1DeprecationWarning",
31    "convert_hamt_to_sharded",
32    "sharded_converter_cli",
33]
def blake3_hashfn(input_bytes: bytes) -> bytes:
54def blake3_hashfn(input_bytes: bytes) -> bytes:
55    """Return the HAMT's default raw 32-byte BLAKE3 digest."""
56    return blake3(input_bytes).digest(length=32)

Return the HAMT's default raw 32-byte BLAKE3 digest.

class HAMT:
361class HAMT:
362    """
363    An implementation of a Hash Array Mapped Trie for an arbitrary Content Addressed Storage (CAS) system, e.g. IPFS. This uses the IPLD data model.
364
365    Use this to store arbitrarily large key-value mappings in your CAS of choice.
366
367    For writing, this HAMT is async safe but NOT thread safe. Only write in an async event loop within the same thread.
368
369    When in read-only mode, the HAMT is both async and thread safe.
370
371    #### A note about memory management, read+write and read-only modes
372    The HAMT can be in either read+write mode or read-only mode. For either of these modes, the HAMT has some internal performance optimizations.
373
374    Note that in read+write, the real root node id IS NOT VALID. You should call `make_read_only()` to convert to read only mode and then read `root_node_id`.
375
376    These optimizations also trade off performance for memory use. Use `cache_size` to monitor the approximate memory usage. Be warned that for large key-value mapping sets this may take a bit to run. Use `cache_vacate` if you are over your memory limits.
377
378    #### IPFS HAMT Sample Code
379    ```python
380    kubo_cas = KuboCAS() # connects to a local kubo node with the default endpoints
381    hamt = await HAMT.build(cas=kubo_cas)
382    await hamt.set("foo", "bar")
383    assert (await hamt.get("foo")) == "bar"
384    await hamt.make_read_only()
385    cid = hamt.root_node_id # our root node CID
386    print(cid)
387    ```
388    """
389
390    def __init__(
391        self,
392        cas: ContentAddressedStore,
393        hash_fn: Callable[[bytes], bytes] = blake3_hashfn,
394        root_node_id: IPLDKind | None = None,
395        read_only: bool = False,
396        max_bucket_size: int = 4,
397        values_are_bytes: bool = False,
398    ):
399        """
400        Use `build` if you need to create a completely empty HAMT, as this requires some async operations with the CAS. For what each of the constructor input variables refer to, check the documentation with the matching names below.
401        """
402
403        self.cas: ContentAddressedStore = cas
404        """The backing storage system. py-hamt provides an implementation `KuboCAS` for IPFS."""
405
406        self.hash_fn: Callable[[bytes], bytes] = hash_fn
407        """
408        This is the hash function used to place a key-value within the HAMT.
409
410        To provide your own hash function, create a function that takes in arbitrarily long bytes and returns the hash bytes.
411
412        It's important to note that the resulting hash must must always be a multiple of 8 bits since python bytes object can only represent in segments of bytes, and thus 8 bits.
413
414        Theoretically your hash size must only be a minimum of 1 byte, and there can be less than or the same number of hash collisions as the bucket size. Any more and the HAMT will most likely throw errors.
415        """
416
417        self.lock: asyncio.Lock = asyncio.Lock()
418        """@private"""
419
420        self.values_are_bytes: bool = values_are_bytes
421        """Set this to true if you are only going to be storing python bytes objects into the hamt. This will improve performance by skipping a serialization step from IPLDKind.
422
423        This is theoretically safe to change in between operations, but this has not been verified in testing, so only do this at your own risk.
424        """
425
426        if max_bucket_size < 1:
427            raise ValueError("Bucket size maximum must be a positive integer")
428        self.max_bucket_size: int = max_bucket_size
429        """
430        This is only important for tuning performance when writing! For reading a HAMT that was written with a different max bucket size, this does not need to match and can be left unprovided.
431
432        This is an internal detail that has been exposed for performance tuning. The HAMT handles large key-value mapping sets even on a content addressed system by essentially sharding all the mappings across many smaller Nodes. The memory footprint of each of these Nodes footprint is a linear function of the maximum bucket size. Larger bucket sizes will result in larger Nodes, but more time taken to retrieve and decode these nodes from your backing CAS.
433
434        This must be a positive integer with a minimum of 1.
435        """
436
437        self.root_node_id: IPLDKind = root_node_id
438        """
439        This is type IPLDKind but the documentation generator pdoc mangles it a bit.
440
441        Read from this only when in read mode to get something valid!
442        """
443
444        self.read_only: bool = read_only
445        """Clients should NOT modify this.
446
447        This is here for checking whether the HAMT is in read only or read/write mode.
448
449        The distinction is made for performance and correctness reasons. In read only mode, the HAMT has an internal read cache that can speed up operations. In read/write mode, for reads the HAMT maintains strong consistency for reads by using async locks, and for writes the HAMT writes to an in memory buffer rather than performing (possibly) network calls to the underlying CAS.
450        """
451        self.node_store: NodeStore
452        """@private"""
453        if read_only:
454            self.node_store = ReadCacheStore(self)
455        else:
456            self.node_store = InMemoryTreeStore(self)
457
458    @classmethod
459    async def build(cls, *args: Any, **kwargs: Any) -> "HAMT":
460        """
461        Use this if you are initializing a completely empty HAMT! That means passing in None for the root_node_id. Method arguments are the exact same as `__init__`. If the root_node_id is not None, this will have no difference than creating a HAMT instance with __init__.
462
463        This separate async method is required since initializing an empty HAMT means sending some internal objects to the underlying CAS, which requires async operations. python does not allow for an async __init__, so this method is separately provided.
464        """
465        hamt = cls(*args, **kwargs)
466        if hamt.root_node_id is None:
467            hamt.root_node_id = await hamt.node_store.save(None, Node())
468        return hamt
469
470    # This is typically a massive blocking operation, you dont want to be running this concurrently with a bunch of other operations, so it's ok to have it not be async
471    async def make_read_only(self) -> None:
472        """
473        Makes the HAMT read only, which allows for more parallel read operations. The HAMT also needs to be in read only mode to get the real root node ID.
474
475        In read+write mode, the HAMT normally has to block separate get calls to enable strong consistency in case a set/delete operation falls in between.
476        """
477        async with self.lock:
478            inmemory_tree: InMemoryTreeStore = cast(InMemoryTreeStore, self.node_store)
479            await inmemory_tree.vacate()
480
481            self.read_only = True
482            self.node_store = ReadCacheStore(self)
483
484    async def enable_write(self) -> None:
485        """
486        Enable both reads and writes. Calling this while writes are already enabled is a no-op that preserves any buffered changes. The read-only to writable transition creates an internal structure for performance optimizations which will result in the root node ID no longer being valid; to read it at the end of your operations, first use `make_read_only`.
487        """
488        async with self.lock:
489            if not self.read_only:
490                return
491
492            # The read cache has no writes that need to be sent upstream so we can remove it without vacating
493            self.read_only = False
494            self.node_store = InMemoryTreeStore(self)
495
496    async def cache_size(self) -> int:
497        """
498        Returns the memory used by some internal performance optimization tools in bytes.
499
500        This is async concurrency safe, so call it whenever. This does mean it will block and wait for other writes to finish however.
501
502        Be warned that this may take a while to run for large HAMTs.
503
504        For more on memory management, see the `HAMT` class documentation.
505        """
506        if self.read_only:
507            return self.node_store.size()
508        async with self.lock:
509            return self.node_store.size()
510
511    async def cache_vacate(self) -> None:
512        """
513        Vacate and completely empty out the internal read/write cache.
514
515        Be warned that this may take a while if there have been a lot of write operations.
516
517        For more on memory management, see the `HAMT` class documentation.
518        """
519        if self.read_only:
520            await self.node_store.vacate()
521        else:
522            async with self.lock:
523                await self.node_store.vacate()
524
525    async def _reserialize_and_link(
526        self,
527        node_stack: list[tuple[IPLDKind, Node]],
528        link_path: list[int],
529    ) -> None:
530        """
531        This function starts from the node at the end of the list and reserializes so that each node holds valid new IDs after insertion into the store
532        Takes a stack of nodes, we represent a stack with a list where the first element is the root element and the last element is the top of the stack
533        Each element in the list is a tuple where the first element is the ID from the store and the second element is the Node in python
534        `link_path[i]` is the index in `node_stack[i - 1]` through which
535        `node_stack[i]` is linked. The root entry at index zero is a sentinel.
536        If a node ends up being empty, then it is deleted entirely, unless it is the root node
537        Modifies in place
538        """
539        # iterate in the reverse direction, this range goes from n-1 to 0, from the bottommost tree node to the root
540        for stack_index in range(len(node_stack) - 1, -1, -1):
541            old_id, node = node_stack[stack_index]
542
543            # If this node is empty, and it's not the root node, then we can delete it entirely from the list
544            is_root: bool = stack_index == 0
545            if node.is_empty() and not is_root:
546                # Unlink from the rest of the tree using the recorded parent slot.
547                _, prev_node = node_stack[stack_index - 1]
548                prev_node.data[link_path[stack_index]] = {}
549
550                # Remove from our stack, continue reserializing up the tree
551                node_stack.pop(stack_index)
552                link_path.pop(stack_index)
553                continue
554
555            # If not an empty node, just reserialize like normal and replace this one
556            new_store_id: IPLDKind = await self.node_store.save(old_id, node)
557            node_stack[stack_index] = (new_store_id, node)
558
559            # If this is not the last i.e. root node, we need to change the linking of the node prior in the list since we just reserialized
560            if not is_root:
561                _, prev_node = node_stack[stack_index - 1]
562                prev_node.set_link(link_path[stack_index], new_store_id)
563
564    async def _collect_subtree_entries(
565        self, node: Node, limit: int
566    ) -> dict[str, IPLDKind] | None:
567        """Collect a subtree's entries when they fit in a single bucket."""
568        entries: dict[str, IPLDKind] = {}
569
570        for bucket in node.iter_buckets():
571            if len(entries) + len(bucket) > limit:
572                return None
573            entries.update(bucket)
574
575        for link in node.iter_links():
576            child = await self.node_store.load(link)
577            child_entries = await self._collect_subtree_entries(
578                child, limit - len(entries)
579            )
580            if child_entries is None:
581                return None
582            entries.update(child_entries)
583
584        return entries
585
586    async def _collapse_delete_path(
587        self,
588        node_stack: list[tuple[IPLDKind, Node]],
589        link_path: list[int],
590    ) -> None:
591        """Collapse small subtrees into parent buckets after a deletion.
592
593        Applying this bottom-up restores the same shape produced by a fresh build:
594        every linked subtree contains more entries than ``max_bucket_size``. This
595        can change CIDs only for trees that previously retained a non-canonical
596        post-delete shape.
597        """
598        node_store = cast(InMemoryTreeStore, self.node_store)
599        for stack_index in range(len(node_stack) - 1, 0, -1):
600            old_id, node = node_stack[stack_index]
601            entries = await self._collect_subtree_entries(node, self.max_bucket_size)
602            if entries is None:
603                continue
604
605            _, parent = node_stack[stack_index - 1]
606            parent.data[link_path[stack_index]] = entries
607            node_store.remove_clean_node(old_id)
608            node_stack.pop(stack_index)
609            link_path.pop(stack_index)
610
611    # automatically skip encoding if the value provided is of the bytes variety
612    async def set(self, key: str, val: IPLDKind) -> None:
613        """Write a key-value mapping."""
614        if self.read_only:
615            raise Exception("Cannot call set on a read only HAMT")
616
617        data: bytes
618        if self.values_are_bytes:
619            data = cast(
620                bytes, val
621            )  # let users get an exception if they pass in a non bytes when they want to skip encoding
622        else:
623            data = dag_cbor.encode(val)
624
625        pointer: IPLDKind = await self.cas.save(data, codec="raw")
626        await self._set_pointer(key, pointer)
627
628    async def _build_overflow_subtree(
629        self,
630        kvs_queue: list[tuple[str, IPLDKind, bytes]],
631        depth: int,
632        key_hashes: dict[str, bytes],
633    ) -> IPLDKind:
634        """Build a detached subtree for a full bucket.
635
636        Every node touched here is new to the current write. Hash exhaustion can
637        therefore fail without exposing a partial mutation in the existing tree.
638        """
639        root_node = Node()
640        root_node_id = await self.node_store.save(None, root_node)
641        node_stack: list[tuple[IPLDKind, Node]] = [(root_node_id, root_node)]
642
643        while kvs_queue:
644            _, top_node = node_stack[-1]
645            curr_key, curr_val_ptr, raw_hash = kvs_queue[0]
646            map_key = extract_bits(raw_hash, depth + len(node_stack) - 1, 8)
647
648            item = top_node.data[map_key]
649            if isinstance(item, list):
650                next_node_id = item[0]
651                next_node = await self.node_store.load(next_node_id)
652                node_stack.append((next_node_id, next_node))
653                continue
654
655            bucket = cast(dict[str, IPLDKind], item)
656            if curr_key in bucket or len(bucket) < self.max_bucket_size:
657                bucket[curr_key] = curr_val_ptr
658                kvs_queue.pop(0)
659                continue
660
661            for bucket_key, bucket_value in bucket.items():
662                kvs_queue.append((bucket_key, bucket_value, key_hashes[bucket_key]))
663
664            new_node = Node()
665            new_node_id = await self.node_store.save(None, new_node)
666            top_node.set_link(map_key, new_node_id)
667
668        return root_node_id
669
670    async def _set_pointer(self, key: str, val_ptr: IPLDKind) -> None:
671        """Set a value pointer without exposing partial tree mutations on failure."""
672        async with self.lock:
673            node_store = cast(InMemoryTreeStore, self.node_store)
674            node_stack: list[tuple[IPLDKind, Node]] = []
675            link_path: list[int] = [-1]
676            root_node: Node = await self.node_store.load(self.root_node_id)
677            node_stack.append((self.root_node_id, root_node))
678
679            raw_hash = self.hash_fn(key.encode())
680            while True:
681                _, top_node = node_stack[-1]
682                map_key = extract_bits(raw_hash, len(node_stack) - 1, 8)
683
684                item = top_node.data[map_key]
685                if isinstance(item, list):
686                    next_node_id = item[0]
687                    next_node = await self.node_store.load(next_node_id)
688                    node_stack.append((next_node_id, next_node))
689                    link_path.append(map_key)
690                    continue
691
692                bucket = cast(dict[str, IPLDKind], item)
693                if key in bucket or len(bucket) < self.max_bucket_size:
694                    bucket[key] = val_ptr
695                    break
696
697                key_hashes = {key: raw_hash}
698                kvs_queue = [(key, val_ptr, raw_hash)]
699                for bucket_key, bucket_value in bucket.items():
700                    key_hashes[bucket_key] = self.hash_fn(bucket_key.encode())
701                    kvs_queue.append((bucket_key, bucket_value, key_hashes[bucket_key]))
702
703                original_buffer_ids = set(node_store.buffer)
704                try:
705                    new_node_id = await self._build_overflow_subtree(
706                        kvs_queue, len(node_stack), key_hashes
707                    )
708                except BaseException:
709                    for buffer_id in set(node_store.buffer) - original_buffer_ids:
710                        del node_store.buffer[buffer_id]
711                    raise
712
713                top_node.set_link(map_key, new_node_id)
714                break
715
716            # Finally, reserialize and fix all links.
717            await self._reserialize_and_link(node_stack, link_path)
718            self.root_node_id = node_stack[0][0]
719
720    async def delete(self, key: str) -> None:
721        """Delete a key-value mapping.
722
723        Failure-atomic with respect to storage errors: all fallible CAS loads
724        happen before any node is mutated, so if deletion raises, the
725        observable tree remains unchanged.
726        """
727
728        # Also deletes the pointer at the same time so this doesn't have a _delete_pointer duo
729        if self.read_only:
730            raise Exception("Cannot call delete on a read only HAMT")
731
732        async with self.lock:
733            raw_hash: bytes = self.hash_fn(key.encode())
734
735            node_stack: list[tuple[IPLDKind, Node]] = []
736            link_path: list[int] = [-1]
737            root_node: Node = await self.node_store.load(self.root_node_id)
738            node_stack.append((self.root_node_id, root_node))
739
740            created_change: bool = False
741            while True:
742                _, top_node = node_stack[-1]
743                map_key: int = extract_bits(raw_hash, len(node_stack) - 1, 8)
744
745                item = top_node.data[map_key]
746                if isinstance(item, dict):
747                    bucket = item
748                    if key in bucket:
749                        # Collapse may inspect sibling subtrees after the bucket is
750                        # changed. Load them first so a CAS failure cannot leave a
751                        # shared cached node partially mutated. The pre-delete tree
752                        # has one extra entry along this path, hence the +1 budget.
753                        for _, path_node in node_stack[1:]:
754                            await self._collect_subtree_entries(
755                                path_node, self.max_bucket_size + 1
756                            )
757                        del bucket[key]
758                        created_change = True
759                    # Break out since whether or not the key is in the bucket, it should have been here so either now reserialize or raise a KeyError
760                    break
761                elif isinstance(item, list):
762                    link: IPLDKind = item[0]
763                    next_node: Node = await self.node_store.load(link)
764                    node_stack.append((link, next_node))
765                    link_path.append(map_key)
766
767            # Finally, restore the canonical shape and fix all remaining links.
768            if created_change:
769                await self._collapse_delete_path(node_stack, link_path)
770                await self._reserialize_and_link(node_stack, link_path)
771                self.root_node_id = node_stack[0][0]
772            else:
773                # If we didn't make a change, then this key must not exist within the HAMT
774                raise KeyError
775
776    async def get(
777        self,
778        key: str,
779        offset: Optional[int] = None,
780        length: Optional[int] = None,
781        suffix: Optional[int] = None,
782    ) -> IPLDKind:
783        """Get a value."""
784        pointer: IPLDKind = await self.get_pointer(key)
785        data: bytes = await self.cas.load(
786            pointer, offset=offset, length=length, suffix=suffix
787        )
788        if self.values_are_bytes:
789            return data
790        else:
791            return dag_cbor.decode(data)
792
793    async def get_pointer(self, key: str) -> IPLDKind:
794        """
795        Get a store ID that points to the value for this key.
796
797        This is useful for some applications that want to implement a read cache. Due to the restrictions of `ContentAddressedStore` on IDs, pointers are regarded as immutable by python so they can be easily used as IDs for read caches. This is utilized in `ZarrHAMTStore` for example.
798        """
799        # If read only, no need to acquire a lock
800        pointer: IPLDKind
801        if self.read_only:
802            pointer = await self._get_pointer(key)
803        else:
804            async with self.lock:
805                pointer = await self._get_pointer(key)
806
807        return pointer
808
809    # Callers MUST handle acquiring a lock
810    async def _get_pointer(self, key: str) -> IPLDKind:
811        with instrumentation.span(
812            "py_hamt.hamt.lookup", {"py_hamt.hamt.lookup.key": key}
813        ):
814            lookup_started_at = time.perf_counter()
815            raw_hash: bytes = self.hash_fn(key.encode())
816
817            current_id: IPLDKind = self.root_node_id
818            current_depth: int = 0
819            node_loads = 0
820            node_cache_hits = 0
821
822            # Don't check if result is none but use a boolean to indicate finding something, this is because None is a possible value of IPLDKind
823            result_ptr: IPLDKind = None
824            found_a_result: bool = False
825            try:
826                while True:
827                    top_id: IPLDKind = current_id
828                    if (
829                        isinstance(self.node_store, ReadCacheStore)
830                        and top_id in self.node_store.cache
831                    ):
832                        node_cache_hits += 1
833                    node_loads += 1
834                    top_node: Node = await self.node_store.load(top_id)
835                    map_key: int = extract_bits(raw_hash, current_depth, 8)
836
837                    # Check if this key is in one of the buckets
838                    item = top_node.data[map_key]
839                    if isinstance(item, dict):
840                        bucket = item
841                        if key in bucket:
842                            result_ptr = bucket[key]
843                            found_a_result = True
844                            break
845
846                    if isinstance(item, list):
847                        link: IPLDKind = item[0]
848                        current_id = link
849                        current_depth += 1
850                        continue
851
852                    # Nowhere left to go, stop walking down the tree
853                    break
854
855                if not found_a_result:
856                    raise KeyError
857
858                return result_ptr
859            finally:
860                instrumentation.record_hamt_lookup(
861                    key,
862                    depth=current_depth,
863                    node_loads=node_loads,
864                    node_cache_hits=node_cache_hits,
865                    found=found_a_result,
866                    seconds=time.perf_counter() - lookup_started_at,
867                )
868
869    # Callers MUST handle locking or not on their own
870    async def _iter_nodes(self) -> AsyncIterator[tuple[IPLDKind, Node]]:
871        node_id_stack: list[IPLDKind] = [self.root_node_id]
872        while len(node_id_stack) > 0:
873            top_id: IPLDKind = node_id_stack.pop()
874            node: Node = await self.node_store.load(top_id)
875            yield (top_id, node)
876            node_id_stack.extend(list(node.iter_links()))
877
878    async def keys(self) -> AsyncIterator[str]:
879        """
880        AsyncIterator returning all keys in the HAMT.
881
882        If the HAMT is write enabled, the keys present when iteration starts are
883        copied while holding the async lock. The lock is released before any key
884        is yielded, so reads and mutations are safe between iterations and do not
885        affect the keys returned by an iteration already in progress.
886
887        When the HAMT is in read only mode however, this can be run concurrently with get operations.
888        """
889        if self.read_only:
890            async for k in self._keys_no_locking():
891                yield k
892        else:
893            # Buffered nodes are mutable, so copying only the root ID would not
894            # isolate iteration from mutations made after a caller-visible yield.
895            async with self.lock:
896                keys_snapshot = [key async for key in self._keys_no_locking()]
897            for key in keys_snapshot:
898                yield key
899
900    async def _keys_no_locking(self) -> AsyncIterator[str]:
901        async for _, node in self._iter_nodes():
902            for bucket in node.iter_buckets():
903                for key in bucket:
904                    yield key
905
906    async def len(self) -> int:
907        """
908        Return the number of key value mappings in this HAMT.
909
910        When the HAMT is write enabled, keys are counted directly while holding
911        the async lock, without materializing a snapshot. If read only, counting
912        can run concurrently with other operations.
913        """
914        count: int = 0
915        if self.read_only:
916            async for _ in self._keys_no_locking():
917                count += 1
918        else:
919            async with self.lock:
920                async for _ in self._keys_no_locking():
921                    count += 1
922
923        return count

An implementation of a Hash Array Mapped Trie for an arbitrary Content Addressed Storage (CAS) system, e.g. IPFS. This uses the IPLD data model.

Use this to store arbitrarily large key-value mappings in your CAS of choice.

For writing, this HAMT is async safe but NOT thread safe. Only write in an async event loop within the same thread.

When in read-only mode, the HAMT is both async and thread safe.

A note about memory management, read+write and read-only modes

The HAMT can be in either read+write mode or read-only mode. For either of these modes, the HAMT has some internal performance optimizations.

Note that in read+write, the real root node id IS NOT VALID. You should call make_read_only() to convert to read only mode and then read root_node_id.

These optimizations also trade off performance for memory use. Use cache_size to monitor the approximate memory usage. Be warned that for large key-value mapping sets this may take a bit to run. Use cache_vacate if you are over your memory limits.

IPFS HAMT Sample Code

kubo_cas = KuboCAS() # connects to a local kubo node with the default endpoints
hamt = await HAMT.build(cas=kubo_cas)
await hamt.set("foo", "bar")
assert (await hamt.get("foo")) == "bar"
await hamt.make_read_only()
cid = hamt.root_node_id # our root node CID
print(cid)
HAMT( cas: ContentAddressedStore, hash_fn: Callable[[bytes], bytes] = <function blake3_hashfn>, root_node_id: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]] = None, read_only: bool = False, max_bucket_size: int = 4, values_are_bytes: bool = False)
390    def __init__(
391        self,
392        cas: ContentAddressedStore,
393        hash_fn: Callable[[bytes], bytes] = blake3_hashfn,
394        root_node_id: IPLDKind | None = None,
395        read_only: bool = False,
396        max_bucket_size: int = 4,
397        values_are_bytes: bool = False,
398    ):
399        """
400        Use `build` if you need to create a completely empty HAMT, as this requires some async operations with the CAS. For what each of the constructor input variables refer to, check the documentation with the matching names below.
401        """
402
403        self.cas: ContentAddressedStore = cas
404        """The backing storage system. py-hamt provides an implementation `KuboCAS` for IPFS."""
405
406        self.hash_fn: Callable[[bytes], bytes] = hash_fn
407        """
408        This is the hash function used to place a key-value within the HAMT.
409
410        To provide your own hash function, create a function that takes in arbitrarily long bytes and returns the hash bytes.
411
412        It's important to note that the resulting hash must must always be a multiple of 8 bits since python bytes object can only represent in segments of bytes, and thus 8 bits.
413
414        Theoretically your hash size must only be a minimum of 1 byte, and there can be less than or the same number of hash collisions as the bucket size. Any more and the HAMT will most likely throw errors.
415        """
416
417        self.lock: asyncio.Lock = asyncio.Lock()
418        """@private"""
419
420        self.values_are_bytes: bool = values_are_bytes
421        """Set this to true if you are only going to be storing python bytes objects into the hamt. This will improve performance by skipping a serialization step from IPLDKind.
422
423        This is theoretically safe to change in between operations, but this has not been verified in testing, so only do this at your own risk.
424        """
425
426        if max_bucket_size < 1:
427            raise ValueError("Bucket size maximum must be a positive integer")
428        self.max_bucket_size: int = max_bucket_size
429        """
430        This is only important for tuning performance when writing! For reading a HAMT that was written with a different max bucket size, this does not need to match and can be left unprovided.
431
432        This is an internal detail that has been exposed for performance tuning. The HAMT handles large key-value mapping sets even on a content addressed system by essentially sharding all the mappings across many smaller Nodes. The memory footprint of each of these Nodes footprint is a linear function of the maximum bucket size. Larger bucket sizes will result in larger Nodes, but more time taken to retrieve and decode these nodes from your backing CAS.
433
434        This must be a positive integer with a minimum of 1.
435        """
436
437        self.root_node_id: IPLDKind = root_node_id
438        """
439        This is type IPLDKind but the documentation generator pdoc mangles it a bit.
440
441        Read from this only when in read mode to get something valid!
442        """
443
444        self.read_only: bool = read_only
445        """Clients should NOT modify this.
446
447        This is here for checking whether the HAMT is in read only or read/write mode.
448
449        The distinction is made for performance and correctness reasons. In read only mode, the HAMT has an internal read cache that can speed up operations. In read/write mode, for reads the HAMT maintains strong consistency for reads by using async locks, and for writes the HAMT writes to an in memory buffer rather than performing (possibly) network calls to the underlying CAS.
450        """
451        self.node_store: NodeStore
452        """@private"""
453        if read_only:
454            self.node_store = ReadCacheStore(self)
455        else:
456            self.node_store = InMemoryTreeStore(self)

Use build if you need to create a completely empty HAMT, as this requires some async operations with the CAS. For what each of the constructor input variables refer to, check the documentation with the matching names below.

The backing storage system. py-hamt provides an implementation KuboCAS for IPFS.

hash_fn: Callable[[bytes], bytes]

This is the hash function used to place a key-value within the HAMT.

To provide your own hash function, create a function that takes in arbitrarily long bytes and returns the hash bytes.

It's important to note that the resulting hash must must always be a multiple of 8 bits since python bytes object can only represent in segments of bytes, and thus 8 bits.

Theoretically your hash size must only be a minimum of 1 byte, and there can be less than or the same number of hash collisions as the bucket size. Any more and the HAMT will most likely throw errors.

values_are_bytes: bool

Set this to true if you are only going to be storing python bytes objects into the hamt. This will improve performance by skipping a serialization step from IPLDKind.

This is theoretically safe to change in between operations, but this has not been verified in testing, so only do this at your own risk.

max_bucket_size: int

This is only important for tuning performance when writing! For reading a HAMT that was written with a different max bucket size, this does not need to match and can be left unprovided.

This is an internal detail that has been exposed for performance tuning. The HAMT handles large key-value mapping sets even on a content addressed system by essentially sharding all the mappings across many smaller Nodes. The memory footprint of each of these Nodes footprint is a linear function of the maximum bucket size. Larger bucket sizes will result in larger Nodes, but more time taken to retrieve and decode these nodes from your backing CAS.

This must be a positive integer with a minimum of 1.

root_node_id: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]

This is type IPLDKind but the documentation generator pdoc mangles it a bit.

Read from this only when in read mode to get something valid!

read_only: bool

Clients should NOT modify this.

This is here for checking whether the HAMT is in read only or read/write mode.

The distinction is made for performance and correctness reasons. In read only mode, the HAMT has an internal read cache that can speed up operations. In read/write mode, for reads the HAMT maintains strong consistency for reads by using async locks, and for writes the HAMT writes to an in memory buffer rather than performing (possibly) network calls to the underlying CAS.

@classmethod
async def build(cls, *args: Any, **kwargs: Any) -> HAMT:
458    @classmethod
459    async def build(cls, *args: Any, **kwargs: Any) -> "HAMT":
460        """
461        Use this if you are initializing a completely empty HAMT! That means passing in None for the root_node_id. Method arguments are the exact same as `__init__`. If the root_node_id is not None, this will have no difference than creating a HAMT instance with __init__.
462
463        This separate async method is required since initializing an empty HAMT means sending some internal objects to the underlying CAS, which requires async operations. python does not allow for an async __init__, so this method is separately provided.
464        """
465        hamt = cls(*args, **kwargs)
466        if hamt.root_node_id is None:
467            hamt.root_node_id = await hamt.node_store.save(None, Node())
468        return hamt

Use this if you are initializing a completely empty HAMT! That means passing in None for the root_node_id. Method arguments are the exact same as __init__. If the root_node_id is not None, this will have no difference than creating a HAMT instance with __init__.

This separate async method is required since initializing an empty HAMT means sending some internal objects to the underlying CAS, which requires async operations. python does not allow for an async __init__, so this method is separately provided.

async def make_read_only(self) -> None:
471    async def make_read_only(self) -> None:
472        """
473        Makes the HAMT read only, which allows for more parallel read operations. The HAMT also needs to be in read only mode to get the real root node ID.
474
475        In read+write mode, the HAMT normally has to block separate get calls to enable strong consistency in case a set/delete operation falls in between.
476        """
477        async with self.lock:
478            inmemory_tree: InMemoryTreeStore = cast(InMemoryTreeStore, self.node_store)
479            await inmemory_tree.vacate()
480
481            self.read_only = True
482            self.node_store = ReadCacheStore(self)

Makes the HAMT read only, which allows for more parallel read operations. The HAMT also needs to be in read only mode to get the real root node ID.

In read+write mode, the HAMT normally has to block separate get calls to enable strong consistency in case a set/delete operation falls in between.

async def enable_write(self) -> None:
484    async def enable_write(self) -> None:
485        """
486        Enable both reads and writes. Calling this while writes are already enabled is a no-op that preserves any buffered changes. The read-only to writable transition creates an internal structure for performance optimizations which will result in the root node ID no longer being valid; to read it at the end of your operations, first use `make_read_only`.
487        """
488        async with self.lock:
489            if not self.read_only:
490                return
491
492            # The read cache has no writes that need to be sent upstream so we can remove it without vacating
493            self.read_only = False
494            self.node_store = InMemoryTreeStore(self)

Enable both reads and writes. Calling this while writes are already enabled is a no-op that preserves any buffered changes. The read-only to writable transition creates an internal structure for performance optimizations which will result in the root node ID no longer being valid; to read it at the end of your operations, first use make_read_only.

async def cache_size(self) -> int:
496    async def cache_size(self) -> int:
497        """
498        Returns the memory used by some internal performance optimization tools in bytes.
499
500        This is async concurrency safe, so call it whenever. This does mean it will block and wait for other writes to finish however.
501
502        Be warned that this may take a while to run for large HAMTs.
503
504        For more on memory management, see the `HAMT` class documentation.
505        """
506        if self.read_only:
507            return self.node_store.size()
508        async with self.lock:
509            return self.node_store.size()

Returns the memory used by some internal performance optimization tools in bytes.

This is async concurrency safe, so call it whenever. This does mean it will block and wait for other writes to finish however.

Be warned that this may take a while to run for large HAMTs.

For more on memory management, see the HAMT class documentation.

async def cache_vacate(self) -> None:
511    async def cache_vacate(self) -> None:
512        """
513        Vacate and completely empty out the internal read/write cache.
514
515        Be warned that this may take a while if there have been a lot of write operations.
516
517        For more on memory management, see the `HAMT` class documentation.
518        """
519        if self.read_only:
520            await self.node_store.vacate()
521        else:
522            async with self.lock:
523                await self.node_store.vacate()

Vacate and completely empty out the internal read/write cache.

Be warned that this may take a while if there have been a lot of write operations.

For more on memory management, see the HAMT class documentation.

async def set( self, key: str, val: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]]) -> None:
612    async def set(self, key: str, val: IPLDKind) -> None:
613        """Write a key-value mapping."""
614        if self.read_only:
615            raise Exception("Cannot call set on a read only HAMT")
616
617        data: bytes
618        if self.values_are_bytes:
619            data = cast(
620                bytes, val
621            )  # let users get an exception if they pass in a non bytes when they want to skip encoding
622        else:
623            data = dag_cbor.encode(val)
624
625        pointer: IPLDKind = await self.cas.save(data, codec="raw")
626        await self._set_pointer(key, pointer)

Write a key-value mapping.

async def delete(self, key: str) -> None:
720    async def delete(self, key: str) -> None:
721        """Delete a key-value mapping.
722
723        Failure-atomic with respect to storage errors: all fallible CAS loads
724        happen before any node is mutated, so if deletion raises, the
725        observable tree remains unchanged.
726        """
727
728        # Also deletes the pointer at the same time so this doesn't have a _delete_pointer duo
729        if self.read_only:
730            raise Exception("Cannot call delete on a read only HAMT")
731
732        async with self.lock:
733            raw_hash: bytes = self.hash_fn(key.encode())
734
735            node_stack: list[tuple[IPLDKind, Node]] = []
736            link_path: list[int] = [-1]
737            root_node: Node = await self.node_store.load(self.root_node_id)
738            node_stack.append((self.root_node_id, root_node))
739
740            created_change: bool = False
741            while True:
742                _, top_node = node_stack[-1]
743                map_key: int = extract_bits(raw_hash, len(node_stack) - 1, 8)
744
745                item = top_node.data[map_key]
746                if isinstance(item, dict):
747                    bucket = item
748                    if key in bucket:
749                        # Collapse may inspect sibling subtrees after the bucket is
750                        # changed. Load them first so a CAS failure cannot leave a
751                        # shared cached node partially mutated. The pre-delete tree
752                        # has one extra entry along this path, hence the +1 budget.
753                        for _, path_node in node_stack[1:]:
754                            await self._collect_subtree_entries(
755                                path_node, self.max_bucket_size + 1
756                            )
757                        del bucket[key]
758                        created_change = True
759                    # Break out since whether or not the key is in the bucket, it should have been here so either now reserialize or raise a KeyError
760                    break
761                elif isinstance(item, list):
762                    link: IPLDKind = item[0]
763                    next_node: Node = await self.node_store.load(link)
764                    node_stack.append((link, next_node))
765                    link_path.append(map_key)
766
767            # Finally, restore the canonical shape and fix all remaining links.
768            if created_change:
769                await self._collapse_delete_path(node_stack, link_path)
770                await self._reserialize_and_link(node_stack, link_path)
771                self.root_node_id = node_stack[0][0]
772            else:
773                # If we didn't make a change, then this key must not exist within the HAMT
774                raise KeyError

Delete a key-value mapping.

Failure-atomic with respect to storage errors: all fallible CAS loads happen before any node is mutated, so if deletion raises, the observable tree remains unchanged.

async def get( self, key: str, offset: Optional[int] = None, length: Optional[int] = None, suffix: Optional[int] = None) -> Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]]:
776    async def get(
777        self,
778        key: str,
779        offset: Optional[int] = None,
780        length: Optional[int] = None,
781        suffix: Optional[int] = None,
782    ) -> IPLDKind:
783        """Get a value."""
784        pointer: IPLDKind = await self.get_pointer(key)
785        data: bytes = await self.cas.load(
786            pointer, offset=offset, length=length, suffix=suffix
787        )
788        if self.values_are_bytes:
789            return data
790        else:
791            return dag_cbor.decode(data)

Get a value.

async def get_pointer( self, key: str) -> Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]]:
793    async def get_pointer(self, key: str) -> IPLDKind:
794        """
795        Get a store ID that points to the value for this key.
796
797        This is useful for some applications that want to implement a read cache. Due to the restrictions of `ContentAddressedStore` on IDs, pointers are regarded as immutable by python so they can be easily used as IDs for read caches. This is utilized in `ZarrHAMTStore` for example.
798        """
799        # If read only, no need to acquire a lock
800        pointer: IPLDKind
801        if self.read_only:
802            pointer = await self._get_pointer(key)
803        else:
804            async with self.lock:
805                pointer = await self._get_pointer(key)
806
807        return pointer

Get a store ID that points to the value for this key.

This is useful for some applications that want to implement a read cache. Due to the restrictions of ContentAddressedStore on IDs, pointers are regarded as immutable by python so they can be easily used as IDs for read caches. This is utilized in ZarrHAMTStore for example.

async def keys(self) -> AsyncIterator[str]:
878    async def keys(self) -> AsyncIterator[str]:
879        """
880        AsyncIterator returning all keys in the HAMT.
881
882        If the HAMT is write enabled, the keys present when iteration starts are
883        copied while holding the async lock. The lock is released before any key
884        is yielded, so reads and mutations are safe between iterations and do not
885        affect the keys returned by an iteration already in progress.
886
887        When the HAMT is in read only mode however, this can be run concurrently with get operations.
888        """
889        if self.read_only:
890            async for k in self._keys_no_locking():
891                yield k
892        else:
893            # Buffered nodes are mutable, so copying only the root ID would not
894            # isolate iteration from mutations made after a caller-visible yield.
895            async with self.lock:
896                keys_snapshot = [key async for key in self._keys_no_locking()]
897            for key in keys_snapshot:
898                yield key

AsyncIterator returning all keys in the HAMT.

If the HAMT is write enabled, the keys present when iteration starts are copied while holding the async lock. The lock is released before any key is yielded, so reads and mutations are safe between iterations and do not affect the keys returned by an iteration already in progress.

When the HAMT is in read only mode however, this can be run concurrently with get operations.

async def len(self) -> int:
906    async def len(self) -> int:
907        """
908        Return the number of key value mappings in this HAMT.
909
910        When the HAMT is write enabled, keys are counted directly while holding
911        the async lock, without materializing a snapshot. If read only, counting
912        can run concurrently with other operations.
913        """
914        count: int = 0
915        if self.read_only:
916            async for _ in self._keys_no_locking():
917                count += 1
918        else:
919            async with self.lock:
920                async for _ in self._keys_no_locking():
921                    count += 1
922
923        return count

Return the number of key value mappings in this HAMT.

When the HAMT is write enabled, keys are counted directly while holding the async lock, without materializing a snapshot. If read only, counting can run concurrently with other operations.

class ContentAddressedStore(abc.ABC):
395class ContentAddressedStore(ABC):
396    """
397    Abstract class that represents a content addressed storage that the `HAMT` can use for keeping data.
398
399    Note that the return type of save and input to load is really type `IPLDKind`, but the documentation generator pdoc mangles it unfortunately.
400
401    #### A note on the IPLDKind return types
402    Save and load return the type IPLDKind and not just a CID. As long as python regards the underlying type as immutable it can be used, allowing for more flexibility. There are two exceptions:
403    1. No lists or dicts, since python does not classify these as immutable.
404    2. No `None` values since this is used in HAMT's `__init__` to indicate that an empty HAMT needs to be initialized.
405    """
406
407    CodecInput = Literal["raw", "dag-cbor"]
408
409    @abstractmethod
410    async def save(self, data: bytes, codec: CodecInput) -> IPLDKind:
411        """Save data to a storage mechanism, and return an ID for the data in the IPLDKind type.
412
413        `codec` will be set to "dag-cbor" if this data should be marked as special linked data a la IPLD data model.
414        """
415
416    @abstractmethod
417    async def load(
418        self,
419        id: IPLDKind,
420        offset: Optional[int] = None,
421        length: Optional[int] = None,
422        suffix: Optional[int] = None,
423    ) -> bytes:
424        """Retrieve data."""
425
426    async def pin_cid(self, id: IPLDKind, target_rpc: str) -> None:
427        """Pin a CID in the storage."""
428        pass  # pragma: no cover
429
430    async def unpin_cid(self, id: IPLDKind, target_rpc: str) -> None:
431        """Unpin a CID in the storage."""
432        pass  # pragma: no cover
433
434    async def pin_update(
435        self, old_id: IPLDKind, new_id: IPLDKind, target_rpc: str
436    ) -> None:
437        """Update the pinned CID in the storage."""
438        pass  # pragma: no cover
439
440    async def pin_ls(self, target_rpc: str) -> list[Dict[str, Any]]:
441        """List all pinned CIDs in the storage."""
442        return []  # pragma: no cover

Abstract class that represents a content addressed storage that the HAMT can use for keeping data.

Note that the return type of save and input to load is really type IPLDKind, but the documentation generator pdoc mangles it unfortunately.

A note on the IPLDKind return types

Save and load return the type IPLDKind and not just a CID. As long as python regards the underlying type as immutable it can be used, allowing for more flexibility. There are two exceptions:

  1. No lists or dicts, since python does not classify these as immutable.
  2. No None values since this is used in HAMT's __init__ to indicate that an empty HAMT needs to be initialized.
CodecInput = typing.Literal['raw', 'dag-cbor']
@abstractmethod
async def save( self, data: bytes, codec: Literal['raw', 'dag-cbor']) -> Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]]:
409    @abstractmethod
410    async def save(self, data: bytes, codec: CodecInput) -> IPLDKind:
411        """Save data to a storage mechanism, and return an ID for the data in the IPLDKind type.
412
413        `codec` will be set to "dag-cbor" if this data should be marked as special linked data a la IPLD data model.
414        """

Save data to a storage mechanism, and return an ID for the data in the IPLDKind type.

codec will be set to "dag-cbor" if this data should be marked as special linked data a la IPLD data model.

@abstractmethod
async def load( self, id: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]], offset: Optional[int] = None, length: Optional[int] = None, suffix: Optional[int] = None) -> bytes:
416    @abstractmethod
417    async def load(
418        self,
419        id: IPLDKind,
420        offset: Optional[int] = None,
421        length: Optional[int] = None,
422        suffix: Optional[int] = None,
423    ) -> bytes:
424        """Retrieve data."""

Retrieve data.

async def pin_cid( self, id: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]], target_rpc: str) -> None:
426    async def pin_cid(self, id: IPLDKind, target_rpc: str) -> None:
427        """Pin a CID in the storage."""
428        pass  # pragma: no cover

Pin a CID in the storage.

async def unpin_cid( self, id: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]], target_rpc: str) -> None:
430    async def unpin_cid(self, id: IPLDKind, target_rpc: str) -> None:
431        """Unpin a CID in the storage."""
432        pass  # pragma: no cover

Unpin a CID in the storage.

async def pin_update( self, old_id: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]], new_id: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]], target_rpc: str) -> None:
434    async def pin_update(
435        self, old_id: IPLDKind, new_id: IPLDKind, target_rpc: str
436    ) -> None:
437        """Update the pinned CID in the storage."""
438        pass  # pragma: no cover

Update the pinned CID in the storage.

async def pin_ls(self, target_rpc: str) -> list[typing.Dict[str, typing.Any]]:
440    async def pin_ls(self, target_rpc: str) -> list[Dict[str, Any]]:
441        """List all pinned CIDs in the storage."""
442        return []  # pragma: no cover

List all pinned CIDs in the storage.

class GatewayContentMismatch(builtins.Exception):
199class GatewayContentMismatch(Exception):
200    """A gateway returned bytes that do not hash to the requested CID.
201
202    Raised only when content verification is enabled. Treated as a per-gateway
203    failure, so a multi-gateway ``KuboCAS`` fails over to the next gateway
204    rather than returning corrupt data to the caller.
205    """

A gateway returned bytes that do not hash to the requested CID.

Raised only when content verification is enabled. Treated as a per-gateway failure, so a multi-gateway KuboCAS fails over to the next gateway rather than returning corrupt data to the caller.

class GatewayContentUnverifiable(py_hamt.GatewayContentMismatch):
208class GatewayContentUnverifiable(GatewayContentMismatch):
209    """Verification was requested but could not be carried out.
210
211    Subclasses ``GatewayContentMismatch`` so it fails over and is caught by
212    existing handlers. Distinct because the cause differs: the content is not
213    known to be wrong, only unproven. ``verify_content=True`` must still fail
214    closed here -- returning unverified bytes would silently downgrade the
215    guarantee exactly when the hashing backend is broken or the algorithm is
216    unavailable.
217    """

Verification was requested but could not be carried out.

Subclasses GatewayContentMismatch so it fails over and is caught by existing handlers. Distinct because the cause differs: the content is not known to be wrong, only unproven. verify_content=True must still fail closed here -- returning unverified bytes would silently downgrade the guarantee exactly when the hashing backend is broken or the algorithm is unavailable.

class InMemoryCAS(py_hamt.ContentAddressedStore):
445class InMemoryCAS(ContentAddressedStore):
446    """Used mostly for faster testing, this is why this is not exported. It hashes all inputs and uses that as a key to an in-memory python dict, mimicking a content addressed storage system. The hash bytes are the ID that `save` returns and `load` takes in."""
447
448    store: dict[bytes, bytes]
449    hash_alg: Multihash
450
451    def __init__(self):
452        self.store = dict()
453        self.hash_alg = multihash.get("blake3")
454
455    async def save(self, data: bytes, codec: ContentAddressedStore.CodecInput) -> bytes:
456        hash: bytes = self.hash_alg.digest(data, size=32)
457        self.store[hash] = data
458        return hash
459
460    async def load(
461        self,
462        id: IPLDKind,
463        offset: Optional[int] = None,
464        length: Optional[int] = None,
465        suffix: Optional[int] = None,
466    ) -> bytes:
467        """
468        Retrieve all or part of an object using Python slice semantics.
469
470        A zero ``length`` or ``suffix`` returns an empty byte string.
471
472        `ContentAddressedStore` allows any IPLD scalar key.  For the in-memory
473        backend we *require* a `bytes` hash; anything else is rejected at run
474        time. In OO type-checking, a subclass may widen (make more general) argument types,
475        but it must never narrow them; otherwise callers that expect the base-class contract can break.
476        Mypy enforces this contra-variance rule and emits the "violates Liskov substitution principle" error.
477        This is why we use `cast` here, to tell mypy that we know what we are doing.
478        h/t https://stackoverflow.com/questions/75209249/overriding-a-method-mypy-throws-an-incompatible-with-super-type-error-when-ch
479        """
480        if (offset is not None and length == 0) or (offset is None and suffix == 0):
481            return b""
482
483        key = cast(bytes, id)
484        if not isinstance(key, (bytes, bytearray)):  # defensive guard
485            raise TypeError(
486                f"InMemoryCAS only supports byte‐hash keys; got {type(id).__name__}"
487            )
488        data: bytes
489        try:
490            data = self.store[key]
491        except KeyError as exc:
492            raise KeyError("Object not found in in-memory store") from exc
493
494        return _slice_requested_range(data, offset, length, suffix)

Used mostly for faster testing, this is why this is not exported. It hashes all inputs and uses that as a key to an in-memory python dict, mimicking a content addressed storage system. The hash bytes are the ID that save returns and load takes in.

store: dict[bytes, bytes]
hash_alg: multiformats.multihash.Multihash
async def save(self, data: bytes, codec: Literal['raw', 'dag-cbor']) -> bytes:
455    async def save(self, data: bytes, codec: ContentAddressedStore.CodecInput) -> bytes:
456        hash: bytes = self.hash_alg.digest(data, size=32)
457        self.store[hash] = data
458        return hash

Save data to a storage mechanism, and return an ID for the data in the IPLDKind type.

codec will be set to "dag-cbor" if this data should be marked as special linked data a la IPLD data model.

async def load( self, id: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]], offset: Optional[int] = None, length: Optional[int] = None, suffix: Optional[int] = None) -> bytes:
460    async def load(
461        self,
462        id: IPLDKind,
463        offset: Optional[int] = None,
464        length: Optional[int] = None,
465        suffix: Optional[int] = None,
466    ) -> bytes:
467        """
468        Retrieve all or part of an object using Python slice semantics.
469
470        A zero ``length`` or ``suffix`` returns an empty byte string.
471
472        `ContentAddressedStore` allows any IPLD scalar key.  For the in-memory
473        backend we *require* a `bytes` hash; anything else is rejected at run
474        time. In OO type-checking, a subclass may widen (make more general) argument types,
475        but it must never narrow them; otherwise callers that expect the base-class contract can break.
476        Mypy enforces this contra-variance rule and emits the "violates Liskov substitution principle" error.
477        This is why we use `cast` here, to tell mypy that we know what we are doing.
478        h/t https://stackoverflow.com/questions/75209249/overriding-a-method-mypy-throws-an-incompatible-with-super-type-error-when-ch
479        """
480        if (offset is not None and length == 0) or (offset is None and suffix == 0):
481            return b""
482
483        key = cast(bytes, id)
484        if not isinstance(key, (bytes, bytearray)):  # defensive guard
485            raise TypeError(
486                f"InMemoryCAS only supports byte‐hash keys; got {type(id).__name__}"
487            )
488        data: bytes
489        try:
490            data = self.store[key]
491        except KeyError as exc:
492            raise KeyError("Object not found in in-memory store") from exc
493
494        return _slice_requested_range(data, offset, length, suffix)

Retrieve all or part of an object using Python slice semantics.

A zero length or suffix returns an empty byte string.

ContentAddressedStore allows any IPLD scalar key. For the in-memory backend we require a bytes hash; anything else is rejected at run time. In OO type-checking, a subclass may widen (make more general) argument types, but it must never narrow them; otherwise callers that expect the base-class contract can break. Mypy enforces this contra-variance rule and emits the "violates Liskov substitution principle" error. This is why we use cast here, to tell mypy that we know what we are doing. h/t https://stackoverflow.com/questions/75209249/overriding-a-method-mypy-throws-an-incompatible-with-super-type-error-when-ch

class KuboCAS(py_hamt.ContentAddressedStore):
 497class KuboCAS(ContentAddressedStore):
 498    """
 499    Connects to an **IPFS Kubo** daemon.
 500
 501    The IDs in save and load are IPLD CIDs.
 502
 503    * **save()**  → RPC  (`/api/v0/add`)
 504    * **load()**  → HTTP gateway  (`/ipfs/{cid}`)
 505
 506    `save` uses the RPC API and `load` uses the HTTP Gateway. This means that read-only HAMTs will only access the HTTP Gateway, so no RPC endpoint is required for use.
 507
 508    ### Authentication / custom headers
 509    You have two options:
 510
 511    1. **Bring your own `httpx.AsyncClient` or client factory**
 512       Pass a client via `client=...` for use on one event loop, or pass
 513       `client_factory=...` to build a fully configured client for each event
 514       loop. Reusing a supplied client from a later loop emits a warning and
 515       falls back to an internal client that preserves only headers, auth,
 516       timeout, redirect policy, and event hooks.
 517    2. **Let `KuboCAS` build the client** but pass
 518       `headers=` *and*/or `auth=` kwargs; they are forwarded to the
 519       internally-created `AsyncClient`.
 520
 521    ```python
 522    import httpx
 523    from py_hamt import KuboCAS
 524
 525    # Option 1: user-supplied client
 526    client = httpx.AsyncClient(
 527        headers={"Authorization": "Bearer <token>"},
 528        auth=("user", "pass"),
 529        follow_redirects=True,
 530    )
 531    cas = KuboCAS(client=client)
 532
 533    # Option 2: let KuboCAS create the client
 534    cas = KuboCAS(
 535        headers={"X-My-Header": "yes"},
 536        auth=("user", "pass"),
 537    )
 538    ```
 539
 540    ### Parameters
 541    - **hasher** (str): multihash name (defaults to *blake3*).
 542    - **client** (`httpx.AsyncClient | None`): reuse an existing
 543      client and its configured timeout and redirect policy. User-supplied
 544      clients should set ``follow_redirects=True`` when gateways may redirect.
 545      If *None*, KuboCAS will create one lazily with a 60-second timeout and
 546      redirect following and HTTP/2 enabled. Plaintext endpoints continue to
 547      use HTTP/1.1 because HTTP/2 negotiation requires TLS/ALPN.
 548    - **client_factory** (`Callable[[], httpx.AsyncClient] | None`): create a
 549      separate, fully configured client for each event loop. KuboCAS owns and
 550      closes clients returned by the factory. Mutually exclusive with
 551      **client**.
 552    - **headers** (dict[str, str] | None): default headers for the
 553      internally-created client.
 554    - **auth** (`tuple[str, str] | None`): authentication tuple (username, password)
 555      for the internally-created client.
 556    - **rpc_base_url / gateway_base_url** (str | None): override daemon
 557      endpoints (defaults match the local daemon ports). Gateway URLs may end
 558      with `/ipfs` and may include a trailing slash.
 559    - **gateway_base_urls** (list[str] | None): read from several gateways with
 560      automatic failover. Mutually exclusive with `gateway_base_url`. Each read
 561      tries one gateway at a time, healthy gateways first, until one succeeds;
 562      requests are not raced in parallel. A gateway that fails three times in a
 563      row is moved to the back of the rotation for 30 seconds and then probed
 564      again. `concurrency` applies per gateway. If every gateway fails, an
 565      `ExceptionGroup` of the underlying errors is raised.
 566    - **verify_content** (bool): check that returned bytes hash to the
 567      requested CID, raising `GatewayContentMismatch` (and failing over to the
 568      next gateway) when they do not. Worth enabling when reading from public
 569      gateways you do not control. Only full-body reads of non-`dag-pb` CIDs
 570      can be verified; Range reads and `dag-pb` reads are passed through
 571      unchecked because neither returns the exact bytes the CID commits to.
 572      Requests to a gateway outside the origin of
 573      `gateway_base_url`/`rpc_base_url` forward only content-negotiation
 574      headers (`Accept`, `Accept-Encoding`, `Accept-Language`, `User-Agent`)
 575      and drop client-level `auth`. Because any header name may carry a
 576      credential, everything else is withheld -- so a private primary can
 577      safely be paired with public fallbacks, but a foreign gateway that needs
 578      its own custom header will not receive one.
 579    - **chunker** (str): chunking algorithm specification for Kubo's `add`
 580      RPC. Accepted formats are `"size-<positive int>"`, `"rabin"`, or
 581      `"rabin-<min>-<avg>-<max>"`.
 582
 583    ...
 584    """
 585
 586    KUBO_DEFAULT_LOCAL_GATEWAY_BASE_URL: str = "http://127.0.0.1:8080"
 587    KUBO_DEFAULT_LOCAL_RPC_BASE_URL: str = "http://127.0.0.1:5001"
 588
 589    DAG_PB_MARKER: int = 0x70
 590    """@private"""
 591
 592    # Take in a httpx client that can be reused across POSTs and GETs to a specific IPFS daemon
 593    def __init__(
 594        self,
 595        hasher: str = "blake3",
 596        client: httpx.AsyncClient | None = None,
 597        rpc_base_url: str | None = None,
 598        gateway_base_url: str | None = None,
 599        concurrency: int = 32,
 600        *,
 601        gateway_base_urls: list[str] | None = None,
 602        verify_content: bool = False,
 603        client_factory: Optional[Callable[[], httpx.AsyncClient]] = None,
 604        headers: dict[str, str] | None = None,
 605        auth: Tuple[str, str] | None = None,
 606        pin_on_add: bool = False,
 607        chunker: str = "size-1048576",
 608        max_retries: int = 3,
 609        initial_delay: float = 1.0,
 610        backoff_factor: float = 2.0,
 611    ):
 612        """
 613        If None is passed into the rpc or gateway base url, then the default for kubo local daemons will be used. The default local values will also be used if nothing is passed in at all.
 614
 615        ### `httpx.AsyncClient` Management
 616        If `client` is not provided, it will be automatically initialized. It is the responsibility of the user to close this at an appropriate time, using `await cas.aclose()`
 617        as a class instance cannot know when it will no longer be in use, unless explicitly told to do so.
 618
 619        A supplied client is associated with the running event loop lazily on
 620        first use, so constructing ``KuboCAS`` does not require an async
 621        context. On a later event loop, KuboCAS warns and uses an internally
 622        created fallback that preserves only the supplied client's headers,
 623        auth, timeout, redirect policy, and event hooks. Pass
 624        ``client_factory`` instead when every event loop needs the client's
 625        full configuration. Factory clients are owned and closed by KuboCAS.
 626        Clients created internally by ``KuboCAS`` use a 60-second timeout,
 627        follow redirects, and negotiate HTTP/2 for HTTPS endpoints that
 628        support it.
 629
 630        If you are using the `KuboCAS` instance in an `async with` block, it will automatically close the client when the block is exited which is what we suggest below:
 631        ```python
 632        async with httpx.AsyncClient() as client, KuboCAS(
 633            rpc_base_url=rpc_base_url,
 634            gateway_base_url=gateway_base_url,
 635            client=client,
 636        ) as kubo_cas:
 637            hamt = await HAMT.build(cas=kubo_cas, values_are_bytes=True)
 638            zhs = ZarrHAMTStore(hamt)
 639            # Use the KuboCAS instance as needed
 640            # ...
 641        ```
 642        As mentioned, if you do not use the `async with` syntax, you should call `await cas.aclose()` when you are done using the instance to ensure that all resources are cleaned up.
 643        ``` python
 644        cas = KuboCAS(rpc_base_url=rpc_base_url, gateway_base_url=gateway_base_url)
 645        # Use the KuboCAS instance as needed
 646        # ...
 647        await cas.aclose()  # Ensure resources are cleaned up
 648        ```
 649
 650        ### Authenticated RPC/Gateway Access
 651        Users can set whatever headers and auth credentials they need if they are connecting to an authenticated kubo instance by setting them in their own `httpx.AsyncClient` and then passing that in.
 652        Alternatively, they can pass in `headers` and `auth` parameters to the constructor, which will be used to create a new `httpx.AsyncClient` if one is not provided.
 653        If you do not need authentication, you can leave these parameters as `None`.
 654
 655        ### RPC and HTTP Gateway Base URLs
 656        These are the first part of the url, defaults that refer to the default that kubo launches with on a local machine are provided.
 657        """
 658
 659        if client is not None and client_factory is not None:
 660            raise ValueError("client and client_factory are mutually exclusive")
 661        if client_factory is not None and (headers is not None or auth is not None):
 662            raise ValueError(
 663                "client_factory is mutually exclusive with headers/auth; "
 664                "configure them on the clients the factory builds"
 665            )
 666
 667        self._owns_client: bool = False
 668        self._closed: bool = True
 669        self._client_per_loop: Dict[asyncio.AbstractEventLoop, httpx.AsyncClient] = {}
 670        self._internally_created_clients: set[httpx.AsyncClient] = set()
 671        # Serializes first-use client binding so concurrent event loops on
 672        # different threads cannot both consume ``_supplied_client`` and bind
 673        # one httpx.AsyncClient to two loops.
 674        self._first_use_lock: threading.Lock = threading.Lock()
 675        self._semaphore_per_loop: Dict[
 676            asyncio.AbstractEventLoop, asyncio.Semaphore
 677        ] = {}
 678        # Gateway reads get a semaphore per (loop, gateway) so ``concurrency``
 679        # means "in-flight requests per gateway". Sharing one budget across
 680        # gateways would divide effective parallelism by the gateway count and
 681        # let a slow gateway starve the healthy ones of slots.
 682        self._gateway_semaphore_per_loop: Dict[
 683            Tuple[asyncio.AbstractEventLoop, str], asyncio.Semaphore
 684        ] = {}
 685
 686        # Now, perform validation that might raise an exception
 687        chunker_pattern = r"(?:size-[1-9]\d*|rabin(?:-[1-9]\d*-[1-9]\d*-[1-9]\d*)?)"
 688        if re.fullmatch(chunker_pattern, chunker) is None:
 689            raise ValueError("Invalid chunker specification")
 690        self.chunker: str = chunker
 691
 692        self.hasher: str = hasher
 693        """The hash function to send to IPFS when storing bytes. Cannot be changed after initialization. The default blake3 follows the default hashing algorithm used by HAMT."""
 694
 695        if rpc_base_url is None:
 696            rpc_base_url = KuboCAS.KUBO_DEFAULT_LOCAL_RPC_BASE_URL  # pragma
 697
 698        if gateway_base_urls is not None:
 699            if gateway_base_url is not None:
 700                raise ValueError(
 701                    "gateway_base_url and gateway_base_urls are mutually "
 702                    "exclusive; pass every gateway in gateway_base_urls"
 703                )
 704            if not gateway_base_urls:
 705                raise ValueError("gateway_base_urls must not be empty")
 706            normalized = [_normalize_gateway_base_url(url) for url in gateway_base_urls]
 707            # Preserve caller order while dropping duplicates: a repeated
 708            # gateway would otherwise get several rotation slots and several
 709            # independent concurrency budgets pointed at one host.
 710            self.gateway_base_urls: list[str] = list(dict.fromkeys(normalized))
 711        else:
 712            if gateway_base_url is None:
 713                gateway_base_url = KuboCAS.KUBO_DEFAULT_LOCAL_GATEWAY_BASE_URL
 714            self.gateway_base_urls = [_normalize_gateway_base_url(gateway_base_url)]
 715
 716        pin_string: str = "true" if pin_on_add else "false"
 717        # cid-version=1 is required, not cosmetic. Kubo returns a CIDv0 for any
 718        # add that does not ask for v1, and a CIDv0 is dag-pb by definition --
 719        # so with sha2-256 (a CIDv0-representable hasher) the daemon would wrap
 720        # the payload in a UnixFS dag-pb node and hand back Qm... regardless of
 721        # the codec this store asked for. That breaks two things:
 722        #   * save() cannot relabel the CID's codec, because the stored block is
 723        #     the protobuf wrapper rather than the bytes passed in, so the digest
 724        #     would no longer match the block.
 725        #   * _cid_is_verifiable() declines to check dag-pb responses, so
 726        #     verify_content would silently pass every block through unverified.
 727        # Requesting v1 makes Kubo store the raw bytes under the codec asked
 728        # for, which is what blake3 (not CIDv0-representable) already got.
 729        self.rpc_url: str = f"{rpc_base_url}/api/v0/add?hash={self.hasher}&chunker={self.chunker}&pin={pin_string}&cid-version=1"
 730        """@private"""
 731        self.gateway_base_url: str = self.gateway_base_urls[0]
 732        """@private"""
 733
 734        # Origins the caller's credentials were configured for: the primary
 735        # gateway and the RPC endpoint. Reads to any other gateway drop
 736        # credentialed headers and client auth (see _load_from_gateway).
 737        self._credentialed_origins: set[tuple[str, str, int | None]] = {
 738            _origin_of(self.gateway_base_url),
 739            _origin_of(rpc_base_url),
 740        }
 741
 742        self.verify_content: bool = verify_content
 743        """@private"""
 744        # Health is per gateway but shared across event loops: a gateway that is
 745        # rate-limiting or down is doing so regardless of which loop observed it.
 746        self._gateway_health: Dict[str, _GatewayHealth] = {
 747            url: _GatewayHealth() for url in self.gateway_base_urls
 748        }
 749
 750        if client is not None:
 751            # Bind the user-supplied client lazily on first async use.
 752            self._owns_client = False
 753            self._supplied_client: httpx.AsyncClient | None = client
 754            self._user_client: httpx.AsyncClient | None = client
 755            self._default_headers: httpx.Headers | dict[str, str] | None = (
 756                httpx.Headers(client.headers)
 757            )
 758            self._default_auth: httpx.Auth | Tuple[str, str] | None = client.auth
 759            self._default_timeout: httpx.Timeout | float = client.timeout
 760            self._default_limits = self._copy_client_limits(client)
 761            self._default_follow_redirects: bool = client.follow_redirects
 762            # Snapshot the hooks like the headers above: later mutations of the
 763            # supplied client must not leak into fallback clients.
 764            self._default_event_hooks: dict[str, list[Callable[..., Any]]] | None = {
 765                event: list(hooks) for event, hooks in client.event_hooks.items()
 766            }
 767        else:
 768            # No client supplied. We will own any clients we create.
 769            self._owns_client = True
 770            self._supplied_client = None
 771            self._user_client = None
 772            self._default_headers = headers
 773            self._default_auth = auth
 774            self._default_timeout = 60.0
 775            self._default_limits = httpx.Limits(
 776                max_connections=64, max_keepalive_connections=32
 777            )
 778            self._default_follow_redirects = True
 779            self._default_event_hooks = None
 780        self._client_factory: Optional[Callable[[], httpx.AsyncClient]] = client_factory
 781
 782        if concurrency <= 0:
 783            raise ValueError("concurrency must be a positive integer")
 784        self._concurrency: int = concurrency
 785        self._closed = False
 786
 787        # Validate retry parameters
 788        if max_retries < 0:
 789            raise ValueError("max_retries must be non-negative")
 790        if initial_delay <= 0:
 791            raise ValueError("initial_delay must be positive")
 792        if backoff_factor < 1.0:
 793            raise ValueError("backoff_factor must be >= 1.0 for exponential backoff")
 794
 795        self.max_retries = max_retries
 796        self.initial_delay = initial_delay
 797        self.backoff_factor = backoff_factor
 798
 799    @staticmethod
 800    def _copy_client_limits(client: httpx.AsyncClient) -> httpx.Limits:
 801        """Copy connection limits from a standard HTTPX async transport.
 802
 803        HTTPX does not expose client limits publicly, so custom transports fall
 804        back to the limits KuboCAS uses for its own clients.
 805        """
 806        transport: Any = client._transport
 807        pool: Any = getattr(transport, "_pool", None)
 808        return httpx.Limits(
 809            max_connections=getattr(pool, "_max_connections", 64),
 810            max_keepalive_connections=getattr(pool, "_max_keepalive_connections", 32),
 811            keepalive_expiry=getattr(pool, "_keepalive_expiry", 5.0),
 812        )
 813
 814    # --------------------------------------------------------------------- #
 815    # helper: get or create the client bound to the current running loop    #
 816    # --------------------------------------------------------------------- #
 817    def _loop_semaphore(self) -> asyncio.Semaphore:
 818        """Get or create the concurrency semaphore for the running event loop.
 819
 820        Semaphores cannot be shared safely across event loops once contended,
 821        so their lifecycle mirrors the per-loop HTTP clients.
 822        """
 823        if self._closed:
 824            if not self._owns_client:
 825                raise RuntimeError("KuboCAS is closed; create a new instance")
 826            self._closed = False
 827            self._client_per_loop = {}
 828            self._internally_created_clients = set()
 829            self._semaphore_per_loop = {}
 830            self._gateway_semaphore_per_loop = {}
 831
 832        loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
 833        try:
 834            return self._semaphore_per_loop[loop]
 835        except KeyError:
 836            semaphore = asyncio.Semaphore(self._concurrency)
 837            self._semaphore_per_loop[loop] = semaphore
 838            return semaphore
 839
 840    def _gateway_semaphore(self, gateway_base_url: str) -> asyncio.Semaphore:
 841        """Get or create the concurrency semaphore for one gateway on this loop.
 842
 843        With a single gateway this is equivalent to ``_loop_semaphore``; with
 844        several it keeps each gateway's ``concurrency`` budget independent.
 845        """
 846        loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
 847        key = (loop, gateway_base_url)
 848        try:
 849            return self._gateway_semaphore_per_loop[key]
 850        except KeyError:
 851            semaphore = asyncio.Semaphore(self._concurrency)
 852            self._gateway_semaphore_per_loop[key] = semaphore
 853            return semaphore
 854
 855    def _ordered_gateways(self) -> list[str]:
 856        """Gateways to try, best first.
 857
 858        Ordered by health, then by whether a concurrency slot is free right now.
 859        The second key avoids head-of-line blocking: attempts wait on their
 860        gateway's semaphore, so without it a read queued behind a saturated
 861        gateway would stall even when an idle gateway could serve it
 862        immediately -- precisely the case multiple gateways exist to handle.
 863
 864        Deprioritizing rather than dropping unhealthy gateways means a run where
 865        every gateway has tripped still attempts them all instead of failing
 866        with nothing tried.
 867        """
 868        if len(self.gateway_base_urls) == 1:
 869            return self.gateway_base_urls
 870
 871        now = time.monotonic()
 872        # is_healthy() clears an expired trip, so evaluate it exactly once per
 873        # gateway rather than inside a sort key, which may call it repeatedly.
 874        ranks: Dict[str, tuple[int, int]] = {}
 875        for url in self.gateway_base_urls:
 876            unhealthy = 0 if self._gateway_health[url].is_healthy(now) else 1
 877            busy = 1 if self._gateway_semaphore(url).locked() else 0
 878            ranks[url] = (unhealthy, busy)
 879
 880        # Stable sort, so configured order breaks ties within a rank.
 881        return sorted(self.gateway_base_urls, key=lambda url: ranks[url])
 882
 883    def _loop_client(self) -> httpx.AsyncClient:
 884        """Get or create a client for the current event loop.
 885
 886        A user-supplied client is bound to the first loop that requests it.
 887        If the instance was previously closed but owns its clients, a fresh
 888        client mapping is lazily created on demand. Users that supplied their
 889        own ``httpx.AsyncClient`` still receive an error when the instance has
 890        been closed, as we cannot safely recreate their client. Internally
 891        created clients enable HTTP/2 negotiation for HTTPS endpoints.
 892        """
 893        if self._closed:
 894            if not self._owns_client:
 895                raise RuntimeError("KuboCAS is closed; create a new instance")
 896            # We previously closed all internally-owned clients. Reset the
 897            # state so that new clients can be created lazily.
 898            self._closed = False
 899            self._client_per_loop = {}
 900            self._semaphore_per_loop = {}
 901            self._gateway_semaphore_per_loop = {}
 902
 903        loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
 904        try:
 905            return self._client_per_loop[loop]
 906        except KeyError:
 907            # First use on this loop. Hold the lock across supplied-client
 908            # consumption, client creation, and the per-loop assignment so two
 909            # loops racing on different threads cannot bind the same client.
 910            with self._first_use_lock:
 911                if self._supplied_client is not None:
 912                    client = self._supplied_client
 913                    self._supplied_client = None
 914                elif self._client_factory is not None:
 915                    client = self._client_factory()
 916                    self._internally_created_clients.add(client)
 917                else:
 918                    if self._user_client is not None:
 919                        warnings.warn(
 920                            "A user-supplied httpx.AsyncClient cannot be reused "
 921                            "across event loops; falling back to an internally "
 922                            "created client that preserves only headers, auth, "
 923                            "timeout, limits, redirect policy, and event hooks. "
 924                            "Pass client_factory to preserve full configuration.",
 925                            RuntimeWarning,
 926                            stacklevel=2,
 927                        )
 928                    client = httpx.AsyncClient(
 929                        timeout=self._default_timeout,
 930                        headers=self._default_headers,
 931                        auth=self._default_auth,
 932                        limits=self._default_limits,
 933                        follow_redirects=self._default_follow_redirects,
 934                        event_hooks=self._default_event_hooks,
 935                        http2=True,
 936                    )
 937                    self._internally_created_clients.add(client)
 938                self._client_per_loop[loop] = client
 939                return client
 940
 941    # --------------------------------------------------------------------- #
 942    # graceful shutdown: close **all** clients we own                       #
 943    # --------------------------------------------------------------------- #
 944    async def aclose(self) -> None:
 945        """
 946        Close every internally-created client, leaving a supplied client open.
 947
 948        Must be called from an async context.
 949
 950        For clients owned by closed loops with stock async-only transports,
 951        cleanup degenerates to a warning. The OS-level socket is shut down
 952        with a FIN, but its local file descriptor is released at garbage
 953        collection. Callers that require deterministic release should call
 954        ``aclose()`` on the owning loop before it exits.
 955        """
 956        try:
 957            current_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
 958        except RuntimeError:
 959            current_loop = None
 960
 961        for owner_loop, client in list(self._client_per_loop.items()):
 962            if client not in self._internally_created_clients:
 963                continue
 964
 965            try:
 966                if owner_loop is current_loop:
 967                    await client.aclose()
 968                    continue
 969
 970                if not owner_loop.is_closed():
 971                    if owner_loop.is_running():
 972                        close_future = asyncio.run_coroutine_threadsafe(
 973                            client.aclose(), owner_loop
 974                        )
 975                        try:
 976                            await asyncio.wait_for(
 977                                asyncio.wrap_future(close_future),
 978                                timeout=_CROSS_LOOP_ACLOSE_TIMEOUT_S,
 979                            )
 980                        except TimeoutError:
 981                            # The owner loop stopped (or stalled) after
 982                            # is_running() succeeded, so the scheduled close can
 983                            # never complete. Cancel it and fall through to the
 984                            # synchronous transport shutdown below.
 985                            close_future.cancel()
 986                        else:
 987                            continue
 988                    else:
 989                        await asyncio.to_thread(
 990                            _close_client_on_stopped_loop, owner_loop, client
 991                        )
 992                        continue
 993
 994                # AsyncClient marks itself closed before awaiting its transport.
 995                # A dead owner loop therefore needs the transport's sync fallback.
 996                transport: Any = client._transport
 997                close_transport = getattr(transport, "close", None)
 998                if close_transport is None:
 999                    await client.aclose()
1000                    continue
1001
1002                close_transport()
1003                try:
1004                    await client.aclose()
1005                except Exception:
1006                    pass  # The transport was already closed synchronously.
1007            except Exception as exc:
1008                warnings.warn(
1009                    f"Failed to close an internally created HTTP client: {exc}",
1010                    RuntimeWarning,
1011                    stacklevel=2,
1012                )
1013
1014        self._client_per_loop.clear()
1015        self._internally_created_clients.clear()
1016        self._semaphore_per_loop.clear()
1017        self._gateway_semaphore_per_loop.clear()
1018        self._closed = True
1019
1020    # At this point, _client_per_loop should be empty or only contain
1021    # clients from loops we haven't seen (which shouldn't happen in practice)
1022    async def __aenter__(self) -> "KuboCAS":
1023        return self
1024
1025    async def __aexit__(self, *exc: Any) -> None:
1026        await self.aclose()
1027
1028    def __del__(self) -> None:
1029        """Best-effort close for internally-created clients."""
1030        if not hasattr(self, "_owns_client") or not hasattr(self, "_closed"):
1031            return
1032
1033        if (
1034            not self._owns_client
1035            and not getattr(self, "_internally_created_clients", set())
1036        ) or self._closed:
1037            return
1038
1039        # Attempt proper cleanup if possible
1040        try:
1041            loop = asyncio.get_running_loop()
1042        except RuntimeError:
1043            # No running loop - can't do async cleanup
1044            # Just clear the client references synchronously
1045            if hasattr(self, "_client_per_loop"):
1046                # We can't await client.aclose() without a loop,
1047                # so just clear the references
1048                self._client_per_loop.clear()
1049                self._semaphore_per_loop.clear()
1050                self._gateway_semaphore_per_loop.clear()
1051                self._closed = True
1052            return
1053
1054        # If we get here, we have a running loop
1055        try:
1056            if loop.is_running():
1057                # Schedule cleanup in the existing loop
1058                loop.create_task(self.aclose())
1059            else:
1060                # Loop exists but not running - try asyncio.run
1061                coro = self.aclose()  # Create the coroutine
1062                try:
1063                    asyncio.run(coro)
1064                except Exception:
1065                    # If asyncio.run fails, we need to close the coroutine properly
1066                    coro.close()  # This prevents the RuntimeWarning
1067                    raise  # Re-raise to hit the outer except block
1068        except Exception:
1069            # If all else fails, just clear references
1070            if hasattr(self, "_client_per_loop"):
1071                self._client_per_loop.clear()
1072                self._semaphore_per_loop.clear()
1073                self._gateway_semaphore_per_loop.clear()
1074                self._closed = True
1075
1076    # --------------------------------------------------------------------- #
1077    # save() - now uses the per-loop client                                 #
1078    # --------------------------------------------------------------------- #
1079    async def save(self, data: bytes, codec: ContentAddressedStore.CodecInput) -> CID:
1080        """Add data to Kubo and return its CID.
1081
1082        Transient request failures and gateway statuses are retried. Retrying
1083        the ``/api/v0/add`` POST is safe because the uploaded content is
1084        content-addressed, making repeated additions idempotent. Concurrency
1085        slots are held per HTTP attempt and released during retry backoff.
1086        """
1087        files = {"file": data}
1088        client = self._loop_client()
1089        semaphore = self._loop_semaphore()
1090        retry_count = 0
1091
1092        while retry_count <= self.max_retries:
1093            try:
1094                async with semaphore:
1095                    response = await client.post(self.rpc_url, files=files)
1096                response.raise_for_status()
1097                cid_str: str = response.json()["Hash"]
1098                cid: CID = CID.decode(cid_str)
1099                if cid.codec.code != self.DAG_PB_MARKER:
1100                    cid = cid.set(codec=codec)
1101                elif self.verify_content:
1102                    # Kubo splits payloads larger than ``chunker`` into a UnixFS
1103                    # dag-pb tree, so the root block is the protobuf node rather
1104                    # than the bytes handed in. The requested codec cannot be
1105                    # applied (the digest would stop matching the block), and
1106                    # _cid_is_verifiable() skips dag-pb, so verify_content
1107                    # silently does nothing for this object. Warn rather than
1108                    # fail: the data still round-trips correctly, and the
1109                    # threshold depends on the caller's chunker setting.
1110                    warnings.warn(
1111                        f"Saved {len(data)} bytes exceeded the '{self.chunker}' "
1112                        f"chunker, so Kubo returned a dag-pb CID ({cid}). "
1113                        "Content verification is not possible for this object; "
1114                        "raise the chunker size to keep payloads in one block.",
1115                        RuntimeWarning,
1116                        stacklevel=2,
1117                    )
1118                return cid
1119
1120            except httpx.RequestError:
1121                if retry_count >= self.max_retries:
1122                    raise
1123                retry_count += 1
1124                await asyncio.sleep(
1125                    _retry_delay(self.initial_delay, self.backoff_factor, retry_count)
1126                )
1127
1128            except httpx.HTTPStatusError as error:
1129                if error.response.status_code not in _RETRYABLE_STATUS_CODES:
1130                    raise
1131                if retry_count >= self.max_retries:
1132                    raise
1133                retry_count += 1
1134                await asyncio.sleep(
1135                    _retry_delay(
1136                        self.initial_delay,
1137                        self.backoff_factor,
1138                        retry_count,
1139                        error.response,
1140                    )
1141                )
1142        raise RuntimeError("Exited the retry loop unexpectedly.")  # pragma: no cover
1143
1144    def _request_headers_for(
1145        self, client: httpx.AsyncClient, url: str, headers: Dict[str, str]
1146    ) -> Tuple[Dict[str, str], bool]:
1147        """Build headers for ``url``, dropping credentials on a foreign origin.
1148
1149        Returns the headers and whether credentials were withheld (which also
1150        means client-level ``auth`` must be suppressed).
1151
1152        httpx merges client-level headers into every request and offers no way
1153        to drop one per-request: ``Client._merge_headers`` starts from
1154        ``self.headers`` and only ``update()``s, so an omitted or blanked entry
1155        is reinstated. Building the ``Request`` explicitly is the only reliable
1156        way to withhold a credential.
1157        """
1158        # Read from .raw: httpx.Headers.items() lower-cases names, and building
1159        # a Request from that would silently rewrite every outgoing header name
1160        # compared with the client.get() path this replaces.
1161        client_headers = [
1162            (name.decode("latin-1"), value.decode("latin-1"))
1163            for name, value in client.headers.raw
1164        ]
1165
1166        if _origin_of(url) in self._credentialed_origins:
1167            merged = dict(client_headers)
1168            merged.update(headers)
1169            return merged, False
1170
1171        safe_headers = {
1172            name: value
1173            for name, value in client_headers
1174            if name.lower() in _FORWARDABLE_HEADERS
1175        }
1176        # Range headers are computed by load() for this request, never
1177        # caller-supplied credentials, so they are always safe to send.
1178        safe_headers.update(headers)
1179        return safe_headers, True
1180
1181    async def _get_with_origin_scoped_credentials(
1182        self, client: httpx.AsyncClient, url: str, headers: Dict[str, str]
1183    ) -> httpx.Response:
1184        """GET ``url``, re-deciding credential scope at every redirect hop.
1185
1186        Redirects are followed manually because httpx's own redirect handling
1187        strips only ``Authorization`` and ``Cookie`` when crossing origins (see
1188        ``Client._redirect_headers``). Custom credentials -- ``X-API-Key`` and
1189        friends, which ``KuboCAS`` documents as a supported way to
1190        authenticate -- survive its stripping, so a gateway could redirect to an
1191        origin of its choosing and harvest them. Following each hop ourselves
1192        re-applies the full origin check to the *redirect target*.
1193        """
1194        if not _carries_credentials(client):
1195            # Nothing to withhold: no credentialed header and no client auth, so
1196            # no origin can harvest anything by redirecting. Keep the plain
1197            # client.get() path, which preserves httpx's own redirect, auth, and
1198            # header-casing behaviour for the overwhelmingly common case.
1199            return await client.get(url, headers=headers or None)
1200
1201        redirects_remaining = client.max_redirects if client.follow_redirects else 0
1202        current_url = url
1203
1204        while True:
1205            request_headers, stripped = self._request_headers_for(
1206                client, current_url, headers
1207            )
1208            # auth=None also suppresses client-level httpx.Auth, which would
1209            # otherwise re-add an Authorization header after our filtering.
1210            response = await client.send(
1211                httpx.Request("GET", current_url, headers=request_headers),
1212                auth=None if stripped else httpx.USE_CLIENT_DEFAULT,
1213                follow_redirects=False,
1214            )
1215
1216            location = response.headers.get("Location")
1217            if not (response.is_redirect and location):
1218                return response
1219            if redirects_remaining <= 0:
1220                # Mirror httpx's own behaviour rather than silently returning
1221                # the 3xx as if it were the block.
1222                raise httpx.TooManyRedirects(
1223                    "Exceeded maximum allowed redirects.", request=response.request
1224                )
1225
1226            redirects_remaining -= 1
1227            await response.aread()
1228            await response.aclose()
1229            current_url = str(response.url.join(location))
1230
1231    async def _load_from_gateway(
1232        self,
1233        gateway_base_url: str,
1234        cid: CID,
1235        headers: Dict[str, str],
1236        offset: Optional[int],
1237        length: Optional[int],
1238        suffix: Optional[int],
1239        stats: "_LoadStats",
1240    ) -> bytes:
1241        """Fetch ``cid`` from one gateway, retrying that gateway's transients.
1242
1243        Raises on failure so the caller can fail over. ``stats`` accumulates the
1244        byte count and retry total across every gateway attempted, so the trace
1245        emitted by ``load`` reflects the whole operation rather than the last leg.
1246
1247        Credentials configured for the primary gateway or the RPC endpoint are
1248        stripped when this gateway is on a different origin, so failing over to
1249        a public fallback cannot disclose a private gateway's token.
1250        """
1251        url = f"{gateway_base_url}{cid}"
1252        client = self._loop_client()
1253        semaphore = self._gateway_semaphore(gateway_base_url)
1254        retry_count = 0
1255
1256        while retry_count <= self.max_retries:
1257            try:
1258                async with semaphore:  # Throttle each gateway attempt
1259                    response = await self._get_with_origin_scoped_credentials(
1260                        client, url, headers
1261                    )
1262                # An unsatisfiable range is answered with 416 by a compliant
1263                # gateway; return b"" to match Python-slice semantics (and
1264                # InMemoryCAS) instead of raising.
1265                if (
1266                    response.status_code == httpx.codes.REQUESTED_RANGE_NOT_SATISFIABLE
1267                    and _range_not_satisfiable_is_empty(response, offset, suffix)
1268                ):
1269                    return b""
1270                response.raise_for_status()
1271                content = response.content
1272                stats.response_bytes = len(content)
1273                if headers:
1274                    if response.status_code == httpx.codes.OK:
1275                        logger.debug(
1276                            "Gateway ignored Range request for CID %s; "
1277                            "slicing the complete response locally",
1278                            cid,
1279                        )
1280                        return _slice_requested_range(content, offset, length, suffix)
1281                    if response.status_code == httpx.codes.PARTIAL_CONTENT:
1282                        # Trust the partial body only after proving its
1283                        # Content-Range matches the requested byte window.
1284                        _validate_partial_content(
1285                            response, offset, length, suffix, stats.response_bytes
1286                        )
1287                        return content
1288                    # Any other 2xx to a Range request is unexpected: we
1289                    # cannot know which bytes it carries, so fail rather than
1290                    # return a possibly-wrong window.
1291                    raise httpx.HTTPStatusError(
1292                        f"unexpected {response.status_code} response to a "
1293                        "Range request",
1294                        request=response.request,
1295                        response=response,
1296                    )
1297                if self.verify_content and _cid_is_verifiable(cid, offset, suffix):
1298                    # A mismatch means this gateway served wrong bytes. Raising
1299                    # here routes it through the caller's failover path like any
1300                    # other per-gateway failure.
1301                    try:
1302                        _verify_cid_content(cid, content)
1303                    except GatewayContentMismatch:
1304                        stats.status = "content_mismatch"
1305                        raise
1306                return content
1307
1308            except httpx.RequestError:
1309                if retry_count >= self.max_retries:
1310                    stats.status = "request_error"
1311                    raise
1312                retry_count += 1
1313                stats.retries += 1
1314                await asyncio.sleep(
1315                    _retry_delay(self.initial_delay, self.backoff_factor, retry_count)
1316                )
1317
1318            except httpx.HTTPStatusError as error:
1319                if (
1320                    error.response.status_code not in _RETRYABLE_STATUS_CODES
1321                    or retry_count >= self.max_retries
1322                ):
1323                    stats.status = "http_error"
1324                    raise
1325                retry_count += 1
1326                stats.retries += 1
1327                await asyncio.sleep(
1328                    _retry_delay(
1329                        self.initial_delay,
1330                        self.backoff_factor,
1331                        retry_count,
1332                        error.response,
1333                    )
1334                )
1335        raise RuntimeError("Exited the retry loop unexpectedly.")  # pragma: no cover
1336
1337    async def load(
1338        self,
1339        id: IPLDKind,
1340        offset: Optional[int] = None,
1341        length: Optional[int] = None,
1342        suffix: Optional[int] = None,
1343    ) -> bytes:
1344        """Load all or part of a CID using the IPFS gateway.
1345
1346        Gateways that ignore a Range header and return a complete ``200`` body
1347        are handled by applying the requested byte window locally. Transient
1348        request failures, rate limits, and gateway server errors are retried;
1349        other HTTP errors fail immediately. Zero-length and zero-suffix reads
1350        return immediately without a gateway request. Concurrency slots are
1351        held per HTTP attempt and released during retry backoff.
1352
1353        When several gateways are configured, each is tried in turn -- healthy
1354        ones first -- until one succeeds. Requests are *not* raced in parallel:
1355        fanning every read out to every gateway would multiply egress and burn
1356        each gateway's rate-limit budget N times over, which is the opposite of
1357        what helps when rate limiting is the problem being solved. A gateway
1358        that fails ``_GATEWAY_FAILURE_THRESHOLD`` times consecutively is moved
1359        to the back of the rotation for a cooldown. If every gateway fails, the
1360        collected errors are raised together as an ``ExceptionGroup``.
1361        """
1362        if (offset is not None and length == 0) or (offset is None and suffix == 0):
1363            return b""
1364
1365        cid = cast(CID, id)
1366        headers: Dict[str, str] = {}
1367
1368        # Construct the Range header if required
1369        if offset is not None:
1370            start = offset
1371            if length is not None:
1372                # Standard HTTP Range: bytes=start-end (inclusive)
1373                end = start + length - 1
1374                headers["Range"] = f"bytes={start}-{end}"
1375            else:
1376                # Standard HTTP Range: bytes=start- (from start to end)
1377                headers["Range"] = f"bytes={start}-"
1378        elif suffix is not None:
1379            # Standard HTTP Range: bytes=-N (last N bytes)
1380            headers["Range"] = f"bytes=-{suffix}"
1381
1382        trace_started_at = instrumentation.begin_cas_load(cid, bool(headers))
1383        stats = _LoadStats()
1384        gateways = self._ordered_gateways()
1385        failures: list[Exception] = []
1386        try:
1387            for gateway_base_url in gateways:
1388                health = self._gateway_health[gateway_base_url]
1389                try:
1390                    content = await self._load_from_gateway(
1391                        gateway_base_url, cid, headers, offset, length, suffix, stats
1392                    )
1393                except (httpx.HTTPError, GatewayContentMismatch) as error:
1394                    health.record_failure(time.monotonic())
1395                    failures.append(error)
1396                    if len(gateways) > 1:
1397                        logger.debug(
1398                            "Gateway %s failed for CID %s (%s); trying the next one",
1399                            gateway_base_url,
1400                            cid,
1401                            error,
1402                        )
1403                    continue
1404                else:
1405                    health.record_success()
1406                    # A gateway leg may have set a failure status before a later
1407                    # gateway succeeded; the operation as a whole is a success.
1408                    stats.status = "ok"
1409                    return content
1410
1411            # Every gateway failed. With one configured, re-raise its error
1412            # unchanged so existing single-gateway callers keep seeing the exact
1413            # httpx exception type they handle today.
1414            if len(failures) == 1:
1415                raise failures[0]
1416            raise ExceptionGroup(
1417                f"all {len(gateways)} gateways failed for CID {cid}", failures
1418            )
1419        finally:
1420            instrumentation.end_cas_load(
1421                trace_started_at,
1422                byte_count=stats.response_bytes,
1423                retries=stats.retries,
1424                status=stats.status,
1425            )
1426
1427    # --------------------------------------------------------------------- #
1428    # pin_cid() - method to pin a CID                                       #
1429    # --------------------------------------------------------------------- #
1430    async def pin_cid(
1431        self,
1432        cid: CID,
1433        target_rpc: str = "http://127.0.0.1:5001",
1434    ) -> None:
1435        """
1436        Pins a CID to the local Kubo node via the RPC API.
1437
1438        This call is recursive by default, pinning all linked objects.
1439
1440        Args:
1441            cid (CID): The Content ID to pin.
1442            target_rpc (str): The RPC URL of the Kubo node.
1443        """
1444        params = {"arg": str(cid), "recursive": "true"}
1445        pin_add_url_base: str = f"{target_rpc}/api/v0/pin/add"
1446
1447        async with self._loop_semaphore():  # throttle RPC
1448            client = self._loop_client()
1449            response = await client.post(pin_add_url_base, params=params)
1450            response.raise_for_status()
1451
1452    async def unpin_cid(
1453        self, cid: CID, target_rpc: str = "http://127.0.0.1:5001"
1454    ) -> None:
1455        """
1456        Unpins a CID from the local Kubo node via the RPC API.
1457
1458        Args:
1459            cid (CID): The Content ID to unpin.
1460        """
1461        params = {"arg": str(cid), "recursive": "true"}
1462        unpin_url_base: str = f"{target_rpc}/api/v0/pin/rm"
1463        async with self._loop_semaphore():  # throttle RPC
1464            client = self._loop_client()
1465            response = await client.post(unpin_url_base, params=params)
1466            response.raise_for_status()
1467
1468    async def pin_update(
1469        self,
1470        old_id: IPLDKind,
1471        new_id: IPLDKind,
1472        target_rpc: str = "http://127.0.0.1:5001",
1473    ) -> None:
1474        """
1475        Updates the pinned CID in the storage.
1476
1477        Args:
1478            old_id (IPLDKind): The old Content ID to replace.
1479            new_id (IPLDKind): The new Content ID to pin.
1480        """
1481        params = {"arg": [str(old_id), str(new_id)]}
1482        pin_update_url_base: str = f"{target_rpc}/api/v0/pin/update"
1483        async with self._loop_semaphore():  # throttle RPC
1484            client = self._loop_client()
1485            response = await client.post(pin_update_url_base, params=params)
1486            response.raise_for_status()
1487
1488    async def pin_ls(
1489        self, target_rpc: str = "http://127.0.0.1:5001"
1490    ) -> list[Dict[str, Any]]:
1491        """
1492        Lists all pinned CIDs on the local Kubo node via the RPC API.
1493
1494        Args:
1495            target_rpc (str): The RPC URL of the Kubo node.
1496
1497        Returns:
1498            List[CID]: A list of pinned CIDs.
1499        """
1500        pin_ls_url_base: str = f"{target_rpc}/api/v0/pin/ls"
1501        async with self._loop_semaphore():  # throttle RPC
1502            client = self._loop_client()
1503            response = await client.post(pin_ls_url_base)
1504            response.raise_for_status()
1505            pins = response.json().get("Keys", [])
1506            return pins

Connects to an IPFS Kubo daemon.

The IDs in save and load are IPLD CIDs.

  • save() → RPC (/api/v0/add)
  • load() → HTTP gateway (/ipfs/{cid})

save uses the RPC API and load uses the HTTP Gateway. This means that read-only HAMTs will only access the HTTP Gateway, so no RPC endpoint is required for use.

Authentication / custom headers

You have two options:

  1. Bring your own httpx.AsyncClient or client factory Pass a client via client=... for use on one event loop, or pass client_factory=... to build a fully configured client for each event loop. Reusing a supplied client from a later loop emits a warning and falls back to an internal client that preserves only headers, auth, timeout, redirect policy, and event hooks.
  2. Let KuboCAS build the client but pass headers= and/or auth= kwargs; they are forwarded to the internally-created AsyncClient.
import httpx
from py_hamt import KuboCAS

# Option 1: user-supplied client
client = httpx.AsyncClient(
    headers={"Authorization": "Bearer <token>"},
    auth=("user", "pass"),
    follow_redirects=True,
)
cas = KuboCAS(client=client)

# Option 2: let KuboCAS create the client
cas = KuboCAS(
    headers={"X-My-Header": "yes"},
    auth=("user", "pass"),
)

Parameters

  • hasher (str): multihash name (defaults to blake3).
  • client (httpx.AsyncClient | None): reuse an existing client and its configured timeout and redirect policy. User-supplied clients should set follow_redirects=True when gateways may redirect. If None, KuboCAS will create one lazily with a 60-second timeout and redirect following and HTTP/2 enabled. Plaintext endpoints continue to use HTTP/1.1 because HTTP/2 negotiation requires TLS/ALPN.
  • client_factory (Callable[[], httpx.AsyncClient] | None): create a separate, fully configured client for each event loop. KuboCAS owns and closes clients returned by the factory. Mutually exclusive with client.
  • headers (dict[str, str] | None): default headers for the internally-created client.
  • auth (tuple[str, str] | None): authentication tuple (username, password) for the internally-created client.
  • rpc_base_url / gateway_base_url (str | None): override daemon endpoints (defaults match the local daemon ports). Gateway URLs may end with /ipfs and may include a trailing slash.
  • gateway_base_urls (list[str] | None): read from several gateways with automatic failover. Mutually exclusive with gateway_base_url. Each read tries one gateway at a time, healthy gateways first, until one succeeds; requests are not raced in parallel. A gateway that fails three times in a row is moved to the back of the rotation for 30 seconds and then probed again. concurrency applies per gateway. If every gateway fails, an ExceptionGroup of the underlying errors is raised.
  • verify_content (bool): check that returned bytes hash to the requested CID, raising GatewayContentMismatch (and failing over to the next gateway) when they do not. Worth enabling when reading from public gateways you do not control. Only full-body reads of non-dag-pb CIDs can be verified; Range reads and dag-pb reads are passed through unchecked because neither returns the exact bytes the CID commits to. Requests to a gateway outside the origin of gateway_base_url/rpc_base_url forward only content-negotiation headers (Accept, Accept-Encoding, Accept-Language, User-Agent) and drop client-level auth. Because any header name may carry a credential, everything else is withheld -- so a private primary can safely be paired with public fallbacks, but a foreign gateway that needs its own custom header will not receive one.
  • chunker (str): chunking algorithm specification for Kubo's add RPC. Accepted formats are "size-<positive int>", "rabin", or "rabin-<min>-<avg>-<max>".

...

KuboCAS( hasher: str = 'blake3', client: httpx.AsyncClient | None = None, rpc_base_url: str | None = None, gateway_base_url: str | None = None, concurrency: int = 32, *, gateway_base_urls: list[str] | None = None, verify_content: bool = False, client_factory: Optional[Callable[[], httpx.AsyncClient]] = None, headers: dict[str, str] | None = None, auth: Optional[Tuple[str, str]] = None, pin_on_add: bool = False, chunker: str = 'size-1048576', max_retries: int = 3, initial_delay: float = 1.0, backoff_factor: float = 2.0)
593    def __init__(
594        self,
595        hasher: str = "blake3",
596        client: httpx.AsyncClient | None = None,
597        rpc_base_url: str | None = None,
598        gateway_base_url: str | None = None,
599        concurrency: int = 32,
600        *,
601        gateway_base_urls: list[str] | None = None,
602        verify_content: bool = False,
603        client_factory: Optional[Callable[[], httpx.AsyncClient]] = None,
604        headers: dict[str, str] | None = None,
605        auth: Tuple[str, str] | None = None,
606        pin_on_add: bool = False,
607        chunker: str = "size-1048576",
608        max_retries: int = 3,
609        initial_delay: float = 1.0,
610        backoff_factor: float = 2.0,
611    ):
612        """
613        If None is passed into the rpc or gateway base url, then the default for kubo local daemons will be used. The default local values will also be used if nothing is passed in at all.
614
615        ### `httpx.AsyncClient` Management
616        If `client` is not provided, it will be automatically initialized. It is the responsibility of the user to close this at an appropriate time, using `await cas.aclose()`
617        as a class instance cannot know when it will no longer be in use, unless explicitly told to do so.
618
619        A supplied client is associated with the running event loop lazily on
620        first use, so constructing ``KuboCAS`` does not require an async
621        context. On a later event loop, KuboCAS warns and uses an internally
622        created fallback that preserves only the supplied client's headers,
623        auth, timeout, redirect policy, and event hooks. Pass
624        ``client_factory`` instead when every event loop needs the client's
625        full configuration. Factory clients are owned and closed by KuboCAS.
626        Clients created internally by ``KuboCAS`` use a 60-second timeout,
627        follow redirects, and negotiate HTTP/2 for HTTPS endpoints that
628        support it.
629
630        If you are using the `KuboCAS` instance in an `async with` block, it will automatically close the client when the block is exited which is what we suggest below:
631        ```python
632        async with httpx.AsyncClient() as client, KuboCAS(
633            rpc_base_url=rpc_base_url,
634            gateway_base_url=gateway_base_url,
635            client=client,
636        ) as kubo_cas:
637            hamt = await HAMT.build(cas=kubo_cas, values_are_bytes=True)
638            zhs = ZarrHAMTStore(hamt)
639            # Use the KuboCAS instance as needed
640            # ...
641        ```
642        As mentioned, if you do not use the `async with` syntax, you should call `await cas.aclose()` when you are done using the instance to ensure that all resources are cleaned up.
643        ``` python
644        cas = KuboCAS(rpc_base_url=rpc_base_url, gateway_base_url=gateway_base_url)
645        # Use the KuboCAS instance as needed
646        # ...
647        await cas.aclose()  # Ensure resources are cleaned up
648        ```
649
650        ### Authenticated RPC/Gateway Access
651        Users can set whatever headers and auth credentials they need if they are connecting to an authenticated kubo instance by setting them in their own `httpx.AsyncClient` and then passing that in.
652        Alternatively, they can pass in `headers` and `auth` parameters to the constructor, which will be used to create a new `httpx.AsyncClient` if one is not provided.
653        If you do not need authentication, you can leave these parameters as `None`.
654
655        ### RPC and HTTP Gateway Base URLs
656        These are the first part of the url, defaults that refer to the default that kubo launches with on a local machine are provided.
657        """
658
659        if client is not None and client_factory is not None:
660            raise ValueError("client and client_factory are mutually exclusive")
661        if client_factory is not None and (headers is not None or auth is not None):
662            raise ValueError(
663                "client_factory is mutually exclusive with headers/auth; "
664                "configure them on the clients the factory builds"
665            )
666
667        self._owns_client: bool = False
668        self._closed: bool = True
669        self._client_per_loop: Dict[asyncio.AbstractEventLoop, httpx.AsyncClient] = {}
670        self._internally_created_clients: set[httpx.AsyncClient] = set()
671        # Serializes first-use client binding so concurrent event loops on
672        # different threads cannot both consume ``_supplied_client`` and bind
673        # one httpx.AsyncClient to two loops.
674        self._first_use_lock: threading.Lock = threading.Lock()
675        self._semaphore_per_loop: Dict[
676            asyncio.AbstractEventLoop, asyncio.Semaphore
677        ] = {}
678        # Gateway reads get a semaphore per (loop, gateway) so ``concurrency``
679        # means "in-flight requests per gateway". Sharing one budget across
680        # gateways would divide effective parallelism by the gateway count and
681        # let a slow gateway starve the healthy ones of slots.
682        self._gateway_semaphore_per_loop: Dict[
683            Tuple[asyncio.AbstractEventLoop, str], asyncio.Semaphore
684        ] = {}
685
686        # Now, perform validation that might raise an exception
687        chunker_pattern = r"(?:size-[1-9]\d*|rabin(?:-[1-9]\d*-[1-9]\d*-[1-9]\d*)?)"
688        if re.fullmatch(chunker_pattern, chunker) is None:
689            raise ValueError("Invalid chunker specification")
690        self.chunker: str = chunker
691
692        self.hasher: str = hasher
693        """The hash function to send to IPFS when storing bytes. Cannot be changed after initialization. The default blake3 follows the default hashing algorithm used by HAMT."""
694
695        if rpc_base_url is None:
696            rpc_base_url = KuboCAS.KUBO_DEFAULT_LOCAL_RPC_BASE_URL  # pragma
697
698        if gateway_base_urls is not None:
699            if gateway_base_url is not None:
700                raise ValueError(
701                    "gateway_base_url and gateway_base_urls are mutually "
702                    "exclusive; pass every gateway in gateway_base_urls"
703                )
704            if not gateway_base_urls:
705                raise ValueError("gateway_base_urls must not be empty")
706            normalized = [_normalize_gateway_base_url(url) for url in gateway_base_urls]
707            # Preserve caller order while dropping duplicates: a repeated
708            # gateway would otherwise get several rotation slots and several
709            # independent concurrency budgets pointed at one host.
710            self.gateway_base_urls: list[str] = list(dict.fromkeys(normalized))
711        else:
712            if gateway_base_url is None:
713                gateway_base_url = KuboCAS.KUBO_DEFAULT_LOCAL_GATEWAY_BASE_URL
714            self.gateway_base_urls = [_normalize_gateway_base_url(gateway_base_url)]
715
716        pin_string: str = "true" if pin_on_add else "false"
717        # cid-version=1 is required, not cosmetic. Kubo returns a CIDv0 for any
718        # add that does not ask for v1, and a CIDv0 is dag-pb by definition --
719        # so with sha2-256 (a CIDv0-representable hasher) the daemon would wrap
720        # the payload in a UnixFS dag-pb node and hand back Qm... regardless of
721        # the codec this store asked for. That breaks two things:
722        #   * save() cannot relabel the CID's codec, because the stored block is
723        #     the protobuf wrapper rather than the bytes passed in, so the digest
724        #     would no longer match the block.
725        #   * _cid_is_verifiable() declines to check dag-pb responses, so
726        #     verify_content would silently pass every block through unverified.
727        # Requesting v1 makes Kubo store the raw bytes under the codec asked
728        # for, which is what blake3 (not CIDv0-representable) already got.
729        self.rpc_url: str = f"{rpc_base_url}/api/v0/add?hash={self.hasher}&chunker={self.chunker}&pin={pin_string}&cid-version=1"
730        """@private"""
731        self.gateway_base_url: str = self.gateway_base_urls[0]
732        """@private"""
733
734        # Origins the caller's credentials were configured for: the primary
735        # gateway and the RPC endpoint. Reads to any other gateway drop
736        # credentialed headers and client auth (see _load_from_gateway).
737        self._credentialed_origins: set[tuple[str, str, int | None]] = {
738            _origin_of(self.gateway_base_url),
739            _origin_of(rpc_base_url),
740        }
741
742        self.verify_content: bool = verify_content
743        """@private"""
744        # Health is per gateway but shared across event loops: a gateway that is
745        # rate-limiting or down is doing so regardless of which loop observed it.
746        self._gateway_health: Dict[str, _GatewayHealth] = {
747            url: _GatewayHealth() for url in self.gateway_base_urls
748        }
749
750        if client is not None:
751            # Bind the user-supplied client lazily on first async use.
752            self._owns_client = False
753            self._supplied_client: httpx.AsyncClient | None = client
754            self._user_client: httpx.AsyncClient | None = client
755            self._default_headers: httpx.Headers | dict[str, str] | None = (
756                httpx.Headers(client.headers)
757            )
758            self._default_auth: httpx.Auth | Tuple[str, str] | None = client.auth
759            self._default_timeout: httpx.Timeout | float = client.timeout
760            self._default_limits = self._copy_client_limits(client)
761            self._default_follow_redirects: bool = client.follow_redirects
762            # Snapshot the hooks like the headers above: later mutations of the
763            # supplied client must not leak into fallback clients.
764            self._default_event_hooks: dict[str, list[Callable[..., Any]]] | None = {
765                event: list(hooks) for event, hooks in client.event_hooks.items()
766            }
767        else:
768            # No client supplied. We will own any clients we create.
769            self._owns_client = True
770            self._supplied_client = None
771            self._user_client = None
772            self._default_headers = headers
773            self._default_auth = auth
774            self._default_timeout = 60.0
775            self._default_limits = httpx.Limits(
776                max_connections=64, max_keepalive_connections=32
777            )
778            self._default_follow_redirects = True
779            self._default_event_hooks = None
780        self._client_factory: Optional[Callable[[], httpx.AsyncClient]] = client_factory
781
782        if concurrency <= 0:
783            raise ValueError("concurrency must be a positive integer")
784        self._concurrency: int = concurrency
785        self._closed = False
786
787        # Validate retry parameters
788        if max_retries < 0:
789            raise ValueError("max_retries must be non-negative")
790        if initial_delay <= 0:
791            raise ValueError("initial_delay must be positive")
792        if backoff_factor < 1.0:
793            raise ValueError("backoff_factor must be >= 1.0 for exponential backoff")
794
795        self.max_retries = max_retries
796        self.initial_delay = initial_delay
797        self.backoff_factor = backoff_factor

If None is passed into the rpc or gateway base url, then the default for kubo local daemons will be used. The default local values will also be used if nothing is passed in at all.

httpx.AsyncClient Management

If client is not provided, it will be automatically initialized. It is the responsibility of the user to close this at an appropriate time, using await cas.aclose() as a class instance cannot know when it will no longer be in use, unless explicitly told to do so.

A supplied client is associated with the running event loop lazily on first use, so constructing KuboCAS does not require an async context. On a later event loop, KuboCAS warns and uses an internally created fallback that preserves only the supplied client's headers, auth, timeout, redirect policy, and event hooks. Pass client_factory instead when every event loop needs the client's full configuration. Factory clients are owned and closed by KuboCAS. Clients created internally by KuboCAS use a 60-second timeout, follow redirects, and negotiate HTTP/2 for HTTPS endpoints that support it.

If you are using the KuboCAS instance in an async with block, it will automatically close the client when the block is exited which is what we suggest below:

async with httpx.AsyncClient() as client, KuboCAS(
    rpc_base_url=rpc_base_url,
    gateway_base_url=gateway_base_url,
    client=client,
) as kubo_cas:
    hamt = await HAMT.build(cas=kubo_cas, values_are_bytes=True)
    zhs = ZarrHAMTStore(hamt)
    # Use the KuboCAS instance as needed
    # ...

As mentioned, if you do not use the async with syntax, you should call await cas.aclose() when you are done using the instance to ensure that all resources are cleaned up.

cas = KuboCAS(rpc_base_url=rpc_base_url, gateway_base_url=gateway_base_url)
# Use the KuboCAS instance as needed
# ...
await cas.aclose()  # Ensure resources are cleaned up

Authenticated RPC/Gateway Access

Users can set whatever headers and auth credentials they need if they are connecting to an authenticated kubo instance by setting them in their own httpx.AsyncClient and then passing that in. Alternatively, they can pass in headers and auth parameters to the constructor, which will be used to create a new httpx.AsyncClient if one is not provided. If you do not need authentication, you can leave these parameters as None.

RPC and HTTP Gateway Base URLs

These are the first part of the url, defaults that refer to the default that kubo launches with on a local machine are provided.

KUBO_DEFAULT_LOCAL_GATEWAY_BASE_URL: str = 'http://127.0.0.1:8080'
KUBO_DEFAULT_LOCAL_RPC_BASE_URL: str = 'http://127.0.0.1:5001'
chunker: str
hasher: str

The hash function to send to IPFS when storing bytes. Cannot be changed after initialization. The default blake3 follows the default hashing algorithm used by HAMT.

max_retries
initial_delay
backoff_factor
async def aclose(self) -> None:
 944    async def aclose(self) -> None:
 945        """
 946        Close every internally-created client, leaving a supplied client open.
 947
 948        Must be called from an async context.
 949
 950        For clients owned by closed loops with stock async-only transports,
 951        cleanup degenerates to a warning. The OS-level socket is shut down
 952        with a FIN, but its local file descriptor is released at garbage
 953        collection. Callers that require deterministic release should call
 954        ``aclose()`` on the owning loop before it exits.
 955        """
 956        try:
 957            current_loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
 958        except RuntimeError:
 959            current_loop = None
 960
 961        for owner_loop, client in list(self._client_per_loop.items()):
 962            if client not in self._internally_created_clients:
 963                continue
 964
 965            try:
 966                if owner_loop is current_loop:
 967                    await client.aclose()
 968                    continue
 969
 970                if not owner_loop.is_closed():
 971                    if owner_loop.is_running():
 972                        close_future = asyncio.run_coroutine_threadsafe(
 973                            client.aclose(), owner_loop
 974                        )
 975                        try:
 976                            await asyncio.wait_for(
 977                                asyncio.wrap_future(close_future),
 978                                timeout=_CROSS_LOOP_ACLOSE_TIMEOUT_S,
 979                            )
 980                        except TimeoutError:
 981                            # The owner loop stopped (or stalled) after
 982                            # is_running() succeeded, so the scheduled close can
 983                            # never complete. Cancel it and fall through to the
 984                            # synchronous transport shutdown below.
 985                            close_future.cancel()
 986                        else:
 987                            continue
 988                    else:
 989                        await asyncio.to_thread(
 990                            _close_client_on_stopped_loop, owner_loop, client
 991                        )
 992                        continue
 993
 994                # AsyncClient marks itself closed before awaiting its transport.
 995                # A dead owner loop therefore needs the transport's sync fallback.
 996                transport: Any = client._transport
 997                close_transport = getattr(transport, "close", None)
 998                if close_transport is None:
 999                    await client.aclose()
1000                    continue
1001
1002                close_transport()
1003                try:
1004                    await client.aclose()
1005                except Exception:
1006                    pass  # The transport was already closed synchronously.
1007            except Exception as exc:
1008                warnings.warn(
1009                    f"Failed to close an internally created HTTP client: {exc}",
1010                    RuntimeWarning,
1011                    stacklevel=2,
1012                )
1013
1014        self._client_per_loop.clear()
1015        self._internally_created_clients.clear()
1016        self._semaphore_per_loop.clear()
1017        self._gateway_semaphore_per_loop.clear()
1018        self._closed = True

Close every internally-created client, leaving a supplied client open.

Must be called from an async context.

For clients owned by closed loops with stock async-only transports, cleanup degenerates to a warning. The OS-level socket is shut down with a FIN, but its local file descriptor is released at garbage collection. Callers that require deterministic release should call aclose() on the owning loop before it exits.

async def save( self, data: bytes, codec: Literal['raw', 'dag-cbor']) -> multiformats.cid.CID:
1079    async def save(self, data: bytes, codec: ContentAddressedStore.CodecInput) -> CID:
1080        """Add data to Kubo and return its CID.
1081
1082        Transient request failures and gateway statuses are retried. Retrying
1083        the ``/api/v0/add`` POST is safe because the uploaded content is
1084        content-addressed, making repeated additions idempotent. Concurrency
1085        slots are held per HTTP attempt and released during retry backoff.
1086        """
1087        files = {"file": data}
1088        client = self._loop_client()
1089        semaphore = self._loop_semaphore()
1090        retry_count = 0
1091
1092        while retry_count <= self.max_retries:
1093            try:
1094                async with semaphore:
1095                    response = await client.post(self.rpc_url, files=files)
1096                response.raise_for_status()
1097                cid_str: str = response.json()["Hash"]
1098                cid: CID = CID.decode(cid_str)
1099                if cid.codec.code != self.DAG_PB_MARKER:
1100                    cid = cid.set(codec=codec)
1101                elif self.verify_content:
1102                    # Kubo splits payloads larger than ``chunker`` into a UnixFS
1103                    # dag-pb tree, so the root block is the protobuf node rather
1104                    # than the bytes handed in. The requested codec cannot be
1105                    # applied (the digest would stop matching the block), and
1106                    # _cid_is_verifiable() skips dag-pb, so verify_content
1107                    # silently does nothing for this object. Warn rather than
1108                    # fail: the data still round-trips correctly, and the
1109                    # threshold depends on the caller's chunker setting.
1110                    warnings.warn(
1111                        f"Saved {len(data)} bytes exceeded the '{self.chunker}' "
1112                        f"chunker, so Kubo returned a dag-pb CID ({cid}). "
1113                        "Content verification is not possible for this object; "
1114                        "raise the chunker size to keep payloads in one block.",
1115                        RuntimeWarning,
1116                        stacklevel=2,
1117                    )
1118                return cid
1119
1120            except httpx.RequestError:
1121                if retry_count >= self.max_retries:
1122                    raise
1123                retry_count += 1
1124                await asyncio.sleep(
1125                    _retry_delay(self.initial_delay, self.backoff_factor, retry_count)
1126                )
1127
1128            except httpx.HTTPStatusError as error:
1129                if error.response.status_code not in _RETRYABLE_STATUS_CODES:
1130                    raise
1131                if retry_count >= self.max_retries:
1132                    raise
1133                retry_count += 1
1134                await asyncio.sleep(
1135                    _retry_delay(
1136                        self.initial_delay,
1137                        self.backoff_factor,
1138                        retry_count,
1139                        error.response,
1140                    )
1141                )
1142        raise RuntimeError("Exited the retry loop unexpectedly.")  # pragma: no cover

Add data to Kubo and return its CID.

Transient request failures and gateway statuses are retried. Retrying the /api/v0/add POST is safe because the uploaded content is content-addressed, making repeated additions idempotent. Concurrency slots are held per HTTP attempt and released during retry backoff.

async def load( self, id: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]], offset: Optional[int] = None, length: Optional[int] = None, suffix: Optional[int] = None) -> bytes:
1337    async def load(
1338        self,
1339        id: IPLDKind,
1340        offset: Optional[int] = None,
1341        length: Optional[int] = None,
1342        suffix: Optional[int] = None,
1343    ) -> bytes:
1344        """Load all or part of a CID using the IPFS gateway.
1345
1346        Gateways that ignore a Range header and return a complete ``200`` body
1347        are handled by applying the requested byte window locally. Transient
1348        request failures, rate limits, and gateway server errors are retried;
1349        other HTTP errors fail immediately. Zero-length and zero-suffix reads
1350        return immediately without a gateway request. Concurrency slots are
1351        held per HTTP attempt and released during retry backoff.
1352
1353        When several gateways are configured, each is tried in turn -- healthy
1354        ones first -- until one succeeds. Requests are *not* raced in parallel:
1355        fanning every read out to every gateway would multiply egress and burn
1356        each gateway's rate-limit budget N times over, which is the opposite of
1357        what helps when rate limiting is the problem being solved. A gateway
1358        that fails ``_GATEWAY_FAILURE_THRESHOLD`` times consecutively is moved
1359        to the back of the rotation for a cooldown. If every gateway fails, the
1360        collected errors are raised together as an ``ExceptionGroup``.
1361        """
1362        if (offset is not None and length == 0) or (offset is None and suffix == 0):
1363            return b""
1364
1365        cid = cast(CID, id)
1366        headers: Dict[str, str] = {}
1367
1368        # Construct the Range header if required
1369        if offset is not None:
1370            start = offset
1371            if length is not None:
1372                # Standard HTTP Range: bytes=start-end (inclusive)
1373                end = start + length - 1
1374                headers["Range"] = f"bytes={start}-{end}"
1375            else:
1376                # Standard HTTP Range: bytes=start- (from start to end)
1377                headers["Range"] = f"bytes={start}-"
1378        elif suffix is not None:
1379            # Standard HTTP Range: bytes=-N (last N bytes)
1380            headers["Range"] = f"bytes=-{suffix}"
1381
1382        trace_started_at = instrumentation.begin_cas_load(cid, bool(headers))
1383        stats = _LoadStats()
1384        gateways = self._ordered_gateways()
1385        failures: list[Exception] = []
1386        try:
1387            for gateway_base_url in gateways:
1388                health = self._gateway_health[gateway_base_url]
1389                try:
1390                    content = await self._load_from_gateway(
1391                        gateway_base_url, cid, headers, offset, length, suffix, stats
1392                    )
1393                except (httpx.HTTPError, GatewayContentMismatch) as error:
1394                    health.record_failure(time.monotonic())
1395                    failures.append(error)
1396                    if len(gateways) > 1:
1397                        logger.debug(
1398                            "Gateway %s failed for CID %s (%s); trying the next one",
1399                            gateway_base_url,
1400                            cid,
1401                            error,
1402                        )
1403                    continue
1404                else:
1405                    health.record_success()
1406                    # A gateway leg may have set a failure status before a later
1407                    # gateway succeeded; the operation as a whole is a success.
1408                    stats.status = "ok"
1409                    return content
1410
1411            # Every gateway failed. With one configured, re-raise its error
1412            # unchanged so existing single-gateway callers keep seeing the exact
1413            # httpx exception type they handle today.
1414            if len(failures) == 1:
1415                raise failures[0]
1416            raise ExceptionGroup(
1417                f"all {len(gateways)} gateways failed for CID {cid}", failures
1418            )
1419        finally:
1420            instrumentation.end_cas_load(
1421                trace_started_at,
1422                byte_count=stats.response_bytes,
1423                retries=stats.retries,
1424                status=stats.status,
1425            )

Load all or part of a CID using the IPFS gateway.

Gateways that ignore a Range header and return a complete 200 body are handled by applying the requested byte window locally. Transient request failures, rate limits, and gateway server errors are retried; other HTTP errors fail immediately. Zero-length and zero-suffix reads return immediately without a gateway request. Concurrency slots are held per HTTP attempt and released during retry backoff.

When several gateways are configured, each is tried in turn -- healthy ones first -- until one succeeds. Requests are not raced in parallel: fanning every read out to every gateway would multiply egress and burn each gateway's rate-limit budget N times over, which is the opposite of what helps when rate limiting is the problem being solved. A gateway that fails _GATEWAY_FAILURE_THRESHOLD times consecutively is moved to the back of the rotation for a cooldown. If every gateway fails, the collected errors are raised together as an ExceptionGroup.

async def pin_cid( self, cid: multiformats.cid.CID, target_rpc: str = 'http://127.0.0.1:5001') -> None:
1430    async def pin_cid(
1431        self,
1432        cid: CID,
1433        target_rpc: str = "http://127.0.0.1:5001",
1434    ) -> None:
1435        """
1436        Pins a CID to the local Kubo node via the RPC API.
1437
1438        This call is recursive by default, pinning all linked objects.
1439
1440        Args:
1441            cid (CID): The Content ID to pin.
1442            target_rpc (str): The RPC URL of the Kubo node.
1443        """
1444        params = {"arg": str(cid), "recursive": "true"}
1445        pin_add_url_base: str = f"{target_rpc}/api/v0/pin/add"
1446
1447        async with self._loop_semaphore():  # throttle RPC
1448            client = self._loop_client()
1449            response = await client.post(pin_add_url_base, params=params)
1450            response.raise_for_status()

Pins a CID to the local Kubo node via the RPC API.

This call is recursive by default, pinning all linked objects.

Args: cid (CID): The Content ID to pin. target_rpc (str): The RPC URL of the Kubo node.

async def unpin_cid( self, cid: multiformats.cid.CID, target_rpc: str = 'http://127.0.0.1:5001') -> None:
1452    async def unpin_cid(
1453        self, cid: CID, target_rpc: str = "http://127.0.0.1:5001"
1454    ) -> None:
1455        """
1456        Unpins a CID from the local Kubo node via the RPC API.
1457
1458        Args:
1459            cid (CID): The Content ID to unpin.
1460        """
1461        params = {"arg": str(cid), "recursive": "true"}
1462        unpin_url_base: str = f"{target_rpc}/api/v0/pin/rm"
1463        async with self._loop_semaphore():  # throttle RPC
1464            client = self._loop_client()
1465            response = await client.post(unpin_url_base, params=params)
1466            response.raise_for_status()

Unpins a CID from the local Kubo node via the RPC API.

Args: cid (CID): The Content ID to unpin.

async def pin_update( self, old_id: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]], new_id: Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]], Dict[str, Union[NoneType, bool, int, float, str, bytes, multiformats.cid.CID, List[ForwardRef('IPLDKind')], Dict[str, ForwardRef('IPLDKind')]]]], target_rpc: str = 'http://127.0.0.1:5001') -> None:
1468    async def pin_update(
1469        self,
1470        old_id: IPLDKind,
1471        new_id: IPLDKind,
1472        target_rpc: str = "http://127.0.0.1:5001",
1473    ) -> None:
1474        """
1475        Updates the pinned CID in the storage.
1476
1477        Args:
1478            old_id (IPLDKind): The old Content ID to replace.
1479            new_id (IPLDKind): The new Content ID to pin.
1480        """
1481        params = {"arg": [str(old_id), str(new_id)]}
1482        pin_update_url_base: str = f"{target_rpc}/api/v0/pin/update"
1483        async with self._loop_semaphore():  # throttle RPC
1484            client = self._loop_client()
1485            response = await client.post(pin_update_url_base, params=params)
1486            response.raise_for_status()

Updates the pinned CID in the storage.

Args: old_id (IPLDKind): The old Content ID to replace. new_id (IPLDKind): The new Content ID to pin.

async def pin_ls( self, target_rpc: str = 'http://127.0.0.1:5001') -> list[typing.Dict[str, typing.Any]]:
1488    async def pin_ls(
1489        self, target_rpc: str = "http://127.0.0.1:5001"
1490    ) -> list[Dict[str, Any]]:
1491        """
1492        Lists all pinned CIDs on the local Kubo node via the RPC API.
1493
1494        Args:
1495            target_rpc (str): The RPC URL of the Kubo node.
1496
1497        Returns:
1498            List[CID]: A list of pinned CIDs.
1499        """
1500        pin_ls_url_base: str = f"{target_rpc}/api/v0/pin/ls"
1501        async with self._loop_semaphore():  # throttle RPC
1502            client = self._loop_client()
1503            response = await client.post(pin_ls_url_base)
1504            response.raise_for_status()
1505            pins = response.json().get("Keys", [])
1506            return pins

Lists all pinned CIDs on the local Kubo node via the RPC API.

Args: target_rpc (str): The RPC URL of the Kubo node.

Returns: List[CID]: A list of pinned CIDs.

class ZarrHAMTStore(zarr.abc.store.Store):
 16class ZarrHAMTStore(zarr.abc.store.Store):
 17    """
 18    Write and read Zarr v3s with a HAMT.
 19
 20    Read **or** write a Zarr-v3 store whose key/value pairs live inside a
 21    py-hamt mapping.
 22
 23    Keys are stored verbatim (``"temp/c/0/0/0"`` → same string in HAMT) and
 24    the value is the raw byte payload produced by Zarr.  No additional
 25    framing, compression, or encryption is applied by this class. For a zarr encryption example
 26    see where metadata is available use the method in https://github.com/dClimate/jupyter-notebooks/blob/main/notebooks/202b%20-%20Encryption%20Example%20(Encryption%20with%20Zarr%20Codecs).ipynb
 27    For a fully encrypted zarr store, where metadata is not available, please see
 28    :class:`SimpleEncryptedZarrHAMTStore` but we do not recommend using it.
 29
 30    #### A note about using the same `ZarrHAMTStore` for writing and then reading again
 31    If you write a Zarr to a HAMT, and then change it to read only mode, it's best to reinitialize a new ZarrHAMTStore with the proper read only setting. This is because this class, to err on the safe side, will not touch its super class's settings.
 32
 33    #### Sample Code
 34    ```python
 35    # --- Write ---
 36    ds: xarray.Dataset = # ...
 37    cas: ContentAddressedStore = # ...
 38    hamt: HAMT = # ... make sure values_are_bytes is True and read_only is False to enable writes
 39    hamt = await HAMT.build(cas, values_are_bytes=True)     # write-enabled
 40    zhs  = ZarrHAMTStore(hamt, read_only=False)
 41    ds.to_zarr(store=zhs, mode="w", zarr_format=3)
 42    await hamt.make_read_only() # flush + freeze
 43    root_node_id = hamt.root_node_id
 44    print(root_node_id)
 45
 46     # --- read ---
 47    hamt_ro = await HAMT.build(
 48        cas, root_node_id=root_cid, read_only=True, values_are_bytes=True
 49    )
 50    zhs_ro  = ZarrHAMTStore(hamt_ro, read_only=True)
 51    ds_ro = xarray.open_zarr(store=zhs_ro)
 52
 53
 54    print(ds_ro)
 55    xarray.testing.assert_identical(ds, ds_ro)
 56    ```
 57    """
 58
 59    _forced_read_only: bool | None = None  # sentinel for wrapper clones
 60
 61    def __init__(self, hamt: HAMT, read_only: bool = False) -> None:
 62        """
 63        ### `hamt` and `read_only`
 64        You need to make sure the following two things are true:
 65
 66        1. The HAMT is in the same read only mode that you are passing into the Zarr store. This means that `hamt.read_only == read_only`. This is because making a HAMT read only automatically requires async operations, but `__init__` cannot be async.
 67        2. The HAMT has `hamt.values_are_bytes == True`. This improves efficiency with Zarr v3 operations.
 68
 69        ##### A note about the zarr chunk separator, "/" vs "."
 70        While Zarr v2 used periods by default, Zarr v3 uses forward slashes, and that is assumed here as well.
 71
 72        #### Metadata Read Cache
 73        `ZarrHAMTStore` has an internal read cache for metadata. In practice metadata "zarr.json" files are very very frequently and duplicately requested compared to all other keys, and there are significant speed improvements gotten by implementing this cache. In terms of memory management, in practice this cache does not need an eviction step since "zarr.json" files are much smaller than the memory requirement of the zarr data.
 74        """
 75        super().__init__(read_only=read_only)
 76
 77        assert hamt.read_only == read_only
 78        assert hamt.values_are_bytes
 79        self.hamt: HAMT = hamt
 80        """
 81        The internal HAMT.
 82        Once done with write operations, the hamt can be set to read only mode as usual to get your root node ID.
 83        """
 84
 85        self.metadata_read_cache: dict[str, bytes] = {}
 86        """@private"""
 87
 88    def _map_byte_request(
 89        self, byte_range: Optional[zarr.abc.store.ByteRequest]
 90    ) -> tuple[Optional[int], Optional[int], Optional[int]]:
 91        """Helper to map Zarr ByteRequest to offset, length, suffix."""
 92        offset: Optional[int] = None
 93        length: Optional[int] = None
 94        suffix: Optional[int] = None
 95
 96        if byte_range:
 97            if isinstance(byte_range, zarr.abc.store.RangeByteRequest):
 98                offset = byte_range.start
 99                length = byte_range.end - byte_range.start
100                if length is not None and length < 0:
101                    raise ValueError("End must be >= start for RangeByteRequest")
102            elif isinstance(byte_range, zarr.abc.store.OffsetByteRequest):
103                offset = byte_range.offset
104            elif isinstance(byte_range, zarr.abc.store.SuffixByteRequest):
105                suffix = byte_range.suffix
106            else:
107                raise TypeError(f"Unsupported ByteRequest type: {type(byte_range)}")
108
109        return offset, length, suffix
110
111    @property
112    def read_only(self) -> bool:  # type: ignore[override]
113        if self._forced_read_only is not None:  # instance attr overrides
114            return self._forced_read_only
115        return self.hamt.read_only
116
117    def with_read_only(self, read_only: bool = False) -> "ZarrHAMTStore":
118        """
119        Return this store (if the flag already matches) or a *shallow*
120        clone that presents the requested read‑only status.
121
122        The clone **shares** the same :class:`~py_hamt.hamt.HAMT`
123        instance; no flushing, network traffic or async work is done.
124        """
125        # Fast path
126        if read_only == self.read_only:
127            return self  # Same mode, return same instance
128
129        # Create new instance with different read_only flag
130        # Creates a *bare* instance without running its __init__
131        clone = type(self).__new__(type(self))
132
133        # Copy attributes that matter
134        clone.hamt = self.hamt  # Share the HAMT
135        clone._forced_read_only = read_only
136        clone.metadata_read_cache = self.metadata_read_cache.copy()
137
138        # Re‑initialise the zarr base class so that Zarr sees the flag
139        zarr.abc.store.Store.__init__(clone, read_only=read_only)
140        return clone
141
142    def __eq__(self, other: object) -> bool:
143        """@private"""
144        if not isinstance(other, ZarrHAMTStore):
145            return False
146        return self.hamt.root_node_id == other.hamt.root_node_id
147
148    async def get(
149        self,
150        key: str,
151        prototype: zarr.core.buffer.BufferPrototype,
152        byte_range: zarr.abc.store.ByteRequest | None = None,
153    ) -> zarr.core.buffer.Buffer | None:
154        """@private"""
155        with instrumentation.span(
156            "py_hamt.hamt_store.get",
157            {
158                "py_hamt.zarr.key": key,
159                "py_hamt.zarr.byte_range": byte_range is not None,
160            },
161        ):
162            started_at = time.perf_counter()
163            hit = False
164            is_metadata = len(key) >= 9 and key[-9:] == "zarr.json"
165            try:
166                val: bytes
167                # do len check to avoid indexing into overly short strings, 3.12 does not throw errors but we dont know if other versions will
168                # if path ends with zarr.json
169
170                if (
171                    is_metadata
172                    and byte_range is None
173                    and key in self.metadata_read_cache
174                ):
175                    val = self.metadata_read_cache[key]
176                else:
177                    offset, length, suffix = self._map_byte_request(byte_range)
178                    val = cast(
179                        bytes,
180                        await self.hamt.get(
181                            key, offset=offset, length=length, suffix=suffix
182                        ),
183                    )  # We know values received will always be bytes since we only store bytes in the HAMT
184                    if is_metadata and byte_range is None:
185                        self.metadata_read_cache[key] = val
186
187                hit = True
188                return prototype.buffer.from_bytes(val)
189            except KeyError:
190                # Sometimes zarr queries keys that don't exist anymore, just return nothing on those cases
191                return None
192            except Exception as e:
193                print(f"Error getting key '{key}' with range {byte_range}: {e}")
194                raise
195            finally:
196                instrumentation.record_zarr_get(
197                    store="hamt_store",
198                    key=key,
199                    kind="metadata" if is_metadata else "chunk",
200                    hit=hit,
201                    seconds=time.perf_counter() - started_at,
202                    byte_range=byte_range is not None,
203                )
204
205    async def get_partial_values(
206        self,
207        prototype: zarr.core.buffer.BufferPrototype,
208        key_ranges: Iterable[tuple[str, zarr.abc.store.ByteRequest | None]],
209    ) -> list[zarr.core.buffer.Buffer | None]:
210        """
211        Retrieves multiple keys or byte ranges concurrently using asyncio.gather.
212        """
213        tasks = [self.get(key, prototype, byte_range) for key, byte_range in key_ranges]
214        results = await asyncio.gather(
215            *tasks, return_exceptions=False
216        )  # Set return_exceptions=True for debugging
217        return results
218
219    async def exists(self, key: str) -> bool:
220        """@private"""
221        try:
222            await self.hamt.get(key)
223            return True
224        except KeyError:
225            return False
226
227    @property
228    def supports_writes(self) -> bool:
229        """@private"""
230        return not self.hamt.read_only
231
232    @property
233    def supports_partial_writes(self) -> bool:
234        """@private"""
235        return False
236
237    async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None:
238        """Store a value and update any existing metadata cache entry."""
239        if self.read_only:
240            raise Exception("Cannot write to a read-only store.")
241
242        raw_bytes = value.to_bytes()
243        await self.hamt.set(key, raw_bytes)
244        if key in self.metadata_read_cache:
245            self.metadata_read_cache[key] = raw_bytes
246
247    async def set_if_not_exists(self, key: str, value: zarr.core.buffer.Buffer) -> None:
248        """@private"""
249        if not (await self.exists(key)):
250            await self.set(key, value)
251
252    async def set_partial_values(
253        self, key_start_values: Iterable[tuple[str, int, BytesLike]]
254    ) -> None:
255        """@private"""
256        raise NotImplementedError
257
258    @property
259    def supports_deletes(self) -> bool:
260        """@private"""
261        return not self.hamt.read_only
262
263    async def delete(self, key: str) -> None:
264        """Delete a key and evict any cached metadata for it."""
265        if self.read_only:
266            raise Exception("Cannot write to a read-only store.")
267        try:
268            await self.hamt.delete(key)
269        # It's fine if the key was not in the HAMT
270        # Sometimes zarr v3 calls deletes on keys that don't exist (or have already been deleted) for some reason, probably concurrency issues
271        except KeyError:
272            pass
273        self.metadata_read_cache.pop(key, None)
274
275    @property
276    def supports_listing(self) -> bool:
277        """@private"""
278        return True
279
280    async def list(self) -> AsyncIterator[str]:
281        """@private"""
282        async for key in self.hamt.keys():
283            yield key
284
285    async def list_prefix(self, prefix: str) -> AsyncIterator[str]:
286        """@private"""
287        async for key in self.hamt.keys():
288            if key.startswith(prefix):
289                yield key
290
291    async def list_dir(self, prefix: str) -> AsyncIterator[str]:
292        """
293        @private
294        List *immediate* children that live directly under **prefix**.
295
296        This is similar to :py:meth:`list_prefix` but collapses everything
297        below the first ``"/"`` after *prefix*.  Each child name is yielded
298        **exactly once** in the order of first appearance while scanning the
299        HAMT keys.
300
301        Parameters
302        ----------
303        prefix : str
304            Logical directory path.  *Must* end with ``"/"`` for the result to
305            make sense (e.g. ``"a/b/"``).
306
307        Yields
308        ------
309        str
310            The name of each direct child (file or sub-directory) of *prefix*.
311
312        Examples
313        --------
314        With keys ::
315
316            a/b/c/d
317            a/b/c/e
318            a/b/f
319            a/b/g/h/i
320
321        ``await list_dir("a/b/")`` produces ::
322
323            c
324            f
325            g
326
327        Notes
328        -----
329        • Internally uses a :class:`set` to deduplicate names; memory grows
330            with the number of *unique* children, not the total number of keys.
331        • Order is **not** sorted; it reflects the first encounter while
332            iterating over :py:meth:`HAMT.keys`.
333        """
334        seen_names: set[str] = set()
335        async for key in self.hamt.keys():
336            if key.startswith(prefix):
337                suffix: str = key[len(prefix) :]
338                first_slash: int = suffix.find("/")
339                if first_slash == -1:
340                    if suffix not in seen_names:
341                        seen_names.add(suffix)
342                        yield suffix
343                else:
344                    name: str = suffix[0:first_slash]
345                    if name not in seen_names:
346                        seen_names.add(name)
347                        yield name

Write and read Zarr v3s with a HAMT.

Read or write a Zarr-v3 store whose key/value pairs live inside a py-hamt mapping.

Keys are stored verbatim ("temp/c/0/0/0" → same string in HAMT) and the value is the raw byte payload produced by Zarr. No additional framing, compression, or encryption is applied by this class. For a zarr encryption example see where metadata is available use the method in https://github.com/dClimate/jupyter-notebooks/blob/main/notebooks/202b%20-%20Encryption%20Example%20(Encryption%20with%20Zarr%20Codecs).ipynb For a fully encrypted zarr store, where metadata is not available, please see SimpleEncryptedZarrHAMTStore but we do not recommend using it.

A note about using the same ZarrHAMTStore for writing and then reading again

If you write a Zarr to a HAMT, and then change it to read only mode, it's best to reinitialize a new ZarrHAMTStore with the proper read only setting. This is because this class, to err on the safe side, will not touch its super class's settings.

Sample Code

# --- Write ---
ds: xarray.Dataset = # ...
cas: ContentAddressedStore = # ...
hamt: HAMT = # ... make sure values_are_bytes is True and read_only is False to enable writes
hamt = await HAMT.build(cas, values_are_bytes=True)     # write-enabled
zhs  = ZarrHAMTStore(hamt, read_only=False)
ds.to_zarr(store=zhs, mode="w", zarr_format=3)
await hamt.make_read_only() # flush + freeze
root_node_id = hamt.root_node_id
print(root_node_id)

 # --- read ---
hamt_ro = await HAMT.build(
    cas, root_node_id=root_cid, read_only=True, values_are_bytes=True
)
zhs_ro  = ZarrHAMTStore(hamt_ro, read_only=True)
ds_ro = xarray.open_zarr(store=zhs_ro)


print(ds_ro)
xarray.testing.assert_identical(ds, ds_ro)
ZarrHAMTStore(hamt: HAMT, read_only: bool = False)
61    def __init__(self, hamt: HAMT, read_only: bool = False) -> None:
62        """
63        ### `hamt` and `read_only`
64        You need to make sure the following two things are true:
65
66        1. The HAMT is in the same read only mode that you are passing into the Zarr store. This means that `hamt.read_only == read_only`. This is because making a HAMT read only automatically requires async operations, but `__init__` cannot be async.
67        2. The HAMT has `hamt.values_are_bytes == True`. This improves efficiency with Zarr v3 operations.
68
69        ##### A note about the zarr chunk separator, "/" vs "."
70        While Zarr v2 used periods by default, Zarr v3 uses forward slashes, and that is assumed here as well.
71
72        #### Metadata Read Cache
73        `ZarrHAMTStore` has an internal read cache for metadata. In practice metadata "zarr.json" files are very very frequently and duplicately requested compared to all other keys, and there are significant speed improvements gotten by implementing this cache. In terms of memory management, in practice this cache does not need an eviction step since "zarr.json" files are much smaller than the memory requirement of the zarr data.
74        """
75        super().__init__(read_only=read_only)
76
77        assert hamt.read_only == read_only
78        assert hamt.values_are_bytes
79        self.hamt: HAMT = hamt
80        """
81        The internal HAMT.
82        Once done with write operations, the hamt can be set to read only mode as usual to get your root node ID.
83        """
84
85        self.metadata_read_cache: dict[str, bytes] = {}
86        """@private"""

hamt and read_only

You need to make sure the following two things are true:

  1. The HAMT is in the same read only mode that you are passing into the Zarr store. This means that hamt.read_only == read_only. This is because making a HAMT read only automatically requires async operations, but __init__ cannot be async.
  2. The HAMT has hamt.values_are_bytes == True. This improves efficiency with Zarr v3 operations.
A note about the zarr chunk separator, "/" vs "."

While Zarr v2 used periods by default, Zarr v3 uses forward slashes, and that is assumed here as well.

Metadata Read Cache

ZarrHAMTStore has an internal read cache for metadata. In practice metadata "zarr.json" files are very very frequently and duplicately requested compared to all other keys, and there are significant speed improvements gotten by implementing this cache. In terms of memory management, in practice this cache does not need an eviction step since "zarr.json" files are much smaller than the memory requirement of the zarr data.

hamt: HAMT

The internal HAMT. Once done with write operations, the hamt can be set to read only mode as usual to get your root node ID.

read_only: bool
111    @property
112    def read_only(self) -> bool:  # type: ignore[override]
113        if self._forced_read_only is not None:  # instance attr overrides
114            return self._forced_read_only
115        return self.hamt.read_only

Is the store read-only?

def with_read_only(self, read_only: bool = False) -> ZarrHAMTStore:
117    def with_read_only(self, read_only: bool = False) -> "ZarrHAMTStore":
118        """
119        Return this store (if the flag already matches) or a *shallow*
120        clone that presents the requested read‑only status.
121
122        The clone **shares** the same :class:`~py_hamt.hamt.HAMT`
123        instance; no flushing, network traffic or async work is done.
124        """
125        # Fast path
126        if read_only == self.read_only:
127            return self  # Same mode, return same instance
128
129        # Create new instance with different read_only flag
130        # Creates a *bare* instance without running its __init__
131        clone = type(self).__new__(type(self))
132
133        # Copy attributes that matter
134        clone.hamt = self.hamt  # Share the HAMT
135        clone._forced_read_only = read_only
136        clone.metadata_read_cache = self.metadata_read_cache.copy()
137
138        # Re‑initialise the zarr base class so that Zarr sees the flag
139        zarr.abc.store.Store.__init__(clone, read_only=read_only)
140        return clone

Return this store (if the flag already matches) or a shallow clone that presents the requested read‑only status.

The clone shares the same ~py_hamt.hamt.HAMT instance; no flushing, network traffic or async work is done.

async def get_partial_values( self, prototype: zarr.core.buffer.core.BufferPrototype, key_ranges: Iterable[tuple[str, zarr.abc.store.RangeByteRequest | zarr.abc.store.OffsetByteRequest | zarr.abc.store.SuffixByteRequest | None]]) -> list[zarr.core.buffer.core.Buffer | None]:
205    async def get_partial_values(
206        self,
207        prototype: zarr.core.buffer.BufferPrototype,
208        key_ranges: Iterable[tuple[str, zarr.abc.store.ByteRequest | None]],
209    ) -> list[zarr.core.buffer.Buffer | None]:
210        """
211        Retrieves multiple keys or byte ranges concurrently using asyncio.gather.
212        """
213        tasks = [self.get(key, prototype, byte_range) for key, byte_range in key_ranges]
214        results = await asyncio.gather(
215            *tasks, return_exceptions=False
216        )  # Set return_exceptions=True for debugging
217        return results

Retrieves multiple keys or byte ranges concurrently using asyncio.gather.

async def set(self, key: str, value: zarr.core.buffer.core.Buffer) -> None:
237    async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None:
238        """Store a value and update any existing metadata cache entry."""
239        if self.read_only:
240            raise Exception("Cannot write to a read-only store.")
241
242        raw_bytes = value.to_bytes()
243        await self.hamt.set(key, raw_bytes)
244        if key in self.metadata_read_cache:
245            self.metadata_read_cache[key] = raw_bytes

Store a value and update any existing metadata cache entry.

async def delete(self, key: str) -> None:
263    async def delete(self, key: str) -> None:
264        """Delete a key and evict any cached metadata for it."""
265        if self.read_only:
266            raise Exception("Cannot write to a read-only store.")
267        try:
268            await self.hamt.delete(key)
269        # It's fine if the key was not in the HAMT
270        # Sometimes zarr v3 calls deletes on keys that don't exist (or have already been deleted) for some reason, probably concurrency issues
271        except KeyError:
272            pass
273        self.metadata_read_cache.pop(key, None)

Delete a key and evict any cached metadata for it.

class SimpleEncryptedZarrHAMTStore(py_hamt.ZarrHAMTStore):
 13class SimpleEncryptedZarrHAMTStore(ZarrHAMTStore):
 14    """
 15    Write and read Zarr v3s with a HAMT, encrypting *everything* for maximum privacy.
 16
 17    This store uses ChaCha20-Poly1305 to encrypt every single key-value pair
 18    stored in the Zarr, including all metadata (`zarr.json`, `.zarray`, etc.)
 19    and data chunks. This provides strong privacy but means the Zarr store is
 20    completely opaque and unusable without the correct encryption key and header.
 21
 22    Note: For standard zarr encryption and decryption where metadata is available use the method in https://github.com/dClimate/jupyter-notebooks/blob/main/notebooks/202b%20-%20Encryption%20Example%20(Encryption%20with%20Zarr%20Codecs).ipynb
 23
 24    #### Encryption Details
 25    - Uses XChaCha20_Poly1305 (via pycryptodome's ChaCha20_Poly1305 with a 24-byte nonce).
 26    - Requires a 32-byte encryption key and a header.
 27    - Each encrypted value includes a 24-byte nonce and a 16-byte tag.
 28
 29    #### Important Considerations
 30    - Since metadata is encrypted, standard Zarr tools cannot inspect the
 31      dataset without prior decryption using this store class.
 32    - There is no support for partial encryption or excluding variables.
 33    - There is no metadata caching.
 34
 35    #### Sample Code
 36    ```python
 37    import xarray
 38    from py_hamt import HAMT, KuboCAS # Assuming an KuboCAS or similar
 39    from Crypto.Random import get_random_bytes
 40    import numpy as np
 41
 42    # Setup
 43    ds = xarray.Dataset(
 44        {"data": (("y", "x"), np.arange(12).reshape(3, 4))},
 45        coords={"y": [1, 2, 3], "x": [10, 20, 30, 40]}
 46    )
 47    cas = KuboCAS() # Example ContentAddressedStore
 48    encryption_key = get_random_bytes(32)
 49    header = b"fully-encrypted-zarr"
 50
 51    # --- Write ---
 52    hamt_write = await HAMT.build(cas=cas, values_are_bytes=True)
 53    ezhs_write = SimpleEncryptedZarrHAMTStore(
 54        hamt_write, False, encryption_key, header
 55    )
 56    print("Writing fully encrypted Zarr...")
 57    ds.to_zarr(store=ezhs_write, mode="w")
 58    await hamt_write.make_read_only()
 59    root_node_id = hamt_write.root_node_id
 60    print(f"Wrote Zarr with root: {root_node_id}")
 61
 62    # --- Read ---
 63    hamt_read = await HAMT.build(
 64            cas=cas, root_node_id=root_node_id, values_are_bytes=True, read_only=True
 65        )
 66    ezhs_read = SimpleEncryptedZarrHAMTStore(
 67        hamt_read, True, encryption_key, header
 68    )
 69    print("\nReading fully encrypted Zarr...")
 70    ds_read = xarray.open_zarr(store=ezhs_read)
 71    print("Read back dataset:")
 72    print(ds_read)
 73    xarray.testing.assert_identical(ds, ds_read)
 74    print("Read successful and data verified.")
 75
 76    # --- Read with wrong key (demonstrates failure) ---
 77    wrong_key = get_random_bytes(32)
 78    hamt_bad = await HAMT.build(
 79        cas=cas, root_node_id=root_node_id, read_only=True, values_are_bytes=True
 80    )
 81    ezhs_bad = SimpleEncryptedZarrHAMTStore(
 82        hamt_bad, True, wrong_key, header
 83    )
 84    print("\nAttempting to read with wrong key...")
 85    try:
 86        ds_bad = xarray.open_zarr(store=ezhs_bad)
 87        print(ds_bad)
 88    except Exception as e:
 89        print(f"Failed to read as expected: {type(e).__name__} - {e}")
 90    ```
 91    """
 92
 93    def __init__(
 94        self, hamt: HAMT, read_only: bool, encryption_key: bytes, header: bytes
 95    ) -> None:
 96        """
 97        Initializes the SimpleEncryptedZarrHAMTStore.
 98
 99        Args:
100            hamt: The HAMT instance for storage. Must have `values_are_bytes=True`.
101                  Its `read_only` status must match the `read_only` argument.
102            read_only: If True, the store is in read-only mode.
103            encryption_key: A 32-byte key for ChaCha20-Poly1305.
104            header: A header (bytes) used as associated data in encryption.
105        """
106        super().__init__(hamt, read_only=read_only)
107
108        if len(encryption_key) != 32:
109            raise ValueError("Encryption key must be exactly 32 bytes long.")
110        self.encryption_key = encryption_key
111        self.header = header
112        self.metadata_read_cache: dict[str, bytes] = {}
113
114    def with_read_only(self, read_only: bool = False) -> "SimpleEncryptedZarrHAMTStore":
115        if read_only == self.read_only:
116            return self
117
118        clone = type(self).__new__(type(self))
119        clone.hamt = self.hamt
120        clone.encryption_key = self.encryption_key
121        clone.header = self.header
122        clone.metadata_read_cache = self.metadata_read_cache
123        clone._forced_read_only = read_only  # safe; attribute is declared
124        zarr.abc.store.Store.__init__(clone, read_only=read_only)
125        return clone
126
127    def _encrypt(self, val: bytes) -> bytes:
128        """Encrypts data using ChaCha20-Poly1305."""
129        nonce = get_random_bytes(24)  # XChaCha20 uses a 24-byte nonce
130        cipher = ChaCha20_Poly1305.new(key=self.encryption_key, nonce=nonce)
131        cipher.update(self.header)
132        ciphertext, tag = cipher.encrypt_and_digest(val)
133        return nonce + tag + ciphertext
134
135    def _decrypt(self, val: bytes) -> bytes:
136        """Decrypts data using ChaCha20-Poly1305."""
137        try:
138            # Extract nonce (24), tag (16), and ciphertext
139            nonce, tag, ciphertext = val[:24], val[24:40], val[40:]
140            cipher = ChaCha20_Poly1305.new(key=self.encryption_key, nonce=nonce)
141            cipher.update(self.header)
142            plaintext = cipher.decrypt_and_verify(ciphertext, tag)
143            return plaintext
144        except Exception as e:
145            # Catching a broad exception as various issues (key, tag, length) can occur.
146            raise ValueError(
147                "Decryption failed. Check key, header, or data integrity."
148            ) from e
149
150    def __eq__(self, other: object) -> bool:
151        """@private"""
152        if not isinstance(other, SimpleEncryptedZarrHAMTStore):
153            return False
154        return (
155            self.hamt.root_node_id == other.hamt.root_node_id
156            and self.encryption_key == other.encryption_key
157            and self.header == other.header
158        )
159
160    async def get(
161        self,
162        key: str,
163        prototype: zarr.core.buffer.BufferPrototype,
164        byte_range: zarr.abc.store.ByteRequest | None = None,
165    ) -> zarr.core.buffer.Buffer | None:
166        """@private"""
167        try:
168            decrypted_val: bytes
169            is_metadata: bool = (
170                len(key) >= 9 and key[-9:] == "zarr.json"
171            )  # if path ends with zarr.json
172
173            if is_metadata and key in self.metadata_read_cache:
174                decrypted_val = self.metadata_read_cache[key]
175            else:
176                raw_val = cast(
177                    bytes, await self.hamt.get(key)
178                )  # We know values received will always be bytes since we only store bytes in the HAMT
179                decrypted_val = self._decrypt(raw_val)
180                if is_metadata:
181                    self.metadata_read_cache[key] = decrypted_val
182            return prototype.buffer.from_bytes(decrypted_val)
183        except KeyError:
184            return None
185
186    async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None:
187        """Encrypt and store a value, then update cached metadata."""
188        if self.read_only:
189            raise Exception("Cannot write to a read-only store.")
190
191        raw_bytes = value.to_bytes()
192        # Encrypt it
193        encrypted_bytes = self._encrypt(raw_bytes)
194        await self.hamt.set(key, encrypted_bytes)
195        if key in self.metadata_read_cache:
196            self.metadata_read_cache[key] = raw_bytes

Write and read Zarr v3s with a HAMT, encrypting everything for maximum privacy.

This store uses ChaCha20-Poly1305 to encrypt every single key-value pair
stored in the Zarr, including all metadata (`zarr.json`, `.zarray`, etc.)
and data chunks. This provides strong privacy but means the Zarr store is
completely opaque and unusable without the correct encryption key and header.

Note: For standard zarr encryption and decryption where metadata is available use the method in https://github.com/dClimate/jupyter-notebooks/blob/main/notebooks/202b%20-%20Encryption%20Example%20(Encryption%20with%20Zarr%20Codecs).ipynb

#### Encryption Details
- Uses XChaCha20_Poly1305 (via pycryptodome's ChaCha20_Poly1305 with a 24-byte nonce).
- Requires a 32-byte encryption key and a header.
- Each encrypted value includes a 24-byte nonce and a 16-byte tag.

#### Important Considerations
- Since metadata is encrypted, standard Zarr tools cannot inspect the
  dataset without prior decryption using this store class.
- There is no support for partial encryption or excluding variables.
- There is no metadata caching.

#### Sample Code


    import xarray
    from py_hamt import HAMT, KuboCAS # Assuming an KuboCAS or similar
    from Crypto.Random import get_random_bytes
    import numpy as np

    # Setup
    ds = xarray.Dataset(
        {"data": (("y", "x"), np.arange(12).reshape(3, 4))},
        coords={"y": [1, 2, 3], "x": [10, 20, 30, 40]}
    )
    cas = KuboCAS() # Example ContentAddressedStore
    encryption_key = get_random_bytes(32)
    header = b"fully-encrypted-zarr"

    # --- Write ---
    hamt_write = await HAMT.build(cas=cas, values_are_bytes=True)
    ezhs_write = SimpleEncryptedZarrHAMTStore(
        hamt_write, False, encryption_key, header
    )
    print("Writing fully encrypted Zarr...")
    ds.to_zarr(store=ezhs_write, mode="w")
    await hamt_write.make_read_only()
    root_node_id = hamt_write.root_node_id
    print(f"Wrote Zarr with root: {root_node_id}")

    # --- Read ---
    hamt_read = await HAMT.build(
            cas=cas, root_node_id=root_node_id, values_are_bytes=True, read_only=True
        )
    ezhs_read = SimpleEncryptedZarrHAMTStore(
        hamt_read, True, encryption_key, header
    )
    print("
Reading fully encrypted Zarr...")
    ds_read = xarray.open_zarr(store=ezhs_read)
    print("Read back dataset:")
    print(ds_read)
    xarray.testing.assert_identical(ds, ds_read)
    print("Read successful and data verified.")

    # --- Read with wrong key (demonstrates failure) ---
    wrong_key = get_random_bytes(32)
    hamt_bad = await HAMT.build(
        cas=cas, root_node_id=root_node_id, read_only=True, values_are_bytes=True
    )
    ezhs_bad = SimpleEncryptedZarrHAMTStore(
        hamt_bad, True, wrong_key, header
    )
    print("
Attempting to read with wrong key...")
    try:
        ds_bad = xarray.open_zarr(store=ezhs_bad)
        print(ds_bad)
    except Exception as e:
        print(f"Failed to read as expected: {type(e).__name__} - {e}")
SimpleEncryptedZarrHAMTStore( hamt: HAMT, read_only: bool, encryption_key: bytes, header: bytes)
 93    def __init__(
 94        self, hamt: HAMT, read_only: bool, encryption_key: bytes, header: bytes
 95    ) -> None:
 96        """
 97        Initializes the SimpleEncryptedZarrHAMTStore.
 98
 99        Args:
100            hamt: The HAMT instance for storage. Must have `values_are_bytes=True`.
101                  Its `read_only` status must match the `read_only` argument.
102            read_only: If True, the store is in read-only mode.
103            encryption_key: A 32-byte key for ChaCha20-Poly1305.
104            header: A header (bytes) used as associated data in encryption.
105        """
106        super().__init__(hamt, read_only=read_only)
107
108        if len(encryption_key) != 32:
109            raise ValueError("Encryption key must be exactly 32 bytes long.")
110        self.encryption_key = encryption_key
111        self.header = header
112        self.metadata_read_cache: dict[str, bytes] = {}

Initializes the SimpleEncryptedZarrHAMTStore.

Args: hamt: The HAMT instance for storage. Must have values_are_bytes=True. Its read_only status must match the read_only argument. read_only: If True, the store is in read-only mode. encryption_key: A 32-byte key for ChaCha20-Poly1305. header: A header (bytes) used as associated data in encryption.

encryption_key
header
def with_read_only( self, read_only: bool = False) -> SimpleEncryptedZarrHAMTStore:
114    def with_read_only(self, read_only: bool = False) -> "SimpleEncryptedZarrHAMTStore":
115        if read_only == self.read_only:
116            return self
117
118        clone = type(self).__new__(type(self))
119        clone.hamt = self.hamt
120        clone.encryption_key = self.encryption_key
121        clone.header = self.header
122        clone.metadata_read_cache = self.metadata_read_cache
123        clone._forced_read_only = read_only  # safe; attribute is declared
124        zarr.abc.store.Store.__init__(clone, read_only=read_only)
125        return clone

Return this store (if the flag already matches) or a shallow clone that presents the requested read‑only status.

The clone shares the same ~py_hamt.hamt.HAMT instance; no flushing, network traffic or async work is done.

async def set(self, key: str, value: zarr.core.buffer.core.Buffer) -> None:
186    async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None:
187        """Encrypt and store a value, then update cached metadata."""
188        if self.read_only:
189            raise Exception("Cannot write to a read-only store.")
190
191        raw_bytes = value.to_bytes()
192        # Encrypt it
193        encrypted_bytes = self._encrypt(raw_bytes)
194        await self.hamt.set(key, encrypted_bytes)
195        if key in self.metadata_read_cache:
196            self.metadata_read_cache[key] = raw_bytes

Encrypt and store a value, then update cached metadata.

class ShardedZarrStore(zarr.abc.store.Store):
 445class ShardedZarrStore(zarr.abc.store.Store):
 446    """
 447    Implements the Zarr Store API using a sharded layout for chunk CIDs.
 448
 449    ``sharded_zarr_v1`` roots keep the original single global shard index for
 450    compatibility. ``sharded_zarr_v2`` roots keep one shard index per Zarr array
 451    path, allowing grouped arrays to reuse chunk coordinates without collisions.
 452    """
 453
 454    _V1_COORDINATE_ARRAY_PREFIXES: ClassVar[frozenset[str]] = frozenset({
 455        "time",
 456        "lat",
 457        "lon",
 458        "latitude",
 459        "longitude",
 460        "forecast_reference_time",
 461        "step",
 462    })
 463    _V1_DEPRECATION_MESSAGE: ClassVar[str] = (
 464        "sharded_zarr_v1 is deprecated and will be removed in a future py-hamt "
 465        "release. Prefer sharded_zarr_v2 for new stores; pyramid Zarr readers "
 466        "should open the desired group explicitly, for example group='0'."
 467    )
 468    _V2_MULTI_GROUP_READ_MESSAGE: ClassVar[str] = (
 469        "sharded_zarr_v2 stores with multiple top-level groups require an "
 470        "explicit Zarr group when reading. Open the desired pyramid level with "
 471        "xr.open_zarr(..., group='0') or another available group."
 472    )
 473    _V2_WRITE_GROUP_MESSAGE: ClassVar[str] = (
 474        "sharded_zarr_v2 writes require an explicit Zarr group. Write the "
 475        "dataset with ds.to_zarr(..., group='0') or another group name."
 476    )
 477    # Sparse reads of a *single* shard before "auto" latches the whole store to
 478    # full decodes. Measured crossover, one CAS round trip per sparse entry:
 479    #
 480    #     lookups     full     sparse
 481    #           1    775ms      3.6ms
 482    #          32    775ms     82.2ms
 483    #         128    830ms    340.0ms
 484    #         256    861ms    706.3ms   <- sparse still ahead
 485    #         512    832ms   1334.9ms   <- full ahead
 486    #
 487    # Sparse costs ~2.6ms/lookup against a ~800ms flat full decode, so the
 488    # break-even is ~300; 256 trips just before it. Unlike the jaxray reference
 489    # (threshold 32, local blockstore reads), this is a *scan-detection*
 490    # threshold rather than a per-shard promotion point: it is paid once for the
 491    # whole store, not once per shard.
 492    _SPARSE_PROMOTE_THRESHOLD: ClassVar[int] = 256
 493
 494    def __init__(
 495        self,
 496        cas: ContentAddressedStore,
 497        read_only: bool,
 498        root_cid: Optional[str] = None,
 499        *,
 500        max_cache_memory_bytes: int = 100 * 1024 * 1024,  # 100MB default
 501        shard_read_mode: ShardReadMode = "auto",
 502    ):
 503        """Use the async `open()` classmethod to instantiate this class."""
 504        super().__init__(read_only=read_only)
 505        if shard_read_mode not in {"full", "sparse", "auto"}:
 506            raise ValueError(
 507                f"Unsupported shard_read_mode: {shard_read_mode!r}. "
 508                "Expected 'full', 'sparse', or 'auto'."
 509            )
 510        self.cas = cas
 511        self._root_cid = root_cid
 512        self.shard_read_mode = shard_read_mode
 513        self._root_obj: dict = {}
 514        self._manifest_version = SHARDED_ZARR_V1
 515
 516        self._resize_lock = asyncio.Lock()
 517        self._resize_complete = asyncio.Event()
 518        self._resize_complete.set()
 519        self._write_lock = asyncio.Lock()
 520        self._shard_locks: DefaultDict[ShardCacheKey, asyncio.Lock] = defaultdict(
 521            asyncio.Lock
 522        )
 523
 524        self._shard_data_cache = MemoryBoundedLRUCache(max_cache_memory_bytes)
 525        self._pending_shard_loads: Dict[ShardCacheKey, asyncio.Event] = {}
 526        # Per-shard sparse-read counts, used only to detect the scan pattern in
 527        # "auto" mode. Detection is per-shard because 256 reads spread across
 528        # 256 distinct shards is a point-read workload, not a scan.
 529        self._sparse_read_counts: Dict[ShardCacheKey, int] = {}
 530        # Latched once any single shard crosses the threshold: the caller is
 531        # scanning, so every shard gets the full path from here on. Store-wide
 532        # because the access pattern belongs to the caller, not the shard.
 533        #
 534        # Held in a one-element list so with_read_only clones share the *cell*
 535        # rather than a copied bool. They already share the counters and the
 536        # cache, and a clone that latched would otherwise clear those shared
 537        # counters while leaving its siblings believing they were still sparse
 538        # -- so the next clone would resume sparse reads with no counter left
 539        # to re-earn promotion. See _full_mode_latched.
 540        self._full_mode_latched_cell: List[bool] = [False]
 541        self._metadata_read_cache: Dict[str, bytes] = {}
 542
 543        self.array_indices: Dict[str, ArrayIndex] = {}
 544        self._primary_array_path: Optional[str] = None
 545        # True only when the primary path was heuristically inferred for a
 546        # legacy V1 root (never recorded on disk). Tracked separately from the
 547        # path's truthiness because an inferred *root* primary is "", which is
 548        # indistinguishable from "not inferred" under a plain truthiness check.
 549        self._primary_inferred: bool = False
 550        self._default_chunks_per_shard: Optional[int] = None
 551
 552        self._array_shape: Tuple[int, ...] = ()
 553        self._chunk_shape: Tuple[int, ...] = ()
 554        self._chunks_per_dim: Tuple[int, ...] = ()
 555        self._chunks_per_shard: int = 0
 556        self._num_shards: int = 0
 557        self._total_chunks: int = 0
 558
 559        self._dirty_root = False
 560        self._v2_pending_root_group_write = False
 561
 562    @staticmethod
 563    def _normalize_array_path(array_path: str) -> str:
 564        return array_path.strip("/")
 565
 566    @staticmethod
 567    def _array_path_from_metadata_key(key: str) -> Optional[str]:
 568        if key in {"zarr.json", ".zarray"}:
 569            return ""
 570        if key.endswith("/zarr.json"):
 571            return key[: -len("/zarr.json")]
 572        if key.endswith("/.zarray"):
 573            return key[: -len("/.zarray")]
 574        return None
 575
 576    @staticmethod
 577    def _group_path_from_metadata_key(key: str) -> Optional[str]:
 578        if key in {"zarr.json", ".zgroup"}:
 579            return ""
 580        if key.endswith("/zarr.json"):
 581            return key[: -len("/zarr.json")]
 582        if key.endswith("/.zgroup"):
 583            return key[: -len("/.zgroup")]
 584        return None
 585
 586    @staticmethod
 587    def _format_chunk_key(array_path: str, coords: tuple[int, ...]) -> str:
 588        coord_path = "/".join(str(coord) for coord in coords)
 589        if array_path:
 590            return f"{array_path}/c/{coord_path}"
 591        return f"c/{coord_path}"
 592
 593    @staticmethod
 594    def _v2_group_metadata_key(group_path: str) -> str:
 595        return ".zgroup" if group_path == "" else f"{group_path}/.zgroup"
 596
 597    @staticmethod
 598    def _v3_group_metadata_key(group_path: str) -> str:
 599        return "zarr.json" if group_path == "" else f"{group_path}/zarr.json"
 600
 601    @staticmethod
 602    def _coords_from_linear_index(
 603        linear_index: int, chunks_per_dim: tuple[int, ...]
 604    ) -> tuple[int, ...]:
 605        coords: list[int] = []
 606        remaining = linear_index
 607        for stride in reversed(chunks_per_dim):
 608            coords.append(remaining % stride)
 609            remaining //= stride
 610        return tuple(reversed(coords))
 611
 612    def __update_geometry(self) -> None:
 613        """Calculates legacy v1 geometric properties from the base shapes."""
 614        index = ArrayIndex.new(
 615            array_path="",
 616            array_shape=self._array_shape,
 617            chunk_shape=self._chunk_shape,
 618            chunks_per_shard=self._chunks_per_shard,
 619        )
 620        self._chunks_per_dim = index.chunks_per_dim
 621        self._total_chunks = index.total_chunks
 622        self._num_shards = index.num_shards
 623
 624    @classmethod
 625    def _warn_v1_deprecated(cls, *, stacklevel: int) -> None:
 626        warnings.warn(
 627            cls._V1_DEPRECATION_MESSAGE,
 628            ShardedZarrV1DeprecationWarning,
 629            stacklevel=stacklevel,
 630        )
 631
 632    @classmethod
 633    async def open(
 634        cls,
 635        cas: ContentAddressedStore,
 636        read_only: bool,
 637        root_cid: Optional[str] = None,
 638        *,
 639        array_shape: Optional[Tuple[int, ...]] = None,
 640        chunk_shape: Optional[Tuple[int, ...]] = None,
 641        chunks_per_shard: Optional[int] = None,
 642        max_cache_memory_bytes: int = 100 * 1024 * 1024,  # 100MB default
 643        manifest_version: Optional[str] = None,
 644        primary_array_path: str = "",
 645        shard_read_mode: ShardReadMode = "auto",
 646    ) -> "ShardedZarrStore":
 647        """
 648        Asynchronously opens an existing ShardedZarrStore or initializes a new one.
 649
 650        Shape-based creation remains the v1 compatibility path. To create a new
 651        path-aware v2 store, pass ``manifest_version="sharded_zarr_v2"`` or omit
 652        ``array_shape``/``chunk_shape`` and provide ``chunks_per_shard``.
 653
 654        ``shard_read_mode`` controls how a **read-only** cache miss resolves a
 655        chunk pointer. It has no effect on writes: a writable store always goes
 656        through the shard cache so pending writes stay visible, so writes behave
 657        as ``"full"`` does regardless of this setting.
 658
 659        - ``"auto"`` (the default) starts sparse, then latches the **entire
 660          store** to full decodes once any *single* shard has been read
 661          ``_SPARSE_PROMOTE_THRESHOLD`` times. The latch is store-wide because
 662          the access pattern belongs to the caller rather than the shard: a
 663          caller reading one shard that heavily is scanning and will scan the
 664          rest too, so making every other shard re-learn that independently
 665          would re-pay the detection cost on each one. It is permanent for the
 666          store's lifetime and unaffected by cache eviction. Below the
 667          threshold it is byte-for-byte the ``"sparse"`` path, so point reads
 668          pay nothing for the safety net.
 669        - ``"sparse"`` fetches only the requested entry and caches nothing. Far
 670          cheaper for point reads, but degrades without bound on a scan,
 671          eventually costing more than ``"full"``. Pin this when you know the
 672          workload is point reads and want to rule out the latch entirely --
 673          for instance a long-lived reader that hammers one hot shard without
 674          ever scanning, which ``"auto"`` would latch on.
 675        - ``"full"`` decodes and caches the whole shard. Flat cost regardless of
 676          how many chunks are then read from it, so it suits known scans and
 677          skips ``"auto"``'s detection cost.
 678        """
 679        store = cls(
 680            cas,
 681            read_only,
 682            root_cid,
 683            max_cache_memory_bytes=max_cache_memory_bytes,
 684            shard_read_mode=shard_read_mode,
 685        )
 686        if root_cid:
 687            await store._load_root_from_cid()
 688        elif not read_only:
 689            if manifest_version not in {None, SHARDED_ZARR_V1, SHARDED_ZARR_V2}:
 690                raise ValueError(f"Incompatible manifest version: {manifest_version}.")
 691
 692            if (
 693                manifest_version in {None, SHARDED_ZARR_V1}
 694                and array_shape is None
 695                and chunk_shape is None
 696                and chunks_per_shard is None
 697            ):
 698                raise ValueError(
 699                    "array_shape and chunk_shape must be provided for a new store."
 700                )
 701            if manifest_version in {None, SHARDED_ZARR_V1} and (
 702                (array_shape is None) != (chunk_shape is None)
 703            ):
 704                raise ValueError(
 705                    "array_shape and chunk_shape must be provided for a new store."
 706                )
 707            if manifest_version == SHARDED_ZARR_V1 and (
 708                array_shape is None or chunk_shape is None
 709            ):
 710                raise ValueError(
 711                    "array_shape and chunk_shape must be provided for a new store."
 712                )
 713
 714            if not isinstance(chunks_per_shard, int) or chunks_per_shard <= 0:
 715                raise ValueError("chunks_per_shard must be a positive integer.")
 716
 717            use_v2 = manifest_version == SHARDED_ZARR_V2 or (
 718                array_shape is None and chunk_shape is None
 719            )
 720            if use_v2:
 721                if (array_shape is None) != (chunk_shape is None):
 722                    raise ValueError(
 723                        "array_shape and chunk_shape must both be provided when seeding a v2 array index."
 724                    )
 725                store._initialize_new_root_v2(
 726                    chunks_per_shard=chunks_per_shard,
 727                    array_shape=array_shape,
 728                    chunk_shape=chunk_shape,
 729                    primary_array_path=primary_array_path,
 730                )
 731            else:
 732                if array_shape is None or chunk_shape is None:  # pragma: no cover
 733                    raise ValueError(
 734                        "array_shape and chunk_shape must be provided for a new store."
 735                    )
 736                store._initialize_new_root(array_shape, chunk_shape, chunks_per_shard)
 737        else:
 738            raise ValueError("root_cid must be provided for a read-only store.")
 739        return store
 740
 741    def _initialize_new_root(
 742        self,
 743        array_shape: Tuple[int, ...],
 744        chunk_shape: Tuple[int, ...],
 745        chunks_per_shard: int,
 746    ) -> None:
 747        self._warn_v1_deprecated(stacklevel=4)
 748        self._manifest_version = SHARDED_ZARR_V1
 749        self._array_shape = tuple(array_shape)
 750        self._chunk_shape = tuple(chunk_shape)
 751        self._chunks_per_shard = chunks_per_shard
 752        self._default_chunks_per_shard = chunks_per_shard
 753
 754        self.__update_geometry()
 755
 756        self._root_obj = {
 757            "manifest_version": SHARDED_ZARR_V1,
 758            "metadata": {},
 759            "chunks": {
 760                "array_shape": list(self._array_shape),
 761                "chunk_shape": list(self._chunk_shape),
 762                "sharding_config": {
 763                    "chunks_per_shard": self._chunks_per_shard,
 764                },
 765                "shard_cids": [None] * self._num_shards,
 766            },
 767        }
 768        self.array_indices = {
 769            "": ArrayIndex(
 770                array_path="",
 771                array_shape=self._array_shape,
 772                chunk_shape=self._chunk_shape,
 773                chunks_per_shard=self._chunks_per_shard,
 774                shard_cids=self._root_obj["chunks"]["shard_cids"],
 775            )
 776        }
 777        self._primary_array_path = ""
 778        self._dirty_root = True
 779
 780    def _initialize_new_root_v2(
 781        self,
 782        *,
 783        chunks_per_shard: int,
 784        array_shape: Optional[Tuple[int, ...]] = None,
 785        chunk_shape: Optional[Tuple[int, ...]] = None,
 786        primary_array_path: str = "",
 787    ) -> None:
 788        self._manifest_version = SHARDED_ZARR_V2
 789        self._default_chunks_per_shard = chunks_per_shard
 790        self._root_obj = {
 791            "manifest_version": SHARDED_ZARR_V2,
 792            "store_type": "py_hamt.sharded_zarr",
 793            "zarr_format": 3,
 794            "sharding_config": {
 795                "chunks_per_shard": chunks_per_shard,
 796                "order": "C",
 797            },
 798            "metadata": {},
 799            "arrays": {},
 800        }
 801        self.array_indices = {}
 802        self._primary_array_path = None
 803        self._array_shape = ()
 804        self._chunk_shape = ()
 805        self._chunks_per_dim = ()
 806        self._chunks_per_shard = chunks_per_shard
 807        self._num_shards = 0
 808        self._total_chunks = 0
 809
 810        if array_shape is not None and chunk_shape is not None:
 811            self._register_or_update_array_index(
 812                array_path=primary_array_path,
 813                array_shape=tuple(array_shape),
 814                chunk_shape=tuple(chunk_shape),
 815                chunks_per_shard=chunks_per_shard,
 816            )
 817        self._dirty_root = True
 818
 819    async def _load_root_from_cid(self) -> None:
 820        root_bytes = await self.cas.load(self._root_cid)
 821        try:
 822            decoded_root = dag_cbor.decode(root_bytes)
 823            if not isinstance(decoded_root, dict):
 824                raise ValueError("Root object is not a valid dictionary.")
 825            self._root_obj = decoded_root
 826        except Exception as e:
 827            raise ValueError(f"Failed to decode root object: {e}") from e
 828
 829        manifest_version = self._root_obj.get("manifest_version")
 830        if manifest_version == SHARDED_ZARR_V1:
 831            self._load_v1_root()
 832            await self._infer_v1_legacy_primary_array_path()
 833        elif manifest_version == SHARDED_ZARR_V2:
 834            self._load_v2_root()
 835        else:
 836            raise ValueError(
 837                f"Incompatible manifest version: {manifest_version!r}. Expected '{SHARDED_ZARR_V1}' or '{SHARDED_ZARR_V2}'."
 838            )
 839
 840    def _load_v1_root(self) -> None:
 841        self._warn_v1_deprecated(stacklevel=5)
 842        if "chunks" not in self._root_obj:
 843            raise ValueError("Root object is not a valid dictionary with 'chunks' key.")
 844        chunk_info = self._root_obj["chunks"]
 845        if not isinstance(chunk_info.get("shard_cids"), list):
 846            raise ValueError("shard_cids is not a list.")
 847
 848        self._manifest_version = SHARDED_ZARR_V1
 849        self._array_shape = tuple(chunk_info["array_shape"])
 850        self._chunk_shape = tuple(chunk_info["chunk_shape"])
 851        self._chunks_per_shard = chunk_info["sharding_config"]["chunks_per_shard"]
 852        self._default_chunks_per_shard = self._chunks_per_shard
 853
 854        self.__update_geometry()
 855
 856        if len(chunk_info["shard_cids"]) != self._num_shards:
 857            raise ValueError(
 858                f"Inconsistent number of shards. Expected {self._num_shards}, found {len(chunk_info['shard_cids'])}."
 859            )
 860        self.array_indices = {
 861            "": ArrayIndex(
 862                array_path="",
 863                array_shape=self._array_shape,
 864                chunk_shape=self._chunk_shape,
 865                chunks_per_shard=self._chunks_per_shard,
 866                shard_cids=chunk_info["shard_cids"],
 867            )
 868        }
 869        primary_array_path = chunk_info.get("primary_array_path", "")
 870        self._primary_array_path = (
 871            self._normalize_array_path(primary_array_path)
 872            if isinstance(primary_array_path, str)
 873            else ""
 874        )
 875
 876    async def _infer_v1_legacy_primary_array_path(self) -> None:
 877        chunk_info = self._root_obj["chunks"]
 878        # Only legacy roots missing the field may be inferred; recorded values win.
 879        if "primary_array_path" in chunk_info:
 880            return
 881
 882        metadata = self._root_obj.get("metadata")
 883        if not isinstance(metadata, dict):
 884            return
 885
 886        candidates: list[tuple[str, IPLDKind]] = []
 887        for key, metadata_cid in metadata.items():
 888            if not isinstance(key, str):
 889                continue
 890            array_path = self._array_path_from_metadata_key(key)
 891            if array_path is None:
 892                continue
 893            normalized_path = self._normalize_array_path(array_path)
 894            if normalized_path.rsplit("/", 1)[-1] in self._V1_COORDINATE_ARRAY_PREFIXES:
 895                continue
 896
 897            candidates.append((normalized_path, metadata_cid))
 898
 899        matching_paths: set[str] = set()
 900        for batch_start in range(0, len(candidates), _V1_INFERENCE_CONCURRENCY):
 901            batch = candidates[batch_start : batch_start + _V1_INFERENCE_CONCURRENCY]
 902            metadata_results = await asyncio.gather(
 903                *(self.cas.load(metadata_cid) for _, metadata_cid in batch),
 904                return_exceptions=True,
 905            )
 906
 907            for (normalized_path, _), metadata_result in zip(batch, metadata_results):
 908                if isinstance(metadata_result, BaseException):
 909                    if isinstance(metadata_result, asyncio.CancelledError):
 910                        raise metadata_result
 911                    # A candidate we could not read might have been the true
 912                    # primary. Guessing from the survivors could rebind shard
 913                    # data under the wrong prefix, so abort the inference and
 914                    # keep the legacy default instead.
 915                    return
 916                metadata_json = self._decode_metadata_json(metadata_result)
 917                if metadata_json is None:
 918                    continue
 919                declared_shape = metadata_json.get("shape")
 920                if not (
 921                    isinstance(declared_shape, (list, tuple))
 922                    and tuple(declared_shape) == tuple(self._array_shape)
 923                ):
 924                    continue
 925                declared_chunk_shape = self._declared_chunk_shape(metadata_json)
 926                if declared_chunk_shape != tuple(self._chunk_shape):
 927                    continue
 928                matching_paths.add(normalized_path)
 929
 930            # Once two different paths match, later metadata cannot make the
 931            # inference unambiguous. Avoid issuing more remote CAS requests.
 932            if len(matching_paths) > 1:
 933                return
 934
 935        if len(matching_paths) == 1:
 936            # Inference only lands here when exactly one candidate matches, so
 937            # the identified primary is correct. The flag keeps the
 938            # (possibly empty-string) inferred primary exclusive so foreign
 939            # chunk writes route to metadata instead of rebinding over it.
 940            self._primary_array_path = next(iter(matching_paths))
 941            self._primary_inferred = True
 942            if not self.read_only:
 943                # Persist the unambiguous inference so a later reopen routes from
 944                # a recorded primary rather than re-inferring. This is what makes
 945                # routing deterministic: without it, adding a second
 946                # same-geometry array would make the reopen inference ambiguous
 947                # and misroute a metadata-stored chunk into the shard slot.
 948                # Read-only opens cannot flush, so they rely on the flag alone.
 949                self._root_obj["chunks"]["primary_array_path"] = (
 950                    self._primary_array_path
 951                )
 952                self._dirty_root = True
 953
 954    @staticmethod
 955    def _declared_chunk_shape(metadata_json: dict) -> Optional[tuple[int, ...]]:
 956        """Extract the chunk shape a zarr v2/v3 array metadata document declares."""
 957        chunk_grid = metadata_json.get("chunk_grid")
 958        if isinstance(chunk_grid, dict):
 959            configuration = chunk_grid.get("configuration")
 960            if isinstance(configuration, dict):
 961                chunk_shape = configuration.get("chunk_shape")
 962                if isinstance(chunk_shape, (list, tuple)):
 963                    return tuple(chunk_shape)
 964        chunks = metadata_json.get("chunks")
 965        if isinstance(chunks, (list, tuple)):
 966            return tuple(chunks)
 967        return None
 968
 969    def _load_v2_root(self) -> None:
 970        metadata = self._root_obj.get("metadata")
 971        arrays = self._root_obj.get("arrays")
 972        if not isinstance(metadata, dict) or not isinstance(arrays, dict):
 973            raise ValueError(
 974                "Root object is not a valid v2 dictionary with 'metadata' and 'arrays' keys."
 975            )
 976
 977        self._manifest_version = SHARDED_ZARR_V2
 978        self.array_indices = {}
 979        self._primary_array_path = None
 980        root_sharding_config = self._root_obj.get("sharding_config", {})
 981        if isinstance(root_sharding_config, dict):
 982            self._default_chunks_per_shard = root_sharding_config.get(
 983                "chunks_per_shard"
 984            )
 985        else:
 986            self._default_chunks_per_shard = None
 987
 988        for array_path, array_manifest in arrays.items():
 989            if not isinstance(array_path, str) or not isinstance(array_manifest, dict):
 990                raise ValueError("arrays must map string paths to dictionaries.")
 991            try:
 992                array_index = ArrayIndex.from_manifest(array_path, array_manifest)
 993            except ValueError as exc:
 994                if str(exc).startswith("Inconsistent number of shards"):
 995                    raise ValueError(
 996                        f"Inconsistent number of shards for array '{array_path}'. {exc}"
 997                    ) from exc
 998                raise
 999            self.array_indices[array_index.array_path] = array_index
1000            if self._primary_array_path is None:
1001                self._primary_array_path = array_index.array_path
1002
1003        if self.array_indices:
1004            primary_index = self.array_indices[self._primary_array_path or ""]
1005            self._default_chunks_per_shard = primary_index.chunks_per_shard
1006            self._set_legacy_geometry_from_index(primary_index)
1007        else:
1008            self._array_shape = ()
1009            self._chunk_shape = ()
1010            self._chunks_per_dim = ()
1011            self._chunks_per_shard = 0
1012            self._num_shards = 0
1013            self._total_chunks = 0
1014
1015    def _set_legacy_geometry_from_index(self, array_index: ArrayIndex) -> None:
1016        self._array_shape = array_index.array_shape
1017        self._chunk_shape = array_index.chunk_shape
1018        self._chunks_per_dim = array_index.chunks_per_dim
1019        self._chunks_per_shard = array_index.chunks_per_shard
1020        self._num_shards = array_index.num_shards
1021        self._total_chunks = array_index.total_chunks
1022
1023    def _sync_arrays_to_root(self) -> None:
1024        if self._manifest_version == SHARDED_ZARR_V2:
1025            self._root_obj["arrays"] = {
1026                array_path: array_index.to_manifest()
1027                for array_path, array_index in self.array_indices.items()
1028            }
1029
1030    def _register_or_update_array_index(
1031        self,
1032        *,
1033        array_path: str,
1034        array_shape: tuple[int, ...],
1035        chunk_shape: tuple[int, ...],
1036        chunks_per_shard: Optional[int] = None,
1037    ) -> ArrayIndex:
1038        normalized_path = self._normalize_array_path(array_path)
1039        if chunks_per_shard is None:
1040            chunks_per_shard = self._default_chunks_per_shard
1041        if chunks_per_shard is None:
1042            raise RuntimeError("Store is missing a default chunks_per_shard value.")
1043
1044        existing = self.array_indices.get(normalized_path)
1045        if existing is None:
1046            array_index = ArrayIndex.new(
1047                array_path=normalized_path,
1048                array_shape=array_shape,
1049                chunk_shape=chunk_shape,
1050                chunks_per_shard=chunks_per_shard,
1051            )
1052            self.array_indices[normalized_path] = array_index
1053            if self._primary_array_path is None:
1054                self._primary_array_path = normalized_path
1055                self._set_legacy_geometry_from_index(array_index)
1056        else:
1057            new_chunk_shape = tuple(chunk_shape)
1058            if existing.chunk_shape != new_chunk_shape:
1059                raise ValueError(
1060                    f"Cannot change chunk_shape for existing array index '{normalized_path}'."
1061                )
1062            existing.resize(tuple(array_shape))
1063            array_index = existing
1064
1065        if self._primary_array_path == normalized_path:
1066            self._set_legacy_geometry_from_index(array_index)
1067        self._sync_arrays_to_root()
1068        self._dirty_root = True
1069        return array_index
1070
1071    def _v2_top_level_groups(self) -> set[str]:
1072        if self._manifest_version != SHARDED_ZARR_V2:
1073            return set()
1074        return {
1075            array_path.split("/", 1)[0]
1076            for array_path in self.array_indices
1077            if "/" in array_path
1078        }
1079
1080    def _v2_is_grouped_only(self) -> bool:
1081        if self._manifest_version != SHARDED_ZARR_V2 or not self.array_indices:
1082            return False
1083        return all("/" in array_path for array_path in self.array_indices)
1084
1085    def _v2_default_group_for_root_read(self) -> Optional[str]:
1086        if not self._v2_is_grouped_only():
1087            return None
1088        groups = self._v2_top_level_groups()
1089        if len(groups) != 1:
1090            return None
1091        return next(iter(groups))
1092
1093    def _v2_requires_explicit_group_for_root_read(self) -> bool:
1094        return self._v2_is_grouped_only() and len(self._v2_top_level_groups()) > 1
1095
1096    def _v2_effective_read_key(self, key: str) -> str:
1097        default_group = self._v2_default_group_for_root_read()
1098        if default_group is None:
1099            return key
1100
1101        normalized_key = key.strip("/")
1102        if normalized_key in {"zarr.json", ".zgroup", ".zattrs", ".zmetadata", ""}:
1103            return key
1104        if normalized_key == default_group or normalized_key.startswith(
1105            f"{default_group}/"
1106        ):
1107            return key
1108        return f"{default_group}/{normalized_key}"
1109
1110    def _v2_effective_list_dir_prefix(self, normalized_prefix: str) -> str:
1111        default_group = self._v2_default_group_for_root_read()
1112        if default_group is None:
1113            return normalized_prefix
1114        if normalized_prefix == "":
1115            return default_group
1116        if normalized_prefix == default_group or normalized_prefix.startswith(
1117            f"{default_group}/"
1118        ):
1119            return normalized_prefix
1120        return f"{default_group}/{normalized_prefix}"
1121
1122    def _strip_v2_root_consolidated_metadata(self, key: str, raw_data: bytes) -> bytes:
1123        if key != "zarr.json" or not self._v2_is_grouped_only():
1124            return raw_data
1125
1126        metadata_json = self._decode_metadata_json(raw_data)
1127        if (
1128            metadata_json is None
1129            or metadata_json.get("node_type") != "group"
1130            or "consolidated_metadata" not in metadata_json
1131        ):
1132            return raw_data
1133
1134        metadata_json.pop("consolidated_metadata")
1135        return json.dumps(metadata_json).encode("utf-8")
1136
1137    @staticmethod
1138    def _v2_path_has_group(array_path: str) -> bool:
1139        normalized_path = ShardedZarrStore._normalize_array_path(array_path)
1140        return "/" in normalized_path
1141
1142    def _raise_if_v2_write_without_group(self, key: str, raw_data: bytes) -> None:
1143        if self._manifest_version != SHARDED_ZARR_V2:
1144            return
1145
1146        metadata_json = self._decode_metadata_json(raw_data)
1147        if metadata_json is None:
1148            return
1149
1150        group_path = self._group_path_from_metadata_key(key)
1151        metadata_path = self._array_path_from_metadata_key(key)
1152        array_metadata = self._extract_array_metadata(metadata_json)
1153        is_group_metadata = group_path is not None and array_metadata is None
1154
1155        if is_group_metadata and group_path == "":
1156            self._v2_pending_root_group_write = True
1157            return
1158        if is_group_metadata:
1159            self._v2_pending_root_group_write = False
1160            return
1161
1162        if metadata_path is None or array_metadata is None:
1163            return
1164
1165        if self._v2_path_has_group(metadata_path):
1166            self._v2_pending_root_group_write = False
1167            return
1168
1169        if self._v2_pending_root_group_write:
1170            self._v2_pending_root_group_write = False
1171            raise ValueError(self._V2_WRITE_GROUP_MESSAGE)
1172
1173    async def _snapshot_shards_for_resize(
1174        self,
1175        array_index: ArrayIndex,
1176    ) -> dict[int, list[Optional[CID]]]:
1177        shards_by_index: dict[int, list[Optional[CID]]] = {}
1178        for shard_idx, shard_cid_obj in enumerate(array_index.shard_cids):
1179            cache_key = self._cache_key(array_index.array_path, shard_idx)
1180            shard_lock = self._shard_locks[cache_key]
1181            # Pin across fetch+read: an over-budget cache whose older entries are
1182            # all dirty or pinned would otherwise make the just-fetched clean
1183            # shard the sole eviction candidate, evicting it before the snapshot
1184            # read below and turning the load into a spurious RuntimeError.
1185            async with shard_lock, self._shard_data_cache.pin(cache_key):
1186                shard_data = await self._shard_data_cache.get(cache_key)
1187                if shard_data is None and shard_cid_obj is not None:
1188                    await self._fetch_and_cache_full_shard(
1189                        cache_key,
1190                        shard_idx,
1191                        str(shard_cid_obj),
1192                        array_index.chunks_per_shard,
1193                    )
1194                    shard_data = await self._shard_data_cache.get(cache_key)
1195                    if shard_data is None:  # pragma: no cover
1196                        raise RuntimeError(f"Failed to load shard {shard_idx}")
1197                if shard_data is not None:
1198                    shards_by_index[shard_idx] = list(shard_data)
1199        return shards_by_index
1200
1201    @staticmethod
1202    def _remap_shards_for_resize(
1203        old_shards_by_index: dict[int, list[Optional[CID]]],
1204        old_chunks_per_dim: tuple[int, ...],
1205        old_total_chunks: int,
1206        new_array_index: ArrayIndex,
1207    ) -> dict[int, list[Optional[CID]]]:
1208        new_shards_by_index = {
1209            shard_idx: [None] * new_array_index.chunks_per_shard
1210            for shard_idx in range(new_array_index.num_shards)
1211        }
1212        for old_shard_idx, old_shard in old_shards_by_index.items():
1213            for old_index_in_shard, pointer_cid_obj in enumerate(old_shard):
1214                if pointer_cid_obj is None:
1215                    continue
1216                old_linear_index = (
1217                    old_shard_idx * new_array_index.chunks_per_shard
1218                    + old_index_in_shard
1219                )
1220                if old_linear_index >= old_total_chunks:
1221                    continue
1222                coords = ShardedZarrStore._coords_from_linear_index(
1223                    old_linear_index, old_chunks_per_dim
1224                )
1225                if any(
1226                    coord >= chunks
1227                    for coord, chunks in zip(
1228                        coords, new_array_index.chunks_per_dim, strict=True
1229                    )
1230                ):
1231                    continue
1232
1233                new_linear_index = ShardedZarrStore._get_linear_chunk_index_for_index(
1234                    coords, new_array_index
1235                )
1236                new_shard_idx, new_index_in_shard = (
1237                    ShardedZarrStore._get_shard_info_for_index(
1238                        new_linear_index, new_array_index
1239                    )
1240                )
1241                new_shards_by_index[new_shard_idx][new_index_in_shard] = pointer_cid_obj
1242        return new_shards_by_index
1243
1244    async def _replace_shards_after_resize(
1245        self,
1246        array_index: ArrayIndex,
1247        old_num_shards: int,
1248        old_shard_cids: list[Optional[CID]],
1249        old_shards_by_index: dict[int, list[Optional[CID]]],
1250        new_shards_by_index: dict[int, list[Optional[CID]]],
1251    ) -> None:
1252        async with self._shard_data_cache._cache_lock:
1253            dirty_cache_keys = set(self._shard_data_cache._dirty_shards)
1254
1255        for shard_idx in range(array_index.num_shards):
1256            cache_key = self._cache_key(array_index.array_path, shard_idx)
1257            shard_lock = self._shard_locks[cache_key]
1258            new_shard = new_shards_by_index[shard_idx]
1259            old_shard = old_shards_by_index.get(shard_idx)
1260            old_shard_cid = (
1261                old_shard_cids[shard_idx] if shard_idx < len(old_shard_cids) else None
1262            )
1263            async with shard_lock:
1264                if all(pointer_cid_obj is None for pointer_cid_obj in new_shard):
1265                    array_index.shard_cids[shard_idx] = None
1266                    await self._shard_data_cache.discard(cache_key)
1267                elif old_shard == new_shard and (
1268                    old_shard_cid is not None or cache_key in dirty_cache_keys
1269                ):
1270                    array_index.shard_cids[shard_idx] = old_shard_cid
1271                else:
1272                    array_index.shard_cids[shard_idx] = None
1273                    await self._shard_data_cache.put(
1274                        cache_key, new_shard, is_dirty=True
1275                    )
1276
1277        for shard_idx in range(array_index.num_shards, old_num_shards):
1278            cache_key = self._cache_key(array_index.array_path, shard_idx)
1279            shard_lock = self._shard_locks[cache_key]
1280            async with shard_lock:
1281                await self._shard_data_cache.discard(cache_key)
1282
1283    @staticmethod
1284    def _can_fast_resize_leading_dimension(
1285        array_index: ArrayIndex, new_shape: tuple[int, ...]
1286    ) -> bool:
1287        """Return whether resizing only appends to the row-major chunk grid."""
1288        old_shape = array_index.array_shape
1289        return (
1290            len(old_shape) > 0
1291            and len(new_shape) == len(old_shape)
1292            and new_shape[0] >= old_shape[0]
1293            and new_shape[1:] == old_shape[1:]
1294            and array_index.order == "C"
1295        )
1296
1297    async def _resize_array_index(
1298        self, array_index: ArrayIndex, new_shape: tuple[int, ...]
1299    ) -> None:
1300        new_shape = tuple(new_shape)
1301        if self._can_fast_resize_leading_dimension(array_index, new_shape):
1302            array_index.resize(new_shape)
1303            if self._primary_array_path == array_index.array_path:
1304                self._set_legacy_geometry_from_index(array_index)
1305            self._sync_arrays_to_root()
1306            self._dirty_root = True
1307            return
1308
1309        old_num_shards = array_index.num_shards
1310        old_total_chunks = array_index.total_chunks
1311        old_chunks_per_dim = array_index.chunks_per_dim
1312        old_shard_cids = list(array_index.shard_cids)
1313        old_shards_by_index = await self._snapshot_shards_for_resize(array_index)
1314        array_index.resize(new_shape)
1315        new_shards_by_index = self._remap_shards_for_resize(
1316            old_shards_by_index,
1317            old_chunks_per_dim,
1318            old_total_chunks,
1319            array_index,
1320        )
1321        await self._replace_shards_after_resize(
1322            array_index,
1323            old_num_shards,
1324            old_shard_cids,
1325            old_shards_by_index,
1326            new_shards_by_index,
1327        )
1328        if self._primary_array_path == array_index.array_path:
1329            self._set_legacy_geometry_from_index(array_index)
1330        self._sync_arrays_to_root()
1331        self._dirty_root = True
1332
1333    async def _resize_array_index_guarded(
1334        self, array_index: ArrayIndex, new_shape: tuple[int, ...]
1335    ) -> None:
1336        async with self._resize_lock:
1337            self._resize_complete.clear()
1338            try:
1339                await self._resize_array_index(array_index, new_shape)
1340            finally:
1341                self._resize_complete.set()
1342
1343    @staticmethod
1344    def _decode_metadata_json(raw_data: bytes) -> Optional[dict]:
1345        try:
1346            decoded = json.loads(raw_data.decode("utf-8"))
1347        except (UnicodeDecodeError, json.JSONDecodeError):
1348            return None
1349        return decoded if isinstance(decoded, dict) else None
1350
1351    @staticmethod
1352    def _extract_array_metadata(
1353        metadata_json: dict,
1354    ) -> Optional[tuple[tuple[int, ...], tuple[int, ...]]]:
1355        shape = metadata_json.get("shape")
1356        if shape is None:
1357            return None
1358
1359        chunk_shape = None
1360        chunk_grid = metadata_json.get("chunk_grid")
1361        if isinstance(chunk_grid, dict):
1362            configuration = chunk_grid.get("configuration")
1363            if isinstance(configuration, dict):
1364                chunk_shape = configuration.get("chunk_shape")
1365
1366        if chunk_shape is None:
1367            chunk_shape = metadata_json.get("chunks")
1368
1369        if chunk_shape is None:
1370            return None
1371
1372        return tuple(int(dim) for dim in shape), tuple(int(dim) for dim in chunk_shape)
1373
1374    def _infer_v1_migration_source_array_path(self, primary_array_path: str) -> str:
1375        metadata = self._root_obj.get("metadata", {})
1376        candidates = [primary_array_path]
1377        primary_leaf = primary_array_path.rsplit("/", 1)[-1]
1378        if primary_leaf not in candidates:
1379            candidates.append(primary_leaf)
1380        candidates.append("")
1381
1382        for candidate in candidates:
1383            metadata_keys = (
1384                ("zarr.json", ".zarray")
1385                if candidate == ""
1386                else (f"{candidate}/zarr.json", f"{candidate}/.zarray")
1387            )
1388            if any(key in metadata for key in metadata_keys):
1389                return candidate
1390        return primary_leaf
1391
1392    @staticmethod
1393    def _rewrite_v1_metadata_key_for_migration(
1394        key: str, source_array_path: str, primary_array_path: str
1395    ) -> str:
1396        parent_path = (
1397            primary_array_path.rsplit("/", 1)[0] if "/" in primary_array_path else ""
1398        )
1399
1400        if source_array_path:
1401            source_prefix = f"{source_array_path}/"
1402            if key.startswith(source_prefix):
1403                return f"{primary_array_path}/{key[len(source_prefix) :]}"
1404        elif key.startswith("c/"):
1405            return f"{primary_array_path}/{key}"
1406        elif key in {"zarr.json", ".zarray"}:
1407            return f"{primary_array_path}/{key}"
1408
1409        if parent_path and "/" in key and not key.startswith(f"{parent_path}/"):
1410            return f"{parent_path}/{key}"
1411        return key
1412
1413    async def _add_missing_group_metadata(
1414        self, metadata: dict[str, IPLDKind], array_path: str
1415    ) -> None:
1416        parts = array_path.split("/")
1417        group_paths = ["/".join(parts[:idx]) for idx in range(len(parts))]
1418        uses_zarr_v2_metadata = f"{array_path}/.zarray" in metadata
1419        if uses_zarr_v2_metadata:
1420            group_metadata_key = self._v2_group_metadata_key
1421            group_metadata = json.dumps({"zarr_format": 2}).encode("utf-8")
1422        else:
1423            group_metadata_key = self._v3_group_metadata_key
1424            group_metadata = json.dumps({
1425                "zarr_format": 3,
1426                "node_type": "group",
1427                "attributes": {},
1428            }).encode("utf-8")
1429
1430        for group_path in group_paths:
1431            metadata_key = group_metadata_key(group_path)
1432            if metadata_key in metadata:
1433                if not uses_zarr_v2_metadata:
1434                    await self._strip_consolidated_metadata(metadata, metadata_key)
1435                continue
1436            metadata[metadata_key] = await self.cas.save(group_metadata, codec="raw")
1437
1438    async def _strip_consolidated_metadata(
1439        self, metadata: dict[str, IPLDKind], metadata_key: str
1440    ) -> None:
1441        raw_metadata = await self.cas.load(str(metadata[metadata_key]))
1442        metadata_json = self._decode_metadata_json(raw_metadata)
1443        if (
1444            metadata_json is None
1445            or metadata_json.get("node_type") != "group"
1446            or "consolidated_metadata" not in metadata_json
1447        ):
1448            return
1449
1450        metadata_json.pop("consolidated_metadata")
1451        metadata[metadata_key] = await self.cas.save(
1452            json.dumps(metadata_json).encode("utf-8"), codec="raw"
1453        )
1454
1455    async def _register_array_metadata_from_bytes(
1456        self, key: str, raw_data: bytes
1457    ) -> None:
1458        array_path = self._array_path_from_metadata_key(key)
1459        if array_path is None:
1460            return
1461
1462        metadata_json = self._decode_metadata_json(raw_data)
1463        if metadata_json is None:
1464            return
1465
1466        array_metadata = self._extract_array_metadata(metadata_json)
1467        if array_metadata is None and self._manifest_version == SHARDED_ZARR_V2:
1468            return
1469        if array_metadata is None:
1470            shape = metadata_json.get("shape")
1471            if shape is None:
1472                return
1473            new_array_shape = tuple(int(dim) for dim in shape)
1474            new_chunk_shape = self._chunk_shape
1475        else:
1476            new_array_shape, new_chunk_shape = array_metadata
1477
1478        if self._manifest_version == SHARDED_ZARR_V2:
1479            normalized_path = self._normalize_array_path(array_path)
1480            existing = self.array_indices.get(normalized_path)
1481            if existing is None:
1482                self._register_or_update_array_index(
1483                    array_path=array_path,
1484                    array_shape=new_array_shape,
1485                    chunk_shape=new_chunk_shape,
1486                )
1487            else:
1488                if existing.chunk_shape != new_chunk_shape:
1489                    raise ValueError(
1490                        f"Cannot change chunk_shape for existing array index '{normalized_path}'."
1491                    )
1492                if existing.array_shape != new_array_shape:
1493                    await self._resize_array_index_guarded(existing, new_array_shape)
1494            return
1495
1496        if (
1497            len(new_array_shape) == len(self._array_shape)
1498            and new_array_shape != self._array_shape
1499        ):
1500            await self._resize_store_unlocked(new_shape=new_array_shape)
1501
1502    async def _ensure_v2_parent_group_metadata(self, key: str) -> None:
1503        if self._manifest_version != SHARDED_ZARR_V2:
1504            return
1505
1506        metadata_path = self._array_path_from_metadata_key(key)
1507        if metadata_path is None:
1508            return
1509
1510        if key == ".zarray" or key.endswith("/.zarray"):
1511            group_metadata_key = self._v2_group_metadata_key
1512            group_metadata = json.dumps({"zarr_format": 2}).encode("utf-8")
1513        else:
1514            group_metadata_key = self._v3_group_metadata_key
1515            group_metadata = json.dumps({
1516                "zarr_format": 3,
1517                "node_type": "group",
1518                "attributes": {},
1519            }).encode("utf-8")
1520
1521        normalized_path = self._normalize_array_path(metadata_path)
1522        parent_paths = [""]
1523        if normalized_path:
1524            parts = normalized_path.split("/")
1525            parent_paths.extend("/".join(parts[:idx]) for idx in range(1, len(parts)))
1526
1527        for parent_path in parent_paths:
1528            metadata_key = group_metadata_key(parent_path)
1529            if metadata_key in self._root_obj["metadata"]:
1530                continue
1531            metadata_cid = await self.cas.save(group_metadata, codec="raw")
1532            self._root_obj["metadata"][metadata_key] = metadata_cid
1533            self._metadata_read_cache[metadata_key] = group_metadata
1534            self._dirty_root = True
1535
1536    async def _fetch_and_cache_full_shard(
1537        self,
1538        cache_key: ShardCacheKey,
1539        shard_idx: int,
1540        shard_cid: IPLDKind,
1541        expected_entries: int,
1542        max_retries: int = 3,
1543        retry_delay: float = 1.0,
1544    ) -> None:
1545        """
1546        Fetch a shard from CAS and cache it, with retry logic for transient errors.
1547        """
1548        for attempt in range(max_retries):
1549            try:
1550                shard_data_bytes = await self.cas.load(shard_cid)
1551                decoded_shard = dag_cbor.decode(shard_data_bytes)
1552                if not isinstance(decoded_shard, list):
1553                    raise TypeError(f"Shard {shard_idx} did not decode to a list.")
1554                if len(decoded_shard) != expected_entries:
1555                    raise ValueError(
1556                        f"Shard {shard_idx} contains {len(decoded_shard)} entries; expected {expected_entries}."
1557                    )
1558                shard_data: List[Optional[CID]] = []
1559                for item in decoded_shard:
1560                    if item is not None and not isinstance(item, CID):
1561                        raise TypeError(f"Shard {shard_idx} contains a non-CID entry.")
1562                    shard_data.append(item)
1563                await self._shard_data_cache.put(cache_key, shard_data)
1564                if cache_key in self._pending_shard_loads:
1565                    self._pending_shard_loads[cache_key].set()
1566                    del self._pending_shard_loads[cache_key]
1567                return
1568            except (ConnectionError, TimeoutError) as e:
1569                if attempt < max_retries - 1:
1570                    await asyncio.sleep(retry_delay * (2**attempt))
1571                    continue
1572                raise RuntimeError(
1573                    f"Failed to fetch shard {shard_idx} after {max_retries} attempts: {e}"
1574                ) from e
1575
1576    @property
1577    def _full_mode_latched(self) -> bool:
1578        """Whether ``auto`` mode has committed this store to full decodes.
1579
1580        Backed by a cell shared with every ``with_read_only`` clone, so a latch
1581        earned by one clone is immediately visible to all of them.
1582        """
1583        return self._full_mode_latched_cell[0]
1584
1585    @_full_mode_latched.setter
1586    def _full_mode_latched(self, value: bool) -> None:
1587        self._full_mode_latched_cell[0] = value
1588
1589    def _sparse_read_is_eligible(
1590        self,
1591        array_index: ArrayIndex,
1592        shard_idx: int,
1593        cached_shard: Optional[List[Optional[CID]]],
1594        byte_range: Optional[zarr.abc.store.ByteRequest],
1595    ) -> bool:
1596        """
1597        Whether a single-entry shard decode is structurally legal here.
1598
1599        Independent of ``shard_read_mode``: a writable store must go through the
1600        cache so pending writes stay visible, a cache hit is already cheaper
1601        than any fetch, a byte range needs the full CID resolution path, and an
1602        absent or out-of-range shard CID has nothing to sparsely decode.
1603        """
1604        return (
1605            self.read_only
1606            and cached_shard is None
1607            and byte_range is None
1608            and 0 <= shard_idx < array_index.num_shards
1609            and array_index.shard_cids[shard_idx] is not None
1610        )
1611
1612    def _auto_mode_wants_sparse(self, cache_key: ShardCacheKey) -> bool:
1613        """
1614        Record one ``auto``-mode sparse read and report whether to stay sparse.
1615
1616        Called only under ``self._shard_locks[cache_key]``, so each shard's
1617        read-modify-write of the counter is serialized. Once any single shard
1618        crosses the threshold the whole store latches to full mode: a caller
1619        reading one shard that heavily is scanning, and will scan the rest too.
1620
1621        ``_full_mode_latched`` is written under one shard's lock and read under
1622        others, so two shards can latch concurrently. That race is benign and
1623        deliberate — the write is idempotent (never ``True`` back to ``False``)
1624        and the worst outcome is one extra sparse read on a shard that was about
1625        to latch anyway, so it does not warrant a second lock.
1626        """
1627        if self._full_mode_latched:
1628            return False
1629        count = self._sparse_read_counts.get(cache_key, 0) + 1
1630        if count >= self._SPARSE_PROMOTE_THRESHOLD:
1631            self._full_mode_latched = True
1632            # Never read again once latched; drop whatever it accumulated.
1633            self._sparse_read_counts.clear()
1634            return False
1635        self._sparse_read_counts[cache_key] = count
1636        return True
1637
1638    async def _load_sparse_shard_entry(
1639        self,
1640        cache_key: ShardCacheKey,
1641        shard_idx: int,
1642        shard_cid: IPLDKind,
1643        index_in_shard: int,
1644        expected_entries: int,
1645    ) -> Optional[CID]:
1646        if await self._shard_data_cache.get(cache_key) is not None:
1647            return None
1648
1649        shard_data_bytes = await self.cas.load(shard_cid)
1650        entry_count, _ = _read_cbor_list_header(shard_data_bytes)
1651        if entry_count != expected_entries:
1652            raise ValueError(
1653                f"Shard {shard_idx} contains {entry_count} entries; expected {expected_entries}."
1654            )
1655        return decode_shard_entry(shard_data_bytes, index_in_shard)
1656
1657    def _parse_chunk_key(self, key: str) -> Optional[ChunkKey]:
1658        if key.endswith(ZARR_METADATA_SUFFIXES):
1659            return None
1660
1661        chunk_marker = "/c/"
1662        marker_idx = key.rfind(chunk_marker)
1663        if marker_idx != -1:
1664            array_path = key[:marker_idx]
1665            coord_part = key[marker_idx + len(chunk_marker) :]
1666        elif key.startswith("c/"):
1667            if self._manifest_version == SHARDED_ZARR_V1:
1668                recorded_path = self._root_obj.get("chunks", {}).get(
1669                    "primary_array_path"
1670                )
1671                if not (
1672                    isinstance(recorded_path, str)
1673                    and self._normalize_array_path(recorded_path) == ""
1674                ):
1675                    return None
1676            array_path = ""
1677            coord_part = key[len("c/") :]
1678        else:
1679            return self._parse_classic_v2_chunk_key(key)
1680
1681        normalized_path = self._normalize_array_path(array_path)
1682        if self._manifest_version == SHARDED_ZARR_V1:
1683            actual_array_name = (
1684                normalized_path.split("/")[-1] if normalized_path else ""
1685            )
1686            if actual_array_name in self._V1_COORDINATE_ARRAY_PREFIXES:
1687                return None
1688            recorded_path = self._root_obj.get("chunks", {}).get("primary_array_path")
1689            if isinstance(recorded_path, str):
1690                primary_path_is_exclusive = True
1691                effective_primary_path = recorded_path
1692            elif self._primary_inferred or self._primary_array_path:
1693                # An inferred primary is exclusive even when it is the root
1694                # ("") — the flag, not the truthiness, decides. Without it a
1695                # foreign same-rank named chunk would parse against the shard
1696                # index and rebind the primary to itself.
1697                primary_path_is_exclusive = True
1698                effective_primary_path = self._primary_array_path or ""
1699            else:
1700                primary_path_is_exclusive = False
1701                effective_primary_path = ""
1702            if (
1703                primary_path_is_exclusive
1704                and normalized_path
1705                != self._normalize_array_path(effective_primary_path)
1706            ):
1707                return None
1708
1709        parts = coord_part.split("/")
1710        try:
1711            coords = tuple(map(int, parts))
1712        except ValueError:
1713            classic_chunk = self._parse_classic_v2_chunk_key(key)
1714            if classic_chunk is not None:
1715                return classic_chunk
1716            raise
1717
1718        if self._manifest_version == SHARDED_ZARR_V1:
1719            key_is_primary = (
1720                primary_path_is_exclusive
1721                and normalized_path
1722                == self._normalize_array_path(effective_primary_path)
1723            )
1724            named_array_metadata = self._root_obj.get("metadata", {})
1725            if (
1726                normalized_path
1727                and not key_is_primary
1728                and len(coords) != len(self.array_indices[""].chunks_per_dim)
1729                and (
1730                    f"{normalized_path}/zarr.json" in named_array_metadata
1731                    or f"{normalized_path}/.zarray" in named_array_metadata
1732                )
1733            ):
1734                # A named array that registered its own metadata and whose
1735                # rank differs from the primary geometry can never be a
1736                # primary chunk: classify it as metadata instead of failing
1737                # coordinate validation. Keys under the effective primary path
1738                # still validate strictly so malformed primary keys fail loud.
1739                return None
1740            self._validate_chunk_coords(coords, self.array_indices[""])
1741        elif normalized_path in self.array_indices:
1742            self._validate_chunk_coords(coords, self.array_indices[normalized_path])
1743
1744        return ChunkKey(array_path=normalized_path, coords=coords)
1745
1746    def _parse_classic_v2_chunk_key(self, key: str) -> Optional[ChunkKey]:
1747        if self._manifest_version != SHARDED_ZARR_V2 or not self.array_indices:
1748            return None
1749
1750        dotted_chunk = self._parse_classic_dotted_v2_chunk_key(key)
1751        if dotted_chunk is not None:
1752            return dotted_chunk
1753
1754        for array_path, array_index in sorted(
1755            self.array_indices.items(), key=lambda item: len(item[0]), reverse=True
1756        ):
1757            prefix = f"{array_path}/" if array_path else ""
1758            if prefix:
1759                if not key.startswith(prefix):
1760                    continue
1761                coord_part = key[len(prefix) :]
1762            else:
1763                coord_part = key
1764
1765            parts = coord_part.split("/")
1766            if len(parts) != len(array_index.chunks_per_dim):
1767                continue
1768            if not all(part.isdecimal() for part in parts):
1769                continue
1770            coords = tuple(int(part) for part in parts)
1771            self._validate_chunk_coords(coords, array_index)
1772            return ChunkKey(array_path=array_path, coords=coords)
1773        return None
1774
1775    def _parse_classic_dotted_v2_chunk_key(self, key: str) -> Optional[ChunkKey]:
1776        array_path, _, coord_part = key.rpartition("/")
1777        if "." not in coord_part:
1778            return None
1779
1780        parts = coord_part.split(".")
1781        if not parts or not all(part.isdecimal() for part in parts):
1782            return None
1783
1784        normalized_path = self._normalize_array_path(array_path)
1785        array_index = self.array_indices.get(normalized_path)
1786        if array_index is None or len(parts) != len(array_index.chunks_per_dim):
1787            return None
1788
1789        coords = tuple(int(part) for part in parts)
1790        self._validate_chunk_coords(coords, array_index)
1791        return ChunkKey(array_path=normalized_path, coords=coords)
1792
1793    @staticmethod
1794    def _validate_chunk_coords(
1795        chunk_coords: tuple[int, ...], array_index: ArrayIndex
1796    ) -> None:
1797        if len(chunk_coords) != len(array_index.chunks_per_dim):
1798            raise IndexError("tuple index out of range")
1799        for i, c_coord in enumerate(chunk_coords):
1800            if not (0 <= c_coord < array_index.chunks_per_dim[i]):
1801                raise IndexError(
1802                    f"Chunk coordinate {c_coord} at dimension {i} is out of bounds for dimension size {array_index.chunks_per_dim[i]}."
1803                )
1804
1805    def _get_linear_chunk_index(self, chunk_coords: Tuple[int, ...]) -> int:
1806        return self._get_linear_chunk_index_for_index(
1807            tuple(chunk_coords), self.array_indices[""]
1808        )
1809
1810    @staticmethod
1811    def _get_linear_chunk_index_for_index(
1812        chunk_coords: tuple[int, ...], array_index: ArrayIndex
1813    ) -> int:
1814        linear_index = 0
1815        multiplier = 1
1816        for i in reversed(range(len(array_index.chunks_per_dim))):
1817            linear_index += chunk_coords[i] * multiplier
1818            multiplier *= array_index.chunks_per_dim[i]
1819        return linear_index
1820
1821    def _get_shard_info(self, linear_chunk_index: int) -> Tuple[int, int]:
1822        shard_idx = linear_chunk_index // self._chunks_per_shard
1823        index_in_shard = linear_chunk_index % self._chunks_per_shard
1824        return shard_idx, index_in_shard
1825
1826    @staticmethod
1827    def _get_shard_info_for_index(
1828        linear_chunk_index: int, array_index: ArrayIndex
1829    ) -> Tuple[int, int]:
1830        shard_idx = linear_chunk_index // array_index.chunks_per_shard
1831        index_in_shard = linear_chunk_index % array_index.chunks_per_shard
1832        return shard_idx, index_in_shard
1833
1834    def _array_index_for_path(self, array_path: Optional[str]) -> ArrayIndex:
1835        if self._manifest_version == SHARDED_ZARR_V1:
1836            return self.array_indices[""]
1837
1838        normalized_path = self._normalize_array_path(array_path or "")
1839        try:
1840            return self.array_indices[normalized_path]
1841        except KeyError as exc:
1842            raise KeyError(
1843                f"No array index registered for chunk path '{normalized_path}'."
1844            ) from exc
1845
1846    def _cache_key(self, array_path: Optional[str], shard_idx: int) -> ShardCacheKey:
1847        if self._manifest_version == SHARDED_ZARR_V1:
1848            return shard_idx
1849        return (self._normalize_array_path(array_path or ""), shard_idx)
1850
1851    def _map_byte_request(
1852        self, byte_range: Optional[zarr.abc.store.ByteRequest]
1853    ) -> tuple[Optional[int], Optional[int], Optional[int]]:
1854        req_offset = None
1855        req_length = None
1856        req_suffix = None
1857
1858        if byte_range:
1859            if isinstance(byte_range, RangeByteRequest):
1860                req_offset = byte_range.start
1861                if byte_range.end is not None:
1862                    if byte_range.start > byte_range.end:
1863                        raise ValueError(
1864                            f"Byte range start ({byte_range.start}) cannot be greater than end ({byte_range.end})"
1865                        )
1866                    req_length = byte_range.end - byte_range.start
1867            elif isinstance(byte_range, OffsetByteRequest):
1868                req_offset = byte_range.offset
1869            elif isinstance(byte_range, SuffixByteRequest):
1870                req_suffix = byte_range.suffix
1871        return req_offset, req_length, req_suffix
1872
1873    async def _get_legacy_metadata_chunk(
1874        self,
1875        key: str,
1876        prototype: zarr.core.buffer.BufferPrototype,
1877        byte_range: Optional[zarr.abc.store.ByteRequest],
1878    ) -> Optional[zarr.core.buffer.Buffer]:
1879        metadata_cid_obj = self._root_obj["metadata"].get(key)
1880        if metadata_cid_obj is None:
1881            return None
1882        req_offset, req_length, req_suffix = self._map_byte_request(byte_range)
1883        data = await self.cas.load(
1884            metadata_cid_obj,
1885            offset=req_offset,
1886            length=req_length,
1887            suffix=req_suffix,
1888        )
1889        return prototype.buffer.from_bytes(data)
1890
1891    async def _load_or_initialize_shard_cache(
1892        self, shard_idx: int, array_path: Optional[str] = None
1893    ) -> List[Optional[CID]]:
1894        """Return a shard after keeping it pinned throughout cache population."""
1895        array_index = self._array_index_for_path(array_path)
1896        cache_key = self._cache_key(array_index.array_path, shard_idx)
1897        async with self._shard_data_cache.pin(cache_key):
1898            return await self._load_or_initialize_shard_cache_pinned(
1899                shard_idx, array_path
1900            )
1901
1902    async def _load_or_initialize_shard_cache_pinned(
1903        self, shard_idx: int, array_path: Optional[str] = None
1904    ) -> List[Optional[CID]]:
1905        """
1906        Load a shard into the cache or initialize an empty shard if it doesn't exist.
1907        """
1908        started_at = time.perf_counter()
1909        array_index = self._array_index_for_path(array_path)
1910        cache_key = self._cache_key(array_index.array_path, shard_idx)
1911
1912        cached_shard = await self._shard_data_cache.get(cache_key)
1913        if cached_shard is not None:
1914            instrumentation.record_shard_load(
1915                shard_idx=shard_idx,
1916                cache_hit=True,
1917                seconds=time.perf_counter() - started_at,
1918                entries=len(cached_shard),
1919            )
1920            return cached_shard
1921
1922        if cache_key in self._pending_shard_loads:
1923            try:
1924                await asyncio.wait_for(
1925                    self._pending_shard_loads[cache_key].wait(), timeout=60.0
1926                )
1927                cached_shard = await self._shard_data_cache.get(cache_key)
1928                if cached_shard is not None:
1929                    return cached_shard
1930                raise RuntimeError(
1931                    f"Shard {shard_idx} not found in cache after pending load completed."
1932                )
1933            except asyncio.TimeoutError as exc:
1934                if cache_key in self._pending_shard_loads:
1935                    self._pending_shard_loads[cache_key].set()
1936                    del self._pending_shard_loads[cache_key]
1937                raise RuntimeError(
1938                    f"Timeout waiting for shard {shard_idx} to load."
1939                ) from exc
1940
1941        if not (0 <= shard_idx < array_index.num_shards):
1942            raise ValueError(f"Shard index {shard_idx} out of bounds.")
1943
1944        shard_cid_obj = array_index.shard_cids[shard_idx]
1945        if shard_cid_obj:
1946            self._pending_shard_loads[cache_key] = asyncio.Event()
1947            try:
1948                await self._fetch_and_cache_full_shard(
1949                    cache_key, shard_idx, shard_cid_obj, array_index.chunks_per_shard
1950                )
1951            finally:
1952                pending_load = self._pending_shard_loads.pop(cache_key, None)
1953                if pending_load is not None:
1954                    pending_load.set()
1955        else:
1956            empty_shard: List[Optional[CID]] = [None] * array_index.chunks_per_shard
1957            await self._shard_data_cache.put(cache_key, empty_shard)
1958
1959        result = await self._shard_data_cache.get(cache_key)
1960        if result is None:
1961            raise RuntimeError(f"Failed to load or initialize shard {shard_idx}")
1962        instrumentation.record_shard_load(
1963            shard_idx=shard_idx,
1964            cache_hit=False,
1965            seconds=time.perf_counter() - started_at,
1966            entries=len(result),
1967        )
1968        return result
1969
1970    @asynccontextmanager
1971    async def _use_shard(
1972        self, shard_idx: int, array_path: Optional[str] = None
1973    ) -> AsyncIterator[List[Optional[CID]]]:
1974        """Yield a pinned shard while serializing access to its contents."""
1975        array_index = self._array_index_for_path(array_path)
1976        cache_key = self._cache_key(array_index.array_path, shard_idx)
1977        async with self._shard_data_cache.pin(cache_key):
1978            async with self._shard_locks[cache_key]:
1979                yield await self._load_or_initialize_shard_cache(
1980                    shard_idx, array_index.array_path
1981                )
1982
1983    async def set_partial_values(
1984        self, key_start_values: Iterable[Tuple[str, int, BytesLike]]
1985    ) -> None:
1986        raise NotImplementedError(
1987            "Partial writes are not supported by ShardedZarrStore."
1988        )
1989
1990    async def get_partial_values(
1991        self,
1992        prototype: zarr.core.buffer.BufferPrototype,
1993        key_ranges: Iterable[Tuple[str, zarr.abc.store.ByteRequest | None]],
1994    ) -> List[Optional[zarr.core.buffer.Buffer]]:
1995        tasks = [self.get(key, prototype, byte_range) for key, byte_range in key_ranges]
1996        results = await asyncio.gather(*tasks)
1997        return results
1998
1999    def with_read_only(self, read_only: bool = False) -> "ShardedZarrStore":
2000        """
2001        Return this store (if the flag already matches) or a shallow clone with
2002        the requested read-only status.
2003        """
2004        if read_only == self.read_only:
2005            return self
2006
2007        clone = type(self).__new__(type(self))
2008
2009        clone.cas = self.cas
2010        clone._root_cid = self._root_cid
2011        clone._root_obj = self._root_obj
2012        clone._manifest_version = self._manifest_version
2013        clone.shard_read_mode = self.shard_read_mode
2014
2015        clone._resize_lock = self._resize_lock
2016        clone._resize_complete = self._resize_complete
2017        clone._write_lock = self._write_lock
2018        clone._shard_locks = self._shard_locks
2019
2020        clone._shard_data_cache = self._shard_data_cache
2021        clone._pending_shard_loads = self._pending_shard_loads
2022        # Both shared by reference, like the cache above. The counters must be
2023        # shared so a clone does not restart counting while reading through the
2024        # *same* cache; the latch cell must be shared for the same reason in
2025        # reverse -- latching clears the shared counters, so a sibling holding
2026        # a copied False would resume sparse reads with nothing left to re-earn
2027        # promotion from.
2028        clone._sparse_read_counts = self._sparse_read_counts
2029        clone._full_mode_latched_cell = self._full_mode_latched_cell
2030        clone._metadata_read_cache = self._metadata_read_cache
2031
2032        clone.array_indices = self.array_indices
2033        clone._primary_array_path = self._primary_array_path
2034        clone._primary_inferred = self._primary_inferred
2035        clone._default_chunks_per_shard = self._default_chunks_per_shard
2036
2037        clone._array_shape = self._array_shape
2038        clone._chunk_shape = self._chunk_shape
2039        clone._chunks_per_dim = self._chunks_per_dim
2040        clone._chunks_per_shard = self._chunks_per_shard
2041        clone._num_shards = self._num_shards
2042        clone._total_chunks = self._total_chunks
2043
2044        clone._dirty_root = self._dirty_root
2045        clone._v2_pending_root_group_write = self._v2_pending_root_group_write
2046
2047        # A V1 primary that was inferred against a read-only open never reached
2048        # the persistence branch in _infer_v1_legacy_primary_array_path (that
2049        # branch is gated on `not self.read_only`), so it lives only in the
2050        # in-memory `_primary_inferred` flag. Making a writable clone would carry
2051        # that flag forward while leaving the shared root unrecorded: the first
2052        # chunk write skips its seal-on-write (it is gated on
2053        # `not self._primary_inferred`), so a same-geometry secondary array could
2054        # flush with no recorded primary. On reopen inference would then be
2055        # ambiguous and misroute the secondary's metadata chunk into the primary
2056        # shard slot. Perform the deferred persistence the read-only open
2057        # skipped, reproducing the state a writable open would have produced.
2058        if (
2059            not read_only
2060            and clone._manifest_version == SHARDED_ZARR_V1
2061            and clone._primary_inferred
2062            and isinstance(clone._root_obj.get("chunks"), dict)
2063            and "primary_array_path" not in clone._root_obj["chunks"]
2064        ):
2065            clone._root_obj["chunks"]["primary_array_path"] = (
2066                clone._primary_array_path or ""
2067            )
2068            clone._dirty_root = True
2069
2070        zarr.abc.store.Store.__init__(clone, read_only=read_only)
2071        return clone
2072
2073    def __eq__(self, other: object) -> bool:
2074        if not isinstance(other, ShardedZarrStore):
2075            return False
2076        return self._root_cid == other._root_cid
2077
2078    async def flush(self) -> str:
2079        async with self._write_lock:
2080            return await self._flush_unlocked()
2081
2082    async def _flush_unlocked(self) -> str:
2083        async with self._shard_data_cache._cache_lock:
2084            dirty_shards = list(self._shard_data_cache._dirty_shards)
2085        if dirty_shards:
2086            sorted_dirty_shards = sorted(dirty_shards, key=str)
2087            next_shard_index = 0
2088
2089            async def flush_shards() -> None:
2090                nonlocal next_shard_index
2091                while next_shard_index < len(sorted_dirty_shards):
2092                    shard_index = next_shard_index
2093                    next_shard_index += 1
2094                    cache_key = sorted_dirty_shards[shard_index]
2095                    async with self._shard_data_cache.pin(cache_key):
2096                        shard_lock = self._shard_locks[cache_key]
2097                        async with shard_lock:
2098                            shard_data_list = await self._shard_data_cache.get(
2099                                cache_key
2100                            )
2101                            if shard_data_list is None:
2102                                raise RuntimeError(
2103                                    f"Dirty shard {cache_key} not found in cache"
2104                                )
2105
2106                            shard_data_bytes = dag_cbor.encode(
2107                                cast(IPLDKind, shard_data_list)
2108                            )
2109                            new_shard_cid_obj = await self.cas.save(
2110                                shard_data_bytes,
2111                                codec="dag-cbor",
2112                            )
2113                            if not isinstance(
2114                                new_shard_cid_obj, CID
2115                            ):  # pragma: no cover
2116                                raise TypeError(
2117                                    "ShardedZarrStore requires CAS.save to return CIDs."
2118                                )
2119
2120                            if self._manifest_version == SHARDED_ZARR_V1:
2121                                if not isinstance(cache_key, int):  # pragma: no cover
2122                                    raise TypeError(
2123                                        "v1 shard cache keys must be integers."
2124                                    )
2125                                shard_idx = int(cache_key)
2126                                if (
2127                                    self._root_obj["chunks"]["shard_cids"][shard_idx]
2128                                    != new_shard_cid_obj
2129                                ):
2130                                    self._root_obj["chunks"]["shard_cids"][
2131                                        shard_idx
2132                                    ] = new_shard_cid_obj
2133                                    self.array_indices[""].shard_cids[shard_idx] = (
2134                                        new_shard_cid_obj
2135                                    )
2136                                    self._dirty_root = True
2137                            else:
2138                                if isinstance(cache_key, int):  # pragma: no cover
2139                                    raise TypeError(
2140                                        "v2 shard cache keys must include array paths."
2141                                    )
2142                                array_path, shard_idx = cache_key
2143                                array_index = self.array_indices[array_path]
2144                                if (
2145                                    array_index.shard_cids[shard_idx]
2146                                    != new_shard_cid_obj
2147                                ):
2148                                    array_index.shard_cids[shard_idx] = (
2149                                        new_shard_cid_obj
2150                                    )
2151                                    self._dirty_root = True
2152
2153                            await self._shard_data_cache.mark_clean(cache_key)
2154
2155            flush_tasks = [
2156                asyncio.ensure_future(flush_shards())
2157                for _ in range(min(_FLUSH_CONCURRENCY, len(sorted_dirty_shards)))
2158            ]
2159            try:
2160                await asyncio.gather(*flush_tasks)
2161            except BaseException:
2162                # No sibling task may outlive a failed flush: they would keep
2163                # mutating store state (and using the CAS) without the write
2164                # lock after the caller has already observed the failure.
2165                for flush_task in flush_tasks:
2166                    flush_task.cancel()
2167                await asyncio.gather(*flush_tasks, return_exceptions=True)
2168                raise
2169
2170        if self._dirty_root:
2171            self._root_obj["metadata"] = {
2172                k: (CID.decode(v) if isinstance(v, str) else v)
2173                for k, v in self._root_obj["metadata"].items()
2174            }
2175            self._sync_arrays_to_root()
2176            root_obj_bytes = dag_cbor.encode(self._root_obj)
2177            new_root_cid = await self.cas.save(root_obj_bytes, codec="dag-cbor")
2178            self._root_cid = str(new_root_cid)
2179            self._dirty_root = False
2180
2181        return self._root_cid  # type: ignore[return-value]
2182
2183    async def get(
2184        self,
2185        key: str,
2186        prototype: zarr.core.buffer.BufferPrototype,
2187        byte_range: Optional[zarr.abc.store.ByteRequest] = None,
2188    ) -> Optional[zarr.core.buffer.Buffer]:
2189        with instrumentation.span(
2190            "py_hamt.sharded_store.get",
2191            {
2192                "py_hamt.zarr.key": key,
2193                "py_hamt.zarr.byte_range": byte_range is not None,
2194            },
2195        ):
2196            started_at = time.perf_counter()
2197            hit = False
2198            kind = "metadata"
2199            shard_idx_for_trace: int | None = None
2200            lookup_key = self._v2_effective_read_key(key)
2201            try:
2202                parsed_chunk = self._parse_chunk_key(lookup_key)
2203            except (ValueError, IndexError):
2204                if self._manifest_version != SHARDED_ZARR_V2:
2205                    raise
2206                return None
2207            try:
2208                if parsed_chunk is None:
2209                    metadata_cid_obj = self._root_obj["metadata"].get(lookup_key)
2210                    if metadata_cid_obj is None:
2211                        return None
2212                    data = (
2213                        self._metadata_read_cache.get(lookup_key)
2214                        if byte_range is None
2215                        else None
2216                    )
2217                    if data is None:
2218                        req_offset, req_length, req_suffix = self._map_byte_request(
2219                            byte_range
2220                        )
2221                        data = await self.cas.load(
2222                            metadata_cid_obj,
2223                            offset=req_offset,
2224                            length=req_length,
2225                            suffix=req_suffix,
2226                        )
2227                    if byte_range is None:
2228                        self._metadata_read_cache[lookup_key] = data
2229                    hit = True
2230                    return prototype.buffer.from_bytes(data)
2231
2232                kind = "chunk"
2233                try:
2234                    array_index = self._array_index_for_path(parsed_chunk.array_path)
2235                except KeyError:
2236                    return await self._get_legacy_metadata_chunk(
2237                        lookup_key, prototype, byte_range
2238                    )
2239                linear_chunk_index = self._get_linear_chunk_index_for_index(
2240                    parsed_chunk.coords, array_index
2241                )
2242                shard_idx, index_in_shard = self._get_shard_info_for_index(
2243                    linear_chunk_index, array_index
2244                )
2245                shard_idx_for_trace = shard_idx
2246
2247                cache_key = self._cache_key(array_index.array_path, shard_idx)
2248                shard_lock = self._shard_locks[cache_key]
2249                async with shard_lock:
2250                    cached_shard = await self._shard_data_cache.get(cache_key)
2251                    use_sparse = (
2252                        self.shard_read_mode != "full"
2253                        and self._sparse_read_is_eligible(
2254                            array_index, shard_idx, cached_shard, byte_range
2255                        )
2256                    )
2257                    if use_sparse and self.shard_read_mode == "auto":
2258                        use_sparse = self._auto_mode_wants_sparse(cache_key)
2259                    if use_sparse:
2260                        chunk_cid_obj = await self._load_sparse_shard_entry(
2261                            cache_key,
2262                            shard_idx,
2263                            cast(CID, array_index.shard_cids[shard_idx]),
2264                            index_in_shard,
2265                            array_index.chunks_per_shard,
2266                        )
2267                    else:
2268                        if cached_shard is None:
2269                            cached_shard = await self._load_or_initialize_shard_cache(
2270                                shard_idx, array_index.array_path
2271                            )
2272                        chunk_cid_obj = cached_shard[index_in_shard]
2273                if chunk_cid_obj is None:
2274                    legacy_buffer = await self._get_legacy_metadata_chunk(
2275                        lookup_key, prototype, byte_range
2276                    )
2277                    hit = legacy_buffer is not None
2278                    return legacy_buffer
2279
2280                req_offset, req_length, req_suffix = self._map_byte_request(byte_range)
2281                data = await self.cas.load(
2282                    chunk_cid_obj,
2283                    offset=req_offset,
2284                    length=req_length,
2285                    suffix=req_suffix,
2286                )
2287                hit = True
2288                return prototype.buffer.from_bytes(data)
2289            finally:
2290                instrumentation.record_zarr_get(
2291                    store="sharded_store",
2292                    key=key,
2293                    kind=kind,
2294                    hit=hit,
2295                    seconds=time.perf_counter() - started_at,
2296                    byte_range=byte_range is not None,
2297                    shard_idx=shard_idx_for_trace,
2298                )
2299
2300    async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None:
2301        if self.read_only:
2302            raise PermissionError("Cannot write to a read-only store.")
2303        async with self._write_lock:
2304            await self._set_unlocked(key, value)
2305        return None  # type: ignore[return-value]
2306
2307    async def _set_unlocked(self, key: str, value: zarr.core.buffer.Buffer) -> None:
2308        await self._resize_complete.wait()
2309
2310        raw_data_bytes = self._strip_v2_root_consolidated_metadata(
2311            key, value.to_bytes()
2312        )
2313        self._raise_if_v2_write_without_group(key, raw_data_bytes)
2314        await self._register_array_metadata_from_bytes(key, raw_data_bytes)
2315        await self._ensure_v2_parent_group_metadata(key)
2316
2317        try:
2318            parsed_chunk = self._parse_chunk_key(key)
2319        except (ValueError, IndexError):
2320            if self._manifest_version != SHARDED_ZARR_V2:
2321                raise
2322            return None
2323
2324        try:
2325            data_cid_obj = await self.cas.save(raw_data_bytes, codec="raw")
2326            if not isinstance(data_cid_obj, CID):
2327                raise TypeError("ShardedZarrStore requires CAS.save to return CIDs.")
2328            await self._set_pointer_cid(key, data_cid_obj, register_metadata=False)
2329            if parsed_chunk is None:
2330                self._metadata_read_cache[key] = raw_data_bytes
2331        except Exception as e:
2332            raise RuntimeError(f"Failed to save data for key {key}: {e}") from e
2333        return None  # type: ignore[return-value]
2334
2335    async def set_pointer(self, key: str, pointer: str) -> None:
2336        if self.read_only:
2337            raise PermissionError("Cannot write to a read-only store.")
2338        async with self._write_lock:
2339            await self._resize_complete.wait()
2340            await self._set_pointer(key, pointer, register_metadata=True)
2341
2342    async def _set_pointer(
2343        self, key: str, pointer: str, *, register_metadata: bool
2344    ) -> None:
2345        await self._set_pointer_cid(
2346            key, CID.decode(pointer), register_metadata=register_metadata
2347        )
2348
2349    async def _set_pointer_cid(
2350        self, key: str, pointer_cid_obj: CID, *, register_metadata: bool
2351    ) -> None:
2352        try:
2353            parsed_chunk = self._parse_chunk_key(key)
2354        except (ValueError, IndexError):
2355            if self._manifest_version != SHARDED_ZARR_V2:
2356                raise
2357            return None
2358        if parsed_chunk is None:
2359            if register_metadata and self._manifest_version == SHARDED_ZARR_V2:
2360                raw_metadata = await self.cas.load(pointer_cid_obj)
2361                stripped_metadata = self._strip_v2_root_consolidated_metadata(
2362                    key, raw_metadata
2363                )
2364                if stripped_metadata != raw_metadata:
2365                    stripped_pointer = await self.cas.save(
2366                        stripped_metadata, codec="raw"
2367                    )
2368                    if not isinstance(stripped_pointer, CID):  # pragma: no cover
2369                        raise TypeError(
2370                            "ShardedZarrStore requires CAS.save to return CIDs."
2371                        )
2372                    pointer_cid_obj = stripped_pointer
2373            if (
2374                register_metadata
2375                and self._array_path_from_metadata_key(key) is not None
2376            ):
2377                raw_metadata = await self.cas.load(pointer_cid_obj)
2378                await self._register_array_metadata_from_bytes(key, raw_metadata)
2379            if register_metadata:
2380                await self._ensure_v2_parent_group_metadata(key)
2381            self._root_obj["metadata"][key] = pointer_cid_obj
2382            self._metadata_read_cache.pop(key, None)
2383            self._dirty_root = True
2384            return None
2385
2386        array_index = self._array_index_for_path(parsed_chunk.array_path)
2387        linear_chunk_index = self._get_linear_chunk_index_for_index(
2388            parsed_chunk.coords, array_index
2389        )
2390        shard_idx, index_in_shard = self._get_shard_info_for_index(
2391            linear_chunk_index, array_index
2392        )
2393
2394        cache_key = self._cache_key(array_index.array_path, shard_idx)
2395        async with self._use_shard(shard_idx, array_index.array_path):
2396            await self._shard_data_cache.update_entry(
2397                cache_key, index_in_shard, pointer_cid_obj
2398            )
2399            # A legacy root may also carry this chunk key in the metadata
2400            # mapping; drop it so the superseded CID is not pinned forever and
2401            # cannot resurface through the legacy fallback once the shard slot
2402            # empties again.
2403            if self._root_obj["metadata"].pop(key, None) is not None:
2404                self._metadata_read_cache.pop(key, None)
2405                self._dirty_root = True
2406
2407        if self._manifest_version == SHARDED_ZARR_V1 and not self._primary_inferred:
2408            # A genuinely-unrecorded root's first chunk write establishes and
2409            # persists its primary. An *inferred* primary is never sealed here:
2410            # inference is in-memory only, and the exclusivity flag guarantees
2411            # only true-primary chunks reach this point anyway.
2412            chunk_info = self._root_obj["chunks"]
2413            if "primary_array_path" not in chunk_info:
2414                self._primary_array_path = parsed_chunk.array_path
2415                chunk_info["primary_array_path"] = parsed_chunk.array_path
2416                self._dirty_root = True
2417        return None
2418
2419    async def exists(self, key: str) -> bool:
2420        lookup_key = self._v2_effective_read_key(key)
2421        try:
2422            parsed_chunk = self._parse_chunk_key(lookup_key)
2423            if parsed_chunk is None:
2424                return lookup_key in self._root_obj.get("metadata", {})
2425            try:
2426                array_index = self._array_index_for_path(parsed_chunk.array_path)
2427            except KeyError:
2428                return lookup_key in self._root_obj.get("metadata", {})
2429            linear_chunk_index = self._get_linear_chunk_index_for_index(
2430                parsed_chunk.coords, array_index
2431            )
2432            shard_idx, index_in_shard = self._get_shard_info_for_index(
2433                linear_chunk_index, array_index
2434            )
2435            async with self._use_shard(
2436                shard_idx, array_index.array_path
2437            ) as target_shard_list:
2438                return target_shard_list[
2439                    index_in_shard
2440                ] is not None or lookup_key in self._root_obj.get("metadata", {})
2441        except (ValueError, IndexError, KeyError):
2442            return False
2443
2444    @property
2445    def supports_writes(self) -> bool:
2446        return not self.read_only
2447
2448    @property
2449    def supports_partial_writes(self) -> bool:
2450        return False
2451
2452    @property
2453    def supports_deletes(self) -> bool:
2454        return not self.read_only
2455
2456    async def delete(self, key: str) -> None:
2457        if self.read_only:
2458            raise PermissionError("Cannot delete from a read-only store.")
2459        async with self._write_lock:
2460            await self._delete_unlocked(key)
2461
2462    async def _delete_unlocked(self, key: str) -> None:
2463        await self._resize_complete.wait()
2464
2465        try:
2466            parsed_chunk = self._parse_chunk_key(key)
2467        except (ValueError, IndexError):
2468            if self._manifest_version != SHARDED_ZARR_V2:
2469                raise
2470            return None
2471        if parsed_chunk is None:
2472            if self._root_obj["metadata"].pop(key, None) is not None:
2473                self._metadata_read_cache.pop(key, None)
2474                self._dirty_root = True
2475            return None
2476
2477        try:
2478            array_index = self._array_index_for_path(parsed_chunk.array_path)
2479        except KeyError:
2480            if self._root_obj["metadata"].pop(key, None) is not None:
2481                self._metadata_read_cache.pop(key, None)
2482                self._dirty_root = True
2483            return None
2484        linear_chunk_index = self._get_linear_chunk_index_for_index(
2485            parsed_chunk.coords, array_index
2486        )
2487        shard_idx, index_in_shard = self._get_shard_info_for_index(
2488            linear_chunk_index, array_index
2489        )
2490
2491        cache_key = self._cache_key(array_index.array_path, shard_idx)
2492        async with self._use_shard(shard_idx, array_index.array_path):
2493            await self._shard_data_cache.update_entry(cache_key, index_in_shard, None)
2494            if self._root_obj["metadata"].pop(key, None) is not None:
2495                self._metadata_read_cache.pop(key, None)
2496                self._dirty_root = True
2497
2498    async def delete_dir(self, prefix: str) -> None:
2499        if self._manifest_version != SHARDED_ZARR_V2:
2500            await zarr.abc.store.Store.delete_dir(self, prefix)
2501            return
2502        if self.read_only:
2503            raise PermissionError("Cannot delete from a read-only store.")
2504
2505        async with self._write_lock:
2506            await self._resize_complete.wait()
2507            normalized_prefix = prefix.strip("/")
2508            if normalized_prefix == "":
2509                await self._clear_v2_unlocked()
2510                return
2511
2512            match_prefix = f"{normalized_prefix}/"
2513            metadata_keys_to_delete = [
2514                key
2515                for key in self._root_obj.get("metadata", {})
2516                if key.startswith(match_prefix)
2517            ]
2518            for key in metadata_keys_to_delete:
2519                await self._delete_unlocked(key)
2520            await self._prune_v2_array_indices_for_prefix(normalized_prefix)
2521
2522    async def clear(self) -> None:
2523        if self._manifest_version != SHARDED_ZARR_V2:
2524            await zarr.abc.store.Store.clear(self)
2525            return
2526        if self.read_only:
2527            raise PermissionError("Cannot clear a read-only store.")
2528
2529        async with self._write_lock:
2530            await self._resize_complete.wait()
2531            await self._clear_v2_unlocked()
2532
2533    async def _clear_v2_unlocked(self) -> None:
2534        if self._manifest_version != SHARDED_ZARR_V2:
2535            return
2536        for pending_load in self._pending_shard_loads.values():
2537            pending_load.set()
2538        self._pending_shard_loads.clear()
2539        await self._shard_data_cache.clear()
2540        # A cleared store has a new access pattern to learn.
2541        self._sparse_read_counts.clear()
2542        self._full_mode_latched = False
2543        self._root_obj["metadata"] = {}
2544        self._root_obj["arrays"] = {}
2545        self.array_indices.clear()
2546        self._primary_array_path = None
2547        self._metadata_read_cache.clear()
2548        self._array_shape = ()
2549        self._chunk_shape = ()
2550        self._chunks_per_dim = ()
2551        self._chunks_per_shard = 0
2552        self._num_shards = 0
2553        self._total_chunks = 0
2554        self._dirty_root = True
2555
2556    async def _prune_v2_array_indices_for_prefix(self, prefix: str) -> None:
2557        if self._manifest_version != SHARDED_ZARR_V2:
2558            return
2559
2560        normalized_prefix = self._normalize_array_path(prefix)
2561        array_paths = [
2562            array_path
2563            for array_path in self.array_indices
2564            if array_path == normalized_prefix
2565            or array_path.startswith(f"{normalized_prefix}/")
2566        ]
2567        if not array_paths:
2568            return
2569
2570        for array_path in array_paths:
2571            array_index = self.array_indices.pop(array_path)
2572            self._root_obj["arrays"].pop(array_path, None)
2573            for shard_idx in range(array_index.num_shards):
2574                cache_key = self._cache_key(array_path, shard_idx)
2575                pending_load = self._pending_shard_loads.pop(cache_key, None)
2576                if pending_load is not None:
2577                    pending_load.set()
2578                shard_lock = self._shard_locks[cache_key]
2579                async with shard_lock:
2580                    await self._shard_data_cache.discard(cache_key)
2581
2582        if self.array_indices:
2583            self._primary_array_path = next(iter(self.array_indices))
2584            self._set_legacy_geometry_from_index(
2585                self.array_indices[self._primary_array_path]
2586            )
2587        else:
2588            self._primary_array_path = None
2589            self._array_shape = ()
2590            self._chunk_shape = ()
2591            self._chunks_per_dim = ()
2592            self._chunks_per_shard = 0
2593            self._num_shards = 0
2594            self._total_chunks = 0
2595        self._sync_arrays_to_root()
2596        self._dirty_root = True
2597
2598    @property
2599    def supports_listing(self) -> bool:
2600        return True
2601
2602    async def list(self) -> AsyncIterator[str]:
2603        yielded: set[str] = set()
2604        for key in list(self._root_obj.get("metadata", {})):
2605            yielded.add(key)
2606            yield key
2607
2608        async for chunk_key in self._iter_chunk_keys():
2609            if chunk_key not in yielded:
2610                yield chunk_key
2611
2612    async def _iter_chunk_keys(self) -> AsyncIterator[str]:
2613        for array_path, array_index in self.array_indices.items():
2614            listed_array_path = (
2615                self._primary_array_path
2616                if self._manifest_version == SHARDED_ZARR_V1
2617                else array_path
2618            )
2619            for shard_idx in range(array_index.num_shards):
2620                cache_key = self._cache_key(array_path, shard_idx)
2621                shard_data = await self._shard_data_cache.get(cache_key)
2622                if shard_data is None:
2623                    if array_index.shard_cids[shard_idx] is None:
2624                        continue
2625                    shard_data = await self._load_or_initialize_shard_cache(
2626                        shard_idx, array_path
2627                    )
2628
2629                for index_in_shard, cid_obj in enumerate(shard_data):
2630                    if cid_obj is None:
2631                        continue
2632                    linear_index = (
2633                        shard_idx * array_index.chunks_per_shard + index_in_shard
2634                    )
2635                    if linear_index >= array_index.total_chunks:
2636                        continue
2637                    coords = self._coords_from_linear_index(
2638                        linear_index, array_index.chunks_per_dim
2639                    )
2640                    chunk_key = self._format_chunk_key(listed_array_path or "", coords)
2641                    if (
2642                        self._manifest_version == SHARDED_ZARR_V1
2643                        and not listed_array_path
2644                    ):
2645                        recorded_path = self._root_obj.get("chunks", {}).get(
2646                            "primary_array_path"
2647                        )
2648                        if not (
2649                            isinstance(recorded_path, str)
2650                            and self._normalize_array_path(recorded_path) == ""
2651                        ):
2652                            # Unrecorded V1 roots distinguish shard chunks
2653                            # ("/c/...") from legacy metadata keys ("c/...").
2654                            chunk_key = f"/{chunk_key}"
2655                    yield chunk_key
2656
2657    async def list_prefix(self, prefix: str) -> AsyncIterator[str]:
2658        async for key in self.list():
2659            if key.startswith(prefix):
2660                yield key
2661
2662    def _list_dir_candidate_keys(self) -> Set[str]:
2663        keys = set(self._root_obj.get("metadata", {}))
2664        if self._manifest_version != SHARDED_ZARR_V2:
2665            chunk_prefix = (
2666                "c" if not self._primary_array_path else f"{self._primary_array_path}/c"
2667            )
2668            keys.add(chunk_prefix)
2669            return keys
2670
2671        for array_path in self.array_indices:
2672            if array_path:
2673                parts = array_path.split("/")
2674                keys.update("/".join(parts[:idx]) for idx in range(1, len(parts) + 1))
2675            keys.add("c" if array_path == "" else f"{array_path}/c")
2676        return keys
2677
2678    def _chunk_listing_prefixes(self) -> Iterator[str]:
2679        """The chunk-directory prefixes ``_iter_chunk_keys`` emits keys under."""
2680        if self._manifest_version == SHARDED_ZARR_V2:
2681            for array_path in self.array_indices:
2682                yield "c" if array_path == "" else f"{array_path}/c"
2683        else:
2684            # V1 emits a single primary chunk tree, rooted at "c" or
2685            # "<primary>/c" to match the recorded/inferred primary path.
2686            yield (
2687                "c" if not self._primary_array_path else f"{self._primary_array_path}/c"
2688            )
2689
2690    def _is_chunk_listing_prefix(self, normalized_prefix: str) -> bool:
2691        for chunk_prefix in self._chunk_listing_prefixes():
2692            if normalized_prefix == chunk_prefix or normalized_prefix.startswith(
2693                f"{chunk_prefix}/"
2694            ):
2695                return True
2696        return False
2697
2698    async def graft_store(
2699        self,
2700        store_to_graft_cid: str,
2701        chunk_offset: Tuple[int, ...],
2702        *,
2703        source_array_path: Optional[str] = None,
2704        target_array_path: Optional[str] = None,
2705    ) -> None:
2706        if self.read_only:
2707            raise PermissionError("Cannot graft onto a read-only store.")
2708        async with self._write_lock:
2709            await self._graft_store_unlocked(
2710                store_to_graft_cid,
2711                chunk_offset,
2712                source_array_path=source_array_path,
2713                target_array_path=target_array_path,
2714            )
2715
2716    async def _graft_store_unlocked(
2717        self,
2718        store_to_graft_cid: str,
2719        chunk_offset: Tuple[int, ...],
2720        *,
2721        source_array_path: Optional[str] = None,
2722        target_array_path: Optional[str] = None,
2723    ) -> None:
2724        await self._resize_complete.wait()
2725
2726        store_to_graft = await ShardedZarrStore.open(
2727            cas=self.cas, read_only=True, root_cid=store_to_graft_cid
2728        )
2729        source_path = (
2730            source_array_path
2731            if source_array_path is not None
2732            else store_to_graft._primary_array_path
2733        )
2734        if source_path is None:
2735            return None
2736
2737        source_index = store_to_graft._array_index_for_path(source_path)
2738        target_path = (
2739            target_array_path if target_array_path is not None else source_path
2740        )
2741        target_index = self._array_index_for_path(target_path)
2742        if len(chunk_offset) != len(source_index.chunks_per_dim) or len(
2743            chunk_offset
2744        ) != len(target_index.chunks_per_dim):
2745            raise ValueError(
2746                "chunk_offset must have the same number of dimensions as both source and target arrays."
2747            )
2748
2749        for local_coords in itertools.product(*[
2750            range(s) for s in source_index.chunks_per_dim
2751        ]):
2752            linear_local_index = self._get_linear_chunk_index_for_index(
2753                tuple(local_coords), source_index
2754            )
2755            local_shard_idx, index_in_local_shard = self._get_shard_info_for_index(
2756                linear_local_index, source_index
2757            )
2758            source_shard_list = await store_to_graft._load_or_initialize_shard_cache(
2759                local_shard_idx, source_index.array_path
2760            )
2761
2762            pointer_cid_obj = source_shard_list[index_in_local_shard]
2763            if pointer_cid_obj is None:
2764                continue
2765
2766            global_coords = tuple(
2767                c_local + c_offset
2768                for c_local, c_offset in zip(local_coords, chunk_offset, strict=True)
2769            )
2770            try:
2771                self._validate_chunk_coords(global_coords, target_index)
2772            except IndexError as exc:
2773                raise ValueError(
2774                    f"Graft target chunk coordinates {global_coords} are out of bounds."
2775                ) from exc
2776            linear_global_index = self._get_linear_chunk_index_for_index(
2777                global_coords, target_index
2778            )
2779            global_shard_idx, index_in_global_shard = self._get_shard_info_for_index(
2780                linear_global_index, target_index
2781            )
2782
2783            cache_key = self._cache_key(target_index.array_path, global_shard_idx)
2784            async with self._use_shard(global_shard_idx, target_index.array_path):
2785                await self._shard_data_cache.update_entry(
2786                    cache_key, index_in_global_shard, pointer_cid_obj
2787                )
2788
2789    async def resize_store(
2790        self, new_shape: Tuple[int, ...], *, array_path: Optional[str] = None
2791    ) -> None:
2792        if self.read_only:
2793            raise PermissionError("Cannot resize a read-only store.")
2794        async with self._write_lock:
2795            await self._resize_store_unlocked(new_shape, array_path=array_path)
2796
2797    async def _resize_store_unlocked(
2798        self, new_shape: Tuple[int, ...], *, array_path: Optional[str] = None
2799    ) -> None:
2800        """
2801        Resizes one shard index to accommodate a new array shape.
2802        """
2803
2804        if self._manifest_version == SHARDED_ZARR_V2:
2805            target_path = (
2806                array_path if array_path is not None else self._primary_array_path
2807            )
2808            if target_path is None:
2809                raise RuntimeError("Store is not properly initialized for resizing.")
2810            array_index = self._array_index_for_path(target_path)
2811            await self._resize_array_index_guarded(array_index, tuple(new_shape))
2812            return None
2813
2814        if (
2815            self._chunk_shape is None
2816            or self._chunks_per_shard is None
2817            or self._array_shape is None
2818        ):
2819            raise RuntimeError("Store is not properly initialized for resizing.")
2820        if len(new_shape) != len(self._array_shape):
2821            raise ValueError(
2822                "New shape must have the same number of dimensions as the old shape."
2823            )
2824
2825        array_index = self.array_indices[""]
2826        await self._resize_array_index_guarded(array_index, tuple(new_shape))
2827        self._root_obj["chunks"]["array_shape"] = list(array_index.array_shape)
2828        self._root_obj["chunks"]["shard_cids"] = array_index.shard_cids
2829        return None
2830
2831    async def resize_variable(
2832        self, variable_name: str, new_shape: Tuple[int, ...]
2833    ) -> None:
2834        if self.read_only:
2835            raise PermissionError("Cannot resize a read-only store.")
2836        async with self._write_lock:
2837            await self._resize_variable_unlocked(variable_name, new_shape)
2838
2839    async def _resize_variable_unlocked(
2840        self, variable_name: str, new_shape: Tuple[int, ...]
2841    ) -> None:
2842        """
2843        Resizes the Zarr metadata and shard index for a specific variable.
2844        """
2845        await self._resize_complete.wait()
2846
2847        normalized_name = self._normalize_array_path(variable_name)
2848        zarr_metadata_key = (
2849            "zarr.json" if normalized_name == "" else f"{normalized_name}/zarr.json"
2850        )
2851
2852        old_zarr_metadata_cid = self._root_obj["metadata"].get(zarr_metadata_key)
2853        if not old_zarr_metadata_cid:
2854            raise KeyError(
2855                f"Cannot find metadata for key '{zarr_metadata_key}' to resize."
2856            )
2857
2858        old_zarr_metadata_bytes = await self.cas.load(old_zarr_metadata_cid)
2859        zarr_metadata_json = json.loads(old_zarr_metadata_bytes)
2860        zarr_metadata_json["shape"] = list(new_shape)
2861
2862        new_zarr_metadata_bytes = json.dumps(zarr_metadata_json, indent=2).encode(
2863            "utf-8"
2864        )
2865        new_zarr_metadata_cid = await self.cas.save(
2866            new_zarr_metadata_bytes, codec="raw"
2867        )
2868
2869        self._root_obj["metadata"][zarr_metadata_key] = new_zarr_metadata_cid
2870        self._metadata_read_cache[zarr_metadata_key] = new_zarr_metadata_bytes
2871        if self._manifest_version == SHARDED_ZARR_V2:
2872            await self._register_array_metadata_from_bytes(
2873                zarr_metadata_key, new_zarr_metadata_bytes
2874            )
2875        self._dirty_root = True
2876
2877    async def migrate_v1_to_v2(self, primary_array_path: str) -> str:
2878        if self.read_only:
2879            raise PermissionError("Cannot migrate a read-only store.")
2880        async with self._write_lock:
2881            return await self._migrate_v1_to_v2_unlocked(primary_array_path)
2882
2883    async def _migrate_v1_to_v2_unlocked(self, primary_array_path: str) -> str:
2884        """
2885        Rewrite this store root as a v2 manifest, reusing the existing v1 shards
2886        under ``primary_array_path``.
2887        """
2888        normalized_path = self._normalize_array_path(primary_array_path)
2889        if not normalized_path:
2890            raise ValueError("primary_array_path must be a non-empty array path.")
2891        if self._manifest_version != SHARDED_ZARR_V1:
2892            raise ValueError("Only sharded_zarr_v1 stores can be migrated to v2.")
2893
2894        await self._flush_unlocked()
2895        await self._shard_data_cache.clear()
2896        # Cache keys change shape from int to tuple[str, int] across the
2897        # migration, so stale counter entries would be unreachable garbage.
2898        self._sparse_read_counts.clear()
2899        self._full_mode_latched = False
2900
2901        source_array_path = self._infer_v1_migration_source_array_path(normalized_path)
2902        old_metadata = dict(self._root_obj.get("metadata", {}))
2903        migrated_metadata = {
2904            self._rewrite_v1_metadata_key_for_migration(
2905                key, source_array_path, normalized_path
2906            ): cid
2907            for key, cid in old_metadata.items()
2908        }
2909        await self._add_missing_group_metadata(migrated_metadata, normalized_path)
2910        old_shard_cids = list(self._root_obj["chunks"]["shard_cids"])
2911        migrated_index = ArrayIndex(
2912            array_path=normalized_path,
2913            array_shape=self._array_shape,
2914            chunk_shape=self._chunk_shape,
2915            chunks_per_shard=self._chunks_per_shard,
2916            shard_cids=old_shard_cids,
2917        )
2918
2919        self._manifest_version = SHARDED_ZARR_V2
2920        self.array_indices = {normalized_path: migrated_index}
2921        self._primary_array_path = normalized_path
2922        self._default_chunks_per_shard = migrated_index.chunks_per_shard
2923        self._set_legacy_geometry_from_index(migrated_index)
2924        self._root_obj = {
2925            "manifest_version": SHARDED_ZARR_V2,
2926            "store_type": "py_hamt.sharded_zarr",
2927            "zarr_format": 3,
2928            "sharding_config": {
2929                "chunks_per_shard": migrated_index.chunks_per_shard,
2930                "order": migrated_index.order,
2931            },
2932            "metadata": migrated_metadata,
2933            "arrays": {normalized_path: migrated_index.to_manifest()},
2934        }
2935        self._metadata_read_cache.clear()
2936        self._dirty_root = True
2937        return await self._flush_unlocked()
2938
2939    async def list_dir(self, prefix: str) -> AsyncIterator[str]:
2940        seen: Set[str] = set()
2941        normalized_prefix = prefix.strip("/")
2942        if (
2943            self.read_only
2944            and normalized_prefix == ""
2945            and self._v2_requires_explicit_group_for_root_read()
2946        ):
2947            raise ValueError(self._V2_MULTI_GROUP_READ_MESSAGE)
2948        effective_prefix = self._v2_effective_list_dir_prefix(normalized_prefix)
2949        match_prefix = f"{effective_prefix}/" if effective_prefix else ""
2950
2951        if self._is_chunk_listing_prefix(effective_prefix):
2952            async for key in self._iter_chunk_keys():
2953                # Unrecorded V1 roots emit the primary shard tree with a
2954                # leading slash ("/c/...") to distinguish it from legacy
2955                # metadata keys, but list_dir prefixes are slash-stripped.
2956                # Normalize the emitted key so list_dir("c") matches "/c/...".
2957                normalized_key = key[1:] if key.startswith("/") else key
2958                if not normalized_key.startswith(match_prefix):
2959                    continue
2960                suffix = normalized_key[len(match_prefix) :]
2961                first_component = suffix.split("/", 1)[0]
2962                if first_component not in seen:
2963                    seen.add(first_component)
2964                    yield first_component
2965            return
2966
2967        for key in self._list_dir_candidate_keys():
2968            if not key.startswith(match_prefix):
2969                continue
2970            suffix = key[len(match_prefix) :]
2971            if suffix == "":
2972                continue
2973            first_component = suffix.split("/", 1)[0]
2974            if first_component not in seen:
2975                seen.add(first_component)
2976                yield first_component

Implements the Zarr Store API using a sharded layout for chunk CIDs.

sharded_zarr_v1 roots keep the original single global shard index for compatibility. sharded_zarr_v2 roots keep one shard index per Zarr array path, allowing grouped arrays to reuse chunk coordinates without collisions.

ShardedZarrStore( cas: ContentAddressedStore, read_only: bool, root_cid: Optional[str] = None, *, max_cache_memory_bytes: int = 104857600, shard_read_mode: Literal['full', 'sparse', 'auto'] = 'auto')
494    def __init__(
495        self,
496        cas: ContentAddressedStore,
497        read_only: bool,
498        root_cid: Optional[str] = None,
499        *,
500        max_cache_memory_bytes: int = 100 * 1024 * 1024,  # 100MB default
501        shard_read_mode: ShardReadMode = "auto",
502    ):
503        """Use the async `open()` classmethod to instantiate this class."""
504        super().__init__(read_only=read_only)
505        if shard_read_mode not in {"full", "sparse", "auto"}:
506            raise ValueError(
507                f"Unsupported shard_read_mode: {shard_read_mode!r}. "
508                "Expected 'full', 'sparse', or 'auto'."
509            )
510        self.cas = cas
511        self._root_cid = root_cid
512        self.shard_read_mode = shard_read_mode
513        self._root_obj: dict = {}
514        self._manifest_version = SHARDED_ZARR_V1
515
516        self._resize_lock = asyncio.Lock()
517        self._resize_complete = asyncio.Event()
518        self._resize_complete.set()
519        self._write_lock = asyncio.Lock()
520        self._shard_locks: DefaultDict[ShardCacheKey, asyncio.Lock] = defaultdict(
521            asyncio.Lock
522        )
523
524        self._shard_data_cache = MemoryBoundedLRUCache(max_cache_memory_bytes)
525        self._pending_shard_loads: Dict[ShardCacheKey, asyncio.Event] = {}
526        # Per-shard sparse-read counts, used only to detect the scan pattern in
527        # "auto" mode. Detection is per-shard because 256 reads spread across
528        # 256 distinct shards is a point-read workload, not a scan.
529        self._sparse_read_counts: Dict[ShardCacheKey, int] = {}
530        # Latched once any single shard crosses the threshold: the caller is
531        # scanning, so every shard gets the full path from here on. Store-wide
532        # because the access pattern belongs to the caller, not the shard.
533        #
534        # Held in a one-element list so with_read_only clones share the *cell*
535        # rather than a copied bool. They already share the counters and the
536        # cache, and a clone that latched would otherwise clear those shared
537        # counters while leaving its siblings believing they were still sparse
538        # -- so the next clone would resume sparse reads with no counter left
539        # to re-earn promotion. See _full_mode_latched.
540        self._full_mode_latched_cell: List[bool] = [False]
541        self._metadata_read_cache: Dict[str, bytes] = {}
542
543        self.array_indices: Dict[str, ArrayIndex] = {}
544        self._primary_array_path: Optional[str] = None
545        # True only when the primary path was heuristically inferred for a
546        # legacy V1 root (never recorded on disk). Tracked separately from the
547        # path's truthiness because an inferred *root* primary is "", which is
548        # indistinguishable from "not inferred" under a plain truthiness check.
549        self._primary_inferred: bool = False
550        self._default_chunks_per_shard: Optional[int] = None
551
552        self._array_shape: Tuple[int, ...] = ()
553        self._chunk_shape: Tuple[int, ...] = ()
554        self._chunks_per_dim: Tuple[int, ...] = ()
555        self._chunks_per_shard: int = 0
556        self._num_shards: int = 0
557        self._total_chunks: int = 0
558
559        self._dirty_root = False
560        self._v2_pending_root_group_write = False

Use the async open() classmethod to instantiate this class.

cas
shard_read_mode
array_indices: Dict[str, py_hamt.sharded_zarr_store.ArrayIndex]
@classmethod
async def open( cls, cas: ContentAddressedStore, read_only: bool, root_cid: Optional[str] = None, *, array_shape: Optional[Tuple[int, ...]] = None, chunk_shape: Optional[Tuple[int, ...]] = None, chunks_per_shard: Optional[int] = None, max_cache_memory_bytes: int = 104857600, manifest_version: Optional[str] = None, primary_array_path: str = '', shard_read_mode: Literal['full', 'sparse', 'auto'] = 'auto') -> ShardedZarrStore:
632    @classmethod
633    async def open(
634        cls,
635        cas: ContentAddressedStore,
636        read_only: bool,
637        root_cid: Optional[str] = None,
638        *,
639        array_shape: Optional[Tuple[int, ...]] = None,
640        chunk_shape: Optional[Tuple[int, ...]] = None,
641        chunks_per_shard: Optional[int] = None,
642        max_cache_memory_bytes: int = 100 * 1024 * 1024,  # 100MB default
643        manifest_version: Optional[str] = None,
644        primary_array_path: str = "",
645        shard_read_mode: ShardReadMode = "auto",
646    ) -> "ShardedZarrStore":
647        """
648        Asynchronously opens an existing ShardedZarrStore or initializes a new one.
649
650        Shape-based creation remains the v1 compatibility path. To create a new
651        path-aware v2 store, pass ``manifest_version="sharded_zarr_v2"`` or omit
652        ``array_shape``/``chunk_shape`` and provide ``chunks_per_shard``.
653
654        ``shard_read_mode`` controls how a **read-only** cache miss resolves a
655        chunk pointer. It has no effect on writes: a writable store always goes
656        through the shard cache so pending writes stay visible, so writes behave
657        as ``"full"`` does regardless of this setting.
658
659        - ``"auto"`` (the default) starts sparse, then latches the **entire
660          store** to full decodes once any *single* shard has been read
661          ``_SPARSE_PROMOTE_THRESHOLD`` times. The latch is store-wide because
662          the access pattern belongs to the caller rather than the shard: a
663          caller reading one shard that heavily is scanning and will scan the
664          rest too, so making every other shard re-learn that independently
665          would re-pay the detection cost on each one. It is permanent for the
666          store's lifetime and unaffected by cache eviction. Below the
667          threshold it is byte-for-byte the ``"sparse"`` path, so point reads
668          pay nothing for the safety net.
669        - ``"sparse"`` fetches only the requested entry and caches nothing. Far
670          cheaper for point reads, but degrades without bound on a scan,
671          eventually costing more than ``"full"``. Pin this when you know the
672          workload is point reads and want to rule out the latch entirely --
673          for instance a long-lived reader that hammers one hot shard without
674          ever scanning, which ``"auto"`` would latch on.
675        - ``"full"`` decodes and caches the whole shard. Flat cost regardless of
676          how many chunks are then read from it, so it suits known scans and
677          skips ``"auto"``'s detection cost.
678        """
679        store = cls(
680            cas,
681            read_only,
682            root_cid,
683            max_cache_memory_bytes=max_cache_memory_bytes,
684            shard_read_mode=shard_read_mode,
685        )
686        if root_cid:
687            await store._load_root_from_cid()
688        elif not read_only:
689            if manifest_version not in {None, SHARDED_ZARR_V1, SHARDED_ZARR_V2}:
690                raise ValueError(f"Incompatible manifest version: {manifest_version}.")
691
692            if (
693                manifest_version in {None, SHARDED_ZARR_V1}
694                and array_shape is None
695                and chunk_shape is None
696                and chunks_per_shard is None
697            ):
698                raise ValueError(
699                    "array_shape and chunk_shape must be provided for a new store."
700                )
701            if manifest_version in {None, SHARDED_ZARR_V1} and (
702                (array_shape is None) != (chunk_shape is None)
703            ):
704                raise ValueError(
705                    "array_shape and chunk_shape must be provided for a new store."
706                )
707            if manifest_version == SHARDED_ZARR_V1 and (
708                array_shape is None or chunk_shape is None
709            ):
710                raise ValueError(
711                    "array_shape and chunk_shape must be provided for a new store."
712                )
713
714            if not isinstance(chunks_per_shard, int) or chunks_per_shard <= 0:
715                raise ValueError("chunks_per_shard must be a positive integer.")
716
717            use_v2 = manifest_version == SHARDED_ZARR_V2 or (
718                array_shape is None and chunk_shape is None
719            )
720            if use_v2:
721                if (array_shape is None) != (chunk_shape is None):
722                    raise ValueError(
723                        "array_shape and chunk_shape must both be provided when seeding a v2 array index."
724                    )
725                store._initialize_new_root_v2(
726                    chunks_per_shard=chunks_per_shard,
727                    array_shape=array_shape,
728                    chunk_shape=chunk_shape,
729                    primary_array_path=primary_array_path,
730                )
731            else:
732                if array_shape is None or chunk_shape is None:  # pragma: no cover
733                    raise ValueError(
734                        "array_shape and chunk_shape must be provided for a new store."
735                    )
736                store._initialize_new_root(array_shape, chunk_shape, chunks_per_shard)
737        else:
738            raise ValueError("root_cid must be provided for a read-only store.")
739        return store

Asynchronously opens an existing ShardedZarrStore or initializes a new one.

Shape-based creation remains the v1 compatibility path. To create a new path-aware v2 store, pass manifest_version="sharded_zarr_v2" or omit array_shape/chunk_shape and provide chunks_per_shard.

shard_read_mode controls how a read-only cache miss resolves a chunk pointer. It has no effect on writes: a writable store always goes through the shard cache so pending writes stay visible, so writes behave as "full" does regardless of this setting.

  • "auto" (the default) starts sparse, then latches the entire store to full decodes once any single shard has been read _SPARSE_PROMOTE_THRESHOLD times. The latch is store-wide because the access pattern belongs to the caller rather than the shard: a caller reading one shard that heavily is scanning and will scan the rest too, so making every other shard re-learn that independently would re-pay the detection cost on each one. It is permanent for the store's lifetime and unaffected by cache eviction. Below the threshold it is byte-for-byte the "sparse" path, so point reads pay nothing for the safety net.
  • "sparse" fetches only the requested entry and caches nothing. Far cheaper for point reads, but degrades without bound on a scan, eventually costing more than "full". Pin this when you know the workload is point reads and want to rule out the latch entirely -- for instance a long-lived reader that hammers one hot shard without ever scanning, which "auto" would latch on.
  • "full" decodes and caches the whole shard. Flat cost regardless of how many chunks are then read from it, so it suits known scans and skips "auto"'s detection cost.
async def set_partial_values( self, key_start_values: Iterable[typing.Tuple[str, int, bytes | bytearray | memoryview]]) -> None:
1983    async def set_partial_values(
1984        self, key_start_values: Iterable[Tuple[str, int, BytesLike]]
1985    ) -> None:
1986        raise NotImplementedError(
1987            "Partial writes are not supported by ShardedZarrStore."
1988        )

Store values at a given key, starting at byte range_start.

Parameters

key_start_values : list[tuple[str, int, BytesLike]] set of key, range_start, values triples, a key may occur multiple times with different range_starts, range_starts (considering the length of the respective values) must not specify overlapping ranges for the same key

async def get_partial_values( self, prototype: zarr.core.buffer.core.BufferPrototype, key_ranges: Iterable[typing.Tuple[str, zarr.abc.store.RangeByteRequest | zarr.abc.store.OffsetByteRequest | zarr.abc.store.SuffixByteRequest | None]]) -> List[Optional[zarr.core.buffer.core.Buffer]]:
1990    async def get_partial_values(
1991        self,
1992        prototype: zarr.core.buffer.BufferPrototype,
1993        key_ranges: Iterable[Tuple[str, zarr.abc.store.ByteRequest | None]],
1994    ) -> List[Optional[zarr.core.buffer.Buffer]]:
1995        tasks = [self.get(key, prototype, byte_range) for key, byte_range in key_ranges]
1996        results = await asyncio.gather(*tasks)
1997        return results

Retrieve possibly partial values from given key_ranges.

Parameters

prototype : BufferPrototype The prototype of the output buffer. Stores may support a default buffer prototype. key_ranges : Iterable[tuple[str, tuple[int | None, int | None]]] Ordered set of key, range pairs, a key may occur multiple times with different ranges

Returns

list of values, in the order of the key_ranges, may contain null/none for missing keys

def with_read_only( self, read_only: bool = False) -> ShardedZarrStore:
1999    def with_read_only(self, read_only: bool = False) -> "ShardedZarrStore":
2000        """
2001        Return this store (if the flag already matches) or a shallow clone with
2002        the requested read-only status.
2003        """
2004        if read_only == self.read_only:
2005            return self
2006
2007        clone = type(self).__new__(type(self))
2008
2009        clone.cas = self.cas
2010        clone._root_cid = self._root_cid
2011        clone._root_obj = self._root_obj
2012        clone._manifest_version = self._manifest_version
2013        clone.shard_read_mode = self.shard_read_mode
2014
2015        clone._resize_lock = self._resize_lock
2016        clone._resize_complete = self._resize_complete
2017        clone._write_lock = self._write_lock
2018        clone._shard_locks = self._shard_locks
2019
2020        clone._shard_data_cache = self._shard_data_cache
2021        clone._pending_shard_loads = self._pending_shard_loads
2022        # Both shared by reference, like the cache above. The counters must be
2023        # shared so a clone does not restart counting while reading through the
2024        # *same* cache; the latch cell must be shared for the same reason in
2025        # reverse -- latching clears the shared counters, so a sibling holding
2026        # a copied False would resume sparse reads with nothing left to re-earn
2027        # promotion from.
2028        clone._sparse_read_counts = self._sparse_read_counts
2029        clone._full_mode_latched_cell = self._full_mode_latched_cell
2030        clone._metadata_read_cache = self._metadata_read_cache
2031
2032        clone.array_indices = self.array_indices
2033        clone._primary_array_path = self._primary_array_path
2034        clone._primary_inferred = self._primary_inferred
2035        clone._default_chunks_per_shard = self._default_chunks_per_shard
2036
2037        clone._array_shape = self._array_shape
2038        clone._chunk_shape = self._chunk_shape
2039        clone._chunks_per_dim = self._chunks_per_dim
2040        clone._chunks_per_shard = self._chunks_per_shard
2041        clone._num_shards = self._num_shards
2042        clone._total_chunks = self._total_chunks
2043
2044        clone._dirty_root = self._dirty_root
2045        clone._v2_pending_root_group_write = self._v2_pending_root_group_write
2046
2047        # A V1 primary that was inferred against a read-only open never reached
2048        # the persistence branch in _infer_v1_legacy_primary_array_path (that
2049        # branch is gated on `not self.read_only`), so it lives only in the
2050        # in-memory `_primary_inferred` flag. Making a writable clone would carry
2051        # that flag forward while leaving the shared root unrecorded: the first
2052        # chunk write skips its seal-on-write (it is gated on
2053        # `not self._primary_inferred`), so a same-geometry secondary array could
2054        # flush with no recorded primary. On reopen inference would then be
2055        # ambiguous and misroute the secondary's metadata chunk into the primary
2056        # shard slot. Perform the deferred persistence the read-only open
2057        # skipped, reproducing the state a writable open would have produced.
2058        if (
2059            not read_only
2060            and clone._manifest_version == SHARDED_ZARR_V1
2061            and clone._primary_inferred
2062            and isinstance(clone._root_obj.get("chunks"), dict)
2063            and "primary_array_path" not in clone._root_obj["chunks"]
2064        ):
2065            clone._root_obj["chunks"]["primary_array_path"] = (
2066                clone._primary_array_path or ""
2067            )
2068            clone._dirty_root = True
2069
2070        zarr.abc.store.Store.__init__(clone, read_only=read_only)
2071        return clone

Return this store (if the flag already matches) or a shallow clone with the requested read-only status.

async def flush(self) -> str:
2078    async def flush(self) -> str:
2079        async with self._write_lock:
2080            return await self._flush_unlocked()
async def get( self, key: str, prototype: zarr.core.buffer.core.BufferPrototype, byte_range: Union[zarr.abc.store.RangeByteRequest, zarr.abc.store.OffsetByteRequest, zarr.abc.store.SuffixByteRequest, NoneType] = None) -> Optional[zarr.core.buffer.core.Buffer]:
2183    async def get(
2184        self,
2185        key: str,
2186        prototype: zarr.core.buffer.BufferPrototype,
2187        byte_range: Optional[zarr.abc.store.ByteRequest] = None,
2188    ) -> Optional[zarr.core.buffer.Buffer]:
2189        with instrumentation.span(
2190            "py_hamt.sharded_store.get",
2191            {
2192                "py_hamt.zarr.key": key,
2193                "py_hamt.zarr.byte_range": byte_range is not None,
2194            },
2195        ):
2196            started_at = time.perf_counter()
2197            hit = False
2198            kind = "metadata"
2199            shard_idx_for_trace: int | None = None
2200            lookup_key = self._v2_effective_read_key(key)
2201            try:
2202                parsed_chunk = self._parse_chunk_key(lookup_key)
2203            except (ValueError, IndexError):
2204                if self._manifest_version != SHARDED_ZARR_V2:
2205                    raise
2206                return None
2207            try:
2208                if parsed_chunk is None:
2209                    metadata_cid_obj = self._root_obj["metadata"].get(lookup_key)
2210                    if metadata_cid_obj is None:
2211                        return None
2212                    data = (
2213                        self._metadata_read_cache.get(lookup_key)
2214                        if byte_range is None
2215                        else None
2216                    )
2217                    if data is None:
2218                        req_offset, req_length, req_suffix = self._map_byte_request(
2219                            byte_range
2220                        )
2221                        data = await self.cas.load(
2222                            metadata_cid_obj,
2223                            offset=req_offset,
2224                            length=req_length,
2225                            suffix=req_suffix,
2226                        )
2227                    if byte_range is None:
2228                        self._metadata_read_cache[lookup_key] = data
2229                    hit = True
2230                    return prototype.buffer.from_bytes(data)
2231
2232                kind = "chunk"
2233                try:
2234                    array_index = self._array_index_for_path(parsed_chunk.array_path)
2235                except KeyError:
2236                    return await self._get_legacy_metadata_chunk(
2237                        lookup_key, prototype, byte_range
2238                    )
2239                linear_chunk_index = self._get_linear_chunk_index_for_index(
2240                    parsed_chunk.coords, array_index
2241                )
2242                shard_idx, index_in_shard = self._get_shard_info_for_index(
2243                    linear_chunk_index, array_index
2244                )
2245                shard_idx_for_trace = shard_idx
2246
2247                cache_key = self._cache_key(array_index.array_path, shard_idx)
2248                shard_lock = self._shard_locks[cache_key]
2249                async with shard_lock:
2250                    cached_shard = await self._shard_data_cache.get(cache_key)
2251                    use_sparse = (
2252                        self.shard_read_mode != "full"
2253                        and self._sparse_read_is_eligible(
2254                            array_index, shard_idx, cached_shard, byte_range
2255                        )
2256                    )
2257                    if use_sparse and self.shard_read_mode == "auto":
2258                        use_sparse = self._auto_mode_wants_sparse(cache_key)
2259                    if use_sparse:
2260                        chunk_cid_obj = await self._load_sparse_shard_entry(
2261                            cache_key,
2262                            shard_idx,
2263                            cast(CID, array_index.shard_cids[shard_idx]),
2264                            index_in_shard,
2265                            array_index.chunks_per_shard,
2266                        )
2267                    else:
2268                        if cached_shard is None:
2269                            cached_shard = await self._load_or_initialize_shard_cache(
2270                                shard_idx, array_index.array_path
2271                            )
2272                        chunk_cid_obj = cached_shard[index_in_shard]
2273                if chunk_cid_obj is None:
2274                    legacy_buffer = await self._get_legacy_metadata_chunk(
2275                        lookup_key, prototype, byte_range
2276                    )
2277                    hit = legacy_buffer is not None
2278                    return legacy_buffer
2279
2280                req_offset, req_length, req_suffix = self._map_byte_request(byte_range)
2281                data = await self.cas.load(
2282                    chunk_cid_obj,
2283                    offset=req_offset,
2284                    length=req_length,
2285                    suffix=req_suffix,
2286                )
2287                hit = True
2288                return prototype.buffer.from_bytes(data)
2289            finally:
2290                instrumentation.record_zarr_get(
2291                    store="sharded_store",
2292                    key=key,
2293                    kind=kind,
2294                    hit=hit,
2295                    seconds=time.perf_counter() - started_at,
2296                    byte_range=byte_range is not None,
2297                    shard_idx=shard_idx_for_trace,
2298                )

Retrieve the value associated with a given key.

Parameters

key : str prototype : BufferPrototype The prototype of the output buffer. Stores may support a default buffer prototype. byte_range : ByteRequest, optional ByteRequest may be one of the following. If not provided, all data associated with the key is retrieved. - RangeByteRequest(int, int): Request a specific range of bytes in the form (start, end). The end is exclusive. If the given range is zero-length or starts after the end of the object, an error will be returned. Additionally, if the range ends after the end of the object, the entire remainder of the object will be returned. Otherwise, the exact requested range will be returned. - OffsetByteRequest(int): Request all bytes starting from a given byte offset. This is equivalent to bytes={int}- as an HTTP header. - SuffixByteRequest(int): Request the last int bytes. Note that here, int is the size of the request, not the byte offset. This is equivalent to bytes=-{int} as an HTTP header.

Returns

Buffer

async def set(self, key: str, value: zarr.core.buffer.core.Buffer) -> None:
2300    async def set(self, key: str, value: zarr.core.buffer.Buffer) -> None:
2301        if self.read_only:
2302            raise PermissionError("Cannot write to a read-only store.")
2303        async with self._write_lock:
2304            await self._set_unlocked(key, value)
2305        return None  # type: ignore[return-value]

Store a (key, value) pair.

Parameters

key : str value : Buffer

async def set_pointer(self, key: str, pointer: str) -> None:
2335    async def set_pointer(self, key: str, pointer: str) -> None:
2336        if self.read_only:
2337            raise PermissionError("Cannot write to a read-only store.")
2338        async with self._write_lock:
2339            await self._resize_complete.wait()
2340            await self._set_pointer(key, pointer, register_metadata=True)
async def exists(self, key: str) -> bool:
2419    async def exists(self, key: str) -> bool:
2420        lookup_key = self._v2_effective_read_key(key)
2421        try:
2422            parsed_chunk = self._parse_chunk_key(lookup_key)
2423            if parsed_chunk is None:
2424                return lookup_key in self._root_obj.get("metadata", {})
2425            try:
2426                array_index = self._array_index_for_path(parsed_chunk.array_path)
2427            except KeyError:
2428                return lookup_key in self._root_obj.get("metadata", {})
2429            linear_chunk_index = self._get_linear_chunk_index_for_index(
2430                parsed_chunk.coords, array_index
2431            )
2432            shard_idx, index_in_shard = self._get_shard_info_for_index(
2433                linear_chunk_index, array_index
2434            )
2435            async with self._use_shard(
2436                shard_idx, array_index.array_path
2437            ) as target_shard_list:
2438                return target_shard_list[
2439                    index_in_shard
2440                ] is not None or lookup_key in self._root_obj.get("metadata", {})
2441        except (ValueError, IndexError, KeyError):
2442            return False

Check if a key exists in the store.

Parameters

key : str

Returns

bool

supports_writes: bool
2444    @property
2445    def supports_writes(self) -> bool:
2446        return not self.read_only

Does the store support writes?

supports_partial_writes: bool
2448    @property
2449    def supports_partial_writes(self) -> bool:
2450        return False

Does the store support partial writes?

supports_deletes: bool
2452    @property
2453    def supports_deletes(self) -> bool:
2454        return not self.read_only

Does the store support deletes?

async def delete(self, key: str) -> None:
2456    async def delete(self, key: str) -> None:
2457        if self.read_only:
2458            raise PermissionError("Cannot delete from a read-only store.")
2459        async with self._write_lock:
2460            await self._delete_unlocked(key)

Remove a key from the store

Parameters

key : str

async def delete_dir(self, prefix: str) -> None:
2498    async def delete_dir(self, prefix: str) -> None:
2499        if self._manifest_version != SHARDED_ZARR_V2:
2500            await zarr.abc.store.Store.delete_dir(self, prefix)
2501            return
2502        if self.read_only:
2503            raise PermissionError("Cannot delete from a read-only store.")
2504
2505        async with self._write_lock:
2506            await self._resize_complete.wait()
2507            normalized_prefix = prefix.strip("/")
2508            if normalized_prefix == "":
2509                await self._clear_v2_unlocked()
2510                return
2511
2512            match_prefix = f"{normalized_prefix}/"
2513            metadata_keys_to_delete = [
2514                key
2515                for key in self._root_obj.get("metadata", {})
2516                if key.startswith(match_prefix)
2517            ]
2518            for key in metadata_keys_to_delete:
2519                await self._delete_unlocked(key)
2520            await self._prune_v2_array_indices_for_prefix(normalized_prefix)

Remove all keys and prefixes in the store that begin with a given prefix.

async def clear(self) -> None:
2522    async def clear(self) -> None:
2523        if self._manifest_version != SHARDED_ZARR_V2:
2524            await zarr.abc.store.Store.clear(self)
2525            return
2526        if self.read_only:
2527            raise PermissionError("Cannot clear a read-only store.")
2528
2529        async with self._write_lock:
2530            await self._resize_complete.wait()
2531            await self._clear_v2_unlocked()

Clear the store.

Remove all keys and values from the store.

supports_listing: bool
2598    @property
2599    def supports_listing(self) -> bool:
2600        return True

Does the store support listing?

async def list(self) -> AsyncIterator[str]:
2602    async def list(self) -> AsyncIterator[str]:
2603        yielded: set[str] = set()
2604        for key in list(self._root_obj.get("metadata", {})):
2605            yielded.add(key)
2606            yield key
2607
2608        async for chunk_key in self._iter_chunk_keys():
2609            if chunk_key not in yielded:
2610                yield chunk_key

Retrieve all keys in the store.

Returns

AsyncIterator[str]

async def list_prefix(self, prefix: str) -> AsyncIterator[str]:
2657    async def list_prefix(self, prefix: str) -> AsyncIterator[str]:
2658        async for key in self.list():
2659            if key.startswith(prefix):
2660                yield key

Retrieve all keys in the store that begin with a given prefix. Keys are returned relative to the root of the store.

Parameters

prefix : str

Returns

AsyncIterator[str]

async def graft_store( self, store_to_graft_cid: str, chunk_offset: Tuple[int, ...], *, source_array_path: Optional[str] = None, target_array_path: Optional[str] = None) -> None:
2698    async def graft_store(
2699        self,
2700        store_to_graft_cid: str,
2701        chunk_offset: Tuple[int, ...],
2702        *,
2703        source_array_path: Optional[str] = None,
2704        target_array_path: Optional[str] = None,
2705    ) -> None:
2706        if self.read_only:
2707            raise PermissionError("Cannot graft onto a read-only store.")
2708        async with self._write_lock:
2709            await self._graft_store_unlocked(
2710                store_to_graft_cid,
2711                chunk_offset,
2712                source_array_path=source_array_path,
2713                target_array_path=target_array_path,
2714            )
async def resize_store( self, new_shape: Tuple[int, ...], *, array_path: Optional[str] = None) -> None:
2789    async def resize_store(
2790        self, new_shape: Tuple[int, ...], *, array_path: Optional[str] = None
2791    ) -> None:
2792        if self.read_only:
2793            raise PermissionError("Cannot resize a read-only store.")
2794        async with self._write_lock:
2795            await self._resize_store_unlocked(new_shape, array_path=array_path)
async def resize_variable(self, variable_name: str, new_shape: Tuple[int, ...]) -> None:
2831    async def resize_variable(
2832        self, variable_name: str, new_shape: Tuple[int, ...]
2833    ) -> None:
2834        if self.read_only:
2835            raise PermissionError("Cannot resize a read-only store.")
2836        async with self._write_lock:
2837            await self._resize_variable_unlocked(variable_name, new_shape)
async def migrate_v1_to_v2(self, primary_array_path: str) -> str:
2877    async def migrate_v1_to_v2(self, primary_array_path: str) -> str:
2878        if self.read_only:
2879            raise PermissionError("Cannot migrate a read-only store.")
2880        async with self._write_lock:
2881            return await self._migrate_v1_to_v2_unlocked(primary_array_path)
async def list_dir(self, prefix: str) -> AsyncIterator[str]:
2939    async def list_dir(self, prefix: str) -> AsyncIterator[str]:
2940        seen: Set[str] = set()
2941        normalized_prefix = prefix.strip("/")
2942        if (
2943            self.read_only
2944            and normalized_prefix == ""
2945            and self._v2_requires_explicit_group_for_root_read()
2946        ):
2947            raise ValueError(self._V2_MULTI_GROUP_READ_MESSAGE)
2948        effective_prefix = self._v2_effective_list_dir_prefix(normalized_prefix)
2949        match_prefix = f"{effective_prefix}/" if effective_prefix else ""
2950
2951        if self._is_chunk_listing_prefix(effective_prefix):
2952            async for key in self._iter_chunk_keys():
2953                # Unrecorded V1 roots emit the primary shard tree with a
2954                # leading slash ("/c/...") to distinguish it from legacy
2955                # metadata keys, but list_dir prefixes are slash-stripped.
2956                # Normalize the emitted key so list_dir("c") matches "/c/...".
2957                normalized_key = key[1:] if key.startswith("/") else key
2958                if not normalized_key.startswith(match_prefix):
2959                    continue
2960                suffix = normalized_key[len(match_prefix) :]
2961                first_component = suffix.split("/", 1)[0]
2962                if first_component not in seen:
2963                    seen.add(first_component)
2964                    yield first_component
2965            return
2966
2967        for key in self._list_dir_candidate_keys():
2968            if not key.startswith(match_prefix):
2969                continue
2970            suffix = key[len(match_prefix) :]
2971            if suffix == "":
2972                continue
2973            first_component = suffix.split("/", 1)[0]
2974            if first_component not in seen:
2975                seen.add(first_component)
2976                yield first_component

Retrieve all keys and prefixes with a given prefix and which do not contain the character “/” after the given prefix.

Parameters

prefix : str

Returns

AsyncIterator[str]

ShardReadMode = typing.Literal['full', 'sparse', 'auto']
class ShardedZarrV1DeprecationWarning(builtins.FutureWarning):
135class ShardedZarrV1DeprecationWarning(FutureWarning):
136    """Warning emitted when using deprecated sharded_zarr_v1 roots."""

Warning emitted when using deprecated sharded_zarr_v1 roots.

async def convert_hamt_to_sharded( cas: ContentAddressedStore, hamt_root_cid: str, chunks_per_shard: int) -> str:
 81async def convert_hamt_to_sharded(
 82    cas: ContentAddressedStore, hamt_root_cid: str, chunks_per_shard: int
 83) -> str:
 84    """
 85    Converts a Zarr dataset from a HAMT-based store to a ShardedZarrStore.
 86
 87    Args:
 88        cas: An initialized ContentAddressedStore instance (KuboCAS).
 89        hamt_root_cid: The root CID of the source ZarrHAMTStore.
 90        chunks_per_shard: The number of chunks to group into a single shard in the new store.
 91
 92    Returns:
 93        The root CID of the newly created ShardedZarrStore.
 94    """
 95    print(f"--- Starting Conversion from HAMT Root {hamt_root_cid} ---")
 96    start_time = time.perf_counter()
 97    # 1. Open the source HAMT store for reading
 98    print("Opening source HAMT store...")
 99    hamt_ro = await HAMT.build(
100        cas=cas, root_node_id=hamt_root_cid, values_are_bytes=True, read_only=True
101    )
102
103    # 2. Create the destination ShardedZarrStore for writing.
104    print(
105        f"Initializing new ShardedZarrStore v2 with {chunks_per_shard} chunks per shard..."
106    )
107    dest_store = await ShardedZarrStore.open(
108        cas=cas,
109        read_only=False,
110        chunks_per_shard=chunks_per_shard,
111        manifest_version=SHARDED_ZARR_V2,
112    )
113
114    print("Destination store initialized.")
115
116    # 3. Copy metadata first so each chunked array path registers its own shard
117    # index before chunk pointers are inserted.
118    print("Starting data migration...")
119    count = 0
120    async for key in hamt_ro.keys():
121        if not _is_zarr_metadata_key(key):
122            continue
123        count += 1
124        cid = await hamt_ro.get_pointer(key)
125        if not isinstance(cid, CID):  # pragma: no cover
126            raise TypeError(f"Expected CID pointer for key {key!r}.")
127        cid_base32_str = str(cid.encode("base32"))
128        await dest_store.set_pointer(key, cid_base32_str)
129        if count % 200 == 0:  # pragma: no cover
130            print(f"Migrated {count} keys...")  # pragma: no cover
131
132    async for key in hamt_ro.keys():
133        chunk_key = _normalize_zarr_chunk_key(key, dest_store.array_indices)
134        if chunk_key is None:
135            if _is_zarr_metadata_key(key):
136                continue
137            raise ValueError(
138                f"Cannot classify Zarr key {key!r} as metadata or chunk during conversion."
139            )
140        count += 1
141        cid = await hamt_ro.get_pointer(key)
142        if not isinstance(cid, CID):  # pragma: no cover
143            raise TypeError(f"Expected CID pointer for key {key!r}.")
144        cid_base32_str = str(cid.encode("base32"))
145        await dest_store.set_pointer(chunk_key, cid_base32_str)
146        if count % 200 == 0:  # pragma: no cover
147            print(f"Migrated {count} keys...")  # pragma: no cover
148
149    print(f"Migration of {count} total keys complete.")
150
151    # 5. Finalize the new store by flushing it to the CAS
152    print("Flushing new store to get final root CID...")
153    new_root_cid = await dest_store.flush()
154    end_time = time.perf_counter()
155
156    print("\n--- Conversion Complete! ---")
157    print(f"Total time: {end_time - start_time:.2f} seconds")
158    print(f"New ShardedZarrStore Root CID: {new_root_cid}")
159    return new_root_cid

Converts a Zarr dataset from a HAMT-based store to a ShardedZarrStore.

Args: cas: An initialized ContentAddressedStore instance (KuboCAS). hamt_root_cid: The root CID of the source ZarrHAMTStore. chunks_per_shard: The number of chunks to group into a single shard in the new store.

Returns: The root CID of the newly created ShardedZarrStore.

async def sharded_converter_cli():
162async def sharded_converter_cli():
163    parser = argparse.ArgumentParser(
164        description="Convert a Zarr HAMT store to a Sharded Zarr store."
165    )
166    parser.add_argument(
167        "hamt_cid", type=str, help="The root CID of the source Zarr HAMT store."
168    )
169    parser.add_argument(
170        "--chunks-per-shard",
171        type=int,
172        default=6250,
173        help="Number of chunk CIDs to store per shard in the new store.",
174    )
175    parser.add_argument(
176        "--rpc-url",
177        type=str,
178        default="http://127.0.0.1:5001",
179        help="The URL of the IPFS Kubo RPC API.",
180    )
181    parser.add_argument(
182        "--gateway-url",
183        type=str,
184        default="http://127.0.0.1:8080",
185        help="The URL of the IPFS Gateway.",
186    )
187    args = parser.parse_args()
188    # Initialize the KuboCAS client with the provided RPC and Gateway URLs
189    async with KuboCAS(
190        rpc_base_url=args.rpc_url, gateway_base_url=args.gateway_url
191    ) as cas_client:
192        try:
193            await convert_hamt_to_sharded(
194                cas=cas_client,
195                hamt_root_cid=args.hamt_cid,
196                chunks_per_shard=args.chunks_per_shard,
197            )
198        except Exception as e:
199            print(f"\nAn error occurred: {e}")