Skip to content

Commit 6243c06

Browse files
committed
fix: hold a repeated hour apart in match.is_now, and read a mapping by its keys under ignore and include
1 parent 37b87ba commit 6243c06

5 files changed

Lines changed: 208 additions & 26 deletions

File tree

‎assertpy2/_engine/_equality.py‎

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -230,14 +230,17 @@ def _flattened(node: Any, through: frozenset[str], depth: int = 0) -> Any:
230230
type(node),
231231
{name: _flattened(value, through, deeper) for name, value in model_field_values(node).items()},
232232
)
233-
if isinstance(node, tuple) and hasattr(node, "_fields"):
234-
return type(node)(*[_flattened(item, through, deeper) for item in node])
235-
if isinstance(node, (list, tuple)):
236-
return type(node)(_flattened(item, through, deeper) for item in node)
237-
if isinstance(node, dict):
233+
# what a value is, is asked of its class from here on: a value may answer `isinstance` itself
234+
held: Any = node
235+
kind = type(held)
236+
if issubclass(kind, tuple) and hasattr(held, "_fields"):
237+
return kind(*[_flattened(item, through, deeper) for item in held])
238+
if issubclass(kind, (list, tuple)):
239+
return kind(_flattened(item, through, deeper) for item in held)
240+
if issubclass(kind, dict):
238241
# a key stays as held: taken apart, a record used as one could not be hashed
239-
rebuilt = {key: _flattened(value, through, deeper) for key, value in node.items()}
240-
return rebuilt if type(node) is dict else _as_its_class(node, rebuilt)
242+
rebuilt = {key: _flattened(value, through, deeper) for key, value in held.items()}
243+
return rebuilt if kind is dict else _as_its_class(held, rebuilt)
241244
return node
242245

243246

@@ -293,19 +296,19 @@ def _mend(memo: dict[int, Any]) -> None:
293296
nothing, so that ends. A field attrs compares through a key is held raw, and stays so.
294297
"""
295298
for rebuilt in list(memo.values()):
296-
if isinstance(rebuilt, dict):
299+
if issubclass(type(rebuilt), dict):
297300
raw = rebuilt.compared_by if isinstance(rebuilt, TakenApart) else ()
298301
for key, value in rebuilt.items():
299302
if key not in raw:
300303
rebuilt[key] = _mended(value, memo)
301-
elif isinstance(rebuilt, list):
304+
elif issubclass(type(rebuilt), list):
302305
rebuilt[:] = [_mended(item, memo) for item in rebuilt]
303306

304307

305308
def _mended(value: Any, memo: dict[int, Any]) -> Any:
306309
"""*value* as it was rebuilt where it was left as it was, or made again where it is a tuple holding such a one."""
307310
rebuilt = memo.get(id(value), value)
308-
if rebuilt is not value or not isinstance(value, tuple):
311+
if rebuilt is not value or not issubclass(type(value), tuple):
309312
return rebuilt
310313
items = [_mended(item, memo) for item in value]
311314
if all(new is old for new, old in zip(items, value, strict=True)):
@@ -343,11 +346,11 @@ def _flattened_once(node: Any, through: frozenset[str], memo: dict[int, Any]) ->
343346
for name, value in fields.items():
344347
taken[name] = value if name in taken.compared_by else _flattened_once(value, through, memo)
345348
return taken
346-
if not isinstance(node, (list, tuple, dict)):
349+
if not issubclass(type(node), (list, tuple, dict)):
347350
return node
348351
# no generator: through one a level was three frames on Python 3.10, 331 levels deep against 496
349352
rebuilt: Any
350-
if isinstance(node, dict):
353+
if issubclass(type(node), dict):
351354
rebuilt = memo[id(node)] = {} if type(node) is dict else _of_its_class(node)
352355
put = _put_into(rebuilt)
353356
for key, value in node.items():
@@ -400,19 +403,30 @@ def _read_through(obj: object) -> frozenset[str] | None:
400403
return None
401404

402405

403-
def as_fields(value: object, beside: object = None) -> dict | None:
404-
"""A plain dict as itself, anything else through `comparable_fields`.
406+
def as_fields(value: object, beside: object = None) -> Any:
407+
"""A dict or another mapping by its keys, anything else through `comparable_fields`.
405408
406409
A dict *beside* a record taken apart all the way down is taken apart as deep. Left as held, a record the
407410
dict holds was compared whole against the fields of the equal one the record holds, and the two differed.
411+
A dict that is a record as well stays as held: taken apart it was read by its fields, and a key it held
412+
beside them was gone.
413+
414+
What is a dict is asked of the class, since a value may answer `isinstance` itself. A mapping that is no
415+
dict and no record is its keys too: refused, a `MappingProxyType` beside a record was called not dict-like,
416+
and a `ChainMap` was read through the attribute it keeps its maps in.
408417
"""
409-
if not isinstance(value, dict):
410-
return comparable_fields(value)
411-
through = None if isinstance(beside, dict) else _read_through(beside)
412-
return value if through is None else cast("dict", _fields_through(value, through))
418+
if not issubclass(type(value), dict):
419+
own = _read_through(value)
420+
if own is not None:
421+
return _fields_through(value, own)
422+
return value if mapping_shaped(value, check_values=False) else comparable_fields(value)
423+
through = None if issubclass(type(beside), dict) else _read_through(beside)
424+
if through is None or _read_through(value) is not None:
425+
return value
426+
return _fields_through(value, through)
413427

