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
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""One shape for every refusal of a wrong type, and one place that decides how it reads.
|
|
2
|
+
|
|
3
|
+
An assertion refuses a value or an argument in two situations, and until now each site worded its own:
|
|
4
|
+
ninety-three different phrasings across a hundred and fifty-nine sites, for about ten actual
|
|
5
|
+
situations. Ten of them said "not a number" and eleven said "not a string", differing in whether the
|
|
6
|
+
offending value appeared at all, whether its type did, and in what order. The reader could not learn
|
|
7
|
+
the format once, because there was no format.
|
|
8
|
+
|
|
9
|
+
The shape here is::
|
|
10
|
+
|
|
11
|
+
<subject> must be <expectation>, but was <value> (<type>)
|
|
12
|
+
|
|
13
|
+
`subject` is `val` for the value under assertion and `given <name> arg` for an argument, which is the
|
|
14
|
+
wording the package already used where it named the operand at all. The value is shown because that is
|
|
15
|
+
the question a reader actually has: not *that* the type was wrong, but *what arrived* instead. A
|
|
16
|
+
payload field that came back as the string ``"12"`` rather than the number ``12`` is invisible in
|
|
17
|
+
"val is not numeric" and obvious in ``but was <'12'> (str)``.
|
|
18
|
+
|
|
19
|
+
The type is printed next to the value even when `repr` already implies it. `'12'` and `12` are told
|
|
20
|
+
apart by their quotes, but `Decimal("1")`, a numpy scalar and any class with a hand-written `__repr__`
|
|
21
|
+
are not, and a rule with an exception is a rule nobody can rely on.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import decimal
|
|
27
|
+
import difflib
|
|
28
|
+
import functools
|
|
29
|
+
import inspect
|
|
30
|
+
import re
|
|
31
|
+
import types
|
|
32
|
+
from typing import Any, Final, NoReturn, TypeVar
|
|
33
|
+
|
|
34
|
+
from ..errors import _safe_repr, _truncated
|
|
35
|
+
from ._size import length_of
|
|
36
|
+
|
|
37
|
+
_T = TypeVar("_T")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def raised_on_purpose(excinfo: Any = None) -> bool:
|
|
41
|
+
"""Whether pytest hides a frame of the module that names this as its ``__tracebackhide__``.
|
|
42
|
+
|
|
43
|
+
Only where the frame the exception was born in is a refusal of this library: one sets a name of its own,
|
|
44
|
+
``_assertpy_refusal``, where it raises, and then no frame of the library says anything of the mistake.
|
|
45
|
+
Anything else keeps its frames, what the caller's code or another library raised, hidden by pytest's own
|
|
46
|
+
flag or not, and a defect of this one, since they are what locate it.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
last = excinfo.tb
|
|
50
|
+
while last.tb_next is not None:
|
|
51
|
+
last = last.tb_next
|
|
52
|
+
return last.tb_frame.f_locals.get("_assertpy_refusal") is True
|
|
53
|
+
except Exception:
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__tracebackhide__ = raised_on_purpose
|
|
58
|
+
|
|
59
|
+
# a refusal is read on one terminal line, so the cap is well below the one a diff row uses
|
|
60
|
+
_SHOWN = 60
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# a type name can be as long as its author liked, and the refusal is still one line
|
|
64
|
+
_NAMED = 40
|
|
65
|
+
# `repr` output is arbitrary text: escape sequences repaint the terminal, and newlines turn one refusal into several
|
|
66
|
+
_CONTROL = {code: "\\x{:02x}".format(code) for code in [*range(0x20), 0x7F, 0x85]} | { # noqa: UP032 # an f-string cannot hold this escape
|
|
67
|
+
# the bidi overrides reorder the printed line without changing the string
|
|
68
|
+
code: "\\u{:04x}".format(code) # noqa: UP032 # same
|
|
69
|
+
for code in [0x2028, 0x2029, *range(0x202A, 0x2030), *range(0x2066, 0x206A)]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _one_line(text: str) -> str:
|
|
74
|
+
"""*text* with control characters spelled out, so a refusal stays one readable line."""
|
|
75
|
+
return text.translate(_CONTROL)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _shown(value: object) -> str:
|
|
79
|
+
"""The value and its type, both capped, both safe to print next to a traceback.
|
|
80
|
+
|
|
81
|
+
`_safe_repr` already answers `<unreprable X>` for a `__repr__` that raises, returns a non-string or
|
|
82
|
+
recurses. What it does not do is make the result printable: an object is free to return escape
|
|
83
|
+
sequences or newlines from `__repr__`, and a diagnostic that repaints the terminal or spreads over
|
|
84
|
+
four lines is worse than the mistake it reports.
|
|
85
|
+
"""
|
|
86
|
+
# escaped before it is capped: capping first let sixty control characters render as two hundred and forty
|
|
87
|
+
shown = _truncated(_one_line(_safe_repr(value)), _SHOWN)
|
|
88
|
+
return f"<{shown}> ({_truncated(type(value).__name__, _NAMED)})"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def raised_inside(exc: BaseException) -> bool:
|
|
92
|
+
"""Whether *exc* came out of somebody else's code rather than from the operation itself.
|
|
93
|
+
|
|
94
|
+
`len(42)` and `1 < "a"` are refusals by the operation: the traceback stops at the frame that tried
|
|
95
|
+
it. A `__len__` or a `__lt__` that raises `TypeError` of its own adds a frame, and that error is a
|
|
96
|
+
bug in the value being tested, not a wrong operand. Answering it with "val must be a sized object"
|
|
97
|
+
is a lie that sends the reader looking in the wrong file, so those are re-raised untouched.
|
|
98
|
+
|
|
99
|
+
A frame of `decimal` itself is the operation's own, where CPython was built without the C accelerator and
|
|
100
|
+
runs it in Python (`pure_decimal_code`): the same refusal raised in C adds no frame at all. Only when the
|
|
101
|
+
method first called runs on a real `Decimal` or `Context`, since a class of anybody's can borrow one.
|
|
102
|
+
"""
|
|
103
|
+
traceback = exc.__traceback__
|
|
104
|
+
deeper = traceback.tb_next if traceback is not None else None
|
|
105
|
+
if deeper is None:
|
|
106
|
+
return False
|
|
107
|
+
operation = pure_decimal_code()
|
|
108
|
+
entry = deeper.tb_frame
|
|
109
|
+
if entry.f_code not in operation or not isinstance(entry.f_locals.get("self"), (decimal.Decimal, decimal.Context)):
|
|
110
|
+
return True
|
|
111
|
+
while deeper is not None:
|
|
112
|
+
if deeper.tb_frame.f_code not in operation:
|
|
113
|
+
return True
|
|
114
|
+
deeper = deeper.tb_next
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@functools.cache
|
|
119
|
+
def pure_decimal_code() -> frozenset[types.CodeType]:
|
|
120
|
+
"""The code `decimal` runs in Python where CPython was built without its C accelerator, else nothing.
|
|
121
|
+
|
|
122
|
+
Read once from the namespace `Decimal.__add__` was defined in, nested code included: the functions and the
|
|
123
|
+
classes defined there, `Decimal` and `Context` among them, and not what it imported from elsewhere, such as
|
|
124
|
+
`collections.namedtuple`. That namespace is `_pydecimal`'s, whether `decimal` is that module itself, as on
|
|
125
|
+
3.15, or re-exports its names, as below it, and it must hold the very `Decimal` in use.
|
|
126
|
+
"""
|
|
127
|
+
added = inspect.getattr_static(decimal.Decimal, "__add__")
|
|
128
|
+
if not isinstance(added, types.FunctionType) or added.__globals__.get("Decimal") is not decimal.Decimal:
|
|
129
|
+
return frozenset()
|
|
130
|
+
namespace = added.__globals__
|
|
131
|
+
held = list(namespace.values())
|
|
132
|
+
for kind in [one for one in held if isinstance(one, type) and one.__module__ == namespace.get("__name__")]:
|
|
133
|
+
for member in vars(kind).values():
|
|
134
|
+
held += [getattr(member, "__func__", member)]
|
|
135
|
+
if isinstance(member, property):
|
|
136
|
+
held += [member.fget, member.fset, member.fdel]
|
|
137
|
+
pending = [
|
|
138
|
+
function.__code__
|
|
139
|
+
for function in held
|
|
140
|
+
if isinstance(function, types.FunctionType) and function.__globals__ is namespace
|
|
141
|
+
]
|
|
142
|
+
found: set[types.CodeType] = set()
|
|
143
|
+
while pending:
|
|
144
|
+
code = pending.pop()
|
|
145
|
+
if code not in found:
|
|
146
|
+
found.add(code)
|
|
147
|
+
pending += [constant for constant in code.co_consts if isinstance(constant, types.CodeType)]
|
|
148
|
+
return frozenset(found)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def refuse(value: object, expectation: str, *, subject: str = "val", note: str = "") -> NoReturn:
|
|
152
|
+
"""Raise the refusal for *value*, for a check the caller has already made, with *note* after it."""
|
|
153
|
+
message = f"{subject} must be {expectation}, but was {_shown(value)}{note}"
|
|
154
|
+
_assertpy_refusal = True
|
|
155
|
+
raise TypeError(message)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def require_type(
|
|
159
|
+
value: object, types: type[_T] | tuple[type[_T], ...], expectation: str, *, subject: str = "val"
|
|
160
|
+
) -> _T:
|
|
161
|
+
"""Refuse *value* unless it is an instance of *types*, which is what most of these checks are.
|
|
162
|
+
|
|
163
|
+
The value is handed back so a caller can bind it and keep the narrowing: a type checker cannot see
|
|
164
|
+
through a function that only raises, and the alternative at each site was a second `isinstance` or a
|
|
165
|
+
cast written purely for the checker.
|
|
166
|
+
"""
|
|
167
|
+
if not isinstance(value, types):
|
|
168
|
+
refuse(value, expectation, subject=subject)
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def argument(name: str) -> str:
|
|
173
|
+
"""The subject for an argument of the assertion, as opposed to the value under assertion."""
|
|
174
|
+
return f"given {name} arg"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def require_pattern(pattern: object) -> re.Pattern[str]:
|
|
178
|
+
"""*pattern* compiled, refused unless it is a string or a pattern compiled from one, and not empty.
|
|
179
|
+
|
|
180
|
+
A pattern compiled from bytes is never found in a string, and an empty one is found in every string. A
|
|
181
|
+
string goes through ``re.compile`` and its cache, as ``re.search`` would take it. A compiled one comes back
|
|
182
|
+
as it is, flags and all.
|
|
183
|
+
"""
|
|
184
|
+
text = pattern.pattern if isinstance(pattern, re.Pattern) else pattern
|
|
185
|
+
if not isinstance(text, str):
|
|
186
|
+
refuse(pattern, "a string or a pattern compiled from one", subject=argument("pattern"))
|
|
187
|
+
if len(text) == 0:
|
|
188
|
+
_assertpy_refusal = True
|
|
189
|
+
raise ValueError("given pattern arg must not be empty")
|
|
190
|
+
return pattern if isinstance(pattern, re.Pattern) else re.compile(text)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def sized_len(value: object, *, subject: str = "val") -> int:
|
|
194
|
+
"""The length of *value*, refusing it in the shared shape when it has none.
|
|
195
|
+
|
|
196
|
+
Left to `len()` alone, the refusal reads "object of type 'int' has no len()": true, and about the
|
|
197
|
+
builtin rather than about the assertion, with no mention of which operand was wrong.
|
|
198
|
+
"""
|
|
199
|
+
length = length_of(value)
|
|
200
|
+
if length is None:
|
|
201
|
+
refuse(value, "a sized object", subject=subject)
|
|
202
|
+
# a `__len__` that exists and still raises is a bug in the value, and it travels out as its author wrote it
|
|
203
|
+
return length
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def reject_unknown_kwargs(kwargs: dict, known: frozenset, method: str) -> None:
|
|
207
|
+
"""Raise on a keyword the method does not read, naming the closest one it does.
|
|
208
|
+
|
|
209
|
+
A misspelt option is the worst silent pass there is. ``is_equal_to(other, strict_type=True)``
|
|
210
|
+
returned green with the comparison never tightened, and nothing said so: no error, no warning, and
|
|
211
|
+
no type error either, because a ``**kwargs`` signature makes every spelling legal to a checker.
|
|
212
|
+
The reader is left certain they asserted something they did not.
|
|
213
|
+
|
|
214
|
+
The matcher spelling of the same option already fails loudly, since a real parameter list gets
|
|
215
|
+
this from the interpreter for free. This gives the ``**kwargs`` entry points the same manners.
|
|
216
|
+
"""
|
|
217
|
+
unknown = sorted(set(kwargs) - known)
|
|
218
|
+
if not unknown:
|
|
219
|
+
return
|
|
220
|
+
named = []
|
|
221
|
+
for name in unknown:
|
|
222
|
+
# one suggestion, like the extraction hint: measured typos score ~0.9 and wrong neighbours ~0.65
|
|
223
|
+
close = difflib.get_close_matches(str(name), sorted(known), n=1)
|
|
224
|
+
named.append(f"{name!r}" + (f" (did you mean {close[0]!r}?)" if close else ""))
|
|
225
|
+
plural = "" if len(named) == 1 else "s"
|
|
226
|
+
message = f"{method}() got an unexpected keyword argument{plural} {', '.join(named)}"
|
|
227
|
+
_assertpy_refusal = True
|
|
228
|
+
raise TypeError(message)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class VerdictError(TypeError):
|
|
232
|
+
"""A predicate handed back something that is not an answer: a mistake in the test, not a non-match.
|
|
233
|
+
|
|
234
|
+
Its own type because several assertions catch `TypeError` from a probe on purpose, reading it as
|
|
235
|
+
"this one does not match", which is right for a predicate that cannot judge some item and wrong for
|
|
236
|
+
one that was never asked: swallowed there, the refusal came back as an ordinary failed assertion.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class CoroutineVerdictError(VerdictError):
|
|
241
|
+
"""A predicate handed back a coroutine, so nothing ever ran."""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class MatcherVerdictError(VerdictError):
|
|
245
|
+
"""A predicate handed back a matcher, so nothing was ever asked of the value."""
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
_MATCHER_MEMBERS: Final = ("matches", "describe", "describe_mismatch")
|
|
249
|
+
|
|
250
|
+
NON_MATCHER_TYPES: Final = frozenset(
|
|
251
|
+
{int, float, bool, complex, str, bytes, bytearray, list, tuple, dict, set, frozenset, type(None)}
|
|
252
|
+
)
|
|
253
|
+
"""Types no matcher is, which is what an ordinary answer is: one frozenset lookup instead of three reads."""
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _answers_like_a_matcher(answer: object) -> bool:
|
|
257
|
+
"""Whether this object is a matcher by the only rule that matters: it has the three members.
|
|
258
|
+
|
|
259
|
+
Read statically, wherever a matcher keeps its members: on the type, in the instance dictionary or in
|
|
260
|
+
slots. Never through `getattr`, which runs a `__getattr__`, a property or a metaclass hook: a `Mock`
|
|
261
|
+
answers every name with something callable, and a hostile object could decide its own verdict.
|
|
262
|
+
|
|
263
|
+
The frozenset first because the static read costs 1.08 us against 0.11 us for a plain `getattr`, and
|
|
264
|
+
the answers that reach here are almost always an ordinary value of a builtin type.
|
|
265
|
+
"""
|
|
266
|
+
if type(answer) in NON_MATCHER_TYPES:
|
|
267
|
+
return False
|
|
268
|
+
for member in _MATCHER_MEMBERS:
|
|
269
|
+
held = inspect.getattr_static(answer, member, None)
|
|
270
|
+
if isinstance(held, types.MemberDescriptorType):
|
|
271
|
+
# a slot: its own `__get__` reads the value, which is a C-level read and not their code
|
|
272
|
+
try:
|
|
273
|
+
held = held.__get__(answer)
|
|
274
|
+
except AttributeError:
|
|
275
|
+
return False # the slot was never filled
|
|
276
|
+
if not callable(held):
|
|
277
|
+
return False
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def verdict(answer: object, *, subject: str = "predicate") -> object:
|
|
282
|
+
"""The answer a predicate gave, refusing what is not one rather than reading it as truth.
|
|
283
|
+
|
|
284
|
+
A coroutine object is truthy, so an `async def` predicate passed every assertion that reads one
|
|
285
|
+
without ever running: measured, ten of the thirteen places that take a callable went green on a
|
|
286
|
+
predicate that always answers `False`. A matcher is truthy the same way, so a factory that was
|
|
287
|
+
named but never called, or a predicate written in matcher style, passed on any value at all.
|
|
288
|
+
|
|
289
|
+
Asked of the answer rather than of the callable, so a lambda handing one back is caught too, and a
|
|
290
|
+
coroutine is closed before raising so the refusal is not followed by "was never awaited".
|
|
291
|
+
"""
|
|
292
|
+
if answer is True or answer is False:
|
|
293
|
+
return answer
|
|
294
|
+
# the exact type, since `inspect.iscoroutine` and even `isinstance` read attributes off the answer,
|
|
295
|
+
# and a hostile `__getattribute__` then decides a failure that is not about it at all. Nothing
|
|
296
|
+
# subclasses a coroutine, so `is` asks the same question
|
|
297
|
+
if type(answer) is types.CoroutineType:
|
|
298
|
+
answer.close()
|
|
299
|
+
message = (
|
|
300
|
+
f"{subject} handed back a coroutine instead of an answer; assertions here are synchronous, "
|
|
301
|
+
"so await the call yourself and assert on what it returned"
|
|
302
|
+
)
|
|
303
|
+
_assertpy_refusal = True
|
|
304
|
+
raise CoroutineVerdictError(message)
|
|
305
|
+
if _answers_like_a_matcher(answer):
|
|
306
|
+
message = (
|
|
307
|
+
f"{subject} handed back a matcher instead of an answer; pass the matcher where one is taken, "
|
|
308
|
+
"or ask it about the value and answer with what it said"
|
|
309
|
+
)
|
|
310
|
+
_assertpy_refusal = True
|
|
311
|
+
raise MatcherVerdictError(message)
|
|
312
|
+
return answer
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Size as one decision: how long a value is, or that it has no length at all.
|
|
2
|
+
|
|
3
|
+
`len()` answers both questions at once by raising, and the two callers need them apart. A builder
|
|
4
|
+
refuses a value with no length, because a wrong subject there is a mistake in the test. A matcher
|
|
5
|
+
answers "no match", because it feeds `==` and the combinators, where raising would be wrong. Written
|
|
6
|
+
separately, the two used a different rule for what "has no length" means: one asked the protocol, the
|
|
7
|
+
other caught `TypeError` and so also swallowed a `__len__` of the user's own that raised.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sized
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
_SIZED = frozenset({list, tuple, dict, str, bytes, set, frozenset, bytearray})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def length_of(value: object) -> int | None:
|
|
19
|
+
"""The length of *value*, or ``None`` when it has none.
|
|
20
|
+
|
|
21
|
+
Answered by the protocol rather than by catching `TypeError`, so a `__len__` that exists and raises
|
|
22
|
+
travels out as its author wrote it: that is a bug in the value, not a value without a length, and
|
|
23
|
+
reporting it as the latter sends the reader looking in the wrong file. It also does not depend on a
|
|
24
|
+
Python frame being pushed, which a `__len__` implemented in C does not do.
|
|
25
|
+
"""
|
|
26
|
+
if type(value) in _SIZED:
|
|
27
|
+
# `isinstance` against an ABC is the whole cost here, and it was paid per element on the extraction path
|
|
28
|
+
return len(cast("Sized", value))
|
|
29
|
+
if not isinstance(value, Sized):
|
|
30
|
+
return None
|
|
31
|
+
return len(value)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Text relations as one decision: containment, prefix and suffix over `str` and `bytes` alike.
|
|
2
|
+
|
|
3
|
+
This is the family that already drifted. The methods grew `bytes` support and the matchers kept
|
|
4
|
+
`isinstance(value, str)`, so `assert_that(b"hello").contains(b"ell")` passed while
|
|
5
|
+
`match.contains_string(b"ell")` said no, and the disagreement reached everything a matcher feeds:
|
|
6
|
+
`satisfies()`, nested specs and the `==` protocol. It was found by measurement rather than by reading,
|
|
7
|
+
which is the argument for the rule living in one place from here on.
|
|
8
|
+
|
|
9
|
+
`str` and `bytes` are neighbours and never each other's operands. Mixing them is answered "no" rather
|
|
10
|
+
than raised on, because that is what a matcher needs; a builder checks the pair before it asks.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _comparable(value: object, operand: object) -> bool:
|
|
19
|
+
"""Whether the two are text of the same family: `str` with `str`, `bytes` with `bytes`."""
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
return isinstance(operand, str)
|
|
22
|
+
if isinstance(value, (bytes, bytearray)):
|
|
23
|
+
return isinstance(operand, (bytes, bytearray))
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def contains(value: Any, part: Any) -> bool:
|
|
28
|
+
"""Whether *part* appears in *value*, both being text of the same family."""
|
|
29
|
+
return _comparable(value, part) and part in value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def starts_with(value: Any, prefix: Any) -> bool:
|
|
33
|
+
"""Whether *value* begins with *prefix*, both being text of the same family."""
|
|
34
|
+
return _comparable(value, prefix) and value.startswith(prefix)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ends_with(value: Any, suffix: Any) -> bool:
|
|
38
|
+
"""Whether *value* ends with *suffix*, both being text of the same family."""
|
|
39
|
+
return _comparable(value, suffix) and value.endswith(suffix)
|