Skip to content

Commit b6d7d88

Browse files
authored
Merge pull request #364 from 15r10nk/fix-dataclasses-as-dict-keys
fix: dataclasses as dictionary keys
2 parents 009d353 + ae3b50e commit b6d7d88

11 files changed

Lines changed: 175 additions & 24 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
runs-on: ${{matrix.os}}
2424
strategy:
2525
matrix:
26-
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14', pypy3.9, pypy3.10]
26+
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14', pypy3.11]
2727
os: [ubuntu-latest, windows-2022, macos-14]
2828
extra_deps: ['"--with=pydantic<2"', '"--with=pydantic>2"']
2929
is_insider:
@@ -47,9 +47,7 @@ jobs:
4747
- os: macos-14
4848
python-version: '3.13'
4949
- os: macos-14
50-
python-version: pypy3.9
51-
- os: macos-14
52-
python-version: pypy3.10
50+
python-version: pypy3.11
5351

5452
- os: windows-2022
5553
python-version: '3.10'
@@ -60,9 +58,7 @@ jobs:
6058
- os: windows-2022
6159
python-version: '3.13'
6260
- os: windows-2022
63-
python-version: pypy3.9
64-
- os: windows-2022
65-
python-version: pypy3.10
61+
python-version: pypy3.11
6662

