Skip to content
This repository was archived by the owner on Nov 1, 2026. It is now read-only.

Commit d3c56c2

Browse files
authored
Merge branch 'main' into is_instance_union
2 parents 04e1f5b + 22bfecb commit d3c56c2

5 files changed

Lines changed: 151 additions & 19 deletions

File tree

‎assertpy2/_engine/_introspection.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,20 @@ def materialized(value: Iterable[_T]) -> Iterable[_T]:
159159
return list(value) if iter(value) is value else value
160160
except TypeError:
161161
return value
162+
163+
164+
def definition_of(klass: type, name: str) -> tuple[type, object] | None:
165+
"""The class in *klass*'s tree that defines *name*, and the definition itself, or ``None``.
166+
167+
Walked and read raw, which is the reading the interpreter does for an operator: no attribute access
168+
on the class, so neither a metaclass nor a descriptor of the class's own gets to answer for it.
169+
170+
A definition of ``None`` reads as no definition, so a class declaring `__hash__ = None` reports the
171+
hashable base above it. Callers that care ask the class about `__hash__` directly, which is the
172+
cheaper question anyway.
173+
"""
174+
for base in type.__getattribute__(klass, "__mro__"):
175+
found = type.__getattribute__(base, "__dict__").get(name)
176+
if found is not None:
177+
return base, found
178+
return None

‎assertpy2/_engine/_membership.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from collections.abc import Iterable
2020
from typing import TYPE_CHECKING, Any, cast
2121

22-
from ._introspection import materialized
22+
from ._introspection import definition_of, materialized
2323

2424
if TYPE_CHECKING:
2525
from collections.abc import Callable, Sequence
@@ -121,17 +121,38 @@ def _kinds(items: Any) -> set[type] | None:
121121

122122

