diff --git a/swh/model/model.py b/swh/model/model.py --- a/swh/model/model.py +++ b/swh/model/model.py @@ -162,6 +162,7 @@ ModelType = TypeVar("ModelType", bound="BaseModel") +HashableModelType = TypeVar("HashableModelType", bound="BaseHashableModel") class BaseModel: @@ -183,6 +184,10 @@ recursively builds the corresponding objects.""" return cls(**d) + def evolve(self: ModelType, **kwargs) -> ModelType: + """Alias to call :func:`attr.evolve` on this object, returning a new object.""" + return attr.evolve(self, **kwargs) + def anonymize(self: ModelType) -> Optional[ModelType]: """Returns an anonymized version of the object, if needed. @@ -230,6 +235,17 @@ obj_id = self.compute_hash() object.__setattr__(self, "id", obj_id) + def evolve(self: HashableModelType, **kwargs) -> HashableModelType: + """Alias to call :func:`attr.evolve` on this object, returning a new object + with its ``id`` recomputed based on the content.""" + if "id" in kwargs: + raise TypeError( + f"{self.__class__.__name__}.evolve recomputes the id itself; " + f"use attr.evolve to change the id manually." + ) + obj = attr.evolve(self, **kwargs) + return attr.evolve(obj, id=obj.compute_hash()) + def unique_key(self) -> KeyType: return self.id diff --git a/swh/model/tests/test_model.py b/swh/model/tests/test_model.py --- a/swh/model/tests/test_model.py +++ b/swh/model/tests/test_model.py @@ -910,17 +910,52 @@ raw_manifest = b"foo" id_ = hashlib.new("sha1", raw_manifest).digest() + # Forgot to update the id -> error directory2 = attr.evolve(directory, raw_manifest=raw_manifest) assert directory2.to_dict()["raw_manifest"] == raw_manifest with pytest.raises(ValueError, match="does not match recomputed hash"): directory2.check() + # id set to the right value -> ok directory2 = attr.evolve(directory, raw_manifest=raw_manifest, id=id_) assert directory2.id is not None assert directory2.id == id_ != directory.id assert directory2.to_dict()["raw_manifest"] == raw_manifest directory2.check() + # id implicitly set to the right value -> ok + directory3 = directory.evolve(raw_manifest=raw_manifest) + assert directory3.id is not None + assert directory3.id == id_ != directory.id + assert directory3.to_dict()["raw_manifest"] == raw_manifest + directory3.check() + + +@given(strategies.directories(raw_manifest=none())) +def test_directory_evolve(directory): + directory.check() + + # Add an entry (while making sure it is not a duplicate) + longest_entry_name = max((entry.name for entry in directory.entries), key=len) + entries = ( + *directory.entries, + DirectoryEntry( + name=longest_entry_name + b"x", + type="file", + target=b"\x00" * 20, + perms=0, + ), + ) + directory2 = directory.evolve(entries=entries) + assert directory2.id != directory, "directory.evolve() did not update the id" + directory2.check() + + with pytest.raises(TypeError, match="use attr.evolve"): + directory.evolve(id=b"\x00" * 20) + + with pytest.raises(TypeError, match="unexpected keyword argument"): + directory.evolve(foo=b"") + def test_directory_entry_name_validation(): with pytest.raises(ValueError, match="valid directory entry name."):