Skip to content

Commit 787e785

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 ce10c0b commit 787e785

16 files changed

Lines changed: 1157 additions & 3 deletions

File tree

changes/4192.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.

docs/api/zarr/abc/url_pipeline.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
title: url_pipeline
3+
---
4+
5+
::: zarr.abc.url_pipeline

docs/user-guide/storage.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,24 @@ print(group)
103103
- a [`Store`][zarr.abc.store.Store] or [`StorePath`][zarr.storage.StorePath] -
104104
see explicit store creation below.
105105

106+
## URL Pipelines {#user-guide-url-pipelines}
107+
108+
Zarr supports [URL pipelines](https://github.com/jbms/url-pipeline): `|`-chained URLs
109+
that address zarr data through nested storage layers, read left to right. The first
110+
sub-URL locates a resource with a conventional URL; each subsequent sub-URL names an
111+
*adapter* that reinterprets everything to its left (e.g.
112+
`s3://bucket/data.zip|zip:|zarr3:`). Adapters are provided by packages through the
113+
`zarr.url_adapters` entry-point group — see
114+
[`zarr.abc.url_pipeline`][zarr.abc.url_pipeline] for the adapter interface. Builtin
115+
adapters (`zip:`, `zarr2:`/`zarr3:`) are under development and will expand this
116+
section. URLs without a `|` (and without a registered root scheme) are handled
117+
exactly as before.
118+
119+
`storage_options` passed to `zarr.open` apply to the *root* sub-URL (e.g. fsspec
120+
options for `s3://...`); adapters may consume adapter-specific, namespaced keys.
121+
Non-dict forms of `storage_options` are reserved for future per-segment
122+
configuration.
123+
106124
## Explicit Store Creation
107125

108126
In some cases, it may be helpful to create a store instance directly. Zarr-Python offers

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ nav:
4343
- '<code class="doc-symbol doc-symbol-toc doc-symbol-module"></code> <code>zarr.abc.metadata</code>': api/zarr/abc/metadata.md
4444
- '<code class="doc-symbol doc-symbol-toc doc-symbol-module"></code> <code>zarr.abc.numcodec</code>': api/zarr/abc/numcodec.md
4545
- '<code class="doc-symbol doc-symbol-toc doc-symbol-module"></code> <code>zarr.abc.store</code>': api/zarr/abc/store.md
46+
- '<code class="doc-symbol doc-symbol-toc doc-symbol-module"></code> <code>zarr.abc.url_pipeline</code>': api/zarr/abc/url_pipeline.md
4647
- '<code class="doc-symbol doc-symbol-toc doc-symbol-module"></code> <code>zarr.api</code>':
4748
- api/zarr/api/index.md
4849
- '<code class="doc-symbol doc-symbol-toc doc-symbol-module"></code> <code>zarr.api.asynchronous</code>': api/zarr/api/asynchronous.md

src/zarr/abc/url_pipeline.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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
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+
"""
82+
83+
store: Store
84+
path: str = ""
85+
86+
87+
@dataclass(frozen=True)
88+
class PipelineContext:
89+
"""
90+
Context handed to a [`URLPipelineAdapter`][zarr.abc.url_pipeline.URLPipelineAdapter]
91+
describing the pipeline to the left of its segment.
92+
93+
Attributes
94+
----------
95+
preceding : tuple[PipelineSegment, ...]
96+
The parsed sub-URLs to the left of the adapter's segment, outer to
97+
inner. Empty when the adapter's segment is the pipeline root.
98+
mode : AccessModeLiteral | None
99+
The access mode requested by the caller (e.g. `zarr.open(mode=...)`),
100+
or None when unspecified. Adapters for read-only resources should
101+
raise for unambiguous write modes (`"w"`, `"w-"`, `"r+"`) and
102+
open read-only otherwise. `"a"` (the `zarr.open` default) means
103+
open-or-create: read-only adapters serve the "open" half, and any
104+
subsequent write fails at the store level.
105+
read_only : bool
106+
True when the caller requires a read-only store (`mode == "r"`).
107+
Adapters must construct their store read-only when this is set;
108+
when it is False, they may construct a writable store if the
109+
underlying resource supports writing.
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); adapters may consume
113+
adapter-specific keys, and should namespace them (e.g.
114+
`myscheme_credentials`) to avoid collisions with other segments'
115+
backends. Non-dict forms of the caller-facing `storage_options`
116+
argument are reserved for future per-segment configuration (one
117+
mapping per pipeline segment); this attribute will remain a single
118+
mapping — the one addressed to this adapter's segment.
119+
"""
120+
121+
preceding: tuple[PipelineSegment, ...]
122+
mode: AccessModeLiteral | None
123+
read_only: bool
124+
storage_options: dict[str, Any] | None
125+
_resolver: Callable[[tuple[PipelineSegment, ...]], Awaitable[AdapterResolution]] = field(
126+
repr=False
127+
)
128+
129+
@property
130+
def preceding_url(self) -> str:
131+
"""
132+
The pipeline to the left of this segment, reconstructed exactly.
133+
134+
An adapter that consumes this string instead of calling
135+
[`resolve_preceding`][zarr.abc.url_pipeline.PipelineContext.resolve_preceding]
136+
takes ownership of the *entire* preceding pipeline: it must
137+
validate every preceding segment itself and raise
138+
[`URLPipelineError`][zarr.errors.URLPipelineError] for segments it
139+
does not understand, so that no segment is ever silently ignored.
140+
"""
141+
return "|".join(segment.raw for segment in self.preceding)
142+
143+
async def resolve_preceding(self) -> AdapterResolution:
144+
"""
145+
Resolve the preceding pipeline into a store.
146+
147+
This is the entry point for *wrapper* adapters (e.g. `zip:`) that
148+
operate on the resource produced by the segments to their left. It
149+
composes with any preceding adapters, because each segment is
150+
resolved by its own adapter. Adapters backed by their own I/O
151+
machinery (e.g. `icechunk:`) may instead consume
152+
[`preceding_url`][zarr.abc.url_pipeline.PipelineContext.preceding_url]
153+
and never materialize the intermediate store — subject to the
154+
ownership contract documented there.
155+
"""
156+
return await self._resolver(self.preceding)
157+
158+
159+
class URLPipelineAdapter(ABC):
160+
"""
161+
Handler for one URL pipeline scheme.
162+
163+
Subclasses implement a single classmethod,
164+
[`open_pipeline_segment`][zarr.abc.url_pipeline.URLPipelineAdapter.open_pipeline_segment],
165+
and are registered under the `zarr.url_adapters` entry-point group with
166+
the URL scheme as the entry-point name:
167+
168+
[project.entry-points."zarr.url_adapters"]
169+
myscheme = "mypackage.zarr_adapter:MyAdapter"
170+
171+
An adapter is used in two positions:
172+
173+
- as an *adapter segment*: `s3://bucket/repo|icechunk://tag.v1` — the
174+
context carries the preceding sub-URLs;
175+
- as a *root scheme*: `gh://org/repo` — `context.preceding` is empty.
176+
"""
177+
178+
@classmethod
179+
@abstractmethod
180+
async def open_pipeline_segment(
181+
cls, segment: PipelineSegment, context: PipelineContext
182+
) -> AdapterResolution:
183+
"""
184+
Resolve `segment` (in the context of the pipeline to its left)
185+
into a store and an optional residual path within that store.
186+
187+
The returned store must already be open and must honor
188+
`context.read_only`.
189+
"""
190+
...

src/zarr/errors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"MetadataValidationError",
1313
"NegativeStepError",
1414
"NodeTypeValidationError",
15+
"URLPipelineError",
1516
"UnstableSpecificationWarning",
1617
"VindexInvalidSelectionError",
1718
"ZarrDeprecationWarning",
@@ -100,6 +101,12 @@ class UnknownCodecError(BaseZarrError):
100101
"""
101102

