dandifs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dandifs-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Yaël Balbastre
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
dandifs-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: dandifs
3
+ Version: 0.1.0
4
+ Summary: A fsspec filesystem for the DANDI archive
5
+ Author-email: Yael Balbastre <y.balbastre@ucl.ac.uk>
6
+ Maintainer-email: Yael Balbastre <y.balbastre@ucl.ac.uk>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2025 Yaël Balbastre
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ Project-URL: Homepage, https://github.com/balbasty/dandifs
30
+ Project-URL: Issues, https://github.com/balbasty/dandifs/issues
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Operating System :: OS Independent
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3.8
35
+ Classifier: Intended Audience :: Science/Research
36
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
37
+ Requires-Python: >=3.8
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE
40
+ Requires-Dist: fsspec[http]>=2022.1.0
41
+ Requires-Dist: aiohttp
42
+ Requires-Dist: typing_extensions
43
+ Provides-Extra: auth
44
+ Requires-Dist: keyring!=23.9.0; extra == "auth"
45
+ Requires-Dist: keyrings.alt; extra == "auth"
46
+ Requires-Dist: pycryptodomex; extra == "auth"
47
+ Provides-Extra: test
48
+ Requires-Dist: pytest; extra == "test"
49
+ Requires-Dist: pytest-asyncio; extra == "test"
50
+ Requires-Dist: aioresponses; extra == "test"
51
+ Requires-Dist: aiohttp<3.14; extra == "test"
52
+ Provides-Extra: lint
53
+ Requires-Dist: ruff==0.16.5; extra == "lint"
54
+ Requires-Dist: codespell; extra == "lint"
55
+ Dynamic: license-file
56
+
57
+ # dandifs
58
+
59
+ An [`fsspec`](https://filesystem-spec.readthedocs.io) filesystem for the
60
+ [DANDI archive](https://dandiarchive.org).
61
+
62
+ `dandifs` resolves `dandi://` URLs to the bytes behind DANDI assets, including
63
+ files that live **inside a Zarr asset**. It is async-first (built on
64
+ `aiohttp`, via fsspec's `AsyncFileSystem`), has a small dependency footprint,
65
+ and does **not** depend on `dandi` or `dandi-schema`.
66
+
67
+ ## Install
68
+
69
+ ```bash
70
+ pip install dandifs
71
+ # optional: keyring-based credentials for private/embargoed dandisets
72
+ pip install "dandifs[auth]"
73
+ ```
74
+
75
+ ## URL format
76
+
77
+ ```
78
+ dandi://<instance>/<dandiset>[@<version>]/<path>
79
+ ```
80
+
81
+ - `<instance>` — a registered instance name (`dandi`, `dandi-staging`,
82
+ `ember`, `ember-sandbox`, ...) or any DANDI-schema server (see *Instances*).
83
+ - `<dandiset>` — the six-digit dandiset identifier, e.g. `000026`.
84
+ - `@<version>` — optional; defaults to the most recent published version, or
85
+ the draft version if none is published.
86
+ - `<path>` — a path within the dandiset. It may descend **into** a Zarr asset:
87
+ in `.../image.zarr/0/0/0`, only `.../image.zarr` is a registered asset and
88
+ `0/0/0` is a key inside that Zarr's store.
89
+
90
+ ## Public surface
91
+
92
+ The **only** public API is the filesystem class, `DandiFileSystem`.
93
+ Everything else is internal.
94
+
95
+ ## Usage
96
+
97
+ ### Via the registered protocol
98
+
99
+ ```python
100
+ import fsspec, json
101
+
102
+ with fsspec.open(
103
+ "dandi://dandi/000026/rawdata/sub-I38/ses-MRI/anat/"
104
+ "sub-I38_ses-MRI-echo-4_flip-4_VFA.json"
105
+ ) as f:
106
+ info = json.load(f)
107
+ ```
108
+
109
+ ### Bound to a dandiset
110
+
111
+ ```python
112
+ from dandifs import DandiFileSystem
113
+
114
+ fs = DandiFileSystem("000026") # bind to a dandiset (+ version)
115
+ fs.ls("rawdata") # browse
116
+ fs.glob("**/anat/*.json") # glob assets
117
+ with fs.open("rawdata/sub-I38/.../VFA.json") as f:
118
+ data = f.read()
119
+ ```
120
+
121
+ ### Files inside a Zarr asset
122
+
123
+ ```python
124
+ fs = DandiFileSystem("000108")
125
+
126
+ fs.ls("path/to/image.zarr") # list entries inside the Zarr
127
+ fs.info("path/to/image.zarr/0/0/0") # stat a chunk
128
+ with fs.open("path/to/image.zarr/0/0/0", "rb") as f:
129
+ chunk = f.read() # read chunk bytes
130
+ ```
131
+
132
+ This also works transparently with libraries that open a store through fsspec,
133
+ e.g. `zarr.open("dandi://dandi/<id>/path/to/image.zarr")`.
134
+
135
+ ### Async
136
+
137
+ `DandiFileSystem` is a real async filesystem. Following fsspec's
138
+ [`AsyncFileSystem`](https://filesystem-spec.readthedocs.io/en/latest/async.html)
139
+ convention, the asynchronous API **is** the set of underscore-prefixed
140
+ coroutine methods (`_ls`, `_info`, `_cat_file`, `_cat`, `_exists`, `_glob`,
141
+ `_get`, ...) — the same pattern used by `s3fs`, `gcsfs`, and every other
142
+ async fsspec backend. The public, non-underscore names (`ls`, `cat_file`,
143
+ ...) are the *synchronous* wrappers fsspec generates from those coroutines.
144
+ Pass `asynchronous=True` and `await` the coroutines:
145
+
146
+ ```python
147
+ fs = DandiFileSystem("000026", asynchronous=True)
148
+ entries = await fs._ls("rawdata")
149
+ data = await fs._cat_file("rawdata/sub-I38/.../VFA.json")
150
+ ```
151
+
152
+ The synchronous API is generated from these coroutines by fsspec and runs on
153
+ fsspec's shared background event loop, so it also works inside a running loop
154
+ (e.g. Jupyter).
155
+
156
+ ## Authentication
157
+
158
+ Public dandisets need **no** authentication. For private or embargoed
159
+ resources, a token is resolved **lazily** — only after a request returns
160
+ `401` — in this order:
161
+
162
+ 1. an explicit `token=` argument to `DandiFileSystem`;
163
+ 2. the `DANDI_API_KEY` environment variable;
164
+ 3. a per-instance `<INSTANCE>_API_KEY` variable (e.g. `EMBER_API_KEY`);
165
+ 4. the system keyring (only if the optional `auth` extra is installed).
166
+
167
+ ```python
168
+ fs = DandiFileSystem("000026", token="…") # explicit
169
+ # or: export DANDI_API_KEY=… (recommended)
170
+ ```
171
+
172
+ ## Instances
173
+
174
+ Registered instances: `dandi`, `dandi-staging`, `ember`, `ember-sandbox`, and
175
+ `dandi-api-local-docker-tests`. You can also point at any DANDI-schema server:
176
+
177
+ ```python
178
+ # by API URL
179
+ DandiFileSystem("000001", instance="https://api.my-dandi.org/api")
180
+ # by a bare server URL — discovered lazily via the server's /info/ endpoint
181
+ DandiFileSystem("000001", instance="https://my-dandi.org")
182
+ ```
183
+
184
+ ## Licensing
185
+
186
+ `dandifs` is released under the MIT License (see `LICENSE`). Portions of this
187
+ project are derived from [dandi-cli](https://github.com/dandi/dandi-cli)
188
+ (Apache-2.0); see the per-file `NOTICE` headers and `OTHER_LICENSES`.
@@ -0,0 +1,132 @@
1
+ # dandifs
2
+
3
+ An [`fsspec`](https://filesystem-spec.readthedocs.io) filesystem for the
4
+ [DANDI archive](https://dandiarchive.org).
5
+
6
+ `dandifs` resolves `dandi://` URLs to the bytes behind DANDI assets, including
7
+ files that live **inside a Zarr asset**. It is async-first (built on
8
+ `aiohttp`, via fsspec's `AsyncFileSystem`), has a small dependency footprint,
9
+ and does **not** depend on `dandi` or `dandi-schema`.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install dandifs
15
+ # optional: keyring-based credentials for private/embargoed dandisets
16
+ pip install "dandifs[auth]"
17
+ ```
18
+
19
+ ## URL format
20
+
21
+ ```
22
+ dandi://<instance>/<dandiset>[@<version>]/<path>
23
+ ```
24
+
25
+ - `<instance>` — a registered instance name (`dandi`, `dandi-staging`,
26
+ `ember`, `ember-sandbox`, ...) or any DANDI-schema server (see *Instances*).
27
+ - `<dandiset>` — the six-digit dandiset identifier, e.g. `000026`.
28
+ - `@<version>` — optional; defaults to the most recent published version, or
29
+ the draft version if none is published.
30
+ - `<path>` — a path within the dandiset. It may descend **into** a Zarr asset:
31
+ in `.../image.zarr/0/0/0`, only `.../image.zarr` is a registered asset and
32
+ `0/0/0` is a key inside that Zarr's store.
33
+
34
+ ## Public surface
35
+
36
+ The **only** public API is the filesystem class, `DandiFileSystem`.
37
+ Everything else is internal.
38
+
39
+ ## Usage
40
+
41
+ ### Via the registered protocol
42
+
43
+ ```python
44
+ import fsspec, json
45
+
46
+ with fsspec.open(
47
+ "dandi://dandi/000026/rawdata/sub-I38/ses-MRI/anat/"
48
+ "sub-I38_ses-MRI-echo-4_flip-4_VFA.json"
49
+ ) as f:
50
+ info = json.load(f)
51
+ ```
52
+
53
+ ### Bound to a dandiset
54
+
55
+ ```python
56
+ from dandifs import DandiFileSystem
57
+
58
+ fs = DandiFileSystem("000026") # bind to a dandiset (+ version)
59
+ fs.ls("rawdata") # browse
60
+ fs.glob("**/anat/*.json") # glob assets
61
+ with fs.open("rawdata/sub-I38/.../VFA.json") as f:
62
+ data = f.read()
63
+ ```
64
+
65
+ ### Files inside a Zarr asset
66
+
67
+ ```python
68
+ fs = DandiFileSystem("000108")
69
+
70
+ fs.ls("path/to/image.zarr") # list entries inside the Zarr
71
+ fs.info("path/to/image.zarr/0/0/0") # stat a chunk
72
+ with fs.open("path/to/image.zarr/0/0/0", "rb") as f:
73
+ chunk = f.read() # read chunk bytes
74
+ ```
75
+
76
+ This also works transparently with libraries that open a store through fsspec,
77
+ e.g. `zarr.open("dandi://dandi/<id>/path/to/image.zarr")`.
78
+
79
+ ### Async
80
+
81
+ `DandiFileSystem` is a real async filesystem. Following fsspec's
82
+ [`AsyncFileSystem`](https://filesystem-spec.readthedocs.io/en/latest/async.html)
83
+ convention, the asynchronous API **is** the set of underscore-prefixed
84
+ coroutine methods (`_ls`, `_info`, `_cat_file`, `_cat`, `_exists`, `_glob`,
85
+ `_get`, ...) — the same pattern used by `s3fs`, `gcsfs`, and every other
86
+ async fsspec backend. The public, non-underscore names (`ls`, `cat_file`,
87
+ ...) are the *synchronous* wrappers fsspec generates from those coroutines.
88
+ Pass `asynchronous=True` and `await` the coroutines:
89
+
90
+ ```python
91
+ fs = DandiFileSystem("000026", asynchronous=True)
92
+ entries = await fs._ls("rawdata")
93
+ data = await fs._cat_file("rawdata/sub-I38/.../VFA.json")
94
+ ```
95
+
96
+ The synchronous API is generated from these coroutines by fsspec and runs on
97
+ fsspec's shared background event loop, so it also works inside a running loop
98
+ (e.g. Jupyter).
99
+
100
+ ## Authentication
101
+
102
+ Public dandisets need **no** authentication. For private or embargoed
103
+ resources, a token is resolved **lazily** — only after a request returns
104
+ `401` — in this order:
105
+
106
+ 1. an explicit `token=` argument to `DandiFileSystem`;
107
+ 2. the `DANDI_API_KEY` environment variable;
108
+ 3. a per-instance `<INSTANCE>_API_KEY` variable (e.g. `EMBER_API_KEY`);
109
+ 4. the system keyring (only if the optional `auth` extra is installed).
110
+
111
+ ```python
112
+ fs = DandiFileSystem("000026", token="…") # explicit
113
+ # or: export DANDI_API_KEY=… (recommended)
114
+ ```
115
+
116
+ ## Instances
117
+
118
+ Registered instances: `dandi`, `dandi-staging`, `ember`, `ember-sandbox`, and
119
+ `dandi-api-local-docker-tests`. You can also point at any DANDI-schema server:
120
+
121
+ ```python
122
+ # by API URL
123
+ DandiFileSystem("000001", instance="https://api.my-dandi.org/api")
124
+ # by a bare server URL — discovered lazily via the server's /info/ endpoint
125
+ DandiFileSystem("000001", instance="https://my-dandi.org")
126
+ ```
127
+
128
+ ## Licensing
129
+
130
+ `dandifs` is released under the MIT License (see `LICENSE`). Portions of this
131
+ project are derived from [dandi-cli](https://github.com/dandi/dandi-cli)
132
+ (Apache-2.0); see the per-file `NOTICE` headers and `OTHER_LICENSES`.
@@ -0,0 +1,14 @@
1
+ """
2
+ dandifs: an fsspec filesystem for the DANDI archive.
3
+
4
+ The only public surface is the filesystem class. Everything else
5
+ (``_api``, ``_instance``, ``_parser``, ``_keyring``, ``_utils``, ``_consts``,
6
+ ``_exceptions``) is internal and may change without notice.
7
+ """
8
+
9
+ from ._fs import DandiFileSystem
10
+ from ._utils import get_logger, get_version
11
+
12
+ __version__ = get_version()
13
+
14
+ __all__ = ["DandiFileSystem", "get_logger"]
@@ -0,0 +1,226 @@
1
+ # NOTICE
2
+ # This file was inspired by [dandi-cli] dandi/dandiapi.py, which is
3
+ # distributed under the Apache 2.0 license, but has been rewritten to be
4
+ # async (aiohttp) and dependency-light.
5
+ # See: https://github.com/dandi/dandi-cli/blob/master/LICENSE
6
+ """
7
+ Internal async REST client for DANDI API servers.
8
+
9
+ The client is deliberately stateless with respect to the aiohttp session: the
10
+ owning filesystem creates and manages the session (on the fsspec event loop)
11
+ and passes it into every call. The client only holds the API base URL and the
12
+ (lazily resolved) authentication token.
13
+ """
14
+
15
+ import asyncio
16
+ import json
17
+ from typing import Any, AsyncIterator, Dict, Optional
18
+
19
+ from ._consts import DRAFT, REQUEST_RETRIES, RETRY_STATUSES
20
+ from ._exceptions import DandiHTTPError, FailedToConnectError, HTTP404Error
21
+ from ._keyring import resolve_token
22
+ from ._utils import USER_AGENT, clean_params, get_logger, joinurl
23
+
24
+ LOG = get_logger("api")
25
+
26
+
27
+ def _decode(body: bytes) -> str:
28
+ return body.decode("utf-8", "replace")
29
+
30
+
31
+ class DandiClient:
32
+ """An async client for a single DANDI API server."""
33
+
34
+ def __init__(
35
+ self,
36
+ api_url: str,
37
+ instance_name: Optional[str] = None,
38
+ token: Optional[str] = None,
39
+ use_keyring: bool = True,
40
+ ) -> None:
41
+ self.api_url = api_url.rstrip("/")
42
+ self.instance_name = instance_name
43
+ self._token = token
44
+ self._auth_tried = False
45
+ self.use_keyring = use_keyring
46
+
47
+ # -- low-level -----------------------------------------------------
48
+
49
+ def _headers(self) -> Dict[str, str]:
50
+ headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
51
+ if self._token:
52
+ headers["Authorization"] = "token {}".format(self._token)
53
+ return headers
54
+
55
+ async def request(
56
+ self,
57
+ session: Any,
58
+ method: str,
59
+ path: str,
60
+ params: Optional[dict] = None,
61
+ json_resp: bool = True,
62
+ ) -> Any:
63
+ """
64
+ Perform an HTTP request, retrying transient failures and resolving
65
+ authentication lazily (only after a 401).
66
+ """
67
+ url = joinurl(self.api_url, path)
68
+ qparams = clean_params(params)
69
+ for attempt in range(REQUEST_RETRIES):
70
+ LOG.debug("%s %s", method.upper(), url)
71
+ async with session.request(
72
+ method, url, params=qparams, headers=self._headers()
73
+ ) as resp:
74
+ status = resp.status
75
+ if status == 401 and not self._auth_tried:
76
+ self._auth_tried = True
77
+ token = resolve_token(
78
+ self.instance_name, use_keyring=self.use_keyring
79
+ )
80
+ if token and token != self._token:
81
+ LOG.debug("Retrying %s with resolved token", url)
82
+ self._token = token
83
+ continue
84
+ if status in RETRY_STATUSES:
85
+ delay = min(0.5 * (2**attempt), 10.0)
86
+ LOG.debug("Status %d for %s; retrying in %.1fs", status, url, delay)
87
+ await asyncio.sleep(delay)
88
+ continue
89
+ body = await resp.read()
90
+ if status == 404:
91
+ raise HTTP404Error(url, _decode(body))
92
+ if status >= 400:
93
+ raise DandiHTTPError(status, url, _decode(body))
94
+ if not json_resp:
95
+ return body
96
+ text = _decode(body).strip()
97
+ return json.loads(text) if text else None
98
+ raise FailedToConnectError(
99
+ "Request to {} failed after {} attempts".format(url, REQUEST_RETRIES)
100
+ )
101
+
102
+ async def get(
103
+ self, session: Any, path: str, params: Optional[dict] = None, **kw: Any
104
+ ) -> Any:
105
+ """Convenience GET wrapper around :meth:`request`."""
106
+ return await self.request(session, "GET", path, params=params, **kw)
107
+
108
+ async def paginate(
109
+ self, session: Any, path: str, params: Optional[dict] = None
110
+ ) -> AsyncIterator[dict]:
111
+ """Yield items across all pages of a paginated endpoint."""
112
+ page = await self.get(session, path, params=params)
113
+ while page is not None:
114
+ for item in page.get("results", []):
115
+ yield item
116
+ nxt = page.get("next")
117
+ if not nxt:
118
+ break
119
+ page = await self.get(session, nxt)
120
+
121
+ # -- DANDI endpoints ----------------------------------------------
122
+
123
+ @staticmethod
124
+ def _version_path(dandiset_id: str, version_id: str) -> str:
125
+ return "/dandisets/{}/versions/{}".format(dandiset_id, version_id)
126
+
127
+ async def get_dandiset(self, session: Any, dandiset_id: str) -> dict:
128
+ """Return the dandiset record."""
129
+ return await self.get(session, "/dandisets/{}/".format(dandiset_id))
130
+
131
+ async def resolve_version(
132
+ self, session: Any, dandiset_id: str, version_id: Optional[str]
133
+ ) -> str:
134
+ """Return ``version_id`` or the dandiset's default (published/draft)."""
135
+ if version_id:
136
+ return version_id
137
+ record = await self.get_dandiset(session, dandiset_id)
138
+ published = record.get("most_recent_published_version")
139
+ if published:
140
+ return published["version"]
141
+ draft = record.get("draft_version")
142
+ if draft:
143
+ return draft["version"]
144
+ return DRAFT
145
+
146
+ async def assets(
147
+ self,
148
+ session: Any,
149
+ dandiset_id: str,
150
+ version_id: str,
151
+ path: Optional[str] = None,
152
+ glob: Optional[str] = None,
153
+ order: Optional[str] = None,
154
+ metadata: bool = False,
155
+ ) -> AsyncIterator[dict]:
156
+ """Iterate over assets, optionally filtered by path prefix or glob."""
157
+ params: Dict[str, Any] = {}
158
+ if path is not None:
159
+ params["path"] = path
160
+ if glob is not None:
161
+ params["glob"] = glob
162
+ if order is not None:
163
+ params["order"] = order
164
+ if metadata:
165
+ params["metadata"] = True
166
+ endpoint = self._version_path(dandiset_id, version_id) + "/assets/"
167
+ async for asset in self.paginate(session, endpoint, params):
168
+ yield asset
169
+
170
+ async def asset_with_path(
171
+ self,
172
+ session: Any,
173
+ dandiset_id: str,
174
+ version_id: str,
175
+ path: str,
176
+ metadata: bool = True,
177
+ ) -> Optional[dict]:
178
+ """Return the asset whose path equals ``path`` exactly, or ``None``."""
179
+ async for asset in self.assets(
180
+ session, dandiset_id, version_id, path=path, metadata=metadata
181
+ ):
182
+ if asset.get("path") == path:
183
+ return asset
184
+ return None
185
+
186
+ async def asset_paths(
187
+ self,
188
+ session: Any,
189
+ dandiset_id: str,
190
+ version_id: str,
191
+ path_prefix: Optional[str] = None,
192
+ ) -> AsyncIterator[dict]:
193
+ """Iterate over the immediate children (folders/files) of a prefix."""
194
+ params: Dict[str, Any] = {}
195
+ if path_prefix is not None:
196
+ params["path_prefix"] = path_prefix
197
+ endpoint = self._version_path(dandiset_id, version_id) + "/assets/paths/"
198
+ async for entry in self.paginate(session, endpoint, params):
199
+ yield entry
200
+
201
+ async def get_asset(
202
+ self,
203
+ session: Any,
204
+ dandiset_id: str,
205
+ version_id: str,
206
+ asset_id: str,
207
+ info: bool = False,
208
+ ) -> dict:
209
+ """Return an asset record (or its ``/info/`` metadata)."""
210
+ suffix = "/info/" if info else "/"
211
+ endpoint = self._version_path(dandiset_id, version_id) + (
212
+ "/assets/{}{}".format(asset_id, suffix)
213
+ )
214
+ return await self.get(session, endpoint)
215
+
216
+ async def zarr_files(
217
+ self, session: Any, zarr_id: str, prefix: Optional[str] = None
218
+ ) -> AsyncIterator[dict]:
219
+ """Iterate over entries in a Zarr archive's file listing."""
220
+ params: Dict[str, Any] = {}
221
+ if prefix is not None:
222
+ params["prefix"] = prefix
223
+ async for entry in self.paginate(
224
+ session, "/zarr/{}/files/".format(zarr_id), params
225
+ ):
226
+ yield entry
@@ -0,0 +1,26 @@
1
+ # NOTICE
2
+ # Some values in this file were copied and modified from [dandi-cli]
3
+ # dandi/consts.py, which is distributed under the Apache 2.0 license.
4
+ # See: https://github.com/dandi/dandi-cli/blob/master/LICENSE
5
+ """Internal constants for :mod:`dandifs`."""
6
+
7
+ #: Regular expression for a valid Dandiset identifier. Not anchored.
8
+ DANDISET_ID_REGEX = r"[0-9]{6}"
9
+
10
+ #: Regular expression for a valid published (non-draft) Dandiset version.
11
+ PUBLISHED_VERSION_REGEX = r"[0-9]+\.[0-9]+\.[0-9]+"
12
+
13
+ #: Regular expression for any valid Dandiset version identifier.
14
+ VERSION_REGEX = r"(?:[0-9]+\.[0-9]+\.[0-9]+|draft)"
15
+
16
+ #: The identifier used for draft Dandiset versions.
17
+ DRAFT = "draft"
18
+
19
+ #: HTTP response status codes that should be retried until retries run out.
20
+ RETRY_STATUSES = (429, 500, 502, 503, 504)
21
+
22
+ #: Number of attempts made for a single request before giving up.
23
+ REQUEST_RETRIES = 6
24
+
25
+ #: File extensions used to identify Zarr assets.
26
+ ZARR_EXTENSIONS = (".zarr", ".ngff")
@@ -0,0 +1,45 @@
1
+ # NOTICE
2
+ # Some names in this file were copied and modified from [dandi-cli]
3
+ # dandi/exceptions.py, which is distributed under the Apache 2.0 license.
4
+ # See: https://github.com/dandi/dandi-cli/blob/master/LICENSE
5
+ """Internal exceptions for :mod:`dandifs`."""
6
+
7
+ from typing import Optional
8
+
9
+
10
+ class UnknownURLError(ValueError):
11
+ """The given URL does not correspond to a known DANDI resource."""
12
+
13
+
14
+ class NotFoundError(RuntimeError):
15
+ """An online resource we tried to reach was not found."""
16
+
17
+
18
+ class FailedToConnectError(RuntimeError):
19
+ """Failed to connect to an online resource."""
20
+
21
+
22
+ class DandiHTTPError(RuntimeError):
23
+ """An HTTP request to a DANDI API returned an error status."""
24
+
25
+ def __init__(
26
+ self,
27
+ status: int,
28
+ url: str,
29
+ message: Optional[str] = None,
30
+ ) -> None:
31
+ self.status = status
32
+ self.url = url
33
+ self.message = message or ""
34
+ super().__init__(
35
+ "HTTP {} for {}{}".format(
36
+ status, url, ": " + self.message if self.message else ""
37
+ )
38
+ )
39
+
40
+
41
+ class HTTP404Error(DandiHTTPError):
42
+ """An HTTP request returned a 404 (Not Found) status."""
43
+
44
+ def __init__(self, url: str, message: Optional[str] = None) -> None:
45
+ super().__init__(404, url, message)