loremfile 0.2.0__tar.gz → 0.3.1__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.2.0 → loremfile-0.3.1}/PKG-INFO +32 -7
- {loremfile-0.2.0 → loremfile-0.3.1}/README.md +29 -5
- {loremfile-0.2.0 → loremfile-0.3.1}/pyproject.toml +8 -2
- {loremfile-0.2.0 → loremfile-0.3.1}/src/loremfile.egg-info/PKG-INFO +32 -7
- {loremfile-0.2.0 → loremfile-0.3.1}/src/loremfile.egg-info/SOURCES.txt +3 -1
- {loremfile-0.2.0 → loremfile-0.3.1}/src/loremfile.egg-info/entry_points.txt +3 -0
- {loremfile-0.2.0 → loremfile-0.3.1}/src/loremfile_client/__init__.py +1 -1
- {loremfile-0.2.0 → loremfile-0.3.1}/src/loremfile_client/api.py +20 -2
- {loremfile-0.2.0 → loremfile-0.3.1}/src/loremfile_client/cli.py +18 -2
- {loremfile-0.2.0 → loremfile-0.3.1}/src/loremfile_client/mcp.py +5 -2
- loremfile-0.3.1/src/loremfile_client/pytest_plugin.py +110 -0
- {loremfile-0.2.0 → loremfile-0.3.1}/tests/test_commands.py +75 -1
- {loremfile-0.2.0 → loremfile-0.3.1}/tests/test_mcp.py +10 -0
- {loremfile-0.2.0 → loremfile-0.3.1}/tests/test_only_loremfile_dev.py +8 -2
- {loremfile-0.2.0 → loremfile-0.3.1}/tests/test_packaging.py +7 -0
- loremfile-0.3.1/tests/test_pytest_plugin.py +234 -0
- {loremfile-0.2.0 → loremfile-0.3.1}/setup.cfg +0 -0
- {loremfile-0.2.0 → loremfile-0.3.1}/src/loremfile.egg-info/dependency_links.txt +0 -0
- {loremfile-0.2.0 → loremfile-0.3.1}/src/loremfile.egg-info/top_level.txt +0 -0
- {loremfile-0.2.0 → loremfile-0.3.1}/src/loremfile_client/__main__.py +0 -0
- {loremfile-0.2.0 → loremfile-0.3.1}/src/loremfile_client/tools.json +0 -0
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loremfile
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Download CC0 sample files from loremfile.dev and verify every byte
|
|
5
5
|
Author: the loremfile.dev contributors
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://loremfile.dev
|
|
8
8
|
Project-URL: Source, https://github.com/kumarprabhashanand/loremfile
|
|
9
9
|
Project-URL: Issues, https://github.com/kumarprabhashanand/loremfile/issues
|
|
10
|
-
Keywords: fixtures,test-data,sample-files,cc0,download,mcp
|
|
10
|
+
Keywords: fixtures,test-data,sample-files,cc0,download,mcp,pytest
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
15
|
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Classifier: Framework :: Pytest
|
|
16
17
|
Classifier: Environment :: Console
|
|
17
18
|
Requires-Python: >=3.11
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
@@ -45,11 +46,35 @@ correctly by default: every file is verified, and a file that fails is not writt
|
|
|
45
46
|
|
|
46
47
|
Every command takes `--json` for scripts, `--quiet`, and `--catalog-version X.Y.Z` to
|
|
47
48
|
refuse to run if loremfile.dev has moved on. `get` also takes `--force` (overwrite) and
|
|
48
|
-
`--dry-run` (what it would fetch, and how many bytes).
|
|
49
|
+
`--dry-run` (what it would fetch, and how many bytes). `get`, `list` and `verify` leave out the
|
|
50
|
+
`sample.{format}` aliases, which repeat another file's bytes, unless you pass `--include-aliases`;
|
|
51
|
+
a path you name is always fetched.
|
|
49
52
|
|
|
50
53
|
Exit codes: `0` fine, `1` a file failed verification or is missing, `2` the request was
|
|
51
54
|
wrong, `3` loremfile.dev could not be read.
|
|
52
55
|
|
|
56
|
+
## In a pytest suite
|
|
57
|
+
|
|
58
|
+
For files too big to commit: multi-megabyte video, audio and archives that would otherwise
|
|
59
|
+
bloat a repository. Installing the client gives pytest a `loremfile` fixture:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
def test_reads_a_real_mp4(loremfile):
|
|
63
|
+
video = loremfile("mp4/10mb.mp4") # a pathlib.Path
|
|
64
|
+
...
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The first call downloads the file into `.pytest_cache` and checks its SHA-256 against the
|
|
68
|
+
manifest; a file that does not match is never returned. Published files never change at
|
|
69
|
+
their path, so after that the file and its manifest entry are reused and the suite needs no
|
|
70
|
+
network for them. In CI, cache `.pytest_cache/d/loremfile` between runs to keep it that way;
|
|
71
|
+
`pytest --cache-clear` starts over.
|
|
72
|
+
|
|
73
|
+
It is not for every suite. If your tests may not touch the network, or the file you need is
|
|
74
|
+
a 4 KB PDF, commit the file: that is simpler and needs nothing from us. The fixture takes a
|
|
75
|
+
path only (`loremfile list` finds one), needs pytest 8.0 or newer, and does nothing until a
|
|
76
|
+
test asks for it.
|
|
77
|
+
|
|
53
78
|
## MCP server
|
|
54
79
|
|
|
55
80
|
<!-- mcp-name: io.github.kumarprabhashanand/loremfile -->
|
|
@@ -94,7 +119,7 @@ oldest CPython still receiving security fixes when a release is cut; a new one i
|
|
|
94
119
|
supported within six weeks of its release, and an end-of-life one is dropped in the next
|
|
95
120
|
minor release.
|
|
96
121
|
|
|
97
|
-
Zero dependencies
|
|
122
|
+
Zero dependencies: standard library only.
|
|
98
123
|
|
|
99
124
|
## In GitHub Actions, prefer the action
|
|
100
125
|
|
|
@@ -105,15 +130,15 @@ Zero dependencies — standard library only.
|
|
|
105
130
|
```
|
|
106
131
|
|
|
107
132
|
It is shell only and needs no install step. This client is for everywhere else: a laptop,
|
|
108
|
-
a Makefile, a CI system that is not Actions, or Windows
|
|
133
|
+
a Makefile, a CI system that is not Actions, or Windows, which the action does not
|
|
109
134
|
support because it needs `sha256sum`.
|
|
110
135
|
|
|
111
136
|
## One name, two tools
|
|
112
137
|
|
|
113
138
|
The console script installed here is `loremfile`, which is also the name of the
|
|
114
139
|
repository's own development CLI (the one that generates and publishes the fixtures).
|
|
115
|
-
They are never installed together
|
|
116
|
-
image
|
|
140
|
+
They are never installed together (that tool lives only inside the project's toolchain
|
|
141
|
+
image), but a contributor who installs both into one environment gets whichever was
|
|
117
142
|
installed last. The published package contains the client only: no generators, no
|
|
118
143
|
validators, no uploader, no infrastructure code.
|
|
119
144
|
|
|
@@ -26,11 +26,35 @@ correctly by default: every file is verified, and a file that fails is not writt
|
|
|
26
26
|
|
|
27
27
|
Every command takes `--json` for scripts, `--quiet`, and `--catalog-version X.Y.Z` to
|
|
28
28
|
refuse to run if loremfile.dev has moved on. `get` also takes `--force` (overwrite) and
|
|
29
|
-
`--dry-run` (what it would fetch, and how many bytes).
|
|
29
|
+
`--dry-run` (what it would fetch, and how many bytes). `get`, `list` and `verify` leave out the
|
|
30
|
+
`sample.{format}` aliases, which repeat another file's bytes, unless you pass `--include-aliases`;
|
|
31
|
+
a path you name is always fetched.
|
|
30
32
|
|
|
31
33
|
Exit codes: `0` fine, `1` a file failed verification or is missing, `2` the request was
|
|
32
34
|
wrong, `3` loremfile.dev could not be read.
|
|
33
35
|
|
|
36
|
+
## In a pytest suite
|
|
37
|
+
|
|
38
|
+
For files too big to commit: multi-megabyte video, audio and archives that would otherwise
|
|
39
|
+
bloat a repository. Installing the client gives pytest a `loremfile` fixture:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
def test_reads_a_real_mp4(loremfile):
|
|
43
|
+
video = loremfile("mp4/10mb.mp4") # a pathlib.Path
|
|
44
|
+
...
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The first call downloads the file into `.pytest_cache` and checks its SHA-256 against the
|
|
48
|
+
manifest; a file that does not match is never returned. Published files never change at
|
|
49
|
+
their path, so after that the file and its manifest entry are reused and the suite needs no
|
|
50
|
+
network for them. In CI, cache `.pytest_cache/d/loremfile` between runs to keep it that way;
|
|
51
|
+
`pytest --cache-clear` starts over.
|
|
52
|
+
|
|
53
|
+
It is not for every suite. If your tests may not touch the network, or the file you need is
|
|
54
|
+
a 4 KB PDF, commit the file: that is simpler and needs nothing from us. The fixture takes a
|
|
55
|
+
path only (`loremfile list` finds one), needs pytest 8.0 or newer, and does nothing until a
|
|
56
|
+
test asks for it.
|
|
57
|
+
|
|
34
58
|
## MCP server
|
|
35
59
|
|
|
36
60
|
<!-- mcp-name: io.github.kumarprabhashanand/loremfile -->
|
|
@@ -75,7 +99,7 @@ oldest CPython still receiving security fixes when a release is cut; a new one i
|
|
|
75
99
|
supported within six weeks of its release, and an end-of-life one is dropped in the next
|
|
76
100
|
minor release.
|
|
77
101
|
|
|
78
|
-
Zero dependencies
|
|
102
|
+
Zero dependencies: standard library only.
|
|
79
103
|
|
|
80
104
|
## In GitHub Actions, prefer the action
|
|
81
105
|
|
|
@@ -86,15 +110,15 @@ Zero dependencies — standard library only.
|
|
|
86
110
|
```
|
|
87
111
|
|
|
88
112
|
It is shell only and needs no install step. This client is for everywhere else: a laptop,
|
|
89
|
-
a Makefile, a CI system that is not Actions, or Windows
|
|
113
|
+
a Makefile, a CI system that is not Actions, or Windows, which the action does not
|
|
90
114
|
support because it needs `sha256sum`.
|
|
91
115
|
|
|
92
116
|
## One name, two tools
|
|
93
117
|
|
|
94
118
|
The console script installed here is `loremfile`, which is also the name of the
|
|
95
119
|
repository's own development CLI (the one that generates and publishes the fixtures).
|
|
96
|
-
They are never installed together
|
|
97
|
-
image
|
|
120
|
+
They are never installed together (that tool lives only inside the project's toolchain
|
|
121
|
+
image), but a contributor who installs both into one environment gets whichever was
|
|
98
122
|
installed last. The published package contains the client only: no generators, no
|
|
99
123
|
validators, no uploader, no infrastructure code.
|
|
100
124
|
|
|
@@ -11,7 +11,7 @@ build-backend = "setuptools.build_meta"
|
|
|
11
11
|
|
|
12
12
|
[project]
|
|
13
13
|
name = "loremfile"
|
|
14
|
-
version = "0.
|
|
14
|
+
version = "0.3.1"
|
|
15
15
|
description = "Download CC0 sample files from loremfile.dev and verify every byte"
|
|
16
16
|
readme = "README.md"
|
|
17
17
|
# A range, and a policy: the floor is the oldest CPython still receiving security fixes
|
|
@@ -21,13 +21,14 @@ readme = "README.md"
|
|
|
21
21
|
requires-python = ">=3.11"
|
|
22
22
|
license = "MIT"
|
|
23
23
|
authors = [{ name = "the loremfile.dev contributors" }]
|
|
24
|
-
keywords = ["fixtures", "test-data", "sample-files", "cc0", "download", "mcp"]
|
|
24
|
+
keywords = ["fixtures", "test-data", "sample-files", "cc0", "download", "mcp", "pytest"]
|
|
25
25
|
classifiers = [
|
|
26
26
|
"Programming Language :: Python :: 3.11",
|
|
27
27
|
"Programming Language :: Python :: 3.12",
|
|
28
28
|
"Programming Language :: Python :: 3.13",
|
|
29
29
|
"Programming Language :: Python :: 3.14",
|
|
30
30
|
"Topic :: Software Development :: Testing",
|
|
31
|
+
"Framework :: Pytest",
|
|
31
32
|
"Environment :: Console",
|
|
32
33
|
]
|
|
33
34
|
# Deliberately empty, and a decision rather than an omission: the client is standard
|
|
@@ -37,6 +38,11 @@ dependencies = []
|
|
|
37
38
|
[project.scripts]
|
|
38
39
|
loremfile = "loremfile_client.cli:main"
|
|
39
40
|
|
|
41
|
+
# The `loremfile` fixture. pytest loads this in every run where the client is installed,
|
|
42
|
+
# so the module does nothing until a test asks for the fixture (tests/test_pytest_plugin.py).
|
|
43
|
+
[project.entry-points.pytest11]
|
|
44
|
+
loremfile = "loremfile_client.pytest_plugin"
|
|
45
|
+
|
|
40
46
|
[project.urls]
|
|
41
47
|
Homepage = "https://loremfile.dev"
|
|
42
48
|
Source = "https://github.com/kumarprabhashanand/loremfile"
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loremfile
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Download CC0 sample files from loremfile.dev and verify every byte
|
|
5
5
|
Author: the loremfile.dev contributors
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://loremfile.dev
|
|
8
8
|
Project-URL: Source, https://github.com/kumarprabhashanand/loremfile
|
|
9
9
|
Project-URL: Issues, https://github.com/kumarprabhashanand/loremfile/issues
|
|
10
|
-
Keywords: fixtures,test-data,sample-files,cc0,download,mcp
|
|
10
|
+
Keywords: fixtures,test-data,sample-files,cc0,download,mcp,pytest
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.12
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
15
|
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Classifier: Framework :: Pytest
|
|
16
17
|
Classifier: Environment :: Console
|
|
17
18
|
Requires-Python: >=3.11
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
@@ -45,11 +46,35 @@ correctly by default: every file is verified, and a file that fails is not writt
|
|
|
45
46
|
|
|
46
47
|
Every command takes `--json` for scripts, `--quiet`, and `--catalog-version X.Y.Z` to
|
|
47
48
|
refuse to run if loremfile.dev has moved on. `get` also takes `--force` (overwrite) and
|
|
48
|
-
`--dry-run` (what it would fetch, and how many bytes).
|
|
49
|
+
`--dry-run` (what it would fetch, and how many bytes). `get`, `list` and `verify` leave out the
|
|
50
|
+
`sample.{format}` aliases, which repeat another file's bytes, unless you pass `--include-aliases`;
|
|
51
|
+
a path you name is always fetched.
|
|
49
52
|
|
|
50
53
|
Exit codes: `0` fine, `1` a file failed verification or is missing, `2` the request was
|
|
51
54
|
wrong, `3` loremfile.dev could not be read.
|
|
52
55
|
|
|
56
|
+
## In a pytest suite
|
|
57
|
+
|
|
58
|
+
For files too big to commit: multi-megabyte video, audio and archives that would otherwise
|
|
59
|
+
bloat a repository. Installing the client gives pytest a `loremfile` fixture:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
def test_reads_a_real_mp4(loremfile):
|
|
63
|
+
video = loremfile("mp4/10mb.mp4") # a pathlib.Path
|
|
64
|
+
...
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The first call downloads the file into `.pytest_cache` and checks its SHA-256 against the
|
|
68
|
+
manifest; a file that does not match is never returned. Published files never change at
|
|
69
|
+
their path, so after that the file and its manifest entry are reused and the suite needs no
|
|
70
|
+
network for them. In CI, cache `.pytest_cache/d/loremfile` between runs to keep it that way;
|
|
71
|
+
`pytest --cache-clear` starts over.
|
|
72
|
+
|
|
73
|
+
It is not for every suite. If your tests may not touch the network, or the file you need is
|
|
74
|
+
a 4 KB PDF, commit the file: that is simpler and needs nothing from us. The fixture takes a
|
|
75
|
+
path only (`loremfile list` finds one), needs pytest 8.0 or newer, and does nothing until a
|
|
76
|
+
test asks for it.
|
|
77
|
+
|
|
53
78
|
## MCP server
|
|
54
79
|
|
|
55
80
|
<!-- mcp-name: io.github.kumarprabhashanand/loremfile -->
|
|
@@ -94,7 +119,7 @@ oldest CPython still receiving security fixes when a release is cut; a new one i
|
|
|
94
119
|
supported within six weeks of its release, and an end-of-life one is dropped in the next
|
|
95
120
|
minor release.
|
|
96
121
|
|
|
97
|
-
Zero dependencies
|
|
122
|
+
Zero dependencies: standard library only.
|
|
98
123
|
|
|
99
124
|
## In GitHub Actions, prefer the action
|
|
100
125
|
|
|
@@ -105,15 +130,15 @@ Zero dependencies — standard library only.
|
|
|
105
130
|
```
|
|
106
131
|
|
|
107
132
|
It is shell only and needs no install step. This client is for everywhere else: a laptop,
|
|
108
|
-
a Makefile, a CI system that is not Actions, or Windows
|
|
133
|
+
a Makefile, a CI system that is not Actions, or Windows, which the action does not
|
|
109
134
|
support because it needs `sha256sum`.
|
|
110
135
|
|
|
111
136
|
## One name, two tools
|
|
112
137
|
|
|
113
138
|
The console script installed here is `loremfile`, which is also the name of the
|
|
114
139
|
repository's own development CLI (the one that generates and publishes the fixtures).
|
|
115
|
-
They are never installed together
|
|
116
|
-
image
|
|
140
|
+
They are never installed together (that tool lives only inside the project's toolchain
|
|
141
|
+
image), but a contributor who installs both into one environment gets whichever was
|
|
117
142
|
installed last. The published package contains the client only: no generators, no
|
|
118
143
|
validators, no uploader, no infrastructure code.
|
|
119
144
|
|
|
@@ -10,8 +10,10 @@ src/loremfile_client/__main__.py
|
|
|
10
10
|
src/loremfile_client/api.py
|
|
11
11
|
src/loremfile_client/cli.py
|
|
12
12
|
src/loremfile_client/mcp.py
|
|
13
|
+
src/loremfile_client/pytest_plugin.py
|
|
13
14
|
src/loremfile_client/tools.json
|
|
14
15
|
tests/test_commands.py
|
|
15
16
|
tests/test_mcp.py
|
|
16
17
|
tests/test_only_loremfile_dev.py
|
|
17
|
-
tests/test_packaging.py
|
|
18
|
+
tests/test_packaging.py
|
|
19
|
+
tests/test_pytest_plugin.py
|
|
@@ -153,13 +153,28 @@ def active(document: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
153
153
|
return [e for e in document.get("fixtures", []) if e.get("status", "active") == "active"]
|
|
154
154
|
|
|
155
155
|
|
|
156
|
+
#: A `sample.{format}` alias is a second URL for bytes that already have one. Its manifest entry is
|
|
157
|
+
#: the only place that says so, and the field that does is `generator`; its original is
|
|
158
|
+
#: `generator_params.of`. (`aliasOf`, which 1.15.0 carried until 1.15.1, matches nothing.)
|
|
159
|
+
ALIAS_GENERATOR = "alias.copy"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_alias(entry: dict[str, Any]) -> bool:
|
|
163
|
+
return entry.get("generator") == ALIAS_GENERATOR
|
|
164
|
+
|
|
165
|
+
|
|
156
166
|
def select(
|
|
157
167
|
entries: list[dict[str, Any]],
|
|
158
168
|
*,
|
|
159
169
|
paths: list[str] | None = None,
|
|
160
170
|
formats: list[str] | None = None,
|
|
171
|
+
include_aliases: bool = False,
|
|
161
172
|
) -> list[dict[str, Any]]:
|
|
162
|
-
"""The entries a request names, or `Refused` naming what does not exist.
|
|
173
|
+
"""The entries a request names, or `Refused` naming what does not exist.
|
|
174
|
+
|
|
175
|
+
A format selects its files, not the aliases that repeat them, unless `include_aliases`; a
|
|
176
|
+
path that is named is always honoured, alias or not.
|
|
177
|
+
"""
|
|
163
178
|
wanted_paths, wanted_formats = set(paths or []), set(formats or [])
|
|
164
179
|
if not wanted_paths and not wanted_formats:
|
|
165
180
|
raise Refused("name at least one path or format")
|
|
@@ -172,7 +187,10 @@ def select(
|
|
|
172
187
|
if unknown_formats:
|
|
173
188
|
raise Refused(f"no such format: {', '.join(unknown_formats)}")
|
|
174
189
|
chosen = {
|
|
175
|
-
e["path"]: e
|
|
190
|
+
e["path"]: e
|
|
191
|
+
for e in entries
|
|
192
|
+
if e["path"] in wanted_paths
|
|
193
|
+
or (e["format"] in wanted_formats and (include_aliases or not is_alias(e)))
|
|
176
194
|
}
|
|
177
195
|
return [chosen[path] for path in sorted(chosen)]
|
|
178
196
|
|
|
@@ -29,6 +29,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
29
29
|
parser.add_argument("--version", action="version", version=f"loremfile {__version__}")
|
|
30
30
|
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
31
31
|
|
|
32
|
+
def aliases(sub: argparse.ArgumentParser) -> None:
|
|
33
|
+
sub.add_argument(
|
|
34
|
+
"--include-aliases",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="Also the sample.{format} copies, which repeat another file's bytes.",
|
|
37
|
+
)
|
|
38
|
+
|
|
32
39
|
def shared(sub: argparse.ArgumentParser) -> None:
|
|
33
40
|
sub.add_argument("--json", action="store_true", help="Print one JSON object.")
|
|
34
41
|
sub.add_argument("--quiet", action="store_true", help="Print nothing but failures.")
|
|
@@ -50,18 +57,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
50
57
|
get.add_argument("--dest", default=DEFAULT_DEST, type=Path, help=f"Default: {DEFAULT_DEST}")
|
|
51
58
|
get.add_argument("--force", action="store_true", help="Overwrite files already there.")
|
|
52
59
|
get.add_argument("--dry-run", action="store_true", help="List what would be fetched.")
|
|
60
|
+
aliases(get)
|
|
53
61
|
shared(get)
|
|
54
62
|
|
|
55
63
|
listing = subcommands.add_parser("list", help="What is published, without downloading.")
|
|
56
64
|
listing.add_argument("--format", action="append", default=[], metavar="FMT")
|
|
57
65
|
listing.add_argument("--tag", action="append", default=[], metavar="TAG")
|
|
58
66
|
listing.add_argument("--max-bytes", type=int, metavar="N")
|
|
67
|
+
aliases(listing)
|
|
59
68
|
shared(listing)
|
|
60
69
|
|
|
61
70
|
check = subcommands.add_parser("verify", help="Check files on disk against the manifest.")
|
|
62
71
|
check.add_argument("paths", nargs="*", metavar="PATH")
|
|
63
72
|
check.add_argument("--format", action="append", default=[], metavar="FMT")
|
|
64
73
|
check.add_argument("--dest", default=DEFAULT_DEST, type=Path, help=f"Default: {DEFAULT_DEST}")
|
|
74
|
+
aliases(check)
|
|
65
75
|
shared(check)
|
|
66
76
|
|
|
67
77
|
subcommands.add_parser(
|
|
@@ -88,6 +98,7 @@ def run_get(args: argparse.Namespace) -> int:
|
|
|
88
98
|
api.active(api.manifest(catalog_version=args.catalog_version)),
|
|
89
99
|
paths=args.paths,
|
|
90
100
|
formats=args.format,
|
|
101
|
+
include_aliases=args.include_aliases,
|
|
91
102
|
)
|
|
92
103
|
if args.dry_run:
|
|
93
104
|
total = sum(e["bytes"] for e in entries)
|
|
@@ -108,6 +119,8 @@ def run_get(args: argparse.Namespace) -> int:
|
|
|
108
119
|
|
|
109
120
|
def run_list(args: argparse.Namespace) -> int:
|
|
110
121
|
entries = api.active(api.manifest(catalog_version=args.catalog_version))
|
|
122
|
+
if not args.include_aliases:
|
|
123
|
+
entries = [e for e in entries if not api.is_alias(e)]
|
|
111
124
|
if args.format:
|
|
112
125
|
entries = [e for e in entries if e["format"] in set(args.format)]
|
|
113
126
|
if args.tag:
|
|
@@ -124,9 +137,12 @@ def run_list(args: argparse.Namespace) -> int:
|
|
|
124
137
|
def run_verify(args: argparse.Namespace) -> int:
|
|
125
138
|
entries = api.active(api.manifest(catalog_version=args.catalog_version))
|
|
126
139
|
if args.paths or args.format:
|
|
127
|
-
entries = api.select(
|
|
140
|
+
entries = api.select(
|
|
141
|
+
entries, paths=args.paths, formats=args.format, include_aliases=args.include_aliases
|
|
142
|
+
)
|
|
128
143
|
else:
|
|
129
|
-
# Everything the destination actually holds, so `verify` alone checks a directory
|
|
144
|
+
# Everything the destination actually holds, so `verify` alone checks a directory,
|
|
145
|
+
# aliases included: a copy that is on disk was put there on purpose.
|
|
130
146
|
entries = [e for e in entries if (args.dest / Path(e["path"])).is_file()]
|
|
131
147
|
results = [api.verify(entry, args.dest) for entry in entries]
|
|
132
148
|
failing = [r for r in results if not r.ok]
|
|
@@ -148,12 +148,15 @@ def list_fixtures(document: dict[str, Any], args: Any) -> dict[str, Any]: # noq
|
|
|
148
148
|
args.get("limit", MISSING), "limit", low=1, high=MAX_LIMIT, fallback=DEFAULT_LIMIT
|
|
149
149
|
)
|
|
150
150
|
offset = _integer(args.get("offset", MISSING), "offset", low=0, high=None, fallback=0)
|
|
151
|
-
# The same filters as `loremfile list`: any of the formats, any of the tags, all three.
|
|
151
|
+
# The same filters as `loremfile list`: any of the formats, any of the tags, all three. The
|
|
152
|
+
# sample.{format} aliases repeat another file's bytes and stay out; describe_fixture and
|
|
153
|
+
# verify_file still take one by path.
|
|
152
154
|
matches = sorted(
|
|
153
155
|
(
|
|
154
156
|
e
|
|
155
157
|
for e in api.active(document)
|
|
156
|
-
if
|
|
158
|
+
if not api.is_alias(e)
|
|
159
|
+
and (not formats or e["format"] in formats)
|
|
157
160
|
and (not tags or set(tags) & set(e.get("tags") or []))
|
|
158
161
|
and (max_bytes is None or e["bytes"] <= max_bytes)
|
|
159
162
|
),
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The `loremfile` pytest fixture: a published file, verified, as a local path.
|
|
2
|
+
|
|
3
|
+
pytest imports this module through the `pytest11` entry point in every run where the
|
|
4
|
+
client is installed, whether or not a test asks for the fixture. So importing it does
|
|
5
|
+
nothing: no network, no files, no hooks, no options. All the work happens the first time
|
|
6
|
+
a test calls the fixture, and `tests/test_pytest_plugin.py` holds that as a test.
|
|
7
|
+
|
|
8
|
+
Published files never change at their path, so a cached file and its cached manifest entry
|
|
9
|
+
are final. The manifest is fetched again only for a path the cached copy does not list,
|
|
10
|
+
and once that cache is filled a suite runs without the network.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import tempfile
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
from loremfile_client import api
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Files:
|
|
27
|
+
"""One session's fetcher over a cache directory. Called with a path, returns a file."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, root: Path) -> None:
|
|
30
|
+
self.root = root
|
|
31
|
+
self.files = root / "files"
|
|
32
|
+
self.cached_manifest = root / "manifest.json"
|
|
33
|
+
self.entries: dict[str, dict[str, Any]] | None = None
|
|
34
|
+
self.refreshed = False
|
|
35
|
+
self.downloads = 0
|
|
36
|
+
self.ready: dict[str, Path] = {}
|
|
37
|
+
|
|
38
|
+
def __call__(self, path: str) -> Path:
|
|
39
|
+
if path in self.ready:
|
|
40
|
+
return self.ready[path]
|
|
41
|
+
entry = self.entry(path)
|
|
42
|
+
if not api.verify(entry, self.files).ok:
|
|
43
|
+
self.download(entry)
|
|
44
|
+
local = api.target(self.files, path)
|
|
45
|
+
self.ready[path] = local
|
|
46
|
+
return local
|
|
47
|
+
|
|
48
|
+
def entry(self, path: str) -> dict[str, Any]:
|
|
49
|
+
if self.entries is None:
|
|
50
|
+
self.entries = self.read_cached()
|
|
51
|
+
if path not in self.entries and not self.refreshed:
|
|
52
|
+
self.entries = self.refresh()
|
|
53
|
+
# `select` is the CLI's own refusal, so an unknown or removed path reads the same.
|
|
54
|
+
return api.select(list(self.entries.values()), paths=[path])[0]
|
|
55
|
+
|
|
56
|
+
def read_cached(self) -> dict[str, dict[str, Any]]:
|
|
57
|
+
try:
|
|
58
|
+
document = json.loads(self.cached_manifest.read_text(encoding="utf-8"))
|
|
59
|
+
except (OSError, ValueError):
|
|
60
|
+
return {}
|
|
61
|
+
return {e["path"]: e for e in api.active(document)}
|
|
62
|
+
|
|
63
|
+
def refresh(self) -> dict[str, dict[str, Any]]:
|
|
64
|
+
document = api.manifest()
|
|
65
|
+
self.refreshed = True
|
|
66
|
+
try: # noqa: SIM105 - contextlib would be a new import for one line
|
|
67
|
+
self.write_cached(document)
|
|
68
|
+
except OSError:
|
|
69
|
+
pass # only the next session pays: this one has the document in memory
|
|
70
|
+
return {e["path"]: e for e in api.active(document)}
|
|
71
|
+
|
|
72
|
+
def write_cached(self, document: dict[str, Any]) -> None:
|
|
73
|
+
"""Written beside its final name and renamed, so a parallel worker never reads half."""
|
|
74
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
handle = tempfile.NamedTemporaryFile( # noqa: SIM115 - closed by the `with` below
|
|
76
|
+
"w", dir=self.root, suffix=".part", delete=False, encoding="utf-8"
|
|
77
|
+
)
|
|
78
|
+
partial = Path(handle.name)
|
|
79
|
+
try:
|
|
80
|
+
with handle:
|
|
81
|
+
json.dump(document, handle)
|
|
82
|
+
partial.replace(self.cached_manifest)
|
|
83
|
+
finally:
|
|
84
|
+
partial.unlink(missing_ok=True)
|
|
85
|
+
|
|
86
|
+
def download(self, entry: dict[str, Any]) -> None:
|
|
87
|
+
if self.downloads:
|
|
88
|
+
api.pace()
|
|
89
|
+
self.downloads += 1
|
|
90
|
+
try:
|
|
91
|
+
api.download(entry, self.files, force=True)
|
|
92
|
+
except OSError:
|
|
93
|
+
# On Windows the final rename fails while another xdist worker has the file
|
|
94
|
+
# open. If that worker already put the verified bytes there, they are ours too.
|
|
95
|
+
if not api.verify(entry, self.files).ok:
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.fixture(scope="session")
|
|
100
|
+
def loremfile(
|
|
101
|
+
pytestconfig: pytest.Config, tmp_path_factory: pytest.TempPathFactory
|
|
102
|
+
) -> Callable[[str], Path]:
|
|
103
|
+
"""loremfile(path) -> Path: a loremfile.dev file, checked against its published SHA-256.
|
|
104
|
+
|
|
105
|
+
Cached in .pytest_cache, so each file is downloaded once; `--cache-clear` starts over.
|
|
106
|
+
"""
|
|
107
|
+
# `-p no:cacheprovider` removes the attribute itself; a file then lasts one session.
|
|
108
|
+
cache = getattr(pytestconfig, "cache", None)
|
|
109
|
+
root = cache.mkdir("loremfile") if cache is not None else tmp_path_factory.mktemp("loremfile")
|
|
110
|
+
return Files(root)
|
|
@@ -10,7 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
import pytest
|
|
11
11
|
from loremfile_client import api, cli
|
|
12
12
|
|
|
13
|
-
from conftest import BODIES, Handler
|
|
13
|
+
from conftest import ALIASES, BODIES, MANIFEST, Handler
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def run(*argv: str) -> int:
|
|
@@ -28,6 +28,80 @@ def test_get_by_format_takes_every_file_of_it(server: str, dest: Path) -> None:
|
|
|
28
28
|
assert sorted(p.name for p in (dest / "pdf").iterdir()) == ["other.pdf", "small.pdf"]
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def test_the_fake_manifest_carries_an_alias_the_way_the_real_one_does() -> None:
|
|
32
|
+
"""Control for the tests below: they mean nothing if the alias is not really there."""
|
|
33
|
+
aliases = [e for e in MANIFEST["fixtures"] if e.get("generator") == "alias.copy"]
|
|
34
|
+
assert [e["path"] for e in aliases] == list(ALIASES) == ["pdf/sample.pdf"]
|
|
35
|
+
assert aliases[0]["generator_params"] == {"of": "pdf/small.pdf"}
|
|
36
|
+
assert "aliasOf" not in aliases[0], "docs/04 §1.4: there is no such field"
|
|
37
|
+
assert aliases[0]["sha256"] == next(
|
|
38
|
+
e["sha256"] for e in MANIFEST["fixtures"] if e["path"] == "pdf/small.pdf"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_get_by_format_leaves_the_aliases_out_unless_asked(server: str, dest: Path) -> None:
|
|
43
|
+
assert run("get", "--format", "pdf", "--dest", str(dest)) == cli.OK
|
|
44
|
+
assert not (dest / "pdf" / "sample.pdf").exists()
|
|
45
|
+
assert run("get", "--format", "pdf", "--include-aliases", "--dest", str(dest)) == cli.OK
|
|
46
|
+
assert (dest / "pdf" / "sample.pdf").read_bytes() == BODIES["pdf/small.pdf"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_a_dry_run_counts_the_bytes_it_would_fetch_without_the_duplicate(
|
|
50
|
+
server: str, capsys: pytest.CaptureFixture
|
|
51
|
+
) -> None:
|
|
52
|
+
run("get", "--format", "pdf", "--dry-run", "--json")
|
|
53
|
+
without = json.loads(capsys.readouterr().out)["summary"]
|
|
54
|
+
run("get", "--format", "pdf", "--include-aliases", "--dry-run", "--json")
|
|
55
|
+
with_aliases = json.loads(capsys.readouterr().out)["summary"]
|
|
56
|
+
assert (without["files"], with_aliases["files"]) == (2, 3)
|
|
57
|
+
assert with_aliases["bytes"] - without["bytes"] == len(BODIES["pdf/small.pdf"])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_a_path_named_outright_is_fetched_even_when_it_is_an_alias(server: str, dest: Path) -> None:
|
|
61
|
+
assert run("get", "pdf/sample.pdf", "--dest", str(dest)) == cli.OK
|
|
62
|
+
assert (dest / "pdf" / "sample.pdf").read_bytes() == BODIES["pdf/small.pdf"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_filtering_on_the_field_the_manifest_does_not_have_would_match_nothing() -> None:
|
|
66
|
+
"""The mistake the 1.15.1 brief warns about, made on purpose: `aliasOf` finds no alias."""
|
|
67
|
+
entries = api.active(MANIFEST)
|
|
68
|
+
assert [e["path"] for e in entries if "aliasOf" in e] == []
|
|
69
|
+
assert [e["path"] for e in entries if api.is_alias(e)] == ["pdf/sample.pdf"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_list_leaves_the_aliases_out_unless_asked(
|
|
73
|
+
server: str, capsys: pytest.CaptureFixture
|
|
74
|
+
) -> None:
|
|
75
|
+
def listed(*argv: str) -> set[str]:
|
|
76
|
+
run("list", *argv, "--json")
|
|
77
|
+
return {item["path"] for item in json.loads(capsys.readouterr().out)["items"]}
|
|
78
|
+
|
|
79
|
+
assert "pdf/sample.pdf" not in listed()
|
|
80
|
+
assert "pdf/sample.pdf" not in listed("--format", "pdf")
|
|
81
|
+
assert "pdf/sample.pdf" in listed("--format", "pdf", "--include-aliases")
|
|
82
|
+
assert listed("--include-aliases") == listed() | set(ALIASES)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_verify_checks_an_alias_that_is_on_disk_and_by_format_only_with_the_flag(
|
|
86
|
+
server: str, dest: Path
|
|
87
|
+
) -> None:
|
|
88
|
+
assert run("get", "pdf/sample.pdf", "--dest", str(dest)) == cli.OK
|
|
89
|
+
assert run("verify", "--dest", str(dest)) == cli.OK
|
|
90
|
+
(dest / "pdf" / "sample.pdf").write_bytes(b"not what was published")
|
|
91
|
+
assert run("verify", "--dest", str(dest)) == cli.FAILED, "a held alias is checked"
|
|
92
|
+
assert run("verify", "--format", "pdf", "--dest", str(dest)) == cli.FAILED, (
|
|
93
|
+
"small.pdf and other.pdf are missing"
|
|
94
|
+
)
|
|
95
|
+
(dest / "pdf" / "small.pdf").write_bytes(BODIES["pdf/small.pdf"])
|
|
96
|
+
(dest / "pdf" / "other.pdf").write_bytes(BODIES["pdf/other.pdf"])
|
|
97
|
+
assert run("verify", "--format", "pdf", "--dest", str(dest)) == cli.OK, (
|
|
98
|
+
"the bad alias is skipped"
|
|
99
|
+
)
|
|
100
|
+
assert (
|
|
101
|
+
run("verify", "--format", "pdf", "--include-aliases", "--dest", str(dest)) == cli.FAILED
|
|
102
|
+
), "and asked for, it is checked"
|
|
103
|
+
|
|
104
|
+
|
|
31
105
|
def test_a_withdrawn_entry_is_not_offered(server: str, dest: Path) -> None:
|
|
32
106
|
assert run("get", "pdf/withdrawn.pdf", "--dest", str(dest)) == cli.REFUSED
|
|
33
107
|
|
|
@@ -232,6 +232,16 @@ def test_list_fixtures_pages_and_says_where_it_is(child: Client, server: str) ->
|
|
|
232
232
|
assert (last["count"], last["next_offset"]) == (1, None)
|
|
233
233
|
|
|
234
234
|
|
|
235
|
+
def test_list_fixtures_leaves_the_sample_aliases_out_and_describe_still_finds_one(
|
|
236
|
+
child: Client,
|
|
237
|
+
) -> None:
|
|
238
|
+
listed = child.call("list_fixtures", {"format": ["pdf"]})["structuredContent"]
|
|
239
|
+
assert [f["path"] for f in listed["fixtures"]] == ["pdf/other.pdf", "pdf/small.pdf"]
|
|
240
|
+
assert listed["total"] == 2
|
|
241
|
+
described = child.call("describe_fixture", {"path": "pdf/sample.pdf"})["structuredContent"]
|
|
242
|
+
assert described["path"] == "pdf/sample.pdf", "control: the alias exists and can be asked for"
|
|
243
|
+
|
|
244
|
+
|
|
235
245
|
def test_list_fixtures_filters_as_loremfile_list_does(child: Client) -> None:
|
|
236
246
|
def paths(arguments: dict[str, Any]) -> list[str]:
|
|
237
247
|
result = child.call("list_fixtures", arguments)["structuredContent"]
|
|
@@ -110,10 +110,16 @@ def test_the_client_imports_nothing_but_the_standard_library() -> None:
|
|
|
110
110
|
}
|
|
111
111
|
imported: set[str] = set()
|
|
112
112
|
for source in PACKAGE.glob("*.py"):
|
|
113
|
+
found: set[str] = set()
|
|
113
114
|
for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))):
|
|
114
115
|
if isinstance(node, ast.Import):
|
|
115
|
-
|
|
116
|
+
found |= {alias.name.split(".")[0] for alias in node.names}
|
|
116
117
|
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
117
|
-
|
|
118
|
+
found.add(node.module.split(".")[0])
|
|
119
|
+
# The one exemption, by name: only pytest imports the plugin, so pytest is present.
|
|
120
|
+
if source.name == "pytest_plugin.py":
|
|
121
|
+
assert "pytest" in found, "the exemption names a module that no longer needs it"
|
|
122
|
+
found.discard("pytest")
|
|
123
|
+
imported |= found
|
|
118
124
|
assert imported, "empty-set control: the scan found the imports"
|
|
119
125
|
assert imported <= allowed, imported - allowed
|
|
@@ -46,6 +46,13 @@ def test_the_console_script_is_the_client_entry_point() -> None:
|
|
|
46
46
|
assert "loremfile = loremfile_client.cli:main" in text
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def test_the_wheel_registers_the_pytest_plugin() -> None:
|
|
50
|
+
with zipfile.ZipFile(wheel()) as archive:
|
|
51
|
+
entry_points = next(n for n in archive.namelist() if n.endswith("entry_points.txt"))
|
|
52
|
+
text = archive.read(entry_points).decode()
|
|
53
|
+
assert "[pytest11]\nloremfile = loremfile_client.pytest_plugin" in text
|
|
54
|
+
|
|
55
|
+
|
|
49
56
|
def test_the_wheel_carries_the_mcp_tool_surface() -> None:
|
|
50
57
|
"""mcp.py reads tools.json at import; a wheel without it is a server that cannot start."""
|
|
51
58
|
source = CLIENT / "src" / "loremfile_client" / "tools.json"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""The `loremfile` fixture: what it returns, when it needs the network, and that it is inert.
|
|
2
|
+
|
|
3
|
+
pytest loads the plugin in every run where the client is installed, so the property that
|
|
4
|
+
matters most is the one nobody would notice breaking: a suite that never asks for the
|
|
5
|
+
fixture must not pay for it. The subprocess tests below run a real pytest with the socket
|
|
6
|
+
module refused, and each has a control that watches the same guard catch the plugin.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from importlib import metadata
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
from loremfile_client import api, pytest_plugin
|
|
22
|
+
from loremfile_client.pytest_plugin import Files
|
|
23
|
+
|
|
24
|
+
from conftest import BODIES, Handler
|
|
25
|
+
|
|
26
|
+
SMALL = BODIES["pdf/small.pdf"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NetworkUsed(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def no_network(path: str) -> Any:
|
|
34
|
+
raise NetworkUsed(path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_a_file_arrives_verified_at_a_path(server: str, tmp_path: Path) -> None:
|
|
38
|
+
local = Files(tmp_path / "cache")("pdf/small.pdf")
|
|
39
|
+
assert local == (tmp_path / "cache" / "files" / "pdf" / "small.pdf").resolve()
|
|
40
|
+
assert local.read_bytes() == SMALL
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_a_filled_cache_needs_no_network(
|
|
44
|
+
server: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
45
|
+
) -> None:
|
|
46
|
+
Files(tmp_path / "cache")("pdf/small.pdf")
|
|
47
|
+
monkeypatch.setattr(api, "_open", no_network)
|
|
48
|
+
assert Files(tmp_path / "cache")("pdf/small.pdf").read_bytes() == SMALL
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_control_an_empty_cache_does_need_it(
|
|
52
|
+
server: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
53
|
+
) -> None:
|
|
54
|
+
monkeypatch.setattr(api, "_open", no_network)
|
|
55
|
+
with pytest.raises(NetworkUsed):
|
|
56
|
+
Files(tmp_path / "cache")("pdf/small.pdf")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_a_path_the_cached_copy_lacks_refreshes_it_once(
|
|
60
|
+
server: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
61
|
+
) -> None:
|
|
62
|
+
root = tmp_path / "cache"
|
|
63
|
+
Files(root)("pdf/small.pdf")
|
|
64
|
+
# A cached copy from before svg/square.svg was published.
|
|
65
|
+
document = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
|
66
|
+
document["fixtures"] = [e for e in document["fixtures"] if e["path"] != "svg/square.svg"]
|
|
67
|
+
(root / "manifest.json").write_text(json.dumps(document), encoding="utf-8")
|
|
68
|
+
fetched: list[int] = []
|
|
69
|
+
real = api.manifest
|
|
70
|
+
monkeypatch.setattr(api, "manifest", lambda **kw: fetched.append(1) or real(**kw))
|
|
71
|
+
|
|
72
|
+
get = Files(root)
|
|
73
|
+
assert get("svg/square.svg").read_bytes() == BODIES["svg/square.svg"]
|
|
74
|
+
with pytest.raises(api.Refused, match=re.escape("not published: pdf/not-real.pdf")):
|
|
75
|
+
get("pdf/not-real.pdf")
|
|
76
|
+
assert fetched == [1]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize("path", ["pdf/not-real.pdf", "pdf/withdrawn.pdf"])
|
|
80
|
+
def test_an_unpublished_or_removed_path_is_refused(server: str, tmp_path: Path, path: str) -> None:
|
|
81
|
+
with pytest.raises(api.Refused, match=re.escape(f"not published: {path}")):
|
|
82
|
+
Files(tmp_path / "cache")(path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_bytes_that_do_not_match_are_never_returned(server: str, tmp_path: Path) -> None:
|
|
86
|
+
Handler.corrupt = {"pdf/small.pdf"}
|
|
87
|
+
with pytest.raises(api.Mismatch):
|
|
88
|
+
Files(tmp_path / "cache")("pdf/small.pdf")
|
|
89
|
+
assert not (tmp_path / "cache" / "files" / "pdf" / "small.pdf").exists()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_a_cached_file_that_changed_is_fetched_again(server: str, tmp_path: Path) -> None:
|
|
93
|
+
root = tmp_path / "cache"
|
|
94
|
+
Files(root)("pdf/small.pdf").write_bytes(b"edited by someone")
|
|
95
|
+
assert Files(root)("pdf/small.pdf").read_bytes() == SMALL
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_a_rename_another_worker_won_is_accepted(
|
|
99
|
+
server: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
100
|
+
) -> None:
|
|
101
|
+
real = api.download
|
|
102
|
+
|
|
103
|
+
def raced(entry: dict[str, Any], dest: Path, *, force: bool = False) -> api.Result:
|
|
104
|
+
real(entry, dest, force=force)
|
|
105
|
+
raise PermissionError("the file is open in another process")
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr(api, "download", raced)
|
|
108
|
+
assert Files(tmp_path / "cache")("pdf/small.pdf").read_bytes() == SMALL
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_control_a_failed_rename_with_nothing_verified_is_raised(
|
|
112
|
+
server: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
113
|
+
) -> None:
|
|
114
|
+
def failed(entry: dict[str, Any], dest: Path, *, force: bool = False) -> api.Result:
|
|
115
|
+
raise PermissionError("the file is open in another process")
|
|
116
|
+
|
|
117
|
+
monkeypatch.setattr(api, "download", failed)
|
|
118
|
+
with pytest.raises(PermissionError):
|
|
119
|
+
Files(tmp_path / "cache")("pdf/small.pdf")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_the_plugin_adds_no_hooks() -> None:
|
|
123
|
+
assert [name for name in vars(pytest_plugin) if name.startswith("pytest_")] == []
|
|
124
|
+
assert callable(pytest_plugin.loremfile), "control: the module read is the plugin"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_the_client_imports_without_pytest() -> None:
|
|
128
|
+
"""The CLI and the MCP server must not drag pytest in; the control proves the probe sees it."""
|
|
129
|
+
probe = (
|
|
130
|
+
"import sys, {}; print(sorted(m for m in sys.modules"
|
|
131
|
+
" if m.split('.')[0] == 'pytest' or m.endswith('pytest_plugin')))"
|
|
132
|
+
)
|
|
133
|
+
client = subprocess.run( # noqa: S603 - fixed argv, no shell
|
|
134
|
+
[sys.executable, "-c", probe.format("loremfile_client.cli, loremfile_client.mcp")],
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
check=True,
|
|
138
|
+
)
|
|
139
|
+
assert client.stdout.strip() == "[]"
|
|
140
|
+
control = subprocess.run( # noqa: S603 - fixed argv, no shell
|
|
141
|
+
[sys.executable, "-c", probe.format("loremfile_client.pytest_plugin")],
|
|
142
|
+
capture_output=True,
|
|
143
|
+
text=True,
|
|
144
|
+
check=True,
|
|
145
|
+
)
|
|
146
|
+
assert "'pytest'" in control.stdout
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- a real pytest, in a subprocess, with the network refused --------------------------
|
|
150
|
+
|
|
151
|
+
#: Installed before pytest starts, so it is in place when the plugin is imported.
|
|
152
|
+
GUARD = """
|
|
153
|
+
import json, os, socket, sys
|
|
154
|
+
attempts = []
|
|
155
|
+
def refuse(*args, **kwargs):
|
|
156
|
+
attempts.append(repr(args[:2]))
|
|
157
|
+
raise OSError("network refused by the test")
|
|
158
|
+
socket.socket.connect = refuse
|
|
159
|
+
socket.create_connection = refuse
|
|
160
|
+
socket.getaddrinfo = refuse
|
|
161
|
+
import pytest
|
|
162
|
+
code = pytest.main(sys.argv[1:])
|
|
163
|
+
with open(os.environ["GUARD_LOG"], "w") as log:
|
|
164
|
+
json.dump(attempts, log)
|
|
165
|
+
sys.exit(code)
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
USES_IT = """
|
|
169
|
+
def test_it(loremfile):
|
|
170
|
+
assert loremfile("pdf/small.pdf").read_bytes() == b"%PDF-1.4 pretend\\n"
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
IGNORES_IT = """
|
|
174
|
+
def test_it(request):
|
|
175
|
+
assert request.config.pluginmanager.get_plugin("loremfile") is not None
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def entry_point_installed() -> None:
|
|
180
|
+
"""Fails rather than skips: an inertness test that quietly does not run proves nothing."""
|
|
181
|
+
found = [ep.value for ep in metadata.entry_points(group="pytest11") if ep.name == "loremfile"]
|
|
182
|
+
assert found == ["loremfile_client.pytest_plugin"], (
|
|
183
|
+
f"pytest11 entry points named loremfile: {found}; install the client's wheel first"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def pytest_run(
|
|
188
|
+
where: Path, source: str, *args: str, guarded: bool = False
|
|
189
|
+
) -> tuple[int, list[str]]:
|
|
190
|
+
entry_point_installed()
|
|
191
|
+
(where / "pytest.ini").write_text("[pytest]\n", encoding="utf-8")
|
|
192
|
+
(where / "test_it.py").write_text(source, encoding="utf-8")
|
|
193
|
+
log = where / "attempts.json"
|
|
194
|
+
env = {**os.environ, "GUARD_LOG": str(log)}
|
|
195
|
+
env.pop("PYTEST_DISABLE_PLUGIN_AUTOLOAD", None)
|
|
196
|
+
env.pop("PYTEST_ADDOPTS", None)
|
|
197
|
+
if guarded:
|
|
198
|
+
command = [sys.executable, "-c", GUARD, "-q", *args]
|
|
199
|
+
else:
|
|
200
|
+
command = [sys.executable, "-m", "pytest", "-q", *args]
|
|
201
|
+
done = subprocess.run( # noqa: S603 - fixed argv, no shell
|
|
202
|
+
command, cwd=where, env=env, capture_output=True, text=True, timeout=120, check=False
|
|
203
|
+
)
|
|
204
|
+
attempts = json.loads(log.read_text(encoding="utf-8")) if guarded else []
|
|
205
|
+
assert done.returncode in (0, 1), done.stdout + done.stderr
|
|
206
|
+
return done.returncode, attempts
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_a_suite_that_never_asks_for_it_pays_nothing(tmp_path: Path) -> None:
|
|
210
|
+
code, attempts = pytest_run(tmp_path, IGNORES_IT, guarded=True)
|
|
211
|
+
assert (code, attempts) == (0, [])
|
|
212
|
+
assert not (tmp_path / ".pytest_cache" / "d" / "loremfile").exists()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_control_the_guard_sees_the_plugin_reach_out(server: str, tmp_path: Path) -> None:
|
|
216
|
+
code, attempts = pytest_run(tmp_path, USES_IT, guarded=True)
|
|
217
|
+
assert code == 1
|
|
218
|
+
assert attempts, "the guard recorded nothing, so the test above could not have failed"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_once_the_cache_is_filled_the_suite_runs_offline(server: str, tmp_path: Path) -> None:
|
|
222
|
+
assert pytest_run(tmp_path, USES_IT) == (0, [])
|
|
223
|
+
# Where the README tells CI to cache, and the path the inertness test finds absent.
|
|
224
|
+
cached = tmp_path / ".pytest_cache" / "d" / "loremfile" / "files" / "pdf" / "small.pdf"
|
|
225
|
+
assert cached.read_bytes() == SMALL
|
|
226
|
+
assert pytest_run(tmp_path, USES_IT, guarded=True) == (0, [])
|
|
227
|
+
# Control: the same offline run with the cache cleared has to reach out, and fails.
|
|
228
|
+
code, attempts = pytest_run(tmp_path, USES_IT, "--cache-clear", guarded=True)
|
|
229
|
+
assert code == 1
|
|
230
|
+
assert attempts
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_without_the_cache_provider_it_still_works(server: str, tmp_path: Path) -> None:
|
|
234
|
+
assert pytest_run(tmp_path, USES_IT, "-p", "no:cacheprovider") == (0, [])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|