Source code for pyvo.mivot.writer.annotations
# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
MivotAnnotations: A utility module to build and manage MIVOT annotations.
"""
import os
import logging
try:
import xmlschema
except ImportError:
xmlschema = None
# Use defusedxml only if already present in order to avoid a new dependency.
try:
from defusedxml import ElementTree as etree
except ImportError:
from xml.etree import ElementTree as etree
from astropy.io.votable.tree import VOTableFile, Resource
try:
from astropy.io.votable.tree import MivotBlock
except ImportError:
pass
from astropy.io.votable import parse
from astropy import version
from pyvo.utils.prototype import prototype_feature
from pyvo.mivot.utils.xml_utils import XmlUtils
from pyvo.mivot.utils.exceptions import MappingError, AstropyVersionException
from pyvo.mivot.writer.instance import MivotInstance
from pyvo.mivot.version_checker import check_astropy_version
__all__ = ["MivotAnnotations"]
[docs]
@prototype_feature("MIVOT")
class MivotAnnotations:
"""
This module provides a class to construct, validate, and insert MIVOT
blocks into VOTable files.
The MIVOT block, represented as an XML structure, is used for
data model annotations in the IVOA ecosystem.
The main features are:
- Construct the MIVOT block step-by-step with various components.
- Validate the MIVOT block against the MIVOT XML schema (if ``xmlschema`` is installed).
- Embed the MIVOT block into an existing VOTable file.
The MIVOT block is constructed as a string to maintain compatibility with the Astropy API.
Attributes
----------
suggested_space_frames: string array, class attribute
A warning is emitted if a frame not in this list is used to build a space frame.
This list matches https://www.ivoa.net/rdf/refframe/2022-02-22/refframe.html.
suggested_ref_positions: string array, class attribute
A warning is emitted if a reference position not in this list is
used to build a space or a time frame.
suggested_time_frames: string array, class attribute
A warning is emitted if a frame not in this list is used to build a space frame.
This list matches https://www.ivoa.net/rdf/timescale/2019-03-15/timescale.html.
"""
def __init__(self):
"""
"""
# Dictionary containing models with their names as keys and URLs as values
self._models = {}
# str: Indicates the success status of the annotation process.s
self._report_status = True
# str: message associated with the report, used in the REPORT block.
self._report_message = "Generated by pyvo.mivot.writer"
# list(str or MivotInstance): GLOBALS blocks to be included in the MIVOT block.
self._globals = []
# list(str or MivotInstance): TEMPLATES blocks to be included in the MIVOT block.
self._templates = []
# str: An optional ID for the TEMPLATES block.
self._templates_id = ""
# str: list of the dmid of the INSTANCE stored in the GLOBALS
self._dmids = []
# str: Complete MIVOT block as a string
self._mivot_block = ""
@property
def mivot_block(self):
"""
Getter for the whole MIVOT block.
Returns
-------
str
Complete MIVOT block as a string.
"""
return self._mivot_block
def _get_report(self):
"""
Generate the <REPORT> component of the MIVOT block.
Returns
-------
str
The <REPORT> block as a string, indicating the success or failure of the process.
"""
if self._report_status:
return f'<REPORT status="OK">{self._report_message}</REPORT>'
else:
return f'<REPORT status="FAILED">{self._report_message}</REPORT>'
def _get_models(self):
"""
Generate the <MODEL> components of the MIVOT block.
Returns
-------
str
The <MODEL> components as a formatted string.
"""
models_block = ""
for key, value in self._models.items():
if value:
models_block += f'<MODEL name="{key}" url="{value}" />\n'
else:
models_block += f'<MODEL name="{key}" />\n'
return models_block
def _get_globals(self):
"""
Generate the <GLOBALS> component of the MIVOT block.
Returns
-------
str
The <GLOBALS> block as a formatted string.
"""
globals_block = "<GLOBALS>\n"
for glob in self._globals:
globals_block += f"{glob}\n"
globals_block += "</GLOBALS>\n"
return globals_block
def _get_templates(self):
"""
Generate the <TEMPLATES> component of the MIVOT block.
Returns
-------
str
The <TEMPLATES> block as a formatted string, or an empty string if no templates are defined.
"""
if not self._templates:
return ""
if not self._templates_id:
templates_block = "<TEMPLATES>\n"
else:
templates_block = f'<TEMPLATES tableref="{self._templates_id}">\n'
for templates in self._templates:
templates_block += f"{templates}\n"
templates_block += "</TEMPLATES>\n"
return templates_block
[docs]
def build_mivot_block(self, *, templates_id=None, schema_check=True):
"""
Build a complete MIVOT block from the declared components and validates it
against the MIVOT XML schema.
Parameters
----------
templates_id : str, optional
The ID to associate with the <TEMPLATES> block. Defaults to None.
schema_check : boolean, optional
Skip the XSD validation if False (use to make test working in local mode).
Raises
------
Any exceptions raised during XML validation are not caught and must
be handled by the caller.
"""
if templates_id:
self._templates_id = templates_id
self._mivot_block = '<VODML xmlns="http://www.ivoa.net/xml/mivot">\n'
self._mivot_block += self._get_report()
self._mivot_block += "\n"
self._mivot_block += self._get_models()
self._mivot_block += "\n"
self._mivot_block += self._get_globals()
self._mivot_block += "\n"
self._mivot_block += self._get_templates()
self._mivot_block += "\n"
self._mivot_block += "</VODML>\n"
self._mivot_block = self.mivot_block.replace("\n\n", "\n")
self._mivot_block = XmlUtils.pretty_string(self._mivot_block)
if schema_check:
self.check_xml()
[docs]
def add_templates(self, templates_instance):
"""
Add an <INSTANCE> element to the <TEMPLATES> block.
Parameters
----------
templates_instance : str or MivotInstance
The <INSTANCE> element to be added.
Raises
------
MappingError
If ``templates_instance`` is neither a string nor an instance of `MivotInstance`.
"""
if isinstance(templates_instance, MivotInstance):
self._templates.append(templates_instance.xml_string())
if templates_instance.dmid is not None:
self._dmids.append(templates_instance.dmid)
elif isinstance(templates_instance, str):
self._templates.append(templates_instance)
else:
raise MappingError(
"Instance added to templates must be a string or MivotInstance."
)
[docs]
def add_globals(self, globals_instance):
"""
Add an <INSTANCE> block to the <GLOBALS> block.
Parameters
----------
globals_instance : str or MivotInstance
The <INSTANCE> block to be added.
Raises
------
MappingError
If ``globals_instance`` is neither a string nor an instance of `MivotInstance`.
"""
if isinstance(globals_instance, MivotInstance):
self._globals.append(globals_instance.xml_string())
if globals_instance.dmid is not None:
self._dmids.append(globals_instance.dmid)
elif isinstance(globals_instance, str):
self._globals.append(globals_instance)
else:
raise MappingError(
"Instance added to globals must be a string or MivotInstance."
)
[docs]
def add_model(self, model_name, *, vodml_url=None):
"""
Add a <MODEL> element to the MIVOT block.
Parameters
----------
model_name : str
The short name of the model.
vodml_url : str, optional
The URL of the VO-DML file associated with the model.
"""
self._models[model_name] = vodml_url
[docs]
def set_report(self, status, message):
"""
Set the <REPORT> element of the MIVOT block.
Parameters
----------
status : bool
The status of the annotation process. True for success, False for failure.
message : str
The message associated with the REPORT.
Notes
-----
If ``status`` is False, all components of the MIVOT block except MODEL and REPORT
are cleared.
"""
self._report_status = status
self._report_message = message
if not status:
self._globals = []
self._templates = []
[docs]
def check_xml(self):
"""
Validate the MIVOT block against the MIVOT XML schema v1.0.
Raises
------
MappingError
If the validation fails.
Notes
-----
The schema (mivot 1.0) is loaded from a local file to avoid dependency on a remote service.
"""
# put here just to improve the test coverage
root = etree.fromstring(self._mivot_block)
mivot_block = XmlUtils.pretty_string(root, clean_namespace=False)
if not xmlschema:
logging.warning(
"XML validation skipped: no XML schema validator found. "
+ "Please install it (e.g., pip install xmlschema)."
)
return
schema = xmlschema.XMLSchema11(os.path.dirname(__file__) + "/mivot-v1.xsd")
try:
schema.validate(mivot_block)
except Exception as excep:
raise MappingError(f"Validation failed: {excep}") from excep
[docs]
def insert_into_votable(self, votable_file, override=False):
"""
Insert the MIVOT block into a VOTable.
Parameters
----------
votable_file : str or VOTableFile
The VOTable to be annotated, either as a file path or a ``VOTableFile`` instance.
override : bool
If True, overrides any existing annotations in the VOTable.
Raises
------
MappingError
If a mapping block already exists and ``override`` is False.
"""
if not check_astropy_version():
raise AstropyVersionException(f"Astropy version {version.version} "
"is below the required version 6.0 for the use of MIVOT.")
if isinstance(votable_file, str):
votable = parse(votable_file)
elif isinstance(votable_file, VOTableFile):
votable = votable_file
else:
raise MappingError(
"votable_file must be a file path string or a VOTableFile instance, "
"not a {type(votable_file)}."
)
for resource in votable.resources:
if resource.type == "results":
for subresource in resource.resources:
if subresource.type == "meta":
if not override:
raise MappingError(
"A type='meta' resource already exists in the first 'result' resource."
)
else:
logging.info("Overriding existing type='meta' resource.")
break
mivot_resource = Resource()
mivot_resource.type = "meta"
mivot_resource.mivot_block = MivotBlock(self._mivot_block)
resource.resources.append(mivot_resource)