loremfile 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.
@@ -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,76 @@
1
+ # loremfile
2
+
3
+ Download [loremfile.dev](https://loremfile.dev) sample files and check every byte against
4
+ the published manifest.
5
+
6
+ ```console
7
+ $ pip install loremfile
8
+ $ loremfile get pdf/minimal.pdf mp4/720p-5s.mp4
9
+ written mp4/720p-5s.mp4 1,974,574 bytes
10
+ written pdf/minimal.pdf 317 bytes
11
+ files=2, dest=loremfile-fixtures
12
+ ```
13
+
14
+ Everything it does can be done with `curl` and `sha256sum -c`. What it adds is doing it
15
+ correctly by default: every file is verified, and a file that fails is not written.
16
+
17
+ ## Commands
18
+
19
+ | Command | |
20
+ |---|---|
21
+ | `loremfile get PATH…` | Download those fixtures into `--dest` (default `loremfile-fixtures`), verifying each. |
22
+ | `loremfile get --format svg --format pdf` | The same for every published file of a format. |
23
+ | `loremfile list [--format F] [--tag T] [--max-bytes N]` | What is published, without downloading. |
24
+ | `loremfile verify [PATH…]` | Check files already on disk against the manifest. |
25
+
26
+ Every command takes `--json` for scripts, `--quiet`, and `--catalog-version X.Y.Z` to
27
+ refuse to run if loremfile.dev has moved on. `get` also takes `--force` (overwrite) and
28
+ `--dry-run` (what it would fetch, and how many bytes).
29
+
30
+ Exit codes: `0` fine, `1` a file failed verification or is missing, `2` the request was
31
+ wrong, `3` loremfile.dev could not be read.
32
+
33
+ ## What it will not do
34
+
35
+ - **Write a file it could not verify.** There is no `--no-verify`.
36
+ - **Talk to any host but loremfile.dev.** No mirror option, and redirects off the host are
37
+ not followed.
38
+ - **Take credentials.** Nothing it does needs authentication, so it accepts none.
39
+ - **Exceed the published rate limit**: one file at a time, a pause between them, and a
40
+ 429 is waited out rather than retried tightly.
41
+ - **Generate fixtures.** Those are built in a pinned toolchain image; a client that made
42
+ its own would produce different bytes and undermine the only promise the project makes.
43
+
44
+ ## Python versions
45
+
46
+ 3.11, 3.12, 3.13 and 3.14, tested in CI on Linux, macOS and Windows. The floor is the
47
+ oldest CPython still receiving security fixes when a release is cut; a new one is
48
+ supported within six weeks of its release, and an end-of-life one is dropped in the next
49
+ minor release.
50
+
51
+ Zero dependencies — standard library only.
52
+
53
+ ## In GitHub Actions, prefer the action
54
+
55
+ ```yaml
56
+ - uses: kumarprabhashanand/loremfile/action@action-v1
57
+ with:
58
+ paths: pdf/minimal.pdf mp4/720p-5s.mp4
59
+ ```
60
+
61
+ It is shell only and needs no install step. This client is for everywhere else: a laptop,
62
+ a Makefile, a CI system that is not Actions, or Windows — which the action does not
63
+ support because it needs `sha256sum`.
64
+
65
+ ## One name, two tools
66
+
67
+ The console script installed here is `loremfile`, which is also the name of the
68
+ repository's own development CLI (the one that generates and publishes the fixtures).
69
+ They are never installed together — that tool lives only inside the project's toolchain
70
+ image — but a contributor who installs both into one environment gets whichever was
71
+ installed last. The published package contains the client only: no generators, no
72
+ validators, no uploader, no infrastructure code.
73
+
74
+ ## Licence
75
+
76
+ MIT for the client; the files it downloads are CC0.
@@ -0,0 +1,51 @@
1
+ # The published client, built from this directory alone.
2
+ #
3
+ # The split is the point: the repository's own tooling — generators, validators, the
4
+ # uploader, the infrastructure client — lives in `src/loremfile/` at the repository root
5
+ # and is never published. This tree holds the client and nothing else, so the wheel
6
+ # cannot carry the deploy tooling by accident; `tests/test_packaging.py` reads the built
7
+ # wheel and says so.
8
+ [build-system]
9
+ requires = ["setuptools>=77"]
10
+ build-backend = "setuptools.build_meta"
11
+
12
+ [project]
13
+ name = "loremfile"
14
+ version = "0.1.0"
15
+ description = "Download CC0 sample files from loremfile.dev and verify every byte"
16
+ readme = "README.md"
17
+ # A range, and a policy: the floor is the oldest CPython still receiving security fixes
18
+ # when a release is cut. 3.10 reaches end of life on 2026-10-31 and starts out rather
19
+ # than shipping and immediately going; every version below is tested in CI, on Linux,
20
+ # macOS and Windows. A version nobody tests is not supported.
21
+ requires-python = ">=3.11"
22
+ license = "MIT"
23
+ authors = [{ name = "the loremfile.dev contributors" }]
24
+ keywords = ["fixtures", "test-data", "sample-files", "cc0", "download"]
25
+ classifiers = [
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ "Topic :: Software Development :: Testing",
31
+ "Environment :: Console",
32
+ ]
33
+ # Deliberately empty, and a decision rather than an omission: the client is standard
34
+ # library only. A request that needs a dependency is a conversation, not a patch.
35
+ dependencies = []
36
+
37
+ [project.scripts]
38
+ loremfile = "loremfile_client.cli:main"
39
+
40
+ [project.urls]
41
+ Homepage = "https://loremfile.dev"
42
+ Source = "https://github.com/kumarprabhashanand/loremfile"
43
+ Issues = "https://github.com/kumarprabhashanand/loremfile/issues"
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
47
+
48
+ # So that `pytest` inside this directory is this package's suite, not the repository's.
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+ addopts = "-ra"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/loremfile.egg-info/PKG-INFO
4
+ src/loremfile.egg-info/SOURCES.txt
5
+ src/loremfile.egg-info/dependency_links.txt
6
+ src/loremfile.egg-info/entry_points.txt
7
+ src/loremfile.egg-info/top_level.txt
8
+ src/loremfile_client/__init__.py
9
+ src/loremfile_client/__main__.py
10
+ src/loremfile_client/api.py
11
+ src/loremfile_client/cli.py
12
+ tests/test_commands.py
13
+ tests/test_only_loremfile_dev.py
14
+ tests/test_packaging.py
@@ -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())
@@ -0,0 +1,184 @@
1
+ """`get`, `list` and `verify` against the loopback server (conftest.py)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from loremfile_client import api, cli
10
+
11
+ from conftest import BODIES, Handler
12
+
13
+
14
+ def run(*argv: str) -> int:
15
+ return cli.main(list(argv))
16
+
17
+
18
+ def test_get_writes_the_files_it_verified(server: str, dest: Path) -> None:
19
+ assert run("get", "pdf/small.pdf", "txt/notes.txt", "--dest", str(dest)) == cli.OK
20
+ assert (dest / "pdf" / "small.pdf").read_bytes() == BODIES["pdf/small.pdf"]
21
+ assert (dest / "txt" / "notes.txt").read_bytes() == BODIES["txt/notes.txt"]
22
+
23
+
24
+ def test_get_by_format_takes_every_file_of_it(server: str, dest: Path) -> None:
25
+ assert run("get", "--format", "pdf", "--dest", str(dest)) == cli.OK
26
+ assert sorted(p.name for p in (dest / "pdf").iterdir()) == ["other.pdf", "small.pdf"]
27
+
28
+
29
+ def test_a_withdrawn_entry_is_not_offered(server: str, dest: Path) -> None:
30
+ assert run("get", "pdf/withdrawn.pdf", "--dest", str(dest)) == cli.REFUSED
31
+
32
+
33
+ def test_bytes_that_do_not_match_are_not_written(server: str, dest: Path) -> None:
34
+ """The reason the package exists: a file that fails its hash must not reach the disk,
35
+ and the command must not report success."""
36
+ Handler.corrupt = {"pdf/small.pdf"}
37
+ assert run("get", "pdf/small.pdf", "--dest", str(dest)) == cli.FAILED
38
+ assert not (dest / "pdf" / "small.pdf").exists()
39
+
40
+
41
+ def test_an_unknown_path_is_refused_before_anything_is_fetched(server: str, dest: Path) -> None:
42
+ assert run("get", "pdf/not-real.pdf", "--dest", str(dest)) == cli.REFUSED
43
+ assert not dest.exists()
44
+
45
+
46
+ def test_naming_nothing_is_refused(server: str, dest: Path) -> None:
47
+ assert run("get", "--dest", str(dest)) == cli.REFUSED
48
+
49
+
50
+ def test_a_catalog_version_that_has_moved_on_is_refused(server: str, dest: Path) -> None:
51
+ assert (
52
+ run("get", "pdf/small.pdf", "--dest", str(dest), "--catalog-version", "1.0.0")
53
+ == cli.REFUSED
54
+ )
55
+ assert run("get", "pdf/small.pdf", "--dest", str(dest), "--catalog-version", "9.9.9") == cli.OK
56
+
57
+
58
+ def test_an_existing_file_is_kept_unless_force(server: str, dest: Path) -> None:
59
+ assert run("get", "pdf/small.pdf", "--dest", str(dest)) == cli.OK
60
+ (dest / "pdf" / "small.pdf").write_bytes(b"mine")
61
+ assert run("get", "pdf/small.pdf", "--dest", str(dest)) == cli.OK
62
+ assert (dest / "pdf" / "small.pdf").read_bytes() == b"mine", "it overwrote without --force"
63
+ assert run("get", "pdf/small.pdf", "--dest", str(dest), "--force") == cli.OK
64
+ assert (dest / "pdf" / "small.pdf").read_bytes() == BODIES["pdf/small.pdf"]
65
+
66
+
67
+ def test_a_dry_run_fetches_nothing(server: str, dest: Path, capsys: pytest.CaptureFixture) -> None:
68
+ assert run("get", "--format", "pdf", "--dest", str(dest), "--dry-run", "--json") == cli.OK
69
+ report = json.loads(capsys.readouterr().out)
70
+ assert report["summary"]["files"] == 2
71
+ assert report["summary"]["bytes"] == sum(
72
+ len(BODIES[p]) for p in ("pdf/small.pdf", "pdf/other.pdf")
73
+ )
74
+ assert not dest.exists()
75
+
76
+
77
+ def test_list_filters_without_downloading(server: str, capsys: pytest.CaptureFixture) -> None:
78
+ assert run("list", "--format", "pdf", "--json") == cli.OK
79
+ assert {item["path"] for item in json.loads(capsys.readouterr().out)["items"]} == {
80
+ "pdf/small.pdf",
81
+ "pdf/other.pdf",
82
+ }
83
+ assert run("list", "--max-bytes", "13", "--json") == cli.OK
84
+ listed = {item["path"] for item in json.loads(capsys.readouterr().out)["items"]}
85
+ assert listed == {"txt/notes.txt"}, listed
86
+
87
+
88
+ def test_verify_reports_missing_and_changed_files(server: str, dest: Path) -> None:
89
+ assert run("get", "--format", "pdf", "--dest", str(dest)) == cli.OK
90
+ assert run("verify", "--dest", str(dest)) == cli.OK
91
+
92
+ (dest / "pdf" / "small.pdf").write_bytes(b"not what was published")
93
+ assert run("verify", "--dest", str(dest)) == cli.FAILED
94
+
95
+ (dest / "pdf" / "small.pdf").unlink()
96
+ assert run("verify", "pdf/small.pdf", "--dest", str(dest)) == cli.FAILED
97
+
98
+
99
+ def test_verify_on_an_empty_directory_checks_nothing_and_says_so(
100
+ server: str, dest: Path, capsys: pytest.CaptureFixture
101
+ ) -> None:
102
+ """A verify that finds no files must not report success over an empty set silently."""
103
+ dest.mkdir(parents=True)
104
+ assert run("verify", "--dest", str(dest), "--json") == cli.OK
105
+ assert json.loads(capsys.readouterr().out)["summary"] == {"checked": 0, "failing": 0}
106
+
107
+
108
+ def test_a_host_that_is_not_answering_is_a_separate_exit_code(
109
+ monkeypatch: pytest.MonkeyPatch, dest: Path
110
+ ) -> None:
111
+ """Told apart from a verification failure on purpose: one is a broken network, the
112
+ other is a broken file, and a script should be able to retry only the first."""
113
+ monkeypatch.setenv(api.OVERRIDE, "http://127.0.0.1:1/")
114
+ assert run("get", "pdf/small.pdf", "--dest", str(dest)) == cli.UNREACHABLE
115
+
116
+
117
+ def test_a_fixture_path_that_would_escape_the_destination_is_refused(dest: Path) -> None:
118
+ with pytest.raises(api.Refused, match="not the shape of a fixture path"):
119
+ api.target(dest, "../elsewhere.txt")
120
+ with pytest.raises(api.Refused, match="not the shape of a fixture path"):
121
+ api.target(dest, "/etc/passwd")
122
+
123
+
124
+ # --- how the bytes arrive (a 100 MB fixture must not be a 100 MB process) ----------------
125
+
126
+
127
+ def test_the_body_is_streamed_rather_than_held_in_memory(
128
+ server: str, dest: Path, monkeypatch: pytest.MonkeyPatch
129
+ ) -> None:
130
+ """`fetch` reads a whole body at once and is for the manifest only. If a fixture ever
131
+ goes through it again, the largest ones are 100 MB of a 512 MB container."""
132
+ whole = api.fetch
133
+
134
+ def only_the_manifest(path: str) -> bytes:
135
+ if path != "manifest.json":
136
+ raise AssertionError(f"{path} was read into memory instead of streamed")
137
+ return whole(path)
138
+
139
+ monkeypatch.setattr(api, "fetch", only_the_manifest)
140
+ assert run("get", "pdf/small.pdf", "--dest", str(dest)) == cli.OK
141
+ assert (dest / "pdf" / "small.pdf").read_bytes() == BODIES["pdf/small.pdf"]
142
+
143
+
144
+ def test_a_mismatch_leaves_nothing_behind(server: str, dest: Path) -> None:
145
+ """Not only nothing at the published path: nothing at all. A half-verified file under
146
+ another name is still a file somebody's glob will pick up."""
147
+ Handler.corrupt = {"pdf/small.pdf"}
148
+ assert run("get", "pdf/small.pdf", "--dest", str(dest)) == cli.FAILED
149
+ assert sorted((dest / "pdf").iterdir()) == [], "a partial file survived the mismatch"
150
+
151
+
152
+ def test_an_interrupted_download_leaves_nothing_behind(
153
+ server: str, dest: Path, monkeypatch: pytest.MonkeyPatch
154
+ ) -> None:
155
+ """The same promise when the network drops rather than when the hash differs."""
156
+
157
+ def cut_off(_path: str) -> object:
158
+ yield b"%PDF-1.4 par"
159
+ raise api.Unreachable("the connection went away")
160
+
161
+ monkeypatch.setattr(api, "stream", cut_off)
162
+ assert run("get", "pdf/small.pdf", "--dest", str(dest)) == cli.UNREACHABLE
163
+ assert sorted((dest / "pdf").iterdir()) == []
164
+
165
+
166
+ def test_the_partial_file_is_written_beside_its_destination(
167
+ server: str, dest: Path, monkeypatch: pytest.MonkeyPatch
168
+ ) -> None:
169
+ """In the destination directory, so the final move is a rename on one filesystem and
170
+ not a copy from /tmp — and so a crash cannot leave a stray file somewhere else."""
171
+ seen: list[list[str]] = []
172
+ streaming = api.stream
173
+
174
+ def watching(path: str):
175
+ for chunk in streaming(path):
176
+ seen.append(sorted(p.name for p in (dest / "pdf").iterdir()))
177
+ yield chunk
178
+
179
+ monkeypatch.setattr(api, "stream", watching)
180
+ assert run("get", "pdf/small.pdf", "--dest", str(dest)) == cli.OK
181
+ assert any(
182
+ names and names[0].startswith(".small.pdf") and names[0].endswith(".part") for names in seen
183
+ ), seen
184
+ assert sorted(p.name for p in (dest / "pdf").iterdir()) == ["small.pdf"]
@@ -0,0 +1,115 @@
1
+ """The client talks to loremfile.dev and to nothing else.
2
+
3
+ Four ways of asking, because one of them alone would be easy to satisfy and still be
4
+ wrong: the constant is the production host; the override refuses anything that is not
5
+ loopback; no flag or environment variable reaches the base URL; and no other host appears
6
+ in the source at all. The override exists only so the rest of this suite can run without
7
+ touching production — the same seam, and the same loopback-only rule, as the repository's
8
+ GitHub Action.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import re
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+ from loremfile_client import api, cli
19
+
20
+ PACKAGE = Path(api.__file__).parent
21
+
22
+
23
+ def test_the_constant_is_production() -> None:
24
+ assert api.BASE_URL == "https://loremfile.dev/"
25
+
26
+
27
+ def test_with_no_override_it_is_production(monkeypatch: pytest.MonkeyPatch) -> None:
28
+ monkeypatch.delenv(api.OVERRIDE, raising=False)
29
+ assert api.base_url() == "https://loremfile.dev/"
30
+
31
+
32
+ @pytest.mark.parametrize(
33
+ "value",
34
+ [
35
+ "https://loremfile.dev.evil.test/", # the host name as a prefix
36
+ "https://evil.test/loremfile.dev/", # and as a path
37
+ "http://127.0.0.1.evil.test:8080/", # and the loopback address as a prefix
38
+ "https://example.com/",
39
+ "file:///etc/passwd",
40
+ "http://169.254.169.254/", # the cloud metadata address
41
+ " https://evil.test/", # leading space
42
+ ],
43
+ )
44
+ def test_the_override_refuses_anything_but_loopback(
45
+ monkeypatch: pytest.MonkeyPatch, value: str
46
+ ) -> None:
47
+ monkeypatch.setenv(api.OVERRIDE, value)
48
+ with pytest.raises(api.Refused, match="loopback address only"):
49
+ api.base_url()
50
+
51
+
52
+ @pytest.mark.parametrize(
53
+ "value", ["http://127.0.0.1:8080", "http://localhost:9/", "http://[::1]:1234"]
54
+ )
55
+ def test_the_override_accepts_loopback(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
56
+ """The control: the refusals above are refusals, not a function that rejects everything."""
57
+ monkeypatch.setenv(api.OVERRIDE, value)
58
+ assert api.base_url().startswith(value.rstrip("/"))
59
+
60
+
61
+ def test_no_command_line_flag_reaches_the_host() -> None:
62
+ """A mirror flag would contradict the README, so there must not be one — checked on
63
+ the parser rather than by reading the source, so a flag added later is caught."""
64
+ parser = cli.build_parser()
65
+ options = {
66
+ option
67
+ for action in parser._subparsers._group_actions[0].choices.values()
68
+ for action_option in action._actions
69
+ for option in action_option.option_strings
70
+ } | {option for action in parser._actions for option in action.option_strings}
71
+ assert not {o for o in options if re.search(r"url|host|base|mirror|origin|server", o)}, options
72
+
73
+
74
+ def test_no_other_host_appears_in_the_source() -> None:
75
+ """The last way: whatever the code does with them, these are the only hosts in it."""
76
+ urls: set[str] = set()
77
+ for source in PACKAGE.glob("*.py"):
78
+ urls |= set(re.findall(r"https?://[A-Za-z0-9.\[\]:-]+", source.read_text(encoding="utf-8")))
79
+ assert urls == {
80
+ "https://loremfile.dev",
81
+ "http://127.0.0.1:",
82
+ "http://localhost:",
83
+ "http://[::1]:",
84
+ }, urls
85
+
86
+
87
+ def test_the_client_imports_nothing_but_the_standard_library() -> None:
88
+ """Zero dependencies is a promise in the README and in pyproject; this is what keeps
89
+ it true. Named modules, not a count: a new import is a decision."""
90
+ allowed = {
91
+ "argparse",
92
+ "collections",
93
+ "dataclasses",
94
+ "hashlib",
95
+ "http",
96
+ "json",
97
+ "os",
98
+ "pathlib",
99
+ "sys",
100
+ "tempfile",
101
+ "time",
102
+ "typing",
103
+ "urllib",
104
+ "loremfile_client",
105
+ "__future__",
106
+ }
107
+ imported: set[str] = set()
108
+ for source in PACKAGE.glob("*.py"):
109
+ for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))):
110
+ if isinstance(node, ast.Import):
111
+ imported |= {alias.name.split(".")[0] for alias in node.names}
112
+ elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
113
+ imported.add(node.module.split(".")[0])
114
+ assert imported, "empty-set control: the scan found the imports"
115
+ assert imported <= allowed, imported - allowed
@@ -0,0 +1,54 @@
1
+ """The wheel carries the client and nothing else (the CLI spec's packaging split).
2
+
3
+ Read off the built artifact, not the source tree: the point is what a user installs. CI
4
+ builds the wheel into `dist/` before running these, and the absence of one fails rather
5
+ than skips — a packaging guard that quietly does not run is the packaging guard that lets
6
+ the deploy tooling out.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+ CLIENT = Path(__file__).resolve().parents[1]
15
+ #: Nothing whose path contains these may ever be in the wheel. Named, not counted.
16
+ NEVER = ("infra", "generators", "validators", "site", "upload", "release", "catalog", "manifest")
17
+
18
+
19
+ def wheel() -> Path:
20
+ built = sorted((CLIENT / "dist").glob("*.whl"))
21
+ assert built, f"no wheel in {CLIENT / 'dist'}; build it first: python -m build --wheel"
22
+ return built[-1]
23
+
24
+
25
+ def members() -> list[str]:
26
+ with zipfile.ZipFile(wheel()) as archive:
27
+ return archive.namelist()
28
+
29
+
30
+ def test_every_module_in_the_wheel_is_the_client() -> None:
31
+ modules = [name for name in members() if name.endswith(".py")]
32
+ assert modules, "empty-set control: the wheel was read and holds modules"
33
+ assert all(name.startswith("loremfile_client/") for name in modules), modules
34
+
35
+
36
+ def test_the_wheel_carries_no_repository_tooling() -> None:
37
+ for name in members():
38
+ lowered = name.lower()
39
+ assert not any(f"/{part}" in lowered or lowered.startswith(part) for part in NEVER), name
40
+
41
+
42
+ def test_the_console_script_is_the_client_entry_point() -> None:
43
+ with zipfile.ZipFile(wheel()) as archive:
44
+ entry_points = next(n for n in archive.namelist() if n.endswith("entry_points.txt"))
45
+ text = archive.read(entry_points).decode()
46
+ assert "loremfile = loremfile_client.cli:main" in text
47
+
48
+
49
+ def test_the_wheel_declares_no_dependencies() -> None:
50
+ with zipfile.ZipFile(wheel()) as archive:
51
+ metadata = next(n for n in archive.namelist() if n.endswith("METADATA"))
52
+ text = archive.read(metadata).decode()
53
+ assert "Requires-Dist:" not in text, "the client must install nothing else"
54
+ assert "Requires-Python: >=3.11" in text