102103

104+
class URLPipelineError(BaseZarrError):
105+
"""
106+
Raised when a URL pipeline cannot be parsed or resolved.
107+
"""
108+
109+
103110
class NodeTypeValidationError(MetadataValidationError):
104111
"""
105112
Specialized exception when the node_type of the metadata document is incorrect.

src/zarr/registry.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from zarr.core.config import BadConfigError, config
99
from zarr.core.dtype import data_type_registry
10-
from zarr.errors import ZarrUserWarning
10+
from zarr.errors import URLPipelineError, ZarrUserWarning
1111

1212
if TYPE_CHECKING:
1313
from importlib.metadata import EntryPoint
@@ -21,6 +21,7 @@
2121
CodecPipeline,
2222
)
2323
from zarr.abc.numcodec import Numcodec
24+
from zarr.abc.url_pipeline import URLPipelineAdapter
2425
from zarr.core.buffer import Buffer, NDBuffer
2526
from zarr.core.chunk_key_encodings import ChunkKeyEncoding
2627
from zarr.core.common import JSON
@@ -32,11 +33,14 @@
3233
"get_codec_class",
3334
"get_ndbuffer_class",
3435
"get_pipeline_class",
36+
"get_url_adapter",
37+
"list_url_adapter_schemes",
3538
"register_buffer",
3639
"register_chunk_key_encoding",
3740
"register_codec",
3841
"register_ndbuffer",
3942
"register_pipeline",
43+
"register_url_adapter",
4044
]
4145

4246

@@ -62,6 +66,7 @@ def register(self, cls: type[T], qualname: str | None = None) -> None:
6266
_buffer_registry: Registry[Buffer] = Registry()
6367
_ndbuffer_registry: Registry[NDBuffer] = Registry()
6468
_chunk_key_encoding_registry: Registry[ChunkKeyEncoding] = Registry()
69+
_url_adapter_registry: Registry[URLPipelineAdapter] = Registry()
6570

6671
"""
6772
The registry module is responsible for managing implementations of codecs,
@@ -108,6 +113,8 @@ def _collect_entrypoints() -> list[Registry[Any]]:
108113
entry_points.select(group="zarr", name="chunk_key_encoding")
109114
)
110115

