Skip to content

Commit 20f2ac8

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 20f2ac8

20 files changed

Lines changed: 1887 additions & 17 deletions

File tree

changes/4192.feature.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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. Builtin adapters (`zip:`,
8+
`zarr2:`/`zarr3:`) follow in separate pull requests.
9+
10+
Behavior notes:
11+
12+
- The `|` character is now reserved as the pipeline delimiter in every string
13+
store specification, and no percent-escape is decoded; pass a `pathlib.Path`
14+
to address a local file whose name contains `|`. URLs without a `|` (and
15+
without a registered root adapter scheme) are handled exactly as before —
16+
registered adapters cannot intercept zarr's native `file:`/`memory:`
17+
routing, and fsspec chained URLs (`zip::s3://...`) keep flowing to fsspec.
18+
- Inside a pipeline, `memory:` and `file:` roots follow the URL pipeline
19+
spec's semantics (spelling equivalences; `file:` must be absolute, with at
20+
most a `localhost` authority).
21+
- Mode `"a"` (open-or-create, the `zarr.open` default) on a *read-only* store
22+
now serves the "open" half instead of raising upfront, for all stores;
23+
unambiguous write modes (`"w"`, `"w-"`, `"r+"`) still raise.
24+
- For root-adapter URLs (e.g. `gh://org/repo`), `storage_options` are handed
25+
to the adapter and are not validated as used by `make_store`.

