loremfile 0.2.0__tar.gz → 0.3.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.2.0 → loremfile-0.3.0}/PKG-INFO +29 -6
- {loremfile-0.2.0 → loremfile-0.3.0}/README.md +26 -4
- {loremfile-0.2.0 → loremfile-0.3.0}/pyproject.toml +8 -2
- {loremfile-0.2.0 → loremfile-0.3.0}/src/loremfile.egg-info/PKG-INFO +29 -6
- {loremfile-0.2.0 → loremfile-0.3.0}/src/loremfile.egg-info/SOURCES.txt +3 -1
- {loremfile-0.2.0 → loremfile-0.3.0}/src/loremfile.egg-info/entry_points.txt +3 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/src/loremfile_client/__init__.py +1 -1
- loremfile-0.3.0/src/loremfile_client/pytest_plugin.py +110 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/tests/test_only_loremfile_dev.py +8 -2
- {loremfile-0.2.0 → loremfile-0.3.0}/tests/test_packaging.py +7 -0
- loremfile-0.3.0/tests/test_pytest_plugin.py +234 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/setup.cfg +0 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/src/loremfile.egg-info/dependency_links.txt +0 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/src/loremfile.egg-info/top_level.txt +0 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/src/loremfile_client/__main__.py +0 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/src/loremfile_client/api.py +0 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/src/loremfile_client/cli.py +0 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/src/loremfile_client/mcp.py +0 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/src/loremfile_client/tools.json +0 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/tests/test_commands.py +0 -0
- {loremfile-0.2.0 → loremfile-0.3.0}/tests/test_mcp.py +0 -0
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loremfile
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -50,6 +51,28 @@ refuse to run if loremfile.dev has moved on. `get` also takes `--force` (overwri
|
|
|
50
51
|
Exit codes: `0` fine, `1` a file failed verification or is missing, `2` the request was
|
|
51
52
|
wrong, `3` loremfile.dev could not be read.
|
|
52
53
|
|
|
54
|
+
## In a pytest suite
|
|
55
|
+
|
|
56
|
+
For files too big to commit: multi-megabyte video, audio and archives that would otherwise
|
|
57
|
+
bloat a repository. Installing the client gives pytest a `loremfile` fixture:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
def test_reads_a_real_mp4(loremfile):
|
|
61
|
+
video = loremfile("mp4/10mb.mp4") # a pathlib.Path
|
|
62
|
+
...
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The first call downloads the file into `.pytest_cache` and checks its SHA-256 against the
|
|
66
|
+
manifest; a file that does not match is never returned. Published files never change at
|
|
67
|
+
their path, so after that the file and its manifest entry are reused and the suite needs no
|
|
68
|
+
network for them. In CI, cache `.pytest_cache/d/loremfile` between runs to keep it that way;
|
|
69
|
+
`pytest --cache-clear` starts over.
|
|
70
|
+
|
|
71
|
+
It is not for every suite. If your tests may not touch the network, or the file you need is
|
|
72
|
+
a 4 KB PDF, commit the file: that is simpler and needs nothing from us. The fixture takes a
|
|
73
|
+
path only (`loremfile list` finds one), needs pytest 8.0 or newer, and does nothing until a
|
|
74
|
+
test asks for it.
|
|
75
|
+
|
|
53
76
|
## MCP server
|
|
54
77
|
|
|
55
78
|
<!-- mcp-name: io.github.kumarprabhashanand/loremfile -->
|
|
@@ -94,7 +117,7 @@ oldest CPython still receiving security fixes when a release is cut; a new one i
|
|
|
94
117
|
supported within six weeks of its release, and an end-of-life one is dropped in the next
|
|
95
118
|
minor release.
|
|
96
119
|
|
|
97
|
-
Zero dependencies
|
|
120
|
+
Zero dependencies: standard library only.
|
|
98
121
|
|
|
99
122
|
## In GitHub Actions, prefer the action
|
|
100
123
|
|
|
@@ -105,15 +128,15 @@ Zero dependencies — standard library only.
|
|
|
105
128
|
```
|
|
106
129
|
|
|
107
130
|
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
|
|
131
|
+
a Makefile, a CI system that is not Actions, or Windows, which the action does not
|
|
109
132
|
support because it needs `sha256sum`.
|
|
110
133
|
|
|
111
134
|
## One name, two tools
|
|
112
135
|
|
|
113
136
|
The console script installed here is `loremfile`, which is also the name of the
|
|
114
137
|
repository's own development CLI (the one that generates and publishes the fixtures).
|
|
115
|
-
They are never installed together
|
|
116
|
-
image
|
|
138
|
+
They are never installed together (that tool lives only inside the project's toolchain
|
|
139
|
+
image), but a contributor who installs both into one environment gets whichever was
|
|
117
140
|
installed last. The published package contains the client only: no generators, no
|
|
118
141
|
validators, no uploader, no infrastructure code.
|
|
119
142
|
|
|
@@ -31,6 +31,28 @@ refuse to run if loremfile.dev has moved on. `get` also takes `--force` (overwri
|
|
|
31
31
|
Exit codes: `0` fine, `1` a file failed verification or is missing, `2` the request was
|
|
32
32
|
wrong, `3` loremfile.dev could not be read.
|
|
33
33
|
|
|
34
|
+
## In a pytest suite
|
|
35
|
+
|
|
36
|
+
For files too big to commit: multi-megabyte video, audio and archives that would otherwise
|
|
37
|
+
bloat a repository. Installing the client gives pytest a `loremfile` fixture:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
def test_reads_a_real_mp4(loremfile):
|
|
41
|
+
video = loremfile("mp4/10mb.mp4") # a pathlib.Path
|
|
42
|
+
...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The first call downloads the file into `.pytest_cache` and checks its SHA-256 against the
|
|
46
|
+
manifest; a file that does not match is never returned. Published files never change at
|
|
47
|
+
their path, so after that the file and its manifest entry are reused and the suite needs no
|
|
48
|
+
network for them. In CI, cache `.pytest_cache/d/loremfile` between runs to keep it that way;
|
|
49
|
+
`pytest --cache-clear` starts over.
|
|
50
|
+
|
|
51
|
+
It is not for every suite. If your tests may not touch the network, or the file you need is
|
|
52
|
+
a 4 KB PDF, commit the file: that is simpler and needs nothing from us. The fixture takes a
|
|
53
|
+
path only (`loremfile list` finds one), needs pytest 8.0 or newer, and does nothing until a
|
|
54
|
+
test asks for it.
|
|
55
|
+
|
|
34
56
|
## MCP server
|
|
35
57
|
|
|
36
58
|
<!-- mcp-name: io.github.kumarprabhashanand/loremfile -->
|
|
@@ -75,7 +97,7 @@ oldest CPython still receiving security fixes when a release is cut; a new one i
|
|
|
75
97
|
supported within six weeks of its release, and an end-of-life one is dropped in the next
|
|
76
98
|
minor release.
|
|
77
99
|
|
|
78
|
-
Zero dependencies
|
|
100
|
+
Zero dependencies: standard library only.
|
|
79
101
|
|
|
80
102
|
## In GitHub Actions, prefer the action
|
|
81
103
|
|
|
@@ -86,15 +108,15 @@ Zero dependencies — standard library only.
|
|
|
86
108
|
```
|
|
87
109
|
|
|
88
110
|
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
|
|
111
|
+
a Makefile, a CI system that is not Actions, or Windows, which the action does not
|
|
90
112
|
support because it needs `sha256sum`.
|
|
91
113
|
|
|
92
114
|
## One name, two tools
|
|
93
115
|
|
|
94
116
|
The console script installed here is `loremfile`, which is also the name of the
|
|
95
117
|
repository's own development CLI (the one that generates and publishes the fixtures).
|
|
96
|
-
They are never installed together
|
|
97
|
-
image
|
|
118
|
+
They are never installed together (that tool lives only inside the project's toolchain
|
|
119
|
+
image), but a contributor who installs both into one environment gets whichever was
|
|
98
120
|
installed last. The published package contains the client only: no generators, no
|
|
99
121
|
validators, no uploader, no infrastructure code.
|
|
100
122
|
|
|
@@ -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.0"
|
|
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.0
|
|
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
|
|
@@ -50,6 +51,28 @@ refuse to run if loremfile.dev has moved on. `get` also takes `--force` (overwri
|
|
|
50
51
|
Exit codes: `0` fine, `1` a file failed verification or is missing, `2` the request was
|
|
51
52
|
wrong, `3` loremfile.dev could not be read.
|
|
52
53
|
|
|
54
|
+
## In a pytest suite
|
|
55
|
+
|
|
56
|
+
For files too big to commit: multi-megabyte video, audio and archives that would otherwise
|
|
57
|
+
bloat a repository. Installing the client gives pytest a `loremfile` fixture:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
def test_reads_a_real_mp4(loremfile):
|
|
61
|
+
video = loremfile("mp4/10mb.mp4") # a pathlib.Path
|
|
62
|
+
...
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The first call downloads the file into `.pytest_cache` and checks its SHA-256 against the
|
|
66
|
+
manifest; a file that does not match is never returned. Published files never change at
|
|
67
|
+
their path, so after that the file and its manifest entry are reused and the suite needs no
|
|
68
|
+
network for them. In CI, cache `.pytest_cache/d/loremfile` between runs to keep it that way;
|
|
69
|
+
`pytest --cache-clear` starts over.
|
|
70
|
+
|
|
71
|
+
It is not for every suite. If your tests may not touch the network, or the file you need is
|
|
72
|
+
a 4 KB PDF, commit the file: that is simpler and needs nothing from us. The fixture takes a
|
|
73
|
+
path only (`loremfile list` finds one), needs pytest 8.0 or newer, and does nothing until a
|
|
74
|
+
test asks for it.
|
|
75
|
+
|
|
53
76
|
## MCP server
|
|
54
77
|
|
|
55
78
|
<!-- mcp-name: io.github.kumarprabhashanand/loremfile -->
|
|
@@ -94,7 +117,7 @@ oldest CPython still receiving security fixes when a release is cut; a new one i
|
|
|
94
117
|
supported within six weeks of its release, and an end-of-life one is dropped in the next
|
|
95
118
|
minor release.
|
|
96
119
|
|
|
97
|
-
Zero dependencies
|
|
120
|
+
Zero dependencies: standard library only.
|
|
98
121
|
|
|
99
122
|
## In GitHub Actions, prefer the action
|
|
100
123
|
|
|
@@ -105,15 +128,15 @@ Zero dependencies — standard library only.
|
|
|
105
128
|
```
|
|
106
129
|
|
|
107
130
|
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
|
|
131
|
+
a Makefile, a CI system that is not Actions, or Windows, which the action does not
|
|
109
132
|
support because it needs `sha256sum`.
|
|
110
133
|
|
|
111
134
|
## One name, two tools
|
|
112
135
|
|
|
113
136
|
The console script installed here is `loremfile`, which is also the name of the
|
|
114
137
|
repository's own development CLI (the one that generates and publishes the fixtures).
|
|
115
|
-
They are never installed together
|
|
116
|
-
image
|
|
138
|
+
They are never installed together (that tool lives only inside the project's toolchain
|
|
139
|
+
image), but a contributor who installs both into one environment gets whichever was
|
|
117
140
|
installed last. The published package contains the client only: no generators, no
|
|
118
141
|
validators, no uploader, no infrastructure code.
|
|
119
142
|
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|