Repository: asdf-format/asdf
Pull request #1922 (2025-05-08 15:43:36)

fix deepcopy of lazy tree
## Description

This fixes a bug where a deepcopy of a lazy node retains references to the original lazy tree (instead of becoming non-lazy nodes). While adding tests for and fixing this issue I found that `walk_and_modify` converts `AsdfOrderedDictNode` instances into `dict`. That bug is also fixed in this PR and is required for this PR since `walk_and_modify` is used to handle the deepcopy.

## Tasks

- [ ] [run `pre-commit` on your machine](https://pre-commit.com/#quick-start)
- [ ] run `pytest` on your machine
- [ ] Does this PR add new features and / or change user-facing code / API? (if not, label with `no-changelog-entry-needed`)
    - [ ] write news fragment(s) in `changes/`: `echo "changed something" > changes/<PR#>.<changetype>.rst` (see below for change types)
    - [ ] update relevant docstrings and / or `docs/` page
    - [ ] for any new features, add unit tests

<details><summary>news fragment change types...</summary>

- ``changes/<PR#>.feature.rst``: new feature
- ``changes/<PR#>.bugfix.rst``: bug fix
- ``changes/<PR#>.doc.rst``: documentation change
- ``changes/<PR#>.removal.rst``: deprecation or removal of public API
- ``changes/<PR#>.general.rst``: infrastructure or miscellaneous change
</details>


braingram: Docs failed due to temporary unavailability of the astropy objects.inv needed for intersphinx links.

Patch:
diff --git a/asdf/lazy_nodes.py b/asdf/lazy_nodes.py
index 13d056dd..d470e33f 100644
--- a/asdf/lazy_nodes.py
+++ b/asdf/lazy_nodes.py
@@ -8,7 +8,7 @@ import inspect
 import warnings
 import weakref
 
-from . import tagged, yamlutil
+from . import tagged, treeutil, yamlutil
 from .exceptions import AsdfConversionWarning, AsdfLazyReferenceError
 from .extension._serialization_context import BlockAccess
 
@@ -137,6 +137,9 @@ class _AsdfNode:
         """
         return self.data
 
+    def __deepcopy__(self, memo):
+        return treeutil.walk_and_modify(self, lambda n: n)
+
     def _convert_and_cache(self, value, key):
         """
         Convert ``value`` to either:
diff --git a/asdf/treeutil.py b/asdf/treeutil.py
index e68e1a5d..96ae4cc4 100644
--- a/asdf/treeutil.py
+++ b/asdf/treeutil.py
@@ -2,6 +2,7 @@
 Utility functions for managing tree-like data structures.
 """
 
+import collections
 import types
 from contextlib import contextmanager
 
@@ -278,7 +279,9 @@ def walk_and_modify(top, callback, postorder=True, _context=None):
         return _handle_generator(result)
 
     def _handle_mapping(node, json_id):
-        if isinstance(node, lazy_nodes.AsdfDictNode):
+        if isinstance(node, lazy_nodes.AsdfOrderedDictNode):
+            result = collections.OrderedDict()
+        elif isinstance(node, lazy_nodes.AsdfDictNode):
             result = {}
         else:
             result = node.__class__()
diff --git a/changes/1922.bugfix.rst b/changes/1922.bugfix.rst
new file mode 100644
index 00000000..91fb9d57
--- /dev/null
+++ b/changes/1922.bugfix.rst
@@ -0,0 +1,1 @@
+Fix deepcopy of lazy tree.


Test patch:
diff --git a/asdf/_tests/test_lazy_nodes.py b/asdf/_tests/test_lazy_nodes.py
index 6d242d12..1ab3e58d 100644
--- a/asdf/_tests/test_lazy_nodes.py
+++ b/asdf/_tests/test_lazy_nodes.py
@@ -123,17 +123,20 @@ def test_node_empty_init(NodeClass, base):
 
 
 @pytest.mark.parametrize(
-    "node",
+    "node,base_type",
     [
-        AsdfDictNode({"a": 1, "b": 2}),
-        AsdfListNode([1, 2, 3]),
-        AsdfOrderedDictNode({"a": 1, "b": 2}),
+        (AsdfDictNode({"a": 1, "b": 2}), dict),
+        (AsdfListNode([1, 2, 3]), list),
+        (AsdfOrderedDictNode({"a": 1, "b": 2}), collections.OrderedDict),
     ],
 )
 @pytest.mark.parametrize("copy_operation", [copy.copy, copy.deepcopy])
-def test_copy(node, copy_operation):
+def test_copy(node, base_type, copy_operation):
     copied_node = copy_operation(node)
-    assert isinstance(copied_node, type(node))
+    if copy_operation is copy.copy:
+        assert type(copied_node) is type(node)
+    else:
+        assert type(copied_node) is base_type
     assert copied_node == node
 
 
@@ -408,3 +411,26 @@ def test_lazy_generator_converter(tmp_path, lazy_generator_class):
 
     with asdf.open(fn, lazy_tree=True) as af:
         assert isinstance(af["obj"].data, dict)
+
+
+def test_lazy_copy(tmp_path):
+    """
+    Test that copying an AsdfFile instance with a lazy
+    tree doesn't result in the copy retaining references
+    to the instance.
+    """
+    fn = tmp_path / "test.asdf"
+    obj = asdf.tags.core.IntegerType(1)
+    tree = {"a": {"b": obj}}
+    # make a recursive structure
+    tree["a"]["c"] = tree["a"]
+
+    asdf.AsdfFile(tree).write_to(fn)
+
+    with asdf.open(fn, lazy_tree=True) as af:
+        af2 = af.copy()
+
+    del af
+    gc.collect(2)
+    assert af2["a"]["b"] == obj
+    assert af2["a"]["c"]["b"] is af2["a"]["b"]