6763
env:
6864
TOP: ${{github.workspace}}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
### Fixed
2+
3+
- Fixed snapshot comparison for dicts where keys are dataclass instances (or other custom objects used as dict keys), which previously caused corrupted snapshots — either collapsing multiple entries into one or appending duplicate keys on subsequent runs ([#363](https://github.com/15r10nk/inline-snapshot/issues/363)).
4+
- Fixed tuple snapshot updates to compare elements positionally rather than using sequence alignment, so existing expressions (e.g. `3 + 3`) are preserved when elements are removed from or added to a tuple.

src/inline_snapshot/_customize/_builder.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ def create_dict(self, value: dict) -> Custom:
161161
self._get_handler_recursive(k): self._get_handler_recursive(v)
162162
for k, v in value.items()
163163
}
164+
assert len(value) == len(custom)
165+
164166
return CustomDict(value=custom)
165167

166168
@property

src/inline_snapshot/_customize/_custom_call.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from ._custom import Custom
1212

1313

14-
@dataclass(frozen=True)
14+
@dataclass(frozen=True, eq=False)
1515
class CustomDefault(Custom):
1616
value: Custom = field(compare=False)
1717

@@ -30,7 +30,7 @@ def unwrap_default(value):
3030
return value
3131

3232

33-
@dataclass(frozen=True)
33+
@dataclass(frozen=True, eq=False)
3434
class CustomCall(Custom):
3535
node_type = ast.Call
3636
function: Custom = field(compare=False)

src/inline_snapshot/_customize/_custom_dict.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from ._custom import Custom
1212

1313

14-
@dataclass(frozen=True)
14+
@dataclass(frozen=True, eq=False)
1515
class CustomDict(Custom):
1616
node_type = ast.Dict
1717
value: dict[Custom, Custom] = field(compare=False)

src/inline_snapshot/_customize/_custom_external.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from ._custom import Custom
1616

1717

18-
@dataclass(frozen=True)
18+
@dataclass(frozen=True, eq=False)
1919
class CustomExternal(Custom):
2020
value: Any
2121
format: str | None = None

src/inline_snapshot/_customize/_custom_unmanaged.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from ._custom import Custom
1111

1212

13-
@dataclass()
13+
@dataclass(eq=False)
1414
class CustomUnmanaged(Custom):
1515
value: Any
1616

src/inline_snapshot/_new_adapter.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,9 @@ def compare_CustomCode(
228228

229229
return new_value
230230

231-
def compare_CustomSequence(
232-
self, old_value: CustomSequence, old_node: ast.AST, new_value: CustomSequence
233-
) -> Generator[ChangeBase, None, CustomSequence]:
231+
def compare_CustomList(
232+
self, old_value: CustomList, old_node: ast.AST, new_value: CustomList
233+
) -> Generator[ChangeBase, None, CustomList]:
234234

235235
if old_node is not None:
236236
assert isinstance(
@@ -288,8 +288,47 @@ def compare_CustomSequence(
288288

289289
return type(new_value)(result)
290290

291-
compare_CustomTuple = compare_CustomSequence
292-
compare_CustomList = compare_CustomSequence
291+
def compare_CustomTuple(
292+
self, old_value: CustomTuple, old_node: ast.AST, new_value: CustomTuple
293+
) -> Generator[ChangeBase, None, CustomTuple]:
294+
"""Compare tuples positionally: match elements at the same index,
295+
delete surplus old elements, insert extra new elements."""
296+
297+
if old_node is not None:
298+
assert isinstance(old_node, ast.Tuple)
299+
300+
old_elts = old_value.value
301+
new_elts = new_value.value
302+
old_nodes = old_node.elts if old_node is not None else [None] * len(old_elts)
303+
304+
common = min(len(old_elts), len(new_elts))
305+
result = []
306+
307+
# compare paired elements
308+
for old_elem, old_node_elem, new_elem in zip(old_elts, old_nodes, new_elts):
309+
v = yield from self.compare(old_elem, old_node_elem, new_elem)
310+
result.append(v)
311+
312+
# delete surplus old elements
313+
for old_elem, old_node_elem in zip(old_elts[common:], old_nodes[common:]):
314+
yield Delete("fix", self.context.file, old_node_elem, old_elem)
315+
316+
# insert extra new elements
317+
if len(new_elts) > common:
318+
to_insert = []
319+
for new_elem in new_elts[common:]:
320+
new_code = yield from new_elem._code_repr(self.context)
321+
to_insert.append((new_code, new_elem))
322+
result.append(new_elem)
323+
yield ListInsert(
324+
"fix",
325+
self.context.file,
326+
old_node,
327+
common,
328+
*zip(*to_insert), # type:ignore
329+
)
330+
331+
return CustomTuple(result)
293332

294333
def compare_CustomDict(
295334
self, old_value: CustomDict, old_node: ast.Dict, new_value: CustomDict

tests/adapter/test_dataclass.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,46 @@ def test_A():
778778
)
779779

780780

781+
def test_dataclass_as_dict_key_create():
782+
"""Regression test for CustomCall-keyed dicts: when a dataclass instance is
783+
used as a dict key, the snapshot should be created correctly and should not
784+
corrupt values on subsequent runs (issue #363 / duplicate report)."""
785+
Example(
786+
"""\
787+
from dataclasses import dataclass
788+
from inline_snapshot import snapshot
789+
790+
@dataclass(frozen=True)
791+
class Container:
792+
value: int
793+
794+
def test_something():
795+
data = {Container(40): 40, Container(2): 2}
796+
assert data == snapshot()
797+
"""
798+
).run_inline(
799+
["--inline-snapshot=create"],
800+
changed_files=snapshot(
801+
{
802+
"tests/test_something.py": """\
803+
from dataclasses import dataclass
804+
from inline_snapshot import snapshot
805+
806+
@dataclass(frozen=True)
807+
class Container:
808+
value: int
809+
810+
def test_something():
811+
data = {Container(40): 40, Container(2): 2}
812+
assert data == snapshot({Container(value=40): 40, Container(value=2): 2})
813+
"""
814+
}
815+
),
816+
).run_inline(
817+
["--inline-snapshot=fix"], changed_files=snapshot({})
818+
)
819+
820+
781821
@pytest.mark.skipif(
782822
sys.version_info < (3, 10),
783823
reason="NewType is a function in 3.9 and cannot be checked with isinstance or serialized to code",

tests/adapter/test_sequence.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,77 @@ def test_tuple():
119119
["--inline-snapshot=fix"],
120120
changed_files=snapshot({}),
121121
)
122+
123+
124+
def test_tuple_fix_shorter():
125+
"""Tuple shrinks: surplus old elements are deleted, paired elements fixed.
126+
Expressions in paired positions are preserved."""
127+
Example(
128+
"""\
129+
from inline_snapshot import snapshot
130+
131+
def test_tuple():
132+
assert (1, 5) == snapshot((0+1, 2, 3, 4, 5))
133+
"""
134+
).run_inline(
135+
["--inline-snapshot=fix"],
136+
changed_files=snapshot(
137+
{
138+
"tests/test_something.py": """\
139+
from inline_snapshot import snapshot
140+
141+
def test_tuple():
142+
assert (1, 5) == snapshot((0+1, 5))
143+
"""
144+
}
145+
),
146+
)
147+
148+
149+
def test_tuple_fix_longer():
150+
"""Tuple grows: paired elements are fixed, extra new elements are inserted."""
151+
Example(
152+
"""\
153+
from inline_snapshot import snapshot
154+
155+
def test_tuple():
156+
assert (1, 2, 3, 4, 5, 6) == snapshot((0+1, 3))
157+
"""
158+
).run_inline(
159+
["--inline-snapshot=fix"],
160+
changed_files=snapshot(
161+
{
162+
"tests/test_something.py": """\
163+
from inline_snapshot import snapshot
164+
165+
def test_tuple():
166+
assert (1, 2, 3, 4, 5, 6) == snapshot((0+1, 2, 3, 4, 5, 6))
167+
"""
168+
}
169+
),
170+
)
171+
172+
173+
def test_tuple_update_preserves_expression():
174+
"""Tuple update: elements whose values haven't changed keep their original
175+
expression (e.g. ``2+2`` is not rewritten to ``4``)."""
176+
Example(
177+
"""\
178+
from inline_snapshot import snapshot
179+
180+
def test_tuple():
181+
assert (4, 99) == snapshot((2+2, 1+1))
182+
"""
183+
).run_inline(
184+
["--inline-snapshot=fix"],
185+
changed_files=snapshot(
186+
{
187+
"tests/test_something.py": """\
188+
from inline_snapshot import snapshot
189+
190+
def test_tuple():
191+
assert (4, 99) == snapshot((2+2, 99))
192+
"""
193+
}
194+
),
195+
)

0 commit comments

Comments
 (0)