diff --git a/CHANGES.rst b/CHANGES.rst index e22b15a3..50c43307 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -17,6 +17,7 @@ New Features scale is computed in closed form, and the elliptical and rectangular annuli convert both of their shapes with one WCS evaluation in each direction. [#707] +- Add serialization of regions to ASDF files. [#702] Bug Fixes --------- diff --git a/pyproject.toml b/pyproject.toml index e2286a61..1dc70083 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ requires-python = '>=3.11' dependencies = [ 'astropy >= 7.0', 'numpy >= 2.0', + 'asdf', ] [project.urls] @@ -39,6 +40,7 @@ all = [ 'shapely >= 2.1', ] test = [ + 'pytest-asdf-plugin', 'gwcs >= 0.22', 'pytest-arraydiff >= 0.6.1', 'pytest-astropy >= 0.12', @@ -80,9 +82,13 @@ include-package-data = false namespaces = false [tool.setuptools.package-data] +'regions.io.asdf.resources' = [ + '**/*.yaml' +] '*' = [ 'data/*', ] + 'regions.shapes.tests' = [ 'reference/*.txt', 'data/*.fits', @@ -97,6 +103,11 @@ namespaces = false 'data/*.fits', ] + +[project.entry-points] +'asdf.extensions' = {regions = 'regions.io.asdf.extension:get_extensions'} +'asdf.resource_mappings' = {regions = 'regions.io.asdf.extension:get_resource_mappings'} + [tool.pytest.ini_options] minversion = "8.0" testpaths = [ @@ -117,6 +128,9 @@ addopts = [ '--strict-config', '--strict-markers', ] +asdf_schema_root = 'regions/io/asdf/resources/schemas' +asdf_schema_tests_enabled = true + log_level = 'INFO' log_cli_level = 'INFO' xfail_strict = true @@ -142,6 +156,7 @@ omit = [ '*/regions/*/tests/*', '*/regions/extern/*', '*/regions/version*', + 'regions/io/asdf/converters/tests/*', ] [tool.coverage.report] @@ -224,6 +239,8 @@ exclude = [ '\._.*', # private functions/methods '^test_*', # test code '^conftest.*$', # pytest configuration + 'to_yaml_tree', # ASDF converter + 'from_yaml_tree', # ASDF converter ] [tool.ruff] diff --git a/regions/io/__init__.py b/regions/io/__init__.py index c58b0dbd..d361cd42 100644 --- a/regions/io/__init__.py +++ b/regions/io/__init__.py @@ -15,3 +15,4 @@ from .fits.core import * # noqa: F401, F403 from .fits.read import * # noqa: F401, F403 from .fits.write import * # noqa: F401, F403 +from .asdf import extension # noqa: F401 diff --git a/regions/io/asdf/__init__.py b/regions/io/asdf/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/regions/io/asdf/converters/__init__.py b/regions/io/asdf/converters/__init__.py new file mode 100644 index 00000000..c2f7e2a5 --- /dev/null +++ b/regions/io/asdf/converters/__init__.py @@ -0,0 +1,10 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +ASDF converters. +""" + +from .pixel_coords import PixCoordConverter + +__all__ = [ + 'PixCoordConverter', +] diff --git a/regions/io/asdf/converters/pixel_coords.py b/regions/io/asdf/converters/pixel_coords.py new file mode 100644 index 00000000..81818a5e --- /dev/null +++ b/regions/io/asdf/converters/pixel_coords.py @@ -0,0 +1,33 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +""" +ASDF converter for PixCoord object. +""" +from asdf.extension import Converter + +__all__ = [ + 'PixCoordConverter', +] + + +class PixCoordConverter(Converter): + """ + ASDF converter for pixel coordinates. + """ + + tags = ('tag:astropy.org:regions/pixcoord-*',) + types = ('regions.core.pixcoord.PixCoord',) + + def to_yaml_tree(self, obj, tag, ctx): # noqa: ARG002 + + return { + 'x': obj.x, + 'y': obj.y, + } + + def from_yaml_tree(self, node, tag, ctx): # noqa: ARG002 + from regions.core.pixcoord import PixCoord + return PixCoord( + x=node['x'], + y=node['y'], + ) diff --git a/regions/io/asdf/converters/tests/__init__.py b/regions/io/asdf/converters/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/regions/io/asdf/converters/tests/conftest.py b/regions/io/asdf/converters/tests/conftest.py new file mode 100644 index 00000000..7325137e --- /dev/null +++ b/regions/io/asdf/converters/tests/conftest.py @@ -0,0 +1,21 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +import pytest + +from regions.io.asdf.converters.tests import examples + + +@pytest.fixture +def test_pixcoord(): + """ + Test that PixCoord can be serialized and deserialized correctly. + """ + return examples.pixcoord() + + +@pytest.fixture +def test_pixcoord_array(): + """ + Test that PixCoord can be serialized and deserialized correctly. + """ + return examples.pixcoord_array() diff --git a/regions/io/asdf/converters/tests/examples.py b/regions/io/asdf/converters/tests/examples.py new file mode 100644 index 00000000..b37d8cc1 --- /dev/null +++ b/regions/io/asdf/converters/tests/examples.py @@ -0,0 +1,16 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +import numpy as np + +from regions.core import PixCoord + +parameters = { + 'PixCoord': ['x', 'y'], +} + + +def pixcoord(): + return PixCoord(1, 2), parameters['PixCoord'] + + +def pixcoord_array(): + return PixCoord(np.array([1, 2]), np.array([3, 4])), parameters['PixCoord'] diff --git a/regions/io/asdf/converters/tests/test.py b/regions/io/asdf/converters/tests/test.py new file mode 100644 index 00000000..244c320a --- /dev/null +++ b/regions/io/asdf/converters/tests/test.py @@ -0,0 +1,37 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +import asdf +import pytest +from numpy.testing import assert_array_equal + + +@pytest.fixture +def obj(request): + """ + A pytest fixture that returns a 'Region' instance and the + list of parameters to test. + """ + return request.getfixturevalue(request.param) + + +region_params = pytest.mark.parametrize('obj', [ + 'test_pixcoord', + 'test_pixcoord_array', +], indirect=True) + + +@region_params +def test_region_converters(tmp_path, obj): + """ + Test that the Region converters can round-trip a Region object. + """ + region, pars = obj + with asdf.AsdfFile() as af: + af['region'] = region + af.write_to(tmp_path / 'region.asdf') + + with asdf.open(tmp_path / 'region.asdf') as af: + region2 = af['region'] + for parameter in pars: + assert_array_equal(getattr(region, parameter), + getattr(region2, parameter)) diff --git a/regions/io/asdf/extension.py b/regions/io/asdf/extension.py new file mode 100644 index 00000000..d52afa7a --- /dev/null +++ b/regions/io/asdf/extension.py @@ -0,0 +1,66 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +ASDF extension for regions. +""" +import importlib.resources as importlib_resources + +from asdf.extension import ManifestExtension +from asdf.resource import DirectoryResourceMapping + +from regions.io.asdf.converters.pixel_coords import PixCoordConverter + +__all__ = ['PixCoordConverter'] + + +REGIONS_CONVERTERS = [ + PixCoordConverter(), +] + +# The order here is important; asdf will prefer to use extensions +# that occur earlier in the list. +REGIONS_MANIFEST_URIS = [ + 'asdf://astropy.org/regions/manifests/regions-1.0.0', +] + + +def get_extensions(): + """ + Get the regions extension. + This method is registered with the asdf.extensions entry point. + + Returns + ------- + list + A list of ASDF extensions. + """ + return [ + ManifestExtension.from_uri( + uri, + converters=REGIONS_CONVERTERS, + ) + for uri in REGIONS_MANIFEST_URIS + ] + + +def get_resource_mappings(): + """ + Get the resource mapping instances for the regions schemas + and manifests. This method is registered with the + asdf.resource_mappings entry point. + + Returns + ------- + list + A list of collections.abc.Mapping of ASDF resource mappings. + """ + from regions.io.asdf import resources + + resources_root = importlib_resources.files(resources) + + return [ + DirectoryResourceMapping(resources_root / 'schemas', + 'asdf://astropy.org/regions/schemas', + recursive=True), + DirectoryResourceMapping(resources_root / 'manifests', + 'asdf://astropy.org/regions/manifests'), + ] diff --git a/regions/io/asdf/resources/__init__.py b/regions/io/asdf/resources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/regions/io/asdf/resources/manifests/regions-1.0.0.yaml b/regions/io/asdf/resources/manifests/regions-1.0.0.yaml new file mode 100644 index 00000000..f5f3dc9e --- /dev/null +++ b/regions/io/asdf/resources/manifests/regions-1.0.0.yaml @@ -0,0 +1,10 @@ +id: asdf://astropy.org/regions/manifests/regions-1.0.0 +extension_uri: asdf://astropy.org/regions/extensions/regions-1.0.0 +title: Regions extension 1.0.0 +description: |- + A set of tags for serializing astropy regions objects. +tags: + - tag_uri: tag:astropy.org:regions/pixcoord-1.0.0 + schema_uri: asdf://astropy.org/regions/schemas/pixcoord-1.0.0 + title: PixCoord schema + description: Pixel coordinates for regions. diff --git a/regions/io/asdf/resources/schemas/pixcoord-1.0.0.yaml b/regions/io/asdf/resources/schemas/pixcoord-1.0.0.yaml new file mode 100644 index 00000000..9117cb8a --- /dev/null +++ b/regions/io/asdf/resources/schemas/pixcoord-1.0.0.yaml @@ -0,0 +1,33 @@ +%YAML 1.1 +--- +$schema: 'http://stsci.edu/schemas/yaml-schema/draft-01' +id: 'asdf://astropy.org/regions/schemas/pixcoord-1.0.0' +title: Pixel coordinates +description: | + ASDF schema for serializing a pixel coordinate. + +examples: + - - A 'PixCoord' object with x=1.0 and y=2.0 + - | + ! {x: 1, y: 2} + +type: object +properties: + x: + title: X coordinate. + anyOf: + - type: number + - type: array + - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' + description: | + The x coordinate of the pixel. + y: + title: Y coordinate. + anyOf: + - type: number + - type: array + - tag: 'tag:stsci.edu:asdf/core/ndarray-1.*' + description: | + The y coordinate of the pixel. +required: [x, y] +...