acadfetch 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.
- acadfetch-0.1.0/LICENSE +21 -0
- acadfetch-0.1.0/MANIFEST.in +4 -0
- acadfetch-0.1.0/PKG-INFO +204 -0
- acadfetch-0.1.0/README.md +176 -0
- acadfetch-0.1.0/acadfetch/__init__.py +5 -0
- acadfetch-0.1.0/acadfetch/__main__.py +6 -0
- acadfetch-0.1.0/acadfetch/auth.py +61 -0
- acadfetch-0.1.0/acadfetch/cli.py +280 -0
- acadfetch-0.1.0/acadfetch/client.py +209 -0
- acadfetch-0.1.0/acadfetch/consts.py +15 -0
- acadfetch-0.1.0/acadfetch/downloader.py +289 -0
- acadfetch-0.1.0/acadfetch/errors.py +43 -0
- acadfetch-0.1.0/acadfetch/manifest.py +89 -0
- acadfetch-0.1.0/acadfetch/naming.py +88 -0
- acadfetch-0.1.0/acadfetch/progress.py +161 -0
- acadfetch-0.1.0/acadfetch/py.typed +0 -0
- acadfetch-0.1.0/acadfetch/ratelimit.py +37 -0
- acadfetch-0.1.0/acadfetch/sources/__init__.py +6 -0
- acadfetch-0.1.0/acadfetch/sources/arxiv.py +70 -0
- acadfetch-0.1.0/acadfetch/sources/base.py +50 -0
- acadfetch-0.1.0/acadfetch/sources/direct.py +53 -0
- acadfetch-0.1.0/acadfetch/sources/doi.py +54 -0
- acadfetch-0.1.0/acadfetch/sources/figshare.py +86 -0
- acadfetch-0.1.0/acadfetch/sources/registry.py +60 -0
- acadfetch-0.1.0/acadfetch/sources/zenodo.py +92 -0
- acadfetch-0.1.0/acadfetch/units.py +25 -0
- acadfetch-0.1.0/acadfetch/verify.py +64 -0
- acadfetch-0.1.0/acadfetch.egg-info/PKG-INFO +204 -0
- acadfetch-0.1.0/acadfetch.egg-info/SOURCES.txt +47 -0
- acadfetch-0.1.0/acadfetch.egg-info/dependency_links.txt +1 -0
- acadfetch-0.1.0/acadfetch.egg-info/entry_points.txt +2 -0
- acadfetch-0.1.0/acadfetch.egg-info/top_level.txt +1 -0
- acadfetch-0.1.0/pyproject.toml +55 -0
- acadfetch-0.1.0/setup.cfg +4 -0
- acadfetch-0.1.0/tests/fixtures/README.md +41 -0
- acadfetch-0.1.0/tests/fixtures/figshare_403_block.html +6 -0
- acadfetch-0.1.0/tests/fixtures/figshare_403_forbidden.json +4 -0
- acadfetch-0.1.0/tests/fixtures/figshare_article.json +76 -0
- acadfetch-0.1.0/tests/fixtures/figshare_file.pdf +0 -0
- acadfetch-0.1.0/tests/test_auth.py +107 -0
- acadfetch-0.1.0/tests/test_cli.py +123 -0
- acadfetch-0.1.0/tests/test_client.py +173 -0
- acadfetch-0.1.0/tests/test_downloader.py +382 -0
- acadfetch-0.1.0/tests/test_manifest.py +85 -0
- acadfetch-0.1.0/tests/test_naming.py +57 -0
- acadfetch-0.1.0/tests/test_progress.py +219 -0
- acadfetch-0.1.0/tests/test_ratelimit.py +30 -0
- acadfetch-0.1.0/tests/test_sources.py +248 -0
- acadfetch-0.1.0/tests/test_verify.py +58 -0
acadfetch-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 acadfetch contributors
|
|
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.
|
acadfetch-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: acadfetch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Resumable, rate-limited, checksum-verified bulk downloads from academic data repositories
|
|
5
|
+
Author: young-ski
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/young-ski/acadfetch
|
|
8
|
+
Project-URL: Source, https://github.com/young-ski/acadfetch
|
|
9
|
+
Project-URL: Issues, https://github.com/young-ski/acadfetch/issues
|
|
10
|
+
Keywords: zenodo,figshare,arxiv,doi,reproducibility,download
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# acadfetch
|
|
30
|
+
|
|
31
|
+
[](https://github.com/young-ski/acadfetch/actions/workflows/ci.yml)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
34
|
+
Batch-download files from academic data repositories — resumably, rate-limited, and
|
|
35
|
+
checked against the checksums the repository published.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
$ acadfetch fetch 10.5281/zenodo.23612969 -o ./data
|
|
39
|
+
title : Dinler Tarihi Alanında Güncel Konular ve Akademik Değerlendirmeler
|
|
40
|
+
doi : 10.5281/zenodo.23612969
|
|
41
|
+
license : cc-by-4.0
|
|
42
|
+
files : 1, 1.6 MiB
|
|
43
|
+
landing : https://zenodo.org/records/23612969
|
|
44
|
+
Dinler Tarihi ... .pdf 1.2 MiB/1.6 MiB 312.4 KiB/s ETA 0:02
|
|
45
|
+
ok Dinler Tarihi ... .pdf 1.6 MiB (resumed at 512.0 KiB)
|
|
46
|
+
|
|
47
|
+
1/1 file(s) in ./data
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**What it is not:** a search engine, a scraper for paywalled publishers, or a
|
|
51
|
+
citation manager. It is the boring part in the middle — the part that gets killed
|
|
52
|
+
by a flaky VPN at 80%, and the part that nobody writes down.
|
|
53
|
+
|
|
54
|
+
## Why another downloader
|
|
55
|
+
|
|
56
|
+
Because `curl -O` in a loop loses three things that matter for research data:
|
|
57
|
+
|
|
58
|
+
| Problem | What happens with a plain loop | What acadfetch does |
|
|
59
|
+
| --- | --- | --- |
|
|
60
|
+
| Connection drops at 80% | restart from zero | resumes with `Range` + `If-Range`, keeps the partial |
|
|
61
|
+
| Silent corruption | nobody notices until analysis breaks | verifies the repository's `md5`/`sha256`, re-downloads on mismatch |
|
|
62
|
+
| "Which version did I actually use?" | untraceable | writes `manifest.json` with DOI, license, per-file SHA-256 |
|
|
63
|
+
| Getting rate-limited into a 403 | hammering, then blocked | one global token bucket, honours `Retry-After` |
|
|
64
|
+
|
|
65
|
+
The manifest is the point. A dataset you cannot re-verify is a dataset you cannot
|
|
66
|
+
cite.
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
Python 3.10+. No runtime dependencies.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pipx install acadfetch
|
|
74
|
+
# or, from a checkout
|
|
75
|
+
pipx install git+https://github.com/young-ski/acadfetch
|
|
76
|
+
python -m pip install -e .
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
acadfetch fetch 10.5281/zenodo.123456 # a DOI
|
|
83
|
+
acadfetch fetch zenodo:123456 # a record id
|
|
84
|
+
acadfetch fetch figshare:987654
|
|
85
|
+
acadfetch fetch arxiv:2401.12345
|
|
86
|
+
acadfetch fetch https://example.org/dataset.tar.gz
|
|
87
|
+
|
|
88
|
+
acadfetch fetch zenodo:123456 -n # dry run: what would be fetched
|
|
89
|
+
acadfetch fetch zenodo:123456 --only raw.tar.gz
|
|
90
|
+
acadfetch fetch zenodo:123456 --max-size 2GB # refuse anything bigger
|
|
91
|
+
acadfetch fetch zenodo:123456 -j 1 --rate 0.5 # be a good citizen
|
|
92
|
+
acadfetch manifest zenodo:123456 -o m.json # metadata only, no bytes
|
|
93
|
+
acadfetch fetch zenodo:123456 --header "Authorization: Bearer $TOKEN" # restricted record
|
|
94
|
+
|
|
95
|
+
acadfetch verify ./data/manifest.json # re-hash a directory you downloaded
|
|
96
|
+
acadfetch sources # list adapters
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Progress is one aggregate line for the whole run (`3 file(s) 12.4/44.1 MiB 1.8 MiB/s ETA 0:17`),
|
|
100
|
+
redrawn in place on a TTY and written as occasional whole lines when stdout is a pipe.
|
|
101
|
+
`-q` turns it off.
|
|
102
|
+
|
|
103
|
+
Output defaults to `acadfetch-out/<source>-<doi-or-id>`. Files land in
|
|
104
|
+
`<name>.part` and are renamed only after the checksum matches, so an interrupted
|
|
105
|
+
run never leaves something that *looks* finished.
|
|
106
|
+
|
|
107
|
+
Exit codes: `0` success, `1` failure, `2` partial, `130` interrupted (rerun to resume).
|
|
108
|
+
|
|
109
|
+
## Tokens
|
|
110
|
+
|
|
111
|
+
`ZENODO_TOKEN` and `FIGSHARE_TOKEN` are read from the environment and sent only
|
|
112
|
+
to that repository, so a token never travels to a host that has no business
|
|
113
|
+
seeing it. `--header` still overrides, `--no-token` ignores the environment, and
|
|
114
|
+
the manifest records the variable name plus the last four characters only:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
export ZENODO_TOKEN=... # restricted records, higher rate limits
|
|
118
|
+
acadfetch fetch zenodo:123456 # prints: auth : token from $ZENODO_TOKEN (…9f2c)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Verification and the manifest
|
|
122
|
+
|
|
123
|
+
Every completed run writes `manifest.json`:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"schema": 1,
|
|
128
|
+
"doi": "10.5281/zenodo.123456",
|
|
129
|
+
"license": "cc-by-4.0",
|
|
130
|
+
"files": [
|
|
131
|
+
{ "name": "raw.tar.gz", "size": 8123, "checksum": "md5:9f2c…",
|
|
132
|
+
"sha256": "b41d…", "state": "downloaded", "verified": true }
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`acadfetch verify` recomputes SHA-256 for each entry and exits non-zero on any
|
|
138
|
+
mismatch or missing file. Commit the manifest next to your analysis; it is what
|
|
139
|
+
makes the download reproducible a year later.
|
|
140
|
+
|
|
141
|
+
When a source publishes no checksum (arXiv), verification degrades to a byte
|
|
142
|
+
count and `verified` is `null` rather than a false sense of safety.
|
|
143
|
+
|
|
144
|
+
## Supported sources
|
|
145
|
+
|
|
146
|
+
| Source | Target forms | Checksums |
|
|
147
|
+
| --- | --- | --- |
|
|
148
|
+
| Zenodo | DOI `10.5281/zenodo.N`, `zenodo:N`, record URL | md5 |
|
|
149
|
+
| Figshare | DOI `10.6084/m9.figshare.N`, `figshare:N`, article URL | md5 |
|
|
150
|
+
| arXiv | `arxiv:ID`, bare ID, abs/pdf URL | none (size only) |
|
|
151
|
+
| doi.org | any DOI, resolved then dispatched | depends |
|
|
152
|
+
| direct | any `http(s)` URL | headers, if present |
|
|
153
|
+
|
|
154
|
+
Figshare's API sits behind a WAF that intermittently answers 403 to non-browser
|
|
155
|
+
clients (an AWS ELB block page, some networks always, some never). Those 403s
|
|
156
|
+
are retried twice with backoff, then the legacy per-file endpoint is tried;
|
|
157
|
+
a 403 that is the API's own permission error is still terminal. Downloads
|
|
158
|
+
themselves go to `ndownloader`, which the WAF leaves alone. The payload shapes
|
|
159
|
+
are pinned in `tests/fixtures/`.
|
|
160
|
+
|
|
161
|
+
If you know your network gets through, `--retry-403 0` skips the retries
|
|
162
|
+
entirely and makes every 403 final on the first answer.
|
|
163
|
+
|
|
164
|
+
## Politeness
|
|
165
|
+
|
|
166
|
+
Defaults: 3 parallel transfers, 2 requests/second, 4 retries with jittered
|
|
167
|
+
exponential backoff, `Retry-After` honoured up to 120 s. A WAF block page is
|
|
168
|
+
retried twice (`--retry-403 N`); Zenodo and arXiv are donation-funded —
|
|
169
|
+
`--rate 1 -j 1` costs you minutes and costs them less.
|
|
170
|
+
|
|
171
|
+
Respect the license in the manifest. `--dry-run` prints it before you spend
|
|
172
|
+
bandwidth; `unknown` means *check before redistributing*.
|
|
173
|
+
|
|
174
|
+
## Adding a source
|
|
175
|
+
|
|
176
|
+
1. Read `acadfetch/sources/zenodo.py` — an adapter is `match(target) -> id | None`
|
|
177
|
+
plus `resolve(client, target) -> Record`.
|
|
178
|
+
2. Fill `File(checksum=...)` whenever the API publishes one.
|
|
179
|
+
3. Never hardcode rate limits; take the shared `Client`.
|
|
180
|
+
4. Add a `match` test and, if possible, a fixture of the real API payload.
|
|
181
|
+
|
|
182
|
+
`scripts/probe_resume.py` interrupts a live transfer and resumes it — the only
|
|
183
|
+
honest way to test this code path.
|
|
184
|
+
|
|
185
|
+
## Known limits
|
|
186
|
+
|
|
187
|
+
- Tokens come from the environment only; no interactive `login` flow and no
|
|
188
|
+
per-file token refresh.
|
|
189
|
+
- No search/pagination; you bring the target.
|
|
190
|
+
- arXiv PDFs are fetched by constructed URL, so a withdrawn ID gives a 404 after
|
|
191
|
+
resolution.
|
|
192
|
+
- Windows ARM is untested.
|
|
193
|
+
|
|
194
|
+
## 中文
|
|
195
|
+
|
|
196
|
+
学术数据仓库的批量下载器:断点续传、全局限速、按仓库公布的校验和验证,并落
|
|
197
|
+
`manifest.json`(DOI、许可协议、每文件 SHA-256)供日后复现。
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
acadfetch fetch 10.5281/zenodo.123456 -o ./data
|
|
201
|
+
acadfetch verify ./data/manifest.json
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
MIT。
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# acadfetch
|
|
2
|
+
|
|
3
|
+
[](https://github.com/young-ski/acadfetch/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Batch-download files from academic data repositories — resumably, rate-limited, and
|
|
7
|
+
checked against the checksums the repository published.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
$ acadfetch fetch 10.5281/zenodo.23612969 -o ./data
|
|
11
|
+
title : Dinler Tarihi Alanında Güncel Konular ve Akademik Değerlendirmeler
|
|
12
|
+
doi : 10.5281/zenodo.23612969
|
|
13
|
+
license : cc-by-4.0
|
|
14
|
+
files : 1, 1.6 MiB
|
|
15
|
+
landing : https://zenodo.org/records/23612969
|
|
16
|
+
Dinler Tarihi ... .pdf 1.2 MiB/1.6 MiB 312.4 KiB/s ETA 0:02
|
|
17
|
+
ok Dinler Tarihi ... .pdf 1.6 MiB (resumed at 512.0 KiB)
|
|
18
|
+
|
|
19
|
+
1/1 file(s) in ./data
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**What it is not:** a search engine, a scraper for paywalled publishers, or a
|
|
23
|
+
citation manager. It is the boring part in the middle — the part that gets killed
|
|
24
|
+
by a flaky VPN at 80%, and the part that nobody writes down.
|
|
25
|
+
|
|
26
|
+
## Why another downloader
|
|
27
|
+
|
|
28
|
+
Because `curl -O` in a loop loses three things that matter for research data:
|
|
29
|
+
|
|
30
|
+
| Problem | What happens with a plain loop | What acadfetch does |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| Connection drops at 80% | restart from zero | resumes with `Range` + `If-Range`, keeps the partial |
|
|
33
|
+
| Silent corruption | nobody notices until analysis breaks | verifies the repository's `md5`/`sha256`, re-downloads on mismatch |
|
|
34
|
+
| "Which version did I actually use?" | untraceable | writes `manifest.json` with DOI, license, per-file SHA-256 |
|
|
35
|
+
| Getting rate-limited into a 403 | hammering, then blocked | one global token bucket, honours `Retry-After` |
|
|
36
|
+
|
|
37
|
+
The manifest is the point. A dataset you cannot re-verify is a dataset you cannot
|
|
38
|
+
cite.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
Python 3.10+. No runtime dependencies.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pipx install acadfetch
|
|
46
|
+
# or, from a checkout
|
|
47
|
+
pipx install git+https://github.com/young-ski/acadfetch
|
|
48
|
+
python -m pip install -e .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
acadfetch fetch 10.5281/zenodo.123456 # a DOI
|
|
55
|
+
acadfetch fetch zenodo:123456 # a record id
|
|
56
|
+
acadfetch fetch figshare:987654
|
|
57
|
+
acadfetch fetch arxiv:2401.12345
|
|
58
|
+
acadfetch fetch https://example.org/dataset.tar.gz
|
|
59
|
+
|
|
60
|
+
acadfetch fetch zenodo:123456 -n # dry run: what would be fetched
|
|
61
|
+
acadfetch fetch zenodo:123456 --only raw.tar.gz
|
|
62
|
+
acadfetch fetch zenodo:123456 --max-size 2GB # refuse anything bigger
|
|
63
|
+
acadfetch fetch zenodo:123456 -j 1 --rate 0.5 # be a good citizen
|
|
64
|
+
acadfetch manifest zenodo:123456 -o m.json # metadata only, no bytes
|
|
65
|
+
acadfetch fetch zenodo:123456 --header "Authorization: Bearer $TOKEN" # restricted record
|
|
66
|
+
|
|
67
|
+
acadfetch verify ./data/manifest.json # re-hash a directory you downloaded
|
|
68
|
+
acadfetch sources # list adapters
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Progress is one aggregate line for the whole run (`3 file(s) 12.4/44.1 MiB 1.8 MiB/s ETA 0:17`),
|
|
72
|
+
redrawn in place on a TTY and written as occasional whole lines when stdout is a pipe.
|
|
73
|
+
`-q` turns it off.
|
|
74
|
+
|
|
75
|
+
Output defaults to `acadfetch-out/<source>-<doi-or-id>`. Files land in
|
|
76
|
+
`<name>.part` and are renamed only after the checksum matches, so an interrupted
|
|
77
|
+
run never leaves something that *looks* finished.
|
|
78
|
+
|
|
79
|
+
Exit codes: `0` success, `1` failure, `2` partial, `130` interrupted (rerun to resume).
|
|
80
|
+
|
|
81
|
+
## Tokens
|
|
82
|
+
|
|
83
|
+
`ZENODO_TOKEN` and `FIGSHARE_TOKEN` are read from the environment and sent only
|
|
84
|
+
to that repository, so a token never travels to a host that has no business
|
|
85
|
+
seeing it. `--header` still overrides, `--no-token` ignores the environment, and
|
|
86
|
+
the manifest records the variable name plus the last four characters only:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
export ZENODO_TOKEN=... # restricted records, higher rate limits
|
|
90
|
+
acadfetch fetch zenodo:123456 # prints: auth : token from $ZENODO_TOKEN (…9f2c)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Verification and the manifest
|
|
94
|
+
|
|
95
|
+
Every completed run writes `manifest.json`:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"schema": 1,
|
|
100
|
+
"doi": "10.5281/zenodo.123456",
|
|
101
|
+
"license": "cc-by-4.0",
|
|
102
|
+
"files": [
|
|
103
|
+
{ "name": "raw.tar.gz", "size": 8123, "checksum": "md5:9f2c…",
|
|
104
|
+
"sha256": "b41d…", "state": "downloaded", "verified": true }
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`acadfetch verify` recomputes SHA-256 for each entry and exits non-zero on any
|
|
110
|
+
mismatch or missing file. Commit the manifest next to your analysis; it is what
|
|
111
|
+
makes the download reproducible a year later.
|
|
112
|
+
|
|
113
|
+
When a source publishes no checksum (arXiv), verification degrades to a byte
|
|
114
|
+
count and `verified` is `null` rather than a false sense of safety.
|
|
115
|
+
|
|
116
|
+
## Supported sources
|
|
117
|
+
|
|
118
|
+
| Source | Target forms | Checksums |
|
|
119
|
+
| --- | --- | --- |
|
|
120
|
+
| Zenodo | DOI `10.5281/zenodo.N`, `zenodo:N`, record URL | md5 |
|
|
121
|
+
| Figshare | DOI `10.6084/m9.figshare.N`, `figshare:N`, article URL | md5 |
|
|
122
|
+
| arXiv | `arxiv:ID`, bare ID, abs/pdf URL | none (size only) |
|
|
123
|
+
| doi.org | any DOI, resolved then dispatched | depends |
|
|
124
|
+
| direct | any `http(s)` URL | headers, if present |
|
|
125
|
+
|
|
126
|
+
Figshare's API sits behind a WAF that intermittently answers 403 to non-browser
|
|
127
|
+
clients (an AWS ELB block page, some networks always, some never). Those 403s
|
|
128
|
+
are retried twice with backoff, then the legacy per-file endpoint is tried;
|
|
129
|
+
a 403 that is the API's own permission error is still terminal. Downloads
|
|
130
|
+
themselves go to `ndownloader`, which the WAF leaves alone. The payload shapes
|
|
131
|
+
are pinned in `tests/fixtures/`.
|
|
132
|
+
|
|
133
|
+
If you know your network gets through, `--retry-403 0` skips the retries
|
|
134
|
+
entirely and makes every 403 final on the first answer.
|
|
135
|
+
|
|
136
|
+
## Politeness
|
|
137
|
+
|
|
138
|
+
Defaults: 3 parallel transfers, 2 requests/second, 4 retries with jittered
|
|
139
|
+
exponential backoff, `Retry-After` honoured up to 120 s. A WAF block page is
|
|
140
|
+
retried twice (`--retry-403 N`); Zenodo and arXiv are donation-funded —
|
|
141
|
+
`--rate 1 -j 1` costs you minutes and costs them less.
|
|
142
|
+
|
|
143
|
+
Respect the license in the manifest. `--dry-run` prints it before you spend
|
|
144
|
+
bandwidth; `unknown` means *check before redistributing*.
|
|
145
|
+
|
|
146
|
+
## Adding a source
|
|
147
|
+
|
|
148
|
+
1. Read `acadfetch/sources/zenodo.py` — an adapter is `match(target) -> id | None`
|
|
149
|
+
plus `resolve(client, target) -> Record`.
|
|
150
|
+
2. Fill `File(checksum=...)` whenever the API publishes one.
|
|
151
|
+
3. Never hardcode rate limits; take the shared `Client`.
|
|
152
|
+
4. Add a `match` test and, if possible, a fixture of the real API payload.
|
|
153
|
+
|
|
154
|
+
`scripts/probe_resume.py` interrupts a live transfer and resumes it — the only
|
|
155
|
+
honest way to test this code path.
|
|
156
|
+
|
|
157
|
+
## Known limits
|
|
158
|
+
|
|
159
|
+
- Tokens come from the environment only; no interactive `login` flow and no
|
|
160
|
+
per-file token refresh.
|
|
161
|
+
- No search/pagination; you bring the target.
|
|
162
|
+
- arXiv PDFs are fetched by constructed URL, so a withdrawn ID gives a 404 after
|
|
163
|
+
resolution.
|
|
164
|
+
- Windows ARM is untested.
|
|
165
|
+
|
|
166
|
+
## 中文
|
|
167
|
+
|
|
168
|
+
学术数据仓库的批量下载器:断点续传、全局限速、按仓库公布的校验和验证,并落
|
|
169
|
+
`manifest.json`(DOI、许可协议、每文件 SHA-256)供日后复现。
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
acadfetch fetch 10.5281/zenodo.123456 -o ./data
|
|
173
|
+
acadfetch verify ./data/manifest.json
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
MIT。
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Repository tokens, read from the environment.
|
|
2
|
+
|
|
3
|
+
Putting a token in ``--header`` leaks it into shell history and into every
|
|
4
|
+
``ps`` on the box. Each adapter instead declares the variable it reads, and the
|
|
5
|
+
token is attached per-source so it never travels to a host that has no business
|
|
6
|
+
seeing it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
# source name -> variable that holds its token
|
|
16
|
+
TOKEN_ENV = {
|
|
17
|
+
"zenodo": "ZENODO_TOKEN",
|
|
18
|
+
"figshare": "FIGSHARE_TOKEN",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Token:
|
|
24
|
+
source: str
|
|
25
|
+
env: str
|
|
26
|
+
value: str
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def headers(self) -> dict[str, str]:
|
|
30
|
+
return {"Authorization": f"Bearer {self.value}"}
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def hint(self) -> str:
|
|
34
|
+
"""Enough to tell two tokens apart, not enough to use one."""
|
|
35
|
+
return f"…{self.value[-4:]}" if len(self.value) > 8 else "…"
|
|
36
|
+
|
|
37
|
+
def __str__(self) -> str:
|
|
38
|
+
return f"${self.env} ({self.hint})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load(environ: Mapping[str, str] | None = None) -> dict[str, Token]:
|
|
42
|
+
"""Collect every token the environment provides, keyed by source."""
|
|
43
|
+
env = os.environ if environ is None else environ
|
|
44
|
+
found: dict[str, Token] = {}
|
|
45
|
+
for source, name in TOKEN_ENV.items():
|
|
46
|
+
value = (env.get(name) or "").strip()
|
|
47
|
+
if value:
|
|
48
|
+
found[source] = Token(source=source, env=name, value=value)
|
|
49
|
+
return found
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def headers(tokens: Mapping[str, Token], source: str | None) -> dict[str, str]:
|
|
53
|
+
token = tokens.get(source or "")
|
|
54
|
+
return token.headers if token else {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def describe(tokens: Mapping[str, Token], source: str | None) -> str:
|
|
58
|
+
token = tokens.get(source or "")
|
|
59
|
+
if token is None:
|
|
60
|
+
return ""
|
|
61
|
+
return f"token from {token}"
|