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.
- pytest_resources/__init__.py +36 -0
- pytest_resources/errors.py +13 -0
- pytest_resources/formats.py +67 -0
- pytest_resources/hooks.py +44 -0
- pytest_resources/index.py +92 -0
- pytest_resources/loaders.py +77 -0
- pytest_resources/nodes.py +206 -0
- pytest_resources/plugin.py +68 -0
- pytest_resources/py.typed +1 -0
- pytest_resources-0.1.0.dist-info/METADATA +139 -0
- pytest_resources-0.1.0.dist-info/RECORD +14 -0
- pytest_resources-0.1.0.dist-info/WHEEL +4 -0
- pytest_resources-0.1.0.dist-info/entry_points.txt +2 -0
- pytest_resources-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|