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.
Files changed (63) hide show
  1. assertpy/__init__.py +87 -0
  2. assertpy/_clustering.py +511 -0
  3. assertpy/_dangling.py +505 -0
  4. assertpy/_engine/__init__.py +1 -0
  5. assertpy/_engine/_builder_check_typing.py +1365 -0
  6. assertpy/_engine/_capable_typing.py +772 -0
  7. assertpy/_engine/_check_typing.py +753 -0
  8. assertpy/_engine/_compare.py +983 -0
  9. assertpy/_engine/_compat.py +38 -0
  10. assertpy/_engine/_contract.py +1926 -0
  11. assertpy/_engine/_diff.py +1058 -0
  12. assertpy/_engine/_equality.py +843 -0
  13. assertpy/_engine/_introspection.py +729 -0
  14. assertpy/_engine/_membership.py +522 -0
  15. assertpy/_engine/_mixin_base.py +258 -0
  16. assertpy/_engine/_negated_typing.py +2176 -0
  17. assertpy/_engine/_operations.py +75 -0
  18. assertpy/_engine/_ordering.py +884 -0
  19. assertpy/_engine/_pairing.py +70 -0
  20. assertpy/_engine/_path.py +158 -0
  21. assertpy/_engine/_poll_typing.py +6039 -0
  22. assertpy/_engine/_require.py +312 -0
  23. assertpy/_engine/_size.py +31 -0
  24. assertpy/_engine/_text.py +39 -0
  25. assertpy/_engine/_typing.py +1226 -0
  26. assertpy/_hints.py +1213 -0
  27. assertpy/_inline.py +484 -0
  28. assertpy/_matcher_impls.py +1876 -0
  29. assertpy/_passes.py +249 -0
  30. assertpy/_renamed.py +64 -0
  31. assertpy/_satisfies.py +835 -0
  32. assertpy/_snapshot_codec.py +290 -0
  33. assertpy/assertpy.py +2640 -0
  34. assertpy/async_assertions.py +864 -0
  35. assertpy/base.py +862 -0
  36. assertpy/behave_matchers.py +96 -0
  37. assertpy/bytes_mixin.py +158 -0
  38. assertpy/collection.py +528 -0
  39. assertpy/contains.py +1068 -0
  40. assertpy/dataframe.py +200 -0
  41. assertpy/date.py +327 -0
  42. assertpy/dict.py +249 -0
  43. assertpy/dynamic.py +222 -0
  44. assertpy/errors.py +782 -0
  45. assertpy/exception.py +838 -0
  46. assertpy/extracting.py +266 -0
  47. assertpy/file.py +324 -0
  48. assertpy/helpers.py +747 -0
  49. assertpy/http_mixin.py +218 -0
  50. assertpy/json_mixin.py +1265 -0
  51. assertpy/matchers.py +639 -0
  52. assertpy/numeric.py +696 -0
  53. assertpy/outcome.py +163 -0
  54. assertpy/py.typed +0 -0
  55. assertpy/pytest_plugin.py +1650 -0
  56. assertpy/snapshot.py +1031 -0
  57. assertpy/string.py +803 -0
  58. assertpy/warning.py +137 -0
  59. assertpy-2.35.0.dist-info/METADATA +324 -0
  60. assertpy-2.35.0.dist-info/RECORD +63 -0
  61. assertpy-2.35.0.dist-info/WHEEL +4 -0
  62. assertpy-2.35.0.dist-info/entry_points.txt +2 -0
  63. assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,522 @@
