1414import dataclasses
1515import datetime
1616import decimal
17+ import enum
1718import fractions
1819import math
1920from 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