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,70 @@
|
|
|
1
|
+
"""Maximum pairing between two sides, for every assertion that says "these each, in any order"."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
9
|
+
|
|
10
|
+
__tracebackhide__ = True
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def maximum_pairing(candidates: Sequence[set[int]]) -> dict[int, int]:
|
|
14
|
+
"""As many entries paired with distinct options as can be, returned as ``{option: entry}``.
|
|
15
|
+
|
|
16
|
+
Kuhn's algorithm: every entry takes a free option first, and each one left over then tries to
|
|
17
|
+
displace an owner along a chain of alternatives.
|
|
18
|
+
"""
|
|
19
|
+
paired: dict[int, int] = {}
|
|
20
|
+
unpaired = []
|
|
21
|
+
for entry, options in enumerate(candidates):
|
|
22
|
+
free = next((index for index in options if index not in paired), None)
|
|
23
|
+
if free is None:
|
|
24
|
+
unpaired.append(entry)
|
|
25
|
+
else:
|
|
26
|
+
paired[free] = entry
|
|
27
|
+
return completed_pairing(paired, unpaired, candidates.__getitem__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def completed_pairing(
|
|
31
|
+
paired: dict[int, int], unpaired: Iterable[int], options_of: Callable[[int], Iterable[int]]
|
|
32
|
+
) -> dict[int, int]:
|
|
33
|
+
"""*paired*, any valid pairing, grown to a maximum one by the entries it left out, and returned.
|
|
34
|
+
|
|
35
|
+
An entry no displacement reaches now cannot be reached later, so each is tried once. *options_of* is
|
|
36
|
+
asked only for the entries a chain of displacements meets, which lets a caller work them out on demand.
|
|
37
|
+
"""
|
|
38
|
+
for entry in unpaired:
|
|
39
|
+
_augmented(entry, options_of, paired)
|
|
40
|
+
return paired
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _augmented(entry: int, options_of: Callable[[int], Iterable[int]], paired: dict[int, int]) -> bool:
|
|
44
|
+
"""Whether one entry can be paired by displacing others, updating `paired` when it can.
|
|
45
|
+
|
|
46
|
+
Iterative, because the chain of displacements is as long as the input is wide rather than as deep:
|
|
47
|
+
1100 entries against 1100 options every one of them accepts recursed past the limit and reported a
|
|
48
|
+
`RecursionError` for a pairing that exists.
|
|
49
|
+
"""
|
|
50
|
+
seen: set[int] = set()
|
|
51
|
+
path: list[tuple[int, Iterator[int]]] = [(entry, iter(options_of(entry)))]
|
|
52
|
+
taken: list[int] = []
|
|
53
|
+
while path:
|
|
54
|
+
options = path[-1][1]
|
|
55
|
+
for index in options:
|
|
56
|
+
if index in seen:
|
|
57
|
+
continue
|
|
58
|
+
seen.add(index)
|
|
59
|
+
taken.append(index)
|
|
60
|
+
if index not in paired:
|
|
61
|
+
for step, (displaced, _) in reversed(list(enumerate(path))):
|
|
62
|
+
paired[taken[step]] = displaced
|
|
63
|
+
return True
|
|
64
|
+
path.append((paired[index], iter(options_of(paired[index]))))
|
|
65
|
+
break
|
|
66
|
+
else:
|
|
67
|
+
path.pop()
|
|
68
|
+
if taken:
|
|
69
|
+
taken.pop()
|
|
70
|
+
return False
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Where a diff entry sits, in both the forms its two readers need.
|
|
2
|
+
|
|
3
|
+
`DiffEntry.path` is written for a person and is lossy on purpose: a mapping key goes through ``str()``,
|
|
4
|
+
so ``{3: ...}`` and ``{"3": ...}`` land on the same text, and a key holding a dot or a bracket cannot be
|
|
5
|
+
read back out. That is the right trade for a message and the wrong one for anything that wants to walk
|
|
6
|
+
back into the value, which is what `DiffEntry.steps` is for.
|
|
7
|
+
|
|
8
|
+
Both forms are carried by one object so they cannot drift. Nine producers build paths, and each of them
|
|
9
|
+
would otherwise have written the text and the steps separately, twice, in agreement only by hand.
|
|
10
|
+
|
|
11
|
+
The rendering rules are **not** uniform across producers and are not made uniform here. A mapping key
|
|
12
|
+
renders bare at the root (``b``) while a field renders dotted (``.b``), because a top-level dict is
|
|
13
|
+
reported by `HelpersMixin._dict_err()` with bare keys and has been since before this file existed.
|
|
14
|
+
Each hop keeps its own rule, in one place, instead of at 35 call sites.
|
|
15
|
+
|
|
16
|
+
Building a path is deferred to the point where an entry is actually produced. The walkers used to
|
|
17
|
+
format one per child before deciding whether the child differed, so a sequence of a thousand equal
|
|
18
|
+
elements paid for a thousand strings nobody read.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Literal
|
|
25
|
+
|
|
26
|
+
from ..errors import DiffEntry, Step, _safe_str
|
|
27
|
+
|
|
28
|
+
__tracebackhide__ = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_Joint = Literal["append", "dotted", "reset"]
|
|
32
|
+
"""How a hop's text meets the text before it: after it, after a dot unless nothing came before, or in its place."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True, eq=False, repr=False)
|
|
36
|
+
class _Path:
|
|
37
|
+
"""A location inside a compared value, as rendered text plus the steps that reached it.
|
|
38
|
+
|
|
39
|
+
Not a `NamedTuple`, though it is a pair: ``index`` and ``count`` are already taken by ``tuple``, and
|
|
40
|
+
a hop named ``index`` is the one this is used for most. It was tried, and it measured the same.
|
|
41
|
+
|
|
42
|
+
Held as one hop and the path it was taken from, and read out only when an entry is made. A node's path
|
|
43
|
+
stays alive while its children are walked, so holding the whole text and every step at each hop made the
|
|
44
|
+
paths of a walk quadratic in its depth: 48 MB for three thousand levels, against 0.6 MB held as hops.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
piece: str = ""
|
|
48
|
+
step: Step | None = None
|
|
49
|
+
parent: _Path | None = None
|
|
50
|
+
joint: _Joint = "reset"
|
|
51
|
+
|
|
52
|
+
def key(self, key: object) -> _Path:
|
|
53
|
+
"""Into a mapping. Bare at the root, dotted below it, and the key itself is kept unstringified."""
|
|
54
|
+
rendered = _safe_str(key)
|
|
55
|
+
if type(self.piece) is not str:
|
|
56
|
+
text = self.text
|
|
57
|
+
return _Path(f"{text}.{rendered}" if text else rendered, Step("key", key), self)
|
|
58
|
+
return _Path(rendered, Step("key", key), self, "dotted")
|
|
59
|
+
|
|
60
|
+
def attr(self, name: str, *, dotted_at_root: bool = True) -> _Path:
|
|
61
|
+
"""Into a field of a dataclass, namedtuple, attrs class or model.
|
|
62
|
+
|
|
63
|
+
``dotted_at_root`` is not a style knob. The diff walkers render a root-level field as ``.name``
|
|
64
|
+
and the leaf walker renders it as ``name``, and both are load-bearing: the first matches how the
|
|
65
|
+
dict path has rendered nested keys since before either existed, the second feeds messages that
|
|
66
|
+
name a field of the value under test. Below the root the two agree.
|
|
67
|
+
"""
|
|
68
|
+
if type(name) is not str or type(self.piece) is not str:
|
|
69
|
+
text = self.text
|
|
70
|
+
return _Path(f"{text}.{name}" if text or dotted_at_root else name, Step("attr", name), self)
|
|
71
|
+
if dotted_at_root:
|
|
72
|
+
return _Path(f".{name}", Step("attr", name), self, "append")
|
|
73
|
+
return _Path(name, Step("attr", name), self, "dotted")
|
|
74
|
+
|
|
75
|
+
def index(self, index: int) -> _Path:
|
|
76
|
+
"""Into a position both sequences share."""
|
|
77
|
+
if type(self.piece) is not str:
|
|
78
|
+
return _Path(f"{self.text}[{index}]", Step("index", index), self)
|
|
79
|
+
return _Path(f"[{index}]", Step("index", index), self, "append")
|
|
80
|
+
|
|
81
|
+
def side_index(self, side: Literal["actual", "expected"], index: int) -> _Path:
|
|
82
|
+
"""Into a position only one sequence has, once alignment has shifted the two apart."""
|
|
83
|
+
if type(self.piece) is not str:
|
|
84
|
+
return _Path(f"{self.text}{side}[{index}]", Step("index", index, side=side), self)
|
|
85
|
+
return _Path(f"{side}[{index}]", Step("index", index, side=side), self, "append")
|
|
86
|
+
|
|
87
|
+
def line(self, number: int) -> _Path:
|
|
88
|
+
"""Into one line of a text or bytes diff, numbered from 1."""
|
|
89
|
+
return _Path(f"line {number}", Step("line", number), self)
|
|
90
|
+
|
|
91
|
+
def member(self, item: object, label: str) -> _Path:
|
|
92
|
+
"""Into a member of a set, which has no position to name it by.
|
|
93
|
+
|
|
94
|
+
The text stays the label the set renderer groups on (``extra`` / ``missing``), because a member
|
|
95
|
+
has no coordinate to print. The step carries the member itself, which is the only handle on it.
|
|
96
|
+
"""
|
|
97
|
+
return _Path(label, Step("item", item), self)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def text(self) -> str:
|
|
101
|
+
"""The location as a person reads it."""
|
|
102
|
+
return self._read()[0]
|
|
103
|
+
|
|
104
|
+
def _read(self) -> tuple[str, tuple[Step, ...]]:
|
|
105
|
+
"""Both forms of the location: the text joined from the nearest hop that starts it, the steps from the root.
|
|
106
|
+
|
|
107
|
+
A hop that starts the text holds it whole. Every hop where something other than an exact `str` meets the
|
|
108
|
+
text is one: its text is worked out when it is taken, by 2.28.0's own f-string, whose ``__format__``,
|
|
109
|
+
``__bool__`` and order a `str` subclass can tell apart from a join. Joining is only ever done over exact
|
|
110
|
+
strings, where the two agree.
|
|
111
|
+
"""
|
|
112
|
+
parent = self.parent
|
|
113
|
+
if parent is _ROOT and self.step is not None:
|
|
114
|
+
# one hop from the root reads as the hop itself: the join below cost such an entry 420 ns more
|
|
115
|
+
return self.piece, (self.step,)
|
|
116
|
+
hops: list[_Path] = []
|
|
117
|
+
hop: _Path | None = self
|
|
118
|
+
while hop is not None:
|
|
119
|
+
hops.append(hop)
|
|
120
|
+
hop = hop.parent
|
|
121
|
+
steps = tuple(hop.step for hop in reversed(hops) if hop.step is not None)
|
|
122
|
+
start = next(position for position, hop in enumerate(hops) if hop.joint == "reset")
|
|
123
|
+
if start == 0:
|
|
124
|
+
return self.piece, steps
|
|
125
|
+
pieces = [hops[start].piece]
|
|
126
|
+
written = bool(pieces[0])
|
|
127
|
+
for hop in reversed(hops[:start]):
|
|
128
|
+
if hop.joint == "dotted" and written:
|
|
129
|
+
pieces.append(".")
|
|
130
|
+
pieces.append(hop.piece)
|
|
131
|
+
written = written or bool(hop.piece)
|
|
132
|
+
return "".join(pieces), steps
|
|
133
|
+
|
|
134
|
+
def entry(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
actual: object = None,
|
|
138
|
+
expected: object = None,
|
|
139
|
+
absent: Literal["actual", "expected"] | None = None,
|
|
140
|
+
) -> DiffEntry:
|
|
141
|
+
"""A `DiffEntry` at this location, carrying both forms of it."""
|
|
142
|
+
text, steps = self._read()
|
|
143
|
+
return DiffEntry(path=text, steps=steps, actual=actual, expected=expected, absent=absent)
|
|
144
|
+
|
|
145
|
+
def leaf_entry(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
actual: object = None,
|
|
149
|
+
expected: object = None,
|
|
150
|
+
absent: Literal["actual", "expected"] | None = None,
|
|
151
|
+
) -> DiffEntry:
|
|
152
|
+
"""A `DiffEntry` for the whole value, whose text at the root is ``.`` rather than empty."""
|
|
153
|
+
text, steps = self._read()
|
|
154
|
+
return DiffEntry(path=text or ".", steps=steps, actual=actual, expected=expected, absent=absent)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_ROOT: _Path = _Path()
|
|
158
|
+
"""The value under comparison itself, before any hop into it."""
|