1
+ """Membership as one decision: which of the wanted items a value does not contain.
2
+
3
+ `contains` is the second relation that a builder could express and a matcher could not. A structural
4
+ spec had `each_item` (every element matches) and `is_in` (the value is one of these), but no way to say
5
+ the plain thing: *this collection contains that*. Writing it a second time inside the matcher would
6
+ have repeated the part that is easy to get subtly wrong, since membership is not one rule: a mapping is
7
+ searched by key, a matcher argument is satisfied by any element rather than compared to the whole, and a
8
+ one-shot iterator has to be drained before it is searched more than once.
9
+
10
+ So the decision moves here and both spellings ask it. What stays outside is the wording: the builder
11
+ turns the answer into a message with a closest-element hint, and a matcher turns it into a verdict.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime
17
+ import decimal
18
+ from collections import Counter
19
+ from collections.abc import Iterable
20
+ from typing import TYPE_CHECKING, Any, NoReturn, cast
21
+
22
+ from ._introspection import definition_of, is_mapping_like, materialized
23
+ from ._ordering import (
24
+ _PLAIN,
25
+ MEMBERSHIP_REFUSALS,
26
+ REFUSALS,
27
+ equals,
28
+ lookup,
29
+ may_broadcast,
30
+ member,
31
+ member_past,
32
+ mixes_broadcasting,
33
+ )
34
+ from ._require import raised_inside, verdict
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import Callable, Sequence
38
+
39
+
40
+ def searchable(value: object) -> Any:
41
+ """*value* in a form that can be searched more than once.
42
+
43
+ A generator is consumed by the first pass, and membership is tested once per wanted item. A value
44
+ that cannot be iterated at all is handed back untouched, which is why the parameter is `object`:
45
+ deciding whether membership can be asked of it is `is_searchable`'s job, not this one's.
46
+
47
+ Only a one-shot iterator is copied. A list, set, tuple or mapping is handed back as it is, so a
48
+ matcher built from one stays a live view of it, the way `match.equal_to(expected)` does:
49
+
50
+ known = [1]
51
+ constraint = match.is_subset_of(known)
52
+ known.append(2)
53
+ constraint.matches([2]) # True: the same list, not a snapshot of it
54
+
55
+ The copy for an iterator is therefore not a snapshot policy but the only way to answer twice at all,
56
+ and it is eager: an endless iterator handed in as the expected operand never finishes being drained.
57
+ """
58
+ return materialized(cast("Iterable[Any]", value))
59
+
60
+
61
+ def is_searchable(value: object) -> bool:
62
+ """Whether membership can be asked of *value*: `in` needs a container or an iterable.
63
+
64
+ Wider than `is_walkable` on purpose, and the two are not interchangeable. A value that answers
65
+ `__contains__` and nothing else can be asked whether it holds something, and cannot be listed: it
66
+ fits `contains` and does not fit `contains_only`, which has to see everything that is there.
67
+ """
68
+ # the builtins skip the ABC's 88 ns walk; each has `__contains__`, so the probe below said yes to them anyway
69
+ return isinstance(value, (list, tuple, str, bytes, dict, set, frozenset, Iterable)) or hasattr(
70
+ type(value), "__contains__"
71
+ )
72
+
73
+
74
+ def is_walkable(value: object) -> bool:
75
+ """Whether *value* can be listed, which is what "only these" and "a subset of" both need."""
76
+ return isinstance(value, Iterable)
77
+
78
+
79
+ # types whose `__hash__` and `__eq__` agree; `bytearray` looks like one and turned `contains_only` into TypeError
80
+ _HASH_SAFE = frozenset(
81
+ {
82
+ int,
83
+ float,
84
+ complex,
85
+ bool,
86
+ str,
87
+ bytes,
88
+ frozenset,
89
+ type(None),
90
+ # `Fraction` qualifies and is left out: `fractions` drags in thirteen modules at import
91
+ decimal.Decimal,
92
+ datetime.date,
93
+ datetime.datetime,
94
+ datetime.time,
95
+ datetime.timedelta,
96
+ }
97
+ )
98
+
99
+
100
+ # measured 2x slower at ten elements and several times faster at a hundred
101
+ _WALK_UNDER = 400
102
+
103
+ # the one safe-list type that refuses to hash for a single value: `Decimal("snan")` raises
104
+ _HASH_MAY_REFUSE = frozenset({decimal.Decimal})
105
+
106
+
107
+ def _worth_hashing(container: Any, probes: Any) -> bool:
108
+ """Whether the pair is large enough that a set can win at all, asked before anything expensive.
109
+
110
+ The comparison count is what decides, so the two questions differ: searching one collection for the
111
+ elements of another costs `len * len`, while looking for a repeat inside one collection costs about
112
+ `len` squared over two but pays for only one set. Passing the same collection twice is how the
113
+ second question spells itself.
114
+ """
115
+ try:
116
+ return len(container) * max(len(probes), 1) >= _WALK_UNDER
117
+ except TypeError: # no length: an iterator or a view, where the walk cost is unknown but not small
118
+ return True
119
+ except Exception: # a length refused: the walk never sizes the value, and this is a cost, not a verdict
120
+ return False
121
+
122
+
123
+ def _kinds(items: Any) -> set[type] | None:
124
+ """The distinct types in *items*, or ``None`` when even that cannot be asked.
125
+
126
+ Collected in the interpreter's own loop rather than a Python one: over ten thousand elements the loop
127
+ would cost more than the set it is deciding about, and what is left to judge is one entry per type,
128
+ which is almost always one.
129
+
130
+ Asking can itself fail, which is not hypothetical: a class whose metaclass declares `__hash__ = None`
131
+ cannot go into a set although its instances compare perfectly well. Unanswerable means "no".
132
+ """
133
+ try:
134
+ return set(map(type, items))
135
+ except TypeError: # the class objects themselves refuse to be hashed
136
+ return None
137
+
138
+
139
+ def _safe(kinds: set[type] | None) -> bool:
140
+ """Whether every one of *kinds* hashes and compares by the same rule."""
141
+ return kinds is not None and all(_agrees(kind) for kind in kinds)
142
+
143
+
144
+ def _agrees(kind: type) -> bool:
145
+ """Whether this type's ``==`` and ``hash()`` answer consistently, so a set may stand in for a walk.
146
+
147
+ Hashability is asked of the type, not tried on the value: an unhashable type carries
148
+ `__hash__ = None`. It is not implied by the rest of the rule, since a class can inherit identity
149
+ equality and still declare itself unhashable, and without this the shortcut raised where it answered.
150
+
151
+ The third clause is what admits a subclass. A name on the safe list is not enough on its own,
152
+ because that list holds exact types, and `IntEnum`, `StrEnum` and a plain `class Money(int)` all
153
+ took the quadratic walk although they answer exactly as their base does: four thousand of them cost
154
+ 30 ms against 0.09 ms. Asked of the owner of each definition rather than of the definition itself,
155
+ so a class that assigns `__hash__ = str.__hash__` beside an inherited `int.__eq__` is refused: its
156
+ owner is the class, not a safe type. A base cannot supply the two from different safe types either,
157
+ since inheriting from two of them is a layout conflict the interpreter refuses.
158
+ """
159
+ return kind.__hash__ is not None and (
160
+ kind in _HASH_SAFE
161
+ or kind.__eq__ is object.__eq__
162
+ or (_owned_by_a_safe_type(kind, "__eq__") and _owned_by_a_safe_type(kind, "__hash__"))
163
+ )
164
+
165
+
166
+ def _owned_by_a_safe_type(kind: type, name: str) -> bool:
167
+ """Whether *kind* inherits *name* unchanged from one of the types on the safe list."""
168
+ found = definition_of(kind, name)
169
+ return found is not None and found[0] in _HASH_SAFE
170
+
171
+
172
+ def _hash_safe(items: Any) -> bool:
173
+ """Whether *items* may be indexed. Kept as a name of its own because the tests read better with it."""
174
+ return _safe(_kinds(items))
175
+
176
+
177
+ class MembershipRefusedError(TypeError):
178
+ """The operands refuse the question: this item cannot be searched for in this value at all.
179
+
180
+ Its own type so a matcher can answer "no match" to it, while a `TypeError` raised inside the value's
181
+ own `__contains__` or `__eq__` still reaches the reader as the bug in that value it is. Raised only for
182
+ a caller that asks with ``marked=True``: a builder's caller gets the operator's own `TypeError`, as a
183
+ single item does, where the class used to name a private module whenever two or more were asked.
184
+ """
185
+
186
+
187
+ def _told_apart(pairs: Sequence[tuple[Any, Any]], original: TypeError, *, marked: bool) -> NoReturn:
188
+ """Ask again one expression at a time, so an operand refusal can be told from a bug in their code.
189
+
190
+ Reached only where a walk has already raised, so the second ask costs a passing run nothing. Asked
191
+ whole, the walk is several frames deep and `raised_inside` cannot see which frame tried the operator;
192
+ asked one `in` at a time, it reads exactly that.
193
+ """
194
+ for items, container in pairs:
195
+ for item in items:
196
+ try:
197
+ if item in container:
198
+ continue
199
+ except (decimal.InvalidOperation, OverflowError):
200
+ continue # a signalling NaN or a `numpy` overflow, which `member` answers: not the refusal sought
201
+ except TypeError as refusal:
202
+ if raised_inside(refusal):
203
+ raise
204
+ if _answered(item, container):
205
+ continue
206
+ if not marked:
207
+ raise
208
+ raise MembershipRefusedError(str(refusal)) from refusal
209
+ raise original
210
+
211
+
212
+ def _answered(item: Any, container: Any) -> bool:
213
+ """Whether `member` answers what ``in`` refused, as it does a `Decimal` against a `numpy` integer."""
214
+ try:
215
+ member(item, container)
216
+ except TypeError:
217
+ return False
218
+ return True
219
+
220
+
221
+ def missing_items(
222
+ value: Any, items: Sequence[Any], is_matcher: Callable[[object], bool], *, marked: bool = False
223
+ ) -> list[Any]:
224
+ """Which of *items* are not in *value*, in the order they were asked for.
225
+
226
+ A matcher among the items is satisfied by any single element, which is what makes this more than
227
+ `item in value`: `contains(match.greater_than(3))` asks whether *some* element is greater than 3,
228
+ not whether the collection holds that matcher object.
229
+
230
+ The matcher test is passed in rather than imported: matchers are a layer above this one, and having
231
+ the core reach up for them would make the dependency circular.
232
+ """
233
+ walked = materialized(value)
234
+ wanted = materialized(items)
235
+ present = _index(walked, wanted) if isinstance(walked, (list, tuple)) else None
236
+ return _absent_from(walked if present is None else present, wanted, is_matcher, walked, marked)
237
+
238
+
239
+ def _absent_from(
240
+ present: Any, items: Any, is_matcher: Callable[[object], bool], walked: Any, marked: bool
241
+ ) -> list[Any]:
242
+ """The loop itself, so the fast and the walking form stay one piece of logic rather than two.
243
+
244
+ A refusal is named by the item it came from. A matcher is asked of the elements and never by ``in``, so an
245
+ error of its own is its own and leaves as it was raised; an item `member` refused is asked again alone.
246
+ """
247
+ absent = []
248
+ # the index `_index` built holds only types that hash and compare alike, which no `numpy` scalar or list is
249
+ verify = present is walked
250
+ for item in items:
251
+ if is_matcher(item):
252
+ if not any(verdict(item.matches(element), subject="the matcher") for element in walked):
253
+ absent.append(item)
254
+ continue
255
+ try:
256
+ if not _held(item, present, verify):
257
+ absent.append(item)
258
+ except TypeError as refusal:
259
+ _told_apart((((item,), present),), refusal, marked=marked)
260
+ return absent
261
+
262
+
263
+ def _held(item: Any, present: Any, verify: bool) -> bool:
264
+ """`member`, asking ``in`` itself where no item that may broadcast can come: through it cost 100 items 11%."""
265
+ if not verify or type(item) in _PLAIN:
266
+ try:
267
+ return item in present
268
+ except MEMBERSHIP_REFUSALS as refusal:
269
+ found = member_past(item, present, refusal)
270
+ if found is None:
271
+ raise
272
+ return found
273
+ return member(item, present, verify)
274
+
275
+
276
+ def only_faults(value: Any, items: Sequence[Any], *, marked: bool = False) -> tuple[list[Any], list[Any]]:
277
+ """``(extra, missing)`` for "contains only these": what is there but unwanted, and what is absent.
278
+
279
+ Both halves at once, because reporting only the extras sends the reader to fix one problem and rerun
280
+ into the other. A matcher looks at whether either list is non-empty; the assertion words them.
281
+ """
282
+ # walked three times below, and idempotent for the callers that already did it
283
+ walked, wanted_items = materialized(value), materialized(items)
284
+ # each side is searched for the other, and building both together is what makes either refusal fall back
285
+ # only over sequences, as the other two searches are: a set of a string's characters answers by character
286
+ # where the walk answers by substring, and the verdict then moved with the length of the string
287
+ indexable = isinstance(walked, (list, tuple)) and isinstance(wanted_items, (list, tuple))
288
+ both = _index_both(wanted_items, walked) if indexable else None
289
+ try:
290
+ if both is None:
291
+ return _without(walked, wanted_items), _without(wanted_items, walked)
292
+ wanted, present = both
293
+ extra = _without(walked, wanted)
294
+ missing = _without(wanted_items, present)
295
+ except TypeError as refusal:
296
+ _told_apart(((walked, wanted_items), (wanted_items, walked)), refusal, marked=marked)
297
+ return extra, missing
298
+
299
+
300
+ def _without(items: Iterable[Any], container: Any) -> list[Any]:
301
+ """The *items* not in *container*, asked by ``in`` and, once that raised, as `member` asks it.
302
+
303
+ A `TypeError` is asked again too, since a signalling NaN refuses to hash for a set: `member` answers that
304
+ one and hands every other refusal on.
305
+ """
306
+ # a walk of a sequence is quadratic, so the pass that finds a `numpy` scalar meeting a list costs it nothing
307
+ if isinstance(container, (list, tuple)) and mixes_broadcasting(items, container):
308
+ return [item for item in items if not member(item, container)]
309
+ try:
310
+ return [item for item in items if item not in container]
311
+ except REFUSALS:
312
+ return [item for item in items if not member(item, container)]
313
+
314
+
315
+ def has_duplicates(values: Sequence[Any]) -> bool:
316
+ """Whether any element appears twice, by the same equality the rest of membership uses."""
317
+ if _classified_alone(values):
318
+ try:
319
+ # what a set replaces is a growing list of seen elements, quadratic from the first few
320
+ return len(set(values)) != len(values)
321
+ except Exception: # a value refused to hash after all; only hashing is inside this `try`
322
+ pass
323
+ if mixes_broadcasting(values):
324
+ return any(member(value, values[:index]) for index, value in enumerate(values))
325
+ # `in` rather than a generator of `==`: the interpreter asks it, and it short-circuits on identity
326
+ seen: list[Any] = []
327
+ try:
328
+ for value in values:
329
+ if value in seen:
330
+ return True
331
+ seen.append(value)
332
+ except REFUSALS:
333
+ return any(member(value, values[:index]) for index, value in enumerate(values))
334
+ return False
335
+
336
+
337
+ def occurrences(values: Sequence[Any], items: Sequence[Any]) -> list[int]:
338
+ """How often each of *items* appears in *values*, in the order they were asked for.
339
+
340
+ `list.count()` per item walks the whole collection once per item: a thousand items over ten thousand
341
+ elements is ten million comparisons, and the failure report asked for another pass. Where both sides
342
+ are safe to hash, by the same rule membership uses, one `Counter` answers all of them.
343
+
344
+ The probes are hashed inside the guard and looked up outside it, as `_index` does: a lookup runs
345
+ `__eq__` on a collision, and a comparison that raises is a bug in the value rather than a count.
346
+ """
347
+ counted = None
348
+ if _classified(values, items):
349
+ try:
350
+ counted = Counter(values)
351
+ for item in items:
352
+ hash(item)
353
+ except TypeError: # a value that refuses to hash despite its type, such as a signalling NaN
354
+ counted = None
355
+ if counted is None:
356
+ return [_count(values, item) for item in items]
357
+ return [counted[item] for item in items]
358
+
359
+
360
+ def _count(values: Sequence[Any], item: Any) -> int:
361
+ """``values.count(item)``, each element asked as `equals` asks it once ``count`` raised."""
362
+ if may_broadcast(item) and mixes_broadcasting(values, (item,)):
363
+ return sum(1 for value in values if value is item or equals(value, item))
364
+ try:
365
+ return values.count(item)
366
+ except REFUSALS as refusal:
367
+ if raised_inside(refusal):
368
+ raise
369
+ return sum(1 for value in values if value is item or equals(value, item))
370
+
371
+
372
+ def repeated_counts(values: Sequence[Any]) -> list[tuple[Any, int]]:
373
+ """Each element appearing more than once with how often, in order of first appearance.
374
+
375
+ Counting with `list.count()` per element re-walks the whole sequence every time, so naming the
376
+ duplicates in a collection of a few thousand costs as much as the assertion it explains. Where the
377
+ elements are safe to hash (same rule as membership uses), one pass counts them all.
378
+ """
379
+ counts = None
380
+ if _classified_alone(values):
381
+ try:
382
+ counts = Counter(values)
383
+ except TypeError: # a value that refuses to hash despite its type, such as a signalling NaN
384
+ counts = None
385
+ if counts is None:
386
+ named: list[tuple[Any, int]] = []
387
+ for value in values:
388
+ total = _count(values, value)
389
+ if total > 1 and not any(earlier is value or equals(earlier, value) for earlier, _ in named):
390
+ named.append((value, total))
391
+ return named
392
+ seen: set[Any] = set()
393
+ repeated = []
394
+ for value in values:
395
+ if counts[value] > 1 and value not in seen:
396
+ seen.add(value)
397
+ repeated.append((value, counts[value]))
398
+ return repeated
399
+
400
+
401
+ def repeated_items(values: Sequence[Any]) -> list[Any]:
402
+ """Which elements appear more than once, each named once, in order of first appearance."""
403
+ return [value for value, _count in repeated_counts(values)]
404
+
405
+
406
+ def flattened_supersets(supersets: Sequence[Any]) -> list[Any]:
407
+ """The supersets of `is_subset_of` as one collection of items, which is what the value is searched in.
408
+
409
+ A superset that cannot be iterated is a single item, and a string is its characters, which is what
410
+ ``in`` over a flattened list answers. The matcher used to keep the superset whole, so a string
411
+ superset answered substring membership and a `set` superset hashed items that refuse to hash.
412
+ """
413
+ collected: list[Any] = []
414
+ for superset in supersets:
415
+ try:
416
+ collected.extend(materialized(superset))
417
+ except TypeError: # noqa: PERF203 # a non-iterable superset is one item, as the assertion has always read it
418
+ collected.append(superset)
419
+ return collected
420
+
421
+
422
+ def subset_faults(value: Any, supersets: Sequence[Any]) -> list[Any]:
423
+ """What no superset holds: the pairs of a mapping value, or the elements of anything else.
424
+
425
+ A pair holds when any one superset holds it, rather than when the mapping left after merging them
426
+ does: merging let a later superset overwrite an earlier one, so the verdict moved with the order the
427
+ supersets were written in.
428
+ """
429
+ if is_mapping_like(value):
430
+ pairs = [(key, value[key]) for key in value]
431
+ return [{key: item} for key, item in pairs if not _pair_held(key, item, supersets)]
432
+ return not_contained_in(list(materialized(value)), flattened_supersets(supersets))
433
+
434
+
435
+ def _pair_held(key: Any, item: Any, supersets: Sequence[Any]) -> bool:
436
+ """Whether one key/value pair is in any of the supersets, by identity first, as a container asks."""
437
+ for superset in supersets:
438
+ try:
439
+ if not is_mapping_like(superset) or key not in superset:
440
+ continue
441
+ held = superset[key]
442
+ except REFUSALS as refusal:
443
+ found, held = lookup(superset, key, refusal)
444
+ if not found:
445
+ continue
446
+ if held is item or equals(held, item):
447
+ return True
448
+ return False
449
+
450
+
451
+ def not_contained_in(value: Any, container: Any) -> list[Any]:
452
+ """Which elements of *value* the *container* does not hold, for "is a subset of"."""
453
+ value, container = materialized(value), materialized(container)
454
+ indexed = _index(container, value) if isinstance(container, (list, tuple)) else None
455
+ allowed = container if indexed is None else indexed
456
+ return _without(value, allowed)
457
+
458
+
459
+ def _classified(container: Any, probes: Any) -> bool:
460
+ """Whether a set may stand in for the walk here: worth the preparation, and safe to build."""
461
+ return _worth_hashing(container, probes) and _hash_safe(container) and _hash_safe(probes)
462
+
463
+
464
+ def _index(container: Any, probes: Any) -> set[Any] | None:
465
+ """*container* as a set, or ``None`` when the walk has to answer instead.
466
+
467
+ Everything that can raise is inside, and nothing else is: building the set hashes the container, and
468
+ the probes are asked separately only where a classified type is still allowed to refuse, because
469
+ `x in some_set` hashes `x` too. The search itself always runs outside any `except`.
470
+
471
+ Asking every probe unconditionally was the first shape of this, and it hashed the same values twice
472
+ on the successful path, which is a pass over the whole collection for `is_subset_of`.
473
+
474
+ That boundary is the point. A wider `try` around the search would swallow a failing comparison or a
475
+ matcher raising from user code, and this library treats those as bugs in the test that must travel
476
+ out, not as verdicts. What is caught here is narrower and real: a type may pass the rule and still
477
+ hold a value that refuses to hash, and before this shortcut existed nothing hashed it at all.
478
+
479
+ Two limits, both deliberate. Only subclasses of `Exception` are caught, so a `KeyboardInterrupt`
480
+ during hashing still stops the run, as it should. And a `__hash__` that answers once and raises
481
+ later is not covered by anything here: a hash is required to be deterministic, and a value that
482
+ breaks that contract is left to break it.
483
+ """
484
+ container_kinds, probe_kinds = _kinds(container), _kinds(probes)
485
+ if not (_worth_hashing(container, probes) and _safe(container_kinds) and _safe(probe_kinds)):
486
+ return None
487
+ try:
488
+ indexed = set(container)
489
+ if probe_kinds is not None and (probe_kinds & _HASH_MAY_REFUSE or not probe_kinds <= _HASH_SAFE):
490
+ # anything but an exact builtin may still refuse: a `Decimal("snan")`, or a class of one's own
491
+ # that inherits identity equality and writes a `__hash__` that raises
492
+ for probe in probes:
493
+ hash(probe)
494
+ except Exception: # any Exception raised while hashing means the walk answers instead
495
+ return None
496
+ return indexed
497
+
498
+
499
+ def _index_both(left: Any, right: Any) -> tuple[set[Any], set[Any]] | None:
500
+ """Both collections as sets, or nothing.
501
+
502
+ Where each side is searched for the other, building both is already the whole question: a value that
503
+ refuses to hash stops one of the two constructions, and there is nothing left to check separately.
504
+ Asking `_index` twice would hash every element twice over for no added safety.
505
+ """
506
+ if not _classified(left, right):
507
+ return None
508
+ try:
509
+ return set(left), set(right)
510
+ except Exception: # either side refusing is enough, and neither index is used then
511
+ return None
512
+
513
+
514
+ def _classified_alone(values: Any) -> bool:
515
+ """The same decision for a question asked of one collection, where only one set gets built.
516
+
517
+ Kept apart from `_classified` because the arithmetic differs, and differs in the other direction than
518
+ it first looks: what a set replaces here is a growing list of seen elements, which is quadratic from
519
+ the very first elements. A size threshold made this *slower* at thirty elements, so there is none:
520
+ the only question is whether the elements may be hashed at all.
521
+ """
522
+ return _hash_safe(values)