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.
- loremfile-0.1.0/PKG-INFO +95 -0
- loremfile-0.1.0/README.md +76 -0
- loremfile-0.1.0/pyproject.toml +51 -0
- loremfile-0.1.0/setup.cfg +4 -0
- loremfile-0.1.0/src/loremfile.egg-info/PKG-INFO +95 -0
- loremfile-0.1.0/src/loremfile.egg-info/SOURCES.txt +14 -0
- loremfile-0.1.0/src/loremfile.egg-info/dependency_links.txt +1 -0
- loremfile-0.1.0/src/loremfile.egg-info/entry_points.txt +2 -0
- loremfile-0.1.0/src/loremfile.egg-info/top_level.txt +1 -0
- loremfile-0.1.0/src/loremfile_client/__init__.py +3 -0
- loremfile-0.1.0/src/loremfile_client/__main__.py +3 -0
- loremfile-0.1.0/src/loremfile_client/api.py +246 -0
- loremfile-0.1.0/src/loremfile_client/cli.py +150 -0
- loremfile-0.1.0/tests/test_commands.py +184 -0
- loremfile-0.1.0/tests/test_only_loremfile_dev.py +115 -0
- loremfile-0.1.0/tests/test_packaging.py +54 -0
loremfile-0.1.0/PKG-INFO
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
loremfile_client
|
|
@@ -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
|