pytest-resources 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,36 @@
1
+ """pytest-resources — lazy, typed, attribute-navigable resource fixtures for pytest.
2
+
3
+ One façade over the package. The pytest plugin (`resources` fixture, options and
4
+ hooks) lives in :mod:`pytest_resources.plugin` and auto-loads via the ``pytest11``
5
+ entry point; this module is the import-time surface for programmatic use.
6
+
7
+ import pytest_resources as pr
8
+ resources = pr.build_resources(Path("tests/resources"))
9
+ sample = resources.structured.sample # decoded per its FileType, cached
10
+ """
11
+
12
+ from importlib.metadata import PackageNotFoundError, version
13
+
14
+ from pytest_resources.errors import EntryNotFoundError, ResourceError
15
+ from pytest_resources.formats import FileType
16
+ from pytest_resources.index import abuild_resources, build_resources
17
+ from pytest_resources.loaders import Loader, default_loaders
18
+ from pytest_resources.nodes import ResourceNode, Resources
19
+
20
+ try:
21
+ __version__: str = version("pytest-resources")
22
+ except PackageNotFoundError: # source tree, not installed
23
+ __version__ = "0.0.0+dev"
24
+
25
+ __all__ = [
26
+ "EntryNotFoundError",
27
+ "FileType",
28
+ "Loader",
29
+ "ResourceError",
30
+ "ResourceNode",
31
+ "Resources",
32
+ "__version__",
33
+ "abuild_resources",
34
+ "build_resources",
35
+ "default_loaders",
36
+ ]
@@ -0,0 +1,13 @@
1
+ """pytest_resources.errors: the typed failure surface of the resources plugin."""
2
+
3
+
4
+ class ResourceError(Exception):
5
+ """Base for every resources-plugin failure."""
6
+
7
+
8
+ class EntryNotFoundError(ResourceError, AttributeError):
9
+ """A node was asked for an entry the indexed tree does not hold.
10
+
11
+ Subclasses ``AttributeError`` so attribute access keeps its protocol while the
12
+ error stays typed and catchable as ``ResourceError``.
13
+ """
@@ -0,0 +1,67 @@
1
+ """pytest_resources.formats: canonical file kinds and suffix resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum, auto
6
+ from types import MappingProxyType
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+ from typing import Final
13
+
14
+
15
+ class FileType(StrEnum):
16
+ """The kinds the loader system recognises; any other suffix resolves to ``BINARY``."""
17
+
18
+ JSON = auto()
19
+ JSONC = auto()
20
+ NDJSON = auto()
21
+ YAML = auto()
22
+ TOML = auto()
23
+ INI = auto()
24
+ CSV = auto()
25
+ TSV = auto()
26
+ MARKDOWN = auto()
27
+ TEXT = auto()
28
+ HTML = auto()
29
+ XML = auto()
30
+ PYTHON = auto()
31
+ BINARY = auto()
32
+
33
+ @classmethod
34
+ def of(cls, suffix: str) -> FileType:
35
+ """Kind resolved from a file suffix through the frozen table; unknown is ``BINARY``."""
36
+ return _SUFFIXES.get(suffix.casefold(), FileType.BINARY)
37
+
38
+ @classmethod
39
+ def of_path(cls, path: Path) -> FileType:
40
+ """Kind resolved from a path's extension."""
41
+ return cls.of(path.suffix)
42
+
43
+
44
+ _SUFFIXES: Final[Mapping[str, FileType]] = MappingProxyType(
45
+ {
46
+ ".json": FileType.JSON,
47
+ ".jsonc": FileType.JSONC,
48
+ ".jsonl": FileType.NDJSON,
49
+ ".ndjson": FileType.NDJSON,
50
+ ".yaml": FileType.YAML,
51
+ ".yml": FileType.YAML,
52
+ ".toml": FileType.TOML,
53
+ ".ini": FileType.INI,
54
+ ".cfg": FileType.INI,
55
+ ".conf": FileType.INI,
56
+ ".csv": FileType.CSV,
57
+ ".tsv": FileType.TSV,
58
+ ".md": FileType.MARKDOWN,
59
+ ".markdown": FileType.MARKDOWN,
60
+ ".txt": FileType.TEXT,
61
+ ".rst": FileType.TEXT,
62
+ ".html": FileType.HTML,
63
+ ".htm": FileType.HTML,
64
+ ".xml": FileType.XML,
65
+ ".py": FileType.PYTHON,
66
+ }
67
+ )
@@ -0,0 +1,44 @@
1
+ """pytest_resources.hooks: the extension seams for loaders and resource roots.
2
+
3
+ A project implements these in its own ``conftest.py`` to add or override the default
4
+ codec table, or to contribute extra resource directories, without touching the plugin.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable, Mapping
10
+ from typing import TYPE_CHECKING
11
+
12
+ import pytest
13
+
14
+ from pytest_resources.formats import FileType
15
+ from pytest_resources.loaders import Loader
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+ Register = Callable[[Mapping[FileType, Loader]], None]
21
+
22
+
23
+ @pytest.hookspec
24
+ def pytest_resource_loaders(register: Register) -> None:
25
+ """Bind or override resource loaders declaratively.
26
+
27
+ ``register`` merges a ``{FileType: bytes -> object}`` mapping into the session's
28
+ codec table, so an existing kind can be swapped and an unknown one introduced::
29
+
30
+ def pytest_resource_loaders(register):
31
+ register({FileType.CSV: my_csv_rows, FileType.YAML: ruamel_round_trip})
32
+ """
33
+
34
+
35
+ @pytest.hookspec
36
+ def pytest_resources_roots(roots: list[Path]) -> None:
37
+ """Contribute extra resource root directories to the indexed tree.
38
+
39
+ ``roots`` is the resolved base list (CLI / ini / default); implementations append to
40
+ it, and appended roots win over earlier ones on a top-level key clash::
41
+
42
+ def pytest_resources_roots(roots):
43
+ roots.append(Path("tests/fixtures/shared"))
44
+ """
@@ -0,0 +1,92 @@
1
+ """pytest_resources.index: recursive directory indexing without reading contents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ from operator import attrgetter
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from pytest_resources.errors import ResourceError
12
+ from pytest_resources.loaders import default_loaders
13
+ from pytest_resources.nodes import ResourceNode, Resources, _Context
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Iterable, Mapping
17
+
18
+ from pytest_resources.formats import FileType
19
+ from pytest_resources.loaders import Loader
20
+
21
+ ##### PRIVATE #####
22
+
23
+
24
+ def _key(name: str) -> str:
25
+ """Identifier-safe attribute name from a file stem or a directory name."""
26
+ return re.sub(r"\W|^(?=\d)", "_", Path(name).stem)
27
+
28
+
29
+ def _visible(name: str) -> bool:
30
+ return not name.startswith(".") and name != "__pycache__"
31
+
32
+
33
+ def _children(path: Path, context: _Context) -> dict[str, Path | ResourceNode]:
34
+ entries = sorted(path.iterdir(), key=attrgetter("name"))
35
+ return {
36
+ _key(entry.name): (_node(entry, context) if entry.is_dir() else entry)
37
+ for entry in entries
38
+ if _visible(entry.name)
39
+ }
40
+
41
+
42
+ def _node(path: Path, context: _Context) -> ResourceNode:
43
+ return ResourceNode(path, _children(path, context), context)
44
+
45
+
46
+ def _context(registry: Mapping[FileType, Loader] | None) -> _Context:
47
+ ### A None registry means the best default table; a mapping is copied, never shared.
48
+ return _Context(default_loaders() if registry is None else dict(registry))
49
+
50
+
51
+ def _roots(root: Path | Iterable[Path]) -> tuple[Path, ...]:
52
+ ### One root or many: a lone Path and a sequence of them share the same code path.
53
+ match root:
54
+ case Path() as single:
55
+ return (single,)
56
+ case _:
57
+ return tuple(root)
58
+
59
+
60
+ def _merge(paths: tuple[Path, ...], context: _Context) -> dict[str, Path | ResourceNode]:
61
+ ### Flat top-level merge across roots; a later root overrides an earlier key.
62
+ merged: dict[str, Path | ResourceNode] = {}
63
+ for path in paths:
64
+ merged |= _children(path, context)
65
+ return merged
66
+
67
+
68
+ def _ensure_dir(root: Path) -> Path:
69
+ if not root.is_dir():
70
+ msg = f"resources root is not a directory: {root}"
71
+ raise ResourceError(msg)
72
+ return root
73
+
74
+
75
+ ############################################################
76
+
77
+ ##### PUBLIC #####
78
+
79
+
80
+ def build_resources(root: Path | Iterable[Path], registry: Mapping[FileType, Loader] | None = None) -> Resources:
81
+ """Index ``root`` (one path or several) recursively — read on access, later roots win."""
82
+ paths = tuple(map(_ensure_dir, _roots(root)))
83
+ context = _context(registry)
84
+ return Resources(paths[0], _merge(paths, context), context)
85
+
86
+
87
+ async def abuild_resources(root: Path | Iterable[Path], registry: Mapping[FileType, Loader] | None = None) -> Resources:
88
+ """Same index, walked off the event loop for large trees."""
89
+ paths = tuple(map(_ensure_dir, _roots(root)))
90
+ context = _context(registry)
91
+ children = await asyncio.to_thread(_merge, paths, context)
92
+ return Resources(paths[0], children, context)
@@ -0,0 +1,77 @@
1
+ """pytest_resources.loaders: pluggable decoders, e-serde preferred, stdlib fallback.
2
+
3
+ The system accepts any canonical ``bytes -> object`` loader. ``default_loaders``
4
+ returns the best table available: e-serde's unified native codecs when installed,
5
+ otherwise the standard library (json + tomllib). Kinds with no parser hand back raw
6
+ bytes for the caller to decode.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Callable
13
+ from functools import partial
14
+ from tomllib import loads as _toml_loads
15
+ from typing import Any
16
+
17
+ from pytest_resources.formats import FileType
18
+
19
+ Loader = Callable[[bytes], Any]
20
+
21
+
22
+ def _raw(data: bytes) -> bytes:
23
+ """Passthrough for kinds with no parser bound."""
24
+ return data
25
+
26
+
27
+ def _text(data: bytes) -> str:
28
+ """UTF-8 decode for textual kinds."""
29
+ return data.decode()
30
+
31
+
32
+ def _toml(data: bytes) -> dict[str, Any]:
33
+ """TOML decode through the standard-library reader."""
34
+ return _toml_loads(data.decode())
35
+
36
+
37
+ def _lines(loader: Loader, data: bytes) -> list[Any]:
38
+ """Split into non-empty lines and decode each with `loader`."""
39
+ return [loader(line) for line in data.splitlines() if line.strip()]
40
+
41
+
42
+ def _eserde_loaders() -> dict[FileType, Loader]:
43
+ """Config-codec table backed by e-serde (native Rust/C decoders, native dicts out)."""
44
+ from eserde import Format, loads # noqa: PLC0415 -- optional fast path, not a core dep
45
+
46
+ json_row = partial(loads, format=Format.JSON)
47
+ return {
48
+ FileType.JSON: json_row,
49
+ FileType.JSONC: partial(loads, format=Format.JSONC),
50
+ FileType.YAML: partial(loads, format=Format.YAML),
51
+ FileType.TOML: partial(loads, format=Format.TOML),
52
+ FileType.INI: partial(loads, format=Format.INI),
53
+ FileType.NDJSON: partial(_lines, json_row),
54
+ FileType.MARKDOWN: _text,
55
+ FileType.TEXT: _text,
56
+ }
57
+
58
+
59
+ def _stdlib_loaders() -> dict[FileType, Loader]:
60
+ """Config-codec table from the standard library alone (json + tomllib)."""
61
+ return {
62
+ FileType.JSON: json.loads,
63
+ FileType.TOML: _toml,
64
+ FileType.NDJSON: partial(_lines, json.loads),
65
+ FileType.MARKDOWN: _text,
66
+ FileType.TEXT: _text,
67
+ }
68
+
69
+
70
+ def default_loaders() -> dict[FileType, Loader]:
71
+ """A fresh codec table: e-serde when importable, else the stdlib; other kinds stay bytes."""
72
+ table: dict[FileType, Loader] = dict.fromkeys(FileType, _raw)
73
+ try:
74
+ table |= _eserde_loaders()
75
+ except ImportError:
76
+ table |= _stdlib_loaders()
77
+ return table
@@ -0,0 +1,206 @@
1
+ """pytest_resources.nodes: the lazy, attribute-navigable tree over indexed files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import fnmatch
7
+ import random
8
+ import re
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from pytest_resources.errors import EntryNotFoundError, ResourceError
13
+ from pytest_resources.formats import FileType
14
+ from pytest_resources.loaders import _raw
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import AsyncIterator, Iterator, Mapping
18
+ from re import Pattern
19
+ from typing import Any
20
+
21
+ from pytest_resources.loaders import Loader
22
+
23
+ _MISSING = object()
24
+
25
+ ##### PRIVATE #####
26
+
27
+
28
+ def _matcher(pattern: str | Pattern[str]) -> Pattern[str]:
29
+ """A glob string becomes an anchored regex over the name; a compiled pattern is taken as-is."""
30
+ return pattern if isinstance(pattern, re.Pattern) else re.compile(fnmatch.translate(pattern))
31
+
32
+
33
+ def _accept(path: Path, kind: FileType | None, matchers: tuple[Pattern[str], ...]) -> bool:
34
+ """A file passes when its canonical kind matches and any matcher hits its name."""
35
+ right_kind = kind is None or FileType.of_path(path) is kind
36
+ return right_kind and (not matchers or any(m.search(path.name) for m in matchers))
37
+
38
+
39
+ ############################################################
40
+
41
+
42
+ class _Context:
43
+ """Loader registry and decode cache shared by every node of one indexed tree."""
44
+
45
+ __slots__ = ("_cache", "_registry")
46
+
47
+ def __init__(self, registry: Mapping[FileType, Loader]) -> None:
48
+ self._registry = registry
49
+ self._cache: dict[Path, Any] = {}
50
+
51
+ def load(self, path: Path) -> Any:
52
+ cached = self._cache.get(path, _MISSING)
53
+ return cached if cached is not _MISSING else self._decode(path)
54
+
55
+ def _decode(self, path: Path) -> Any:
56
+ ### The FileType decides the codec; an unbound kind hands back raw bytes.
57
+ loaded = self._registry.get(FileType.of_path(path), _raw)(path.read_bytes())
58
+ self._cache[path] = loaded
59
+ return loaded
60
+
61
+
62
+ class ResourceNode:
63
+ """A lazily resolved directory: attribute, item and iteration views agree."""
64
+
65
+ __slots__ = ("_context", "_entries", "_path")
66
+
67
+ def __init__(self, path: Path, entries: dict[str, Path | ResourceNode], context: _Context) -> None:
68
+ self._path = path
69
+ self._entries = entries
70
+ self._context = context
71
+
72
+ ##### PRIVATE #####
73
+
74
+ def _value(self, entry: object) -> Any:
75
+ match entry:
76
+ case Path() as file:
77
+ return self._context.load(file)
78
+ case _:
79
+ return entry
80
+
81
+ def _absent(self, name: str) -> str:
82
+ ### One message for both miss paths; difflib nudges the nearest name, if any.
83
+ hint = difflib.get_close_matches(name, self._entries, n=1)
84
+ return f"{self._path.name!r} has no entry {name!r}" + (f" — did you mean {hint[0]!r}?" if hint else "")
85
+
86
+ def _files(self, patterns: tuple[str | Pattern[str], ...], kind: FileType | None) -> list[Path]:
87
+ matchers = tuple(map(_matcher, patterns))
88
+ return [file for file in self._entries.values() if isinstance(file, Path) and _accept(file, kind, matchers)]
89
+
90
+ def _walk(self, matchers: tuple[Pattern[str], ...], kind: FileType | None) -> Iterator[Path]:
91
+ for entry in self._entries.values():
92
+ if isinstance(entry, ResourceNode):
93
+ yield from entry._walk(matchers, kind)
94
+ if isinstance(entry, Path) and _accept(entry, kind, matchers):
95
+ yield entry
96
+
97
+ async def _awalk(self, matchers: tuple[Pattern[str], ...], kind: FileType | None) -> AsyncIterator[Path]:
98
+ for entry in self._entries.values():
99
+ if isinstance(entry, ResourceNode):
100
+ async for path in entry._awalk(matchers, kind):
101
+ yield path
102
+ if isinstance(entry, Path) and _accept(entry, kind, matchers):
103
+ yield entry
104
+
105
+ ############################################################
106
+
107
+ ##### PUBLIC #####
108
+
109
+ @property
110
+ def path(self) -> Path:
111
+ """Absolute path this node indexes."""
112
+ return self._path
113
+
114
+ def keys(self) -> list[str]:
115
+ """Every entry name, files and directories alike."""
116
+ return list(self._entries)
117
+
118
+ def values(self) -> list[Any]:
119
+ """Every file entry parsed; directories stay out of the value stream."""
120
+ return self.select()
121
+
122
+ def items(self) -> list[tuple[str, Any]]:
123
+ """``(name, resolved)`` pairs: files parsed, directories as nodes."""
124
+ return [(name, self._value(entry)) for name, entry in self._entries.items()]
125
+
126
+ def as_dict(self) -> dict[str, Any]:
127
+ """Plain nested ``dict`` of the subtree: folders recurse, files are decoded values."""
128
+ return {
129
+ name: child.as_dict() if isinstance(child, ResourceNode) else self._context.load(child)
130
+ for name, child in self._entries.items()
131
+ }
132
+
133
+ def select(self, *patterns: str | Pattern[str], kind: FileType | None = None) -> list[Any]:
134
+ """Decoded values of files in this folder, narrowed by glob/regex and optional kind."""
135
+ return [self._context.load(file) for file in self._files(patterns, kind)]
136
+
137
+ def first(self, *patterns: str | Pattern[str], kind: FileType | None = None) -> Any:
138
+ """The first matching file value in name order (deterministic twin of ``choice``)."""
139
+ files = self._files(patterns, kind)
140
+ if not files:
141
+ msg = f"{self._path.name!r} has no matching file"
142
+ raise ResourceError(msg)
143
+ return self._context.load(files[0])
144
+
145
+ def paths(self, *patterns: str | Pattern[str], kind: FileType | None = None) -> list[Path]:
146
+ """Paths of files in this folder under the same filters — for raw bytes or ``open()``."""
147
+ return self._files(patterns, kind)
148
+
149
+ def walk(self, *patterns: str | Pattern[str], kind: FileType | None = None) -> Iterator[Path]:
150
+ """Depth-first Paths of every file in this subtree passing the filters, lazily."""
151
+ return self._walk(tuple(map(_matcher, patterns)), kind)
152
+
153
+ def awalk(self, *patterns: str | Pattern[str], kind: FileType | None = None) -> AsyncIterator[Path]:
154
+ """Async-generator twin of ``walk`` for streaming a large tree with ``async for``."""
155
+ return self._awalk(tuple(map(_matcher, patterns)), kind)
156
+
157
+ def similar(self, term: str, kind: FileType | None = None, *, n: int = 3, cutoff: float = 0.6) -> list[Any]:
158
+ """Decoded values whose file stem is lexically close to ``term`` (best first)."""
159
+ by_stem: dict[str, list[Path]] = {}
160
+ for file in self._files((), kind):
161
+ by_stem.setdefault(file.stem, []).append(file)
162
+ hits = difflib.get_close_matches(term, by_stem, n=n, cutoff=cutoff)
163
+ return [self._context.load(file) for stem in hits for file in by_stem[stem]]
164
+
165
+ def choice(
166
+ self,
167
+ *patterns: str | Pattern[str],
168
+ kind: FileType | None = None,
169
+ rng: random.Random | None = None,
170
+ ) -> Any:
171
+ """One random file value from the selection (default RNG honours the session seed)."""
172
+ candidates = self.select(*patterns, kind=kind)
173
+ if not candidates:
174
+ msg = f"{self._path.name!r} has no file to choose from"
175
+ raise ResourceError(msg)
176
+ return (rng or random).choice(candidates)
177
+
178
+ def __getattr__(self, name: str) -> Any:
179
+ entry = self._entries.get(name, _MISSING)
180
+ if entry is _MISSING:
181
+ raise EntryNotFoundError(self._absent(name))
182
+ return self._value(entry)
183
+
184
+ def __getitem__(self, name: str) -> Any:
185
+ entry = self._entries.get(name, _MISSING)
186
+ if entry is _MISSING:
187
+ raise KeyError(self._absent(name))
188
+ return self._value(entry)
189
+
190
+ def __iter__(self) -> Iterator[Any]:
191
+ return (self._context.load(entry) for entry in self._entries.values() if isinstance(entry, Path))
192
+
193
+ def __len__(self) -> int:
194
+ return len(self._entries)
195
+
196
+ def __contains__(self, name: object) -> bool:
197
+ return name in self._entries
198
+
199
+ def __repr__(self) -> str:
200
+ return f"{type(self).__name__}({self._path.name!r}, entries={len(self._entries)})"
201
+
202
+
203
+ class Resources(ResourceNode):
204
+ """Root of an indexed resources tree — the object the ``resources`` fixture yields."""
205
+
206
+ __slots__ = ()
@@ -0,0 +1,68 @@
1
+ """pytest_resources.plugin: the pytest entry point — options, hook wiring, fixture.
2
+
3
+ Auto-loaded through the ``pytest11`` entry point. It exposes the ``resources``
4
+ fixture and the extension seams (``--resources-root``/``resources_root`` ini and the
5
+ ``pytest_resource_loaders`` / ``pytest_resources_roots`` hooks).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import pytest
14
+
15
+ from pytest_resources import hooks
16
+ from pytest_resources.index import build_resources
17
+ from pytest_resources.loaders import default_loaders
18
+
19
+ if TYPE_CHECKING:
20
+ from pytest_resources.nodes import Resources
21
+
22
+
23
+ def pytest_addoption(parser: Any) -> None:
24
+ """Register the resources roots as a repeatable CLI option and an ini list."""
25
+ group = parser.getgroup("resources", "lazy typed resource fixtures")
26
+ parser.addini("resources_root", "Directories indexed by the `resources` fixture.", type="linelist")
27
+ group.addoption(
28
+ "--resources-root",
29
+ dest="resources_roots",
30
+ action="append",
31
+ default=None,
32
+ metavar="DIR",
33
+ help="Add a resources root directory (repeatable). CLI wins over the ini list.",
34
+ )
35
+
36
+
37
+ def pytest_configure(config: Any) -> None:
38
+ """Publish the loader- and root-extension hookspecs so conftests may implement them."""
39
+ config.pluginmanager.add_hookspecs(hooks)
40
+
41
+
42
+ ##### PRIVATE #####
43
+
44
+
45
+ def _resolve_roots(config: Any) -> list[Path]:
46
+ ### CLI > ini > default; each source is a list, the first non-empty one is the base.
47
+ chosen = config.getoption("resources_roots") or config.getini("resources_root") or []
48
+ if chosen:
49
+ return [Path(entry) for entry in chosen]
50
+ return [config.rootpath / "tests" / "resources"]
51
+
52
+
53
+ ############################################################
54
+
55
+ ##### FIXTURES #####
56
+
57
+
58
+ @pytest.fixture(scope="session")
59
+ def resources(request: Any) -> Resources:
60
+ """The session's indexed resources tree: default + hook roots, default + hook loaders."""
61
+ config = request.config
62
+ registry = default_loaders()
63
+ config.pluginmanager.hook.pytest_resource_loaders(register=registry.update)
64
+ roots = _resolve_roots(config)
65
+ config.pluginmanager.hook.pytest_resources_roots(roots=roots)
66
+ for root in roots:
67
+ root.mkdir(parents=True, exist_ok=True)
68
+ return build_resources(roots, registry)
@@ -0,0 +1 @@
1
+ # Marker file indicating the package ships PEP 561 type information.
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytest-resources
3
+ Version: 0.1.0
4
+ Summary: Lazy, typed, attribute-navigable fixtures for test resource files, with pluggable canonical loaders (e-serde by default).
5
+ Project-URL: Homepage, https://github.com/damvolkov/pytest-resources
6
+ Project-URL: Source, https://github.com/damvolkov/pytest-resources
7
+ Project-URL: Issues, https://github.com/damvolkov/pytest-resources/issues
8
+ Project-URL: Changelog, https://github.com/damvolkov/pytest-resources/blob/main/CHANGELOG.md
9
+ Author-email: damvolkov <damvolkovv@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: config,e-serde,fixtures,json,pytest,pytest-plugin,resources,testing,toml,yaml
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: Pytest
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.14
22
+ Requires-Dist: pytest>=8.0
23
+ Provides-Extra: serde
24
+ Requires-Dist: e-serde<0.3,>=0.2.0; extra == 'serde'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # pytest-resources
28
+
29
+ Lazy, typed, attribute-navigable fixtures for test resource files — JSON / JSONC / YAML /
30
+ TOML / INI decoded through [`e-serde`](https://pypi.org/project/e-serde/) by default,
31
+ extensible to **any** canonical `bytes -> object` loader, and anything else handed back as
32
+ raw `bytes`.
33
+
34
+ You point it at one or more directories of sample files; it recursively indexes the tree
35
+ without reading anything, and you reach, filter and randomise parsed values the way they
36
+ read:
37
+
38
+ ```python
39
+ import pytest_resources as pr
40
+
41
+ def test_profile(resources: pr.Resources):
42
+ user = resources.structured.user # sample dir -> dict (decoded, cached)
43
+ body = resources.unstructured.intro # a .md -> str
44
+ blob = resources.binary.logo # no codec bound -> raw bytes
45
+
46
+ jsons = resources.data.select("*.json") # filter a folder (glob / regex / kind)
47
+ pick = resources.data.choice("*.json") # random pick, seeded per run
48
+ ```
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ uv add --group test "pytest-resources[serde]" # e-serde default codecs
54
+ uv add --group test pytest-resources # stdlib-only (json + tomllib)
55
+ ```
56
+
57
+ The `resources` fixture auto-loads via the `pytest11` entry point — no imports in your
58
+ `conftest.py`.
59
+
60
+ ## What you get
61
+
62
+ | Idea | Mechanism |
63
+ |---|---|
64
+ | **Recursive index, zero read** | each root is walked once (off the loop); only paths are held |
65
+ | **Lazy + cached** | a file decodes on first access and is memoized for the session |
66
+ | **Attr / item / iter views** | `r.a.b`, `r["a"]["b"]`, `list(r.a)` → list of decoded values |
67
+ | **Extract from a folder** | `select`/`first` · `paths` · `walk`/`awalk` (sync/async, lazy) · `similar` (fuzzy) · `as_dict`, by glob / regex / `kind` |
68
+ | **Randomise the pick** | `r.a.choice(...)` (session-seeded) or pass your own `rng` |
69
+ | **Typed by suffix** | `FileType` (`StrEnum`) resolves the extension; the loader table decodes |
70
+ | **Open-ended fallback** | `CSV`/`TSV`/`PDF`/… and any unknown kind stay raw `bytes` |
71
+ | **Many roots, one tree** | CLI / ini / `pytest_resources_roots` hook, merged (later wins), auto-created |
72
+ | **Pluggable codecs** | override or add any kind through the `pytest_resource_loaders` hook |
73
+ | **e-serde default, optional** | native Rust/C config codecs when installed; stdlib otherwise |
74
+ | **Friendly misses** | `EntryNotFoundError` / `KeyError` carry a "did you mean" hint |
75
+
76
+ ## Configuring the roots
77
+
78
+ By default the plugin indexes `<rootdir>/tests/resources` (and **creates it if missing**).
79
+ Point it at one or several directories:
80
+
81
+ ```toml
82
+ # pyproject.toml
83
+ [tool.pytest.ini_options]
84
+ resources_root = ["fixtures", "fixtures/shared"] # default: <rootdir>/tests/resources
85
+ ```
86
+
87
+ Or per run (`--resources-root` is repeatable), or additively from your `conftest.py`:
88
+
89
+ ```python
90
+ from pathlib import Path
91
+
92
+ def pytest_resources_roots(roots):
93
+ roots.append(Path("tests/fixtures/shared"))
94
+ ```
95
+
96
+ CLI wins over ini, which wins over the default. Every resolved root is merged into one
97
+ navigable tree; on a top-level clash the **later** root wins.
98
+
99
+ ## Custom loaders (the extension seam)
100
+
101
+ The plugin never assumes e-serde. Bind any parser declaratively in your `conftest.py`:
102
+
103
+ ```python
104
+ from pytest_resources import FileType
105
+ import yaml, csv, io
106
+
107
+ def pytest_resource_loaders(register):
108
+ register({FileType.CSV: lambda b: list(csv.DictReader(io.StringIO(b.decode())))})
109
+ register({FileType.YAML: yaml.safe_load})
110
+ ```
111
+
112
+ `register` merges into the session's table, so you can override a default kind as easily
113
+ as add an unknown one. Kinds left unbound hand back **raw bytes**.
114
+
115
+ ## Programmatic use
116
+
117
+ Outside pytest, build a tree yourself:
118
+
119
+ ```python
120
+ from pathlib import Path
121
+ import pytest_resources as pr
122
+
123
+ tree = pr.build_resources(Path("fixtures")) # sync, one root
124
+ tree = pr.build_resources([Path("a"), Path("b")]) # merged roots, later wins
125
+ tree = await pr.abuild_resources(Path("fixtures")) # off the event loop
126
+ table = pr.default_loaders() # best available codecs
127
+ ```
128
+
129
+ ## Development
130
+
131
+ ```bash
132
+ make install # uv sync + git hooks
133
+ make check # ruff + ty + tach + validate + tests
134
+ make ci # everything the pipeline runs (adds coverage gate + docs + zizmor)
135
+ ```
136
+
137
+ ## License
138
+
139
+ MIT
@@ -0,0 +1,14 @@
1
+ pytest_resources/__init__.py,sha256=hyMQFQ8_nl6lqARmhrlLZh-TeiDcg292Mqu1dq3RdyA,1243
2
+ pytest_resources/errors.py,sha256=8ztkYQwSnEzoEt79zyy7jQ4NL9mTcVbrdi_QoAEpkE4,445
3
+ pytest_resources/formats.py,sha256=J3MHZJlETlemzgMpDD4vOLcMuBkPPWFZjoAXUjH8Y5E,1801
4
+ pytest_resources/hooks.py,sha256=wfG_4FAJ93KjT6Yla4Ao-MuwkCVWyLpnWgxuFrJ8iK4,1443
5
+ pytest_resources/index.py,sha256=331rwIl3nfk1KywssQ47SeO0jT0_CsWSVOVrDbZSSd0,3105
6
+ pytest_resources/loaders.py,sha256=VyGr5upGnTPntIeIt1MDFu-FE0RrNL3qtKrV_Vi945A,2525
7
+ pytest_resources/nodes.py,sha256=hDzgHqYL_bKQETUr25PMtAnsqYXbri3cHsmJ0rzaIbw,8333
8
+ pytest_resources/plugin.py,sha256=cTQhBnyeh6CB5ZTiLXog9g18vi2OaBtMhr-xON854Uw,2357
9
+ pytest_resources/py.typed,sha256=_PWtaTGz6Go8JPgNcU0NeRu6NuAC329-7awL4h_zVVM,69
10
+ pytest_resources-0.1.0.dist-info/METADATA,sha256=_KgAlNzRf5gEgacmsCQlQv21Us-j6Layh_X_qrbmPYI,5486
11
+ pytest_resources-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
12
+ pytest_resources-0.1.0.dist-info/entry_points.txt,sha256=3Cw2AUax2cq4b0aJRj-JNeUxEPZsEnRPSnTOhlAwJ3U,47
13
+ pytest_resources-0.1.0.dist-info/licenses/LICENSE,sha256=4K5VRrESrMWeDdFrydaJe4ZtvcVCfF3FSMdxK6893cE,1070
14
+ pytest_resources-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ resources = pytest_resources.plugin
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Damien Volkov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.