Repository: asdf-format/asdf

Issue:
`asdf.util.load_yaml` fails on recursive object
# Description of the problem

Attempting to read an asdf file with a recursive reference using `asdf.util.load_yaml` fails with `ConstructorError: found unconstructable recursive node`.

# Example of the problem

```python
import asdf
d = {}
d['a'] = d
asdf.AsdfFile(d).write_to("recursive.asdf")
asdf.util.load_yaml("recursive.asdf")
```
Produces:
```
ConstructorError: found unconstructable recursive node
```

Note that passing `tagged=True` is a work-around for the above issue (as it triggers using the `AsdfLoader` which handles the recursion).

# System information

asdf version: main
python version: 3.10
operating system: osx


Discussion:

Patch:
diff --git a/asdf/util.py b/asdf/util.py
index bbd873d9..b099213b 100644
--- a/asdf/util.py
+++ b/asdf/util.py
@@ -86,12 +86,12 @@ def load_yaml(init, tagged=False):
     """
 
     from .generic_io import get_file
-    from .yamlutil import AsdfLoader
+    from .yamlutil import AsdfLoader, _IgnoreCustomTagsLoader
 
     if tagged:
         loader = AsdfLoader
     else:
-        loader = yaml.CBaseLoader if getattr(yaml, "__with_libyaml__", None) else yaml.BaseLoader
+        loader = _IgnoreCustomTagsLoader
 
     with get_file(init, "r") as gf:
         reader = gf.reader_until(
diff --git a/asdf/yamlutil.py b/asdf/yamlutil.py
index 63c2d864..718bfecc 100644
--- a/asdf/yamlutil.py
+++ b/asdf/yamlutil.py
@@ -137,6 +137,29 @@ AsdfDumper.add_representer(np.str_, represent_numpy_str)
 AsdfDumper.add_representer(np.bytes_, AsdfDumper.represent_binary)
 
 
+class _IgnoreCustomTagsLoader(_yaml_base_loader):
+    """
+    A specialized YAML loader that ignores tags unknown to the
+    base (safe) loader. This is used by `asdf.util.load_yaml`
+    to read the ASDF tree as "basic" objects, ignoring the
+    custom tags.
+    """
+
+    def construct_undefined(self, node):
+        if isinstance(node, yaml.MappingNode):
+            return self.construct_mapping(node)
+        elif isinstance(node, yaml.SequenceNode):
+            return self.construct_sequence(node)
+        elif isinstance(node, yaml.ScalarNode):
+            return self.construct_scalar(node)
+        return super().construct_undefined(node)
+
+
+# pyyaml will invoke the constructor associated with None when a node's
+# tag is not explicitly handled by another constructor.
+_IgnoreCustomTagsLoader.add_constructor(None, _IgnoreCustomTagsLoader.construct_undefined)
+
+
 class AsdfLoader(_yaml_base_loader):
     """
     A specialized YAML loader that can construct "tagged basic Python
diff --git a/changes/1825.bugfix.rst b/changes/1825.bugfix.rst
new file mode 100644
index 00000000..25782b57
--- /dev/null
+++ b/changes/1825.bugfix.rst
@@ -0,0 +1,1 @@
+Allow ``asdf.util.load_yaml`` to handle recursive objects


Test patch:
diff --git a/asdf/_tests/test_util.py b/asdf/_tests/test_util.py
index e45d68eb..ca64a7a4 100644
--- a/asdf/_tests/test_util.py
+++ b/asdf/_tests/test_util.py
@@ -145,3 +145,17 @@ def test_load_yaml(tmp_path, input_type, tagged):
         assert isinstance(tree["a"], asdf.tagged.TaggedDict)
     else:
         assert not isinstance(tree["a"], asdf.tagged.TaggedDict)
+
+
+@pytest.mark.parametrize("tagged", [True, False])
+def test_load_yaml_recursion(tmp_path, tagged):
+    fn = tmp_path / "test.asdf"
+    tree = {}
+    tree["d"] = {}
+    tree["d"]["d"] = tree["d"]
+    tree["l"] = []
+    tree["l"].append(tree["l"])
+    asdf.AsdfFile(tree).write_to(fn)
+    tree = util.load_yaml(fn, tagged=tagged)
+    assert tree["d"]["d"] is tree["d"]
+    assert tree["l"][0] is tree["l"]