diff --git a/docs/reference/api/git.md b/docs/reference/api/git.md index 2c53a82d..97e2df11 100644 --- a/docs/reference/api/git.md +++ b/docs/reference/api/git.md @@ -34,13 +34,12 @@ ::: dda.utils.git.changeset.ChangeSet options: members: - - changes + - files + - paths - added - modified - deleted - - changed - digest - - from_iter - from_patches ::: dda.utils.git.changeset.ChangedFile diff --git a/src/dda/config/model/__init__.py b/src/dda/config/model/__init__.py index 2ed2142b..df94b05b 100644 --- a/src/dda/config/model/__init__.py +++ b/src/dda/config/model/__init__.py @@ -15,7 +15,7 @@ from dda.config.model.tools import ToolsConfig from dda.config.model.update import UpdateConfig from dda.config.model.user import UserConfig -from dda.utils.fs import Path +from dda.types.hooks import dec_hook, enc_hook def _default_orgs() -> dict[str, OrgConfig]: @@ -52,38 +52,3 @@ def get_default_toml_data() -> dict[str, Any]: builtin_types=(datetime.datetime, datetime.date, datetime.time), enc_hook=enc_hook, ) - - -def dec_hook(type: type[Any], obj: Any) -> Any: # noqa: A002 - if type is Path: - return Path(obj) - - from msgspec import convert - - from dda.utils.git.changeset import ChangedFile, ChangeSet - - if type is ChangeSet: - # Since the dict decode logic from msgspec is not called here we have to manually decode the keys and values - decoded_obj = {} - for key, value in obj.items(): - decoded_key = dec_hook(Path, key) - decoded_value = convert(value, ChangedFile, dec_hook=dec_hook) - decoded_obj[decoded_key] = decoded_value - return ChangeSet(changes=decoded_obj) - - message = f"Cannot decode: {obj!r}" - raise ValueError(message) - - -def enc_hook(obj: Any) -> Any: - if isinstance(obj, Path): - return str(obj) - - from dda.utils.git.changeset import ChangeSet - - # Encode ChangeSet objects as dicts - if isinstance(obj, ChangeSet): - return dict(obj.changes) - - message = f"Cannot encode: {obj!r}" - raise NotImplementedError(message) diff --git a/src/dda/types/__init__.py b/src/dda/types/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/src/dda/types/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/src/dda/types/hooks.py b/src/dda/types/hooks.py new file mode 100644 index 00000000..86fad30a --- /dev/null +++ b/src/dda/types/hooks.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from types import MappingProxyType +from typing import TYPE_CHECKING, Any + +from msgspec import Struct + +if TYPE_CHECKING: + from collections.abc import Callable + + +class Hook(Struct, frozen=True): + encode: Callable[[Any], Any] + decode: Callable[[Any], Any] + + +def register_type_hooks( + typ: type[Any], + *, + encode: Callable[[Any], Any], + decode: Callable[[Any], Any], +) -> None: + __HOOKS[typ] = Hook(encode=encode, decode=decode) + + +def enc_hook(obj: Any) -> Any: + if (registered_type := __HOOKS.get(type(obj))) is not None: + return registered_type.encode(obj) + + message = f"Cannot encode: {obj!r}" + raise NotImplementedError(message) + + +def dec_hook(typ: type[Any], obj: Any) -> Any: + if (registered_type := __HOOKS.get(typ)) is not None: + return registered_type.decode(obj) + + message = f"Cannot decode: {obj!r}" + raise ValueError(message) + + +__HOOKS: dict[type[Any], Hook] = {} + +register_type_hooks(MappingProxyType, encode=dict, decode=MappingProxyType) diff --git a/src/dda/utils/fs.py b/src/dda/utils/fs.py index 5add994c..6cef502c 100644 --- a/src/dda/utils/fs.py +++ b/src/dda/utils/fs.py @@ -10,6 +10,8 @@ from functools import cached_property from typing import IO, TYPE_CHECKING, Any +from dda.types.hooks import register_type_hooks + if TYPE_CHECKING: from collections.abc import Generator @@ -189,3 +191,6 @@ def temp_file(suffix: str = "") -> Generator[Path, None, None]: with NamedTemporaryFile(suffix=suffix) as f: yield Path(f.name).resolve() + + +register_type_hooks(Path, encode=str, decode=Path) diff --git a/src/dda/utils/git/changeset.py b/src/dda/utils/git/changeset.py index b2232fe1..4dc2e0e8 100644 --- a/src/dda/utils/git/changeset.py +++ b/src/dda/utils/git/changeset.py @@ -6,15 +6,17 @@ from enum import StrEnum from functools import cached_property +from itertools import chain from types import MappingProxyType from typing import TYPE_CHECKING, Self -from msgspec import Struct +from msgspec import Struct, convert, to_builtins +from dda.types.hooks import dec_hook, enc_hook, register_type_hooks from dda.utils.fs import Path if TYPE_CHECKING: - from collections.abc import ItemsView, Iterable, Iterator, KeysView, ValuesView + from collections.abc import Iterable class ChangeType(StrEnum): @@ -58,78 +60,45 @@ class ChangeSet: # noqa: PLW1641 When considering the changes to the working directory, the untracked files are considered as added files. """ - def __init__(self, changes: dict[Path, ChangedFile]) -> None: - self.__changes = MappingProxyType(changes) + def __init__(self, changed_files: Iterable[ChangedFile]) -> None: + self.__changed = MappingProxyType({str(c.path): c for c in changed_files}) + self.__files = tuple(self.__changed.values()) @property - def changes(self) -> MappingProxyType[Path, ChangedFile]: - return self.__changes + def paths(self) -> MappingProxyType[str, ChangedFile]: + return self.__changed - def keys(self) -> KeysView[Path]: - return self.__changes.keys() - - def values(self) -> ValuesView[ChangedFile]: - return self.__changes.values() - - def items(self) -> ItemsView[Path, ChangedFile]: - return self.__changes.items() - - def __getitem__(self, key: Path) -> ChangedFile: - return self.__changes[key] - - def __contains__(self, key: Path) -> bool: - return key in self.__changes - - def __len__(self) -> int: - return len(self.__changes) - - def __iter__(self) -> Iterator[Path]: - return iter(self.__changes.keys()) - - def __or__(self, other: Self) -> Self: - return self.from_iter(list(self.values()) + list(other.values())) - - def __eq__(self, other: object) -> bool: - return isinstance(other, ChangeSet) and self.__changes == other.__changes - - @cached_property - def added(self) -> set[Path]: - """List of files that were added.""" - return {change.path for change in self.values() if change.type == ChangeType.ADDED} + @property + def files(self) -> Iterable[ChangedFile]: + return self.__files - @cached_property - def modified(self) -> set[Path]: - """List of files that were modified.""" - return {change.path for change in self.values() if change.type == ChangeType.MODIFIED} + @property + def added(self) -> MappingProxyType[str, ChangedFile]: + """Set of files that were added.""" + return self.__change_types[ChangeType.ADDED] - @cached_property - def deleted(self) -> set[Path]: - """List of files that were deleted.""" - return {change.path for change in self.values() if change.type == ChangeType.DELETED} + @property + def modified(self) -> MappingProxyType[str, ChangedFile]: + """Set of files that were modified.""" + return self.__change_types[ChangeType.MODIFIED] - @cached_property - def changed(self) -> set[Path]: - """List of files that were changed (added, modified, or deleted).""" - return set(self.keys()) + @property + def deleted(self) -> MappingProxyType[str, ChangedFile]: + """Set of files that were deleted.""" + return self.__change_types[ChangeType.DELETED] def digest(self) -> str: """Compute a hash of the changeset.""" from hashlib import sha256 digester = sha256() - for change in sorted(self.values(), key=lambda x: x.path.as_posix()): + for change in sorted(self.files, key=lambda cf: cf.path): digester.update(change.path.as_posix().encode()) digester.update(change.type.value.encode()) digester.update(change.patch.encode()) return str(digester.hexdigest()) - @classmethod - def from_iter(cls, data: Iterable[ChangedFile]) -> Self: - """Create a ChangeSet from an iterable of FileChanges.""" - items = {change.path: change for change in data} - return cls(changes=items) - @classmethod def from_patches(cls, diff_output: str | list[str]) -> Self: """ @@ -182,7 +151,21 @@ def from_patches(cls, diff_output: str | list[str]) -> Self: # Strip every "block" and add the missing separator patch = "" if binary else "\n".join([sep + block.strip() for block in blocks]).strip() changes.append(ChangedFile(path=current_file, type=current_type, binary=binary, patch=patch)) - return cls.from_iter(changes) + return cls(changes) + + def __or__(self, other: Self) -> Self: + return type(self)(chain(self.files, other.files)) + + def __eq__(self, other: object) -> bool: + return isinstance(other, ChangeSet) and self.paths == other.paths + + @cached_property + def __change_types(self) -> dict[ChangeType, MappingProxyType[str, ChangedFile]]: + changes: dict[ChangeType, dict[str, ChangedFile]] = {} + for change in self.files: + changes.setdefault(change.type, {})[str(change.path)] = change + + return {change_type: MappingProxyType(paths) for change_type, paths in changes.items()} def _determine_change_type(before_filename: str, after_filename: str) -> ChangeType: @@ -195,3 +178,10 @@ def _determine_change_type(before_filename: str, after_filename: str) -> ChangeT msg = f"Unexpected file paths in git diff output: {before_filename} -> {after_filename} - this indicates a rename which we do not support" raise ValueError(msg) + + +register_type_hooks( + ChangeSet, + encode=lambda obj: to_builtins(obj.files, enc_hook=enc_hook), + decode=lambda obj: ChangeSet(convert(cf, ChangedFile, dec_hook=dec_hook) for cf in obj), +) diff --git a/src/dda/utils/git/github.py b/src/dda/utils/git/github.py index d313c76a..45f3dcc1 100644 --- a/src/dda/utils/git/github.py +++ b/src/dda/utils/git/github.py @@ -44,7 +44,7 @@ def get_commit_and_changes_from_github(remote: Remote, sha1: str) -> tuple[Commi data = client.get(get_commit_github_api_url(remote, sha1)).json() # Compute ChangeSet - changes = ChangeSet.from_iter( + changes = ChangeSet( ChangedFile( path=Path(file_obj["filename"]), type=get_change_type_from_github_status(file_obj["status"]), diff --git a/tests/tools/git/fixtures/repo_states/complex_changes/expected_changeset.json b/tests/tools/git/fixtures/repo_states/complex_changes/expected_changeset.json index ed10bc7d..70d45e98 100644 --- a/tests/tools/git/fixtures/repo_states/complex_changes/expected_changeset.json +++ b/tests/tools/git/fixtures/repo_states/complex_changes/expected_changeset.json @@ -1,32 +1,32 @@ -{ - "file2.txt": { +[ + { "path": "file2.txt", "type": "D", "binary": false, "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-" }, - "file3.txt": { + { "path": "file3.txt", "type": "A", "binary": false, "patch": "@@ -0,0 +1,2 @@\n+I am file3 !\n+I'm new around here, hopefully everyone treats me nice :)" }, - "file4.txt": { + { "path": "file4.txt", "type": "M", "binary": false, "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 !" }, - "file5.txt": { + { "path": "file5.txt", "type": "D", "binary": false, "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 ?" }, - "file5_new.txt": { + { "path": "file5_new.txt", "type": "A", "binary": false, "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 ?" } -} +] diff --git a/tests/tools/git/fixtures/repo_states/file_add/expected_changeset.json b/tests/tools/git/fixtures/repo_states/file_add/expected_changeset.json index ab00e729..bdef1e5d 100644 --- a/tests/tools/git/fixtures/repo_states/file_add/expected_changeset.json +++ b/tests/tools/git/fixtures/repo_states/file_add/expected_changeset.json @@ -1,8 +1,8 @@ -{ - "file2.txt": { +[ + { "path": "file2.txt", "type": "A", "binary": false, "patch": "@@ -0,0 +1,3 @@\n+file2\n+I am a new file in the repo !\n+That's incredible." } -} +] diff --git a/tests/tools/git/fixtures/repo_states/file_delete/expected_changeset.json b/tests/tools/git/fixtures/repo_states/file_delete/expected_changeset.json index a11aae26..b123c602 100644 --- a/tests/tools/git/fixtures/repo_states/file_delete/expected_changeset.json +++ b/tests/tools/git/fixtures/repo_states/file_delete/expected_changeset.json @@ -1,8 +1,8 @@ -{ - "file2.txt": { +[ + { "path": "file2.txt", "type": "D", "binary": false, "patch": "@@ -1,3 +0,0 @@\n-file2\n-I will be deleted, unfortunately.\n-That's quite sad." } -} +] diff --git a/tests/tools/git/fixtures/repo_states/file_edits/expected_changeset.json b/tests/tools/git/fixtures/repo_states/file_edits/expected_changeset.json index 47521e38..e49db49a 100644 --- a/tests/tools/git/fixtures/repo_states/file_edits/expected_changeset.json +++ b/tests/tools/git/fixtures/repo_states/file_edits/expected_changeset.json @@ -1,32 +1,32 @@ -{ - "added_lines.txt": { +[ + { "path": "added_lines.txt", "type": "M", "binary": false, "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." }, - "added_lines_middle.txt": { + { "path": "added_lines_middle.txt", "type": "M", "binary": false, "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." }, - "deleted_lines.txt": { + { "path": "deleted_lines.txt", "type": "M", "binary": false, "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+" }, - "deleted_lines_middle.txt": { + { "path": "deleted_lines_middle.txt", "type": "M", "binary": false, "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." }, - "edited_lines.txt": { + { "path": "edited_lines.txt", "type": "M", "binary": false, "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]]]]]" } -} +] diff --git a/tests/tools/git/fixtures/repo_states/file_rename/expected_changeset.json b/tests/tools/git/fixtures/repo_states/file_rename/expected_changeset.json index d2670045..44f2427b 100644 --- a/tests/tools/git/fixtures/repo_states/file_rename/expected_changeset.json +++ b/tests/tools/git/fixtures/repo_states/file_rename/expected_changeset.json @@ -1,26 +1,26 @@ -{ - "file1.txt": { +[ + { "path": "file1.txt", "type": "D", "binary": false, "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 !" }, - "file1_new.txt": { + { "path": "file1_new.txt", "type": "A", "binary": false, "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 !" }, - "file2.txt": { + { "path": "file2.txt", "type": "D", "binary": false, "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 ?" }, - "file2_new.txt": { + { "path": "file2_new.txt", "type": "A", "binary": false, "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 ?" } -} +] diff --git a/tests/tools/git/test_git.py b/tests/tools/git/test_git.py index ebc0ab8f..201b3a61 100644 --- a/tests/tools/git/test_git.py +++ b/tests/tools/git/test_git.py @@ -21,19 +21,13 @@ def assert_changesets_equal(actual: ChangeSet, expected: ChangeSet) -> None: """ - Assert that two ChangeSet objects are equal by comparing their keys and - each FileChanges object's file, type, and patch attributes. + Assert that two ChangeSet objects are equal by comparing their mapping of path to ChangedFile object. Args: actual: The actual ChangeSet to compare expected: The expected ChangeSet to compare against """ - assert actual.keys() == expected.keys() - for file in actual: - seen, expected_change = actual[file], expected[file] - assert seen.path == expected_change.path - assert seen.type == expected_change.type - assert seen.patch == expected_change.patch + assert actual.paths == expected.paths def test_basic(app: Application, temp_repo: Path) -> None: # type: ignore[no-untyped-def] @@ -141,9 +135,9 @@ def test_get_changes(app: Application, repo_setup_working_tree: tuple[Path, Chan new_file = Path("new_file.txt") new_file.write_text("new file\n") changeset = git.get_changes(working_tree=True) - new_expected_changeset = expected_changeset | ChangeSet({ - new_file: ChangedFile(path=new_file, type=ChangeType.ADDED, binary=False, patch="@@ -0,0 +1 @@\n+new file") - }) + new_expected_changeset = expected_changeset | ChangeSet([ + ChangedFile(path=new_file, type=ChangeType.ADDED, binary=False, patch="@@ -0,0 +1 @@\n+new file") + ]) assert_changesets_equal(changeset, new_expected_changeset) diff --git a/tests/utils/git/test_changeset.py b/tests/utils/git/test_changeset.py index 0f3d9039..1f3fd793 100644 --- a/tests/utils/git/test_changeset.py +++ b/tests/utils/git/test_changeset.py @@ -6,7 +6,7 @@ import msgspec import pytest -from dda.config.model import dec_hook, enc_hook +from dda.types.hooks import dec_hook, enc_hook from dda.utils.fs import Path from dda.utils.git.changeset import ChangedFile, ChangeSet, ChangeType from tests.tools.git.conftest import REPO_TESTCASES @@ -29,13 +29,13 @@ def test_encode_decode(self): class TestChangeSetClass: def test_basic(self): change = ChangedFile(path=Path("/path/to/file"), type=ChangeType.ADDED, binary=False, patch="patch") - changeset = ChangeSet({change.path: change}) - assert changeset[Path("/path/to/file")] == change + changeset = ChangeSet([change]) + assert changeset.paths[str(change.path)] == change def test_add(self): change = ChangedFile(path=Path("/path/to/file"), type=ChangeType.ADDED, binary=False, patch="patch") - changeset = ChangeSet.from_iter([change]) - assert changeset[Path("/path/to/file")] == change + changeset = ChangeSet([change]) + assert changeset.paths[str(change.path)] == change def test_digest(self): changes = [ @@ -43,20 +43,18 @@ def test_digest(self): ChangedFile(path=Path("file2"), type=ChangeType.MODIFIED, binary=False, patch="patch2"), ChangedFile(path=Path("/path/../file3"), type=ChangeType.DELETED, binary=False, patch="patch3"), ] - changeset = ChangeSet.from_iter(changes) + changeset = ChangeSet(changes) assert changeset.digest() == "aa2369871b3934e0dae9f141b5224704a7dffe5af614f8a31789322837fdcd85" def test_properties(self): - changes = [ - ChangedFile(path=Path("/path/to/file"), type=ChangeType.ADDED, binary=False, patch="patch"), - ChangedFile(path=Path("file2"), type=ChangeType.MODIFIED, binary=False, patch="patch2"), - ChangedFile(path=Path("/path/../file3"), type=ChangeType.DELETED, binary=False, patch="patch3"), - ] - changeset = ChangeSet.from_iter(changes) - assert changeset.added == {Path("/path/to/file")} - assert changeset.modified == {Path("file2")} - assert changeset.deleted == {Path("/path/../file3")} - assert changeset.changed == {Path("/path/to/file"), Path("file2"), Path("/path/../file3")} + added = ChangedFile(path=Path("/path/to/file"), type=ChangeType.ADDED, binary=False, patch="patch") + modified = ChangedFile(path=Path("file2"), type=ChangeType.MODIFIED, binary=False, patch="patch2") + deleted = ChangedFile(path=Path("/path/../file3"), type=ChangeType.DELETED, binary=False, patch="patch3") + changeset = ChangeSet([added, modified, deleted]) + assert changeset.added == {str(added.path): added} + assert changeset.modified == {str(modified.path): modified} + assert changeset.deleted == {str(deleted.path): deleted} + assert changeset.paths == {str(added.path): added, str(modified.path): modified, str(deleted.path): deleted} @pytest.mark.parametrize( "repo_testcase", @@ -74,12 +72,7 @@ def test_from_patches(self, repo_testcase): seen_changeset = ChangeSet.from_patches(diff_output) - assert seen_changeset.keys() == expected_changeset.keys() - for file in seen_changeset: - seen, expected = seen_changeset[file], expected_changeset[file] - assert seen.path == expected.path - assert seen.type == expected.type - assert seen.patch == expected.patch + assert seen_changeset.paths == expected_changeset.paths def test_encode_decode(self): changes = [ @@ -87,7 +80,7 @@ def test_encode_decode(self): ChangedFile(path=Path("file2"), type=ChangeType.MODIFIED, binary=False, patch="patch2"), ChangedFile(path=Path("/path/../file3"), type=ChangeType.DELETED, binary=False, patch="patch3"), ] - changeset = ChangeSet.from_iter(changes) + changeset = ChangeSet(changes) encoded_changeset = msgspec.json.encode(changeset, enc_hook=enc_hook) decoded_changeset = msgspec.json.decode(encoded_changeset, type=ChangeSet, dec_hook=dec_hook) assert decoded_changeset == changeset diff --git a/tests/utils/git/test_github.py b/tests/utils/git/test_github.py index b821cda5..3720e4ec 100644 --- a/tests/utils/git/test_github.py +++ b/tests/utils/git/test_github.py @@ -91,7 +91,7 @@ def test_get_commit_and_changes_from_github(mocker, github_payload_file): ) for file in data["files"] ] - expected_commit_changes = ChangeSet.from_iter(changes) + expected_commit_changes = ChangeSet(changes) # Make the comparisons remote_commit, commit_changes = get_commit_and_changes_from_github(remote, sha1) diff --git a/tests/utils/test_fs.py b/tests/utils/test_fs.py index 39ff1342..83751139 100644 --- a/tests/utils/test_fs.py +++ b/tests/utils/test_fs.py @@ -9,7 +9,7 @@ import msgspec import pytest -from dda.config.model import dec_hook, enc_hook +from dda.types.hooks import dec_hook, enc_hook from dda.utils.fs import Path, temp_directory