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.
@@ -1,18 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loremfile
3
- Version: 0.2.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 — standard library only.
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 — which the action does not
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 — that tool lives only inside the project's toolchain
116
- image — but a contributor who installs both into one environment gets whichever was
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 — standard library only.
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 — which the action does not
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 — that tool lives only inside the project's toolchain
97
- image — but a contributor who installs both into one environment gets whichever was
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.2.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.2.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 — standard library only.
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 — which the action does not
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 — that tool lives only inside the project's toolchain
116
- image — but a contributor who installs both into one environment gets whichever was
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
@@ -1,2 +1,5 @@
1
1
  [console_scripts]
2
2
  loremfile = loremfile_client.cli:main
3
+
4
+ [pytest11]
5
+ loremfile = loremfile_client.pytest_plugin
@@ -1,3 +1,3 @@
1
1
  """A small client for loremfile.dev: download CC0 sample files and verify every byte."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -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
- imported |= {alias.name.split(".")[0] for alias in node.names}
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
- imported.add(node.module.split(".")[0])
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