pytest-probatio 0.2.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_probatio/__init__.py +7 -0
- pytest_probatio/plugin.py +149 -0
- pytest_probatio/py.typed +0 -0
- pytest_probatio-0.2.0.dist-info/METADATA +62 -0
- pytest_probatio-0.2.0.dist-info/RECORD +8 -0
- pytest_probatio-0.2.0.dist-info/WHEEL +4 -0
- pytest_probatio-0.2.0.dist-info/entry_points.txt +2 -0
- pytest_probatio-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Use a probatio schema as a pytest assertion matcher.
|
|
2
|
+
|
|
3
|
+
``assert response == Exact({"name": str, "port": Port()})`` validates ``response``
|
|
4
|
+
against the schema. On a mismatch, pytest's assertion rewriting calls the
|
|
5
|
+
``pytest_assertrepr_compare`` hook below, which renders each probatio error by its
|
|
6
|
+
path, so the failure points at the exact offending value instead of a bare
|
|
7
|
+
``assert ... == ...``.
|
|
8
|
+
|
|
9
|
+
Two matchers are exposed:
|
|
10
|
+
|
|
11
|
+
- ``Exact(schema)``: extra dict keys make it unequal; ``<=`` relaxes that to a
|
|
12
|
+
partial match (extra keys allowed).
|
|
13
|
+
- ``Partial(schema)``: a partial match under ``==`` (extra keys allowed).
|
|
14
|
+
|
|
15
|
+
This lives outside the ``probatio`` package on purpose: the core library is
|
|
16
|
+
dependency-free, and a pytest plugin is a test-framework concern.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import typing
|
|
22
|
+
|
|
23
|
+
from probatio import ALLOW_EXTRA, PREVENT_EXTRA, Invalid, MultipleInvalid, Schema
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _compile(schema: typing.Any, extra: int) -> Schema:
|
|
27
|
+
"""Return ``schema`` as a ``Schema`` with the given extra-key policy.
|
|
28
|
+
|
|
29
|
+
A ready-made ``Schema`` (you can build one once and reuse it across tests) is
|
|
30
|
+
rebuilt with the requested policy, its underlying schema and ``required`` flag
|
|
31
|
+
preserved, so ``Exact`` and ``Partial`` control extra keys the same way whether
|
|
32
|
+
they are given a raw shape or a ``Schema``.
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(schema, Schema):
|
|
35
|
+
return Schema(schema.schema, required=schema.required, extra=extra)
|
|
36
|
+
return Schema(schema, extra=extra)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Matcher:
|
|
40
|
+
"""A schema that compares equal to data validating against it.
|
|
41
|
+
|
|
42
|
+
``__eq__`` runs the schema and records the errors, so the assertion hook can
|
|
43
|
+
show them. It is not hashable (the recorded errors make it mutable), which also
|
|
44
|
+
keeps it out of places that expect a stable hash.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("_errors", "_loose", "_strict", "_strict_eq")
|
|
48
|
+
|
|
49
|
+
def __init__(self, schema: typing.Any, *, partial: bool) -> None:
|
|
50
|
+
"""Build the strict and partial schemas; ``partial`` picks which ``==`` uses."""
|
|
51
|
+
self._strict = _compile(schema, PREVENT_EXTRA)
|
|
52
|
+
self._loose = _compile(schema, ALLOW_EXTRA)
|
|
53
|
+
# ``Partial`` validates loosely under ``==``; ``Exact`` validates strictly.
|
|
54
|
+
self._strict_eq = not partial
|
|
55
|
+
self._errors: list[Invalid] = []
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def errors(self) -> list[Invalid]:
|
|
59
|
+
"""The probatio errors from the most recent comparison (empty if it matched)."""
|
|
60
|
+
return self._errors
|
|
61
|
+
|
|
62
|
+
def _run(self, data: typing.Any, schema: Schema) -> bool:
|
|
63
|
+
"""Validate ``data``, recording any errors, returning whether it matched."""
|
|
64
|
+
try:
|
|
65
|
+
schema(data)
|
|
66
|
+
except MultipleInvalid as exc:
|
|
67
|
+
self._errors = list(exc.errors)
|
|
68
|
+
return False
|
|
69
|
+
except Invalid as exc:
|
|
70
|
+
self._errors = [exc]
|
|
71
|
+
return False
|
|
72
|
+
self._errors = []
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def __eq__(self, data: object) -> bool:
|
|
76
|
+
"""Whether ``data`` validates (strictly for ``Exact``, loosely for ``Partial``)."""
|
|
77
|
+
return self._run(data, self._strict if self._strict_eq else self._loose)
|
|
78
|
+
|
|
79
|
+
def __ne__(self, data: object) -> bool:
|
|
80
|
+
"""Return the negation of ``__eq__``."""
|
|
81
|
+
return not self.__eq__(data)
|
|
82
|
+
|
|
83
|
+
def __le__(self, data: object) -> bool:
|
|
84
|
+
"""Return a partial match: ``data`` validates with extra keys allowed."""
|
|
85
|
+
return self._run(data, self._loose)
|
|
86
|
+
|
|
87
|
+
def __ge__(self, data: object) -> bool:
|
|
88
|
+
"""Support the reversed ``data <= matcher`` form (also a partial match)."""
|
|
89
|
+
return self._run(data, self._loose)
|
|
90
|
+
|
|
91
|
+
__hash__ = None # type: ignore[assignment]
|
|
92
|
+
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
"""Render with the underlying schema, so a failing assert reads clearly."""
|
|
95
|
+
return f"{type(self).__name__}({self._strict.schema!r})"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Exact(_Matcher):
|
|
99
|
+
"""A schema matcher: ``==`` requires an exact match, ``<=`` a partial one.
|
|
100
|
+
|
|
101
|
+
Exact means an extra dictionary key makes the data unequal; ``<=`` relaxes
|
|
102
|
+
that to allow extra keys, the same as ``Partial`` under ``==``.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, schema: typing.Any) -> None:
|
|
106
|
+
"""Wrap ``schema`` as an exact-by-equality matcher."""
|
|
107
|
+
super().__init__(schema, partial=False)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Partial(_Matcher):
|
|
111
|
+
"""A schema matcher whose ``==`` allows extra keys (a partial match)."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, schema: typing.Any) -> None:
|
|
114
|
+
"""Wrap ``schema`` as a partial-by-equality matcher."""
|
|
115
|
+
super().__init__(schema, partial=True)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _format_path(path: list[typing.Any]) -> str:
|
|
119
|
+
"""Render an error path as ``data['a'][0]``, or ``<root>`` when empty."""
|
|
120
|
+
if not path:
|
|
121
|
+
return "<root>"
|
|
122
|
+
return "data" + "".join(f"[{segment!r}]" for segment in path)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def pytest_assertrepr_compare(
|
|
126
|
+
op: str,
|
|
127
|
+
left: object,
|
|
128
|
+
right: object,
|
|
129
|
+
) -> list[str] | None:
|
|
130
|
+
"""Explain a failed schema comparison by listing each error with its path."""
|
|
131
|
+
if op not in ("==", "!=", "<=", ">="):
|
|
132
|
+
return None
|
|
133
|
+
if isinstance(left, _Matcher):
|
|
134
|
+
matcher: _Matcher = left
|
|
135
|
+
elif isinstance(right, _Matcher):
|
|
136
|
+
matcher = right
|
|
137
|
+
else:
|
|
138
|
+
return None
|
|
139
|
+
if not matcher.errors:
|
|
140
|
+
# A negated comparison (``!=``) that failed because the data *did* match.
|
|
141
|
+
return [
|
|
142
|
+
f"data matches the probatio schema, but the assertion ({op}) required it not to"
|
|
143
|
+
]
|
|
144
|
+
lines = [f"data does not match the probatio schema ({op}):"]
|
|
145
|
+
lines.extend(
|
|
146
|
+
f" {_format_path(error.path)}: {error.error_message or str(error)}"
|
|
147
|
+
for error in matcher.errors
|
|
148
|
+
)
|
|
149
|
+
return lines
|
pytest_probatio/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-probatio
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Use a probatio schema as a pytest assertion matcher
|
|
5
|
+
Project-URL: Homepage, https://github.com/frenck/probatio
|
|
6
|
+
Project-URL: Repository, https://github.com/frenck/probatio
|
|
7
|
+
Project-URL: Issues, https://github.com/frenck/probatio/issues
|
|
8
|
+
Author-email: Franck Nijhof <opensource@frenck.dev>
|
|
9
|
+
Maintainer-email: Franck Nijhof <opensource@frenck.dev>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: probatio,pytest,schema,testing,validation
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: Pytest
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.13
|
|
24
|
+
Requires-Dist: probatio>=0.1.0
|
|
25
|
+
Requires-Dist: pytest>=8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# pytest-probatio
|
|
29
|
+
|
|
30
|
+
Use a [probatio](https://github.com/frenck/probatio) schema as a pytest assertion
|
|
31
|
+
matcher. A schema reads as the expected shape, and a mismatch is explained by
|
|
32
|
+
probatio's path-precise errors instead of a bare `assert`.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from pytest_probatio import Exact, Partial
|
|
36
|
+
from probatio import Port
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_response(response):
|
|
40
|
+
# Exact: extra keys make it unequal.
|
|
41
|
+
assert response == Exact({"name": str, "port": Port()})
|
|
42
|
+
|
|
43
|
+
# Partial: extra keys are allowed.
|
|
44
|
+
assert response == Partial({"name": str})
|
|
45
|
+
assert Exact({"name": str}) <= response
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
When the data does not match, the failure lists each error by its path:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
data does not match the probatio schema (==):
|
|
52
|
+
data['port']: expected a port number between 1 and 65535
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Install it alongside pytest; the plugin registers itself:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install pytest-probatio
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This package lives in the probatio monorepo but ships separately, so the core
|
|
62
|
+
`probatio` library stays dependency-free.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pytest_probatio/__init__.py,sha256=s-S0nBcMm4wXs81cn9IYHFP6MNJPACmPjKPozAgTxlc,195
|
|
2
|
+
pytest_probatio/plugin.py,sha256=mXNR0lG5OQC_JKhWioUqg7FzCEKgmayL7v1_1yGGyu4,5600
|
|
3
|
+
pytest_probatio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
pytest_probatio-0.2.0.dist-info/METADATA,sha256=qKB2-ADAgfIueEMcURO26jYX4-7wQsecIkQ1_qX-UQg,2069
|
|
5
|
+
pytest_probatio-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
6
|
+
pytest_probatio-0.2.0.dist-info/entry_points.txt,sha256=-3C_rZOBSZPp9mqG-exojMsjiZTYDFgqkjn4naPq9oQ,45
|
|
7
|
+
pytest_probatio-0.2.0.dist-info/licenses/LICENSE,sha256=xey_9Ex7UIJ45DSFo_Gzo0HN_fsyUJ6ANF2SKQDwaJA,1070
|
|
8
|
+
pytest_probatio-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Franck Nijhof
|
|
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.
|