docs/api/zarr/abc/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ Abstract base classes for extending Zarr-Python.
1111
- **[zarr.abc.metadata](./metadata.md)** - Creating metadata classes compatible with the Zarr API
1212
- **[zarr.abc.numcodec](./numcodec.md)** - Protocols and classes for modeling codec interface used by numcodecs
1313
- **[zarr.abc.store](./store.md)** - ABC for implementing Zarr stores and managing getting and setting bytes in a store
14+
- **[zarr.abc.url_pipeline](./url_pipeline.md)** - ABC for implementing [URL pipeline](https://github.com/jbms/url-pipeline) adapters

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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,31 @@ 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+
124+
The `|` character is reserved as the pipeline delimiter in every string store
125+
specification, and no percent-escape is decoded: to address a local file whose
126+
*name* contains `|` (or `#`), pass a `pathlib.Path` instead of a string.
127+
Registered adapters cannot intercept zarr's native `file:` and `memory:` root
128+
schemes, and fsspec's chained-URL syntax (`zip::s3://...`) keeps flowing to
129+
fsspec.
130+
106131
## Explicit Store Creation
107132

108133
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: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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+
import enum
20+
from abc import ABC, abstractmethod
21+
from dataclasses import dataclass
22+
from typing import TYPE_CHECKING, Any
23+
24+
from zarr.errors import URLPipelineError
25+
26+
if TYPE_CHECKING:
27+
from zarr.abc.store import Store
28+
from zarr.core.common import AccessModeLiteral, ZarrFormat
29+
30+
__all__ = [
31+
"AdapterResolution",
32+
"PipelineContext",
33+
"PipelineSegment",
34+
"URLPipelineAdapter",
35+
]
36+
37+
38+
class _Unset(enum.Enum):
39+
token = 0
40+
41+
42+
_UNSET = _Unset.token
43+
44+
45+
@dataclass(frozen=True)
46+
class PipelineSegment:
47+
"""
48+
One `|`-delimited sub-URL of a URL pipeline.
49+
50+
Attributes
51+
----------
52+
scheme : str
53+
The lowercased URL scheme. Empty string only for a schemeless root
54+
(a bare local path), which is treated as opaque text.
55+
body : str
56+
The text after `scheme:` and before any `?`. Interpretation is
57+
scheme-defined; it is **not** URL-normalized, so case-significant
58+
content (e.g. icechunk snapshot IDs) is preserved.
59+
query : str | None
60+
The raw query string after `?`, or None. Interpretation is
61+
scheme-defined.
62+
raw : str
63+
The exact original sub-URL text, preserved for lossless
64+
reconstruction of the pipeline.
65+
"""
66+
67+
scheme: str
68+
body: str
69+
query: str | None
70+
raw: str
71+
72+
def __str__(self) -> str:
73+
return self.raw
74+
75+
76+
@dataclass(frozen=True)
77+
class AdapterResolution:
78+
"""
79+
The result of resolving a URL pipeline (or a prefix of one).
80+
81+
Attributes
82+
----------
83+
store : Store
84+
The resolved store.
85+
path : str
86+
Residual path *within* the store that the pipeline addresses
87+
(e.g. `"path/to/node"` for `...|icechunk://tag.v1/path/to/node`).
88+
Empty string when the pipeline addresses the store root.
89+
zarr_format : ZarrFormat | None
90+
Zarr format selected by a format segment (`zarr2:`/`zarr3:`),
91+
or None if unspecified. A wrapper adapter that re-wraps a preceding
92+
resolution must carry every field it does not change forward —
93+
prefer `dataclasses.replace(preceding, store=..., path=...)` over
94+
reconstructing, so fields added later are never silently dropped.
95+
"""
96+
97+
store: Store
98+
path: str = ""
99+
zarr_format: ZarrFormat | None = None
100+
101+
102+
@dataclass(frozen=True)
103+
class PipelineContext:
104+
"""
105+
Context handed to a [`URLPipelineAdapter`][zarr.abc.url_pipeline.URLPipelineAdapter]
106+
describing the pipeline to the left of its segment.
107+
108+
Attributes
109+
----------
110+
preceding : tuple[PipelineSegment, ...]
111+
The parsed sub-URLs to the left of the adapter's segment, outer to
112+
inner. Empty when the adapter's segment is the pipeline root.
113+
mode : AccessModeLiteral | None
114+
The access mode requested by the caller (e.g. `zarr.open(mode=...)`),
115+
or None when unspecified. Adapters for read-only resources should
116+
raise for unambiguous write modes (`"w"`, `"w-"`, `"r+"`) and
117+
open read-only otherwise. `"a"` (the `zarr.open` default) means
118+
open-or-create: read-only adapters serve the "open" half, and any
119+
subsequent write fails at the store level.
120+
storage_options : dict[str, Any] | None
121+
Options passed by the caller. By convention these configure the
122+
*root* sub-URL (e.g. fsspec options); adapters may consume
123+
adapter-specific keys, and should namespace them (e.g.
124+
`myscheme_credentials`) to avoid collisions with other segments'
125+
backends. An adapter that consumes keys should strip them before
126+
resolving the rest of the pipeline, by passing the reduced mapping
127+
to [`resolve_preceding`][zarr.abc.url_pipeline.PipelineContext.resolve_preceding].
128+
Non-dict forms of the caller-facing `storage_options` argument are
129+
reserved for future per-segment configuration (one mapping per
130+
pipeline segment); this attribute will remain a single mapping —
131+
the one addressed to this adapter's segment.
132+
"""
133+
134+
preceding: tuple[PipelineSegment, ...]
135+
mode: AccessModeLiteral | None
136+
storage_options: dict[str, Any] | None
137+
138+
@property
139+
def read_only(self) -> bool:
140+
"""
141+
True when the caller requires a read-only store (`mode == "r"`).
142+
143+
Adapters must construct their store read-only when this is set
144+
(the resolver enforces it afterwards); when it is False, they may
145+
construct a writable store if the underlying resource supports
146+
writing.
147+
"""
148+
return self.mode == "r"
149+
150+
@property
151+
def preceding_url(self) -> str:
152+
"""
153+
The pipeline to the left of this segment, reconstructed exactly.
154+
155+
An adapter that consumes this string instead of calling
156+
[`resolve_preceding`][zarr.abc.url_pipeline.PipelineContext.resolve_preceding]
157+
takes ownership of the *entire* preceding pipeline: it must
158+
validate every preceding segment itself and raise
159+
[`URLPipelineError`][zarr.errors.URLPipelineError] for segments it
160+
does not understand, so that no segment is ever silently ignored.
161+
"""
162+
return "|".join(segment.raw for segment in self.preceding)
163+
164+
async def resolve_preceding(
165+
self,
166+
*,
167+
mode: AccessModeLiteral | _Unset | None = _UNSET,
168+
storage_options: dict[str, Any] | _Unset | None = _UNSET,
169+
) -> AdapterResolution:
170+
"""
171+
Resolve the preceding pipeline into a store.
172+
173+
This is the entry point for *wrapper* adapters (e.g. `zip:`) that
174+
operate on the resource produced by the segments to their left. It
175+
composes with any preceding adapters, because each segment is
176+
resolved by its own adapter. Adapters backed by their own I/O
177+
machinery (e.g. `icechunk:`) may instead consume
178+
[`preceding_url`][zarr.abc.url_pipeline.PipelineContext.preceding_url]
179+
and never materialize the intermediate store — subject to the
180+
ownership contract documented there.
181+
182+
Parameters
183+
----------
184+
mode : AccessModeLiteral | None, optional
185+
Override the mode used to resolve the preceding pipeline.
186+
Wrapper adapters that only read the preceding resource should
187+
pass `mode="r"` so the root is opened read-only and without
188+
create-on-open side effects, regardless of the caller's mode.
189+
When omitted, the caller's mode is used.
190+
storage_options : dict | None, optional
191+
Override the options forwarded to the preceding pipeline. An
192+
adapter that consumed adapter-specific keys should pass the
193+
remaining mapping here (or None when nothing remains), so the
194+
root store never sees keys that were not addressed to it.
195+
When omitted, the caller's options are forwarded unchanged.
196+
"""
197+
from zarr.storage._url_pipeline import _resolve
198+
199+
if not self.preceding:
200+
raise URLPipelineError(
201+
"this adapter segment is at the pipeline root; "
202+
"there is no preceding sub-URL to resolve"
203+
)
204+
return await _resolve(
205+
self.preceding,
206+
mode=self.mode if isinstance(mode, _Unset) else mode,
207+
storage_options=(
208+
self.storage_options if isinstance(storage_options, _Unset) else storage_options
209+
),
210+
)
211+
212+
213+
class URLPipelineAdapter(ABC):
214+
"""
215+
Handler for one URL pipeline scheme.
216+
217+
Subclasses implement a single classmethod,
218+
[`open_pipeline_segment`][zarr.abc.url_pipeline.URLPipelineAdapter.open_pipeline_segment],
219+
and are registered under the `zarr.url_adapters` entry-point group with
220+
the URL scheme as the entry-point name:
221+
222+
[project.entry-points."zarr.url_adapters"]
223+
mypackage.myscheme = "mypackage.zarr_adapter:MyAdapter"
224+
225+
Nonstandard schemes should be vendor-prefixed (`vendor.scheme`) per the
226+
URL pipeline specification.
227+
228+
An adapter is used in two positions:
229+
230+
- as an *adapter segment*: `s3://bucket/repo|icechunk://tag.v1` — the
231+
context carries the preceding sub-URLs;
232+
- as a *root scheme*: `gh://org/repo` — `context.preceding` is empty.
233+
"""
234+
235+
@classmethod
236+
@abstractmethod
237+
async def open_pipeline_segment(
238+
cls, segment: PipelineSegment, context: PipelineContext
239+
) -> AdapterResolution:
240+
"""
241+
Resolve `segment` (in the context of the pipeline to its left)
242+
into a store and an optional residual path within that store.
243+
244+
The returned store must already be open and must honor
245+
`context.read_only` (the resolver additionally enforces it by
246+
downgrading — or rejecting — a writable store when the caller
247+
required read-only).
248+
249+
This coroutine runs on zarr's internal I/O event loop. It must not
250+
block (do I/O through async APIs or a thread executor) and must not
251+
call zarr's synchronous API (`zarr.open`, `Group.open`, or anything
252+
else that uses `zarr.core.sync.sync`) — doing so raises
253+
`SyncError`. To open the preceding pipeline, use
254+
[`resolve_preceding`][zarr.abc.url_pipeline.PipelineContext.resolve_preceding].
255+
"""
256+
...

0 commit comments

Comments
 (0)