assertpy 2.35.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.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
assertpy/assertpy.py
ADDED
|
@@ -0,0 +1,2640 @@
|
|
|
1
|
+
"""Assertion library for python unit testing with a fluent API"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import contextvars
|
|
7
|
+
import functools
|
|
8
|
+
import inspect
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import types
|
|
14
|
+
from dataclasses import replace
|
|
15
|
+
from typing import (
|
|
16
|
+
TYPE_CHECKING,
|
|
17
|
+
Any,
|
|
18
|
+
Final,
|
|
19
|
+
Generic,
|
|
20
|
+
Literal,
|
|
21
|
+
NoReturn,
|
|
22
|
+
Protocol,
|
|
23
|
+
SupportsIndex,
|
|
24
|
+
TypeVar,
|
|
25
|
+
cast,
|
|
26
|
+
overload,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
import datetime
|
|
31
|
+
import pathlib
|
|
32
|
+
from collections.abc import Callable, Collection, Mapping
|
|
33
|
+
|
|
34
|
+
from typing_extensions import TypeIs
|
|
35
|
+
|
|
36
|
+
from ._engine._builder_check_typing import _CheckAnyValue
|
|
37
|
+
from ._engine._capable_typing import _CapableAssertion
|
|
38
|
+
from ._engine._compat import Self
|
|
39
|
+
from ._engine._typing import (
|
|
40
|
+
_ArrayAssertion,
|
|
41
|
+
_ArrayT_co,
|
|
42
|
+
_BoolAssertion,
|
|
43
|
+
_BytesAssertion,
|
|
44
|
+
_CallableAssertion,
|
|
45
|
+
_CapableT,
|
|
46
|
+
_ComplexAssertion,
|
|
47
|
+
_DateAssertion,
|
|
48
|
+
_DateTimeAssertion,
|
|
49
|
+
_DictAssertion,
|
|
50
|
+
_FrameAssertion,
|
|
51
|
+
_FrameT_co,
|
|
52
|
+
_IterableAssertion,
|
|
53
|
+
_NumericAssertion,
|
|
54
|
+
_ObjectAssertion,
|
|
55
|
+
_PathAssertion,
|
|
56
|
+
_StringAssertion,
|
|
57
|
+
)
|
|
58
|
+
from ._matcher_impls import ClassInfo, ExceptionInfo, RetriedInfo
|
|
59
|
+
from ._passes import PassLogger
|
|
60
|
+
from .errors import PollTrace
|
|
61
|
+
from .matchers import Matcher
|
|
62
|
+
|
|
63
|
+
from . import _hints, _passes
|
|
64
|
+
from . import errors as _errors
|
|
65
|
+
from ._engine._compat import _LoggerAdapter
|
|
66
|
+
from ._engine._contract import exactness_failure, kept_apart, put_as_sent, put_back, runs_own_code, sent_record
|
|
67
|
+
from ._engine._introspection import ASSERTPY_FILES, WarningLogger, is_same_implementation
|
|
68
|
+
from ._engine._operations import (
|
|
69
|
+
ALSO_ASSERTS,
|
|
70
|
+
CONFIGURES,
|
|
71
|
+
DESCRIBES,
|
|
72
|
+
HANDS_THE_SUBJECT_BACK,
|
|
73
|
+
NOT_AN_OPERATION,
|
|
74
|
+
POLLS,
|
|
75
|
+
TRANSFORMS,
|
|
76
|
+
WHAT_IT_DOES,
|
|
77
|
+
WITHOUT_A_VERDICT,
|
|
78
|
+
)
|
|
79
|
+
from ._engine._path import _ROOT, _Path
|
|
80
|
+
from ._engine._require import argument, refuse, verdict
|
|
81
|
+
from .async_assertions import AsyncAssertionBuilder, SyncAssertionBuilder, _normalize_ignoring
|
|
82
|
+
from .base import BaseMixin
|
|
83
|
+
from .bytes_mixin import BytesMixin
|
|
84
|
+
from .collection import CollectionMixin
|
|
85
|
+
from .contains import ContainsMixin
|
|
86
|
+
from .dataframe import DataFrameMixin
|
|
87
|
+
from .date import DateMixin
|
|
88
|
+
from .dict import DictMixin
|
|
89
|
+
from .dynamic import DynamicMixin, _one_operand
|
|
90
|
+
from .errors import (
|
|
91
|
+
AssertionFailure,
|
|
92
|
+
DiffEntry,
|
|
93
|
+
DiffResult,
|
|
94
|
+
Step,
|
|
95
|
+
_capped,
|
|
96
|
+
_capped_repr,
|
|
97
|
+
_diff_sides,
|
|
98
|
+
_render_diff,
|
|
99
|
+
_safe_str,
|
|
100
|
+
_truncated,
|
|
101
|
+
_windowed,
|
|
102
|
+
)
|
|
103
|
+
from .exception import _UNSET, ExceptionMixin
|
|
104
|
+
from .extracting import ExtractingMixin
|
|
105
|
+
from .file import FileMixin
|
|
106
|
+
from .helpers import HelpersMixin
|
|
107
|
+
from .http_mixin import HttpMixin, response_note, response_of
|
|
108
|
+
from .json_mixin import JsonMixin
|
|
109
|
+
from .numeric import NumericMixin
|
|
110
|
+
from .outcome import MISSING, AssertionOutcome, Requirement
|
|
111
|
+
from .snapshot import SnapshotMixin
|
|
112
|
+
from .string import StringMixin
|
|
113
|
+
from .warning import WarningMixin
|
|
114
|
+
|
|
115
|
+
__version__ = "2.35.0"
|
|
116
|
+
|
|
117
|
+
# the tracked value type of the generic AssertionBuilder fallback (_U appears only in narrowing stubs)
|
|
118
|
+
_T = TypeVar("_T")
|
|
119
|
+
# the assertion type `not_` was reached from, so an inverted step hands the same narrowed type back
|
|
120
|
+
_E_co = TypeVar("_E_co", covariant=True) # the element a pivot hands back
|
|
121
|
+
_S = TypeVar("_S")
|
|
122
|
+
if TYPE_CHECKING:
|
|
123
|
+
_U = TypeVar("_U")
|
|
124
|
+
_U2 = TypeVar("_U2")
|
|
125
|
+
_U3 = TypeVar("_U3")
|
|
126
|
+
_E = TypeVar("_E") # element type of a collection, so first()/element()/... narrow to it
|
|
127
|
+
_R = TypeVar("_R") # result element type after a mapping pivot
|
|
128
|
+
_K = TypeVar("_K") # dict key type, so .value keeps dict[K, V]
|
|
129
|
+
_V = TypeVar("_V") # dict value type
|
|
130
|
+
_P = TypeVar("_P") # what a probe hands back, so a polling chain asserts on that and not on `object`
|
|
131
|
+
|
|
132
|
+
class _Extension(Protocol):
|
|
133
|
+
"""A function attached as an extension, which the registry keys on its own name.
|
|
134
|
+
|
|
135
|
+
The name is part of the contract rather than incidental: `add_extension` refuses a value whose
|
|
136
|
+
`__name__` is not an identifier, and `remove_extension` reads it to find what to take off.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
__name__: str
|
|
140
|
+
|
|
141
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
|
|
142
|
+
|
|
143
|
+
class _ElementSource(Protocol[_E_co]):
|
|
144
|
+
"""Anything whose value is a collection of `_E_co`, however it was annotated.
|
|
145
|
+
|
|
146
|
+
Structural because `AssertionBuilder` is invariant in `_T`: a builder over a `Sequence[int]`
|
|
147
|
+
is not a builder over a `Collection[int]`, so the nominal self type bound for a mapping and
|
|
148
|
+
missed every other container.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def value(self) -> Collection[_E_co]: ...
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__tracebackhide__ = True # clean tracebacks via py.test integration
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_run_as_written: contextvars.ContextVar[bool] = contextvars.ContextVar("assertpy_run_as_written", default=False)
|
|
159
|
+
"""Whether the assertion being located was written inside a callable we are running.
|
|
160
|
+
|
|
161
|
+
`assert_all()` calls what it was given, so the outermost handover is its own call site and every failure
|
|
162
|
+
it collected was tagged with that one line. A callable passed to it holds the assertions as written, so
|
|
163
|
+
theirs is the line to report.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _caller_location() -> tuple[str, int] | None:
|
|
168
|
+
"""The ``(filename, lineno)`` of the user frame that called into assertpy, skipping internal frames.
|
|
169
|
+
|
|
170
|
+
Used to locate a warn-mode warning and each collected soft-assertion failure.
|
|
171
|
+
|
|
172
|
+
The answer is the frame just outside the *outermost* assertpy frame, which is why the walk cannot
|
|
173
|
+
stop at the first user frame it meets going inwards: a predicate passed to `satisfies()` or `each()`
|
|
174
|
+
runs inside our own call, and the line worth reporting is the assertion in the test rather than the
|
|
175
|
+
lambda we invoked. Walking outwards and keeping the last handover does the same in one pass,
|
|
176
|
+
without building a list of the whole stack.
|
|
177
|
+
|
|
178
|
+
``None`` where no such handover exists, so a caller that cannot be located logs without the prefix
|
|
179
|
+
instead of crashing on unpacking.
|
|
180
|
+
"""
|
|
181
|
+
frame: types.FrameType | None = sys._getframe(1) # CPython accessor; the inspect equivalent is 10x slower here
|
|
182
|
+
innermost = _run_as_written.get()
|
|
183
|
+
location: tuple[str, int] | None = None
|
|
184
|
+
inner_is_internal = False
|
|
185
|
+
while frame:
|
|
186
|
+
filename = frame.f_code.co_filename
|
|
187
|
+
is_internal = filename in ASSERTPY_FILES
|
|
188
|
+
if inner_is_internal and not is_internal:
|
|
189
|
+
location = (filename, frame.f_lineno)
|
|
190
|
+
if innermost:
|
|
191
|
+
return location
|
|
192
|
+
inner_is_internal = is_internal
|
|
193
|
+
frame = frame.f_back
|
|
194
|
+
return location
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _what_was_asked(builder: object, given: Requirement | None = None) -> Requirement | None:
|
|
198
|
+
"""What the failure being composed was asked for.
|
|
199
|
+
|
|
200
|
+
A live extension call on this builder first, because it owns what runs under it the way
|
|
201
|
+
`is_positive()` owns the `is_greater_than(0)` it asks. That has to beat *given*, which is what the
|
|
202
|
+
negated proxy and a dynamic ``has_`` assertion say about themselves: an extension delegating to
|
|
203
|
+
either of those was reporting the delegate.
|
|
204
|
+
|
|
205
|
+
The extension answers only for the builder it was called on. An assertion it makes about some
|
|
206
|
+
other value is that assertion's own, exactly as one inside a `satisfies()` predicate is.
|
|
207
|
+
"""
|
|
208
|
+
asked = _asked_of.get()
|
|
209
|
+
if asked is not None and asked.answers_for(builder):
|
|
210
|
+
return _extension_requirement(asked)
|
|
211
|
+
return given if given is not None else _requirement_of()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _requirement_of() -> Requirement | None:
|
|
215
|
+
"""Which operation the failure being composed was asked for, read off the stack.
|
|
216
|
+
|
|
217
|
+
The name and the arguments live at the assertion's entry and not at its failure: of the 164 places
|
|
218
|
+
that reach `error()`, 144 are the public method itself and 20 are private helpers it calls. So the
|
|
219
|
+
answer is read here instead of passed down from each of them, which costs a built-in assertion
|
|
220
|
+
nothing at all on the passing path and one microsecond on a failure that is already building a diff
|
|
221
|
+
and a hint. An extension is the exception and pays a wrapper on every call, because a frame cannot
|
|
222
|
+
be tied back to a registration: `_ExtensionCall` says why.
|
|
223
|
+
|
|
224
|
+
The *outermost* assertion still inside the package, for the same reason `_caller_location()` walks
|
|
225
|
+
outwards: `is_positive()` asks `is_greater_than(0)` and `contains_key()` asks `contains()`, so the
|
|
226
|
+
innermost frame answers the delegate rather than the assertion in the test. A predicate passed to
|
|
227
|
+
`satisfies()` is not our code, so the walk leaves the package there and an assertion inside the
|
|
228
|
+
predicate still answers itself. An extension is not read off the stack at all: it says what it is
|
|
229
|
+
before its body runs, because a frame cannot be tied back to a registration. Two instances of one
|
|
230
|
+
callable class, and two functions from one decorator factory, share a code object.
|
|
231
|
+
|
|
232
|
+
``None`` where no operation was asked: `fail()`, and a bare `error()` with the caller's own message.
|
|
233
|
+
"""
|
|
234
|
+
frame: types.FrameType | None = sys._getframe(3) # skips this, `_what_was_asked` and `_compose`
|
|
235
|
+
found: types.FrameType | None = None
|
|
236
|
+
while frame is not None and frame.f_code.co_filename in ASSERTPY_FILES:
|
|
237
|
+
if frame.f_code.co_name in _ASSERTS:
|
|
238
|
+
found = frame
|
|
239
|
+
frame = frame.f_back
|
|
240
|
+
return None if found is None else Requirement(found.f_code.co_name, _bound_parameters(found))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _bound_parameters(frame: types.FrameType, receivers: int = 1) -> dict[str, object]:
|
|
244
|
+
"""Every declared parameter of *frame* with the value it was bound to, minus the receiver.
|
|
245
|
+
|
|
246
|
+
A frame cannot say whether an argument was omitted, passed positionally or passed by keyword, so
|
|
247
|
+
what is read here is what the assertion ran with. `Requirement.parameters` says why that is the
|
|
248
|
+
more useful of the two.
|
|
249
|
+
"""
|
|
250
|
+
code = frame.f_code
|
|
251
|
+
local = frame.f_locals
|
|
252
|
+
names = code.co_varnames
|
|
253
|
+
taken = code.co_argcount + code.co_kwonlyargcount
|
|
254
|
+
found: dict[str, object] = {name: local[name] for name in names[receivers:taken] if name in local}
|
|
255
|
+
if code.co_flags & inspect.CO_VARARGS:
|
|
256
|
+
found[names[taken]] = tuple(local.get(names[taken], ()))
|
|
257
|
+
taken += 1
|
|
258
|
+
if code.co_flags & inspect.CO_VARKEYWORDS:
|
|
259
|
+
found[names[taken]] = dict(local.get(names[taken], {}))
|
|
260
|
+
return found
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@functools.cache
|
|
264
|
+
def _method_signature(own: Callable[..., object]) -> inspect.Signature:
|
|
265
|
+
"""The signature of an assertion as its caller writes it, without the receiver.
|
|
266
|
+
|
|
267
|
+
Cached on the function rather than on the bound method, which is a new object per attribute access.
|
|
268
|
+
`inspect.signature` costs 4.8 microseconds, three times what composing a negated failure costs
|
|
269
|
+
without it, and the set of assertion functions is fixed at import.
|
|
270
|
+
"""
|
|
271
|
+
found = inspect.signature(own)
|
|
272
|
+
return found.replace(parameters=list(found.parameters.values())[1:])
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _signature_of(attr: Callable[..., object]) -> inspect.Signature | None:
|
|
276
|
+
"""As a caller writes it, for anything callable, or ``None`` where it cannot be read.
|
|
277
|
+
|
|
278
|
+
Only a bound method is cached: a dynamic ``has_`` assertion and a registered extension are closures
|
|
279
|
+
built per access, and holding them in a cache would grow it for the life of the process.
|
|
280
|
+
"""
|
|
281
|
+
own = getattr(attr, "__func__", None)
|
|
282
|
+
try:
|
|
283
|
+
# only a function is cached: a callable object need not be hashable, and the key would refuse it
|
|
284
|
+
return _method_signature(own) if isinstance(own, types.FunctionType) else inspect.signature(attr)
|
|
285
|
+
except (TypeError, ValueError):
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class _SoftBlock:
|
|
290
|
+
"""The failures one `soft_assertions()` block is collecting, and whether it is still collecting.
|
|
291
|
+
|
|
292
|
+
``active`` exists because a context variable is copied *by value* into a new task: a child created
|
|
293
|
+
inside the block inherits the depth and the list, and the parent's exit is invisible to it. The
|
|
294
|
+
list is shared, so the flag rides along with it and the child can see that nobody is listening any
|
|
295
|
+
more. Without it, an assertion in a task that outlived the block appended to an orphaned list and
|
|
296
|
+
the test passed having checked nothing.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
__slots__ = ("active", "compared", "failures")
|
|
300
|
+
|
|
301
|
+
def __init__(self) -> None:
|
|
302
|
+
self.failures: list[AssertionOutcome] = []
|
|
303
|
+
# by the identity of the record, which `failures` keeps alive, the pair its equality compared
|
|
304
|
+
self.compared: dict[int, tuple[object, object]] = {}
|
|
305
|
+
self.active = True
|
|
306
|
+
|
|
307
|
+
def taken(self) -> tuple[list[AssertionOutcome], tuple[tuple[object, object] | None, ...]]:
|
|
308
|
+
"""What was collected, with the pair beside each, the block left empty for whatever comes next."""
|
|
309
|
+
errs, compared = self.failures, self.compared
|
|
310
|
+
self.failures, self.compared = [], {}
|
|
311
|
+
return errs, tuple(compared.get(id(outcome)) for outcome in errs)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _collecting() -> _SoftBlock | None:
|
|
315
|
+
"""The block currently collecting in this context, or ``None`` when assertions should raise."""
|
|
316
|
+
if not _soft_ctx.get():
|
|
317
|
+
return None
|
|
318
|
+
block = _soft_err.get(None)
|
|
319
|
+
return block if block is not None and block.active else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
_soft_ctx: contextvars.ContextVar[int] = contextvars.ContextVar("assertpy_soft_ctx", default=0)
|
|
323
|
+
# nothing about a soft failure is flattened until the aggregate message is rendered
|
|
324
|
+
_soft_err: contextvars.ContextVar[_SoftBlock | None] = contextvars.ContextVar("assertpy_soft_err", default=None)
|
|
325
|
+
_soft_group: contextvars.ContextVar[str | None] = contextvars.ContextVar("assertpy_soft_group", default=None)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class _Group:
|
|
329
|
+
"""The context `group()` hands back.
|
|
330
|
+
|
|
331
|
+
A class rather than a `@contextlib.contextmanager` generator, and the same goes for the soft block
|
|
332
|
+
itself. A generator-based manager puts `contextlib.__exit__` on the stack, so a failure raised on
|
|
333
|
+
the way out is reported against `contextlib.py` instead of the `with` line in the test. The old
|
|
334
|
+
cure was setting `__tracebackhide__` on the `contextlib` module, which fixed our two managers by
|
|
335
|
+
changing how every third-party context manager in the process is reported.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
__slots__ = ("_label", "_token")
|
|
339
|
+
|
|
340
|
+
def __init__(self, label: str) -> None:
|
|
341
|
+
self._label = label
|
|
342
|
+
|
|
343
|
+
def __enter__(self) -> None:
|
|
344
|
+
self._token = _soft_group.set(self._label)
|
|
345
|
+
|
|
346
|
+
def __exit__(self, *_exc: object) -> None:
|
|
347
|
+
_soft_group.reset(self._token)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class SoftAssertionCollector:
|
|
351
|
+
"""Collector returned by [`soft_assertions()`][assertpy.assertpy.soft_assertions] for grouping
|
|
352
|
+
errors hierarchically."""
|
|
353
|
+
|
|
354
|
+
def group(self, label: str) -> _Group:
|
|
355
|
+
"""Group subsequent assertion failures under *label*.
|
|
356
|
+
|
|
357
|
+
Examples:
|
|
358
|
+
Usage:
|
|
359
|
+
|
|
360
|
+
with soft_assertions() as sa:
|
|
361
|
+
with sa.group("Headers"):
|
|
362
|
+
assert_that(headers).contains_key("Content-Type")
|
|
363
|
+
with sa.group("Body"):
|
|
364
|
+
assert_that(body["status"]).is_equal_to("ok")
|
|
365
|
+
"""
|
|
366
|
+
return _Group(label)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _located(location: tuple[str, int] | None, msg: str) -> str:
|
|
370
|
+
"""Append ``[file:line]`` so each collected soft failure can be jumped to, matching warn-mode."""
|
|
371
|
+
if location is None: # pragma: no cover - only None when a user file shadows assertpy (see _caller_location)
|
|
372
|
+
return msg
|
|
373
|
+
return f"{msg} [{os.path.basename(location[0])}:{location[1]}]"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# half the block form's window, because the compact form puts both sides on one line
|
|
377
|
+
_SCANNABLE_WIDTH = 60
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _indented_diff(diff: object, indent: str) -> list[str]:
|
|
381
|
+
"""Render a collected failure's diff under its entry, or nothing when it carries none."""
|
|
382
|
+
# one line per differing path: a soft run exists to show many of them at once
|
|
383
|
+
lines = []
|
|
384
|
+
entries = getattr(diff, "entries", None) or []
|
|
385
|
+
# a mapping whose only differing key is spelled "." has a step, and used to be dropped here
|
|
386
|
+
only = entries[0] if len(entries) == 1 else None
|
|
387
|
+
if only is not None and not only.steps and only.path == ".":
|
|
388
|
+
return lines
|
|
389
|
+
if only is not None and only.steps == (Step("line", 1),):
|
|
390
|
+
# past this width the change cannot be found by eye, so a window around it is drawn instead
|
|
391
|
+
actual_text, expected_text = _safe_str(only.actual), _safe_str(only.expected)
|
|
392
|
+
if len(actual_text) <= _SCANNABLE_WIDTH and len(expected_text) <= _SCANNABLE_WIDTH:
|
|
393
|
+
return lines
|
|
394
|
+
near_actual, near_expected = _windowed(actual_text, expected_text, _SCANNABLE_WIDTH)
|
|
395
|
+
return [f"{indent}{only.path}: {near_actual} != {near_expected}"]
|
|
396
|
+
shown = entries[:5] # bound once: a slice and a separate threshold would drift apart
|
|
397
|
+
for entry in shown:
|
|
398
|
+
if entry.absent == "expected": # an extra item, which has no counterpart to contrast with
|
|
399
|
+
lines.append(f"{indent}{entry.path}: {_capped_repr(entry.actual)}")
|
|
400
|
+
elif entry.absent == "actual": # a missing one
|
|
401
|
+
lines.append(f"{indent}{entry.path}: {_capped_repr(entry.expected)}")
|
|
402
|
+
else:
|
|
403
|
+
actual_side, expected_side = _diff_sides(entry.actual, entry.expected, 4000)
|
|
404
|
+
lines.append(f"{indent}{entry.path}: {actual_side} != {expected_side}")
|
|
405
|
+
if len(entries) > len(shown):
|
|
406
|
+
lines.append(f"{indent}... and {len(entries) - len(shown)} more")
|
|
407
|
+
return lines
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class _Room:
|
|
411
|
+
"""What is left of the room for the blocks of collected failures: twenty failures drawn and 60 000 characters.
|
|
412
|
+
|
|
413
|
+
One per message of a soft block where that message is the only place its diffs are read, and one per report
|
|
414
|
+
under pytest, shared there by every block that failed in it. A failure drawn and then too wide for what is left
|
|
415
|
+
still takes one of the twenty: 200 wide failures each drawn and refused cost a report 0.49 s, against 0.05 s.
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
def __init__(self) -> None:
|
|
419
|
+
self.failures = 20
|
|
420
|
+
self.characters = 60_000
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _numbered(numbers: list[int]) -> str:
|
|
424
|
+
"""``[3, 21, 22, 23]`` as ``failures 3, 21 to 23``, and ``[3]`` as ``failure 3``: failures named by number."""
|
|
425
|
+
runs: list[list[int]] = []
|
|
426
|
+
for number in numbers:
|
|
427
|
+
if runs and number == runs[-1][-1] + 1:
|
|
428
|
+
runs[-1].append(number)
|
|
429
|
+
else:
|
|
430
|
+
runs.append([number])
|
|
431
|
+
spans = ", ".join(str(run[0]) if len(run) == 1 else f"{run[0]} to {run[-1]}" for run in runs)
|
|
432
|
+
return f"failure {spans}" if len(numbers) == 1 else f"failures {spans}"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _block_of(diff: object, indent: str, room: _Room) -> list[str] | None:
|
|
436
|
+
"""*diff* in block form under an entry, or ``None`` where it has none, the room is spent or it does not fit.
|
|
437
|
+
|
|
438
|
+
A diff that cannot be drawn keeps the compact rows the summary has always printed, and a line saying why.
|
|
439
|
+
"""
|
|
440
|
+
if diff is None or room.failures == 0:
|
|
441
|
+
return None
|
|
442
|
+
try:
|
|
443
|
+
rendered = _render_diff(diff)
|
|
444
|
+
except Exception as e:
|
|
445
|
+
room.failures -= 1
|
|
446
|
+
return [*_indented_diff(diff, indent), f"{indent}(no block: drawing it raised {type(e).__name__})"]
|
|
447
|
+
if not rendered:
|
|
448
|
+
return None
|
|
449
|
+
room.failures -= 1
|
|
450
|
+
block = [f"{indent}{line}" for line in rendered.splitlines()]
|
|
451
|
+
size = sum(len(line) + 1 for line in block)
|
|
452
|
+
if size > room.characters:
|
|
453
|
+
return None
|
|
454
|
+
room.characters -= size
|
|
455
|
+
return block
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _format_soft_errors(errs: list[AssertionOutcome], *, blocks: bool = False, kept: bool = False) -> str:
|
|
459
|
+
"""The numbered summary a soft block raises, each entry located, with each failure's diff under it.
|
|
460
|
+
|
|
461
|
+
The diff goes in compact rows, one per differing path, where the pytest plugin hangs each failure's block on
|
|
462
|
+
the report anyway. With *blocks*, for a message that is the only place a reader finds them, each diff goes in
|
|
463
|
+
the block form a raised failure prints while a `_Room` lasts. A block that does not fit keeps its compact rows
|
|
464
|
+
and takes one of the twenty but none of the characters, so a smaller block after it still fits. The last line
|
|
465
|
+
names those failures, pointing at `failures` where *kept* says the summary is the message that carries them.
|
|
466
|
+
"""
|
|
467
|
+
has_groups = any(outcome.group is not None for outcome in errs)
|
|
468
|
+
lines = ["soft assertion failures:"]
|
|
469
|
+
room = _Room() if blocks else None
|
|
470
|
+
compact: list[int] = []
|
|
471
|
+
current_group: str | None = None
|
|
472
|
+
for counter, outcome in enumerate(errs, 1):
|
|
473
|
+
indent = ""
|
|
474
|
+
# one failure that cannot be drawn, its group's label included, costs its own entry and not the summary
|
|
475
|
+
try:
|
|
476
|
+
group = outcome.group
|
|
477
|
+
indent = (" " if group is not None else " ") if has_groups else ""
|
|
478
|
+
if has_groups and not _same_label(group, current_group):
|
|
479
|
+
header = None if group is None else f" [{group}]"
|
|
480
|
+
current_group = group
|
|
481
|
+
if header is not None:
|
|
482
|
+
lines.append(header)
|
|
483
|
+
entry, squeezed = _entry_of(counter, outcome, indent, room)
|
|
484
|
+
except Exception as e:
|
|
485
|
+
entry, squeezed = [f"{indent}{counter}. (this failure could not be drawn: {type(e).__name__})"], False
|
|
486
|
+
lines.extend(entry)
|
|
487
|
+
if squeezed:
|
|
488
|
+
compact.append(counter)
|
|
489
|
+
if compact:
|
|
490
|
+
pointer = " The diff of each is on `failures`." if kept else ""
|
|
491
|
+
lines.append(f"Not drawn as a block for lack of room: {_numbered(compact)}.{pointer}")
|
|
492
|
+
return "\n".join(lines)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _same_label(label: object, other: object) -> bool:
|
|
496
|
+
"""Whether two group labels are one, a label whose ``==`` raises reading as another."""
|
|
497
|
+
if label is other:
|
|
498
|
+
return True
|
|
499
|
+
try:
|
|
500
|
+
return bool(label == other)
|
|
501
|
+
except Exception: # a label is anyone's object, and the entries after it still have to be drawn
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _entry_of(counter: int, outcome: AssertionOutcome, indent: str, room: _Room | None) -> tuple[list[str], bool]:
|
|
506
|
+
"""One entry of the summary, and whether its diff went in compact rows for lack of room."""
|
|
507
|
+
# written flat, the location landed on the last line and the next entry read as a new section
|
|
508
|
+
headline, *continuation = outcome.message.splitlines() or [""]
|
|
509
|
+
lines = [f"{indent}{counter}. {_located(outcome.location, headline)}"]
|
|
510
|
+
lines.extend(f"{indent} {line}" for line in continuation)
|
|
511
|
+
block = None if room is None else _block_of(outcome.diff, indent + " ", room)
|
|
512
|
+
if block is not None:
|
|
513
|
+
return [*lines, *block], False
|
|
514
|
+
lines.extend(_indented_diff(outcome.diff, indent + " "))
|
|
515
|
+
return lines, room is not None and bool(getattr(outcome.diff, "entries", None))
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _drawn(errs: list[AssertionOutcome], *, blocks: bool, kept: bool = False) -> str:
|
|
519
|
+
"""`_format_soft_errors`, or a line saying how many were collected where drawing them raised.
|
|
520
|
+
|
|
521
|
+
The summary is a diagnostic: it must not take the place of the failure it describes, nor of an exception of the
|
|
522
|
+
caller's that it is a note on.
|
|
523
|
+
"""
|
|
524
|
+
try:
|
|
525
|
+
return _format_soft_errors(errs, blocks=blocks, kept=kept)
|
|
526
|
+
except Exception as e:
|
|
527
|
+
return f"soft assertion failures: {len(errs)} collected, and drawing them raised {type(e).__name__}"
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _carry(
|
|
531
|
+
exc: BaseException, collected: tuple[AssertionOutcome, ...], pairs: tuple[tuple[object, object] | None, ...]
|
|
532
|
+
) -> None:
|
|
533
|
+
"""Leave what a soft block collected on the exception that escaped it, where the pytest plugin reads it.
|
|
534
|
+
|
|
535
|
+
Through the instance's own dictionary and past any ``__setattr__`` of its own, best-effort as the note is: the
|
|
536
|
+
exception the caller raised is the one that has to come out of the block, whatever this manages. Added to what
|
|
537
|
+
an earlier block left, as its note is added to the notes, since one exception can escape two blocks in turn.
|
|
538
|
+
"""
|
|
539
|
+
# an instance without a dictionary keeps the note, which carries the text
|
|
540
|
+
with contextlib.suppress(Exception):
|
|
541
|
+
vars(exc).setdefault("_assertpy_collected", []).append((collected, pairs))
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _attach_soft_note(exc: BaseException, rendered: str) -> None:
|
|
545
|
+
"""Put the collected failures on *exc* without ever becoming the reason the test failed.
|
|
546
|
+
|
|
547
|
+
This is a diagnostic hung on somebody else's exception, so every step of it is best-effort. Three
|
|
548
|
+
things can go wrong and each of them used to matter more than the note:
|
|
549
|
+
|
|
550
|
+
* `__notes__` may already be something other than a list. CPython's own `add_note` refuses with
|
|
551
|
+
`TypeError: Cannot add note: __notes__ is not a list`, and that error replaced the exception the
|
|
552
|
+
user raised, leaving theirs in `__context__`. Exactly backwards for a mechanism whose whole job
|
|
553
|
+
is not losing information;
|
|
554
|
+
* a user exception type can override `add_note` with something that raises, or with something that
|
|
555
|
+
is not callable at all. `BaseException.add_note` is called unbound to step around the override;
|
|
556
|
+
* the attribute may not be writable, on a type with `__slots__` or a frozen instance.
|
|
557
|
+
|
|
558
|
+
`add_note` also arrived in 3.11 and this package supports 3.10, which the first branch covers on the
|
|
559
|
+
way past: there the unbound lookup raises `AttributeError` and the assignment takes over.
|
|
560
|
+
"""
|
|
561
|
+
try:
|
|
562
|
+
BaseException.add_note(exc, rendered) # ty: ignore[unresolved-attribute] # absent on 3.10
|
|
563
|
+
return
|
|
564
|
+
except Exception: # every failure here falls through to the assignment below
|
|
565
|
+
pass
|
|
566
|
+
try:
|
|
567
|
+
existing = getattr(exc, "__notes__", ())
|
|
568
|
+
notes = [*existing, rendered] if isinstance(existing, (list, tuple)) else [rendered]
|
|
569
|
+
exc.__notes__ = notes # ty: ignore[unresolved-attribute] # 3.10 lacks it until set
|
|
570
|
+
except Exception: # a note is worth nothing next to the exception it annotates
|
|
571
|
+
pass
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class _SoftAssertions:
|
|
575
|
+
"""The context `soft_assertions()` hands back. See `_Group` for why this is a class."""
|
|
576
|
+
|
|
577
|
+
__slots__ = ()
|
|
578
|
+
|
|
579
|
+
def __enter__(self) -> SoftAssertionCollector:
|
|
580
|
+
ctx = _soft_ctx.get()
|
|
581
|
+
block = _soft_err.get(None)
|
|
582
|
+
if ctx == 0 or block is None or not block.active:
|
|
583
|
+
# a task outliving the block it inherited holds a closed one: collecting into it reaches nobody
|
|
584
|
+
_soft_err.set(_SoftBlock())
|
|
585
|
+
ctx = 0
|
|
586
|
+
_soft_ctx.set(ctx + 1)
|
|
587
|
+
return SoftAssertionCollector()
|
|
588
|
+
|
|
589
|
+
def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, *_rest: object) -> None:
|
|
590
|
+
_soft_ctx.set(_soft_ctx.get() - 1)
|
|
591
|
+
if exc_type is not None:
|
|
592
|
+
# the escaping error still wins, and what was collected before it travels as a note
|
|
593
|
+
if exc is not None and (block := _soft_err.get(None)) is not None and _soft_ctx.get() == 0:
|
|
594
|
+
block.active = False
|
|
595
|
+
errs, pairs = block.taken()
|
|
596
|
+
if errs:
|
|
597
|
+
_carry(exc, tuple(errs), pairs)
|
|
598
|
+
_attach_soft_note(exc, _drawn(errs, blocks=_errors._RENDER_DIFF_IN_MESSAGE))
|
|
599
|
+
return
|
|
600
|
+
block = _soft_err.get(None)
|
|
601
|
+
errs: list[AssertionOutcome] = block.failures if block is not None else []
|
|
602
|
+
pairs: tuple[tuple[object, object] | None, ...] = ()
|
|
603
|
+
if _soft_ctx.get() == 0 and block is not None:
|
|
604
|
+
# a task still running with this block in its own context copy stops collecting
|
|
605
|
+
block.active = False
|
|
606
|
+
errs, pairs = block.taken()
|
|
607
|
+
if errs and _soft_ctx.get() == 0:
|
|
608
|
+
# the same class as a single failure, so one `except AssertionFailure` covers both
|
|
609
|
+
failure = AssertionFailure(_drawn(errs, blocks=False), failures=tuple(errs))
|
|
610
|
+
failure._compared_pairs = pairs
|
|
611
|
+
failure._soft_summary = functools.partial(_drawn, list(errs), blocks=True, kept=True)
|
|
612
|
+
raise failure
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def soft_assertions() -> _SoftAssertions:
|
|
616
|
+
"""Create a soft assertion context.
|
|
617
|
+
|
|
618
|
+
Normally, any assertion failure will halt test execution immediately by raising an error.
|
|
619
|
+
Soft assertions are way to collect assertion failures (and failure messages) together, to be
|
|
620
|
+
raised all at once at the end, without halting your test.
|
|
621
|
+
|
|
622
|
+
Uses `contextvars` internally, so each thread and each ``asyncio`` task gets its own
|
|
623
|
+
soft-assertion state. A task started inside an open block collects into that block while it is open.
|
|
624
|
+
|
|
625
|
+
Examples:
|
|
626
|
+
Create a soft assertion context, and some failing tests:
|
|
627
|
+
|
|
628
|
+
from assertpy import assert_that, soft_assertions
|
|
629
|
+
|
|
630
|
+
with soft_assertions():
|
|
631
|
+
assert_that('foo').is_length(4)
|
|
632
|
+
assert_that('foo').is_empty()
|
|
633
|
+
assert_that('foo').is_false()
|
|
634
|
+
assert_that('foo').is_digit()
|
|
635
|
+
assert_that('123').is_alpha()
|
|
636
|
+
|
|
637
|
+
When the context ends, any assertion failures are collected together and a single
|
|
638
|
+
``AssertionError`` is raised, each tagged with the ``file:line`` it came from:
|
|
639
|
+
|
|
640
|
+
AssertionError: soft assertion failures:
|
|
641
|
+
1. Expected <foo> to be of length <4>, but was <3>. [test_str.py:10]
|
|
642
|
+
2. Expected <foo> to be empty string, but was not. [test_str.py:11]
|
|
643
|
+
3. Expected <False>, but was not. [test_str.py:12]
|
|
644
|
+
4. Expected <foo> to contain only digits, but did not. [test_str.py:13]
|
|
645
|
+
5. Expected <123> to contain only alphabetic chars, but did not. [test_str.py:14]
|
|
646
|
+
|
|
647
|
+
Group errors by section:
|
|
648
|
+
|
|
649
|
+
with soft_assertions() as sa:
|
|
650
|
+
with sa.group("Headers"):
|
|
651
|
+
assert_that(headers["Content-Type"]).is_equal_to("application/json")
|
|
652
|
+
with sa.group("Body"):
|
|
653
|
+
assert_that(body["status"]).is_equal_to("ok")
|
|
654
|
+
|
|
655
|
+
Note:
|
|
656
|
+
The soft assertion context only collects *assertion* failures, other errors such as
|
|
657
|
+
``TypeError`` or ``ValueError`` are always raised immediately. Triggering an explicit test
|
|
658
|
+
failure with [`fail()`][assertpy.assertpy.fail] will similarly halt execution immediately.
|
|
659
|
+
If you need more forgiving behavior, use [`soft_fail()`][assertpy.assertpy.soft_fail] to add
|
|
660
|
+
a failure message without halting test execution.
|
|
661
|
+
"""
|
|
662
|
+
return _SoftAssertions()
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def assert_all(*callables: Callable[[], object]) -> None:
|
|
666
|
+
"""Run all callables inside a soft assertion context.
|
|
667
|
+
|
|
668
|
+
A convenience wrapper around [`soft_assertions()`][assertpy.assertpy.soft_assertions] for inline use.
|
|
669
|
+
|
|
670
|
+
Examples:
|
|
671
|
+
Usage:
|
|
672
|
+
|
|
673
|
+
from assertpy import assert_all, assert_that
|
|
674
|
+
|
|
675
|
+
assert_all(
|
|
676
|
+
lambda: assert_that(x).is_positive(),
|
|
677
|
+
lambda: assert_that(y).is_not_none(),
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
Raises:
|
|
681
|
+
AssertionError: if any of the callables produce assertion failures
|
|
682
|
+
TypeError: if a callable hands back a coroutine, which has asserted nothing
|
|
683
|
+
"""
|
|
684
|
+
with soft_assertions():
|
|
685
|
+
for call in callables:
|
|
686
|
+
located = _run_as_written.set(True)
|
|
687
|
+
try:
|
|
688
|
+
verdict(call(), subject="the callable")
|
|
689
|
+
finally:
|
|
690
|
+
_run_as_written.reset(located)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _build_with(open_blocks: int) -> None:
|
|
694
|
+
"""Point `_builder()` at `_scoped_builder` while a block is open anywhere, called under the count's lock.
|
|
695
|
+
|
|
696
|
+
A global swapped here rather than a test in `_builder()`, which outside every block cost `assert_that()` 9%.
|
|
697
|
+
"""
|
|
698
|
+
global _made_builder
|
|
699
|
+
_made_builder = _scoped_builder if open_blocks else _ExtendedBuilder
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _scoped_builder(val, description="", kind=None, expected=None, logger=None):
|
|
703
|
+
"""A builder whose assertions are observed when it is made inside an entry of a block, a plain one otherwise.
|
|
704
|
+
|
|
705
|
+
Asked of the context that makes it, so a builder made in a thread with no block of its own is plain whatever
|
|
706
|
+
another thread has open, and one made before its block opened stays plain inside it. An observed builder
|
|
707
|
+
reports into the entry it was made in, and into no other once that one has closed.
|
|
708
|
+
"""
|
|
709
|
+
entered = _passes.active_scope()
|
|
710
|
+
if entered is None or _passes.logged_class is None:
|
|
711
|
+
return _ExtendedBuilder(val, description, kind, expected, logger)
|
|
712
|
+
made = _passes.logged_class(val, description, kind, expected, logger)
|
|
713
|
+
made._reports_to = entered
|
|
714
|
+
return made
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class _LogPasses:
|
|
718
|
+
"""The context `log_passes()` hands back. Each entry reports into a scope of its own, so it may be reentered."""
|
|
719
|
+
|
|
720
|
+
__slots__ = ("_level", "_logger", "_on_pass")
|
|
721
|
+
|
|
722
|
+
def __init__(self, logger: PassLogger, level: int, on_pass: Callable[[AssertionOutcome], object] | None) -> None:
|
|
723
|
+
self._logger, self._level, self._on_pass = logger, level, on_pass
|
|
724
|
+
|
|
725
|
+
def __enter__(self) -> None:
|
|
726
|
+
_passes.open_block(_logged_builder_class, _build_with)
|
|
727
|
+
_passes.scope.set(_passes.PassScope(self._logger, self._level, self._on_pass, _passes.scope.get()))
|
|
728
|
+
|
|
729
|
+
def __exit__(self, *_exc: object) -> None:
|
|
730
|
+
try:
|
|
731
|
+
# the entry `__enter__` set in this context, which is the one a `with` statement exits in
|
|
732
|
+
entered = cast("_passes.PassScope", _passes.scope.get())
|
|
733
|
+
# a task outliving the entry reads it from its own context copy, and stops here
|
|
734
|
+
entered.active = False
|
|
735
|
+
_passes.scope.set(entered.previous)
|
|
736
|
+
finally:
|
|
737
|
+
_passes.close_block(_build_with)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def log_passes(
|
|
741
|
+
logger: PassLogger | None = None,
|
|
742
|
+
*,
|
|
743
|
+
level: int = logging.INFO,
|
|
744
|
+
on_pass: Callable[[AssertionOutcome], object] | None = None,
|
|
745
|
+
) -> contextlib.AbstractContextManager[None]:
|
|
746
|
+
"""Report every assertion that passes inside the block, with the value it held for.
|
|
747
|
+
|
|
748
|
+
An assertion that holds says nothing by default. Inside this block each one logs a line naming the call, the
|
|
749
|
+
value and the file and line it was asked at, and, for a comparison against a bound, how far the value was from
|
|
750
|
+
it. Nothing else changes: a failure is raised, collected or logged exactly as it would be outside.
|
|
751
|
+
|
|
752
|
+
One line per assertion the test asked. What an assertion asks underneath (`is_positive()` asking
|
|
753
|
+
`is_greater_than(0)`, an assertion inside a `satisfies()` predicate) is part of it and not reported again. A
|
|
754
|
+
soft or warn assertion that failed is not a pass, nor is one run under `check()`. A poll reports the steps of
|
|
755
|
+
the attempt that passed, once.
|
|
756
|
+
|
|
757
|
+
Uses `contextvars`, so each thread and task has its own: a task started inside the block reports into it while
|
|
758
|
+
the block is open. A builder reports into the block it was made in, while that block is open: one made before
|
|
759
|
+
it reports nothing, and one kept past its end reports nothing, in a later block too. A pivot reports where the
|
|
760
|
+
builder it came from reports.
|
|
761
|
+
|
|
762
|
+
Args:
|
|
763
|
+
logger: where the lines go, any object with ``isEnabledFor()`` and ``log()``. Defaults to the
|
|
764
|
+
``assertpy.passes`` logger
|
|
765
|
+
level: the level of each line. Defaults to ``logging.INFO``
|
|
766
|
+
on_pass: called with each pass's [`AssertionOutcome`][assertpy.outcome.AssertionOutcome] in place of
|
|
767
|
+
logging it: ``passed`` set, the value, the requirement, the ``(file, line)`` and, as ``hint``, how far
|
|
768
|
+
the value was from its bound. Not together with ``logger``
|
|
769
|
+
|
|
770
|
+
Examples:
|
|
771
|
+
Log the passes of a block:
|
|
772
|
+
|
|
773
|
+
from assertpy import assert_that, log_passes
|
|
774
|
+
|
|
775
|
+
with log_passes():
|
|
776
|
+
assert_that(latency).is_less_than(0.5)
|
|
777
|
+
assert_that(status).is_equal_to(200)
|
|
778
|
+
|
|
779
|
+
Each pass is one line at ``INFO`` on the ``assertpy.passes`` logger:
|
|
780
|
+
|
|
781
|
+
[test_api.py:5]: is_less_than(0.5) passed for <0.42>, the value is about 0.08 below the number given
|
|
782
|
+
[test_api.py:6]: is_equal_to(200) passed for <200>
|
|
783
|
+
|
|
784
|
+
Under pytest a passing test shows its log only when asked, so turn on live logging, for one run with
|
|
785
|
+
``pytest -o log_cli=true --log-cli-level=INFO`` or for good in the configuration:
|
|
786
|
+
|
|
787
|
+
[tool.pytest.ini_options]
|
|
788
|
+
log_cli = true
|
|
789
|
+
log_cli_level = "INFO"
|
|
790
|
+
|
|
791
|
+
Raises:
|
|
792
|
+
TypeError: if ``level`` is not an ``int``, ``logger`` has no ``isEnabledFor()`` or ``log()``, ``on_pass``
|
|
793
|
+
is not callable, or both ``logger`` and ``on_pass`` are given
|
|
794
|
+
"""
|
|
795
|
+
if on_pass is not None:
|
|
796
|
+
if logger is not None:
|
|
797
|
+
refuse(logger, "None when on_pass is given, which takes each pass in its place", subject=argument("logger"))
|
|
798
|
+
if not callable(on_pass):
|
|
799
|
+
refuse(on_pass, "callable", subject=argument("on_pass"))
|
|
800
|
+
if logger is not None and not (
|
|
801
|
+
callable(getattr(logger, "isEnabledFor", None)) and callable(getattr(logger, "log", None))
|
|
802
|
+
):
|
|
803
|
+
refuse(logger, "a logger, with isEnabledFor() and log()", subject=argument("logger"))
|
|
804
|
+
if type(level) is not int:
|
|
805
|
+
refuse(level, "an int logging level", subject=argument("level"))
|
|
806
|
+
return _LogPasses(logger if logger is not None else _passes.default_logger, level, on_pass)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
@overload
|
|
810
|
+
def assert_that(val: str, description: str = "") -> _StringAssertion: ...
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
@overload
|
|
814
|
+
def assert_that(val: bool, description: str = "") -> _BoolAssertion: ...
|
|
815
|
+
@overload
|
|
816
|
+
def assert_that(val: int, description: str = "") -> _NumericAssertion[int]: ...
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
@overload
|
|
820
|
+
def assert_that(val: float, description: str = "") -> _NumericAssertion[float]: ...
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@overload
|
|
824
|
+
def assert_that(val: complex, description: str = "") -> _ComplexAssertion: ...
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
@overload
|
|
828
|
+
def assert_that(val: dict[_K, _V], description: str = "") -> _DictAssertion[_K, _V]: ...
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
@overload
|
|
832
|
+
def assert_that(val: list[_E] | tuple[_E, ...], description: str = "") -> _IterableAssertion[_E]: ...
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@overload
|
|
836
|
+
def assert_that(val: set[_E] | frozenset[_E], description: str = "") -> _IterableAssertion[_E]: ...
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
@overload
|
|
840
|
+
# a `datetime` is a `date` and is claimed first: one view for both offered chronology to a type that cannot answer
|
|
841
|
+
def assert_that(val: datetime.datetime, description: str = "") -> _DateTimeAssertion: ...
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
@overload
|
|
845
|
+
def assert_that(val: datetime.date, description: str = "") -> _DateAssertion: ...
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
@overload
|
|
849
|
+
def assert_that(val: pathlib.Path, description: str = "") -> _PathAssertion: ...
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
@overload
|
|
853
|
+
def assert_that(val: bytes, description: str = "") -> _BytesAssertion[bytes]: ...
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
@overload
|
|
857
|
+
def assert_that(val: bytearray, description: str = "") -> _BytesAssertion[bytearray]: ...
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
# a frame is assignable to every structural protocol, so it goes first; `tests/test_overload_order.py` holds that
|
|
861
|
+
@overload
|
|
862
|
+
def assert_that(val: _FrameT_co, description: str = "") -> _FrameAssertion[_FrameT_co]: ...
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
@overload
|
|
866
|
+
def assert_that(val: _ArrayT_co, description: str = "") -> _ArrayAssertion[_ArrayT_co]: ...
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
# the umbrella: below the frame pair so it cannot claim their values, above the fallback so a capability keeps all
|
|
870
|
+
@overload
|
|
871
|
+
def assert_that(val: _CapableT, description: str = "") -> _CapableAssertion[_CapableT]: ...
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
# below the umbrella: an ASGI or WSGI response is a callable, and this claimed one before its capability could
|
|
875
|
+
@overload
|
|
876
|
+
def assert_that(val: Callable[..., _P], description: str = "") -> _CallableAssertion[_P]: ...
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
# the fallback, at the price of an overload overlap mypy and pyright report and ty does not
|
|
880
|
+
@overload
|
|
881
|
+
def assert_that(val: _T, description: str = "") -> _ObjectAssertion[_T]: ...
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
# `Any` rather than the base protocol, which took pyright past its 4 GB heap; the overloads are what a caller sees
|
|
885
|
+
def assert_that(val: object, description="") -> Any:
|
|
886
|
+
"""Set the value to be tested, plus an optional description, and allow assertions to be called.
|
|
887
|
+
|
|
888
|
+
This is a factory method for the `AssertionBuilder`, and the single most important
|
|
889
|
+
method in all of assertpy.
|
|
890
|
+
|
|
891
|
+
Args:
|
|
892
|
+
val: the value to be tested (aka the actual value)
|
|
893
|
+
description (str, optional): the extra error message description. Defaults to ``''``
|
|
894
|
+
(aka empty string)
|
|
895
|
+
|
|
896
|
+
Examples:
|
|
897
|
+
Just import it once at the top of your test file, and away you go...
|
|
898
|
+
|
|
899
|
+
from assertpy import assert_that
|
|
900
|
+
|
|
901
|
+
def test_something():
|
|
902
|
+
assert_that(1 + 2).is_equal_to(3)
|
|
903
|
+
assert_that('foobar').is_length(6).starts_with('foo').ends_with('bar')
|
|
904
|
+
assert_that(['a', 'b', 'c']).contains('a').does_not_contain('x')
|
|
905
|
+
"""
|
|
906
|
+
if _collecting() is not None:
|
|
907
|
+
return _builder(val, description, "soft")
|
|
908
|
+
return _builder(val, description)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _contract_entries(exc: object, prefix: _Path = _ROOT) -> list[DiffEntry]:
|
|
912
|
+
"""Turn a pydantic ``ValidationError`` into diff rows, mirroring the OpenAPI path in `json_mixin`.
|
|
913
|
+
|
|
914
|
+
``errors()`` already carries the location, the message and the offending input, so the structured
|
|
915
|
+
channel costs a reshape rather than a second walk of the payload. Under ``each=True`` the caller
|
|
916
|
+
passes the element's ``[i]`` prefix, the way ``contract_drift`` labels its own paths.
|
|
917
|
+
|
|
918
|
+
``loc`` is already a tuple of keys and indices, so the machine half of the path comes straight
|
|
919
|
+
across rather than being parsed back out of the text.
|
|
920
|
+
"""
|
|
921
|
+
errors = exc.errors() if hasattr(exc, "errors") else [] # ty: ignore[call-non-callable] # duck-typed pydantic
|
|
922
|
+
entries = []
|
|
923
|
+
for error in errors:
|
|
924
|
+
path = prefix
|
|
925
|
+
for segment in error.get("loc", ()):
|
|
926
|
+
path = path.index(segment) if isinstance(segment, int) else path.key(segment)
|
|
927
|
+
entries.append(path.leaf_entry(actual=error.get("input"), expected=error.get("msg", "")))
|
|
928
|
+
return entries
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _exact_failure(val: object, pairs: Any, carrier: str, sent: Any) -> tuple[str, str, DiffResult] | None:
|
|
932
|
+
"""What an exact check of *pairs* found, in the words a failure continues with, the payload as it then reads, and
|
|
933
|
+
the same finds as a diff. ``None`` when it found nothing.
|
|
934
|
+
|
|
935
|
+
All are read while the payload is put back as it was *sent*, where validation changed it in place, and it is
|
|
936
|
+
returned to what validation left before this returns, whatever the check does.
|
|
937
|
+
"""
|
|
938
|
+
left = put_as_sent(val, sent) if sent is not None else None
|
|
939
|
+
try:
|
|
940
|
+
found = exactness_failure(pairs, carrier=carrier)
|
|
941
|
+
if found is None:
|
|
942
|
+
return None
|
|
943
|
+
entries = kept_apart(found[1]) if left else found[1]
|
|
944
|
+
return found[0], _truncated(_safe_str(val)), DiffResult(kind="match", entries=entries)
|
|
945
|
+
finally:
|
|
946
|
+
if left:
|
|
947
|
+
put_back(left)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
@overload
|
|
951
|
+
def assert_conforms(
|
|
952
|
+
val: object, model: type[_U], description: str = ..., *, exact: bool = ..., each: Literal[False] = ...
|
|
953
|
+
) -> AssertionBuilder[_U]: ...
|
|
954
|
+
@overload
|
|
955
|
+
def assert_conforms(
|
|
956
|
+
val: object, model: type[_U], description: str = ..., *, exact: bool = ..., each: Literal[True]
|
|
957
|
+
) -> AssertionBuilder[list[_U]]: ...
|
|
958
|
+
def assert_conforms(
|
|
959
|
+
val: object, model: type[_U], description: str = "", *, exact: bool = False, each: bool = False
|
|
960
|
+
) -> AssertionBuilder[Any]:
|
|
961
|
+
"""Validate ``val`` against a pydantic v2 ``model`` and continue over the validated instance.
|
|
962
|
+
|
|
963
|
+
The narrowing-complete companion to [`assert_that()`][assertpy.assertpy.assert_that] for
|
|
964
|
+
contract testing. Runs ``model.model_validate(val)``: on success the returned builder carries the
|
|
965
|
+
validated, coerced instance (so ``.value`` and ``extracting`` see typed fields); on failure it
|
|
966
|
+
fails with pydantic's validation errors.
|
|
967
|
+
|
|
968
|
+
Because the return type is driven by ``model`` rather than by the type of ``val``, the chain
|
|
969
|
+
narrows to ``model`` for **any** input - including the ``Any`` a decoded JSON payload carries.
|
|
970
|
+
|
|
971
|
+
With ``exact=True`` it also asserts **contract drift**: the payload must not carry fields the model
|
|
972
|
+
does not declare. ``model_validate`` silently drops undeclared fields, so a stale model keeps
|
|
973
|
+
passing after the live API grows new ones.
|
|
974
|
+
|
|
975
|
+
``exact`` catches that drift in the models, dataclasses and ``TypedDict`` values the payload became inside
|
|
976
|
+
lists, tuples, sets, dicts, unions and root models, and reports the exact paths. It is alias-aware, and
|
|
977
|
+
respects a model that opts into extras (``extra="allow"``). A key that spells a field and was not read,
|
|
978
|
+
a name sent beside its alias, is dropped by pydantic like any other, and is named with where the field
|
|
979
|
+
was read from.
|
|
980
|
+
|
|
981
|
+
Raw items that no longer pair one by one with what they became are still checked where they can be:
|
|
982
|
+
|
|
983
|
+
- A set, a filtered list, an object wrapped into a list: validated again by their model class when they
|
|
984
|
+
all became one, which runs that class's validators once more for them. JSON text is read as the
|
|
985
|
+
original JSON input.
|
|
986
|
+
- An emptied list, whose raw items a validator left no built item for: checked against the type their
|
|
987
|
+
field declares. They are validated again as it, which runs the item model's validators but not the
|
|
988
|
+
field's own, so a field validator that would have built them otherwise is not seen.
|
|
989
|
+
|
|
990
|
+
What still cannot be paired fails with ``<path> cannot be checked`` and the reason:
|
|
991
|
+
|
|
992
|
+
- items of mixed classes in a set or a resized list
|
|
993
|
+
- emptied items the declared type does not build, or declares through a union of two or more types
|
|
994
|
+
besides ``None`` or a named type alias
|
|
995
|
+
- merged dict keys
|
|
996
|
+
- a generator consumed during validation, or a lazy ``Iterable``
|
|
997
|
+
|
|
998
|
+
It reads the payload as it was sent: what a validator changed in place in a plain dict or list is put back
|
|
999
|
+
for the check and returned afterwards.
|
|
1000
|
+
|
|
1001
|
+
Two things it does not see:
|
|
1002
|
+
|
|
1003
|
+
- a validator that renames keys, or reorders or rewrites the items of a container without changing its
|
|
1004
|
+
size: those items are read by position
|
|
1005
|
+
- a model inside a container of plain values (``list[Any]``)
|
|
1006
|
+
|
|
1007
|
+
A ``TypedDict`` is read off the schema pydantic built, so text annotations and type variables declare it
|
|
1008
|
+
too. Where several are declared for one value and their ``Literal`` fields do not tell them apart, or a
|
|
1009
|
+
type beside them may build a dict as well, a key the built dict lost fails with ``cannot be checked``.
|
|
1010
|
+
|
|
1011
|
+
An ``exact`` failure carries its finds as a ``diff``: an entry per undeclared field, holding the value the
|
|
1012
|
+
payload sent there and the steps that lead to it, or one entry holding the part that cannot be checked
|
|
1013
|
+
against the reason.
|
|
1014
|
+
|
|
1015
|
+
Args:
|
|
1016
|
+
val: the raw payload to validate (e.g. a decoded JSON response)
|
|
1017
|
+
model: a pydantic v2 model class (anything exposing ``model_validate``)
|
|
1018
|
+
description (str, optional): the extra error message description. Defaults to ``''``
|
|
1019
|
+
exact (bool, optional): also fail if the payload carries fields ``model`` does not declare.
|
|
1020
|
+
Defaults to ``False``
|
|
1021
|
+
each (bool, optional): validate a *list* payload element-by-element against ``model`` (for list
|
|
1022
|
+
endpoints), narrowing the chain to ``list[model]``. ``exact`` then applies per element.
|
|
1023
|
+
Defaults to ``False``
|
|
1024
|
+
|
|
1025
|
+
Examples:
|
|
1026
|
+
Usage:
|
|
1027
|
+
|
|
1028
|
+
from assertpy import assert_conforms, assert_that
|
|
1029
|
+
|
|
1030
|
+
order = assert_conforms(response.json(), OrderModel).value # .value: OrderModel
|
|
1031
|
+
assert_that(order.total).is_greater_than(0)
|
|
1032
|
+
|
|
1033
|
+
# catch silent API growth: fail if the response grew fields the model does not declare
|
|
1034
|
+
assert_conforms(response.json(), OrderModel, exact=True)
|
|
1035
|
+
|
|
1036
|
+
# a list endpoint: validate every item, narrowing to list[OrderModel]
|
|
1037
|
+
orders = assert_conforms(response.json(), OrderModel, each=True).value # .value: list[OrderModel]
|
|
1038
|
+
|
|
1039
|
+
Returns:
|
|
1040
|
+
AssertionBuilder: a builder over the validated model instance, statically typed as ``model``
|
|
1041
|
+
|
|
1042
|
+
Raises:
|
|
1043
|
+
TypeError: if ``model`` is not a pydantic v2 model class
|
|
1044
|
+
AssertionError: if ``val`` does not validate against ``model``, or (with ``exact``) drifts from it or
|
|
1045
|
+
holds a part that cannot be paired with the model built from it
|
|
1046
|
+
"""
|
|
1047
|
+
if not (isinstance(model, type) and hasattr(model, "model_validate")):
|
|
1048
|
+
raise TypeError("assert_conforms requires a pydantic v2 model class")
|
|
1049
|
+
kind = "soft" if _collecting() is not None else None
|
|
1050
|
+
builder = _builder(val, description, kind)
|
|
1051
|
+
if type(builder) is _passes.logged_class:
|
|
1052
|
+
asked: dict[str, object] = {"model": model, "description": description, "exact": exact, "each": each}
|
|
1053
|
+
written: dict[str, object] = {name: True for name in ("exact", "each") if asked[name]}
|
|
1054
|
+
return _observe(
|
|
1055
|
+
builder,
|
|
1056
|
+
lambda: _conforming(builder, val, model, description, exact=exact, each=each, kind=kind),
|
|
1057
|
+
lambda: ("assert_conforms", (model,), written, Requirement("assert_conforms", asked)),
|
|
1058
|
+
)
|
|
1059
|
+
return _conforming(builder, val, model, description, exact=exact, each=each, kind=kind)
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _conforming(
|
|
1063
|
+
builder: AssertionBuilder[Any],
|
|
1064
|
+
val: object,
|
|
1065
|
+
model: Any,
|
|
1066
|
+
description: str,
|
|
1067
|
+
*,
|
|
1068
|
+
exact: bool,
|
|
1069
|
+
each: bool,
|
|
1070
|
+
kind: str | None,
|
|
1071
|
+
) -> AssertionBuilder[Any]:
|
|
1072
|
+
"""The body of `assert_conforms()`, run beneath its frame, which is the one a failure reads its requirement off."""
|
|
1073
|
+
pydantic = sys.modules.get("pydantic") # loaded already, since model exposes model_validate
|
|
1074
|
+
catchable: tuple[type[BaseException], ...] = (pydantic.ValidationError,) if pydantic is not None else ()
|
|
1075
|
+
# only a model with code of its own can change the payload it validates; what it is sent is kept for the check
|
|
1076
|
+
sent = sent_record(val) if exact and runs_own_code(model) else None
|
|
1077
|
+
if each:
|
|
1078
|
+
if not isinstance(val, (list, tuple)):
|
|
1079
|
+
raise TypeError("assert_conforms(each=True) requires a list or tuple payload")
|
|
1080
|
+
validated_items = []
|
|
1081
|
+
for index, item in enumerate(val):
|
|
1082
|
+
try:
|
|
1083
|
+
validated_items.append(model.model_validate(item))
|
|
1084
|
+
except catchable as exc: # noqa: PERF203 # per-element catch reports which item failed; ~0 cost on 3.11+
|
|
1085
|
+
return builder.error(
|
|
1086
|
+
f"Expected item [{index}] to conform to <{model.__name__}>, but it did not:\n{exc}",
|
|
1087
|
+
actual=val,
|
|
1088
|
+
expected=model,
|
|
1089
|
+
diff=DiffResult(kind="match", entries=_contract_entries(exc, _ROOT.index(index))),
|
|
1090
|
+
suppress_context=True,
|
|
1091
|
+
)
|
|
1092
|
+
if exact:
|
|
1093
|
+
items = enumerate(zip(val, validated_items, strict=True))
|
|
1094
|
+
found = _exact_failure(val, ((item, it, index) for index, (item, it) in items), "", sent)
|
|
1095
|
+
if found:
|
|
1096
|
+
return builder.error(
|
|
1097
|
+
f"Expected every item to conform exactly to <{model.__name__}>, but {found[0]}",
|
|
1098
|
+
actual=val,
|
|
1099
|
+
expected=model,
|
|
1100
|
+
diff=found[2],
|
|
1101
|
+
)
|
|
1102
|
+
return builder.builder(validated_items, description, kind)
|
|
1103
|
+
try:
|
|
1104
|
+
validated = model.model_validate(val)
|
|
1105
|
+
except catchable as exc:
|
|
1106
|
+
return builder.error(
|
|
1107
|
+
f"Expected <{_truncated(_safe_str(val))}> to conform to <{model.__name__}>, but it did not:\n{exc}",
|
|
1108
|
+
actual=val,
|
|
1109
|
+
expected=model,
|
|
1110
|
+
diff=DiffResult(kind="match", entries=_contract_entries(exc)),
|
|
1111
|
+
suppress_context=True,
|
|
1112
|
+
)
|
|
1113
|
+
if exact:
|
|
1114
|
+
found = _exact_failure(val, [(val, validated, None)], "it carries ", sent)
|
|
1115
|
+
if found:
|
|
1116
|
+
return builder.error(
|
|
1117
|
+
f"Expected <{found[1]}> to conform exactly to <{model.__name__}>, but {found[0]}",
|
|
1118
|
+
actual=val,
|
|
1119
|
+
expected=model,
|
|
1120
|
+
diff=found[2],
|
|
1121
|
+
)
|
|
1122
|
+
return builder.builder(validated, description, kind)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def assert_warn(
|
|
1126
|
+
val: object,
|
|
1127
|
+
description: str = "",
|
|
1128
|
+
logger: WarningLogger | None = None,
|
|
1129
|
+
*,
|
|
1130
|
+
on_failure: Callable[[AssertionOutcome], object] | None = None,
|
|
1131
|
+
) -> Any:
|
|
1132
|
+
"""Set the value to be tested, and optional description and logger, and allow assertions to be
|
|
1133
|
+
called, but never fail, only log warnings or hand each failure to ``on_failure``.
|
|
1134
|
+
|
|
1135
|
+
This is a factory method for the `AssertionBuilder`, but unlike [`assert_that()`][assertpy.assertpy.assert_that] a
|
|
1136
|
+
failed assertion raises no `AssertionError` and does not halt execution. Instead, any assertion failures
|
|
1137
|
+
results in a warning message being logged. Uses the given logger, or defaults to a simple logger that prints
|
|
1138
|
+
warnings to ``stdout``. An exception the logger or an ``on_failure`` callable raises does come out of the
|
|
1139
|
+
assertion that failed, whatever its type.
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
Args:
|
|
1143
|
+
val: the value to be tested (aka the actual value)
|
|
1144
|
+
description (str, optional): the extra error message description. Defaults to ``''``
|
|
1145
|
+
(aka empty string)
|
|
1146
|
+
logger (Logger, optional): the logger for warning message on assertion failure. Defaults to ``None``
|
|
1147
|
+
(aka use the default simple logger that prints warnings to ``stdout``)
|
|
1148
|
+
on_failure (Callable, optional): called with each failure's
|
|
1149
|
+
[`AssertionOutcome`][assertpy.outcome.AssertionOutcome] in place of logging it: the message, the
|
|
1150
|
+
values, the diff and the ``(file, line)`` it was asked at, for every link of the chain. An exception
|
|
1151
|
+
it raises, an ``AssertionError`` included, comes out of the assertion that failed. Not together with
|
|
1152
|
+
``logger``.
|
|
1153
|
+
|
|
1154
|
+
Examples:
|
|
1155
|
+
Usage:
|
|
1156
|
+
|
|
1157
|
+
from assertpy import assert_warn
|
|
1158
|
+
|
|
1159
|
+
assert_warn('foo').is_length(4)
|
|
1160
|
+
assert_warn('foo').is_empty()
|
|
1161
|
+
assert_warn('foo').is_false()
|
|
1162
|
+
assert_warn('foo').is_digit()
|
|
1163
|
+
assert_warn('123').is_alpha()
|
|
1164
|
+
|
|
1165
|
+
Even though all of the above assertions fail, ``AssertionError`` is never raised and
|
|
1166
|
+
test execution is never halted. Instead, the failed assertions merely log the following
|
|
1167
|
+
warning messages to ``stdout``, each after the time it was logged, which is left out here:
|
|
1168
|
+
|
|
1169
|
+
WARNING [test.py:23]: Expected <foo> to be of length <4>, but was <3>.
|
|
1170
|
+
WARNING [test.py:24]: Expected <foo> to be empty string, but was not.
|
|
1171
|
+
WARNING [test.py:25]: Expected <False>, but was not.
|
|
1172
|
+
WARNING [test.py:26]: Expected <foo> to contain only digits, but did not.
|
|
1173
|
+
WARNING [test.py:27]: Expected <123> to contain only alphabetic chars, but did not.
|
|
1174
|
+
|
|
1175
|
+
Tip:
|
|
1176
|
+
Use `assert_warn()` if and only if you have a *really* good reason to log assertion
|
|
1177
|
+
failures instead of failing.
|
|
1178
|
+
"""
|
|
1179
|
+
if on_failure is not None:
|
|
1180
|
+
if logger is not None:
|
|
1181
|
+
refuse(
|
|
1182
|
+
logger,
|
|
1183
|
+
"None when on_failure is given, which takes each failure in its place",
|
|
1184
|
+
subject=argument("logger"),
|
|
1185
|
+
)
|
|
1186
|
+
if not callable(on_failure):
|
|
1187
|
+
refuse(on_failure, "callable", subject=argument("on_failure"))
|
|
1188
|
+
logger = _Subscribed(on_failure)
|
|
1189
|
+
return _builder(val, description, "warn", logger=logger)
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def fail(msg: str = "") -> NoReturn:
|
|
1193
|
+
"""Force immediate test failure with the given message.
|
|
1194
|
+
|
|
1195
|
+
Args:
|
|
1196
|
+
msg (str, optional): the failure message. Defaults to ``''``
|
|
1197
|
+
|
|
1198
|
+
Examples:
|
|
1199
|
+
Fail a test:
|
|
1200
|
+
|
|
1201
|
+
from assertpy import assert_that, fail
|
|
1202
|
+
|
|
1203
|
+
def test_fail():
|
|
1204
|
+
fail('forced fail!')
|
|
1205
|
+
|
|
1206
|
+
If you wanted to test for a known failure, here is a useful pattern:
|
|
1207
|
+
|
|
1208
|
+
import operator
|
|
1209
|
+
|
|
1210
|
+
def test_adder_bad_arg():
|
|
1211
|
+
try:
|
|
1212
|
+
operator.add(1, 'bad arg')
|
|
1213
|
+
fail('should have raised error')
|
|
1214
|
+
except TypeError as e:
|
|
1215
|
+
assert_that(str(e)).contains('unsupported operand')
|
|
1216
|
+
"""
|
|
1217
|
+
# the same class as every other failure, so a handler does not have to name two of them
|
|
1218
|
+
raise AssertionFailure(f"Fail: {msg}!" if msg else "Fail!")
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def soft_fail(msg: str = "") -> None:
|
|
1222
|
+
"""Within a [`soft_assertions()`][assertpy.assertpy.soft_assertions] context, append the failure
|
|
1223
|
+
message to the soft error list, but do not halt test execution.
|
|
1224
|
+
|
|
1225
|
+
Otherwise, outside the context, acts identical to [`fail()`][assertpy.assertpy.fail] and forces immediate test
|
|
1226
|
+
failure with the given message.
|
|
1227
|
+
|
|
1228
|
+
Args:
|
|
1229
|
+
msg (str, optional): the failure message. Defaults to ``''``
|
|
1230
|
+
|
|
1231
|
+
Examples:
|
|
1232
|
+
Failing soft assertions:
|
|
1233
|
+
|
|
1234
|
+
from assertpy import assert_that, soft_assertions, soft_fail
|
|
1235
|
+
|
|
1236
|
+
with soft_assertions():
|
|
1237
|
+
assert_that(1).is_equal_to(2)
|
|
1238
|
+
soft_fail('my message')
|
|
1239
|
+
assert_that('foo').is_equal_to('bar')
|
|
1240
|
+
|
|
1241
|
+
Fails, and outputs the following soft error list (each tagged with its ``file:line``):
|
|
1242
|
+
|
|
1243
|
+
AssertionError: soft assertion failures:
|
|
1244
|
+
1. Expected <1> to be equal to <2>, but was not. [test_add.py:10]
|
|
1245
|
+
2. Fail: my message! [test_add.py:11]
|
|
1246
|
+
3. Expected <foo> to be equal to <bar>, but was not. [test_add.py:12]
|
|
1247
|
+
|
|
1248
|
+
"""
|
|
1249
|
+
if (block := _collecting()) is not None:
|
|
1250
|
+
if _passes.open_blocks:
|
|
1251
|
+
_passes.mark_failed()
|
|
1252
|
+
block.failures.append(
|
|
1253
|
+
AssertionOutcome(
|
|
1254
|
+
message=f"Fail: {msg}!" if msg else "Fail!",
|
|
1255
|
+
group=_soft_group.get(),
|
|
1256
|
+
location=_caller_location(),
|
|
1257
|
+
)
|
|
1258
|
+
)
|
|
1259
|
+
return
|
|
1260
|
+
fail(msg)
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
_extensions = {}
|
|
1264
|
+
# guards the check-then-set below against two threads adding the same name
|
|
1265
|
+
_extensions_lock = threading.Lock()
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
# `not_` is answered inside `__getattr__` rather than declared, so it is named here
|
|
1269
|
+
_POLLING_NAMES: Final = frozenset(
|
|
1270
|
+
name
|
|
1271
|
+
for builder in (AsyncAssertionBuilder, SyncAssertionBuilder)
|
|
1272
|
+
for name in vars(builder)
|
|
1273
|
+
if not name.startswith("_")
|
|
1274
|
+
) | {"not_"}
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def add_extension(func: _Extension, *, override: bool = False) -> None:
|
|
1278
|
+
"""Add a new user-defined custom assertion to assertpy.
|
|
1279
|
+
|
|
1280
|
+
Once the assertion is registered with assertpy, use it like any other assertion. Pass val to
|
|
1281
|
+
[`assert_that()`][assertpy.assertpy.assert_that], and then call it.
|
|
1282
|
+
|
|
1283
|
+
A name already in use is refused, so an extension that would quietly replace a built-in assertion
|
|
1284
|
+
or another extension says so instead. Registering the same implementation again is not a clash:
|
|
1285
|
+
a module-scoped ``conftest`` fixture rebuilds its function on every module that requests it.
|
|
1286
|
+
|
|
1287
|
+
Args:
|
|
1288
|
+
func (Callable): the assertion function (to be added)
|
|
1289
|
+
override: replace an assertion of the same name instead of refusing
|
|
1290
|
+
|
|
1291
|
+
Raises:
|
|
1292
|
+
TypeError: if ``func`` is not callable
|
|
1293
|
+
ValueError: if its ``__name__`` is not an identifier, or the name is taken and ``override``
|
|
1294
|
+
is false
|
|
1295
|
+
|
|
1296
|
+
Examples:
|
|
1297
|
+
Usage:
|
|
1298
|
+
|
|
1299
|
+
from assertpy import add_extension
|
|
1300
|
+
|
|
1301
|
+
def is_5(self):
|
|
1302
|
+
if self.val != 5:
|
|
1303
|
+
return self.error(f'{self.val} is NOT 5!')
|
|
1304
|
+
return self
|
|
1305
|
+
|
|
1306
|
+
add_extension(is_5)
|
|
1307
|
+
|
|
1308
|
+
def test_5():
|
|
1309
|
+
assert_that(5).is_5()
|
|
1310
|
+
|
|
1311
|
+
def test_6():
|
|
1312
|
+
assert_that(6).is_5() # fails
|
|
1313
|
+
# 6 is NOT 5!
|
|
1314
|
+
"""
|
|
1315
|
+
if not callable(func):
|
|
1316
|
+
refuse(func, "callable", subject=argument("func"))
|
|
1317
|
+
name = getattr(func, "__name__", None)
|
|
1318
|
+
if not isinstance(name, str) or not name.isidentifier():
|
|
1319
|
+
raise ValueError(f"the assertion's __name__ must be a valid Python identifier, got {name!r}")
|
|
1320
|
+
# nothing to override: after `eventually()` the name is out of reach, and `override=True` bought silence
|
|
1321
|
+
if name in _POLLING_NAMES or name.startswith(("_", "cr_", "gi_")):
|
|
1322
|
+
raise ValueError(
|
|
1323
|
+
f"{name!r} is how a polling chain spells one of its own, so an extension of that name "
|
|
1324
|
+
f"would be unreachable after eventually(); give it another name"
|
|
1325
|
+
)
|
|
1326
|
+
with _extensions_lock:
|
|
1327
|
+
# re-adding the same implementation is a no-op, not a clash. Compared against what was
|
|
1328
|
+
# registered rather than against the wrapper naming it, whose code every extension shares
|
|
1329
|
+
same = is_same_implementation(_registered(vars(_ExtendedBuilder).get(name)), func) or is_same_implementation(
|
|
1330
|
+
_registered(_extensions.get(name)), func
|
|
1331
|
+
)
|
|
1332
|
+
if not override and not same:
|
|
1333
|
+
# an extension called `is_equal_to` used to replace the core assertion in silence
|
|
1334
|
+
if name in vars(_ExtendedBuilder) or name in _extensions:
|
|
1335
|
+
raise ValueError(
|
|
1336
|
+
f"an assertion named {name!r} has already been added; pass override=True to "
|
|
1337
|
+
f"replace it, or remove_extension() it first"
|
|
1338
|
+
)
|
|
1339
|
+
if hasattr(AssertionBuilder, name):
|
|
1340
|
+
raise ValueError(
|
|
1341
|
+
f"{name!r} is already defined on the assertion builder; pass override=True to "
|
|
1342
|
+
f"replace it deliberately, or give the extension another name"
|
|
1343
|
+
)
|
|
1344
|
+
named = _named_extension(name, func)
|
|
1345
|
+
# one implementation per name, so an override that changes shape clears the other half too: a
|
|
1346
|
+
# callable grafted onto the instance in `_builder()` shadows a function set on the class
|
|
1347
|
+
if isinstance(func, types.FunctionType):
|
|
1348
|
+
_extensions.pop(name, None)
|
|
1349
|
+
# the descriptor protocol binds once here, and the subclass keeps `AssertionBuilder` pristine on removal
|
|
1350
|
+
setattr(_ExtendedBuilder, name, named)
|
|
1351
|
+
else:
|
|
1352
|
+
if name in vars(_ExtendedBuilder):
|
|
1353
|
+
delattr(_ExtendedBuilder, name)
|
|
1354
|
+
_extensions[name] = named
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def remove_extension(func: _Extension) -> None:
|
|
1358
|
+
"""Remove a user-defined custom assertion.
|
|
1359
|
+
|
|
1360
|
+
Args:
|
|
1361
|
+
func (Callable): the assertion function (to be removed)
|
|
1362
|
+
|
|
1363
|
+
Examples:
|
|
1364
|
+
Usage:
|
|
1365
|
+
|
|
1366
|
+
from assertpy import remove_extension
|
|
1367
|
+
|
|
1368
|
+
remove_extension(is_5)
|
|
1369
|
+
"""
|
|
1370
|
+
if not callable(func):
|
|
1371
|
+
refuse(func, "callable", subject=argument("func"))
|
|
1372
|
+
if func.__name__ in vars(_ExtendedBuilder):
|
|
1373
|
+
delattr(_ExtendedBuilder, func.__name__)
|
|
1374
|
+
_extensions.pop(func.__name__, None)
|
|
1375
|
+
# a signature cached against a function nobody can call any more
|
|
1376
|
+
_method_signature.cache_clear()
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
class _ExtensionCall:
|
|
1380
|
+
"""One running call of one extension: what it was asked, on which builder, and whether it is still on.
|
|
1381
|
+
|
|
1382
|
+
``alive`` exists for the same reason `_SoftBlock.active` does. A task created inside the call
|
|
1383
|
+
inherits the context variable by value and never sees the reset, so without it a failure raised
|
|
1384
|
+
after the call had returned was still reported under the extension's name.
|
|
1385
|
+
"""
|
|
1386
|
+
|
|
1387
|
+
__slots__ = ("alive", "args", "builder", "func", "kwargs", "name")
|
|
1388
|
+
|
|
1389
|
+
def __init__(
|
|
1390
|
+
self,
|
|
1391
|
+
builder: object,
|
|
1392
|
+
name: str,
|
|
1393
|
+
func: Callable[..., object],
|
|
1394
|
+
args: tuple[object, ...],
|
|
1395
|
+
kwargs: dict[str, object],
|
|
1396
|
+
) -> None:
|
|
1397
|
+
self.builder = builder
|
|
1398
|
+
self.name = name
|
|
1399
|
+
self.func = func
|
|
1400
|
+
self.args = args
|
|
1401
|
+
self.kwargs = kwargs
|
|
1402
|
+
self.alive = True
|
|
1403
|
+
|
|
1404
|
+
def answers_for(self, builder: object) -> bool:
|
|
1405
|
+
"""Whether a failure on *builder* is this call's to name."""
|
|
1406
|
+
return self.alive and self.builder is builder
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
_asked_of: contextvars.ContextVar[_ExtensionCall | None] = contextvars.ContextVar("assertpy_asked_of", default=None)
|
|
1410
|
+
"""The extension call now running, set before its body and read only if it fails.
|
|
1411
|
+
|
|
1412
|
+
An extension cannot be recognised from its frame. Two instances of one callable class share
|
|
1413
|
+
``__call__``'s code, two functions built by one decorator factory share the wrapper's, and a
|
|
1414
|
+
`functools.partial` runs no Python frame of its own at all. Naming it at the call is exact for every
|
|
1415
|
+
shape, and it costs an extension call rather than every assertion.
|
|
1416
|
+
|
|
1417
|
+
Scoped to one builder, because the name belongs to a call and not to everything that runs during it.
|
|
1418
|
+
An extension asserting about some other value has that assertion answer for itself, exactly as one
|
|
1419
|
+
inside a `satisfies()` predicate does. On the same builder the outer call keeps the name, which is
|
|
1420
|
+
what a built-in does: `is_positive()` reports itself rather than the `is_greater_than()` it asks.
|
|
1421
|
+
"""
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def _registered(installed: object) -> object:
|
|
1425
|
+
"""What was handed to `add_extension()`, behind the wrapper that names it."""
|
|
1426
|
+
return getattr(installed, "__wrapped__", installed)
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def _named_extension(name: str, func: Callable[..., object]) -> Callable[..., object]:
|
|
1430
|
+
"""*func*, saying what it was asked for the length of the call."""
|
|
1431
|
+
|
|
1432
|
+
@functools.wraps(func)
|
|
1433
|
+
def named(self: object, *args: object, **kwargs: object) -> object:
|
|
1434
|
+
outer = _asked_of.get()
|
|
1435
|
+
if outer is not None and outer.answers_for(self):
|
|
1436
|
+
return func(self, *args, **kwargs)
|
|
1437
|
+
call = _ExtensionCall(self, name, func, args, kwargs)
|
|
1438
|
+
token = _asked_of.set(call)
|
|
1439
|
+
try:
|
|
1440
|
+
if _passes.open_blocks and type(self) is _passes.logged_class:
|
|
1441
|
+
return _observe_extension(cast("AssertionBuilder[Any]", self), func, args, kwargs, call)
|
|
1442
|
+
return func(self, *args, **kwargs)
|
|
1443
|
+
finally:
|
|
1444
|
+
call.alive = False
|
|
1445
|
+
_asked_of.reset(token)
|
|
1446
|
+
|
|
1447
|
+
return named
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _observe_extension(
|
|
1451
|
+
builder: AssertionBuilder[Any],
|
|
1452
|
+
func: Callable[..., object],
|
|
1453
|
+
args: tuple[object, ...],
|
|
1454
|
+
kwargs: dict[str, object],
|
|
1455
|
+
call: _ExtensionCall,
|
|
1456
|
+
) -> object:
|
|
1457
|
+
"""An extension's call, observed. Apart from `named`: a closure there made its locals cells on every call."""
|
|
1458
|
+
return _observe(
|
|
1459
|
+
builder,
|
|
1460
|
+
lambda: func(builder, *args, **kwargs),
|
|
1461
|
+
lambda: (call.name, args, kwargs, _extension_requirement(call)),
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def _extension_requirement(asked: _ExtensionCall) -> Requirement:
|
|
1466
|
+
"""What the running extension was asked, bound to its own parameter names where they can be read."""
|
|
1467
|
+
name, func, args, kwargs = asked.name, asked.func, asked.args, asked.kwargs
|
|
1468
|
+
found = _signature_of(func)
|
|
1469
|
+
if found is not None:
|
|
1470
|
+
# the receiver is the builder, which the caller did not write
|
|
1471
|
+
caller_writes = found.replace(parameters=list(found.parameters.values())[1:])
|
|
1472
|
+
with contextlib.suppress(TypeError):
|
|
1473
|
+
bound = caller_writes.bind(*args, **kwargs)
|
|
1474
|
+
bound.apply_defaults()
|
|
1475
|
+
return Requirement(name, dict(bound.arguments))
|
|
1476
|
+
# a signature nothing can read, or a call it disagrees with: the operands as the caller passed them
|
|
1477
|
+
return Requirement(name, {"args": args, "kwargs": kwargs})
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _call_text(name: str, args: tuple[object, ...], kwargs: Mapping[str, object]) -> str:
|
|
1481
|
+
"""*name* called with *args* and *kwargs* as a call is written: ``key=value``, keywords in the caller's order."""
|
|
1482
|
+
rendered = ", ".join(
|
|
1483
|
+
[_capped_repr(arg) for arg in args] + [f"{key}={_capped_repr(value)}" for key, value in kwargs.items()]
|
|
1484
|
+
)
|
|
1485
|
+
return f"{name}({rendered})"
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def _bound_requirement(
|
|
1489
|
+
attr: Callable[..., object],
|
|
1490
|
+
name: str,
|
|
1491
|
+
args: tuple[object, ...],
|
|
1492
|
+
kwargs: dict[str, object],
|
|
1493
|
+
*,
|
|
1494
|
+
negated: bool = False,
|
|
1495
|
+
) -> Requirement:
|
|
1496
|
+
"""What a call asked, bound to the parameter names of *attr*, as the positive path reads them off the frame."""
|
|
1497
|
+
found = _signature_of(attr)
|
|
1498
|
+
if found is None:
|
|
1499
|
+
# a builtin with no introspectable signature, or a call the assertion itself will refuse
|
|
1500
|
+
return Requirement(name, {"args": args, "kwargs": kwargs}, negated=negated)
|
|
1501
|
+
try:
|
|
1502
|
+
bound = found.bind(*args, **kwargs)
|
|
1503
|
+
except TypeError:
|
|
1504
|
+
return Requirement(name, {"args": args, "kwargs": kwargs}, negated=negated)
|
|
1505
|
+
bound.apply_defaults()
|
|
1506
|
+
return Requirement(name, dict(bound.arguments), negated=negated)
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
_Ran = TypeVar("_Ran")
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def _observe(
|
|
1513
|
+
builder: AssertionBuilder[Any],
|
|
1514
|
+
run: Callable[[], _Ran],
|
|
1515
|
+
describe: Callable[[], tuple[str, tuple[object, ...], dict[str, object], Requirement]],
|
|
1516
|
+
) -> _Ran:
|
|
1517
|
+
"""Run one assertion of a builder a `log_passes()` block made, and report it if it held.
|
|
1518
|
+
|
|
1519
|
+
Not reported: one asked inside another being observed, which is the outer one's to report, one run for its
|
|
1520
|
+
verdict (`check()`, and the run `not_` inverts), and one that delivered a failure without raising.
|
|
1521
|
+
*describe* is asked only for a pass that is reported.
|
|
1522
|
+
"""
|
|
1523
|
+
if _passes.current_call() is not None:
|
|
1524
|
+
return run()
|
|
1525
|
+
found: _passes.PassScope | None = getattr(builder, "_reports_to", None)
|
|
1526
|
+
if found is None or not found.active:
|
|
1527
|
+
return run()
|
|
1528
|
+
# a verdict asked for is not reported, and neither is anything asked inside it
|
|
1529
|
+
asked_for_a_verdict = builder.kind == "check"
|
|
1530
|
+
call = _passes.Observed()
|
|
1531
|
+
token = _passes.observed.set(call)
|
|
1532
|
+
try:
|
|
1533
|
+
result = run()
|
|
1534
|
+
# still inside the call, so an assertion the logger or the callback makes is its, and reports nothing
|
|
1535
|
+
if not (asked_for_a_verdict or call.failed) and _passes.wants(found):
|
|
1536
|
+
_report_pass(found, builder, *describe())
|
|
1537
|
+
finally:
|
|
1538
|
+
call.alive = False
|
|
1539
|
+
_passes.observed.reset(token)
|
|
1540
|
+
return result
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def _report_pass(
|
|
1544
|
+
found: _passes.PassScope,
|
|
1545
|
+
builder: AssertionBuilder[Any],
|
|
1546
|
+
name: str,
|
|
1547
|
+
args: tuple[object, ...],
|
|
1548
|
+
kwargs: dict[str, object],
|
|
1549
|
+
requirement: Requirement,
|
|
1550
|
+
) -> None:
|
|
1551
|
+
"""Hand one pass to the block: the record now, its line only if a logger is to print it."""
|
|
1552
|
+
location = _caller_location()
|
|
1553
|
+
val, description = builder.val, builder.description
|
|
1554
|
+
hint = _hints.passed_by(requirement.operation, val, requirement.parameters, negated=requirement.negated)
|
|
1555
|
+
outcome = AssertionOutcome(passed=True, actual=val, requirement=requirement, location=location, hint=hint)
|
|
1556
|
+
step = f"not_.{name}" if requirement.negated else name
|
|
1557
|
+
|
|
1558
|
+
def line() -> str:
|
|
1559
|
+
where = f"[{os.path.basename(location[0])}:{location[1]}]: " if location is not None else ""
|
|
1560
|
+
desc = f"[{description}] " if description else ""
|
|
1561
|
+
margin = f", {hint}" if hint else ""
|
|
1562
|
+
text = f"{where}{desc}{_call_text(step, args, kwargs)} passed for <{_capped(val)}>{margin}"
|
|
1563
|
+
# one record, one line; and a lone surrogate cannot be encoded, which pytest-xdist does to captured logs
|
|
1564
|
+
text = text.replace("\r", "\\r").replace("\n", "\\n").encode("utf-8", "backslashreplace").decode("utf-8")
|
|
1565
|
+
return _errors._ends_kept(text)
|
|
1566
|
+
|
|
1567
|
+
_passes.emit(found, outcome, line)
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def _observing(name: str) -> Callable[..., object]:
|
|
1571
|
+
"""The built-in assertion *name*, observed on a builder a `log_passes()` block made."""
|
|
1572
|
+
own = getattr(AssertionBuilder, name)
|
|
1573
|
+
|
|
1574
|
+
@functools.wraps(own)
|
|
1575
|
+
def observed(self: AssertionBuilder[Any], *args: object, **kwargs: object) -> object:
|
|
1576
|
+
taken = vars(_ExtendedBuilder).get(name)
|
|
1577
|
+
if taken is not None:
|
|
1578
|
+
# an extension took the name over, and reports itself as one
|
|
1579
|
+
return taken(self, *args, **kwargs)
|
|
1580
|
+
return _observe(
|
|
1581
|
+
self,
|
|
1582
|
+
lambda: own(self, *args, **kwargs),
|
|
1583
|
+
lambda: (name, args, kwargs, _bound_requirement(types.MethodType(own, self), name, args, kwargs)),
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
return observed
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def _observed_dynamic(self: AssertionBuilder[Any], name: str) -> object:
|
|
1590
|
+
"""A dynamic ``has_`` assertion, observed as the built-ins are; anything else is refused as it always was."""
|
|
1591
|
+
found = DynamicMixin.__getattr__(self, name)
|
|
1592
|
+
|
|
1593
|
+
# wrapped, so `not_` binds the operand to the name the assertion gives it
|
|
1594
|
+
@functools.wraps(found)
|
|
1595
|
+
def observed(*args: object, **kwargs: object) -> object:
|
|
1596
|
+
return _observe(
|
|
1597
|
+
self,
|
|
1598
|
+
lambda: found(*args, **kwargs),
|
|
1599
|
+
lambda: (name, args, kwargs, Requirement(name, _one_operand(args, kwargs))),
|
|
1600
|
+
)
|
|
1601
|
+
|
|
1602
|
+
return observed
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
def _logged_builder_class() -> type[Any]:
|
|
1606
|
+
"""The class a `log_passes()` block builds with: every operation of `_ASSERTS` observed, nothing else changed."""
|
|
1607
|
+
namespace: dict[str, object] = {name: _observing(name) for name in _ASSERTS if name != "assert_conforms"}
|
|
1608
|
+
namespace["__getattr__"] = _observed_dynamic
|
|
1609
|
+
namespace["_reports_to"] = None
|
|
1610
|
+
namespace["__doc__"] = "A builder made inside a `log_passes()` block, whose assertions report themselves."
|
|
1611
|
+
return type("_LoggedBuilder", (_ExtendedBuilder,), namespace)
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def _builder(val, description="", kind=None, expected=None, logger=None):
|
|
1615
|
+
"""Internal helper to build a new `AssertionBuilder` instance and glue on any extension methods.
|
|
1616
|
+
|
|
1617
|
+
Function extensions already live on `_ExtendedBuilder`; only non-function callables (which the
|
|
1618
|
+
descriptor protocol cannot bind) still need per-instance grafting here. Grafted in place rather than through
|
|
1619
|
+
`_grafted()`, whose call cost `assert_that()` 3%.
|
|
1620
|
+
"""
|
|
1621
|
+
builder = _made_builder(val, description, kind, expected, logger)
|
|
1622
|
+
if _extensions:
|
|
1623
|
+
for name, func in _extensions.items():
|
|
1624
|
+
meth = types.MethodType(func, builder)
|
|
1625
|
+
setattr(builder, name, meth)
|
|
1626
|
+
return builder
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _grafted(builder):
|
|
1630
|
+
"""*builder* with every non-function extension bound to it."""
|
|
1631
|
+
if _extensions:
|
|
1632
|
+
for name, func in _extensions.items():
|
|
1633
|
+
meth = types.MethodType(func, builder)
|
|
1634
|
+
setattr(builder, name, meth)
|
|
1635
|
+
return builder
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def _builder_like(parent, val, description, kind, expected, logger):
|
|
1639
|
+
"""A pivot of *parent* while a block is open: observed exactly when *parent* is, into the entry it reports to.
|
|
1640
|
+
|
|
1641
|
+
A pivot made through `_builder()` asked the context that made it, so a builder made before its block took an
|
|
1642
|
+
observed pivot inside it, and one made in a closed entry handed its pivot to the entry open now.
|
|
1643
|
+
"""
|
|
1644
|
+
observed = _passes.logged_class
|
|
1645
|
+
if observed is None or type(parent) is not observed:
|
|
1646
|
+
return _grafted(_ExtendedBuilder(val, description, kind, expected, logger))
|
|
1647
|
+
made = observed(val, description, kind, expected, logger)
|
|
1648
|
+
made._reports_to = parent._reports_to
|
|
1649
|
+
return _grafted(made)
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
class WarningLoggingAdapter(_LoggerAdapter):
|
|
1653
|
+
"""Logging adapter to unwind the stack to get the correct callee filename and line number."""
|
|
1654
|
+
|
|
1655
|
+
def process(self, msg: Any, kwargs: Any) -> tuple[Any, Any]:
|
|
1656
|
+
caller = _caller_location()
|
|
1657
|
+
if caller is None:
|
|
1658
|
+
return msg, kwargs
|
|
1659
|
+
filename, lineno = caller
|
|
1660
|
+
return f"[{os.path.basename(filename)}:{lineno}]: {msg}", kwargs
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
class _Subscribed:
|
|
1664
|
+
"""The callback `assert_warn(on_failure=...)` hands each failure to, carried where the logger is carried.
|
|
1665
|
+
|
|
1666
|
+
In the logger's place because every builder a chain goes on to is built with the logger the chain was given:
|
|
1667
|
+
a pivot, `extracting()` and a poll's own builder carry this too, and six pivots once dropped the logger.
|
|
1668
|
+
"""
|
|
1669
|
+
|
|
1670
|
+
__slots__ = ("callback",)
|
|
1671
|
+
|
|
1672
|
+
def __init__(self, callback: Callable[[AssertionOutcome], object]) -> None:
|
|
1673
|
+
self.callback = callback
|
|
1674
|
+
|
|
1675
|
+
def warning(self, msg: str) -> None:
|
|
1676
|
+
"""What a code of anyone's that logs through the chain's logger reaches: a record carrying its message."""
|
|
1677
|
+
self.callback(AssertionOutcome(message=msg, location=_caller_location()))
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
_logger = logging.getLogger("assertpy")
|
|
1681
|
+
_handler = logging.StreamHandler(sys.stdout)
|
|
1682
|
+
_handler.setLevel(logging.WARNING)
|
|
1683
|
+
_format = logging.Formatter("%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
|
1684
|
+
_handler.setFormatter(_format)
|
|
1685
|
+
_logger.addHandler(_handler)
|
|
1686
|
+
_default_logger = WarningLoggingAdapter(_logger, None)
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
# what the register in `_engine/_operations.py` means for the proxy; `{name}` so the reader finds their call
|
|
1690
|
+
_INSTEAD_OF_NEGATING: Final = {
|
|
1691
|
+
CONFIGURES: "negate the assertion that tests the expectation {name}() set instead",
|
|
1692
|
+
TRANSFORMS: "negate the assertion after {name}() instead",
|
|
1693
|
+
DESCRIBES: "call {name}() before not_ instead",
|
|
1694
|
+
POLLS: "assert the inverted condition instead",
|
|
1695
|
+
}
|
|
1696
|
+
_INSTEAD_OF_CHECKING: Final = {
|
|
1697
|
+
CONFIGURES: "ask check() for the verdict of the assertion that tests the expectation {name}() set",
|
|
1698
|
+
TRANSFORMS: "ask check() for the verdict of the assertion after {name}()",
|
|
1699
|
+
DESCRIBES: "call {name}() before check() instead",
|
|
1700
|
+
POLLS: "a poll delivers its own failure, so there is no verdict to hand back",
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
# the proxies themselves, reached the same way and not operations at all
|
|
1705
|
+
_PROXY_ENTRIES: Final = {
|
|
1706
|
+
"check": "check() cannot be negated; call check().not_ before the assertion instead",
|
|
1707
|
+
"not_": "two negations cancel; drop both instead of writing not_.not_",
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def _is_still_ours(target: object, name: str) -> bool:
|
|
1712
|
+
"""Whether *name* on *target* is this library's own operation rather than an extension's.
|
|
1713
|
+
|
|
1714
|
+
Asked of the object rather than of a global register, because the two ways an extension is applied
|
|
1715
|
+
have different lifetimes. A plain function is set on `_ExtendedBuilder` and every instance sees it
|
|
1716
|
+
at once, but a non-function callable is grafted per instance at construction, so a builder made
|
|
1717
|
+
before the extension was registered keeps the built-in and one made before it was removed keeps the
|
|
1718
|
+
override. Reading the registry instead let the first of those skip the refusal and then fail with
|
|
1719
|
+
`CollectionMixin.mapped() missing 1 required positional argument`.
|
|
1720
|
+
"""
|
|
1721
|
+
return name not in vars(target) and name not in vars(_ExtendedBuilder)
|
|
1722
|
+
|
|
1723
|
+
|
|
1724
|
+
def _refuse_without_a_verdict(target: object, name: str, proxy: str, remedies: dict[str, str]) -> None:
|
|
1725
|
+
"""Raise for an operation reached through a proxy that has nothing to do with it.
|
|
1726
|
+
|
|
1727
|
+
Both proxies exist to work on a verdict, so an operation that reaches none is a mistake in the
|
|
1728
|
+
call rather than something to invert or report. Silently accepting one is worse than it sounds:
|
|
1729
|
+
`check()` answered `passed=True` for a pivot, which reads as an assertion that ran and held.
|
|
1730
|
+
"""
|
|
1731
|
+
category = WITHOUT_A_VERDICT.get(name)
|
|
1732
|
+
# an extension over the name is somebody else's method, so the register's reason to refuse no longer applies
|
|
1733
|
+
if category is None or not _is_still_ours(target, name):
|
|
1734
|
+
return
|
|
1735
|
+
remedy = remedies[category].format(name=name)
|
|
1736
|
+
raise TypeError(f"{name}() {WHAT_IT_DOES[category]}, so it cannot be {proxy}; {remedy}")
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
class NegatedBuilder(Generic[_S]):
|
|
1740
|
+
"""Proxy that inverts the next assertion. Created by ``assert_that(val).not_``.
|
|
1741
|
+
|
|
1742
|
+
Generic over the assertion type it was reached from, so inverting a step returns the view the chain
|
|
1743
|
+
had before ``.not_``: ``assert_that("x").not_.starts_with("y")`` stays a string assertion rather
|
|
1744
|
+
than collapsing to the untyped builder and letting a numeric assertion follow it, and a negated
|
|
1745
|
+
narrowing narrows nothing.
|
|
1746
|
+
"""
|
|
1747
|
+
|
|
1748
|
+
if TYPE_CHECKING:
|
|
1749
|
+
# declared because a checker prefers it over `__getattr__`, which would type this as the negated method
|
|
1750
|
+
val: Any
|
|
1751
|
+
description: str
|
|
1752
|
+
kind: str | None
|
|
1753
|
+
expected: type[BaseException] | ExceptionInfo | None
|
|
1754
|
+
logger: WarningLogger
|
|
1755
|
+
|
|
1756
|
+
def __init__(self, builder: AssertionBuilder[Any]) -> None:
|
|
1757
|
+
self._builder = builder
|
|
1758
|
+
|
|
1759
|
+
def __getattr__(self, name: str) -> Callable[..., _S]:
|
|
1760
|
+
_refuse_without_a_verdict(self._builder, name, "negated with not_", _INSTEAD_OF_NEGATING)
|
|
1761
|
+
# an extension over `check` or `not_` is somebody else's method, so it is not ours to refuse
|
|
1762
|
+
if name in _PROXY_ENTRIES and _is_still_ours(self._builder, name):
|
|
1763
|
+
raise TypeError(_PROXY_ENTRIES[name])
|
|
1764
|
+
attr = getattr(self._builder, name)
|
|
1765
|
+
if name in HANDS_THE_SUBJECT_BACK or not callable(attr):
|
|
1766
|
+
return attr
|
|
1767
|
+
|
|
1768
|
+
def _negated(*args: object, **kwargs: object) -> AssertionBuilder:
|
|
1769
|
+
if self._builder.kind == "soft":
|
|
1770
|
+
return self._negated_soft(name, attr, *args, **kwargs)
|
|
1771
|
+
if self._builder.kind == "warn":
|
|
1772
|
+
return self._negated_warn(name, attr, *args, **kwargs)
|
|
1773
|
+
if self._builder.kind == "check":
|
|
1774
|
+
return self._negated_check(name, attr, *args, **kwargs)
|
|
1775
|
+
return self._negated_strict(name, attr, *args, **kwargs)
|
|
1776
|
+
|
|
1777
|
+
if _passes.open_blocks and type(self._builder) is _passes.logged_class:
|
|
1778
|
+
# chosen here, so a negation outside every block pays no frame of its own
|
|
1779
|
+
def _observed(*args: object, **kwargs: object) -> Any:
|
|
1780
|
+
return _observe(
|
|
1781
|
+
self._builder,
|
|
1782
|
+
lambda: _negated(*args, **kwargs),
|
|
1783
|
+
lambda: (name, args, kwargs, self._asked(attr, name, *args, **kwargs)),
|
|
1784
|
+
)
|
|
1785
|
+
|
|
1786
|
+
return _observed
|
|
1787
|
+
|
|
1788
|
+
# every branch hands back the wrapped builder, unsayable without `_builder: _S`, and an unbound TypeVar has none
|
|
1789
|
+
return _negated # ty: ignore[invalid-return-type] # see above
|
|
1790
|
+
|
|
1791
|
+
def _make_msg(self, name: str, *args: object, **kwargs: object) -> str:
|
|
1792
|
+
"""The negated failure, naming the call that held when it should not have.
|
|
1793
|
+
|
|
1794
|
+
The arguments are in it because without them the message says which relation was asked for and
|
|
1795
|
+
not what it was asked about, which is the one thing the reader needs.
|
|
1796
|
+
|
|
1797
|
+
Rendered here rather than by `HelpersMixin._fmt_args_kwargs()`, which spells a keyword as
|
|
1798
|
+
``'key': value``: that is the shape `when_called_with()` has printed since assertpy and its
|
|
1799
|
+
messages are pinned to it, but this line reads as a call and ``key=value`` is what a call looks
|
|
1800
|
+
like. Keyword order is the caller's, which Python preserves, so it is stable without sorting.
|
|
1801
|
+
"""
|
|
1802
|
+
desc = f"[{self._builder.description}] " if self._builder.description else ""
|
|
1803
|
+
return f"{desc}Expected <{_capped(self._builder.val)}> to NOT satisfy: {_call_text(name, args, kwargs)}"
|
|
1804
|
+
|
|
1805
|
+
def _asked(self, attr: Callable[..., object], name: str, *args: object, **kwargs: object) -> Requirement:
|
|
1806
|
+
"""What the negated call asked for, bound to the underlying assertion's parameter names.
|
|
1807
|
+
|
|
1808
|
+
Named here rather than read off the stack: a negated failure is composed after the assertion
|
|
1809
|
+
it inverted has already returned, so its frame is gone by then. Bound through the signature
|
|
1810
|
+
so the parameters read the same as on the positive path, where they come out of the frame.
|
|
1811
|
+
"""
|
|
1812
|
+
return _bound_requirement(attr, name, args, kwargs, negated=True)
|
|
1813
|
+
|
|
1814
|
+
def _verdict(
|
|
1815
|
+
self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
|
|
1816
|
+
) -> AssertionOutcome | None:
|
|
1817
|
+
"""What the underlying assertion decided, or ``None`` when it held.
|
|
1818
|
+
|
|
1819
|
+
Run in check mode, the one mode that hands a verdict back instead of delivering it. That is
|
|
1820
|
+
what separates a verdict from an accident: anything still raised by the call came out of the
|
|
1821
|
+
value or out of an assertion nested inside it, and inverting *that* turns a break into a pass.
|
|
1822
|
+
|
|
1823
|
+
Catching an exception class cannot make the distinction, whatever class is chosen. A comparator
|
|
1824
|
+
that asserts with this library raises this library's own failure, and it is still not the
|
|
1825
|
+
verdict of the assertion being negated. `_negated_check()` has always read the sink instead;
|
|
1826
|
+
the other three modes now do the same.
|
|
1827
|
+
|
|
1828
|
+
A failure on something the question presupposes is not a verdict either, and inverting it turns
|
|
1829
|
+
"the question has no answer" into a pass. When it is the run's first failure it is delivered
|
|
1830
|
+
before this returns, whatever failed after it, and handed back like any failure so no caller
|
|
1831
|
+
inverts it. One after an ordinary failure was never reached by the strict run, which stops at
|
|
1832
|
+
the first, so the answer stands. It keeps the positive assertion's message and values, takes the
|
|
1833
|
+
requirement every negated failure carries, and is recorded in turn for a negation this one runs
|
|
1834
|
+
inside of when it is that run's first failure too.
|
|
1835
|
+
|
|
1836
|
+
Both records are read where `_verdict_holder()` keeps them, so a negation asked of a pivot inside
|
|
1837
|
+
an extension's own verdict run reads and restores that run's records rather than a stray copy.
|
|
1838
|
+
Only a run inside another one records what it passes on: at the top nobody reads it afterwards.
|
|
1839
|
+
"""
|
|
1840
|
+
builder = self._builder
|
|
1841
|
+
holder = builder if builder._answers_to is None else builder._answers_to
|
|
1842
|
+
kind, answering = builder.kind, builder._answering_another
|
|
1843
|
+
sink, enclosing = holder._check_sink, holder._unmet_prerequisite
|
|
1844
|
+
builder.kind = "check"
|
|
1845
|
+
builder._answering_another = True
|
|
1846
|
+
holder._check_sink = None
|
|
1847
|
+
holder._unmet_prerequisite = None
|
|
1848
|
+
try:
|
|
1849
|
+
attr(*args, **kwargs)
|
|
1850
|
+
decided, unmet = holder._check_sink, holder._unmet_prerequisite
|
|
1851
|
+
finally:
|
|
1852
|
+
builder.kind, builder._answering_another = kind, answering
|
|
1853
|
+
holder._check_sink, holder._unmet_prerequisite = sink, enclosing
|
|
1854
|
+
if kind != "check" and holder._run_pivots is not None:
|
|
1855
|
+
holder._release_pivots()
|
|
1856
|
+
if unmet is None:
|
|
1857
|
+
return decided
|
|
1858
|
+
passed_on = replace(unmet, requirement=_what_was_asked(builder, self._asked(attr, name, *args, **kwargs)))
|
|
1859
|
+
if kind == "check" and sink is None:
|
|
1860
|
+
holder._unmet_prerequisite = passed_on
|
|
1861
|
+
failure = builder._deliver(passed_on)
|
|
1862
|
+
if failure is not None:
|
|
1863
|
+
raise failure
|
|
1864
|
+
return passed_on
|
|
1865
|
+
|
|
1866
|
+
def _negated_strict(
|
|
1867
|
+
self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
|
|
1868
|
+
) -> AssertionBuilder:
|
|
1869
|
+
if self._verdict(name, attr, *args, **kwargs) is not None:
|
|
1870
|
+
return self._builder
|
|
1871
|
+
# composed here rather than by `error()`, which would prefix the description twice
|
|
1872
|
+
raise AssertionBuilder._failure(
|
|
1873
|
+
AssertionOutcome(
|
|
1874
|
+
message=self._make_msg(name, *args, **kwargs),
|
|
1875
|
+
actual=self._builder.val,
|
|
1876
|
+
requirement=_what_was_asked(self._builder, self._asked(attr, name, *args, **kwargs)),
|
|
1877
|
+
)
|
|
1878
|
+
)
|
|
1879
|
+
|
|
1880
|
+
def _negated_soft(
|
|
1881
|
+
self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
|
|
1882
|
+
) -> AssertionBuilder:
|
|
1883
|
+
if self._verdict(name, attr, *args, **kwargs) is not None:
|
|
1884
|
+
return self._builder
|
|
1885
|
+
msg = self._make_msg(name, *args, **kwargs)
|
|
1886
|
+
outcome = AssertionOutcome(
|
|
1887
|
+
message=msg,
|
|
1888
|
+
actual=self._builder.val,
|
|
1889
|
+
group=_soft_group.get(),
|
|
1890
|
+
location=_caller_location(),
|
|
1891
|
+
requirement=_what_was_asked(self._builder, self._asked(attr, name, *args, **kwargs)),
|
|
1892
|
+
)
|
|
1893
|
+
block = _collecting()
|
|
1894
|
+
if block is None:
|
|
1895
|
+
# the block has closed, which an outliving task does: appending puts the failure in a list nobody reads
|
|
1896
|
+
raise self._builder._failure(outcome)
|
|
1897
|
+
if self._builder._value_taint_reason is None:
|
|
1898
|
+
self._builder._value_taint_reason = msg
|
|
1899
|
+
if _passes.open_blocks:
|
|
1900
|
+
_passes.mark_failed()
|
|
1901
|
+
block.failures.append(outcome)
|
|
1902
|
+
return self._builder
|
|
1903
|
+
|
|
1904
|
+
def _negated_check(
|
|
1905
|
+
self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
|
|
1906
|
+
) -> AssertionBuilder:
|
|
1907
|
+
if self._verdict(name, attr, *args, **kwargs) is not None:
|
|
1908
|
+
return self._builder
|
|
1909
|
+
holder = self._builder._verdict_holder()
|
|
1910
|
+
if holder._check_sink is None:
|
|
1911
|
+
holder._check_sink = AssertionOutcome(
|
|
1912
|
+
message=self._make_msg(name, *args, **kwargs),
|
|
1913
|
+
actual=self._builder.val,
|
|
1914
|
+
requirement=_what_was_asked(self._builder, self._asked(attr, name, *args, **kwargs)),
|
|
1915
|
+
)
|
|
1916
|
+
return self._builder
|
|
1917
|
+
|
|
1918
|
+
def _negated_warn(
|
|
1919
|
+
self, name: str, attr: Callable[..., object], *args: object, **kwargs: object
|
|
1920
|
+
) -> AssertionBuilder:
|
|
1921
|
+
if self._verdict(name, attr, *args, **kwargs) is not None:
|
|
1922
|
+
return self._builder
|
|
1923
|
+
self._builder._warned(
|
|
1924
|
+
AssertionOutcome(
|
|
1925
|
+
message=self._make_msg(name, *args, **kwargs),
|
|
1926
|
+
actual=self._builder.val,
|
|
1927
|
+
requirement=_what_was_asked(self._builder, self._asked(attr, name, *args, **kwargs)),
|
|
1928
|
+
)
|
|
1929
|
+
)
|
|
1930
|
+
return self._builder
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
class CheckBuilder:
|
|
1934
|
+
"""Proxy returned by [`check()`][assertpy.assertpy.AssertionBuilder.check].
|
|
1935
|
+
|
|
1936
|
+
Runs one assertion with the builder in verdict mode and hands back what it decided. The mode is
|
|
1937
|
+
put on and taken off around the call rather than held, so a builder that is also used normally
|
|
1938
|
+
afterwards is unaffected, and an assertion that raises for a bad argument still leaves it clean.
|
|
1939
|
+
|
|
1940
|
+
``not_`` is proxied rather than refused, so a negated assertion can be asked for a verdict too.
|
|
1941
|
+
The value is handed straight back under ``val`` and ``value``, callable or not, and so is anything
|
|
1942
|
+
else that is not callable, ``description`` among them.
|
|
1943
|
+
"""
|
|
1944
|
+
|
|
1945
|
+
if TYPE_CHECKING:
|
|
1946
|
+
# declared because a checker prefers it over `__getattr__`, which types every name as a verdict call
|
|
1947
|
+
val: Any
|
|
1948
|
+
description: str
|
|
1949
|
+
|
|
1950
|
+
@property
|
|
1951
|
+
def not_(self) -> CheckBuilder: ...
|
|
1952
|
+
|
|
1953
|
+
def __init__(self, target: object, builder: AssertionBuilder[Any]) -> None:
|
|
1954
|
+
# two references: `not_` moves the target to the negation proxy while the mode stays underneath
|
|
1955
|
+
self._target = target
|
|
1956
|
+
self._builder = builder
|
|
1957
|
+
|
|
1958
|
+
def __getattr__(self, name: str) -> Callable[..., AssertionOutcome]:
|
|
1959
|
+
_refuse_without_a_verdict(self._builder, name, "asked for a verdict", _INSTEAD_OF_CHECKING)
|
|
1960
|
+
if name == "check" and _is_still_ours(self._builder, name):
|
|
1961
|
+
raise TypeError("check() is already the verdict proxy; one check() is enough")
|
|
1962
|
+
attr = getattr(self._target, name)
|
|
1963
|
+
if name in HANDS_THE_SUBJECT_BACK:
|
|
1964
|
+
return attr
|
|
1965
|
+
if isinstance(attr, NegatedBuilder):
|
|
1966
|
+
# ty: ignore[invalid-return-type] # declared above as a proxy; this is the path that cannot be described
|
|
1967
|
+
return CheckBuilder(attr, self._builder)
|
|
1968
|
+
if not callable(attr):
|
|
1969
|
+
return attr
|
|
1970
|
+
|
|
1971
|
+
def _checked(*args: object, **kwargs: object) -> AssertionOutcome:
|
|
1972
|
+
# the verdict is answered here, so one this runs inside keeps its own failure and prerequisite
|
|
1973
|
+
builder = self._builder
|
|
1974
|
+
holder = builder if builder._answers_to is None else builder._answers_to
|
|
1975
|
+
kind, sink, unmet = builder.kind, holder._check_sink, holder._unmet_prerequisite
|
|
1976
|
+
builder.kind = "check"
|
|
1977
|
+
holder._check_sink = None
|
|
1978
|
+
try:
|
|
1979
|
+
attr(*args, **kwargs)
|
|
1980
|
+
failure = holder._check_sink
|
|
1981
|
+
finally:
|
|
1982
|
+
builder.kind, holder._check_sink, holder._unmet_prerequisite = kind, sink, unmet
|
|
1983
|
+
if kind != "check" and holder._run_pivots is not None:
|
|
1984
|
+
holder._release_pivots()
|
|
1985
|
+
if failure is not None:
|
|
1986
|
+
return failure
|
|
1987
|
+
return AssertionOutcome(passed=True, actual=builder.val)
|
|
1988
|
+
|
|
1989
|
+
return _checked
|
|
1990
|
+
|
|
1991
|
+
|
|
1992
|
+
class AssertionBuilder(
|
|
1993
|
+
StringMixin,
|
|
1994
|
+
SnapshotMixin,
|
|
1995
|
+
NumericMixin,
|
|
1996
|
+
JsonMixin,
|
|
1997
|
+
HttpMixin,
|
|
1998
|
+
HelpersMixin,
|
|
1999
|
+
FileMixin,
|
|
2000
|
+
ExtractingMixin,
|
|
2001
|
+
ExceptionMixin,
|
|
2002
|
+
WarningMixin,
|
|
2003
|
+
DynamicMixin,
|
|
2004
|
+
DictMixin,
|
|
2005
|
+
DateMixin,
|
|
2006
|
+
ContainsMixin,
|
|
2007
|
+
CollectionMixin,
|
|
2008
|
+
BytesMixin,
|
|
2009
|
+
DataFrameMixin,
|
|
2010
|
+
BaseMixin,
|
|
2011
|
+
Generic[_T],
|
|
2012
|
+
):
|
|
2013
|
+
"""The main assertion class. Never call the constructor directly, always use the
|
|
2014
|
+
[`assert_that()`][assertpy.assertpy.assert_that] helper instead. Or if you just want warning messages, use the
|
|
2015
|
+
`assert_warn()` helper.
|
|
2016
|
+
|
|
2017
|
+
Args:
|
|
2018
|
+
val: the value to be tested (aka the actual value)
|
|
2019
|
+
description (str, optional): the extra error message description. Defaults to ``''``
|
|
2020
|
+
(aka empty string)
|
|
2021
|
+
kind (str, optional): the kind of assertions, one of ``None``, ``soft``, or ``warn``.
|
|
2022
|
+
Defaults to ``None``
|
|
2023
|
+
expected (Error, optional): the expected exception. Defaults to ``None``
|
|
2024
|
+
logger (Logger, optional): the logger for warning messages. Defaults to ``None``
|
|
2025
|
+
"""
|
|
2026
|
+
|
|
2027
|
+
def __init__(
|
|
2028
|
+
self,
|
|
2029
|
+
val: Any,
|
|
2030
|
+
description: str = "",
|
|
2031
|
+
kind: str | None = None,
|
|
2032
|
+
expected: type[BaseException] | ExceptionInfo | None = None,
|
|
2033
|
+
logger: WarningLogger | None = None,
|
|
2034
|
+
) -> None:
|
|
2035
|
+
"""Never call this constructor directly."""
|
|
2036
|
+
self.val = val
|
|
2037
|
+
self.description = description
|
|
2038
|
+
self.kind = kind
|
|
2039
|
+
self.expected = expected
|
|
2040
|
+
self.logger: WarningLogger = logger or _default_logger
|
|
2041
|
+
self._not_expected = False
|
|
2042
|
+
self._expected_warning = None
|
|
2043
|
+
self._return_value = _UNSET
|
|
2044
|
+
self._raised_exception = _UNSET
|
|
2045
|
+
# `.value` refuses an unverified value and names the root cause rather than breaking its type in silence
|
|
2046
|
+
self._value_taint_reason: str | None = None
|
|
2047
|
+
self._value_origin: str | None = None
|
|
2048
|
+
# where a failure lands while `check()` has this builder in verdict mode
|
|
2049
|
+
self._check_sink: AssertionOutcome | None = None
|
|
2050
|
+
|
|
2051
|
+
@property
|
|
2052
|
+
def not_(self) -> NegatedBuilder[Self]:
|
|
2053
|
+
"""Invert the next assertion in the chain."""
|
|
2054
|
+
return NegatedBuilder(self)
|
|
2055
|
+
|
|
2056
|
+
def check(self) -> _CheckAnyValue[_T]:
|
|
2057
|
+
"""Run the next assertion for its verdict instead of for its failure.
|
|
2058
|
+
|
|
2059
|
+
The assertion does not raise, collect or log. It returns an
|
|
2060
|
+
[`AssertionOutcome`][assertpy.outcome.AssertionOutcome], truthy when it held and carrying the
|
|
2061
|
+
failure message, values and diff when it did not.
|
|
2062
|
+
|
|
2063
|
+
For asking a question about a value. An assertion states a requirement, and a test that stops
|
|
2064
|
+
at the first unmet one is the point; this is for the cases that are not that, like branching on
|
|
2065
|
+
a precondition, or reporting a check into a system that is not pytest.
|
|
2066
|
+
|
|
2067
|
+
A bad argument still raises. ``TypeError`` and ``ValueError`` mean the call itself is wrong,
|
|
2068
|
+
which is not a verdict about the value and would be silenced by returning one.
|
|
2069
|
+
|
|
2070
|
+
An assertion or an extension that fails more than once answers with its first failure, the one
|
|
2071
|
+
it raises outside ``check()``.
|
|
2072
|
+
|
|
2073
|
+
Examples:
|
|
2074
|
+
Usage:
|
|
2075
|
+
|
|
2076
|
+
outcome = assert_that(response).check().is_equal_to(expected)
|
|
2077
|
+
if not outcome:
|
|
2078
|
+
logger.warning(outcome.message)
|
|
2079
|
+
|
|
2080
|
+
assert_that(5).check().is_positive().passed # True
|
|
2081
|
+
assert_that(5).check().not_.is_positive().passed # False
|
|
2082
|
+
"""
|
|
2083
|
+
# the proxy resolves every name through `__getattr__`: the twin is the shape, `CheckBuilder` is the thing
|
|
2084
|
+
return cast("_CheckAnyValue[_T]", CheckBuilder(self, self))
|
|
2085
|
+
|
|
2086
|
+
@property
|
|
2087
|
+
def value(self) -> _T:
|
|
2088
|
+
"""The value under test, returned as-is for typed extract-and-continue.
|
|
2089
|
+
|
|
2090
|
+
Ends a chain by handing the checked value back, so a test can keep using it after the
|
|
2091
|
+
assertions passed. For object- and union-typed values the static type is refined by the
|
|
2092
|
+
narrowing assertions along the way:
|
|
2093
|
+
[`is_not_none()`][assertpy.base.BaseMixin.is_not_none] removes ``None`` and
|
|
2094
|
+
[`is_instance_of()`][assertpy.base.BaseMixin.is_instance_of] narrows to the checked class, so
|
|
2095
|
+
no ``cast()`` or bare ``assert`` is needed to satisfy a type checker.
|
|
2096
|
+
|
|
2097
|
+
``value`` is a strict-mode extraction: it hands the value back only when *every* assertion on
|
|
2098
|
+
it passed. If one failed under
|
|
2099
|
+
[`soft_assertions()`][assertpy.assertpy.soft_assertions] or
|
|
2100
|
+
[`assert_warn()`][assertpy.assertpy.assert_warn] - where failures are collected, not raised -
|
|
2101
|
+
reading ``value`` raises ``TypeError`` instead of returning an unverified value. Read it in
|
|
2102
|
+
strict mode, or after the soft block has closed.
|
|
2103
|
+
|
|
2104
|
+
The taint is per-value, not per-chain. A value-changing pivot
|
|
2105
|
+
([`extracting()`][assertpy.extracting.ExtractingMixin.extracting],
|
|
2106
|
+
[`first()`][assertpy.collection.CollectionMixin.first],
|
|
2107
|
+
[`decoded_as()`][assertpy.bytes_mixin.BytesMixin.decoded_as], ...) starts a *new* value with a
|
|
2108
|
+
fresh guard and validates its own input, so a pivot never reaches ``.value`` with a value
|
|
2109
|
+
derived from a failed assertion - it raises in the pivot first.
|
|
2110
|
+
|
|
2111
|
+
Examples:
|
|
2112
|
+
Usage:
|
|
2113
|
+
|
|
2114
|
+
order: Order | None = repo.find_order(42)
|
|
2115
|
+
paid = assert_that(order).is_not_none().is_instance_of(PaidOrder).value
|
|
2116
|
+
paid.refund() # statically typed as PaidOrder
|
|
2117
|
+
|
|
2118
|
+
Returns:
|
|
2119
|
+
object: the original value under test (never a copy)
|
|
2120
|
+
|
|
2121
|
+
Raises:
|
|
2122
|
+
TypeError: if an assertion on this chain failed under ``soft_assertions()`` or
|
|
2123
|
+
``assert_warn()``, so the value cannot be trusted to match its narrowed type; the
|
|
2124
|
+
message carries the underlying (root) failure so its cause is not lost
|
|
2125
|
+
"""
|
|
2126
|
+
if self._value_taint_reason is not None:
|
|
2127
|
+
raise TypeError(
|
|
2128
|
+
"cannot extract .value: the underlying assertion failed under soft or warn mode - "
|
|
2129
|
+
f"{self._value_taint_reason} (read .value in strict mode, or after the soft-assertions block)"
|
|
2130
|
+
)
|
|
2131
|
+
return self.val
|
|
2132
|
+
|
|
2133
|
+
if TYPE_CHECKING:
|
|
2134
|
+
# narrowing declarations for checkers only; the runtime behaviour lives in `BaseMixin`
|
|
2135
|
+
|
|
2136
|
+
@overload
|
|
2137
|
+
def is_not_none(self: AssertionBuilder[_U | None]) -> AssertionBuilder[_U]: ...
|
|
2138
|
+
@overload
|
|
2139
|
+
def is_not_none(self) -> Self: ...
|
|
2140
|
+
def is_not_none(self) -> Any: ...
|
|
2141
|
+
|
|
2142
|
+
# the second rung takes a tuple, and a union under checkers reading it as `UnionType`; it also keeps conformance
|
|
2143
|
+
@overload
|
|
2144
|
+
def is_instance_of(self, some_class: tuple[type[_U], type[_U2]]) -> AssertionBuilder[_U | _U2]: ...
|
|
2145
|
+
@overload
|
|
2146
|
+
def is_instance_of(
|
|
2147
|
+
self, some_class: tuple[type[_U], type[_U2], type[_U3]]
|
|
2148
|
+
) -> AssertionBuilder[_U | _U2 | _U3]: ...
|
|
2149
|
+
@overload
|
|
2150
|
+
def is_instance_of(self, some_class: type[_U]) -> AssertionBuilder[_U]: ...
|
|
2151
|
+
@overload
|
|
2152
|
+
def is_instance_of(self, some_class: ClassInfo) -> Self: ...
|
|
2153
|
+
def is_instance_of(self, some_class: Any) -> Any: ...
|
|
2154
|
+
|
|
2155
|
+
@overload
|
|
2156
|
+
def is_instance_of_any(self, first: type[_U], second: type[_U2], /) -> AssertionBuilder[_U | _U2]: ...
|
|
2157
|
+
@overload
|
|
2158
|
+
def is_instance_of_any(
|
|
2159
|
+
self, first: type[_U], second: type[_U2], third: type[_U3], /
|
|
2160
|
+
) -> AssertionBuilder[_U | _U2 | _U3]: ...
|
|
2161
|
+
@overload
|
|
2162
|
+
def is_instance_of_any(self, *some_classes: ClassInfo) -> Self: ...
|
|
2163
|
+
def is_instance_of_any(self, *some_classes: Any) -> Any: ...
|
|
2164
|
+
|
|
2165
|
+
# the pivots return `self.builder(<element>)` where `CollectionMixin` says `-> Self`; `_T` is invariant
|
|
2166
|
+
@overload
|
|
2167
|
+
def first(self: AssertionBuilder[Mapping[_K, _V]]) -> AssertionBuilder[_K]: ...
|
|
2168
|
+
@overload
|
|
2169
|
+
def first(self: _ElementSource[_E]) -> AssertionBuilder[_E]: ...
|
|
2170
|
+
@overload
|
|
2171
|
+
def first(self) -> Self: ...
|
|
2172
|
+
def first(self) -> Any: ...
|
|
2173
|
+
|
|
2174
|
+
@overload
|
|
2175
|
+
def last(self: AssertionBuilder[Mapping[_K, _V]]) -> AssertionBuilder[_K]: ...
|
|
2176
|
+
@overload
|
|
2177
|
+
def last(self: _ElementSource[_E]) -> AssertionBuilder[_E]: ...
|
|
2178
|
+
@overload
|
|
2179
|
+
def last(self) -> Self: ...
|
|
2180
|
+
def last(self) -> Any: ...
|
|
2181
|
+
|
|
2182
|
+
@overload
|
|
2183
|
+
def element(self: AssertionBuilder[Mapping[_K, _V]], index: SupportsIndex) -> AssertionBuilder[_K]: ...
|
|
2184
|
+
@overload
|
|
2185
|
+
def element(self: _ElementSource[_E], index: SupportsIndex) -> AssertionBuilder[_E]: ...
|
|
2186
|
+
@overload
|
|
2187
|
+
def element(self, index: SupportsIndex) -> Self: ...
|
|
2188
|
+
def element(self, index: SupportsIndex) -> Any: ...
|
|
2189
|
+
|
|
2190
|
+
# `mapped()` builds a `list` whatever it was given, so `-> Self` was wrong about the container too and
|
|
2191
|
+
# refused working code. Its boundary: a named function binds `_R` under both checkers, a lambda binds it
|
|
2192
|
+
# under ty and comes back `Any` under mypy, losing precision rather than rejecting. `filtered_on()` and
|
|
2193
|
+
# `flat_mapped()` stay undeclared, measured: against `Matcher[_E] | Callable[[_E], object]` a lambda binds
|
|
2194
|
+
# nothing, so `filtered_on()` was `Unknown` under ty and `flat_mapped()` resolved there, and both stayed
|
|
2195
|
+
# `Any` under mypy. One answer out of three checkers is the bar and neither reaches it
|
|
2196
|
+
@overload
|
|
2197
|
+
def mapped(self: _ElementSource[_E], func: Callable[[_E], _R]) -> AssertionBuilder[list[_R]]: ...
|
|
2198
|
+
@overload
|
|
2199
|
+
def mapped(self, func: Callable[..., Any]) -> Self: ...
|
|
2200
|
+
def mapped(self, func: Any) -> Any: # overload impl stub, never executed
|
|
2201
|
+
...
|
|
2202
|
+
|
|
2203
|
+
@overload
|
|
2204
|
+
def single(self: AssertionBuilder[Mapping[_K, _V]]) -> AssertionBuilder[_K]: ...
|
|
2205
|
+
@overload
|
|
2206
|
+
def single(self: _ElementSource[_E]) -> AssertionBuilder[_E]: ...
|
|
2207
|
+
@overload
|
|
2208
|
+
def single(self) -> Self: ...
|
|
2209
|
+
def single(self) -> Any: # overload impl stub, never executed
|
|
2210
|
+
...
|
|
2211
|
+
|
|
2212
|
+
# a `TypeIs` predicate narrows the chain; PyCharm does not solve TypeVars through it yet (PY-89124)
|
|
2213
|
+
@overload
|
|
2214
|
+
def satisfies(self, matcher: Callable[[Any], TypeIs[_U]]) -> AssertionBuilder[_U]: ...
|
|
2215
|
+
@overload
|
|
2216
|
+
def satisfies(self, matcher: Matcher[Any] | Callable[..., bool]) -> Self: ...
|
|
2217
|
+
def satisfies(self, matcher: Any) -> Any: # overload impl stub, never executed
|
|
2218
|
+
...
|
|
2219
|
+
|
|
2220
|
+
def builder(
|
|
2221
|
+
self,
|
|
2222
|
+
val: Any,
|
|
2223
|
+
description: str = "",
|
|
2224
|
+
kind: str | None = None,
|
|
2225
|
+
expected: type[BaseException] | ExceptionInfo | None = None,
|
|
2226
|
+
logger: WarningLogger | None = None,
|
|
2227
|
+
origin: str | None = None,
|
|
2228
|
+
) -> Any:
|
|
2229
|
+
"""Helper to build a new `AssertionBuilder` instance. Use this only if not chaining to ``self``.
|
|
2230
|
+
|
|
2231
|
+
Args:
|
|
2232
|
+
val: the value to be tested (aka the actual value)
|
|
2233
|
+
description (str, optional): the extra error message description. Defaults to ``''``
|
|
2234
|
+
(aka empty string)
|
|
2235
|
+
kind (str, optional): the failure mode of the assertions, one of ``None`` (raise),
|
|
2236
|
+
``soft`` (collect), or ``warn`` (log). Defaults to ``None``. Unrelated to
|
|
2237
|
+
[`DiffResult.kind`][assertpy.errors.DiffResult], which is a diff category
|
|
2238
|
+
expected (Error, optional): the expected exception. Defaults to ``None``
|
|
2239
|
+
logger (Logger, optional): the logger for warning messages. Defaults to ``None``
|
|
2240
|
+
"""
|
|
2241
|
+
pivoted = _builder_like(self, val, description, kind, expected, logger) if _passes.open_blocks else None
|
|
2242
|
+
if pivoted is None:
|
|
2243
|
+
pivoted = _builder(val, description, kind, expected, logger)
|
|
2244
|
+
pivoted._value_origin = origin
|
|
2245
|
+
if kind == "check":
|
|
2246
|
+
holder = self._verdict_holder()
|
|
2247
|
+
pivoted._answers_to = holder
|
|
2248
|
+
pivoted._answering_another = self._answering_another
|
|
2249
|
+
if holder._run_pivots is None:
|
|
2250
|
+
holder._run_pivots = []
|
|
2251
|
+
holder._run_pivots.append(pivoted)
|
|
2252
|
+
# kept across the pivot, and `is not None` not `or`, since a `requests.Response` is falsey for every 4xx
|
|
2253
|
+
pivoted._response = self._response if self._response is not None else response_of(self.val)
|
|
2254
|
+
return pivoted
|
|
2255
|
+
|
|
2256
|
+
def error(
|
|
2257
|
+
self,
|
|
2258
|
+
msg: str,
|
|
2259
|
+
*,
|
|
2260
|
+
actual: Any = MISSING,
|
|
2261
|
+
expected: Any = MISSING,
|
|
2262
|
+
diff: DiffResult | None = None,
|
|
2263
|
+
trace: PollTrace | None = None,
|
|
2264
|
+
requirement: Requirement | None = None,
|
|
2265
|
+
suppress_context: bool = False,
|
|
2266
|
+
) -> Self:
|
|
2267
|
+
"""Helper to raise an ``AssertionError`` with the given message.
|
|
2268
|
+
|
|
2269
|
+
If an error description is set by [`described_as()`][assertpy.base.BaseMixin.described_as], then that
|
|
2270
|
+
description is prepended to the error message.
|
|
2271
|
+
|
|
2272
|
+
Always raises [`AssertionFailure`][assertpy.errors.AssertionFailure], which is an
|
|
2273
|
+
``AssertionError``. Structured data (``actual``, ``expected``, ``diff``) is carried on it when
|
|
2274
|
+
given, and the class is the same either way, which is why nothing reads the class to learn who
|
|
2275
|
+
raised it: `NegatedBuilder._verdict()` asks in check mode instead.
|
|
2276
|
+
|
|
2277
|
+
Args:
|
|
2278
|
+
msg: the error message
|
|
2279
|
+
actual: the actual value (for structured error reporting)
|
|
2280
|
+
expected: the expected value (for structured error reporting)
|
|
2281
|
+
diff: a [`DiffResult`][assertpy.errors.DiffResult] instance (for structured error reporting)
|
|
2282
|
+
trace: a [`PollTrace`][assertpy.errors.PollTrace] from a poll that timed out
|
|
2283
|
+
requirement: what was asked, for a caller the stack cannot be read from. A negated
|
|
2284
|
+
assertion, a poll that timed out and a dynamic ``has_`` assertion each compose their
|
|
2285
|
+
failure after or beside the frame that named the operation, so they say it here.
|
|
2286
|
+
suppress_context: raise ``from None``, dropping the exception currently being handled from
|
|
2287
|
+
the traceback. Pass it when the caught exception is your own plumbing and its text is
|
|
2288
|
+
already folded into ``msg``, so the reader is not shown the same failure twice. Leave
|
|
2289
|
+
it alone when the caught exception is the caller's, which is context they want.
|
|
2290
|
+
|
|
2291
|
+
Raises:
|
|
2292
|
+
AssertionFailure: unless ``kind`` is ``warn``, ``soft`` or ``check``, which log it, collect
|
|
2293
|
+
it and record it respectively.
|
|
2294
|
+
|
|
2295
|
+
Returns:
|
|
2296
|
+
AssertionBuilder: this instance, to chain the next assertion, whenever the failure was
|
|
2297
|
+
delivered some other way than by raising.
|
|
2298
|
+
"""
|
|
2299
|
+
# taken ahead of anything that can raise, so a pair never outlives the failure it was set for
|
|
2300
|
+
compared, self._compared = self._compared, None
|
|
2301
|
+
failure = self._deliver(
|
|
2302
|
+
self._compose(msg, actual=actual, expected=expected, diff=diff, trace=trace, requirement=requirement),
|
|
2303
|
+
compared,
|
|
2304
|
+
)
|
|
2305
|
+
if failure is None:
|
|
2306
|
+
return self
|
|
2307
|
+
failure._compared = compared
|
|
2308
|
+
# the raise stays here: a failure's traceback ends at `error`, three frames deep, pinned in test_traceback.py
|
|
2309
|
+
if suppress_context:
|
|
2310
|
+
raise failure from None
|
|
2311
|
+
raise failure
|
|
2312
|
+
|
|
2313
|
+
def _unmet(
|
|
2314
|
+
self,
|
|
2315
|
+
msg: str,
|
|
2316
|
+
*,
|
|
2317
|
+
expected: Any = MISSING,
|
|
2318
|
+
requirement: Requirement | None = None,
|
|
2319
|
+
suppress_context: bool = False,
|
|
2320
|
+
) -> None:
|
|
2321
|
+
"""Deliver *msg* as a failure of something the question presupposes rather than as its answer.
|
|
2322
|
+
|
|
2323
|
+
Delivered like any failure, with the keywords `error()` takes. What differs is under `not_`, whose
|
|
2324
|
+
verdict run is in check mode: recorded when it is the run's first failure, it is what
|
|
2325
|
+
`NegatedBuilder._verdict()` delivers as it stands.
|
|
2326
|
+
"""
|
|
2327
|
+
holder = self._verdict_holder()
|
|
2328
|
+
first = holder._check_sink is None
|
|
2329
|
+
self.error(msg, expected=expected, requirement=requirement, suppress_context=suppress_context)
|
|
2330
|
+
if first:
|
|
2331
|
+
holder._unmet_prerequisite = holder._check_sink
|
|
2332
|
+
|
|
2333
|
+
def _verdict_holder(self) -> AssertionBuilder[Any]:
|
|
2334
|
+
"""The builder whose check-mode sink this one's failures land in: itself, unless it was pivoted to
|
|
2335
|
+
during another builder's verdict run, whose failures they then are."""
|
|
2336
|
+
return self if self._answers_to is None else self._answers_to
|
|
2337
|
+
|
|
2338
|
+
def _release_pivots(self) -> None:
|
|
2339
|
+
"""Hand the pivots made during the verdict run that just ended the mode this builder is back in.
|
|
2340
|
+
|
|
2341
|
+
Kept in check mode, a pivot an extension held on to after the run swallowed every later failure.
|
|
2342
|
+
Called by the outermost run only, since a run inside another still belongs to that one.
|
|
2343
|
+
"""
|
|
2344
|
+
made, self._run_pivots = self._run_pivots, None
|
|
2345
|
+
for pivot in made or ():
|
|
2346
|
+
pivot.kind, pivot._answers_to, pivot._answering_another = self.kind, None, self._answering_another
|
|
2347
|
+
|
|
2348
|
+
def _compose(
|
|
2349
|
+
self,
|
|
2350
|
+
msg: str,
|
|
2351
|
+
*,
|
|
2352
|
+
actual: object,
|
|
2353
|
+
expected: object,
|
|
2354
|
+
diff: DiffResult | None,
|
|
2355
|
+
trace: PollTrace | None,
|
|
2356
|
+
requirement: Requirement | None = None,
|
|
2357
|
+
) -> AssertionOutcome:
|
|
2358
|
+
"""Build the failure record. Decides nothing about what happens to it."""
|
|
2359
|
+
# filled here rather than per call site, which is what puts it on all 163 failures instead of 34
|
|
2360
|
+
provided = actual is not MISSING
|
|
2361
|
+
out = f"{f'[{self.description}] ' if len(self.description) > 0 else ''}{msg}"
|
|
2362
|
+
if self._value_origin and not len(self.val):
|
|
2363
|
+
# an empty derived value carries no context of its own, so name the step that produced it
|
|
2364
|
+
out = f"{out} The value is empty because {self._value_origin}."
|
|
2365
|
+
hint = _hints.diagnose(
|
|
2366
|
+
diff, actual, expected, identity=self._equality_comparison, comparators=self._comparators_took_part
|
|
2367
|
+
)
|
|
2368
|
+
# a message quoting a failure that already carries the hint printed it twice, the poll timeout among them
|
|
2369
|
+
if hint is not None and hint not in out:
|
|
2370
|
+
# on its own line, so the original message stays a prefix and a `match=` written against it keeps working
|
|
2371
|
+
out = f"{out}\n{hint}"
|
|
2372
|
+
places = _hints.placed(diff)
|
|
2373
|
+
if places is not None and places not in out:
|
|
2374
|
+
out = f"{out}\n{places}"
|
|
2375
|
+
response = self._response if self._response is not None else response_of(self.val)
|
|
2376
|
+
note = response_note(response) if response is not None else None
|
|
2377
|
+
if note is not None:
|
|
2378
|
+
# last, under the explanation: it says where the value came from, not why it differs
|
|
2379
|
+
out = f"{out}\n{note}"
|
|
2380
|
+
return AssertionOutcome(
|
|
2381
|
+
message=out,
|
|
2382
|
+
actual=actual if provided else self.val,
|
|
2383
|
+
actual_provided=provided,
|
|
2384
|
+
expected=expected,
|
|
2385
|
+
diff=diff,
|
|
2386
|
+
trace=trace,
|
|
2387
|
+
hint=hint,
|
|
2388
|
+
requirement=_what_was_asked(self, requirement),
|
|
2389
|
+
)
|
|
2390
|
+
|
|
2391
|
+
def _deliver(
|
|
2392
|
+
self, outcome: AssertionOutcome, compared: tuple[object, object] | None = None
|
|
2393
|
+
) -> AssertionFailure | None:
|
|
2394
|
+
"""Act on a composed failure according to the builder's mode.
|
|
2395
|
+
|
|
2396
|
+
Returns the exception the caller should raise, or ``None`` when the failure was collected or
|
|
2397
|
+
logged instead. Raising is left to `error()` so the traceback keeps its shape. *compared* is the pair
|
|
2398
|
+
an equality held against each other, kept on a failure a soft block collects as a raised one keeps it.
|
|
2399
|
+
"""
|
|
2400
|
+
if self.kind == "warn":
|
|
2401
|
+
self._warned(outcome)
|
|
2402
|
+
return None
|
|
2403
|
+
if self.kind == "soft":
|
|
2404
|
+
block = _collecting()
|
|
2405
|
+
if block is None:
|
|
2406
|
+
# the block has closed, which an outliving task does: appending puts the failure in a list nobody reads
|
|
2407
|
+
return self._failure(outcome)
|
|
2408
|
+
if self._value_taint_reason is None:
|
|
2409
|
+
self._value_taint_reason = outcome.message
|
|
2410
|
+
collected = replace(outcome, group=_soft_group.get(), location=_caller_location())
|
|
2411
|
+
if compared is not None:
|
|
2412
|
+
block.compared[id(collected)] = compared
|
|
2413
|
+
if _passes.open_blocks:
|
|
2414
|
+
_passes.mark_failed()
|
|
2415
|
+
block.failures.append(collected)
|
|
2416
|
+
return None
|
|
2417
|
+
if self.kind == "check":
|
|
2418
|
+
# no taint, as a verdict was asked for and not asserted, and the first failure is it: strict stops there
|
|
2419
|
+
holder = self._verdict_holder()
|
|
2420
|
+
if holder._check_sink is None:
|
|
2421
|
+
holder._check_sink = outcome
|
|
2422
|
+
return None
|
|
2423
|
+
return self._failure(outcome)
|
|
2424
|
+
|
|
2425
|
+
def _warned(self, outcome: AssertionOutcome) -> None:
|
|
2426
|
+
"""Hand a warn-mode failure on: as its record to an `on_failure` callback, or as text to the logger.
|
|
2427
|
+
|
|
2428
|
+
The value is marked as unverified first, and the record is located first, so a callback that raises leaves
|
|
2429
|
+
both as a logger that raises does. Every warn-mode failure comes through here, negations included.
|
|
2430
|
+
"""
|
|
2431
|
+
if self._value_taint_reason is None:
|
|
2432
|
+
self._value_taint_reason = outcome.message
|
|
2433
|
+
if _passes.open_blocks:
|
|
2434
|
+
_passes.mark_failed()
|
|
2435
|
+
logger = self.logger
|
|
2436
|
+
if isinstance(logger, _Subscribed):
|
|
2437
|
+
logger.callback(replace(outcome, location=_caller_location()))
|
|
2438
|
+
return
|
|
2439
|
+
detail = _indented_diff(outcome.diff, " ")
|
|
2440
|
+
logger.warning("\n".join([outcome.message, *detail]) if detail else outcome.message)
|
|
2441
|
+
|
|
2442
|
+
@staticmethod
|
|
2443
|
+
def _failure(outcome: AssertionOutcome) -> AssertionFailure:
|
|
2444
|
+
"""Build the exception for a composed failure.
|
|
2445
|
+
|
|
2446
|
+
One class for every failure. The older split raised a bare `AssertionError` whenever the
|
|
2447
|
+
assertion had named no expected value and built no diff, which is most of them, so whether a
|
|
2448
|
+
caught failure carried structure at all depended on which assertion had failed.
|
|
2449
|
+
|
|
2450
|
+
Separate from `_deliver` for the one caller that composes its own message: `NegatedBuilder`
|
|
2451
|
+
inverts by catching, so its own failure is the case where nothing was caught.
|
|
2452
|
+
"""
|
|
2453
|
+
failure = AssertionFailure(
|
|
2454
|
+
outcome.message,
|
|
2455
|
+
actual=outcome.actual,
|
|
2456
|
+
expected=None if outcome.expected is MISSING else outcome.expected,
|
|
2457
|
+
diff=outcome.diff,
|
|
2458
|
+
requirement=outcome.requirement,
|
|
2459
|
+
)
|
|
2460
|
+
failure._outcome = outcome
|
|
2461
|
+
return failure
|
|
2462
|
+
|
|
2463
|
+
def eventually(
|
|
2464
|
+
self,
|
|
2465
|
+
*,
|
|
2466
|
+
timeout: float = 5.0,
|
|
2467
|
+
interval: float = 0.5,
|
|
2468
|
+
ignoring: type[Exception] | RetriedInfo = (),
|
|
2469
|
+
trace: bool = True,
|
|
2470
|
+
) -> AsyncAssertionBuilder:
|
|
2471
|
+
"""Switch to async polling mode for eventual-consistency assertions.
|
|
2472
|
+
|
|
2473
|
+
The current ``val`` must be a callable (sync or async). Returns an
|
|
2474
|
+
`AsyncAssertionBuilder` whose assertion
|
|
2475
|
+
methods are coroutines that poll ``val()`` until the assertion passes or
|
|
2476
|
+
``timeout`` expires.
|
|
2477
|
+
|
|
2478
|
+
By default only a failing assertion is retried: any other exception raised by ``val()`` itself
|
|
2479
|
+
propagates immediately. An ``AssertionError`` from ``val()`` reaches the same place a failing
|
|
2480
|
+
assertion does and is retried the same way, which the timeout message names. A probe that
|
|
2481
|
+
signals "not ready yet" by raising something else (a connection refused while a service boots,
|
|
2482
|
+
a record not yet visible) can be retried too by listing those types in ``ignoring``.
|
|
2483
|
+
|
|
2484
|
+
Polling itself is always strict - retrying *requires* hard failures - but the final timeout
|
|
2485
|
+
failure honors the builder's mode: inside
|
|
2486
|
+
[`soft_assertions()`][assertpy.assertpy.soft_assertions] it is collected instead of raised,
|
|
2487
|
+
and under [`assert_warn()`][assertpy.assertpy.assert_warn] it is logged.
|
|
2488
|
+
|
|
2489
|
+
Args:
|
|
2490
|
+
timeout: maximum seconds to keep retrying (default ``5.0``). Infinity, or a number past the
|
|
2491
|
+
float range, retries until the assertion passes
|
|
2492
|
+
interval: seconds between retries (default ``0.5``), finite
|
|
2493
|
+
ignoring: an ``Exception`` subclass, or a union or a tuple of them, the polling loop retries instead of
|
|
2494
|
+
propagating (default: none)
|
|
2495
|
+
trace: record a [`PollTrace`][assertpy.errors.PollTrace] flight recorder attached to the
|
|
2496
|
+
timeout failure (default ``True``); pass ``False`` to skip recording, for tight
|
|
2497
|
+
polling loops where per-poll snapshots of a heavy probed value are too costly
|
|
2498
|
+
|
|
2499
|
+
Examples:
|
|
2500
|
+
Usage:
|
|
2501
|
+
|
|
2502
|
+
import asyncio
|
|
2503
|
+
from assertpy import assert_that
|
|
2504
|
+
|
|
2505
|
+
counter = {"n": 0}
|
|
2506
|
+
|
|
2507
|
+
def get_count():
|
|
2508
|
+
counter["n"] += 1
|
|
2509
|
+
return counter["n"]
|
|
2510
|
+
|
|
2511
|
+
asyncio.run(
|
|
2512
|
+
assert_that(get_count).eventually(timeout=2).is_equal_to(3)
|
|
2513
|
+
)
|
|
2514
|
+
|
|
2515
|
+
Retry a probe that raises while the system under test is not ready yet:
|
|
2516
|
+
|
|
2517
|
+
await assert_that(get_order).eventually(timeout=10, ignoring=ConnectionError).has_status("PAID")
|
|
2518
|
+
|
|
2519
|
+
# or configure fluently on the returned builder
|
|
2520
|
+
await assert_that(get_order).eventually().within(10).ignoring(ConnectionError).has_status("PAID")
|
|
2521
|
+
|
|
2522
|
+
Returns:
|
|
2523
|
+
AsyncAssertionBuilder: an async builder whose assertion methods are awaitable
|
|
2524
|
+
|
|
2525
|
+
Raises:
|
|
2526
|
+
TypeError: if ``val`` is not callable, if ``timeout`` or ``interval`` is not a real number or is
|
|
2527
|
+
a bool, or if ``ignoring`` contains anything that is not an ``Exception`` subclass
|
|
2528
|
+
ValueError: if ``timeout`` or ``interval`` is negative or NaN, or ``interval`` is infinite
|
|
2529
|
+
"""
|
|
2530
|
+
if not callable(self.val):
|
|
2531
|
+
refuse(self.val, "callable, since eventually() polls it")
|
|
2532
|
+
return AsyncAssertionBuilder(
|
|
2533
|
+
self.val,
|
|
2534
|
+
builder_func=_builder,
|
|
2535
|
+
description=self.description,
|
|
2536
|
+
timeout=timeout,
|
|
2537
|
+
interval=interval,
|
|
2538
|
+
ignoring=_normalize_ignoring(ignoring),
|
|
2539
|
+
kind=self.kind,
|
|
2540
|
+
logger=self.logger,
|
|
2541
|
+
trace=trace,
|
|
2542
|
+
)
|
|
2543
|
+
|
|
2544
|
+
def eventually_sync(
|
|
2545
|
+
self,
|
|
2546
|
+
*,
|
|
2547
|
+
timeout: float = 5.0,
|
|
2548
|
+
interval: float = 0.5,
|
|
2549
|
+
ignoring: type[Exception] | RetriedInfo = (),
|
|
2550
|
+
trace: bool = True,
|
|
2551
|
+
) -> SyncAssertionBuilder:
|
|
2552
|
+
"""Switch to blocking polling mode for eventual-consistency assertions, without asyncio.
|
|
2553
|
+
|
|
2554
|
+
The synchronous sibling of [`eventually()`][assertpy.assertpy.AssertionBuilder.eventually]:
|
|
2555
|
+
the current ``val`` must be a sync callable, and the returned
|
|
2556
|
+
`SyncAssertionBuilder` exposes assertion methods
|
|
2557
|
+
that block the calling thread (via ``time.sleep``) while polling ``val()`` until the
|
|
2558
|
+
assertion passes or ``timeout`` expires - no event loop and no ``await`` needed. A probe
|
|
2559
|
+
that returns an awaitable raises ``TypeError``; poll async probes with ``eventually()``.
|
|
2560
|
+
|
|
2561
|
+
Retry, failure-mode, and diagnostics semantics are identical to ``eventually()``: only a
|
|
2562
|
+
failing assertion (or an exception type listed in ``ignoring``) is retried, the final
|
|
2563
|
+
timeout failure honors the builder's soft/warn mode, and it carries the same
|
|
2564
|
+
[`PollTrace`][assertpy.errors.PollTrace] flight recorder.
|
|
2565
|
+
|
|
2566
|
+
Args:
|
|
2567
|
+
timeout: maximum seconds to keep retrying (default ``5.0``). Infinity, or a number past the
|
|
2568
|
+
float range, retries until the assertion passes
|
|
2569
|
+
interval: seconds between retries (default ``0.5``), finite
|
|
2570
|
+
ignoring: an ``Exception`` subclass, or a union or a tuple of them, the polling loop retries instead of
|
|
2571
|
+
propagating (default: none)
|
|
2572
|
+
trace: record a [`PollTrace`][assertpy.errors.PollTrace] flight recorder attached to the
|
|
2573
|
+
timeout failure (default ``True``); pass ``False`` to skip recording, for tight
|
|
2574
|
+
polling loops where per-poll snapshots of a heavy probed value are too costly
|
|
2575
|
+
|
|
2576
|
+
Examples:
|
|
2577
|
+
Usage:
|
|
2578
|
+
|
|
2579
|
+
from assertpy import assert_that
|
|
2580
|
+
|
|
2581
|
+
counter = {"n": 0}
|
|
2582
|
+
|
|
2583
|
+
def get_count():
|
|
2584
|
+
counter["n"] += 1
|
|
2585
|
+
return counter["n"]
|
|
2586
|
+
|
|
2587
|
+
assert_that(get_count).eventually_sync(timeout=2, interval=0.1).is_equal_to(3)
|
|
2588
|
+
|
|
2589
|
+
Retry a probe that raises while the system under test is not ready yet:
|
|
2590
|
+
|
|
2591
|
+
assert_that(get_order).eventually_sync(timeout=10, ignoring=ConnectionError).has_status("PAID")
|
|
2592
|
+
|
|
2593
|
+
# or configure fluently on the returned builder
|
|
2594
|
+
assert_that(get_order).eventually_sync().within(10).ignoring(ConnectionError).has_status("PAID")
|
|
2595
|
+
|
|
2596
|
+
Returns:
|
|
2597
|
+
SyncAssertionBuilder: a blocking builder whose assertion methods poll on call
|
|
2598
|
+
|
|
2599
|
+
Raises:
|
|
2600
|
+
TypeError: if ``val`` is not callable, if ``timeout`` or ``interval`` is not a real number or is
|
|
2601
|
+
a bool, or if ``ignoring`` contains anything that is not an ``Exception`` subclass
|
|
2602
|
+
ValueError: if ``timeout`` or ``interval`` is negative or NaN, or ``interval`` is infinite
|
|
2603
|
+
"""
|
|
2604
|
+
if not callable(self.val):
|
|
2605
|
+
refuse(self.val, "callable, since eventually_sync() polls it")
|
|
2606
|
+
return SyncAssertionBuilder(
|
|
2607
|
+
self.val,
|
|
2608
|
+
builder_func=_builder,
|
|
2609
|
+
description=self.description,
|
|
2610
|
+
timeout=timeout,
|
|
2611
|
+
interval=interval,
|
|
2612
|
+
ignoring=_normalize_ignoring(ignoring),
|
|
2613
|
+
kind=self.kind,
|
|
2614
|
+
logger=self.logger,
|
|
2615
|
+
trace=trace,
|
|
2616
|
+
)
|
|
2617
|
+
|
|
2618
|
+
|
|
2619
|
+
class _ExtendedBuilder(AssertionBuilder[Any]):
|
|
2620
|
+
"""Host for user extensions: `add_extension()` installs plain functions here, so binding happens
|
|
2621
|
+
once at registration and `AssertionBuilder` itself stays pristine when an extension is removed."""
|
|
2622
|
+
|
|
2623
|
+
|
|
2624
|
+
_made_builder: Callable[..., Any] = _ExtendedBuilder
|
|
2625
|
+
"""What `_builder()` makes: `_ExtendedBuilder`, or while any `log_passes()` block is open, the class it hands out."""
|
|
2626
|
+
|
|
2627
|
+
|
|
2628
|
+
_ASSERTS: Final = frozenset(
|
|
2629
|
+
name
|
|
2630
|
+
for name in dir(AssertionBuilder)
|
|
2631
|
+
if not name.startswith("_")
|
|
2632
|
+
and name not in NOT_AN_OPERATION
|
|
2633
|
+
and (name not in WITHOUT_A_VERDICT or name in ALSO_ASSERTS)
|
|
2634
|
+
) | {"assert_conforms"}
|
|
2635
|
+
"""The names `_requirement_of()` will answer with, derived rather than listed.
|
|
2636
|
+
|
|
2637
|
+
The complement of the register, plus the one assertion that is a module function and so has no frame
|
|
2638
|
+
with a builder in it. A hybrid such as `when_called_with()` belongs here: the register calls it a
|
|
2639
|
+
configurer because it hands a value back, and it reaches a verdict on the way.
|
|
2640
|
+
"""
|