loremfile 0.1.0__py3-none-any.whl
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.
- loremfile-0.1.0.dist-info/METADATA +95 -0
- loremfile-0.1.0.dist-info/RECORD +9 -0
- loremfile-0.1.0.dist-info/WHEEL +5 -0
- loremfile-0.1.0.dist-info/entry_points.txt +2 -0
- loremfile-0.1.0.dist-info/top_level.txt +1 -0
- loremfile_client/__init__.py +3 -0
- loremfile_client/__main__.py +3 -0
- loremfile_client/api.py +246 -0
- loremfile_client/cli.py +150 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loremfile
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Download CC0 sample files from loremfile.dev and verify every byte
|
|
5
|
+
Author: the loremfile.dev contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://loremfile.dev
|
|
8
|
+
Project-URL: Source, https://github.com/kumarprabhashanand/loremfile
|
|
9
|
+
Project-URL: Issues, https://github.com/kumarprabhashanand/loremfile/issues
|
|
10
|
+
Keywords: fixtures,test-data,sample-files,cc0,download
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Classifier: Environment :: Console
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# loremfile
|
|
21
|
+
|
|
22
|
+
Download [loremfile.dev](https://loremfile.dev) sample files and check every byte against
|
|
23
|
+
the published manifest.
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
$ pip install loremfile
|
|
27
|
+
$ loremfile get pdf/minimal.pdf mp4/720p-5s.mp4
|
|
28
|
+
written mp4/720p-5s.mp4 1,974,574 bytes
|
|
29
|
+
written pdf/minimal.pdf 317 bytes
|
|
30
|
+
files=2, dest=loremfile-fixtures
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Everything it does can be done with `curl` and `sha256sum -c`. What it adds is doing it
|
|
34
|
+
correctly by default: every file is verified, and a file that fails is not written.
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
| Command | |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `loremfile get PATH…` | Download those fixtures into `--dest` (default `loremfile-fixtures`), verifying each. |
|
|
41
|
+
| `loremfile get --format svg --format pdf` | The same for every published file of a format. |
|
|
42
|
+
| `loremfile list [--format F] [--tag T] [--max-bytes N]` | What is published, without downloading. |
|
|
43
|
+
| `loremfile verify [PATH…]` | Check files already on disk against the manifest. |
|
|
44
|
+
|
|
45
|
+
Every command takes `--json` for scripts, `--quiet`, and `--catalog-version X.Y.Z` to
|
|
46
|
+
refuse to run if loremfile.dev has moved on. `get` also takes `--force` (overwrite) and
|
|
47
|
+
`--dry-run` (what it would fetch, and how many bytes).
|
|
48
|
+
|
|
49
|
+
Exit codes: `0` fine, `1` a file failed verification or is missing, `2` the request was
|
|
50
|
+
wrong, `3` loremfile.dev could not be read.
|
|
51
|
+
|
|
52
|
+
## What it will not do
|
|
53
|
+
|
|
54
|
+
- **Write a file it could not verify.** There is no `--no-verify`.
|
|
55
|
+
- **Talk to any host but loremfile.dev.** No mirror option, and redirects off the host are
|
|
56
|
+
not followed.
|
|
57
|
+
- **Take credentials.** Nothing it does needs authentication, so it accepts none.
|
|
58
|
+
- **Exceed the published rate limit**: one file at a time, a pause between them, and a
|
|
59
|
+
429 is waited out rather than retried tightly.
|
|
60
|
+
- **Generate fixtures.** Those are built in a pinned toolchain image; a client that made
|
|
61
|
+
its own would produce different bytes and undermine the only promise the project makes.
|
|
62
|
+
|
|
63
|
+
## Python versions
|
|
64
|
+
|
|
65
|
+
3.11, 3.12, 3.13 and 3.14, tested in CI on Linux, macOS and Windows. The floor is the
|
|
66
|
+
oldest CPython still receiving security fixes when a release is cut; a new one is
|
|
67
|
+
supported within six weeks of its release, and an end-of-life one is dropped in the next
|
|
68
|
+
minor release.
|
|
69
|
+
|
|
70
|
+
Zero dependencies — standard library only.
|
|
71
|
+
|
|
72
|
+
## In GitHub Actions, prefer the action
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
- uses: kumarprabhashanand/loremfile/action@action-v1
|
|
76
|
+
with:
|
|
77
|
+
paths: pdf/minimal.pdf mp4/720p-5s.mp4
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
It is shell only and needs no install step. This client is for everywhere else: a laptop,
|
|
81
|
+
a Makefile, a CI system that is not Actions, or Windows — which the action does not
|
|
82
|
+
support because it needs `sha256sum`.
|
|
83
|
+
|
|
84
|
+
## One name, two tools
|
|
85
|
+
|
|
86
|
+
The console script installed here is `loremfile`, which is also the name of the
|
|
87
|
+
repository's own development CLI (the one that generates and publishes the fixtures).
|
|
88
|
+
They are never installed together — that tool lives only inside the project's toolchain
|
|
89
|
+
image — but a contributor who installs both into one environment gets whichever was
|
|
90
|
+
installed last. The published package contains the client only: no generators, no
|
|
91
|
+
validators, no uploader, no infrastructure code.
|
|
92
|
+
|
|
93
|
+
## Licence
|
|
94
|
+
|
|
95
|
+
MIT for the client; the files it downloads are CC0.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
loremfile_client/__init__.py,sha256=_-0ETP3BJsoTsVDvAMygrvgU2zRaseHmuBlxveRUH9M,112
|
|
2
|
+
loremfile_client/__main__.py,sha256=7NJZ7jIxrhNrkI834PwWD-5TbhXb9hMT6IEHr6xyOhw,64
|
|
3
|
+
loremfile_client/api.py,sha256=8KAnboF63UaAmU8NCJtmZzSggDknfW2yfj6c_gsF1xg,10420
|
|
4
|
+
loremfile_client/cli.py,sha256=eeaqRlN7PC1kWrNY_mxZBxY7gzkJRyvPkW4rhhCBcxU,6019
|
|
5
|
+
loremfile-0.1.0.dist-info/METADATA,sha256=tvGbPvTx2v5sYo-GHSOu0mAiYLFXE-r3MBATBzDbMpY,3968
|
|
6
|
+
loremfile-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
loremfile-0.1.0.dist-info/entry_points.txt,sha256=QwjMb_oQd21294DidZ5E9htPaSTGek3hABkjyfzGI4A,56
|
|
8
|
+
loremfile-0.1.0.dist-info/top_level.txt,sha256=F1MyxSFV3mq7FtWf9ySjuPwMA0F1P7PEOmCNvKTKN2E,17
|
|
9
|
+
loremfile-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
loremfile_client
|
loremfile_client/api.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Fetching fixtures from loremfile.dev, and checking every byte that arrives.
|
|
2
|
+
|
|
3
|
+
Standard library only, on purpose (the CLI spec): a client whose job is to download a
|
|
4
|
+
file and hash it should not ask anyone to resolve a dependency tree, and the smaller the
|
|
5
|
+
supply chain the less there is to audit.
|
|
6
|
+
|
|
7
|
+
The one thing to know about this module: `BASE_URL` is a constant. There is no flag, no
|
|
8
|
+
configuration file and no environment variable that points the client at another host —
|
|
9
|
+
only a loopback override this project's own tests inject, which is rejected for anything
|
|
10
|
+
that is not 127.0.0.1, ::1 or localhost. A mirror flag would contradict the README's
|
|
11
|
+
"do not rely on any other mirror", and an override that accepted a public host would be a
|
|
12
|
+
way to make `loremfile get` fetch from somewhere else entirely.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import http.client
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path, PurePosixPath
|
|
28
|
+
from typing import Any, cast
|
|
29
|
+
|
|
30
|
+
#: The only host this client talks to.
|
|
31
|
+
BASE_URL = "https://loremfile.dev/"
|
|
32
|
+
|
|
33
|
+
#: Injected by this project's tests so they never touch production. Loopback only.
|
|
34
|
+
OVERRIDE = "LOREMFILE_BASE_URL"
|
|
35
|
+
LOOPBACK = ("http://127.0.0.1:", "http://localhost:", "http://[::1]:")
|
|
36
|
+
|
|
37
|
+
#: The published rate limit is 30 requests a second per client; one file at a time with a
|
|
38
|
+
#: pause between them is well under it, and a 429 is answered with patience.
|
|
39
|
+
PAUSE_SECONDS = 0.5
|
|
40
|
+
BACKOFF_SECONDS = (2, 4, 8)
|
|
41
|
+
TIMEOUT_SECONDS = 300
|
|
42
|
+
USER_AGENT = "loremfile-client"
|
|
43
|
+
|
|
44
|
+
#: Read and hashed a megabyte at a time, so a 100 MB fixture costs a megabyte of memory.
|
|
45
|
+
CHUNK_BYTES = 1024 * 1024
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Refused(Exception):
|
|
49
|
+
"""The request was wrong: an unknown path, a bad override, a version that has moved."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Unreachable(Exception):
|
|
53
|
+
"""loremfile.dev could not be read."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Mismatch(Exception):
|
|
57
|
+
"""Bytes arrived that are not the bytes the manifest describes."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def base_url() -> str:
|
|
61
|
+
override = os.environ.get(OVERRIDE, "").strip()
|
|
62
|
+
if not override:
|
|
63
|
+
return BASE_URL
|
|
64
|
+
if not override.startswith(LOOPBACK):
|
|
65
|
+
raise Refused(
|
|
66
|
+
f"{OVERRIDE} accepts a loopback address only, so that this project's tests can "
|
|
67
|
+
f"run without touching production; {override!r} is not one. There is no way to "
|
|
68
|
+
"point this client at another host, by design."
|
|
69
|
+
)
|
|
70
|
+
return override if override.endswith("/") else override + "/"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _open(path: str) -> http.client.HTTPResponse:
|
|
74
|
+
"""An open response, retrying only a 429 — our own rate limit asking for patience.
|
|
75
|
+
|
|
76
|
+
The retry is here, around opening, because that is where a 429 arrives. A stream
|
|
77
|
+
that fails halfway is not retried: the caller would have to decide what to do with
|
|
78
|
+
the bytes it already has, and this client's answer to a half-file is to delete it.
|
|
79
|
+
"""
|
|
80
|
+
url = base_url() + path.lstrip("/")
|
|
81
|
+
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310
|
|
82
|
+
for pause in (*BACKOFF_SECONDS, None):
|
|
83
|
+
try:
|
|
84
|
+
response = urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) # noqa: S310
|
|
85
|
+
except urllib.error.HTTPError as exc:
|
|
86
|
+
if exc.code == 429 and pause is not None: # noqa: PLR2004 - the HTTP status
|
|
87
|
+
time.sleep(pause)
|
|
88
|
+
continue
|
|
89
|
+
raise Unreachable(f"{url} answered {exc.code}") from exc
|
|
90
|
+
except urllib.error.URLError as exc:
|
|
91
|
+
raise Unreachable(f"{url} could not be read: {exc.reason}") from exc
|
|
92
|
+
if response.geturl().split("/")[2] != url.split("/")[2]:
|
|
93
|
+
response.close()
|
|
94
|
+
raise Refused(f"{url} redirected off loremfile.dev, which is never followed")
|
|
95
|
+
# urlopen is typed as returning Any for non-HTTP schemes; this client only ever
|
|
96
|
+
# opens http(s), which `base_url` is the single gate for. A cast rather than an
|
|
97
|
+
# assert: `python -O` strips asserts, and shipped code should not depend on a
|
|
98
|
+
# statement that may not be there.
|
|
99
|
+
return cast("http.client.HTTPResponse", response)
|
|
100
|
+
raise Unreachable(f"{url} is still answering 429")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fetch(path: str) -> bytes:
|
|
104
|
+
"""The whole body at once. For the manifest, which is JSON and has to be parsed."""
|
|
105
|
+
with _open(path) as response:
|
|
106
|
+
return bytes(response.read())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def stream(path: str) -> Iterator[bytes]:
|
|
110
|
+
"""The body in chunks, for fixtures — which run to 100 MB.
|
|
111
|
+
|
|
112
|
+
Reading one of those into memory to hash it would be 100 MB of a CI container's
|
|
113
|
+
often 512 MB, for no benefit: sha256 is happy to be fed a chunk at a time.
|
|
114
|
+
"""
|
|
115
|
+
with _open(path) as response:
|
|
116
|
+
while chunk := response.read(CHUNK_BYTES):
|
|
117
|
+
yield bytes(chunk)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def manifest(*, catalog_version: str | None = None) -> dict[str, Any]:
|
|
121
|
+
try:
|
|
122
|
+
document = json.loads(fetch("manifest.json"))
|
|
123
|
+
except json.JSONDecodeError as exc:
|
|
124
|
+
raise Unreachable(f"the manifest is not JSON: {exc}") from exc
|
|
125
|
+
if not isinstance(document, dict):
|
|
126
|
+
raise Unreachable("the manifest is not a JSON object")
|
|
127
|
+
published = str(document.get("catalog_version", ""))
|
|
128
|
+
if catalog_version and published != catalog_version:
|
|
129
|
+
raise Refused(f"loremfile.dev publishes catalog {published}, not {catalog_version}")
|
|
130
|
+
return dict(document)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def active(document: dict[str, Any]) -> list[dict[str, Any]]:
|
|
134
|
+
return [e for e in document.get("fixtures", []) if e.get("status", "active") == "active"]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def select(
|
|
138
|
+
entries: list[dict[str, Any]],
|
|
139
|
+
*,
|
|
140
|
+
paths: list[str] | None = None,
|
|
141
|
+
formats: list[str] | None = None,
|
|
142
|
+
) -> list[dict[str, Any]]:
|
|
143
|
+
"""The entries a request names, or `Refused` naming what does not exist."""
|
|
144
|
+
wanted_paths, wanted_formats = set(paths or []), set(formats or [])
|
|
145
|
+
if not wanted_paths and not wanted_formats:
|
|
146
|
+
raise Refused("name at least one path or format")
|
|
147
|
+
by_path = {e["path"]: e for e in entries}
|
|
148
|
+
unknown = sorted(wanted_paths - set(by_path))
|
|
149
|
+
if unknown:
|
|
150
|
+
raise Refused(f"not published: {', '.join(unknown)}")
|
|
151
|
+
published_formats = {e["format"] for e in entries}
|
|
152
|
+
unknown_formats = sorted(wanted_formats - published_formats)
|
|
153
|
+
if unknown_formats:
|
|
154
|
+
raise Refused(f"no such format: {', '.join(unknown_formats)}")
|
|
155
|
+
chosen = {
|
|
156
|
+
e["path"]: e for e in entries if e["path"] in wanted_paths or e["format"] in wanted_formats
|
|
157
|
+
}
|
|
158
|
+
return [chosen[path] for path in sorted(chosen)]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def target(dest: Path, path: str) -> Path:
|
|
162
|
+
"""Where a fixture lands, refusing anything that would escape `dest`.
|
|
163
|
+
|
|
164
|
+
The paths come from our own manifest, so this is not the day's most likely failure —
|
|
165
|
+
but a client that writes where a downloaded document tells it to is the shape of
|
|
166
|
+
problem this project publishes a fixture about (`edge/zip-directory-traversal-name.zip`).
|
|
167
|
+
"""
|
|
168
|
+
relative = PurePosixPath(path)
|
|
169
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
170
|
+
raise Refused(f"refusing {path!r}: not the shape of a fixture path")
|
|
171
|
+
resolved = (dest / Path(*relative.parts)).resolve()
|
|
172
|
+
if not str(resolved).startswith(str(dest.resolve())):
|
|
173
|
+
raise Refused(f"refusing {path!r}: it would be written outside {dest}")
|
|
174
|
+
return resolved
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def pace() -> None:
|
|
178
|
+
"""The wait between two downloads. One place, so a test can make it instant and the
|
|
179
|
+
rate manners stay a property of this module rather than of whoever loops."""
|
|
180
|
+
time.sleep(PAUSE_SECONDS)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class Result:
|
|
185
|
+
"""What happened to one fixture."""
|
|
186
|
+
|
|
187
|
+
path: str
|
|
188
|
+
status: str # written | skipped | ok | missing | changed
|
|
189
|
+
detail: str = ""
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def ok(self) -> bool:
|
|
193
|
+
return self.status in {"written", "skipped", "ok"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def download(entry: dict[str, Any], dest: Path, *, force: bool = False) -> Result:
|
|
197
|
+
"""Fetch one fixture, hashing as it arrives, and put it at its path only if it matches.
|
|
198
|
+
|
|
199
|
+
The bytes go to a temporary file **in the destination directory**, not to memory and
|
|
200
|
+
not to the system temporary directory: the first would cost 100 MB of RAM for the
|
|
201
|
+
largest fixtures, and the second could be on another filesystem, where the final move
|
|
202
|
+
would be a copy rather than a rename. `Path.replace` within one directory is atomic,
|
|
203
|
+
so a reader of that directory sees either no file or the whole verified one.
|
|
204
|
+
|
|
205
|
+
The guarantee is unchanged: nothing unverified is ever at the published path. What is
|
|
206
|
+
new is that nothing unverified survives anywhere — the temporary file is removed on a
|
|
207
|
+
mismatch, on an interrupted download, and on any other failure.
|
|
208
|
+
"""
|
|
209
|
+
destination = target(dest, entry["path"])
|
|
210
|
+
if destination.exists() and not force:
|
|
211
|
+
return Result(entry["path"], "skipped", "already here; --force overwrites")
|
|
212
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
|
|
214
|
+
digest = hashlib.sha256()
|
|
215
|
+
written = 0
|
|
216
|
+
handle = tempfile.NamedTemporaryFile( # noqa: SIM115 - closed by the `with` below
|
|
217
|
+
dir=destination.parent, prefix=f".{destination.name}.", suffix=".part", delete=False
|
|
218
|
+
)
|
|
219
|
+
partial = Path(handle.name)
|
|
220
|
+
try:
|
|
221
|
+
with handle:
|
|
222
|
+
for chunk in stream(entry["path"]):
|
|
223
|
+
digest.update(chunk)
|
|
224
|
+
written += handle.write(chunk)
|
|
225
|
+
if digest.hexdigest() != entry["sha256"]:
|
|
226
|
+
raise Mismatch(
|
|
227
|
+
f"{entry['path']}: downloaded sha256 {digest.hexdigest()}, "
|
|
228
|
+
f"the manifest says {entry['sha256']}"
|
|
229
|
+
)
|
|
230
|
+
partial.replace(destination)
|
|
231
|
+
finally:
|
|
232
|
+
# A no-op once the rename has happened, and the whole point otherwise.
|
|
233
|
+
partial.unlink(missing_ok=True)
|
|
234
|
+
return Result(entry["path"], "written", f"{written:,} bytes")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def verify(entry: dict[str, Any], dest: Path) -> Result:
|
|
238
|
+
"""Check a fixture already on disk against the published manifest."""
|
|
239
|
+
destination = target(dest, entry["path"])
|
|
240
|
+
if not destination.is_file():
|
|
241
|
+
return Result(entry["path"], "missing", f"not in {dest}")
|
|
242
|
+
digest = hashlib.sha256(destination.read_bytes()).hexdigest()
|
|
243
|
+
if digest != entry["sha256"]:
|
|
244
|
+
expected = str(entry["sha256"])[:12]
|
|
245
|
+
return Result(entry["path"], "changed", f"sha256 {digest[:12]}, expected {expected}")
|
|
246
|
+
return Result(entry["path"], "ok", f"{destination.stat().st_size:,} bytes")
|
loremfile_client/cli.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""`loremfile get | list | verify` (the CLI spec).
|
|
2
|
+
|
|
3
|
+
argparse rather than a framework, because the package has no dependencies and this is
|
|
4
|
+
three subcommands. Exit codes are the contract: 0 fine, 1 a file failed verification or
|
|
5
|
+
is missing, 2 the request was wrong, 3 loremfile.dev could not be read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from loremfile_client import __version__, api
|
|
18
|
+
|
|
19
|
+
OK, FAILED, REFUSED, UNREACHABLE = 0, 1, 2, 3
|
|
20
|
+
DEFAULT_DEST = "loremfile-fixtures"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="loremfile",
|
|
26
|
+
description="Download CC0 sample files from loremfile.dev and check every byte.",
|
|
27
|
+
epilog="Files come from loremfile.dev and nowhere else; there is no mirror option.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--version", action="version", version=f"loremfile {__version__}")
|
|
30
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
31
|
+
|
|
32
|
+
def shared(sub: argparse.ArgumentParser) -> None:
|
|
33
|
+
sub.add_argument("--json", action="store_true", help="Print one JSON object.")
|
|
34
|
+
sub.add_argument("--quiet", action="store_true", help="Print nothing but failures.")
|
|
35
|
+
sub.add_argument(
|
|
36
|
+
"--catalog-version",
|
|
37
|
+
metavar="X.Y.Z",
|
|
38
|
+
help="Refuse to run unless loremfile.dev publishes this catalog version.",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
get = subcommands.add_parser("get", help="Download fixtures and verify them.")
|
|
42
|
+
get.add_argument("paths", nargs="*", metavar="PATH", help="e.g. pdf/minimal.pdf")
|
|
43
|
+
get.add_argument(
|
|
44
|
+
"--format",
|
|
45
|
+
action="append",
|
|
46
|
+
default=[],
|
|
47
|
+
metavar="FMT",
|
|
48
|
+
help="Every published file of this format. Repeatable.",
|
|
49
|
+
)
|
|
50
|
+
get.add_argument("--dest", default=DEFAULT_DEST, type=Path, help=f"Default: {DEFAULT_DEST}")
|
|
51
|
+
get.add_argument("--force", action="store_true", help="Overwrite files already there.")
|
|
52
|
+
get.add_argument("--dry-run", action="store_true", help="List what would be fetched.")
|
|
53
|
+
shared(get)
|
|
54
|
+
|
|
55
|
+
listing = subcommands.add_parser("list", help="What is published, without downloading.")
|
|
56
|
+
listing.add_argument("--format", action="append", default=[], metavar="FMT")
|
|
57
|
+
listing.add_argument("--tag", action="append", default=[], metavar="TAG")
|
|
58
|
+
listing.add_argument("--max-bytes", type=int, metavar="N")
|
|
59
|
+
shared(listing)
|
|
60
|
+
|
|
61
|
+
check = subcommands.add_parser("verify", help="Check files on disk against the manifest.")
|
|
62
|
+
check.add_argument("paths", nargs="*", metavar="PATH")
|
|
63
|
+
check.add_argument("--format", action="append", default=[], metavar="FMT")
|
|
64
|
+
check.add_argument("--dest", default=DEFAULT_DEST, type=Path, help=f"Default: {DEFAULT_DEST}")
|
|
65
|
+
shared(check)
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def emit(args: argparse.Namespace, summary: dict[str, Any], results: list[api.Result]) -> None:
|
|
70
|
+
if args.json:
|
|
71
|
+
print(json.dumps({"summary": summary, "items": [vars(r) for r in results]}, indent=2))
|
|
72
|
+
return
|
|
73
|
+
for result in results:
|
|
74
|
+
if not result.ok or not args.quiet:
|
|
75
|
+
print(
|
|
76
|
+
f" {result.status:8} {result.path}{' ' + result.detail if result.detail else ''}"
|
|
77
|
+
)
|
|
78
|
+
if not args.quiet:
|
|
79
|
+
print(", ".join(f"{key}={value}" for key, value in summary.items()))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_get(args: argparse.Namespace) -> int:
|
|
83
|
+
entries = api.select(
|
|
84
|
+
api.active(api.manifest(catalog_version=args.catalog_version)),
|
|
85
|
+
paths=args.paths,
|
|
86
|
+
formats=args.format,
|
|
87
|
+
)
|
|
88
|
+
if args.dry_run:
|
|
89
|
+
total = sum(e["bytes"] for e in entries)
|
|
90
|
+
emit(
|
|
91
|
+
args,
|
|
92
|
+
{"files": len(entries), "bytes": total, "dest": str(args.dest), "dry-run": True},
|
|
93
|
+
[api.Result(e["path"], "would fetch", f"{e['bytes']:,} bytes") for e in entries],
|
|
94
|
+
)
|
|
95
|
+
return OK
|
|
96
|
+
results = []
|
|
97
|
+
for index, entry in enumerate(entries):
|
|
98
|
+
results.append(api.download(entry, args.dest, force=args.force))
|
|
99
|
+
if index + 1 < len(entries):
|
|
100
|
+
api.pace()
|
|
101
|
+
emit(args, {"files": len(results), "dest": str(args.dest)}, results)
|
|
102
|
+
return OK
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_list(args: argparse.Namespace) -> int:
|
|
106
|
+
entries = api.active(api.manifest(catalog_version=args.catalog_version))
|
|
107
|
+
if args.format:
|
|
108
|
+
entries = [e for e in entries if e["format"] in set(args.format)]
|
|
109
|
+
if args.tag:
|
|
110
|
+
entries = [e for e in entries if set(args.tag) & set(e.get("tags", []))]
|
|
111
|
+
if args.max_bytes is not None:
|
|
112
|
+
entries = [e for e in entries if e["bytes"] <= args.max_bytes]
|
|
113
|
+
results = [
|
|
114
|
+
api.Result(e["path"], "listed", f"{e['bytes']:,} bytes {e['mime']}") for e in entries
|
|
115
|
+
]
|
|
116
|
+
emit(args, {"files": len(results)}, results)
|
|
117
|
+
return OK
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_verify(args: argparse.Namespace) -> int:
|
|
121
|
+
entries = api.active(api.manifest(catalog_version=args.catalog_version))
|
|
122
|
+
if args.paths or args.format:
|
|
123
|
+
entries = api.select(entries, paths=args.paths, formats=args.format)
|
|
124
|
+
else:
|
|
125
|
+
# Everything the destination actually holds, so `verify` alone checks a directory.
|
|
126
|
+
entries = [e for e in entries if (args.dest / Path(e["path"])).is_file()]
|
|
127
|
+
results = [api.verify(entry, args.dest) for entry in entries]
|
|
128
|
+
failing = [r for r in results if not r.ok]
|
|
129
|
+
emit(args, {"checked": len(results), "failing": len(failing)}, results)
|
|
130
|
+
return FAILED if failing else OK
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
134
|
+
args = build_parser().parse_args(argv)
|
|
135
|
+
runner = {"get": run_get, "list": run_list, "verify": run_verify}[args.command]
|
|
136
|
+
try:
|
|
137
|
+
return runner(args)
|
|
138
|
+
except api.Refused as exc:
|
|
139
|
+
print(f"loremfile: {exc}", file=sys.stderr)
|
|
140
|
+
return REFUSED
|
|
141
|
+
except api.Mismatch as exc:
|
|
142
|
+
print(f"loremfile: {exc}", file=sys.stderr)
|
|
143
|
+
return FAILED
|
|
144
|
+
except api.Unreachable as exc:
|
|
145
|
+
print(f"loremfile: {exc}", file=sys.stderr)
|
|
146
|
+
return UNREACHABLE
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
raise SystemExit(main())
|