414428

415-
def fields_pair(value: object, other: object) -> tuple[dict | None, dict | None]:
429+
def fields_pair(value: object, other: object) -> tuple[Any, Any]:
416430
"""Two values through their fields, each read beside the other (`as_fields`)."""
417431
return as_fields(value, other), as_fields(other, value)
418432

@@ -817,7 +831,7 @@ def _nested_differs(
817831

818832
def _keyed(value: object, beside: object) -> Any:
819833
"""A mapping as what a key path reads it through, its keys, and anything else through its fields."""
820-
if mapping_shaped(value, check_values=False) and not isinstance(value, dict):
834+
if mapping_shaped(value, check_values=False) and not issubclass(type(value), dict):
821835
return value
822836
return as_fields(value, beside)
823837

‎assertpy2/_engine/_introspection.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,8 @@ def is_mapping_like(obj: object) -> TypeGuard[MappingLike]:
316316
return True
317317
if type(obj) in _ATOMIC_TYPES:
318318
return False
319-
return isinstance(obj, MappingLike) and callable(obj.keys)
319+
# read with a default: a value may say it is a dict through `__class__` and have no `keys` at all
320+
return isinstance(obj, MappingLike) and callable(getattr(obj, "keys", None))
320321

321322

322323
def keyed_snapshot(candidate: object) -> MappingLike | None:

‎assertpy2/_matcher_impls.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import sys
66
import uuid as _uuid_mod
77
from dataclasses import dataclass
8-
from datetime import date, datetime, time, timedelta
8+
from datetime import date, datetime, time, timedelta, timezone
99
from decimal import Decimal
1010
from fractions import Fraction
1111
from types import UnionType
@@ -1359,8 +1359,9 @@ def __init__(self, delta: timedelta):
13591359
def matches(self, value: Any) -> bool:
13601360
if not isinstance(value, datetime):
13611361
return False
1362-
# `datetime.now(tzinfo)` matches the value's awareness, so the subtraction never mixes naive and aware
1363-
return abs(value - datetime.now(value.tzinfo)) <= self._delta
1362+
# as moments: on one zone object the two readings of a repeated hour subtract to nothing, an hour apart
1363+
now = datetime.now(timezone.utc) if value.utcoffset() is not None else datetime.now()
1364+
return abs(value - now) <= self._delta
13641365

13651366
def describe(self) -> str:
13661367
return f"a datetime within {self._delta} of now"

‎tests/test_dict_compare.py‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import collections
22
import dataclasses
33
import sys
4+
import types
45
import typing
56

67
import pytest
@@ -900,6 +901,122 @@ def test_a_record_under_two_dicts_is_read_by_its_fields_only_where_a_path_enters
900901
assert_that(actual).is_equal_to(expected, ignore="v")
901902
assert_that(actual).is_equal_to(expected, ignore=["v", ("next", "missing")])
902903

904+
def test_the_diff_of_a_failed_sequence_goes_as_deep_on_the_dict_side(self):
905+
actual, expected = [{"v": 0, "next": _Holder(1)}], [_Holder(0, _Holder(2))]
906+
with pytest.raises(AssertionFailure) as caught:
907+
assert_that(actual).is_equal_to(expected, ignore="missing")
908+
assert_that([entry.path for entry in caught.value.diff.entries]).is_equal_to(["[0].next.v"])
909+
910+
def test_a_dict_that_is_a_record_as_well_keeps_its_keys(self):
911+
@dataclasses.dataclass
912+
class Keyed(dict):
913+
v: int
914+
915+
@dataclasses.dataclass
916+
class Record:
917+
v: int
918+
919+
held = Keyed(1)
920+
held["extra"] = 2
921+
for value, other in ((held, Record(1)), (Record(1), held)):
922+
with pytest.raises(AssertionFailure):
923+
assert_that(value).is_equal_to(other, ignore="missing")
924+
assert_that(held).is_equal_to({"extra": 2}, ignore="missing")
925+
assert_that(_equality.as_fields(held, Record(1))).is_same_as(held)
926+
927+
def test_what_is_a_dict_is_asked_of_the_class_and_not_of_the_value(self):
928+
class SaysDict:
929+
__class__ = property(lambda self: dict)
930+
931+
def __init__(self):
932+
self.v = 0
933+
self.next = None
934+
935+
says = SaysDict()
936+
assert_that(isinstance(says, dict)).is_true()
937+
assert_that(_equality.as_fields(says)).is_not_same_as(says)
938+
assert_that(_equality.as_fields({"v": 0}, says)).is_equal_to({"v": 0})
939+
assert_that(says).is_equal_to(_Holder(0), ignore="missing")
940+
assert_that(_Holder(0)).is_equal_to(says, ignore="missing")
941+
assert_that({"o": says}).is_equal_to({"o": _Holder(9)}, ignore=("o", "v"))
942+
943+
@pytest.mark.parametrize("kind", [dict, list, tuple])
944+
def test_a_value_that_says_what_it_is_not_is_left_whole_where_it_is_held(self, kind):
945+
says = type("Says", (), {"__class__": property(lambda self: kind), "_fields": ()})()
946+
assert_that(isinstance(says, kind)).is_true()
947+
a_list_that_says = type("AListThatSays", (list,), {"__class__": property(lambda self: kind)})([1])
948+
a_tuple_that_says = type("ATupleThatSays", (tuple,), {"__class__": property(lambda self: kind)})((1,))
949+
ring = {"v": 0, "next": says, "more": a_list_that_says, "and": a_tuple_that_says}
950+
ring["me"] = ring
951+
for payload in ({"v": 0, "next": says}, {"v": 0, "next": [says]}, _Holder(0, says), ring):
952+
with pytest.raises(AssertionFailure):
953+
assert_that(payload).is_equal_to(_Holder(0, _Holder(1)), ignore="missing")
954+
955+
def test_a_dict_that_is_a_record_is_read_where_it_is_held_as_its_own_equality_reads_it(self):
956+
@dataclasses.dataclass
957+
class Keyed(dict):
958+
v: int
959+
960+
@dataclasses.dataclass
961+
class Plain:
962+
v: int
963+
964+
held = Keyed(1)
965+
held["extra"] = 2
966+
assert_that(held == Keyed(1)).is_true()
967+
# by its fields, which is how a record holding it was always read: its own `==` does not read its keys
968+
assert_that(_Holder(0, held)).is_equal_to(_Holder(0, Plain(1)), ignore="missing")
969+
assert_that({"v": 0, "next": held}).is_equal_to(_Holder(0, Plain(1)), ignore="missing")
970+
971+
@pytest.mark.parametrize(
972+
"mapping",
973+
[types.MappingProxyType, lambda held: collections.ChainMap(held)],
974+
ids=["a mapping proxy", "a chain map"],
975+
)
976+
def test_a_mapping_that_is_no_dict_is_read_by_its_keys_beside_a_record(self, mapping):
977+
record = _Holder(0, None)
978+
assert_that(mapping({"v": 0, "next": None})).is_equal_to(record, ignore="missing")
979+
assert_that(record).is_equal_to(mapping({"v": 0, "next": None}), ignore="missing")
980+
assert_that(record).is_equal_to(mapping({"v": 9, "next": None}), ignore="v")
981+
for value, other in ((mapping({"v": 1, "next": None}), record), (record, mapping({"v": 1, "next": None}))):
982+
with pytest.raises(AssertionFailure) as caught:
983+
assert_that(value).is_equal_to(other, ignore="missing")
984+
assert_that([entry.path for entry in caught.value.diff.entries]).is_equal_to(["v"])
985+
with pytest.raises(AssertionFailure):
986+
assert_that(mapping({"v": 0, "next": None, "more": 1})).is_equal_to(record, ignore="missing")
987+
988+
def test_a_record_that_answers_as_a_mapping_is_still_read_by_its_fields(self):
989+
@dataclasses.dataclass
990+
class Row:
991+
v: int
992+
next: object = None
993+
994+
def keys(self):
995+
return ["column"]
996+
997+
def __getitem__(self, key):
998+
return "cell"
999+
1000+
def __iter__(self):
1001+
return iter(self.keys())
1002+
1003+
assert_that(Row(0)).is_equal_to(_Holder(0), ignore="missing")
1004+
assert_that(_Holder(0)).is_equal_to(Row(0), ignore="missing")
1005+
with pytest.raises(AssertionFailure):
1006+
assert_that(Row(1)).is_equal_to(_Holder(0), ignore="missing")
1007+
1008+
def test_a_dict_beside_a_model_is_read_through_the_dataclasses_a_model_is_read_through(self):
1009+
pydantic = pytest.importorskip("pydantic")
1010+
1011+
class Shape(pydantic.BaseModel):
1012+
at: _Holder
1013+
name: str = "s"
1014+
1015+
shape, payload = Shape(at=_Holder(1)), {"at": {"v": 1, "next": None}, "name": "s"}
1016+
assert_that(payload).is_equal_to(shape, ignore="missing")
1017+
assert_that(shape).is_equal_to(payload, ignore="missing")
1018+
assert_that({"at": _Holder(1), "name": "s"}).is_equal_to(shape, ignore="missing")
1019+
9031020
def test_the_matcher_answers_as_the_builder_does(self):
9041021
record, payload = _Holder(0, _Holder(1)), {"v": 0, "next": _Holder(1)}
9051022
assert_that(match.equal_to(payload, ignore="v").matches(record)).is_true()

‎tests/test_matchers.py‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import io
22
import re
33
import typing
4-
from datetime import date, datetime, timedelta, timezone
4+
from datetime import date, datetime, timedelta, timezone, tzinfo
55
from math import inf, nan
66

77
import pytest
88

9-
from assertpy2 import AssertionFailure, Matcher, assert_that, match
9+
from assertpy2 import AssertionFailure, Matcher, _matcher_impls, assert_that, match
1010
from assertpy2.matchers import AllOfMatcher, AnyOfMatcher, BaseMatcher, EachMatcher, HasPropertyMatcher, NotMatcher
1111

1212

@@ -33,6 +33,55 @@ def test_is_now_refuses_what_is_no_span_as_it_did(self):
3333
with pytest.raises(TypeError, match="unsupported type for timedelta seconds component"):
3434
match.is_now("soon")
3535

36+
def test_is_now_holds_the_two_readings_of_a_repeated_hour_an_hour_apart(self, monkeypatch):
37+
class FallsBack(tzinfo):
38+
"""Two hours east until its clocks read 03:00 on 1 November 2026, then 02:00 again and one hour east."""
39+
40+
def utcoffset(self, moment):
41+
wall = moment.replace(tzinfo=None)
42+
early = wall < datetime(2026, 11, 1, 2) or (wall < datetime(2026, 11, 1, 3) and not moment.fold)
43+
return timedelta(hours=2 if early else 1)
44+
45+
def tzname(self, moment):
46+
return "falls back"
47+
48+
def dst(self, moment):
49+
return None
50+
51+
real = datetime
52+
second = datetime(2026, 11, 1, 2, 30, tzinfo=FallsBack(), fold=1)
53+
54+
class EveryDatetimeIsOne(type):
55+
def __instancecheck__(cls, instance):
56+
return isinstance(instance, real)
57+
58+
class Pinned(datetime, metaclass=EveryDatetimeIsOne):
59+
@classmethod
60+
def now(cls, tz=None):
61+
return second.astimezone(tz)
62+
63+
monkeypatch.setattr(_matcher_impls, "datetime", Pinned)
64+
first = second.replace(fold=0)
65+
assert_that(first == second).is_true()
66+
assert_that(first.utcoffset() - second.utcoffset()).is_equal_to(timedelta(hours=1))
67+
assert_that(match.is_now(0).matches(second)).is_true()
68+
assert_that(match.is_now(0).matches(first)).is_false()
69+
assert_that(match.is_now(timedelta(minutes=59)).matches(first)).is_false()
70+
assert_that(match.is_now(timedelta(hours=1)).matches(first)).is_true()
71+
72+
def test_is_now_reads_a_zone_that_gives_no_offset_as_no_zone(self):
73+
class GivesNoOffset(tzinfo):
74+
def utcoffset(self, moment):
75+
return None
76+
77+
def tzname(self, moment):
78+
return None
79+
80+
def dst(self, moment):
81+
return None
82+
83+
assert_that(match.is_now(5).matches(datetime.now().replace(tzinfo=GivesNoOffset()))).is_true()
84+
3685
def test_is_now_takes_a_delta_of_nothing(self):
3786
assert_that(match.is_now(0).describe()).is_equal_to("a datetime within 0:00:00 of now")
3887
assert_that(match.is_now(timedelta()).matches(datetime.now() - timedelta(seconds=1))).is_false()

0 commit comments

Comments
 (0)