Skip to content

Commit 03bed15

Browse files
jhammanclaude
andcommitted
feat: url-pipeline core — parser, adapter ABC, registry, store hooks
Implements URL pipeline support (https://github.com/jbms/url-pipeline): '|'-chained URLs resolve through pluggable adapters registered under the 'zarr.url_adapters' entry-point group (entry-point name = URL scheme). - zarr.abc.url_pipeline: PipelineSegment, AdapterResolution, PipelineContext, URLPipelineAdapter (single-classmethod contract) - zarr.storage._url_pipeline: parse_pipeline / resolve_pipeline; the root sub-URL delegates to make_store so existing file/memory/fsspec routing is unchanged - registry: register_url_adapter / get_url_adapter / list_url_adapter_schemes (name check only; no adapter imports) - make_store/make_store_path route strings containing '|' (or a registered root scheme) through the resolver; residual store paths combine with the user-supplied path - StorePath gains a zarr_format attribute (populated by format segments in a follow-up) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7d33da8 commit 03bed15

13 files changed

Lines changed: 881 additions & 56 deletions

File tree

‎changes/2943.feature.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Added core support for URL pipelines (https://github.com/jbms/url-pipeline):
2+
`|`-chained URLs that address zarr data through nested storage layers, e.g.
3+
`s3://bucket/data.zip|zip:|zarr3:`. This PR adds the parser, the single-method
4+
`zarr.abc.url_pipeline.URLPipelineAdapter` interface, and the
5+
`zarr.url_adapters` entry-point group through which third-party packages
6+
(e.g. Icechunk) register adapters for their own schemes. Adapters for a scheme
7+
are loaded lazily and individually; URLs without a `|` separator (and without
8+
a registered root scheme) are handled exactly as before. Builtin adapters
9+
(`zip:`, `zarr2:`/`zarr3:`) follow in separate pull requests.

‎src/zarr/abc/url_pipeline.py‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""
2+
Abstract base class and data model for URL pipeline adapters.
3+
4+
A URL pipeline is a `|`-separated chain of sub-URLs, read outer-to-inner,
5+
as specified by https://github.com/jbms/url-pipeline. The first sub-URL (the
6+
*root*) locates a resource using a conventional URL, and each subsequent
7+
sub-URL names an *adapter* that reinterprets everything to its left:
8+
9+
s3://bucket/data.zip|zip:path/inside|zarr3:
10+
11+
Third-party packages provide adapters by subclassing
12+
[`URLPipelineAdapter`][zarr.abc.url_pipeline.URLPipelineAdapter] and
13+
registering the class under the `zarr.url_adapters` entry-point group,
14+
using the URL scheme as the entry-point name.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from abc import ABC, abstractmethod
20+
from dataclasses import dataclass, field
21+
from typing import TYPE_CHECKING, Any
22+
23+
if TYPE_CHECKING:
24+
from collections.abc import Awaitable, Callable
25+
26+
from zarr.abc.store import Store
27+
from zarr.core.common import AccessModeLiteral, ZarrFormat
28+
29+
__all__ = [
30+
"AdapterResolution",
31+
"PipelineContext",
32+
"PipelineSegment",
33+
"URLPipelineAdapter",
34+
]
35+
36+
37+
@dataclass(frozen=True)
38+
class PipelineSegment:
39+
"""
40+
One `|`-delimited sub-URL of a URL pipeline.
41+
42+
Attributes
43+
----------
44+
scheme : str
45+
The lowercased URL scheme. Empty string only for a schemeless root
46+
(a bare local path).
47+
body : str
48+
The text after `scheme:` and before any `?`. Interpretation is
49+
scheme-defined; it is **not** URL-normalized, so case-significant
50+
content (e.g. icechunk snapshot IDs) is preserved.
51+
query : str | None
52+
The raw query string after `?`, or None. Interpretation is
53+
scheme-defined.
54+
raw : str
55+
The exact original sub-URL text, preserved for lossless
56+
reconstruction of the pipeline.
57+
"""
58+
59+
scheme: str
60+
body: str
61+
query: str | None
62+
raw: str
63+
64+
def __str__(self) -> str:
65+
return self.raw
66+
67+
68+
@dataclass(frozen=True)
69+
class AdapterResolution:
70+
"""
71+
The result of resolving a URL pipeline (or a prefix of one).
72+
73+
Attributes
74+
----------
75+
store : Store
76+
The resolved store.
77+
path : str
78+
Residual path *within* the store that the pipeline addresses
79+
(e.g. `"path/to/node"` for `...|icechunk://tag.v1/path/to/node`).
80+
Empty string when the pipeline addresses the store root.
81+
zarr_format : ZarrFormat | None
82+
Zarr format selected by a format segment (`zarr2:`/`zarr3:`),
83+
or None if unspecified.
84+
"""
85+
86+
store: Store
87+
path: str = ""
88+
zarr_format: ZarrFormat | None = None
89+
90+
91+
@dataclass(frozen=True)
92+
class PipelineContext:
93+
"""
94+
Context handed to a [`URLPipelineAdapter`][zarr.abc.url_pipeline.URLPipelineAdapter]
95+
describing the pipeline to the left of its segment.
96+
97+
Attributes
98+
----------
99+
preceding : tuple[PipelineSegment, ...]
100+
The parsed sub-URLs to the left of the adapter's segment, outer to
101+
inner. Empty when the adapter's segment is the pipeline root.
102+
mode : AccessModeLiteral | None
103+
The access mode requested by the caller (e.g. `zarr.open(mode=...)`),
104+
or None when unspecified. Adapters for read-only resources should
105+
raise for explicit write modes (`"w"`, `"w-"`, `"a"`, `"r+"`)
106+
and open read-only for `None` and `"r"`.
107+
read_only : bool
108+
True when `mode == "r"`. Adapters must construct their store
109+
read-only when this is set.
110+
storage_options : dict[str, Any] | None
111+
Options passed by the caller. By convention these configure the
112+
*root* sub-URL (e.g. fsspec options), but adapters may consume
113+
adapter-specific keys.
114+
"""
115+
116+
preceding: tuple[PipelineSegment, ...]
117+
mode: AccessModeLiteral | None
118+
read_only: bool
119+
storage_options: dict[str, Any] | None
120+
_resolver: Callable[[tuple[PipelineSegment, ...]], Awaitable[AdapterResolution]] = field(
121+
repr=False
122+
)
123+
124+
@property
125+
def preceding_url(self) -> str:
126+
"""The pipeline to the left of this segment, reconstructed exactly."""
127+
return "|".join(segment.raw for segment in self.preceding)
128+
129+
async def resolve_preceding(self) -> AdapterResolution:
130+
"""
131+
Resolve the preceding pipeline into a store.
132+
133+
This is the entry point for *wrapper* adapters (e.g. `zip:`) that
134+
operate on the resource produced by the segments to their left.
135+
Adapters backed by their own I/O machinery (e.g. `icechunk:`)
136+
should use [`preceding_url`][zarr.abc.url_pipeline.PipelineContext.preceding_url]
137+
instead and never materialize the intermediate store.
138+
"""
139+
return await self._resolver(self.preceding)
140+
141+
142+
class URLPipelineAdapter(ABC):
143+
"""
144+
Handler for one URL pipeline scheme.
145+
146+
Subclasses implement a single classmethod,
147+
[`open_pipeline_segment`][zarr.abc.url_pipeline.URLPipelineAdapter.open_pipeline_segment],
148+
and are registered under the `zarr.url_adapters` entry-point group with
149+
the URL scheme as the entry-point name:
150+
151+
[project.entry-points."zarr.url_adapters"]
152+
myscheme = "mypackage.zarr_adapter:MyAdapter"
153+
154+
An adapter is used in two positions:
155+
156+
- as an *adapter segment*: `s3://bucket/repo|icechunk://tag.v1` — the
157+
context carries the preceding sub-URLs;
158+
- as a *root scheme*: `gh://org/repo` — `context.preceding` is empty.
159+
"""
160+
161+
@classmethod
162+
@abstractmethod
163+
async def open_pipeline_segment(
164+
cls, segment: PipelineSegment, context: PipelineContext
165+
) -> AdapterResolution:
166+
"""
167+
Resolve `segment` (in the context of the pipeline to its left)
168+
into a store, an optional residual path within that store, and an
169+
optional zarr format.
170+
171+
The returned store must already be open and must honor
172+
`context.read_only`.
173+
"""
174+
...

‎src/zarr/api/asynchronous.py‎

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103

104104
def _infer_overwrite(mode: AccessModeLiteral) -> bool:
105105
"""
106-
Check that an ``AccessModeLiteral`` is compatible with overwriting an existing Zarr node.
106+
Check that an `AccessModeLiteral` is compatible with overwriting an existing Zarr node.
107107
"""
108108
return mode in _OVERWRITE_MODES
109109

@@ -112,9 +112,9 @@ def _warn_unimplemented_kwargs(kwargs: dict[str, Any]) -> None:
112112
"""
113113
Emit a "not yet implemented" warning for each provided keyword argument that is not None.
114114
115-
``kwargs`` maps a keyword argument name to its supplied value. The ``stacklevel`` is chosen
115+
`kwargs` maps a keyword argument name to its supplied value. The `stacklevel` is chosen
116116
so the warning points at the caller of the public API function (the same location as an
117-
inline ``warnings.warn(..., stacklevel=2)`` would).
117+
inline `warnings.warn(..., stacklevel=2)` would).
118118
"""
119119
for name, value in kwargs.items():
120120
if value is not None:
@@ -194,7 +194,7 @@ async def consolidate_metadata(
194194
195195
Upon completion, the metadata of the root node in the Zarr hierarchy will be
196196
updated to include all the metadata of child nodes. For Stores that do
197-
not support consolidated metadata, this operation raises a ``TypeError``.
197+
not support consolidated metadata, this operation raises a `TypeError`.
198198
199199
Parameters
200200
----------
@@ -215,10 +215,10 @@ async def consolidate_metadata(
215215
Returns
216216
-------
217217
group: AsyncGroup
218-
The group, with the ``consolidated_metadata`` field set to include
218+
The group, with the `consolidated_metadata` field set to include
219219
the metadata of each child node. If the Store doesn't support
220220
consolidated metadata, this function raises a `TypeError`.
221-
See ``Store.supports_consolidated_metadata``.
221+
See `Store.supports_consolidated_metadata`.
222222
"""
223223
store_path = await make_store_path(store, path=path)
224224

@@ -420,7 +420,7 @@ async def open_consolidated(
420420
*args: Any, use_consolidated: Literal[True] = True, **kwargs: Any
421421
) -> AsyncGroup:
422422
"""
423-
Alias for [`open_group`][zarr.api.asynchronous.open_group] with ``use_consolidated=True``.
423+
Alias for [`open_group`][zarr.api.asynchronous.open_group] with `use_consolidated=True`.
424424
"""
425425
if use_consolidated is not True:
426426
raise TypeError(
@@ -484,7 +484,7 @@ async def save_array(
484484
arr : ndarray
485485
NumPy array with data to save.
486486
zarr_format : {2, 3, None}, optional
487-
The zarr format to use when saving. The default is ``None``, which will
487+
The zarr format to use when saving. The default is `None`, which will
488488
use the default Zarr format defined in the global configuration object.
489489
path : str or None, optional
490490
The path within the store where the array will be saved.
@@ -745,12 +745,12 @@ async def create_group(
745745
path : str, optional
746746
Group path within store.
747747
overwrite : bool, optional
748-
If True, pre-existing data at ``path`` will be deleted before
748+
If True, pre-existing data at `path` will be deleted before
749749
creating the group.
750750
zarr_format : {2, 3, None}, optional
751751
The zarr format to use when saving.
752-
If no ``zarr_format`` is provided, the default format will be used.
753-
This default can be changed by modifying the value of ``default_zarr_format``
752+
If no `zarr_format` is provided, the default format will be used.
753+
This default can be changed by modifying the value of `default_zarr_format`
754754
in [`zarr.config`][zarr.config].
755755
storage_options : dict
756756
If using an fsspec URL to create the store, these will be passed to
@@ -828,17 +828,17 @@ async def open_group(
828828
Whether to use consolidated metadata.
829829
830830
By default, consolidated metadata is used if it's present in the
831-
store (in the ``zarr.json`` for Zarr format 3 and in the ``.zmetadata`` file
831+
store (in the `zarr.json` for Zarr format 3 and in the `.zmetadata` file
832832
for Zarr format 2).
833833
834-
To explicitly require consolidated metadata, set ``use_consolidated=True``,
834+
To explicitly require consolidated metadata, set `use_consolidated=True`,
835835
which will raise an exception if consolidated metadata is not found.
836836
837-
To explicitly *not* use consolidated metadata, set ``use_consolidated=False``,
837+
To explicitly *not* use consolidated metadata, set `use_consolidated=False`,
838838
which will fall back to using the regular, non consolidated metadata.
839839
840840
Zarr format 2 allowed configuring the key storing the consolidated metadata
841-
(``.zmetadata`` by default). Specify the custom key as ``use_consolidated``
841+
(`.zmetadata` by default). Specify the custom key as `use_consolidated`
842842
to load consolidated metadata from a non-default key.
843843
844844
Returns
@@ -924,40 +924,40 @@ async def create(
924924
shape : int or tuple of ints
925925
Array shape.
926926
chunks : int or tuple of ints, optional
927-
Chunk shape. If True, will be guessed from ``shape`` and ``dtype``. If
928-
False, will be set to ``shape``, i.e., single chunk for the whole array.
927+
Chunk shape. If True, will be guessed from `shape` and `dtype`. If
928+
False, will be set to `shape`, i.e., single chunk for the whole array.
929929
If an int, the chunk size in each dimension will be given by the value
930-
of ``chunks``. Default is True.
930+
of `chunks`. Default is True.
931931
dtype : str or dtype, optional
932932
NumPy dtype.
933933
compressor : Codec, optional
934934
Primary compressor to compress chunk data.
935-
Zarr format 2 only. Zarr format 3 arrays should use ``codecs`` instead.
935+
Zarr format 2 only. Zarr format 3 arrays should use `codecs` instead.
936936
937-
If neither ``compressor`` nor ``filters`` are provided, the default compressor
937+
If neither `compressor` nor `filters` are provided, the default compressor
938938
[`zarr.codecs.ZstdCodec`][] is used.
939939
940-
If ``compressor`` is set to ``None``, no compression is used.
940+
If `compressor` is set to `None`, no compression is used.
941941
fill_value : Any, optional
942942
Fill value for the array.
943943
order : {'C', 'F'}, optional
944-
Deprecated in favor of the ``config`` keyword argument.
945-
Pass ``{'order': <value>}`` to ``create`` instead of using this parameter.
944+
Deprecated in favor of the `config` keyword argument.
945+
Pass `{'order': <value>}` to `create` instead of using this parameter.
946946
Memory layout to be used within each chunk.
947-
If not specified, the ``array.order`` parameter in the global config will be used.
947+
If not specified, the `array.order` parameter in the global config will be used.
948948
store : StoreLike or None, default=None
949949
StoreLike object to open. See the
950950
[storage documentation in the user guide][user-guide-store-like]
951951
for a description of all valid StoreLike values.
952952
synchronizer : object, optional
953953
Array synchronizer.
954954
overwrite : bool, optional
955-
If True, delete all pre-existing data in ``store`` at ``path`` before
955+
If True, delete all pre-existing data in `store` at `path` before
956956
creating the array.
957957
path : str, optional
958958
Path under which array is stored.
959959
chunk_store : StoreLike or None, default=None
960-
Separate storage for chunks. If not provided, ``store`` will be used
960+
Separate storage for chunks. If not provided, `store` will be used
961961
for storage of both chunks and metadata.
962962
filters : Iterable[Codec] | Literal["auto"], optional
963963
Iterable of filters to apply to each chunk of the array, in order, before serializing that
@@ -970,14 +970,14 @@ async def create(
970970
For Zarr format 2, a "filter" can be any numcodecs codec; you should ensure that the
971971
order of your filters is consistent with the behavior of each filter.
972972
973-
The default value of ``"auto"`` instructs Zarr to use a default based on the data
973+
The default value of `"auto"` instructs Zarr to use a default based on the data
974974
type of the array and the Zarr format specified. For all data types in Zarr V3, and most
975975
data types in Zarr V2, the default filters are empty. The only cases where default filters
976976
are not empty is when the Zarr format is 2, and the data type is a variable-length data type like
977977
[`zarr.dtype.VariableLengthUTF8`][] or [`zarr.dtype.VariableLengthUTF8`][]. In these cases,
978978
the default filters contains a single element which is a codec specific to that particular data type.
979979
980-
To create an array with no filters, provide an empty iterable or the value ``None``.
980+
To create an array with no filters, provide an empty iterable or the value `None`.
981981
cache_metadata : bool, optional
982982
If True, array configuration metadata will be cached for the
983983
lifetime of the object. If False, array metadata will be reloaded
@@ -993,17 +993,17 @@ async def create(
993993
A codec to encode object arrays, only needed if dtype=object.
994994
dimension_separator : {'.', '/'}, optional
995995
Separator placed between the dimensions of a chunk.
996-
Zarr format 2 only. Zarr format 3 arrays should use ``chunk_key_encoding`` instead.
996+
Zarr format 2 only. Zarr format 3 arrays should use `chunk_key_encoding` instead.
997997
write_empty_chunks : bool, optional
998-
Deprecated in favor of the ``config`` keyword argument.
999-
Pass ``{'write_empty_chunks': <value>}`` to ``create`` instead of using this parameter.
998+
Deprecated in favor of the `config` keyword argument.
999+
Pass `{'write_empty_chunks': <value>}` to `create` instead of using this parameter.
10001000
If True, all chunks will be stored regardless of their
10011001
contents. If False, each chunk is compared to the array's fill value
10021002
prior to storing. If a chunk is uniformly equal to the fill value, then
10031003
that chunk is not be stored, and the store entry for that chunk's key
10041004
is deleted.
10051005
zarr_format : {2, 3, None}, optional
1006-
The Zarr format to use when creating an array. The default is ``None``,
1006+
The Zarr format to use when creating an array. The default is `None`,
10071007
which instructs Zarr to choose the default Zarr format value defined in the
10081008
runtime configuration.
10091009
meta_array : array-like, optional
@@ -1016,15 +1016,15 @@ async def create(
10161016
chunk_key_encoding : ChunkKeyEncoding, optional
10171017
A specification of how the chunk keys are represented in storage.
10181018
Zarr format 3 only. Zarr format 2 arrays should use `dimension_separator` instead.
1019-
Default is ``("default", "/")``.
1019+
Default is `("default", "/")`.
10201020
codecs : Sequence of Codecs or dicts, optional
10211021
An iterable of Codec or dict serializations of Codecs. Zarr V3 only.
10221022
1023-
The elements of ``codecs`` specify the transformation from array values to stored bytes.
1024-
Zarr format 3 only. Zarr format 2 arrays should use ``filters`` and ``compressor`` instead.
1023+
The elements of `codecs` specify the transformation from array values to stored bytes.
1024+
Zarr format 3 only. Zarr format 2 arrays should use `filters` and `compressor` instead.
10251025
10261026
If no codecs are provided, default codecs will be used based on the data type of the array.
1027-
For most data types, the default codecs are the tuple ``(BytesCodec(), ZstdCodec())``;
1027+
For most data types, the default codecs are the tuple `(BytesCodec(), ZstdCodec())`;
10281028
data types that require a special [`zarr.abc.codec.ArrayBytesCodec`][], like variable-length strings or bytes,
10291029
will use the [`zarr.abc.codec.ArrayBytesCodec`][] required for the data type instead of [`zarr.codecs.BytesCodec`][].
10301030
dimension_names : Iterable[str | None] | None = None

0 commit comments

Comments
 (0)