Skip to content

Commit eeae3ce

Browse files
committed
feat(git): Make ChangeSet a Struct wrapping a dict instead of a dict subclass
The composition pattern is safer than direct inheritance + using structs allows for easy serialization and deserialization, which is already useful for test data but will be even more in the future, in order to store build metadata.
1 parent c964cfc commit eeae3ce

File tree

14 files changed

+174
-146
lines changed

14 files changed

+174
-146
lines changed

src/dda/tools/git.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ def get_working_tree_changes(self) -> ChangeSet:
171171
untracked_changes = ChangeSet.generate_from_diff_output(diffs)
172172

173173
# Combine the changes
174-
return ChangeSet(tracked_changes | untracked_changes)
174+
return tracked_changes | untracked_changes
175175

176176
def get_merge_base(self, remote_name: str = "origin") -> SHA1Hash | str:
177177
"""

src/dda/utils/fs.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,30 @@ def __as_exe(self) -> Path:
152152
def __as_exe(self) -> Path:
153153
return self
154154

155+
@classmethod
156+
def enc_hook(cls, obj: Any) -> Any:
157+
if isinstance(obj, cls):
158+
return repr(obj)
159+
160+
message = f"Objects of type {type(obj)} are not supported"
161+
raise NotImplementedError(message)
162+
163+
@classmethod
164+
def dec_hook(cls, obj_type: type, obj: Any) -> Any:
165+
if obj_type is cls:
166+
# Was encoded as the repr of this object
167+
# Should be of the form f"{cls.__qualname__}({str(obj)})"
168+
qualname, path = obj.split("(")
169+
path = path.rstrip(")").strip("'").strip('"')
170+
if qualname != cls.__qualname__:
171+
message = f"Objects of type {obj_type} are not supported"
172+
raise NotImplementedError(message)
173+
174+
return cls(path)
175+
176+
message = f"Objects of type {obj_type} are not supported"
177+
raise NotImplementedError(message)
178+
155179

156180
@contextmanager
157181
def temp_directory() -> Generator[Path, None, None]:

src/dda/utils/git/changeset.py

Lines changed: 67 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
11
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
22
#
33
# SPDX-License-Identifier: MIT
4-
from collections.abc import Generator
4+
5+
from __future__ import annotations
6+
57
from enum import StrEnum
68
from functools import cached_property
7-
from typing import Self
9+
from typing import TYPE_CHECKING, Any, Self
810

9-
from msgspec import Struct
11+
from msgspec import Struct, field
1012

1113
from dda.utils.fs import Path
1214
from dda.utils.git.commit import SHA1Hash
1315

16+
if TYPE_CHECKING:
17+
from _collections_abc import dict_items, dict_keys, dict_values
18+
from collections.abc import Generator, Iterable, Iterator
19+
1420

1521
class ChangeType(StrEnum):
1622
ADDED = "A"
1723
MODIFIED = "M"
1824
DELETED = "D"
1925

2026
@classmethod
21-
def from_github_status(cls, status: str) -> "ChangeType":
27+
def from_github_status(cls, status: str) -> ChangeType:
2228
if status == "added":
2329
return cls.ADDED
2430
if status == "modified":
@@ -30,7 +36,7 @@ def from_github_status(cls, status: str) -> "ChangeType":
3036
raise ValueError(msg)
3137

3238

33-
class FileChanges(Struct):
39+
class FileChanges(Struct, frozen=True):
3440
"""Represents changes to a single file in a git repository."""
3541

3642
file: Path
@@ -164,16 +170,54 @@ def from_dict(cls, data: dict) -> Self:
164170
patch=data["patch"],
165171
)
166172

173+
@classmethod
174+
def enc_hook(cls, obj: Any) -> Any:
175+
# Only unsupported objects are Path objects
176+
return Path.enc_hook(obj)
177+
178+
@classmethod
179+
def dec_hook(cls, obj_type: type, obj: Any) -> Any: # type: ignore[valid-type]
180+
# Only unsupported objects are Path objects
181+
return Path.dec_hook(obj_type, obj)
182+
183+
184+
# Need dict=True so that cached_property can be used
185+
class ChangeSet(Struct, dict=True, frozen=True):
186+
_changes: dict[Path, FileChanges] = field(default_factory=dict)
167187

168-
# Easier and safer to subclass UserDict than Dict directly
169-
class ChangeSet(dict[Path, FileChanges]):
170188
"""
171189
Represents a set of changes to files in a git repository.
172190
This can both be a change between two commits, or the changes in the working directory.
173191
174192
When considering the changes to the working directory, the untracked files are considered as added files.
175193
"""
176194

195+
# == dict proxy methods == #
196+
def keys(self) -> dict_keys[Path, FileChanges]:
197+
return self._changes.keys()
198+
199+
def values(self) -> dict_values[Path, FileChanges]:
200+
return self._changes.values()
201+
202+
def items(self) -> dict_items[Path, FileChanges]:
203+
return self._changes.items()
204+
205+
def __getitem__(self, key: Path) -> FileChanges:
206+
return self._changes[key]
207+
208+
def __contains__(self, key: Path) -> bool:
209+
return key in self._changes
210+
211+
def __len__(self) -> int:
212+
return len(self._changes)
213+
214+
def __iter__(self) -> Iterator[Path]:
215+
return iter(self._changes.keys())
216+
217+
def __or__(self, other: Self) -> Self:
218+
return self.from_iter(list(self.values()) + list(other.values()))
219+
220+
# == properties == #
177221
@cached_property
178222
def added(self) -> set[Path]:
179223
"""List of files that were added."""
@@ -194,10 +238,7 @@ def changed(self) -> set[Path]:
194238
"""List of files that were changed (added, modified, or deleted)."""
195239
return set(self.keys())
196240

197-
def add(self, change: FileChanges) -> None:
198-
"""Add a file change to the changeset."""
199-
self[change.file] = change
200-
241+
# == methods == #
201242
def digest(self) -> SHA1Hash:
202243
"""Compute a hash of the changeset."""
203244
from hashlib import sha1
@@ -210,21 +251,26 @@ def digest(self) -> SHA1Hash:
210251

211252
return SHA1Hash(digester.hexdigest())
212253

254+
@classmethod
255+
def from_iter(cls, data: Iterable[FileChanges]) -> Self:
256+
"""Create a ChangeSet from an iterable of FileChanges."""
257+
items = {change.file: change for change in data}
258+
return cls(_changes=items)
259+
213260
@classmethod
214261
def generate_from_diff_output(cls, diff_output: str | list[str]) -> Self:
215262
"""
216263
Generate a changeset from the output of a git diff command.
217264
The output should be passed as a string or a list of lines.
218265
"""
219-
changeset = cls()
220-
for change in FileChanges.generate_from_diff_output(diff_output):
221-
changeset.add(change)
222-
return changeset
266+
return cls.from_iter(FileChanges.generate_from_diff_output(diff_output))
267+
268+
@classmethod
269+
def enc_hook(cls, obj: Any) -> Any:
270+
# Only unsupported objects are Path objects
271+
return Path.enc_hook(obj)
223272

224273
@classmethod
225-
def from_list(cls, data: list[dict]) -> Self:
226-
"""Create a ChangeSet from a JSON-serializable list."""
227-
changeset = cls()
228-
for change in data:
229-
changeset.add(FileChanges.from_dict(change))
230-
return changeset
274+
def dec_hook(cls, obj_type: type, obj: Any) -> Any:
275+
# Only unsupported objects are Path objects
276+
return Path.dec_hook(obj_type, obj)

src/dda/utils/git/commit.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,14 @@ def get_details_and_changes_from_github(self) -> tuple[CommitDetails, ChangeSet]
7373
data = client.get(self.github_api_url).json()
7474

7575
# Compute ChangeSet
76-
changes = ChangeSet()
77-
for file_obj in data["files"]:
78-
changes.add(
79-
FileChanges(
80-
file=Path(file_obj["filename"]),
81-
type=ChangeType.from_github_status(file_obj["status"]),
82-
patch=file_obj["patch"],
83-
)
76+
changes = ChangeSet.from_iter(
77+
FileChanges(
78+
file=Path(file_obj["filename"]),
79+
type=ChangeType.from_github_status(file_obj["status"]),
80+
patch=file_obj["patch"],
8481
)
82+
for file_obj in data["files"]
83+
)
8584
self._changes = changes
8685

8786
self._details = CommitDetails(

tests/tools/conftest.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import json
21
import os
32
import shutil
43
from collections.abc import Callable, Generator
54

5+
import msgspec
66
import pytest
77
from _pytest.fixtures import SubRequest
88

@@ -157,8 +157,7 @@ def _make_repo_changes(
157157

158158
def _load_changeset(filepath: Path) -> ChangeSet:
159159
with open(filepath, encoding="utf-8") as f:
160-
data = json.load(f)
161-
return ChangeSet.from_list(data)
160+
return msgspec.json.decode(f.read(), type=ChangeSet, dec_hook=ChangeSet.dec_hook)
162161

163162

164163
@pytest.fixture(params=REPO_TESTCASES)
Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1 @@
1-
[
2-
{
3-
"file": "file2.txt",
4-
"change_type": "removed",
5-
"patch": "@@ -1,4 +0,0 @@\n-I am file2 !\n-I feel like I take up space for nothing...\n-I have a feeling like I won't exist pretty soon :/\n-"
6-
},
7-
{
8-
"file": "file3.txt",
9-
"change_type": "added",
10-
"patch": "@@ -0,0 +1,2 @@\n+I am file3 !\n+I'm new around here, hopefully everyone treats me nice :)"
11-
},
12-
{
13-
"file": "file4.txt",
14-
"change_type": "modified",
15-
"patch": "@@ -2 +2 @@ I am file4.\n-People often tell me I am unreliable.\n+People often tell me I am THE BEST.\n@@ -4,3 +4,2 @@ Things like:\n-- You always change !\n-- I can never count on you...\n-- I didn't recognize you !\n+- You rock !\n+- I wish I were you !\n@@ -8 +7,3 @@ Do you think they have a point ?\n-I'd need to look at my own history to know...\n+Pah ! Who am I kidding, they're OBVIOUSLY RIGHT.\n+Arrogance ? What is that, an italian ice cream flavor ?\n+Get outta here !"
16-
},
17-
{
18-
"file": "file5.txt",
19-
"change_type": "removed",
20-
"patch": "@@ -1,5 +0,0 @@\n-I am a humble file.\n-Soon I will change name.\n-I think I'll also take this as an opportunity to change myself.\n-New name, new me !\n-Or is that not how the saying goes ?"
21-
},
22-
{
23-
"file": "file5_new.txt",
24-
"change_type": "added",
25-
"patch": "@@ -0,0 +1,5 @@\n+I am a humble file.\n+Hey I have a new name !\n+Wow, I feel much better now.\n+New name, new me !\n+Or is that not how the saying goes ?"
26-
}
27-
]
1+
{"_changes":{"Path('file2.txt')":{"file":"Path('file2.txt')","type":"D","patch":"@@ -1,4 +0,0 @@\n-I am file2 !\n-I feel like I take up space for nothing...\n-I have a feeling like I won't exist pretty soon :/\n-"},"Path('file3.txt')":{"file":"Path('file3.txt')","type":"A","patch":"@@ -0,0 +1,2 @@\n+I am file3 !\n+I'm new around here, hopefully everyone treats me nice :)"},"Path('file4.txt')":{"file":"Path('file4.txt')","type":"M","patch":"@@ -2 +2 @@ I am file4.\n-People often tell me I am unreliable.\n+People often tell me I am THE BEST.\n@@ -4,3 +4,2 @@ Things like:\n-- You always change !\n-- I can never count on you...\n-- I didn't recognize you !\n+- You rock !\n+- I wish I were you !\n@@ -8 +7,3 @@ Do you think they have a point ?\n-I'd need to look at my own history to know...\n+Pah ! Who am I kidding, they're OBVIOUSLY RIGHT.\n+Arrogance ? What is that, an italian ice cream flavor ?\n+Get outta here !"},"Path('file5.txt')":{"file":"Path('file5.txt')","type":"D","patch":"@@ -1,5 +0,0 @@\n-I am a humble file.\n-Soon I will change name.\n-I think I'll also take this as an opportunity to change myself.\n-New name, new me !\n-Or is that not how the saying goes ?"},"Path('file5_new.txt')":{"file":"Path('file5_new.txt')","type":"A","patch":"@@ -0,0 +1,5 @@\n+I am a humble file.\n+Hey I have a new name !\n+Wow, I feel much better now.\n+New name, new me !\n+Or is that not how the saying goes ?"}}}
Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1 @@
1-
[
2-
{
3-
"file": "file2.txt",
4-
"change_type": "added",
5-
"patch": "@@ -0,0 +1,3 @@\n+file2\n+I am a new file in the repo !\n+That's incredible."
6-
}
7-
]
1+
{"_changes":{"Path('file2.txt')":{"file":"Path('file2.txt')","type":"A","patch":"@@ -0,0 +1,3 @@\n+file2\n+I am a new file in the repo !\n+That's incredible."}}}
Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1 @@
1-
[
2-
{
3-
"file": "file2.txt",
4-
"change_type": "removed",
5-
"patch": "@@ -1,3 +0,0 @@\n-file2\n-I will be deleted, unfortunately.\n-That's quite sad."
6-
}
7-
]
1+
{"_changes":{"Path('file2.txt')":{"file":"Path('file2.txt')","type":"D","patch":"@@ -1,3 +0,0 @@\n-file2\n-I will be deleted, unfortunately.\n-That's quite sad."}}}
Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1 @@
1-
[
2-
{
3-
"file": "added_lines.txt",
4-
"change_type": "modified",
5-
"patch": "@@ -2,0 +3,4 @@ I wonder what that could mean ?\n+\n+But of course !\n+I get some added lines.\n+That makes sense."
6-
},
7-
{
8-
"file": "added_lines_middle.txt",
9-
"change_type": "modified",
10-
"patch": "@@ -2,0 +3,2 @@ I have a bit more text than added_lines.\n+Nobody expects the Spanish Inquisition !\n+My developer really wonders if cracking jokes in test data is against company policy."
11-
},
12-
{
13-
"file": "deleted_lines.txt",
14-
"change_type": "modified",
15-
"patch": "@@ -2,2 +2 @@ Hmm, my name is deleted_lines.\n-I wonder what that could mean ?\n-I don't want to be shortened, that does not seem fun.\n+"
16-
},
17-
{
18-
"file": "deleted_lines_middle.txt",
19-
"change_type": "modified",
20-
"patch": "@@ -2,3 +1,0 @@ Hmm, my name is deleted_lines_middle.\n-I wonder what that could mean ?\n-I don't want to be shortened, that does not seem fun.\n-Thinking about it, being short is also kind of nice though."
21-
},
22-
{
23-
"file": "edited_lines.txt",
24-
"change_type": "modified",
25-
"patch": "@@ -4,8 +4,5 @@ I have a big block of text here:\n-Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n-Vestibulum ornare cursus diam, quis hendrerit nibh.\n-Donec sollicitudin neque in tempus ornare.\n-Integer sit amet pretium quam.\n-Maecenas lacinia augue id est malesuada, vitae fermentum justo faucibus.\n-Aenean posuere nisi tincidunt nisi pharetra blandit.\n-Integer sed nulla sed eros aliquet eleifend quis ac quam.\n-Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.\n+What kind of dev uses lorem ipsum ?\n+Keyboard mashing is where it's at !\n+Check this out:\n+phq w4q3t'p\u00a3tfnjklifewqkpjnoq23bjikkijq4ovikobjqv4kioblj;\n+vqpewkvnkqknbpjlo[iqervkb[jplofqvwer[kpjlqfp[okqolp[f;vn]]]]]"
26-
}
27-
]
1+
{"_changes":{"Path('added_lines.txt')":{"file":"Path('added_lines.txt')","type":"M","patch":"@@ -2,0 +3,4 @@ I wonder what that could mean ?\n+\n+But of course !\n+I get some added lines.\n+That makes sense."},"Path('added_lines_middle.txt')":{"file":"Path('added_lines_middle.txt')","type":"M","patch":"@@ -2,0 +3,2 @@ I have a bit more text than added_lines.\n+Nobody expects the Spanish Inquisition !\n+My developer really wonders if cracking jokes in test data is against company policy."},"Path('deleted_lines.txt')":{"file":"Path('deleted_lines.txt')","type":"M","patch":"@@ -2,2 +2 @@ Hmm, my name is deleted_lines.\n-I wonder what that could mean ?\n-I don't want to be shortened, that does not seem fun.\n+"},"Path('deleted_lines_middle.txt')":{"file":"Path('deleted_lines_middle.txt')","type":"M","patch":"@@ -2,3 +1,0 @@ Hmm, my name is deleted_lines_middle.\n-I wonder what that could mean ?\n-I don't want to be shortened, that does not seem fun.\n-Thinking about it, being short is also kind of nice though."},"Path('edited_lines.txt')":{"file":"Path('edited_lines.txt')","type":"M","patch":"@@ -4,8 +4,5 @@ I have a big block of text here:\n-Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n-Vestibulum ornare cursus diam, quis hendrerit nibh.\n-Donec sollicitudin neque in tempus ornare.\n-Integer sit amet pretium quam.\n-Maecenas lacinia augue id est malesuada, vitae fermentum justo faucibus.\n-Aenean posuere nisi tincidunt nisi pharetra blandit.\n-Integer sed nulla sed eros aliquet eleifend quis ac quam.\n-Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.\n+What kind of dev uses lorem ipsum ?\n+Keyboard mashing is where it's at !\n+Check this out:\n+phq w4q3t'p£tfnjklifewqkpjnoq23bjikkijq4ovikobjqv4kioblj;\n+vqpewkvnkqknbpjlo[iqervkb[jplofqvwer[kpjlqfp[okqolp[f;vn]]]]]"}}}
Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1 @@
1-
[
2-
{
3-
"file": "file1.txt",
4-
"change_type": "removed",
5-
"patch": "@@ -1,3 +0,0 @@\n-I am a humble file.\n-Soon I will change name.\n-But that won't change who I am, my contents !"
6-
},
7-
{
8-
"file": "file1_new.txt",
9-
"change_type": "added",
10-
"patch": "@@ -0,0 +1,3 @@\n+I am a humble file.\n+Soon I will change name.\n+But that won't change who I am, my contents !"
11-
},
12-
{
13-
"file": "file2.txt",
14-
"change_type": "removed",
15-
"patch": "@@ -1,5 +0,0 @@\n-I am a humble file.\n-Soon I will change name.\n-I think I'll also take this as an opportunity to change myself.\n-New name, new me !\n-Or is that not how the saying goes ?"
16-
},
17-
{
18-
"file": "file2_new.txt",
19-
"change_type": "added",
20-
"patch": "@@ -0,0 +1,5 @@\n+I am a humble file.\n+Hey I have a new name !\n+Wow, I feel much better now.\n+New name, new me !\n+Or is that not how the saying goes ?"
21-
}
22-
]
1+
{"_changes":{"Path('file1.txt')":{"file":"Path('file1.txt')","type":"D","patch":"@@ -1,3 +0,0 @@\n-I am a humble file.\n-Soon I will change name.\n-But that won't change who I am, my contents !"},"Path('file1_new.txt')":{"file":"Path('file1_new.txt')","type":"A","patch":"@@ -0,0 +1,3 @@\n+I am a humble file.\n+Soon I will change name.\n+But that won't change who I am, my contents !"},"Path('file2.txt')":{"file":"Path('file2.txt')","type":"D","patch":"@@ -1,5 +0,0 @@\n-I am a humble file.\n-Soon I will change name.\n-I think I'll also take this as an opportunity to change myself.\n-New name, new me !\n-Or is that not how the saying goes ?"},"Path('file2_new.txt')":{"file":"Path('file2_new.txt')","type":"A","patch":"@@ -0,0 +1,5 @@\n+I am a humble file.\n+Hey I have a new name !\n+Wow, I feel much better now.\n+New name, new me !\n+Or is that not how the saying goes ?"}}}

0 commit comments

Comments
 (0)