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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ loremfile = loremfile_client.cli:main
@@ -0,0 +1 @@
1
+ loremfile_client
@@ -0,0 +1,3 @@
1
+ """A small client for loremfile.dev: download CC0 sample files and verify every byte."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from loremfile_client.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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")
@@ -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())