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.
@@ -1,18 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loremfile
3
- Version: 0.2.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 — standard library only.
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 — which the action does not
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 — that tool lives only inside the project's toolchain
116
- image — but a contributor who installs both into one environment gets whichever was
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 — standard library only.
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 — which the action does not
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 — that tool lives only inside the project's toolchain
97
- image — but a contributor who installs both into one environment gets whichever was
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.2.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.2.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 — standard library only.
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 — which the action does not
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 — that tool lives only inside the project's toolchain
116
- image — but a contributor who installs both into one environment gets whichever was
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
@@ -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.1"
@@ -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 for e in entries if e["path"] in wanted_paths or e["format"] in wanted_formats
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(entries, paths=args.paths, formats=args.format)
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 (not formats or e["format"] in formats)
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
- 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