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 +21 -0
- dandifs-0.1.0/PKG-INFO +188 -0
- dandifs-0.1.0/README.md +132 -0
- dandifs-0.1.0/dandifs/__init__.py +14 -0
- dandifs-0.1.0/dandifs/_api.py +226 -0
- dandifs-0.1.0/dandifs/_consts.py +26 -0
- dandifs-0.1.0/dandifs/_exceptions.py +45 -0
- dandifs-0.1.0/dandifs/_fs.py +774 -0
- dandifs-0.1.0/dandifs/_instance.py +220 -0
- dandifs-0.1.0/dandifs/_keyring.py +100 -0
- dandifs-0.1.0/dandifs/_parser.py +166 -0
- dandifs-0.1.0/dandifs/_utils.py +84 -0
- dandifs-0.1.0/dandifs/_version.py +1 -0
- dandifs-0.1.0/dandifs/tests/__init__.py +0 -0
- dandifs-0.1.0/dandifs/tests/conftest.py +216 -0
- dandifs-0.1.0/dandifs/tests/test_api.py +50 -0
- dandifs-0.1.0/dandifs/tests/test_fs.py +290 -0
- dandifs-0.1.0/dandifs/tests/test_instance.py +75 -0
- dandifs-0.1.0/dandifs/tests/test_integration.py +44 -0
- dandifs-0.1.0/dandifs/tests/test_keyring.py +61 -0
- dandifs-0.1.0/dandifs/tests/test_no_import_io.py +44 -0
- dandifs-0.1.0/dandifs/tests/test_parser.py +98 -0
- dandifs-0.1.0/dandifs.egg-info/PKG-INFO +188 -0
- dandifs-0.1.0/dandifs.egg-info/SOURCES.txt +27 -0
- dandifs-0.1.0/dandifs.egg-info/dependency_links.txt +1 -0
- dandifs-0.1.0/dandifs.egg-info/requires.txt +18 -0
- dandifs-0.1.0/dandifs.egg-info/top_level.txt +1 -0
- dandifs-0.1.0/pyproject.toml +133 -0
- dandifs-0.1.0/setup.cfg +4 -0
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`.
|
dandifs-0.1.0/README.md
ADDED
|
@@ -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)
|