diff --git a/astropy/io/votable/exceptions.py b/astropy/io/votable/exceptions.py
index 610c80e6518..58a68d2be10 100644
--- a/astropy/io/votable/exceptions.py
+++ b/astropy/io/votable/exceptions.py
@@ -255,6 +255,12 @@ class VOTableSpecWarning(VOWarning, SyntaxWarning):
"""
+class ModelMappingSpecWarning(VOWarning, SyntaxWarning):
+ """
+ The input model mapping XML mapping block violates the spec.
+ """
+
+
class UnimplementedWarning(VOWarning, SyntaxWarning):
"""
A feature of the VOTABLE_ spec is not implemented.
@@ -1524,6 +1530,14 @@ class E25(VOTableSpecWarning):
message_template = "No FIELDs are defined; DATA section will be ignored."
+class E26(VOTableSpecError):
+ """
+ The mapping block can only be set in a type=meta RESOURCE.
+ """
+
+ message_template = "Mapping block can not be set in a RESOURCE with type=result"
+
+
def _get_warning_and_exception_classes(prefix):
classes = []
for key, val in globals().items():
diff --git a/astropy/io/votable/tests/data/mivot_annotated_table.xml b/astropy/io/votable/tests/data/mivot_annotated_table.xml
new file mode 100644
index 00000000000..c8a6858bb1f
--- /dev/null
+++ b/astropy/io/votable/tests/data/mivot_annotated_table.xml
@@ -0,0 +1,352 @@
+
+
+
+ This schema contains data re-published from the official...
+
+
+
+
+
+
+ This schema contains data re-published from the official Gaia mirrors
+ ...
+
+ If you use public Gaia DR3 data in a paper, please take note of
+`ESAC's guide`_ on how to acknowledge and cite it.
+
+.. _ESAC's guide:
+https://gea.esac.esa.int/archive/documentation/GDR3/Miscellaneous/sec_credit_and_citation_instructions/
+ This is gaia_source from the Gaia Data Release 3, stripped to just
+ ...
+
+ Query successful
+ For advice on how to cite the resource(s) that contributed to this result, see http://dc.zah.uni-heidelberg.de/tableinfo/gaia.dr3lite#ti-citing
+ More information on a resource that contributed to this data is found at http://dc.zah.uni-heidelberg.de/tableinfo/gaia.dr3lite
+
+
+
+
+
+
+ hand-made mapping
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This is gaia_source from the Gaia Data Release 3, stripped to just...
+
+
+
+ For the contents of Gaia DR3,...
+
+
+
+
+ Gaia DR3 unique source identifier. Note that this *cannot* be matched against the DR1 or DR2 source_ids.
+
+
+
+
+
+
+
+ Mean magnitude in the G band. This is computed from the G-band mean flux applying the magnitude
+ zero-point
+ in the Vega scale. To obtain error estimates, see phot_g_mean_flux_over_error.
+
+
+
+
+
+
+
+ Mean magnitude in the integrated RP band. This is computed from the RP-band mean flux applying the
+ magnitude zero-point in the Vega scale. To obtain error estimates, see phot_rp_mean_flux_over_error.
+
+
+
+
+
+
+
+ Mean magnitude in the integrated BP band. This is computed from the BP-band mean flux applying the
+ magnitude zero-point in the Vega scale. To obtain error estimates, see phot_bp_mean_flux_over_error.
+
+
+
+
+
+
+
+
+
+ 2165092154924732928 |
+ 19.633097 |
+ 18.230663 |
+ 21.65443 |
+
+
+ 2165092159226514688 |
+ 20.997982 |
+ 20.106546 |
+ 21.16427 |
+
+
+
+
+
+
\ No newline at end of file
diff --git a/astropy/io/votable/tests/data/mivot_block_custom_datatype.xml b/astropy/io/votable/tests/data/mivot_block_custom_datatype.xml
new file mode 100644
index 00000000000..ee8707806ef
--- /dev/null
+++ b/astropy/io/votable/tests/data/mivot_block_custom_datatype.xml
@@ -0,0 +1,287 @@
+
+
+ hand-made mapping
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/astropy/io/votable/tests/data/test.order.xml b/astropy/io/votable/tests/data/test.order.xml
new file mode 100644
index 00000000000..6f705da3dfa
--- /dev/null
+++ b/astropy/io/votable/tests/data/test.order.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/astropy/io/votable/tests/test_tree.py b/astropy/io/votable/tests/test_tree.py
index 0d02aad00fd..4421a22bed6 100644
--- a/astropy/io/votable/tests/test_tree.py
+++ b/astropy/io/votable/tests/test_tree.py
@@ -1,13 +1,14 @@
# Licensed under a 3-clause BSD style license - see LICENSE.rst
+import filecmp
import io
from contextlib import nullcontext
import pytest
from astropy.io.votable import tree
-from astropy.io.votable.exceptions import W07, W08, W21, W41
+from astropy.io.votable.exceptions import E26, W07, W08, W21, W41
from astropy.io.votable.table import parse
-from astropy.io.votable.tree import Resource, VOTableFile
+from astropy.io.votable.tree import MivotBlock, Resource, VOTableFile
from astropy.tests.helper import PYTEST_LT_8_0
from astropy.utils.data import get_pkg_data_filename
from astropy.utils.exceptions import AstropyDeprecationWarning
@@ -175,3 +176,315 @@ def test_votable_tag():
assert 'xmlns="http://www.ivoa.net/xml/VOTable/v1.3"' in xml
assert 'xsi:schemaLocation="http://www.ivoa.net/xml/VOTable/v1.3 ' in xml
assert 'http://www.ivoa.net/xml/VOTable/VOTable-1.4.xsd"' in xml
+
+
+def _squash_xml(data):
+ """
+ Utility squashing XML fragment to easier their comparison
+ This function is only used in the test module. It was more convenient for comparing the xml.
+ """
+ return data.replace(" ", "").replace("\n", "").replace('"', "").replace("'", "")
+
+
+def test_mivot_constructor():
+ """
+ Construct a MIVOT block with wrong tag to test the expected exception
+ """
+ with pytest.raises(ValueError, match="not well-formed"):
+ MivotBlock(
+ """
+
+ Unit test mivot block1
+
+
+
+ """
+ )
+
+
+def test_mivot_readout():
+ """
+ Test the MIVOT block extraction from a file against a reference block stored in data
+ """
+ votable = parse(get_pkg_data_filename("data/mivot_annotated_table.xml"))
+
+ ref_data = ""
+ for resource in votable.resources:
+ with open(
+ get_pkg_data_filename("data/mivot_block_custom_datatype.xml")
+ ) as reference:
+ ref_data = reference.read()
+ assert _squash_xml(ref_data) == _squash_xml(resource.mivot_block.content)
+ assert len(resource.tables) == 1
+
+
+def test_mivot_write():
+ """
+ Build a VOTable, put a MIVOT block in the first resource, checks it can be retrieved
+ as well as the following table
+ """
+ vot = tree
+ mivot_block = MivotBlock(
+ """
+
+
+ Unit test mivot block1
+
+
+
+
+ """
+ )
+ vtf = vot.VOTableFile()
+ mivot_resource = Resource()
+ mivot_resource.type = "meta"
+ mivot_resource.mivot_block = mivot_block
+ # pack the meta resource in a top level resource
+ r1 = vot.Resource()
+ r1.type = "results"
+ r1.resources.append(mivot_resource)
+ vtf.resources.append(r1)
+ # Push the VOTable in an IOSTream (emulates a disk saving)
+ buff = io.BytesIO()
+ vtf.to_xml(buff)
+
+ # Read the IOStream (emulates a disk readout)
+ buff.seek(0)
+ vtf2 = parse(buff)
+ assert len(vtf2.resources) == 1
+ for resource in vtf2.resources:
+ assert _squash_xml(mivot_block.content) == _squash_xml(
+ resource.mivot_block.content
+ )
+ assert len(resource.tables) == 0
+
+
+def test_mivot_write_after_table():
+ """
+ Build a VOTable, put a MIVOT block and a table in the first resource, checks it can be retrieved
+ as well as the following table
+ """
+ vot = tree
+ mivot_block = MivotBlock(
+ """
+
+ Unit test mivot block1
+
+
+
+ """
+ )
+ vtf = vot.VOTableFile()
+ mivot_resource = Resource()
+ mivot_resource.type = "meta"
+ mivot_resource.mivot_block = mivot_block
+ # pack the meta resource in a top level resource
+ r1 = vot.Resource()
+ r1.type = "results"
+ i1 = vot.Info(name="test_name", value="test_value")
+ r1.infos.append(i1)
+ r1.resources.append(mivot_resource)
+ t1 = vot.Table(vtf)
+ t1.name = "t1"
+ r1.tables.append(t1)
+ vtf.resources.append(r1)
+ # Push the VOTable in an IOSTream (emulates a disk saving)
+ buff = io.BytesIO()
+ vtf.to_xml(buff)
+
+ # Read the IOStream (emulates a disk readout)
+ buff.seek(0)
+ vtf2 = parse(buff)
+ assert len(vtf2.resources) == 1
+ for resource in vtf2.resources:
+ assert _squash_xml(mivot_block.content) == _squash_xml(
+ resource.mivot_block.content
+ )
+ assert len(resource.tables) == 1
+
+
+def test_write_no_mivot():
+ """
+ Build a VOTable, put an empty MIVOT block in the first resource, checks it can be retrieved
+ as well as the following table
+ """
+ vot = tree
+ vtf = vot.VOTableFile()
+ mivot_resource = Resource()
+ mivot_resource.type = "meta"
+ # pack the meta resource in a top level resource
+ r1 = vot.Resource()
+ r1.type = "results"
+ r1.resources.append(mivot_resource)
+ t1 = vot.Table(vtf)
+ t1.name = "t1"
+ r1.tables.append(t1)
+ vtf.resources.append(r1)
+ # Push the VOTable in an IOSTream (emulates a disk saving)
+ buff = io.BytesIO()
+ vtf.to_xml(buff)
+
+ # Read the IOStream (emulates a disk readout)
+ buff.seek(0)
+ vtf2 = parse(buff)
+ assert len(vtf2.resources) == 1
+ for resource in vtf2.resources:
+ assert (
+ _squash_xml(resource.mivot_block.content)
+ == "NoMivotblock"
+ )
+ assert len(resource.tables) == 1
+
+
+def test_mivot_write_after_resource():
+ """
+ Build a VOTable, put a MIVOT block in the first resource after another meta resource,
+ checks it can be retrieved as well as the following table
+ """
+ vot = tree
+ mivot_block = MivotBlock(
+ """
+
+ Unit test mivot block1
+
+
+
+ """
+ )
+ vtf = vot.VOTableFile()
+ mivot_resource = Resource()
+ mivot_resource.type = "meta"
+ mivot_resource.mivot_block = mivot_block
+ # pack the meta resource in a top level resource
+ r1 = vot.Resource()
+ r1.type = "results"
+ i1 = vot.Info(name="test_name", value="test_value")
+ r1.infos.append(i1)
+ meta_resource = Resource()
+ meta_resource.type = "meta"
+ r1.resources.append(meta_resource)
+ r1.resources.append(mivot_resource)
+ t1 = vot.Table(vtf)
+ t1.name = "t1"
+ r1.tables.append(t1)
+ vtf.resources.append(r1)
+ # Push the VOTable in an IOSTream (emulates a disk saving)
+ buff = io.BytesIO()
+ vtf.to_xml(buff)
+
+ # Read the IOStream (emulates a disk readout)
+ buff.seek(0)
+ vtf2 = parse(buff)
+ assert len(vtf2.resources) == 1
+ for resource in vtf2.resources:
+ assert _squash_xml(mivot_block.content) == _squash_xml(
+ resource.mivot_block.content
+ )
+ assert len(resource.tables) == 1
+
+
+def test_mivot_forbidden_write():
+ """
+ Build a meta resource containing a MIVOT block,
+ build the dummy MIVOT block first.
+ """
+ mivot_block = MivotBlock(
+ """
+
+ Unit test mivot block1
+
+
+ """
+ )
+ # package the MIVOT block in the resource
+ mivot_resource = Resource()
+ mivot_resource.type = "results"
+
+ with pytest.raises(E26):
+ # A MIVOT block must be with "type=meta"
+ mivot_resource.mivot_block = mivot_block
+
+
+def test_mivot_order(tmp_path):
+ """
+ Build a VOTable with 2 resources containing MivotBlock, parse it, and write it in a file.
+ Then compare it with another file to see if the order of the elements in a resource is respected,
+ in particular the MivotBlock which should be before the tables.
+ """
+ vot = tree
+ mivot_block = MivotBlock(
+ """
+
+
+ """
+ )
+ vtf = vot.VOTableFile()
+
+ mivot_resource = Resource()
+ mivot_resource.type = "meta"
+ mivot_resource.mivot_block = mivot_block
+
+ mivot_resource2 = Resource()
+ mivot_resource2.type = "meta"
+ mivot_resource2.mivot_block = mivot_block
+
+ # R1 : 2 mivot_block, 2 tables, 1 description, 1 info, 1 CooSys
+ r1 = vot.Resource()
+ r1.type = "results"
+
+ t1 = vot.Table(vtf)
+ t1.name = "t1"
+ t2 = vot.Table(vtf)
+ t2.name = "t2"
+
+ r1.tables.append(t1)
+ r1.tables.append(t2)
+
+ r1.resources.append(mivot_resource)
+ r1.resources.append(mivot_resource2)
+
+ cs = vot.CooSys(ID="_XYZ", system="ICRS")
+ r1.coordinate_systems.append(cs)
+ i1 = vot.Info(name="test_name", value="test_value")
+ r1.infos.append(i1)
+
+ vtf.resources.append(r1)
+
+ # R2 : 1 resource "results", 1 mivot_block and 1 table
+ r2 = vot.Resource()
+ r2.type = "results"
+
+ r3 = vot.Resource()
+ r3.type = "results"
+
+ t3 = vot.Table(vtf)
+ t3.name = "t3"
+ r2.tables.append(t3)
+ r2.resources.append(mivot_resource)
+ r2.resources.append(r3)
+
+ vtf.resources.append(r2)
+
+ # Push the VOTable in an IOSTream (emulates a disk saving)
+ buff = io.BytesIO()
+ vtf.to_xml(buff)
+
+ # Read the IOStream (emulates a disk readout)
+ buff.seek(0)
+ vtf2 = parse(buff)
+
+ vpath = get_pkg_data_filename("data/test.order.xml")
+ vpath_out = str(tmp_path / "test.order.out.xml")
+ vtf2.to_xml(vpath_out)
+
+ # We want to remove the xml header from the VOTable
+ with open(vpath_out) as file:
+ lines = file.readlines()
+ # The xml header is on 2 lines (line 2 and 3)
+ del lines[1]
+ del lines[1]
+
+ with open(vpath_out, "w") as file:
+ file.writelines(lines)
+
+ assert filecmp.cmp(vpath, vpath_out)
diff --git a/astropy/io/votable/tree.py b/astropy/io/votable/tree.py
index b8ec93f7cfb..6edaf98e040 100644
--- a/astropy/io/votable/tree.py
+++ b/astropy/io/votable/tree.py
@@ -19,6 +19,7 @@
from astropy.io import fits
from astropy.utils.collections import HomogeneousList
from astropy.utils.exceptions import AstropyDeprecationWarning
+from astropy.utils.xml import iterparser
from astropy.utils.xml.writer import XMLWriter
from . import converters, util, xmlutil
@@ -41,6 +42,7 @@
E22,
E23,
E25,
+ E26,
W06,
W07,
W08,
@@ -106,6 +108,7 @@
"Resource",
"VOTableFile",
"Element",
+ "MivotBlock",
]
@@ -3373,6 +3376,168 @@ def iter_info(self):
yield from self.infos
+class MivotBlock(Element):
+ """
+ MIVOT Block holder:
+ Processing VO model views on data is out of the scope of Astropy.
+ This is why the only VOmodel-related feature implemented here the
+ extraction or the writing of a mapping block from/to a VOTable
+ There is no syntax validation other than the allowed tag names.
+ The mapping block is handled as a correctly indented XML string
+ which is meant to be parsed by the calling API (e.g., PyVO).
+
+ The constructor takes "content" as a parameter, it is the string
+ serialization of the MIVOT block.
+ If it is None, the instance is meant to be set by the Resource parser.
+ Orherwise, the parameter value is parsed to make sure it matches
+ the MIVOT XML structure.
+
+ """
+
+ def __init__(self, content=None):
+ if content is not None:
+ self._content = content.strip()
+ self.check_content_format()
+ else:
+ self._content = ""
+ self._indent_level = 0
+ self._on_error = False
+
+ def __str__(self):
+ return self._content
+
+ def _add_statement(self, start, tag, data, config, pos):
+ """
+ Convert the tag as a string and append it to the mapping
+ block string with the correct indentation level.
+ The signature is the same as for all _add_* methods of the parser.
+ """
+ if self._on_error is True:
+ return
+ # The first mapping tag () is consumed by the host RESOURCE
+ # To check that the content is a mapping block. This cannot be done here
+ # because that RESOURCE might have another content
+ if self._content == "":
+ self._content = '\n'
+ self._indent_level += 1
+
+ ele_content = ""
+ if start:
+ element = "<" + tag
+ for k, v in data.items():
+ element += f" {k}='{v}'"
+ element += ">\n"
+ else:
+ if data:
+ ele_content = f"{data}\n"
+ element = f"{tag}>\n"
+
+ if start is False:
+ self._indent_level -= 1
+ # The content is formatted on the fly: not mandatory but cool for debugging
+ indent = "".join(" " for _ in range(2 * self._indent_level))
+ if ele_content:
+ self._content += indent + " " + ele_content
+ self._content += indent + element
+ if start is True:
+ self._indent_level += 1
+
+ def _unknown_mapping_tag(self, start, tag, data, config, pos):
+ """
+ In case of unexpected tag, the parsing stops and the mapping block
+ is set with a REPORT tag telling what went wrong.
+ The signature si that same as for all _add_* methods of the parser.
+ """
+ self._content = (
+ f'\n '
+ f'Unknown mivot block statement: {tag}\n'
+ )
+ self._on_error = True
+ warn_or_raise(W10, W10, tag, config, pos=pos)
+
+ @property
+ def content(self):
+ """
+ The XML mapping block serialized as string.
+ If there is not mapping block, an empty block is returned in order to
+ prevent client code to deal with None blocks.
+ """
+ if self._content == "":
+ self._content = (
+ '\n '
+ 'No Mivot block\n\n'
+ )
+ return self._content
+
+ def parse(self, votable, iterator, config):
+ """
+ Regular parser similar to others VOTable components.
+ """
+ model_mapping_mapping = {
+ "VODML": self._add_statement,
+ "GLOBALS": self._add_statement,
+ "REPORT": self._add_statement,
+ "MODEL": self._add_statement,
+ "TEMPLATES": self._add_statement,
+ "COLLECTION": self._add_statement,
+ "INSTANCE": self._add_statement,
+ "ATTRIBUTE": self._add_statement,
+ "REFERENCE": self._add_statement,
+ "JOIN": self._add_statement,
+ "WHERE": self._add_statement,
+ "PRIMARY_KEY": self._add_statement,
+ "FOREIGN_KEY": self._add_statement,
+ }
+ for start, tag, data, pos in iterator:
+ model_mapping_mapping.get(tag, self._unknown_mapping_tag)(
+ start, tag, data, config, pos
+ )
+ if start is False and tag == "VODML":
+ break
+
+ return self
+
+ def to_xml(self, w):
+ """
+ Tells the writer to insert the MIVOT block in its output stream.
+ """
+ w.string_element(self._content)
+
+ def check_content_format(self):
+ """
+ Check if the content is on xml format by building a VOTable,
+ putting a MIVOT block in the first resource and trying to parse the VOTable.
+ """
+ if not self._content.startswith("<"):
+ vo_raise(E26)
+
+ in_memory_votable = VOTableFile()
+ mivot_resource = Resource()
+ mivot_resource.type = "meta"
+ mivot_resource.mivot_block = self
+ # pack the meta resource in a top level resource
+ result_resource = Resource()
+ result_resource.type = "results"
+ result_resource.resources.append(mivot_resource)
+ data_table = Table(in_memory_votable)
+ data_table.name = "t1"
+ result_resource.tables.append(data_table)
+ in_memory_votable.resources.append(result_resource)
+
+ # Push the VOTable in an IOSTream (emulates a disk saving)
+ buff = io.BytesIO()
+ in_memory_votable.to_xml(buff)
+
+ # Read the IOStream (emulates a disk readout)
+ buff.seek(0)
+ config = {}
+
+ with iterparser.get_xml_iterator(
+ buff, _debug_python_based_parser=None
+ ) as iterator:
+ return VOTableFile(config=config, pos=(1, 1)).parse(iterator, config)
+
+
class Resource(
Element, _IDProperty, _NameProperty, _UtypeProperty, _DescriptionProperty
):
@@ -3416,6 +3581,7 @@ def __init__(
self._tables = HomogeneousList(Table)
self._resources = HomogeneousList(Resource)
+ self._mivot_block = MivotBlock()
warn_unknown_attrs("RESOURCE", kwargs.keys(), config, pos)
def __repr__(self):
@@ -3444,6 +3610,26 @@ def type(self, type):
vo_raise(E18, type, self._config, self._pos)
self._type = type
+ @property
+ def mivot_block(self):
+ """
+ Returns the MIVOT block instance.
+ If the host resource is of type results, it is taken from the first
+ child resource with a MIVOT block, if any.
+ Otherwise, it is taken from the host resource.
+ """
+ if self.type == "results":
+ for resource in self.resources:
+ if str(resource._mivot_block).strip() != "":
+ return resource._mivot_block
+ return self._mivot_block
+
+ @mivot_block.setter
+ def mivot_block(self, mivot_block):
+ if self.type == "results":
+ vo_raise(E26)
+ self._mivot_block = mivot_block
+
@property
def extra_attributes(self):
"""Dictionary of extra attributes of the RESOURCE_ element.
@@ -3575,7 +3761,11 @@ def parse(self, votable, iterator, config):
}
for start, tag, data, pos in iterator:
- if start:
+ # If the resource content starts with VODML,
+ # the parsing is delegated to the MIVOT parser
+ if tag == "VODML":
+ self._mivot_block.parse(votable, iterator, config)
+ elif start:
tag_mapping.get(tag, self._add_unknown_tag)(
iterator, tag, data, config, pos
)
@@ -3596,18 +3786,30 @@ def to_xml(self, w, **kwargs):
with w.tag("RESOURCE", attrib=attrs):
if self.description is not None:
w.element("DESCRIPTION", self.description, wrap=True)
+ if self.mivot_block is not None and self.type == "meta":
+ self.mivot_block.to_xml(w)
for element_set in (
self.coordinate_systems,
self.time_systems,
self.params,
self.infos,
self.links,
- self.tables,
- self.resources,
):
for element in element_set:
element.to_xml(w, **kwargs)
+ # The mivot_block should be before the table
+ for elm in self.resources:
+ if elm.type == "meta" and elm.mivot_block is not None:
+ elm.to_xml(w, **kwargs)
+
+ for elm in self.tables:
+ elm.to_xml(w, **kwargs)
+
+ for elm in self.resources:
+ if elm.type != "meta":
+ elm.to_xml(w, **kwargs)
+
def iter_tables(self):
"""
Recursively iterates over all tables in the resource and
diff --git a/astropy/utils/xml/writer.py b/astropy/utils/xml/writer.py
index 657f92c057c..d9c36041c54 100644
--- a/astropy/utils/xml/writer.py
+++ b/astropy/utils/xml/writer.py
@@ -304,6 +304,15 @@ def element(self, tag, text=None, wrap=False, attrib={}, **extra):
self.data(text)
self.end(indent=False, wrap=wrap)
+ def string_element(self, xml_string):
+ """
+ Reformat the indentation in the XML to insert the MIVOT block.
+ """
+ self._flush()
+ indent = self.get_indentation_spaces()
+ str_to_write = indent + xml_string.replace("\n", f"\n{indent}").strip() + "\n"
+ self.write(str_to_write)
+
def flush(self):
pass # replaced by the constructor
diff --git a/docs/changes/io.votable/15390.feature.rst b/docs/changes/io.votable/15390.feature.rst
new file mode 100644
index 00000000000..90eacb4484d
--- /dev/null
+++ b/docs/changes/io.votable/15390.feature.rst
@@ -0,0 +1,2 @@
+Added MIVOT feature through the ``MivotBlock`` class
+that allows model annotations reading and writing in VOTable.
diff --git a/docs/io/votable/index.rst b/docs/io/votable/index.rst
index 7d1d1999ccc..a06b245f114 100644
--- a/docs/io/votable/index.rst
+++ b/docs/io/votable/index.rst
@@ -465,6 +465,135 @@ record array must be resized repeatedly during load.
.. _nrows: http://www.ivoa.net/documents/REC/VOTable/VOTable-20040811.html#ToC10
+.. _votable_mivot:
+
+Reading and writing VO model annotations
+========================================
+
+Introduction
+------------
+Model Instances in VOTables (`MIVOT `_)
+defines a syntax to map VOTable data to any model serialised in VO-DML (Virtual Observatory Data Modeling Language).
+This annotation schema operates as a bridge between data and the models. It associates both column/param metadata and data
+from the VOTable to the data model elements (class, attributes, types, etc.). It also brings up VOTable data or
+metadata that were possibly missing in the table, e.g., coordinate system description, or curation tracing.
+The data model elements are grouped in an independent annotation block complying with the MIVOT XML schema which
+is added as an extra resource above the table element.
+The MIVOT syntax allows to describe a data structure as a hierarchy of classes.
+It is also able to represent relations and compositions between them. It can moreover build up data model objects by
+aggregating instances from different tables of the VOTable.
+
+Astropy implementation
+----------------------
+The purpose of Astropy is not to process VO annotations.
+It is just to allow related packages to get and set MIVOT blocks from/into VOTables.
+For this reason, in this implementation MIVOT annotations are both imported and exported as strings.
+The current implementation prevents client code from injecting into VOTables strings
+that are not MIVOT serializations.
+
+MivotBlock implementation:
+
+- MIVOT blocks are handled by the :class:`astropy.io.votable.tree.MivotBlock` class.
+- A MivotBlock instance can only be carried by a resource with "type=meta".
+- This instance holds the XML mapping block as a string.
+- MivotBlock objects are instanced by the Resource parser.
+- The MivotBlock class has its own logic that operates both parsing and IO functionalities.
+
+Example
+^^^^^^^
+
+.. code-block:: xml
+
+
+
+
+
+ ...
+
+
+
+
+
+
+Reading a VOTable containing a MIVOT block
+------------------------------------------
+
+To read in a VOTable file containing or not a MIVOT Resource, pass a file path to`~astropy.io.votable.parse`:
+
+.. code-block:: python
+
+ >>> from astropy.io.votable import parse
+ >>> from astropy.utils.data import get_pkg_data_filename
+ >>> votable = parse(get_pkg_data_filename("data/test.order.xml", package="astropy.io.votable.tests"))
+
+
+
+
+
+
+The parse function will call the MIVOT parser if it detects a MIVOT block.
+
+Building a Resource containing a MIVOT block
+--------------------------------------------
+
+Construct the MIVOT block by passing the XML block as a parameter:
+
+.. code-block:: python
+
+ >>> from astropy.io.votable import tree
+ >>> from astropy.io.votable.tree import MivotBlock, Resource, VOTableFile
+ >>> mivot_block = MivotBlock("""
+
+ Unit test mapping block
+
+
+ """)
+
+Build a new resource:
+
+.. code-block:: python
+
+ >>> mivot_resource = Resource()
+
+Give it the type meta:
+
+.. code-block:: python
+
+ >>> mivot_resource.type = "meta"
+
+Then add it the MIVOT block:
+
+.. code-block:: python
+
+ >>> mivot_resource.mivot_block = mivot_block
+
+Now you have a MIVOT resource that you can add to an object Resource creating a new Resource:
+
+.. code-block:: python
+
+ >>> votable = VOTableFile()
+ >>> r1 = Resource()
+ >>> r1.type = "results"
+ >>> r1.resources.append(mivot_resource)
+
+You can add an `astropy.io.votable.tree.Table` to the resource:
+
+.. code-block:: python
+
+ >>> table = tree.Table(votable)
+ >>> r1.tables.append(t1)
+ >>> votable.resources.append(r1)
+ >>> for resource in votable.resources:
+ ... print(resource.mivot_block.content)
+
+ Unit test mapping block
+
+
+
+
See Also
========
@@ -480,6 +609,9 @@ See Also
- `VOTable Format Definition Version 1.4
`_
+- `MIVOT Recommendation Version 1.0
+ `_
+
.. note that if this section gets too long, it should be moved to a separate
doc page - see the top of performance.inc.rst for the instructions on how to do
that
diff --git a/docs/whatsnew/6.0.rst b/docs/whatsnew/6.0.rst
index d58c989e6a2..542c17afaef 100644
--- a/docs/whatsnew/6.0.rst
+++ b/docs/whatsnew/6.0.rst
@@ -17,6 +17,7 @@ In particular, this release includes:
* :ref:`whatsnew-6.0-broadcasting-frame-attributes`
* :ref:`whatsnew-6.0-cosmology-latex-export`
* :ref:`whatsnew-6.0-iers-data`
+* :ref:`whatsnew-6.0-model-annotation-in-votable`
In addition to these major changes, Astropy v6.0 includes a large number of
smaller improvements and bug fixes, which are described in the :ref:`changelog`.
@@ -235,6 +236,23 @@ download the package manually and transfer it to a computer that has no
public internet connection.
+.. _whatsnew-6.0-model-annotation-in-votable:
+
+Reading and writing VO model annotations
+========================================
+
+Model Instances in VOTables (`MIVOT `_)
+defines a syntax to map VOTable data to any model serialised in VO-DML (Virtual Observatory Data Modeling Language).
+The data model elements are grouped in an independent annotation block complying with
+the MIVOT XML schema which is added as an extra resource above the table element.
+In Astropy, the MIVOT block is implemented as a new component of the Resource element (MivotBlock class).
+MivotBlock instances can only be held by resources with "type=meta".
+In this new feature, Astropy is able to read and write MIVOT annotations from and within VOTables.
+There is no function processing data models, they will be delegated to affiliated packages such as PyVO.
+
+See :ref:`votable_mivot` for more details.
+
+
Full change log
===============