Skip to content

Commit fec4745

Browse files
committed
perf: win back what the numpy, closeness and membership fixes cost against 2.27.1
1 parent 124c7f7 commit fec4745

8 files changed

Lines changed: 176 additions & 19 deletions

File tree

‎assertpy2/_engine/_compare.py‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@
3636
model_field_values,
3737
)
3838
from ._ordering import (
39+
_SEQUENCES,
3940
REFUSALS,
4041
UnorderableError,
4142
_exact_real,
4243
broadcasts,
4344
equal_past,
4445
held_key,
4546
holds,
47+
integral_kind,
4648
numpy_duration,
4749
rational_overflow,
4850
)
@@ -155,7 +157,11 @@ def _ambiguous_array_operand(value: object, other: object) -> object | None:
155157
"""
156158
if not hasattr(value, "__array__") and not hasattr(other, "__array__"):
157159
return None # fast path: no array-like operand, skip the tuple/loop on every is_equal_to
158-
if broadcasts(value, other):
160+
if (
161+
type(value) is not type(other)
162+
and (isinstance(value, _SEQUENCES) or isinstance(other, _SEQUENCES))
163+
and broadcasts(value, other)
164+
):
159165
return None # a `numpy` scalar against a list, which `_guarded_equal` answers as unequal
160166
for candidate, counterpart in ((value, other), (other, value)):
161167
if hasattr(candidate, "__array__"):
@@ -263,7 +269,9 @@ def _guarded_equal(actual, expected, *, method="is_equal_to") -> bool:
263269
"""
264270
try:
265271
equal = actual == expected
266-
return bool(equal) if type(equal) is bool or not broadcasts(actual, expected, answer=equal) else False
272+
if type(equal) is bool or type(actual) is type(expected):
273+
return bool(equal)
274+
return not broadcasts(actual, expected, answer=equal) and bool(equal)
267275
except (ValueError, TypeError) as error:
268276
operand = _find_ambiguous_operand(actual, expected)
269277
if operand is not None:
@@ -291,6 +299,27 @@ def zero_of(tolerance: Any) -> Any:
291299
return tolerance - tolerance if type(tolerance) not in (int, float) and numpy_duration(tolerance) else 0
292300

293301

302+
def plainly_within(actual, expected, tolerance) -> bool | None:
303+
"""`_within_tolerance` over three exact `int` or `float` operands, or ``None`` where an `int` overflows a `float`.
304+
305+
The same rule in its fewest steps: a NaN is close to nothing, an infinity only to an equal one, and a finite
306+
pair by its difference or by the window around either side.
307+
"""
308+
if actual != actual or expected != expected:
309+
return False
310+
try:
311+
if actual in (math.inf, -math.inf) or expected in (math.inf, -math.inf):
312+
return actual == expected
313+
return (
314+
actual == expected
315+
or abs(actual - expected) <= tolerance
316+
or expected - tolerance <= actual <= expected + tolerance
317+
or actual - tolerance <= expected <= actual + tolerance
318+
)
319+
except OverflowError:
320+
return None
321+
322+
294323
def _within_tolerance(actual, expected, tolerance) -> bool:
295324
"""Whether two values lie within ``tolerance`` of each other, the same answer whichever comes first.
296325
@@ -447,7 +476,7 @@ def _non_finite(value) -> str:
447476
if decimal.Decimal.is_finite(value):
448477
return ""
449478
return "nan" if decimal.Decimal.is_nan(value) else "inf"
450-
if isinstance(value, int) or not hasattr(type(value), "__float__"):
479+
if isinstance(value, int) or not hasattr(type(value), "__float__") or integral_kind(type(value)):
451480
return ""
452481
if _is_nan(value):
453482
return "nan"

‎assertpy2/_engine/_membership.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@
2121

2222
from ._introspection import definition_of, is_mapping_like, materialized
2323
from ._ordering import (
24+
_PLAIN,
25+
MEMBERSHIP_REFUSALS,
2426
REFUSALS,
2527
equals,
2628
lookup,
2729
may_broadcast,
2830
member,
31+
member_past,
2932
mixes_broadcasting,
3033
)
3134
from ._require import raised_inside, verdict
@@ -248,13 +251,26 @@ def _absent_from(
248251
absent.append(item)
249252
continue
250253
try:
251-
if not member(item, present, verify):
254+
if not _held(item, present, verify):
252255
absent.append(item)
253256
except TypeError as refusal:
254257
_told_apart((((item,), present),), refusal, marked=marked)
255258
return absent
256259

257260

261+
def _held(item: Any, present: Any, verify: bool) -> bool:
262+
"""`member`, asking ``in`` itself where no item that may broadcast can come: through it cost 100 items 11%."""
263+
if not verify or type(item) in _PLAIN:
264+
try:
265+
return item in present
266+
except MEMBERSHIP_REFUSALS as refusal:
267+
found = member_past(item, present, refusal)
268+
if found is None:
269+
raise
270+
return found
271+
return member(item, present, verify)
272+
273+
258274
def only_faults(value: Any, items: Sequence[Any], *, marked: bool = False) -> tuple[list[Any], list[Any]]:
259275
"""``(extra, missing)`` for "contains only these": what is there but unwanted, and what is absent.
260276