123123
def _safe(kinds: set[type] | None) -> bool:
124-
"""Whether every one of *kinds* hashes and compares by the same rule.
124+
"""Whether every one of *kinds* hashes and compares by the same rule."""
125+
return kinds is not None and all(_agrees(kind) for kind in kinds)
126+
127+
128+
def _agrees(kind: type) -> bool:
129+
"""Whether this type's ``==`` and ``hash()`` answer consistently, so a set may stand in for a walk.
125130
126131
Hashability is asked of the type, not tried on the value: an unhashable type carries
127132
`__hash__ = None`. It is not implied by the rest of the rule, since a class can inherit identity
128133
equality and still declare itself unhashable, and without this the shortcut raised where it answered.
134+
135+
The third clause is what admits a subclass. A name on the safe list is not enough on its own,
136+
because that list holds exact types, and `IntEnum`, `StrEnum` and a plain `class Money(int)` all
137+
took the quadratic walk although they answer exactly as their base does: four thousand of them cost
138+
30 ms against 0.09 ms. Asked of the owner of each definition rather than of the definition itself,
139+
so a class that assigns `__hash__ = str.__hash__` beside an inherited `int.__eq__` is refused: its
140+
owner is the class, not a safe type. A base cannot supply the two from different safe types either,
141+
since inheriting from two of them is a layout conflict the interpreter refuses.
129142
"""
130-
return kinds is not None and all(
131-
kind.__hash__ is not None and (kind in _HASH_SAFE or kind.__eq__ is object.__eq__) for kind in kinds
143+
return kind.__hash__ is not None and (
144+
kind in _HASH_SAFE
145+
or kind.__eq__ is object.__eq__
146+
or (_owned_by_a_safe_type(kind, "__eq__") and _owned_by_a_safe_type(kind, "__hash__"))
132147
)
133148

134149

150+
def _owned_by_a_safe_type(kind: type, name: str) -> bool:
151+
"""Whether *kind* inherits *name* unchanged from one of the types on the safe list."""
152+
found = definition_of(kind, name)
153+
return found is not None and found[0] in _HASH_SAFE
154+
155+
135156
def _hash_safe(items: Any) -> bool:
136157
"""Whether *items* may be indexed. Kept as a name of its own because the tests read better with it."""
137158
return _safe(_kinds(items))

‎assertpy2/_hints.py‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from collections import Counter
2626
from typing import TYPE_CHECKING, Final
2727

28-
from ._engine._introspection import is_attrs_instance, is_mapping_like, is_model_dump_object
28+
from ._engine._introspection import definition_of, is_attrs_instance, is_mapping_like, is_model_dump_object
2929

3030
if TYPE_CHECKING:
3131
from collections.abc import Callable, Sequence
@@ -262,16 +262,10 @@ def _fields_of(value: object) -> dict | None:
262262

263263

264264
def _defined_as(klass: type, name: str) -> bool:
265-
"""Whether the definition of *name* the class tree carries is the one ``object`` carries.
266-
267-
Walked and read raw, which is the reading the interpreter does for an operator: no attribute access
268-
on the class, so neither a metaclass nor a descriptor of the class's own gets to answer for it.
269-
"""
270-
for base in type.__getattribute__(klass, "__mro__"):
271-
found = type.__getattribute__(base, "__dict__").get(name)
272-
if found is not None:
273-
return found is object.__dict__[name]
274-
return False # pragma: no cover - `object` ends every tree and defines both names, so this is dead
265+
"""Whether the definition of *name* the class tree carries is the one ``object`` carries."""
266+
found = definition_of(klass, name)
267+
# pragma: no cover on the `None` half - `object` ends every tree and defines both names asked here
268+
return found is not None and found[1] is object.__dict__[name]
275269

276270

277271
def identity_candidate(left: object, right: object) -> bool:

‎benchmarks/test_perf.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -435,9 +435,10 @@ def run():
435435
def test_a_class_with_its_own_equality_pays_the_quadratic_path(benchmark):
436436
"""A hashable class is still walked pairwise when it defines `__eq__`, and that is on purpose.
437437
438-
`_safe` in `_engine/_membership.py` admits a type to the hashing shortcut only if it is a known one or
439-
inherits `object.__eq__`. A custom `__eq__` may disagree with the type's `__hash__`, and a set would
440-
then miss a duplicate. 2001 values: no hashes at all and 1 999 001 comparisons.
438+
`_agrees` in `_engine/_membership.py` admits a type whose `__eq__` and `__hash__` both come from a
439+
known one, so a plain `class Money(int)` and a `StrEnum` are indexed. This class defines both itself,
440+
which is where the two may disagree and a set would miss a duplicate. 2001 values: no hashes at all
441+
and 1 999 001 comparisons.
441442
"""
442443
values = [_Costly(index) for index in range(2000)] + [_Costly(0)]
443444
_Costly.hashes = 0

‎tests/test_evaluation_core.py‎

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import dataclasses
1515
import datetime
1616
import decimal
17+
import enum
1718
import fractions
1819
import math
1920
from dataclasses import dataclass
@@ -79,8 +80,30 @@ class _NoHash:
7980
__hash__ = None
8081

8182

83+
class _Counted(int):
84+
"""A subclass that changes neither operation, so it answers exactly as `int` does."""
85+
86+
87+
class _HashesLikeText(int):
88+
"""One operation inherited and the other replaced, which is the disagreement the rule looks for."""
89+
90+
__hash__ = str.__hash__
91+
92+
93+
class _Status(str, enum.Enum):
94+
"""Spelled with the mixin rather than as `enum.StrEnum`, which arrived in 3.11 and this suite runs 3.10.
95+
96+
Identical where it matters: both take `__eq__` and `__hash__` from `str`, which is what the rule reads.
97+
"""
98+
99+
PAID = "paid"
100+
DUE = "due"
101+
102+
82103
# both halves of the membership rule in one generator: values a set may answer for, and values it may
83-
# not. Mixing them is what makes the properties below test the classifier rather than the fast path
104+
# not. Mixing them is what makes the properties below test the classifier rather than the fast path.
105+
# The last three are the subclass half of it: two that a set may answer for although their exact types
106+
# are not on the safe list, and one that inherits equality and hashes by another rule entirely
84107
_MIXED_ELEMENTS = st.one_of(
85108
st.integers(-5, 5),
86109
st.text(max_size=2),
@@ -90,6 +113,9 @@ class _NoHash:
90113
st.builds(_ByField, st.integers(-2, 2)),
91114
st.builds(_NoHash),
92115
st.lists(st.integers(-2, 2), max_size=2),
116+
st.builds(_Counted, st.integers(-2, 2)),
117+
st.builds(_HashesLikeText, st.integers(-2, 2)),
118+
st.sampled_from(_Status),
93119
)
94120

95121

@@ -821,6 +847,79 @@ def test_decimals_take_the_shortcut_and_answer_the_same(self):
821847
assert_that(values).is_subset_of(values)
822848
assert_that([*values, values[0]]).contains_duplicates()
823849

850+
def test_a_subclass_that_changes_neither_operation_takes_the_shortcut(self):
851+
"""The safe list holds exact types, so a subclass used to walk although it answers as its base.
852+
853+
`IntEnum` and `StrEnum` are the shapes a suite actually carries, a status field most of all. Four
854+
thousand of them cost 30 ms on the walk against 0.09 ms indexed, and the answer is the same either
855+
way because the subclass inherits both operations unchanged.
856+
"""
857+
858+
class Money(int):
859+
def __init__(self, *_: object) -> None:
860+
self.currency = "USD"
861+
862+
class Status(str, enum.Enum):
863+
PAID = "paid"
864+
DUE = "due"
865+
866+
class Level(enum.IntEnum):
867+
LOW = 1
868+
HIGH = 2
869+
870+
for values in ([Money(index) for index in range(30)], list(Status) * 15, list(Level) * 15):
871+
assert_that(_hash_safe(values)).described_as(f"{type(values[0]).__name__} is safe to index").is_true()
872+
assert_that(only_faults(values, tuple(values))).is_equal_to(([], []))
873+
assert_that(values).contains_only(*values)
874+
assert_that(values).is_subset_of(values)
875+
876+
assert_that([*list(Status), Status.PAID]).contains_duplicates()
877+
assert_that(_hash_safe([Level.LOW, 1, True])).described_as("mixed with the base type").is_true()
878+
879+
def test_a_subclass_that_replaces_one_operation_stays_on_the_walk(self):
880+
"""Inheriting one of the pair and redefining the other is exactly the disagreement to avoid.
881+
882+
Asked of the owner of each definition, so all four of these are refused: the first hashes by one
883+
rule and compares by another, the second compares by its own, the third is not hashable at all
884+
although its base is, and the fourth had equality assigned onto it after the class body ran.
885+
886+
That last one is the case the `__eq__` half of the rule exists for, and it takes a patched class
887+
to build: writing `__eq__` in the body sets `__hash__` to `None` with it, so the hash half already
888+
refuses those. Patched, it keeps `int.__hash__` and answers `True` to every comparison, and a set
889+
keeps two values the walk calls equal.
890+
"""
891+
892+
class HashesLikeText(int):
893+
__hash__ = str.__hash__
894+
895+
class ComparesItsOwnWay(int):
896+
def __eq__(self, other: object) -> bool:
897+
return isinstance(other, ComparesItsOwnWay) and int(self) == int(other)
898+
899+
__hash__ = int.__hash__
900+
901+
class RefusesToHash(int):
902+
__hash__ = None # ty: ignore[invalid-assignment] # the shape under test
903+
904+
class Patched(int):
905+
pass
906+
907+
def _everything_is_equal(self: object, other: object) -> bool:
908+
return True
909+
910+
Patched.__eq__ = _everything_is_equal # ty: ignore[invalid-assignment] # the shape under test
911+
912+
assert_that(Patched(1) == Patched(2)).described_as("the patched comparison").is_true()
913+
assert_that({Patched(1), Patched(2)}).described_as("a set does not agree with it").is_length(2)
914+
915+
for kind in (HashesLikeText, ComparesItsOwnWay, RefusesToHash, Patched):
916+
values = [kind(index) for index in range(30)]
917+
assert_that(_hash_safe(values)).described_as(f"{kind.__name__} is not safe to index").is_false()
918+
919+
walked = [ComparesItsOwnWay(index) for index in range(30)]
920+
assert_that(only_faults(walked, tuple(walked))).is_equal_to(([], []))
921+
assert_that([*walked, walked[0]]).contains_duplicates()
922+
824923
def test_fractions_stay_on_the_walk_and_answer_the_same(self):
825924
"""Kept out of the shortcut for import cost, not for correctness, so both have to be shown."""
826925
values = [fractions.Fraction(index, 3) for index in range(30)]

0 commit comments

Comments
 (0)