Repository: asdf-format/asdf

Issue:
Raise a warning type specific to software version mismatches when wrong version of packages is installed
I would like to be able to convert the "you have a too old version of this converter package" warnings to errors in some situations, but the warning raised here: https://github.com/asdf-format/asdf/blob/7ae2d7eb198b4ef433eed6be8394a18ac22af619/asdf/_asdf.py#L360 is a generic `AsdfWarning` which would make it harder to filter.

Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 0733fc3c..556369e9 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -9,6 +9,10 @@
 
 - Deprecate ``asdf.util.filepath_to_url`` use ``pathlib.Path.to_uri`` [#1735]
 
+- Record package providing manifest for extensions used to write
+  a file and ``AsdfPackageVersionWarning`` when installed extension/manifest
+  package does not match that used to write the file [#1758]
+
 
 3.2.0 (2024-04-05)
 ------------------
diff --git a/asdf/_asdf.py b/asdf/_asdf.py
index 1561d89b..97c573d2 100644
--- a/asdf/_asdf.py
+++ b/asdf/_asdf.py
@@ -19,6 +19,7 @@ from .config import config_context, get_config
 from .exceptions import (
     AsdfConversionWarning,
     AsdfDeprecationWarning,
+    AsdfPackageVersionWarning,
     AsdfWarning,
     DelimiterNotFoundError,
     ValidationError,
@@ -356,7 +357,7 @@ class AsdfFile:
                 if strict:
                     raise RuntimeError(msg)
 
-                warnings.warn(msg, AsdfWarning)
+                warnings.warn(msg, AsdfPackageVersionWarning)
 
             elif extension.software:
                 # Local extensions may not have a real version.  If the package name changed,
@@ -372,7 +373,33 @@ class AsdfFile:
                     if strict:
                         raise RuntimeError(msg)
 
-                    warnings.warn(msg, AsdfWarning)
+                    warnings.warn(msg, AsdfPackageVersionWarning)
+
+            # check version of manifest providing package (if one was recorded)
+            if "manifest_software" in extension:
+                package_name = extension["manifest_software"]["name"]
+                package_version = Version(extension["manifest_software"]["version"])
+                package_description = f"{package_name}=={package_version}"
+                installed_version = None
+                for mapping in get_config().resource_manager._resource_mappings:
+                    if mapping.package_name == package_name:
+                        installed_version = Version(mapping.package_version)
+                        break
+                msg = None
+                if installed_version is None:
+                    msg = (
+                        f"File {filename}was created with package {package_description}, "
+                        "which is currently not installed"
+                    )
+                elif installed_version < package_version:
+                    msg = (
+                        f"File {filename}was created with package {package_description}, "
+                        f"but older package({package_name}=={installed_version}) is installed."
+                    )
+                if msg:
+                    if strict:
+                        raise RuntimeError(msg)
+                    warnings.warn(msg, AsdfPackageVersionWarning)
 
     def _process_plugin_extensions(self):
         """
@@ -460,6 +487,15 @@ class AsdfFile:
                 ext_meta["extension_uri"] = extension.extension_uri
             if extension.compressors:
                 ext_meta["supported_compression"] = [comp.label.decode("ascii") for comp in extension.compressors]
+            manifest = getattr(extension._delegate, "_manifest", None)
+            if manifest is not None:
+                # check if this extension was built from a manifest is a different package
+                resource_mapping = get_config().resource_manager._mappings_by_uri.get(manifest["id"])
+                if resource_mapping.package_name != extension.package_name:
+                    ext_meta["manifest_software"] = Software(
+                        name=resource_mapping.package_name,
+                        version=resource_mapping.package_version,
+                    )
 
             for i, entry in enumerate(tree["history"]["extensions"]):
                 # Update metadata about this extension if it already exists
diff --git a/asdf/exceptions.py b/asdf/exceptions.py
index 752a99b1..edc9ccfe 100644
--- a/asdf/exceptions.py
+++ b/asdf/exceptions.py
@@ -48,3 +48,9 @@ class AsdfProvisionalAPIWarning(AsdfWarning, FutureWarning):
     are likely to be added in a future ASDF version. However, Use of
     provisional features is highly discouraged for production code.
     """
+
+
+class AsdfPackageVersionWarning(AsdfWarning):
+    """
+    A warning indicating a package version mismatch
+    """


Test patch:
diff --git a/asdf/_tests/test_api.py b/asdf/_tests/test_api.py
index 09cf73ee..a6e47679 100644
--- a/asdf/_tests/test_api.py
+++ b/asdf/_tests/test_api.py
@@ -12,8 +12,9 @@ from numpy.testing import assert_array_equal
 
 import asdf
 from asdf import config_context, get_config, treeutil, versioning
-from asdf.exceptions import AsdfDeprecationWarning, AsdfWarning, ValidationError
+from asdf.exceptions import AsdfDeprecationWarning, AsdfPackageVersionWarning, ValidationError
 from asdf.extension import ExtensionProxy
+from asdf.resource import ResourceMappingProxy
 from asdf.testing.helpers import roundtrip_object, yaml_to_asdf
 
 from ._helpers import assert_tree_match
@@ -284,6 +285,7 @@ def test_open_pathlib_path(tmp_path):
 @pytest.mark.parametrize(
     ("installed", "extension", "warns"),
     [
+        (None, "2.0.0", True),
         ("1.2.3", "2.0.0", True),
         ("1.2.3", "2.0.dev10842", True),
         ("2.0.0", "2.0.0", False),
@@ -298,7 +300,8 @@ def test_extension_version_check(installed, extension, warns):
     proxy = ExtensionProxy(FooExtension(), package_name="foo", package_version=installed)
 
     with config_context() as config:
-        config.add_extension(proxy)
+        if installed is not None:
+            config.add_extension(proxy)
         af = asdf.AsdfFile()
 
     af._fname = "test.asdf"
@@ -315,7 +318,7 @@ def test_extension_version_check(installed, extension, warns):
     }
 
     if warns:
-        with pytest.warns(AsdfWarning, match=r"File 'test.asdf' was created with"):
+        with pytest.warns(AsdfPackageVersionWarning, match=r"File 'test.asdf' was created with"):
             af._check_extensions(tree)
 
         with pytest.raises(RuntimeError, match=r"^File 'test.asdf' was created with"):
@@ -325,6 +328,56 @@ def test_extension_version_check(installed, extension, warns):
         af._check_extensions(tree)
 
 
+@pytest.mark.parametrize(
+    ("installed", "extension", "warns"),
+    [
+        (None, "2.0.0", True),
+        ("1.2.3", "2.0.0", True),
+        ("1.2.3", "2.0.dev10842", True),
+        ("2.0.0", "2.0.0", False),
+        ("2.0.1", "2.0.0", False),
+        ("2.0.1", "2.0.dev12345", False),
+    ],
+)
+def test_check_extension_manifest_software(installed, extension, warns):
+    class FooExtension:
+        extension_uri = "asdf://somewhere.org/extensions/foo-1.0.0"
+
+    proxy = ExtensionProxy(FooExtension(), package_name="foo", package_version="1.0.0")
+
+    mapping = ResourceMappingProxy({}, package_name="bar", package_version=installed)
+
+    with config_context() as config:
+        config.add_extension(proxy)
+        if installed is not None:
+            config.add_resource_mapping(mapping)
+        af = asdf.AsdfFile()
+
+        af._fname = "test.asdf"
+
+        tree = {
+            "history": {
+                "extensions": [
+                    asdf.tags.core.ExtensionMetadata(
+                        extension_uri=FooExtension.extension_uri,
+                        software=asdf.tags.core.Software(name="foo", version="1.0.0"),
+                        manifest_software=asdf.tags.core.Software(name="bar", version=extension),
+                    ),
+                ],
+            },
+        }
+
+        if warns:
+            with pytest.warns(AsdfPackageVersionWarning, match=r"File 'test.asdf' was created with"):
+                af._check_extensions(tree)
+
+            with pytest.raises(RuntimeError, match=r"^File 'test.asdf' was created with"):
+                af._check_extensions(tree, strict=True)
+
+        else:
+            af._check_extensions(tree)
+
+
 def test_extension_check_no_warning_on_builtin():
     """
     Prior to asdf 3.0 files were written using the asdf.extension.BuiltinExtension