‎assertpy2/_engine/_ordering.py‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,33 @@ def nan_operand(value: Any) -> bool:
7272
if isinstance(value, decimal.Decimal):
7373
return decimal.Decimal.is_nan(value)
7474
kind = type(value)
75-
if kind is int or type(kind) is not type or issubclass(kind, numbers.Integral):
75+
if kind is int or type(kind) is not type or kind in _INTEGRAL_KINDS or integral_kind(kind):
7676
return False
7777
return _exact_real(value) == "nan"
7878

7979

80+
def integral_kind(kind: type) -> bool:
81+
"""Whether a class built by `type` itself is a `numbers.Integral`, kept once found: a class never stops being one.
82+
83+
Asked of a `numpy` integer at every NaN and infinity question, where the ABC check cost an `int64` tie 11%. At
84+
most 256 are kept, so classes made on the fly cannot grow it without bound, and an ABC registry cleared by hand
85+
through its private API is not followed.
86+
"""
87+
if type(kind) is not type:
88+
return False
89+
if kind in _INTEGRAL_KINDS:
90+
return True
91+
if issubclass(kind, numbers.Integral):
92+
if len(_INTEGRAL_KINDS) < 256:
93+
_INTEGRAL_KINDS.add(kind)
94+
return True
95+
return False
96+
97+
98+
_INTEGRAL_KINDS: set[type] = set()
99+
"""What `integral_kind` has found, only classes built by `type` itself, so hashed as `type` hashes."""
100+
101+
80102
def _kind_of(value: Any) -> type | None:
81103
"""The date-and-time kind a value orders within, subclasses included, or ``None`` for anything else.
82104
@@ -108,7 +130,7 @@ def equals(actual: Any, expected: Any) -> bool:
108130
equal = actual == expected
109131
except (decimal.InvalidOperation, OverflowError, TypeError, ValueError) as refusal:
110132
return equal_past(actual, expected, refusal)
111-
if type(equal) is not bool and broadcasts(actual, expected, answer=equal):
133+
if type(equal) is not bool and type(actual) is not type(expected) and broadcasts(actual, expected, answer=equal):
112134
return False
113135
return bool(equal)
114136

@@ -194,25 +216,40 @@ def member(item: Any, container: Any, verify: bool = True) -> bool:
194216
return any(element is item or equals(element, item) for element in container)
195217
try:
196218
return item in container
197-
except (decimal.InvalidOperation, OverflowError) as refusal:
198-
if raised_inside(refusal):
219+
except MEMBERSHIP_REFUSALS as refusal:
220+
found = member_past(item, container, refusal)
221+
if found is None:
199222
raise
200-
return any(element is item or equals(element, item) for element in container)
201-
except (ValueError, DeprecationWarning) as ambiguous:
223+
return found
224+
225+
226+
MEMBERSHIP_REFUSALS = (decimal.InvalidOperation, OverflowError, ValueError, DeprecationWarning, TypeError)
227+
"""What ``in`` raises that `member_past` may answer."""
228+
229+
230+
def member_past(item: Any, container: Any, refusal: Exception) -> bool | None:
231+
"""`member`'s verdict once ``item in container`` raised *refusal*, or ``None`` to hand the refusal on as raised.
232+
233+
Answered from the refusal already caught, never by asking ``in`` again: an element whose ``==`` answers
234+
differently the second time would have turned the refusal into a verdict.
235+
"""
236+
if isinstance(refusal, (decimal.InvalidOperation, OverflowError)):
237+
if raised_inside(refusal):
238+
return None
239+
elif isinstance(refusal, (ValueError, DeprecationWarning)):
202240
# a hash collision met a `numpy` scalar with a tuple, and `in` took the truth of their array
203-
if raised_inside(ambiguous) or not may_broadcast(item) or not _searched_again(container):
204-
raise
205-
return any(element is item or equals(element, item) for element in container)
206-
except TypeError as refusal:
241+
if raised_inside(refusal) or not may_broadcast(item) or not _searched_again(container):
242+
return None
243+
else:
207244
# a dict's items view hashes the key of a pair alone, and asks about the value only once the key is there
208245
pair = type(container) is type({}.items()) and isinstance(item, tuple) and len(item) == 2
209246
key = item[0] if pair else item
210247
hashed = _searches_by_hash(container)
211248
if hashed and _unhashable_for_a_nan_alone(key):
212249
return False
213250
if raised_inside(refusal) or not (_walks_by_equality(container) or (hashed and _hashes(key))):
214-
raise
215-
return any(element is item or equals(element, item) for element in container)
251+
return None
252+
return any(element is item or equals(element, item) for element in container)
216253

217254

218255
def _walks_by_equality(container: Any) -> bool:
@@ -598,12 +635,14 @@ def compare(actual: Any, expected: Any) -> int:
598635
right: Any = expected
599636
try:
600637
less = left < right
601-
if type(less) is not bool and broadcasts(left, right, ordering=True, answer=less):
638+
mixed = actual_type is not type(expected)
639+
# a pair of one type is never a scalar against a list: asked for it anyway, an `int64` tie cost 19% more
640+
if mixed and type(less) is not bool and broadcasts(left, right, ordering=True, answer=less):
602641
raise UnorderableError("pair")
603642
if less:
604643
return -1
605644
greater = right < left
606-
if type(greater) is not bool and broadcasts(right, left, ordering=True, answer=greater):
645+
if mixed and type(greater) is not bool and broadcasts(right, left, ordering=True, answer=greater):
607646
raise UnorderableError("pair")
608647
if greater:
609648
return 1

‎assertpy2/_matcher_impls.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
_non_finite,
3333
_resolve_comparator,
3434
_within_tolerance,
35+
plainly_within,
3536
zero_of,
3637
)
3738
from ._engine._equality import (
@@ -727,8 +728,13 @@ def __init__(self, expected: object, tolerance: object):
727728
and _non_finite(tolerance) != "nan"
728729
and _measured_under(expected, tolerance, self._duration)
729730
)
731+
self._plain = self._measures and type(expected) in (int, float) and type(tolerance) in (int, float)
730732

731733
def matches(self, value: Any) -> bool:
734+
if self._plain and type(value) in (int, float):
735+
within = plainly_within(value, self.expected, self.tolerance)
736+
if within is not None:
737+
return within
732738
if not self._measures or not _measured_under(value, self.tolerance, self._duration):
733739
return False
734740
try:

‎tests/test_evaluation_core.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2183,6 +2183,25 @@ def __iter__(self):
21832183
with pytest.raises(TypeError, match="only the first time"):
21842184
missing_items(Moody(), [1], _is_matcher)
21852185

2186+
def test_an_element_whose_equality_changes_its_answer_is_asked_once(self):
2187+
"""The refusal ``in`` raised is answered as caught: asked again, the second answer passed the item."""
2188+
numpy = pytest.importorskip("numpy")
2189+
2190+
class Fickle:
2191+
def __init__(self):
2192+
self.asked = 0
2193+
2194+
def __eq__(self, other):
2195+
self.asked += 1
2196+
return numpy.array([True, False]) if self.asked == 1 else True
2197+
2198+
__hash__ = None
2199+
2200+
fickle = Fickle()
2201+
with pytest.raises(ValueError, match="ambiguous"):
2202+
assert_that([fickle]).contains(5)
2203+
assert_that(fickle.asked).is_equal_to(1)
2204+
21862205
def test_a_mapping_of_many_non_string_keys_says_how_many_it_dropped(self):
21872206
wide = {index: index for index in range(101)}
21882207
written = _json_safe(wide)

‎tests/test_numbers.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pytest
1010

1111
from assertpy2 import assert_that, match
12+
from assertpy2._engine import _ordering
1213
from assertpy2._engine._compare import _difference_within
1314

1415

@@ -1043,6 +1044,15 @@ def test_a_fraction_past_a_numpy_integers_width_is_ordered_by_exact_value(width,
10431044
assert_that(ask(_TINY, five)).is_equal_to(holds)
10441045

10451046

1047+
def test_an_integral_class_is_kept_at_most_so_many_times(monkeypatch):
1048+
"""Found and answered past the bound, and not kept: classes made on the fly cannot grow the cache without end."""
1049+
kept = set(range(256))
1050+
monkeypatch.setattr(_ordering, "_INTEGRAL_KINDS", kept)
1051+
counted = type("Counted", (int,), {})
1052+
assert_that(_ordering.integral_kind(counted)).is_true()
1053+
assert_that(kept).is_length(256)
1054+
1055+
10461056
class _OrderingOverflowsOfItsOwn(fractions.Fraction):
10471057
"""A `Fraction` whose own ordering overflows, which the exact order is not allowed to paper over."""
10481058

‎tests/test_numeric_questions.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@
6666
),
6767
("pytest_plugin.py", "_poll_threshold", "float(written)"): "parses ini text, not a value under test",
6868
("behave_matchers.py", "_positive_float", "float(text)"): "parses step text behave matched as digits",
69+
**dict.fromkeys(
70+
(
71+
("_engine/_compare.py", "plainly_within", "actual != actual"),
72+
("_engine/_compare.py", "plainly_within", "expected != expected"),
73+
("_engine/_compare.py", "plainly_within", "actual in (math.inf, -math.inf)"),
74+
("_engine/_compare.py", "plainly_within", "expected in (math.inf, -math.inf)"),
75+
),
76+
"its operands are exact ints and floats, the caller's condition, so the NaN and the infinity are float's",
77+
),
6978
}
7079

7180
_NUMBER_MODULES = frozenset({"math", "cmath", "numpy"})

‎tests/test_property_based.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from assertpy2 import assert_conforms, assert_that, match, soft_assertions
3535
from assertpy2._clustering import _VALUE_LIMIT, Observation, Signature, _shown, clusters, render, stable_repr
3636
from assertpy2._dangling import findings as dangling_findings
37-
from assertpy2._engine._compare import _EQ_ATOMIC
37+
from assertpy2._engine._compare import _EQ_ATOMIC, _within_tolerance, plainly_within
3838
from assertpy2._engine._contract import contract_drift, shape, shape_diff
3939
from assertpy2._engine._diff import _build_equality_diff, _ordered_keys, _sub_diff_entries
4040
from assertpy2._engine._equality import values_differ
@@ -2776,6 +2776,35 @@ def test_closeness_is_one_answer_however_it_is_asked(value, other, tolerance):
27762776
assert_that(set(answers.values())).described_as(str(answers)).is_length(1)
27772777

27782778

2779+
_PLAIN_OPERANDS = st.one_of(
2780+
st.floats(allow_nan=True, allow_infinity=True, allow_subnormal=True),
2781+
st.integers(),
2782+
st.integers(-(10**400), 10**400),
2783+
)
2784+
_PLAIN_TOLERANCES = st.one_of(
2785+
st.floats(min_value=0, allow_infinity=True, allow_subnormal=True), st.integers(0, 10**400)
2786+
)
2787+
2788+
2789+
@settings(deadline=None)
2790+
@example(value=-1.1, other=-0.9, tolerance=0.2)
2791+
@example(value=0.1 + 0.2, other=0.3, tolerance=5.551115123125783e-17)
2792+
@example(value=-0.0, other=0.0, tolerance=0)
2793+
@example(value=5e-324, other=0.0, tolerance=5e-324)
2794+
@example(value=10**400, other=1e308, tolerance=float("inf"))
2795+
@example(value=float("inf"), other=float("inf"), tolerance=0)
2796+
@example(value=float("-inf"), other=float("inf"), tolerance=float("inf"))
2797+
@example(value=float("nan"), other=float("nan"), tolerance=float("inf"))
2798+
@given(value=_PLAIN_OPERANDS, other=_PLAIN_OPERANDS, tolerance=_PLAIN_TOLERANCES)
2799+
def test_the_plain_closeness_answers_as_the_general_one(value, other, tolerance):
2800+
"""`match.close_to` answers exact ints and floats inline, and only an int past the float range leaves it."""
2801+
plain = plainly_within(value, other, tolerance)
2802+
general = _within_tolerance(value, other, tolerance)
2803+
assert_that(plain).described_as(f"{value!r}, {other!r}, {tolerance!r}").is_in(None, general)
2804+
if plain is None:
2805+
assert_that(any(type(operand) is int for operand in (value, other, tolerance))).is_true()
2806+
2807+
27792808
_MOMENT = datetime.datetime(2026, 9, 24, 12, 0, 0)
27802809
_CLOSE_PAIRS = st.one_of(
27812810
st.tuples(

0 commit comments

Comments
 (0)