From 6450cb194ea32ec4293b8f26df7a708ce6551e98 Mon Sep 17 00:00:00 2001 From: Mark Church Date: Mon, 9 Nov 2026 09:57:31 -0500 Subject: [PATCH 1/2] fix: make Data.__hash__ safe during pydantic deepcopy TM.report() deep-copies model elements. When a Data object is carried by more than one Dataflow there is a reference cycle (Data -> carriedBy -> Dataflow -> data (DataSet) -> Data). Pydantic v2's BaseModel.__deepcopy__ registers the new instance in the memo before its fields are copied, so rebuilding the DataSet hashes a Data that has no attributes yet and raises: AttributeError: 'Data' object has no attribute 'name' Read the hashed fields with getattr() so hashing a partially built instance does not raise, and add regression tests for deepcopy and TM.report(). Co-Authored-By: Claude Opus 5.5 --- pytm/data.py | 9 +++++++- tests/test_pydantic_models.py | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/pytm/data.py b/pytm/data.py index c11f24a..f4fcc43 100644 --- a/pytm/data.py +++ b/pytm/data.py @@ -123,7 +123,14 @@ def __eq__(self, other: object) -> bool: def __hash__(self): """Make Data objects hashable for use in sets.""" - return hash((self.name, self.description, self.format, self.classification)) + return hash( + ( + getattr(self, "name", None), + getattr(self, "description", None), + getattr(self, "format", None), + getattr(self, "classification", None), + ) + ) def _safeset(self, attr: str, value) -> None: """Safely set an attribute value.""" diff --git a/tests/test_pydantic_models.py b/tests/test_pydantic_models.py index 75adf76..a77441e 100644 --- a/tests/test_pydantic_models.py +++ b/tests/test_pydantic_models.py @@ -4,6 +4,8 @@ correctly — coverage that generic functional tests do not exercise. """ +import copy + import pytest from pytm import ( @@ -337,6 +339,45 @@ def test_different_data_distinct_in_set(self): b = Data("cookie") assert len({a, b}) == 2 + def test_deepcopy_with_dataflow_cycle(self): + # Data -> carriedBy -> Dataflow -> data (DataSet) -> same Data. + # Pydantic's __deepcopy__ memoizes the new Data before its fields are + # copied, so the DataSet copy hashes a Data with no attributes yet. + token = Data("token", classification=Classification.SECRET) + flow = Dataflow(Actor("user"), Server("web"), "login", data=token) + token.carriedBy = [flow] + + copied = copy.deepcopy(token) + + assert copied is not token + assert copied.name == "token" + assert copied.classification == Classification.SECRET + copied_flow = copied.carriedBy[0] + assert copied_flow is not flow + assert any(d is copied for d in copied_flow.data) + + def test_report_with_data_carried_by_multiple_flows(self, tmp_path): + # TM.report() deep-copies elements. When one Data travels over more + # than one Dataflow this used to raise + # AttributeError: 'Data' object has no attribute 'name'. + TM.reset() + tm = TM("report tm", description="test") + token = Data("token", classification=Classification.SECRET) + web = Server("web") + Dataflow(Actor("user"), web, "login", data=token) + Dataflow(web, Datastore("db"), "store token", data=token) + template = tmp_path / "template.md" + template.write_text("{tm.name}\n{data:repeat:{{item.name}}\n}") + + tm.check() + tm.resolve() + assert len(token.carriedBy) == 2 + + report = tm.report(str(template)) + + assert "report tm" in report + assert "token" in report + # --------------------------------------------------------------------------- # Dataflow.hasDataLeaks From f9f6b91a1fcedfd78405cb88801dcdd182f9fd74 Mon Sep 17 00:00:00 2001 From: Mark Church Date: Wed, 11 Nov 2026 13:40:43 -0500 Subject: [PATCH 2/2] test: don't depend on pydantic's deepcopy memoization test_deepcopy_with_dataflow_cycle checked that the copied Dataflow's data is the very same copy of the Data. That holds only from pydantic 2.14, whose __deepcopy__ memoizes the new instance before copying its fields (which is also what triggers the hashing bug). With the pydantic 2.12.5 in poetry.lock, the cycle produces a second copy, so the test failed in CI. Check instead that the flow carries a copy of the token, not the original. The test still fails without the fix on pydantic 2.14 and passes on 2.12, 2.13 and 2.14. Co-Authored-By: Claude Opus 5.5 --- tests/test_pydantic_models.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_pydantic_models.py b/tests/test_pydantic_models.py index a77441e..0475da9 100644 --- a/tests/test_pydantic_models.py +++ b/tests/test_pydantic_models.py @@ -341,8 +341,9 @@ def test_different_data_distinct_in_set(self): def test_deepcopy_with_dataflow_cycle(self): # Data -> carriedBy -> Dataflow -> data (DataSet) -> same Data. - # Pydantic's __deepcopy__ memoizes the new Data before its fields are - # copied, so the DataSet copy hashes a Data with no attributes yet. + # From pydantic 2.14, __deepcopy__ memoizes the new Data before its + # fields are copied, so the DataSet copy hashes a Data with no + # attributes yet. token = Data("token", classification=Classification.SECRET) flow = Dataflow(Actor("user"), Server("web"), "login", data=token) token.carriedBy = [flow] @@ -354,7 +355,10 @@ def test_deepcopy_with_dataflow_cycle(self): assert copied.classification == Classification.SECRET copied_flow = copied.carriedBy[0] assert copied_flow is not flow - assert any(d is copied for d in copied_flow.data) + # Whether the flow's data is the same copy depends on the pydantic + # version; either way it must be a copy of the token, not the token. + assert [d.name for d in copied_flow.data] == ["token"] + assert all(d is not token for d in copied_flow.data) def test_report_with_data_carried_by_multiple_flows(self, tmp_path): # TM.report() deep-copies elements. When one Data travels over more