116+
_url_adapter_registry.lazy_load_list.extend(entry_points.select(group="zarr.url_adapters"))
117+
111118
_pipeline_registry.lazy_load_list.extend(entry_points.select(group="zarr.codec_pipeline"))
112119
_pipeline_registry.lazy_load_list.extend(
113120
entry_points.select(group="zarr", name="codec_pipeline")
@@ -124,6 +131,7 @@ def _collect_entrypoints() -> list[Registry[Any]]:
124131
_buffer_registry,
125132
_ndbuffer_registry,
126133
_chunk_key_encoding_registry,
134+
_url_adapter_registry,
127135
]
128136

129137

@@ -303,6 +311,51 @@ def get_chunk_key_encoding_class(key: str) -> type[ChunkKeyEncoding]:
303311
return _chunk_key_encoding_registry[key]
304312

305313

314+
def register_url_adapter(scheme: str, cls: type[URLPipelineAdapter]) -> None:
315+
"""
316+
Register a [`URLPipelineAdapter`][zarr.abc.url_pipeline.URLPipelineAdapter]
317+
class for a URL scheme.
318+
"""
319+
_url_adapter_registry.register(cls, scheme.lower())
320+
321+
322+
def list_url_adapter_schemes() -> set[str]:
323+
"""
324+
The set of URL schemes with a registered URL pipeline adapter.
325+
326+
Includes adapters advertised via not-yet-loaded `zarr.url_adapters`
327+
entry points; consulting this does not import any adapter code.
328+
"""
329+
return set(_url_adapter_registry) | {e.name for e in _url_adapter_registry.lazy_load_list}
330+
331+
332+
def get_url_adapter(scheme: str) -> type[URLPipelineAdapter]:
333+
"""
334+
Get the URL pipeline adapter class registered for `scheme`.
335+
336+
Loads pending `zarr.url_adapters` entry points for this scheme only, so
337+
resolving one scheme never imports other providers' packages.
338+
"""
339+
key = scheme.lower()
340+
if key not in _url_adapter_registry:
341+
remaining = []
342+
for entry_point in _url_adapter_registry.lazy_load_list:
343+
if entry_point.name == key:
344+
_url_adapter_registry.register(entry_point.load(), qualname=key)
345+
else:
346+
remaining.append(entry_point)
347+
_url_adapter_registry.lazy_load_list[:] = remaining
348+
try:
349+
return _url_adapter_registry[key]
350+
except KeyError:
351+
registered = sorted(list_url_adapter_schemes())
352+
raise URLPipelineError(
353+
f"no URL pipeline adapter is registered for scheme {scheme!r}. "
354+
f"Registered schemes: {registered}. Adapters are provided by "
355+
"packages via the 'zarr.url_adapters' entry-point group."
356+
) from None
357+
358+
306359
_collect_entrypoints()
307360

308361

0 commit comments

Comments
 (0)