pytest-probatio 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_probatio-0.2.0/.gitignore +80 -0
- pytest_probatio-0.2.0/LICENSE +21 -0
- pytest_probatio-0.2.0/PKG-INFO +62 -0
- pytest_probatio-0.2.0/README.md +35 -0
- pytest_probatio-0.2.0/pyproject.toml +57 -0
- pytest_probatio-0.2.0/src/pytest_probatio/__init__.py +7 -0
- pytest_probatio-0.2.0/src/pytest_probatio/plugin.py +149 -0
- pytest_probatio-0.2.0/src/pytest_probatio/py.typed +0 -0
- pytest_probatio-0.2.0/tests/conftest.py +3 -0
- pytest_probatio-0.2.0/tests/test_matchers.py +121 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# --- Python ---
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg
|
|
6
|
+
*.egg-info/
|
|
7
|
+
.eggs/
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
sdist/
|
|
11
|
+
.Python
|
|
12
|
+
.installed.cfg
|
|
13
|
+
|
|
14
|
+
# --- Python: tooling caches and coverage ---
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
.ty/
|
|
19
|
+
.hypothesis/
|
|
20
|
+
.cache/
|
|
21
|
+
.codspeed/
|
|
22
|
+
.coverage
|
|
23
|
+
.coverage.*
|
|
24
|
+
coverage.xml
|
|
25
|
+
htmlcov/
|
|
26
|
+
.tox/
|
|
27
|
+
.python-version
|
|
28
|
+
|
|
29
|
+
# --- Virtual environments ---
|
|
30
|
+
.venv/
|
|
31
|
+
.venv-*/
|
|
32
|
+
venv/
|
|
33
|
+
ENV/
|
|
34
|
+
env/
|
|
35
|
+
|
|
36
|
+
# --- C extensions ---
|
|
37
|
+
*.so
|
|
38
|
+
*.pyd
|
|
39
|
+
|
|
40
|
+
# --- Documentation site (Node / Astro) ---
|
|
41
|
+
node_modules/
|
|
42
|
+
.astro/
|
|
43
|
+
docs/dist/
|
|
44
|
+
|
|
45
|
+
# --- Tooling and agents ---
|
|
46
|
+
.claude/
|
|
47
|
+
.dccache
|
|
48
|
+
|
|
49
|
+
# --- OS cruft ---
|
|
50
|
+
.DS_Store
|
|
51
|
+
._*
|
|
52
|
+
.AppleDouble
|
|
53
|
+
.LSOverride
|
|
54
|
+
.Spotlight-V100
|
|
55
|
+
.Trashes
|
|
56
|
+
Thumbs.db
|
|
57
|
+
ehthumbs.db
|
|
58
|
+
Desktop.ini
|
|
59
|
+
|
|
60
|
+
# --- Editors ---
|
|
61
|
+
.vscode/
|
|
62
|
+
.idea/
|
|
63
|
+
*.iml
|
|
64
|
+
*.swp
|
|
65
|
+
*~
|
|
66
|
+
|
|
67
|
+
# --- Secrets and stray files ---
|
|
68
|
+
.env
|
|
69
|
+
*.orig
|
|
70
|
+
*.rej
|
|
71
|
+
|
|
72
|
+
# --- Fuzzing crash artifacts (libFuzzer/atheris) ---
|
|
73
|
+
crash-*
|
|
74
|
+
oom-*
|
|
75
|
+
timeout-*
|
|
76
|
+
leak-*
|
|
77
|
+
|
|
78
|
+
# voluptuous's own test suite, copied in for the drop-in proof (BSD-licensed,
|
|
79
|
+
# not vendored). See compat/voluptuous/README.md.
|
|
80
|
+
compat/voluptuous/tests.py
|
|
@@ -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.
|
|
@@ -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,35 @@
|
|
|
1
|
+
# pytest-probatio
|
|
2
|
+
|
|
3
|
+
Use a [probatio](https://github.com/frenck/probatio) schema as a pytest assertion
|
|
4
|
+
matcher. A schema reads as the expected shape, and a mismatch is explained by
|
|
5
|
+
probatio's path-precise errors instead of a bare `assert`.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from pytest_probatio import Exact, Partial
|
|
9
|
+
from probatio import Port
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_response(response):
|
|
13
|
+
# Exact: extra keys make it unequal.
|
|
14
|
+
assert response == Exact({"name": str, "port": Port()})
|
|
15
|
+
|
|
16
|
+
# Partial: extra keys are allowed.
|
|
17
|
+
assert response == Partial({"name": str})
|
|
18
|
+
assert Exact({"name": str}) <= response
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
When the data does not match, the failure lists each error by its path:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
data does not match the probatio schema (==):
|
|
25
|
+
data['port']: expected a port number between 1 and 65535
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Install it alongside pytest; the plugin registers itself:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install pytest-probatio
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This package lives in the probatio monorepo but ships separately, so the core
|
|
35
|
+
`probatio` library stays dependency-free.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pytest-probatio"
|
|
7
|
+
description = "Use a probatio schema as a pytest assertion matcher"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Franck Nijhof", email = "opensource@frenck.dev" }]
|
|
13
|
+
maintainers = [{ name = "Franck Nijhof", email = "opensource@frenck.dev" }]
|
|
14
|
+
keywords = ["pytest", "probatio", "validation", "schema", "testing"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Framework :: Pytest",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Software Development :: Testing",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
# Stays 0.0.0 in development; the release workflow stamps the real version from
|
|
28
|
+
# the git tag at publish time, the same as the core package.
|
|
29
|
+
version = "0.2.0"
|
|
30
|
+
# Released lock-step with probatio (same version), but the matcher only uses
|
|
31
|
+
# probatio's stable public surface, so a floor is enough rather than an exact pin.
|
|
32
|
+
dependencies = ["probatio>=0.1.0", "pytest>=8"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/frenck/probatio"
|
|
36
|
+
Repository = "https://github.com/frenck/probatio"
|
|
37
|
+
Issues = "https://github.com/frenck/probatio/issues"
|
|
38
|
+
|
|
39
|
+
# Register the plugin so pytest loads its assertion-explaining hook automatically.
|
|
40
|
+
[project.entry-points.pytest11]
|
|
41
|
+
probatio = "pytest_probatio.plugin"
|
|
42
|
+
|
|
43
|
+
[tool.uv.sources]
|
|
44
|
+
# Always build against the in-repo core, not a published release.
|
|
45
|
+
probatio = { workspace = true }
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/pytest_probatio"]
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.sdist]
|
|
51
|
+
include = ["src/pytest_probatio", "tests", "pyproject.toml", "README.md", "LICENSE"]
|
|
52
|
+
|
|
53
|
+
# Self-contained test config: this package is not held to the core's 100%-coverage
|
|
54
|
+
# gate, so it runs its own plain pytest rather than inheriting the root addopts.
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
addopts = "-q"
|
|
@@ -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
|
|
File without changes
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Tests for the probatio schema matchers and the assertion-explaining hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pytest_probatio import Exact, Partial
|
|
6
|
+
from pytest_probatio.plugin import pytest_assertrepr_compare
|
|
7
|
+
|
|
8
|
+
from probatio import Port
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_strict_match_passes() -> None:
|
|
12
|
+
"""A value matching the schema exactly compares equal."""
|
|
13
|
+
assert {"name": "app", "port": 8080} == Exact({"name": str, "port": Port()})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_strict_match_rejects_extra_keys() -> None:
|
|
17
|
+
"""An extra key makes an Exact matcher unequal."""
|
|
18
|
+
assert {"name": "app", "extra": 1} != Exact({"name": str})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_strict_match_rejects_a_bad_value() -> None:
|
|
22
|
+
"""A value of the wrong type compares unequal."""
|
|
23
|
+
assert {"port": "nope"} != Exact({"port": Port()})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_partial_allows_extra_keys() -> None:
|
|
27
|
+
"""Partial accepts extra keys under ==."""
|
|
28
|
+
assert {"name": "app", "extra": 1} == Partial({"name": str})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_accepts_a_prebuilt_schema() -> None:
|
|
32
|
+
"""A ready-made Schema can be reused; Exact/Partial still govern extra keys."""
|
|
33
|
+
from probatio import Schema # noqa: PLC0415
|
|
34
|
+
|
|
35
|
+
user = Schema({"id": int, "name": str})
|
|
36
|
+
assert {"id": 1, "name": "ada"} == Exact(user)
|
|
37
|
+
assert {"id": 1, "name": "ada", "extra": 1} != Exact(user)
|
|
38
|
+
assert {"id": 1, "name": "ada", "extra": 1} == Partial(user)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_le_operator_is_a_partial_match() -> None:
|
|
42
|
+
"""The <= operator relaxes Exact to a partial match (extra keys allowed)."""
|
|
43
|
+
assert Exact({"name": str}) <= {"name": "app", "extra": 1}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_ge_operator_is_a_reversed_partial_match() -> None:
|
|
47
|
+
"""Data on the left of <= reaches __ge__, still a partial match (extra keys allowed)."""
|
|
48
|
+
assert {"name": "app", "extra": 1} <= Exact({"name": str})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_assertrepr_hook_explains_partial_match_operators() -> None:
|
|
52
|
+
"""The hook renders explanations for the <= and >= operators, not just ==/!=."""
|
|
53
|
+
matcher = Exact({"port": Port()})
|
|
54
|
+
matcher <= {"port": "nope"} # noqa: B015 - run the comparison to record errors
|
|
55
|
+
for op in ("<=", ">="):
|
|
56
|
+
lines = pytest_assertrepr_compare(op, matcher, {"port": "nope"})
|
|
57
|
+
assert lines is not None
|
|
58
|
+
assert any("data['port']" in line for line in lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_matcher_records_errors_with_paths() -> None:
|
|
62
|
+
"""A failed comparison records probatio errors carrying the offending path."""
|
|
63
|
+
matcher = Exact({"server": {"port": Port()}})
|
|
64
|
+
assert {"server": {"port": 70000}} != matcher
|
|
65
|
+
assert matcher.errors
|
|
66
|
+
assert matcher.errors[0].path == ["server", "port"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_assertrepr_hook_lists_errors_by_path() -> None:
|
|
70
|
+
"""The pytest hook renders each error as a path and message."""
|
|
71
|
+
matcher = Exact({"port": Port()})
|
|
72
|
+
matcher == {"port": "nope"} # noqa: B015 - run the comparison to record errors
|
|
73
|
+
lines = pytest_assertrepr_compare("==", matcher, {"port": "nope"})
|
|
74
|
+
assert lines is not None
|
|
75
|
+
assert any("data['port']" in line for line in lines)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_assertrepr_hook_ignores_unrelated_comparisons() -> None:
|
|
79
|
+
"""The hook returns None when neither side is a matcher (no interference)."""
|
|
80
|
+
assert pytest_assertrepr_compare("==", 1, 2) is None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_assertrepr_hook_explains_a_failed_negation() -> None:
|
|
84
|
+
"""A failed `!=` (the data did match) gets a clear, non-contradictory message."""
|
|
85
|
+
matcher = Exact({"id": int})
|
|
86
|
+
matcher != {"id": 1} # noqa: B015 - the data matches, so != fails; records no errors
|
|
87
|
+
lines = pytest_assertrepr_compare("!=", matcher, {"id": 1})
|
|
88
|
+
assert lines is not None
|
|
89
|
+
assert lines == [
|
|
90
|
+
"data matches the probatio schema, but the assertion (!=) required it not to"
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_matcher_is_unhashable() -> None:
|
|
95
|
+
"""The matcher is not hashable, since it records mutable error state."""
|
|
96
|
+
import pytest # noqa: PLC0415
|
|
97
|
+
|
|
98
|
+
with pytest.raises(TypeError):
|
|
99
|
+
hash(Exact({"a": int}))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_plugin_explains_a_failed_assertion_end_to_end(pytester) -> None:
|
|
103
|
+
"""A failing `data == Exact(...)` under pytest shows the schema error, via the hook.
|
|
104
|
+
|
|
105
|
+
This exercises the registered pytest11 plugin, not just the hook function: the
|
|
106
|
+
entry point must be active for the explanation to appear in a real run.
|
|
107
|
+
"""
|
|
108
|
+
pytester.makepyfile(
|
|
109
|
+
"""
|
|
110
|
+
from pytest_probatio import Exact
|
|
111
|
+
from probatio import Port
|
|
112
|
+
|
|
113
|
+
def test_response():
|
|
114
|
+
assert {"port": "nope"} == Exact({"port": Port()})
|
|
115
|
+
"""
|
|
116
|
+
)
|
|
117
|
+
result = pytester.runpytest()
|
|
118
|
+
result.assert_outcomes(failed=1)
|
|
119
|
+
output = result.stdout.str()
|
|
120
|
+
assert "does not match the probatio schema" in output
|
|
121
|
+
assert "data['port']" in output
|