From 9304187378fe3e2b57d8730d7683ec051af65a9c Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Thu, 4 Sep 2025 11:52:34 +0200 Subject: [PATCH 01/28] remove obsolete words --- pyvo/mivot/utils/vocabulary.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyvo/mivot/utils/vocabulary.py b/pyvo/mivot/utils/vocabulary.py index a7ec58b0..b03a7802 100644 --- a/pyvo/mivot/utils/vocabulary.py +++ b/pyvo/mivot/utils/vocabulary.py @@ -13,8 +13,6 @@ class Constant: FIRST_TABLE = "first_table" FIELD_UNIT = "field_unit" COL_INDEX = "col_index" - ROOT_COLLECTION = "root_collection" - ROOT_OBJECT = "root_object" NOT_SET = "NotSet" ANONYMOUS_TABLE = "AnonymousTable" From 9aca00a529465aef14f9325f2ec35e2c23305b17 Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Thu, 4 Sep 2025 13:57:52 +0200 Subject: [PATCH 02/28] Remove XML API which has never been used --- .../tests/test_mivot_instance_generation.py | 183 ------------------ pyvo/mivot/tests/test_xml_viewer.py | 67 ------- pyvo/mivot/viewer/xml_viewer.py | 145 -------------- 3 files changed, 395 deletions(-) delete mode 100644 pyvo/mivot/tests/test_mivot_instance_generation.py delete mode 100644 pyvo/mivot/tests/test_xml_viewer.py delete mode 100644 pyvo/mivot/viewer/xml_viewer.py diff --git a/pyvo/mivot/tests/test_mivot_instance_generation.py b/pyvo/mivot/tests/test_mivot_instance_generation.py deleted file mode 100644 index 847005cf..00000000 --- a/pyvo/mivot/tests/test_mivot_instance_generation.py +++ /dev/null @@ -1,183 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst -""" -Test for mivot.viewer.model_viewer_level3.py and mivot.viewer.mivot_time.py -""" -import os -import pytest -from urllib.request import urlretrieve -from pyvo.mivot.version_checker import check_astropy_version -from pyvo.mivot.viewer import MivotViewer -from pyvo.mivot.utils.mivot_utils import MivotUtils - - -@pytest.mark.remote_data -@pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") -def test_model_viewer3(votable_test, simple_votable): - """ - Recursively compare an XML element with an element of MIVOT - class with the function recursive_xml_check. - This test run on 2 votables : votable_test and simple_votable. - """ - m_viewer_simple_votable = MivotViewer(votable_path=simple_votable) - MivotInstance = m_viewer_simple_votable.dm_instance - xml_simple_votable = m_viewer_simple_votable.xml_view - assert xml_simple_votable.tag == 'TEMPLATES' - recusive_xml_check(xml_simple_votable, MivotInstance) - m_viewer_votable_test = MivotViewer(votable_path=votable_test) - m_viewer_votable_test.next_row_view() - mivot_instance = m_viewer_votable_test.dm_instance - xml_votable_test = m_viewer_votable_test.xml_view - assert xml_simple_votable.tag == 'TEMPLATES' - recusive_xml_check(xml_votable_test, mivot_instance) - - -@pytest.mark.remote_data -@pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") -def recusive_xml_check(xml_simple_votable, MivotInstance): - if xml_simple_votable.tag == 'TEMPLATES': - recusive_xml_check(xml_simple_votable[0], MivotInstance) - else: - for child in xml_simple_votable: - if child.tag == 'INSTANCE': - for key, value in child.attrib.items(): - if key == 'dmrole': - if value == '': - if child.tag == 'ATTRIBUTE': - recusive_xml_check(child, - getattr(MivotInstance, - MivotInstance._remove_model_name( - child.get('dmrole')))) - elif child.tag == 'INSTANCE': - recusive_xml_check(child, getattr(MivotInstance, - MivotInstance._remove_model_name - (child.get('dmrole')))) - else: - if child.tag == 'ATTRIBUTE': - recusive_xml_check(child, getattr(MivotInstance, - MivotInstance._remove_model_name( - child.get('dmrole')))) - elif child.tag == 'INSTANCE': - recusive_xml_check(child, getattr(MivotInstance, - MivotInstance._remove_model_name( - child.get('dmrole')))) - elif child.tag == 'COLLECTION': - recusive_xml_check(child, getattr(MivotInstance, - MivotInstance._remove_model_name( - child.get('dmrole')))) - elif child.tag == 'COLLECTION': - for key, value in child.attrib.items(): - assert len(getattr(MivotInstance, - MivotInstance._remove_model_name(child.get('dmrole')))) == len(child) - i = 0 - for child2 in child: - recusive_xml_check(child2, getattr(MivotInstance, MivotInstance._remove_model_name - (child.get('dmrole')))[i]) - i += 1 - elif child.tag == 'ATTRIBUTE': - MivotInstance_attribute = getattr(MivotInstance, - MivotInstance._remove_model_name(child.get('dmrole'))) - for key, value in child.attrib.items(): - if key == 'dmtype': - assert MivotInstance_attribute.dmtype in value - elif key == 'value': - if (MivotInstance_attribute.value is not None - and not isinstance(MivotInstance_attribute.value, bool)): - if isinstance(MivotInstance_attribute.value, float): - pytest.approx(float(value), MivotInstance_attribute.value, 0.0001) - else: - assert value == MivotInstance_attribute.value - elif child.tag.startswith("REFERENCE"): - # Viewer not in resolve_ref mode: REFRENCEs are not filtered - pass - else: - print(child.tag) - assert False - - -@pytest.mark.remote_data -@pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") -def test_dict_model_viewer3(votable_test, simple_votable): - """ - To test the generation of the MIVOT class, the function builds a ModelViewerLevel3 - with his MIVOT class and his previous dictionary from XML. - Then, it calls the function recursive_check which recursively compares an element of MIVOT class - with the dictionary on which it was built. - MIVOT class is itself a dictionary with only essential information of the ModelViewerLevel3._dict. - This test run on 2 votables : votable_test and simple_votable. - """ - m_viewer_votable_test = MivotViewer(votable_path=votable_test) - m_viewer_votable_test.next_row_view() - mivot_instance = m_viewer_votable_test.dm_instance - _dict = MivotUtils.xml_to_dict(m_viewer_votable_test.xml_viewer.view) - recursive_check(mivot_instance, **_dict) - mivot_instance = m_viewer_votable_test.dm_instance - _dict = MivotUtils.xml_to_dict(m_viewer_votable_test.xml_view) - recursive_check(mivot_instance, **_dict) - - -def recursive_check(MivotInstance, **kwargs): - for key, value in kwargs.items(): - # the root instance ha no role: this makes an empty value in the unpacked dict - if key == '': - continue - if isinstance(value, list): - nbr_item = 0 - for item in value: - if isinstance(item, dict): - assert 'dmtype' in item.keys() - recursive_check(getattr(MivotInstance, - MivotInstance._remove_model_name(key))[nbr_item], - **item - ) - nbr_item += 1 - elif isinstance(value, dict) and 'value' not in value: - # for INSTANCE of INSTANCEs dmrole needs model_name - assert MivotInstance._remove_model_name(key, True) in vars(MivotInstance).keys() - recursive_check(getattr(MivotInstance, MivotInstance._remove_model_name(key, True)), **value) - else: - if isinstance(value, dict) and MivotInstance._is_leaf(**value): - assert value.keys().__contains__('dmtype' and 'value' and 'unit' and 'ref') - lower_dmtype = value['dmtype'].lower() - if "real" in lower_dmtype or "double" in lower_dmtype or "float" in lower_dmtype: - assert isinstance(value['value'], float) - elif "bool" in lower_dmtype: - assert isinstance(value['value'], bool) - elif value['dmtype'] is None: - assert (value['value'] in - ('notset', 'noset', 'null', 'none', 'NotSet', 'NoSet', 'Null', 'None')) - else: - if value['value'] is not None: - assert isinstance(value['value'], str) - recursive_check(getattr(MivotInstance, MivotInstance._remove_model_name(key)), **value) - else: - assert key == 'dmtype' or 'value' - - -@pytest.fixture -def votable_test(data_path, data_sample_url): - votable_name = "vizier_csc2_gal.annot.xml" - votable_path = os.path.join(data_path, "data", votable_name) - urlretrieve(data_sample_url + votable_name, - votable_path) - yield votable_path - os.remove(votable_path) - - -@pytest.fixture -def simple_votable(data_path, data_sample_url): - votable_name = "simple-annotation-votable.xml" - votable_path = os.path.join(data_path, "data", votable_name) - urlretrieve(data_sample_url + votable_name, - votable_path) - yield votable_path - os.remove(votable_path) - - -@pytest.fixture -def data_path(): - return os.path.dirname(os.path.realpath(__file__)) - - -@pytest.fixture -def data_sample_url(): - return "https://raw.githubusercontent.com/ivoa/dm-usecases/main/pyvo-ci-sample/" diff --git a/pyvo/mivot/tests/test_xml_viewer.py b/pyvo/mivot/tests/test_xml_viewer.py deleted file mode 100644 index 1a0cd713..00000000 --- a/pyvo/mivot/tests/test_xml_viewer.py +++ /dev/null @@ -1,67 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst -""" -Test for mivot.viewer.model_viewer_level2.py -""" -import pytest -try: - from defusedxml.ElementTree import Element as element -except ImportError: - from xml.etree.ElementTree import Element as element -from astropy.utils.data import get_pkg_data_filename -from pyvo.mivot.version_checker import check_astropy_version -from pyvo.mivot.viewer import MivotViewer -from pyvo.mivot.utils.exceptions import MivotError - - -@pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") -def test_xml_viewer(m_viewer): - - m_viewer.next_row_view() - xml_viewer = m_viewer.xml_viewer - with pytest.raises(MivotError, - match="Cannot find dmrole wrong_role in any instances of the VOTable"): - xml_viewer.get_instance_by_role("wrong_role") - - with pytest.raises(MivotError, - match="Cannot find dmrole wrong_role in any instances of the VOTable"): - xml_viewer.get_instance_by_role("wrong_role", all_instances=True) - - with pytest.raises(MivotError, - match="Cannot find dmtype wrong_dmtype in any instances of the VOTable"): - xml_viewer.get_instance_by_type("wrong_dmtype") - - with pytest.raises(MivotError, - match="Cannot find dmtype wrong_dmtype in any instances of the VOTable"): - xml_viewer.get_instance_by_type("wrong_dmtype", all_instances=True) - - with pytest.raises(MivotError, - match="Cannot find dmrole wrong_role in any collections of the VOTable"): - xml_viewer.get_collection_by_role("wrong_role") - - with pytest.raises(MivotError, - match="Cannot find dmrole wrong_role in any collections of the VOTable"): - xml_viewer.get_collection_by_role("wrong_role", all_instances=True) - - instances_list_role = xml_viewer.get_instance_by_role("cube:MeasurementAxis.measure") - assert isinstance(instances_list_role, element) - - instances_list_role = xml_viewer.get_instance_by_role("cube:MeasurementAxis.measure", all_instances=True) - assert len(instances_list_role) == 3 - - instances_list_type = xml_viewer.get_instance_by_type("cube:Observable") - assert isinstance(instances_list_type, element) - - instances_list_type = xml_viewer.get_instance_by_type("cube:Observable", all_instances=True) - assert len(instances_list_type) == 3 - - collections_list_role = xml_viewer.get_collection_by_role("cube:NDPoint.observable") - assert isinstance(collections_list_role, element) - - collections_list_role = xml_viewer.get_collection_by_role("cube:NDPoint.observable", all_instances=True) - assert len(collections_list_role) == 1 - - -@pytest.fixture -def m_viewer(): - return MivotViewer(get_pkg_data_filename("data/test.mivot_viewer.xml"), - tableref="Results") diff --git a/pyvo/mivot/viewer/xml_viewer.py b/pyvo/mivot/viewer/xml_viewer.py deleted file mode 100644 index fd69e79c..00000000 --- a/pyvo/mivot/viewer/xml_viewer.py +++ /dev/null @@ -1,145 +0,0 @@ -# Licensed under a 3-clause BSD style license - see LICENSE.rst -""" -XMLViewer provides several getters on XML instances built by - `pyvo.mivot.viewer.mivot_viewer`. -""" -from pyvo.mivot.utils.exceptions import MivotError -from pyvo.mivot.utils.xpath_utils import XPath -from pyvo.utils.prototype import prototype_feature - - -@prototype_feature('MIVOT') -class XMLViewer: - """ - The XMLViewer is used by `~pyvo.mivot.viewer.mivot_viewer` - to extract from the XML serialization of the model, - elements that will be used to build the dictionary from which - the Python class holding the mapped model will be generated. - """ - def __init__(self, xml_view): - self._xml_view = xml_view - - @property - def view(self): - """ - getter returning the XML model view - - returns - ------- - XML model view to be parsed - by different methods - """ - return self._xml_view - - def get_instance_by_role(self, dmrole, all_instances=False): - """ - If all_instances is False, return the first INSTANCE matching with @dmrole. - If all_instances is True, return a list of all instances matching with @dmrole. - Parameters - ---------- - dmrole : str - The @dmrole to look for. - all_instances : bool, optional - If True, returns a list of all instances, otherwise returns the first instance. - Default is False. - Returns - ------- - Union[`xml.etree.ElementTree.Element`, List[`xml.etree.ElementTree.Element`], None] - If all_instances is False, returns the instance matching with @dmrole. - If all_instances is True, returns a list of all instances matching with @dmrole. - If no matching instance is found, returns None. - Raises - ------ - MivotElementNotFound - If dmrole is not found. - """ - instances = XPath.select_elements_by_atttribute( - self._xml_view, - "INSTANCE", - "dmrole", - dmrole) - - if len(instances) == 0: - raise MivotError( - f"Cannot find dmrole {dmrole} in any instances of the VOTable") - - if all_instances is False: - return instances[0] - else: - return instances - - def get_instance_by_type(self, dmtype, all_instances=False): - """ - Return the instance matching with @dmtype. - If all_instances is False, returns the first INSTANCE matching with @dmtype. - If all_instances is True, returns a list of all instances matching with @dmtype. - Parameters - ---------- - dmtype : str - The @dmtype to look for. - all : bool, optional - If True, returns a list of all instances, otherwise returns the first instance. - Default is False. - Returns - ------- - Union[~`xml.etree.ElementTree.Element`, List[~`xml.etree.ElementTree.Element`], None] - If all_instances is False, returns the instance matching with @dmtype. - If all_instances is True, returns a list of all instances matching with @dmtype. - If no matching instance is found, returns None. - Raises - ------ - MivotElementNotFound - If dmtype is not found. - """ - instances = XPath.select_elements_by_atttribute( - self._xml_view, - "INSTANCE", - "dmtype", - dmtype) - - if len(instances) == 0: - raise MivotError( - f"Cannot find dmtype {dmtype} in any instances of the VOTable") - - if all_instances is False: - return instances[0] - else: - return instances - - def get_collection_by_role(self, dmrole, all_instances=False): - """ - Return the collection matching with @dmrole. - If all_instances is False, returns the first COLLECTION matching with @dmrole. - If all_instances is True, returns a list of all COLLECTION matching with @dmrole. - Parameters - ---------- - dmrole : str - The @dmrole to look for. - all_instances : bool, optional - If True, returns a list of all COLLECTION, otherwise returns the first COLLECTION. - Default is False. - Returns - ------- - Union[~`xml.etree.ElementTree.Element`, List[~`xml.etree.ElementTree.Element`], None] - If all_instances is False, returns the collection matching with @dmrole. - If all_instances is True, returns a list of all collections matching with @dmrole. - If no matching collection is found, returns None. - Raises - ------ - MivotElementNotFound - If dmrole is not found. - """ - collections = XPath.select_elements_by_atttribute( - self._xml_view, - "COLLECTION", - "dmrole", - dmrole) - - if len(collections) == 0: - raise MivotError( - f"Cannot find dmrole {dmrole} in any collections of the VOTable") - - if all_instances is False: - return collections[0] - else: - return collections From 96920de537bf639ae77a38d8261701499a8f9934 Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Thu, 4 Sep 2025 14:01:16 +0200 Subject: [PATCH 03/28] update docstrings and process the when no TEMPLATES is found --- pyvo/mivot/seekers/annotation_seeker.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pyvo/mivot/seekers/annotation_seeker.py b/pyvo/mivot/seekers/annotation_seeker.py index 5c0d718a..307843f6 100644 --- a/pyvo/mivot/seekers/annotation_seeker.py +++ b/pyvo/mivot/seekers/annotation_seeker.py @@ -155,9 +155,11 @@ def get_templates_tableref(self): def get_templates(self): """ Return a list of TEMPLATES @tableref. + Returns ------- - list: TEMPLATES tablerefs + [string] + tablerefs of all TEMPLATES elements """ templates_found = [] eset = XPath.x_path(self._xml_block, ".//" + Ele.TEMPLATES) @@ -170,19 +172,26 @@ def get_templates(self): def get_templates_block(self, tableref): """ - Return the TEMPLATES mapping block of the table matching @tableref. - If tableref is None returns all values of templates_blocks. + Return the TEMPLATES mapping block of the table identified @tableref. + If tableref is None or equals to Constant.FIRST_TABLE, return the first TEMPLATES. + Parameters ---------- tableref (str): @tableref of the searched TEMPLATES + Returns ------- - dict: TEMPLATES tablerefs and their mapping blocks {'tableref': mapping_block, ...} + XML element: matching TEMPLATES block or None """ # one table: name forced to DEFAULT or take the first if tableref is None or tableref == Constant.FIRST_TABLE: for _, tmpl in self._templates_blocks.items(): return tmpl + + if tableref not in self._templates_blocks: + raise MivotError( + "No TEMPLATES with tableref=" + tableref) + return self._templates_blocks[tableref] """ @@ -191,6 +200,7 @@ def get_templates_block(self, tableref): def get_instance_dmtypes(self): """ Get @dmtypes of all mapped instances + Returns ------- dict: @dmtypes of all mapped instances {GLOBALS: [], TEMPLATES: {}} From c564f49f105da65c39565c781da95371221ec2eb Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Thu, 4 Sep 2025 14:03:37 +0200 Subject: [PATCH 04/28] support the case of TEMPLATES containing multiple INSTANCE (doc not updated yet) --- pyvo/mivot/viewer/mivot_viewer.py | 163 +++++++++++++++--------------- 1 file changed, 80 insertions(+), 83 deletions(-) diff --git a/pyvo/mivot/viewer/mivot_viewer.py b/pyvo/mivot/viewer/mivot_viewer.py index 1a87f328..22c3dd05 100644 --- a/pyvo/mivot/viewer/mivot_viewer.py +++ b/pyvo/mivot/viewer/mivot_viewer.py @@ -42,7 +42,6 @@ from pyvo.mivot.viewer.mivot_instance import MivotInstance from pyvo.utils.prototype import prototype_feature from pyvo.mivot.utils.mivot_utils import MivotUtils -from pyvo.mivot.viewer.xml_viewer import XMLViewer # Use defusedxml only if already present in order to avoid a new depency. try: from defusedxml import ElementTree as etree @@ -99,7 +98,7 @@ def __init__(self, votable_path, tableref=None, resolve_ref=False): self._mapping_block = None self._mapped_tables = [] self._resource_seeker = None - self._dm_instance = None + self._dm_instances = [] self._resolve_ref = resolve_ref try: self._set_resource() @@ -107,7 +106,7 @@ def __init__(self, votable_path, tableref=None, resolve_ref=False): self._resource_seeker = ResourceSeeker(self._resource) self._set_mapped_tables() self._connect_table(tableref) - self._init_instance() + self._init_instances() except MappingError as mnf: logging.error(str(mnf)) @@ -162,34 +161,22 @@ def dm_instance(self): """ returns ------- - A Python object (MivotInstance) built from the XML view of - the mapped model with attribute values set from the last values - of the last read data rows + MivotInstance: The Python object (MivotInstance) built from the XML view of the + first 'TEMPLATES' child, with the attribute values set according + to the values of the current read data row. """ - return self._dm_instance + return self._dm_instances[0] @property - def xml_view(self): + def dm_instances(self): """ - returns + Returns ------- - The XML view on the current data row - """ - return self.xml_viewer.view - - @property - def xml_viewer(self): - """ - returns - XMLViewer tuned to browse the TEMPLATES content + [MivotInstance]: The list of Python objects (MivotInstance) built from the XML views of + the TEMPLATES children, whose attribute values are set from the values + of the current read data row. """ - # build a first XMLViewer for extract the content of the TEMPLATES element - model_view = XMLViewer(self._get_model_view()) - first_instance_dmype = self.get_first_instance_dmtype(tableref=self.connected_table_ref) - model_view.get_instance_by_type(first_instance_dmype) - - # return an XMLViewer tuned to process the TEMPLATES content - return XMLViewer(model_view._xml_view) + return self._dm_instances @property def table_row(self): @@ -198,23 +185,23 @@ def table_row(self): def next_row_view(self): """ - jump to the next table row and update the MivotInstance instance + jump to the next table row and update the MivotInstance instance with the row values - returns + Returns ------- - MivotInstance: the updated instance or None - it he able end has been reached + [MivotInstance] + List of updated instances or None + it he able end has been reached """ self.next_table_row() if self._current_data_row is None: return None + self._init_instances() - if self._dm_instance is None: - xml_instance = self.xml_viewer.view - self._dm_instance = MivotInstance(**MivotUtils.xml_to_dict(xml_instance)) - self._dm_instance.update(self._current_data_row) - return self._dm_instance + for dm_instance in self._dm_instances: + dm_instance.update(self._current_data_row) + return self._dm_instances def get_table_ids(self): """ @@ -293,48 +280,47 @@ def rewind(self): if self._table_iterator: self._table_iterator.rewind() - def get_first_instance_dmtype(self, tableref=None): + def get_templates_child_instances(self, tableref=None): """ - Return the dmtype of the head INSTANCE (first TEMPLATES child). - If no INSTANCE is found, take the first COLLECTION. + Returns + ------- + [`xml.etree.ElementTree.Element`] + List of all INSTANCES elements children of the current TEMPLATES block + """ + if self._annotation_seeker is None: + return None + templates_block = self._annotation_seeker.get_templates_block(tableref) + return XPath.x_path(templates_block, ".//" + Ele.INSTANCE) + + def get_first_instance_dmtype(self, tableref): + """ + Return the dmtype of the head INSTANCE (first TEMPLATES child) of the + TEMPLATES block mapping the data table identified by tableref. Parameters ---------- - tableref : str or None, optional - Identifier of the table. + tableref : str or None + Identifier of the data table. + Returns ------- - ~`xml.etree.ElementTree.Element` - The first child of TEMPLATES. + string + dmtype of the selected INSTANCE + + Raises + ------ + MivotError + if no INSTANCE can be found """ - if self._annotation_seeker is None: - return None - child_template = self._annotation_seeker.get_templates_block(tableref) - child = child_template.findall("*") - collection = XPath.x_path(self._annotation_seeker.get_templates_block(tableref), - ".//" + Ele.COLLECTION) - instance = XPath.x_path(self._annotation_seeker.get_templates_block(tableref), ".//" + Ele.INSTANCE) - if len(collection) >= 1: - collection[0].set(Att.dmtype, Constant.ROOT_COLLECTION) - (self._annotation_seeker.get_templates_block(tableref).find(".//" + Ele.COLLECTION) - .set(Att.dmtype, Constant.ROOT_COLLECTION)) - if len(child) > 1: - if len(instance) >= 1: - for inst in instance: - if inst in child: - return inst.get(Att.dmtype) - elif len(collection) >= 1: - for coll in collection: - if coll in child: - return coll.get(Att.dmtype) - elif len(child) == 1: - if child[0] in instance: - return child[0].get(Att.dmtype) - elif child[0] in collection: - return collection[0].get(Att.dmtype) - else: - raise MivotError("Can't find the first " + Ele.INSTANCE - + "/" + Ele.COLLECTION + " in " + Ele.TEMPLATES) + templates_block = self._annotation_seeker.get_templates_block(tableref) + instances = XPath.x_path(templates_block, ".//" + Ele.INSTANCE) + for instance in instances: + return instance.get(Att.dmtype) + + raise MivotError( + "Can't find the first " + Ele.INSTANCE + + " in " + Ele.TEMPLATES + ) def _connect_table(self, tableref=None): """ @@ -379,12 +365,20 @@ def _connect_table(self, tableref=None): self._set_column_indices() self._set_column_units() - def _get_model_view(self): + def _get_model_view(self, xml_instance): """ Return an XML model view of the last read row. - This function resolves references by default. + + - References are possibly resolved here. + - ``ATTRIBUTE@value`` are set with actual data row values + + Returns + ------- + `xml.etree.ElementTree.Element` + XML model view of the last read row. + """ - templates_copy = deepcopy(self._templates) + templates_copy = deepcopy(xml_instance) if self._resolve_ref is True: while StaticReferenceResolver.resolve(self._annotation_seeker, self._connected_tableref, templates_copy) > 0: @@ -405,25 +399,28 @@ def _get_model_view(self): ele.attrib[Att.value] = str(self._current_data_row[int(index)]) return templates_copy - def _init_instance(self): + def _init_instances(self): """ - Read the first table row and build the MivotInstance (_instance attribute) from it. - The table row iterator in rewind at he end to make sure we won't lost the first data row. + Read the first table row and build all MivotInstances (_dm_instances attribute) from it. + The table row iterator in rewind at the end to make sure we won't lost the first data row. """ - if self._dm_instance is None: + if not self._dm_instances: self.next_table_row() - first_instance = self.get_first_instance_dmtype(tableref=self.connected_table_ref) - xml_instance = self.xml_viewer.get_instance_by_type(first_instance) - self._dm_instance = MivotInstance(**MivotUtils.xml_to_dict(xml_instance)) + xml_instances = self.get_templates_child_instances(self.connected_table_ref) + self._dm_instances = [] + for xml_instance in xml_instances: + self._dm_instances.append( + MivotInstance( + **MivotUtils.xml_to_dict(self._get_model_view(xml_instance)) + )) self.rewind() - return self._dm_instance def _set_mapped_tables(self): """ - Set the mapped tables with a list of the TEMPLATES tablerefs. + Set the _mapped_tables list with the TEMPLATES tablerefs. """ if not self.resource_seeker: - self._mapped_table = [] + self._mapped_tables = [] else: self._mapped_tables = self._annotation_seeker.get_templates() @@ -478,7 +475,7 @@ def _squash_join_and_references(self): def _set_column_indices(self): """ Add column ranks to attribute having a ref. - Using ranks allow identifying columns even numpy raw have been serialised as [] + Using ranks allow identifying columns even when numpy raw have been serialised as [] """ index_map = self._resource_seeker.get_id_index_mapping(self._connected_tableref) XmlUtils.add_column_indices(self._templates, index_map) From f2cf3a49eff6ba591081622317e4449a61383250 Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Thu, 4 Sep 2025 14:04:43 +0200 Subject: [PATCH 05/28] adapt the test suite to the new viewer feature (multiple INSTANCE in TEMPLATES) --- .../data/reference/annotation_seeker.0.2.xml | 2 +- .../tests/data/test.instance_multiple.xml | 186 ++++++++++++++++++ .../data/test.mivot_viewer.first_instance.xml | 13 +- pyvo/mivot/tests/test_annotation_seeker.py | 15 +- pyvo/mivot/tests/test_mango_annoter.py | 1 - pyvo/mivot/tests/test_mivot_instance.py | 21 -- pyvo/mivot/tests/test_mivot_viewer.py | 78 +++++++- pyvo/mivot/tests/test_user_api.py | 1 - 8 files changed, 276 insertions(+), 41 deletions(-) create mode 100644 pyvo/mivot/tests/data/test.instance_multiple.xml diff --git a/pyvo/mivot/tests/data/reference/annotation_seeker.0.2.xml b/pyvo/mivot/tests/data/reference/annotation_seeker.0.2.xml index 8b60bf29..d7f39109 100644 --- a/pyvo/mivot/tests/data/reference/annotation_seeker.0.2.xml +++ b/pyvo/mivot/tests/data/reference/annotation_seeker.0.2.xml @@ -1,6 +1,6 @@ - + diff --git a/pyvo/mivot/tests/data/test.instance_multiple.xml b/pyvo/mivot/tests/data/test.instance_multiple.xml new file mode 100644 index 00000000..de0349e6 --- /dev/null +++ b/pyvo/mivot/tests/data/test.instance_multiple.xml @@ -0,0 +1,186 @@ + + + + + + + + + Automatically annotated by XTAPDB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + the flux of the energy band number 1 of the ep camera in sc + + + the flux of the energy band number 2 of the ep camera in sc + + + the flux of the energy band number 3 of the ep camera in sc + + + + + + + + + + + + + + + +
0.00.10.2
1.02.13.2
+
+
diff --git a/pyvo/mivot/tests/data/test.mivot_viewer.first_instance.xml b/pyvo/mivot/tests/data/test.mivot_viewer.first_instance.xml index 2e90f7f4..406af0e9 100644 --- a/pyvo/mivot/tests/data/test.mivot_viewer.first_instance.xml +++ b/pyvo/mivot/tests/data/test.mivot_viewer.first_instance.xml @@ -12,20 +12,11 @@ - - + - - - - - - - - - + diff --git a/pyvo/mivot/tests/test_annotation_seeker.py b/pyvo/mivot/tests/test_annotation_seeker.py index fd741eed..42787e2c 100644 --- a/pyvo/mivot/tests/test_annotation_seeker.py +++ b/pyvo/mivot/tests/test_annotation_seeker.py @@ -12,7 +12,7 @@ from pyvo.mivot.utils.dict_utils import DictUtils from pyvo.mivot.version_checker import check_astropy_version from pyvo.mivot.viewer import MivotViewer -from . import XMLOutputChecker +from pyvo.mivot.tests import XMLOutputChecker @pytest.fixture @@ -24,6 +24,14 @@ def a_seeker(): return AnnotationSeeker(m_viewer._mapping_block) +@pytest.fixture +def a_multiple_seeker(): + m_viewer = MivotViewer( + get_pkg_data_filename("data/test.instance_multiple.xml"), + tableref="Results") + return AnnotationSeeker(m_viewer._mapping_block) + + @pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") def test_multiple_templates(): """ @@ -87,3 +95,8 @@ def test_all_reverts(a_seeker): assert a_seeker.get_globals_instance_from_collection( "_CoordinateSystems", "ICRS").get("dmtype") == "coords:SpaceSys" assert a_seeker.get_globals_instance_from_collection("wrong_dmid", "ICRS") is None + + +def test_multiple_seeker(a_multiple_seeker): + assert (a_multiple_seeker.get_instance_dmtypes()["TEMPLATES"] + == {"Results": ["mango:Brightness", "mango:Brightness", "mango:Brightness"]}) diff --git a/pyvo/mivot/tests/test_mango_annoter.py b/pyvo/mivot/tests/test_mango_annoter.py index 79d4c4cf..ee78bae6 100644 --- a/pyvo/mivot/tests/test_mango_annoter.py +++ b/pyvo/mivot/tests/test_mango_annoter.py @@ -153,7 +153,6 @@ def test_all_properties(): add_photometry(builder) add_epoch_positon(builder) builder.pack_into_votable() - XmlUtils.pretty_print(builder._annotation.mivot_block) assert XmlUtils.strip_xml(builder._annotation.mivot_block) == ( XmlUtils.strip_xml(get_pkg_data_contents("data/reference/mango_object.xml")) ) diff --git a/pyvo/mivot/tests/test_mivot_instance.py b/pyvo/mivot/tests/test_mivot_instance.py index 3cc3035b..e3f89438 100644 --- a/pyvo/mivot/tests/test_mivot_instance.py +++ b/pyvo/mivot/tests/test_mivot_instance.py @@ -4,14 +4,9 @@ @author: michel ''' -import os import pytest from astropy.table import Table -from astropy.utils.data import get_pkg_data_filename -from pyvo.mivot.version_checker import check_astropy_version from pyvo.mivot.viewer.mivot_instance import MivotInstance -from pyvo.mivot.utils.mivot_utils import MivotUtils -from pyvo.mivot.viewer import MivotViewer fake_hk_dict = { "dmtype": "EpochPosition", @@ -103,22 +98,6 @@ } -@pytest.fixture -def m_viewer(): - data_path = get_pkg_data_filename(os.path.join("data", - "test.mivot_viewer.xml") - ) - return MivotViewer(data_path, tableref="Results") - - -@pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") -def test_xml_viewer(m_viewer): - - xml_instance = m_viewer.xml_viewer.view - dm_instance = MivotInstance(**MivotUtils.xml_to_dict(xml_instance)) - assert dm_instance.to_dict() == test_dict - - def test_mivot_instance_constructor(): """Test the class generation from a dict.""" mivot_object = MivotInstance(**fake_hk_dict) diff --git a/pyvo/mivot/tests/test_mivot_viewer.py b/pyvo/mivot/tests/test_mivot_viewer.py index ff79da42..7fb6bc41 100644 --- a/pyvo/mivot/tests/test_mivot_viewer.py +++ b/pyvo/mivot/tests/test_mivot_viewer.py @@ -13,6 +13,39 @@ from pyvo.mivot.viewer import MivotViewer from astropy import version as astropy_version +dm_raw_instances = [ + { + "dmrole": "", + "dmtype": "mango:Brightness", + "value": { + "dmtype": "ivoa:RealQuantity", + "value": None, + "unit": None, + "ref": "SC_EP_1_FLUX", + }, + }, + { + "dmrole": "", + "dmtype": "mango:Brightness", + "value": { + "dmtype": "ivoa:RealQuantity", + "value": None, + "unit": None, + "ref": "SC_EP_2_FLUX", + }, + }, + { + "dmrole": "", + "dmtype": "mango:Brightness", + "value": { + "dmtype": "ivoa:RealQuantity", + "value": None, + "unit": None, + "ref": "SC_EP_3_FLUX", + }, + }, +] + @pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") def test_get_first_instance_dmtype(path_to_first_instance): @@ -22,11 +55,11 @@ def test_get_first_instance_dmtype(path_to_first_instance): """ m_viewer = MivotViewer(votable_path=path_to_first_instance) assert m_viewer.get_first_instance_dmtype("one_instance") == "one_instance" - assert m_viewer.get_first_instance_dmtype("coll_and_instances") == "first" - assert m_viewer.get_first_instance_dmtype("one_collection") == Constant.ROOT_COLLECTION - assert m_viewer.get_first_instance_dmtype("only_collection") == Constant.ROOT_COLLECTION - with pytest.raises(Exception, match="Can't find the first INSTANCE/COLLECTION in TEMPLATES"): + assert m_viewer.get_first_instance_dmtype("some_instances") == "first" + with pytest.raises(Exception, match="Can't find the first INSTANCE in TEMPLATES"): m_viewer.get_first_instance_dmtype("empty") + with pytest.raises(Exception, match="No TEMPLATES with tableref=not_existing_tableref"): + m_viewer.get_first_instance_dmtype("not_existing_tableref") @pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") @@ -77,7 +110,7 @@ def test_global_getters(m_viewer): @pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") def test_no_mivot(path_no_mivot): """ - Test each getter for GLOBALS of the model_viewer specific . + Test the viewer behavior when there is no mapping """ m_viewer = MivotViewer(path_no_mivot) assert m_viewer.get_table_ids() is None @@ -92,6 +125,33 @@ def test_no_mivot(path_no_mivot): assert m_viewer.next_table_row() is None +@pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") +def test_instance_mutiple_in_templates(path_to_multiple_instance): + """ + Test case with a TEMPLATES containing multiple instances + """ + m_viewer = MivotViewer(votable_path=path_to_multiple_instance) + instance_dict = [] + # test the DM instances children of TEMPLATES before their values are set + for dmi in m_viewer.dm_instances: + instance_dict.append(dmi.to_hk_dict()) + assert instance_dict == dm_raw_instances + + # test the DM instances children of TEMPLATES set with the values of the first row + m_viewer.next_row_view() + row_values = [] + for dmi in m_viewer.dm_instances: + row_values.append(dmi.value.value) + assert row_values == pytest.approx([0.0, 0.1, 0.2], rel=1e-3) + + # test the DM instances children of TEMPLATES set with the values of the second row + m_viewer.next_row_view() + row_values = [] + for dmi in m_viewer.dm_instances: + row_values.append(dmi.value.value) + assert row_values == pytest.approx([1.0, 2.1, 3.2], rel=1e-3) + + def test_check_version(path_to_viewer): if not check_astropy_version(): with pytest.raises(Exception, @@ -125,8 +185,16 @@ def path_to_viewer(): return get_pkg_data_filename(os.path.join("data", votable_name)) +@pytest.fixture +def path_to_multiple_instance(): + + votable_name = "test.instance_multiple.xml" + return get_pkg_data_filename(os.path.join("data", votable_name)) + + @pytest.fixture def path_to_first_instance(): + votable_name = "test.mivot_viewer.first_instance.xml" return get_pkg_data_filename(os.path.join("data", votable_name)) diff --git a/pyvo/mivot/tests/test_user_api.py b/pyvo/mivot/tests/test_user_api.py index 44b010b4..39de509f 100644 --- a/pyvo/mivot/tests/test_user_api.py +++ b/pyvo/mivot/tests/test_user_api.py @@ -15,7 +15,6 @@ from pyvo.mivot.viewer import MivotViewer from astropy.io.votable import parse - ref_ra = [ 0.04827189, 0.16283175, From cf72648f479a57abaa99392b40b89856eb5b0ef4 Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Thu, 4 Sep 2025 15:45:57 +0200 Subject: [PATCH 06/28] The viewer builds MivotInstances for INSTANCEs children of GLOBALS --- pyvo/mivot/tests/test_mivot_viewer.py | 138 ++++++++++++++++++++++++++ pyvo/mivot/viewer/mivot_viewer.py | 32 +++++- 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/pyvo/mivot/tests/test_mivot_viewer.py b/pyvo/mivot/tests/test_mivot_viewer.py index 7fb6bc41..1447f5e6 100644 --- a/pyvo/mivot/tests/test_mivot_viewer.py +++ b/pyvo/mivot/tests/test_mivot_viewer.py @@ -46,6 +46,120 @@ }, ] +globals_photcal = { + "dmid": "CoordSystem_XMM_EB1_id", + "dmtype": "Phot:PhotCal", + "identifier": { + "dmtype": "ivoa:string", + "value": "XMM/EPIC/EB1", + "unit": None, + "ref": None, + }, + "magnitudeSystem": { + "dmrole": "Phot:PhotCal.magnitudeSystem", + "dmtype": "Phot:MagnitudeSystem", + "type": { + "dmtype": "Phot:TypeOfMagSystem", + "value": "XMM", + "unit": None, + "ref": None, + }, + "referenceSpectrum": { + "dmtype": "ivoa:anyURI", + "value": "https://xmm-tools.cosmos.esa.int/external" + "/xmm_user_support/documentation/sas_usg/USG/SASUSG.html", + "unit": None, + "ref": None, + }, + }, + "photometryFilter": { + "dmid": "CoordSystem_XMM_FILTER_EB1_id", + "dmtype": "Phot:PhotometryFilter", + "dmrole": "Phot:PhotCal.photometryFilter", + "identifier": { + "dmtype": "ivoa:string", + "value": "XMM/EPIC/EB1", + "unit": None, + "ref": None, + }, + "name": { + "dmtype": "ivoa:string", + "value": "XMM EPIC EB1", + "unit": None, + "ref": None, + }, + "description": { + "dmtype": "ivoa:string", + "value": "Soft", + "unit": None, + "ref": None, + }, + "bandName": { + "dmtype": "ivoa:string", + "value": "EB1", + "unit": None, + "ref": None, + }, + "spectralLocation": { + "dmrole": "Phot:PhotometryFilter.spectralLocation", + "dmtype": "Phot:SpectralLocation", + "ucd": { + "dmtype": "Phot:UCD", + "value": "em.wl.effective", + "unit": None, + "ref": None, + }, + "unitexpression": { + "dmtype": "ivoa:Unit", + "value": "keV", + "unit": None, + "ref": None, + }, + "value": {"dmtype": "ivoa:real", "value": 0.35, "unit": None, "ref": None}, + }, + "bandwidth": { + "dmrole": "Phot:PhotometryFilter.bandwidth", + "dmtype": "Phot:Bandwidth", + "ucd": { + "dmtype": "Phot:UCD", + "value": "instr.bandwidth;stat.fwhm", + "unit": None, + "ref": None, + }, + "unitexpression": { + "dmtype": "ivoa:Unit", + "value": "keV", + "unit": None, + "ref": None, + }, + "extent": {"dmtype": "ivoa:real", "value": 0.3, "unit": None, "ref": None}, + "start": {"dmtype": "ivoa:real", "value": 0.2, "unit": None, "ref": None}, + "stop": {"dmtype": "ivoa:real", "value": 0.5, "unit": None, "ref": None}, + }, + "transmissionCurve": { + "dmrole": "Phot:PhotometryFilter.transmissionCurve", + "dmtype": "Phot:TransmissionCurve", + "access": { + "dmrole": "Phot:TransmissionCurve.access", + "dmtype": "Phot:Access", + "reference": { + "dmtype": "ivoa:anyURI", + "value": "https://xmm-tools.cosmos.esa.int/external/xmm_user_support" + "/documentation/sas_usg/USG/SASUSG.html", + "unit": None, + "ref": None, + }, + "format": { + "dmtype": "ivoa:string", + "value": "text/html", + "unit": None, + "ref": None, + }, + }, + }, + }, +} + @pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") def test_get_first_instance_dmtype(path_to_first_instance): @@ -152,6 +266,30 @@ def test_instance_mutiple_in_templates(path_to_multiple_instance): assert row_values == pytest.approx([1.0, 2.1, 3.2], rel=1e-3) +@pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") +def test_globals_instances(path_to_multiple_instance): + """ + Test case for the GLOBALS instance access as MivotInstances + """ + m_viewer = MivotViewer(votable_path=path_to_multiple_instance) + instance_dict = [] + photcals = 0 + photfilters = 0 + # test the DM instances children of TEMPLATES before their values are set + for dmi in m_viewer.dm_globals_instances: + if dmi.dmtype == "Phot:PhotCal": + photcals += 1 + elif dmi.dmtype == "Phot:PhotometryFilter": + photfilters += 1 + else: + assert False, f"Unexpected dmtype {dmi.dmtype} in GLOBALS " + instance_dict.append(dmi.to_hk_dict()) + assert photcals == 3 + assert photfilters == 3 + # just check the first one + assert instance_dict[0] == globals_photcal + + def test_check_version(path_to_viewer): if not check_astropy_version(): with pytest.raises(Exception, diff --git a/pyvo/mivot/viewer/mivot_viewer.py b/pyvo/mivot/viewer/mivot_viewer.py index 22c3dd05..739d2d70 100644 --- a/pyvo/mivot/viewer/mivot_viewer.py +++ b/pyvo/mivot/viewer/mivot_viewer.py @@ -99,6 +99,7 @@ def __init__(self, votable_path, tableref=None, resolve_ref=False): self._mapped_tables = [] self._resource_seeker = None self._dm_instances = [] + self._dm_globals_instances = [] self._resolve_ref = resolve_ref try: self._set_resource() @@ -107,6 +108,7 @@ def __init__(self, votable_path, tableref=None, resolve_ref=False): self._set_mapped_tables() self._connect_table(tableref) self._init_instances() + self._init_globals_instances() except MappingError as mnf: logging.error(str(mnf)) @@ -178,6 +180,18 @@ def dm_instances(self): """ return self._dm_instances + @property + def dm_globals_instances(self): + """ + @@@@@@@@@ + Returns + ------- + [MivotInstance]: The list of Python objects (MivotInstance) built from the XML views of + the TEMPLATES children, whose attribute values are set from the values + of the current read data row. + """ + return self._dm_globals_instances + @property def table_row(self): """ getter for the current astropy.table.array row """ @@ -391,7 +405,6 @@ def _get_model_view(self, xml_instance): XmlUtils.add_column_units(templates_copy, self._resource_seeker .get_id_unit_mapping(self._connected_tableref)) - # for ele in templates_copy.xpath("//ATTRIBUTE"): for ele in XPath.x_path(templates_copy, ".//ATTRIBUTE"): ref = ele.get(Att.ref) if ref is not None and ref != Constant.NOT_SET and Constant.COL_INDEX in ele.attrib: @@ -415,6 +428,23 @@ def _init_instances(self): )) self.rewind() + def _init_globals_instances(self): + """ + Build one MivotInstance for each GLOBALS/INSTANCE. Internal references are always resolved + Globals MivotInstance are stored in the _dm_globals_instances list + """ + if not self._dm_globals_instances: + globals_copy = deepcopy(self._annotation_seeker.globals_block) + + while StaticReferenceResolver.resolve(self._annotation_seeker, None, + globals_copy) > 0: + pass + for ele in XPath.x_path(globals_copy, "./" + Ele.INSTANCE): + self._dm_globals_instances.append( + MivotInstance( + **MivotUtils.xml_to_dict(ele) + )) + def _set_mapped_tables(self): """ Set the _mapped_tables list with the TEMPLATES tablerefs. From 44aeee56866d6c12b25824b137b91aede92ec888 Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Thu, 4 Sep 2025 16:05:05 +0200 Subject: [PATCH 07/28] add missing astropy version checking --- pyvo/mivot/tests/test_annotation_seeker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyvo/mivot/tests/test_annotation_seeker.py b/pyvo/mivot/tests/test_annotation_seeker.py index 42787e2c..26d2a6d1 100644 --- a/pyvo/mivot/tests/test_annotation_seeker.py +++ b/pyvo/mivot/tests/test_annotation_seeker.py @@ -97,6 +97,7 @@ def test_all_reverts(a_seeker): assert a_seeker.get_globals_instance_from_collection("wrong_dmid", "ICRS") is None +@pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") def test_multiple_seeker(a_multiple_seeker): assert (a_multiple_seeker.get_instance_dmtypes()["TEMPLATES"] == {"Results": ["mango:Brightness", "mango:Brightness", "mango:Brightness"]}) From cf8e5727f128bfe5e29929d42e57c1e6c21015b9 Mon Sep 17 00:00:00 2001 From: lmichel Date: Wed, 24 Sep 2025 17:55:23 +0200 Subject: [PATCH 08/28] make SkyCoordBuilder able to process MangoObject instances To be continued accordingly to the MANGO recommandation process --- pyvo/mivot/features/sky_coord_builder.py | 173 +++++++++++++++----- pyvo/mivot/tests/__init__.py | 8 +- pyvo/mivot/tests/data/simbad-cone-mivot.xml | 160 ++++++++++++++++++ pyvo/mivot/tests/test_sky_coord_builder.py | 26 ++- 4 files changed, 317 insertions(+), 50 deletions(-) create mode 100644 pyvo/mivot/tests/data/simbad-cone-mivot.xml diff --git a/pyvo/mivot/features/sky_coord_builder.py b/pyvo/mivot/features/sky_coord_builder.py index 2d039a4c..aaa59f9b 100644 --- a/pyvo/mivot/features/sky_coord_builder.py +++ b/pyvo/mivot/features/sky_coord_builder.py @@ -2,11 +2,12 @@ """ Utility transforming MIVOT annotation into SkyCoord instances """ - +import numbers from astropy.coordinates import SkyCoord from astropy import units as u from astropy.coordinates import ICRS, Galactic, FK4, FK5 -from pyvo.mivot.utils.exceptions import NoMatchingDMTypeError +from astropy.time.core import Time +from pyvo.mivot.utils.exceptions import NoMatchingDMTypeError, MappingError class MangoRoles: @@ -62,7 +63,9 @@ def __init__(self, mivot_instance_dict): def build_sky_coord(self): """ Build a SkyCoord instance from the MivotInstance dictionary. - The operation requires the dictionary to have ``mango:EpochPosition`` as dmtype + The operation requires the dictionary to have ``mango:EpochPosition`` as dmtype. + This instance can be either the root of the dictionary or it can be one + of the Mango properties if the root object is a mango:MangoObject instance This is a public method which could be extended to support other dmtypes. returns @@ -75,15 +78,28 @@ def build_sky_coord(self): NoMatchingDMTypeError if the SkyCoord instance cannot be built. """ - if self._mivot_instance_dict and self._mivot_instance_dict["dmtype"] == "mango:EpochPosition": + + if self._mivot_instance_dict and self._mivot_instance_dict["dmtype"] == "mango:MangoObject": + property_dock = self._mivot_instance_dict["propertyDock"] + for mango_property in property_dock: + if mango_property["dmtype"] == "mango:EpochPosition": + self._mivot_instance_dict = mango_property + return self._build_sky_coord_from_mango() + raise NoMatchingDMTypeError( + "No INSTANCE with dmtype='mango:EpochPosition' has been found:" + " in the property dock of the MangoObject, " + "cannot build a SkyCoord from annotations") + + elif self._mivot_instance_dict and self._mivot_instance_dict["dmtype"] == "mango:EpochPosition": return self._build_sky_coord_from_mango() raise NoMatchingDMTypeError( "No INSTANCE with dmtype='mango:EpochPosition' has been found:" " cannot build a SkyCoord from annotations") - def _set_year_time_format(self, hk_field, besselian=False): + def _get_time_instance(self, hk_field, besselian=False): """ Format a date expressed in year as [scale]year + - Exception possibly risen by Astropy are not caught parameters ---------- @@ -94,33 +110,96 @@ def _set_year_time_format(self, hk_field, besselian=False): returns ------- - string or None - attribute value formatted as [scale]year + Time instance or None + + raise + ----- + MappingError: if the Time instance cannot be built for some reason """ - scale = "J" if not besselian else "B" # Process complex type "mango:DateTime - # only "year" representation are supported yet if hk_field['dmtype'] == "mango:DateTime": representation = hk_field['representation']['value'] timestamp = hk_field['dateTime']['value'] - if representation == "year": - return f"{scale}{timestamp}" + # Process simple attribute + else: + representation = hk_field.get("unit") + timestamp = hk_field.get("value") + + if not representation or not timestamp: + raise MappingError(f"Cannot interpret field {hk_field} " + f"as a {('besselian' if besselian else 'julian')} timestamp") + + time_instance = self. _build_time_instance(timestamp, representation, besselian) + if not time_instance: + raise MappingError(f"Cannot build a Time instance from {hk_field}") + + return time_instance + + def _build_time_instance(self, timestamp, representation, besselian=False): + """ + Build a Time instance matching the input parameters. + - Returns None if the parameters do not allow any Time setup + - Exception possibly risen by Astropy are not caught at this level + + parameters + ---------- + timestamp: string or number + The timestamp must comply with the given representation + representation: string + year, iso, ... (See MANGO primitive types derived from ivoa:timeStamp) + besselian: boolean (optional) + Flag telling to use the besselain calendar. We assume it to only be + relevant for FK5 frame + returns + ------- + Time instance or None + """ + if representation in ["year", "yr"]: + # it the timestamp is numeric, we infer its format from the besselian flag + if isinstance(timestamp, numbers.Number): + return Time(f"{('B' if besselian else 'J')}{timestamp}", + format=("byear_str" if besselian else "jyear_str")) + if besselian: + if timestamp.startswith("B"): + return Time(f"{timestamp}", format="byear_str") + elif timestamp.startswith("J"): + # a besselain year cannot be given as "Jxxxx" + return None + elif timestamp.isnumeric(): + # we force the string representation not to break the test assertions + return Time(f"B{timestamp}", format="byear_str") + else: + if timestamp.startswith("J"): + return Time(f"{timestamp}", format="jyear_str") + elif timestamp.startswith("B"): + # a julian year cannot be given as "Bxxxx" + return None + elif timestamp.isnumeric(): + # we force the string representation not to break the test assertions + return Time(f"J{timestamp}", format="jyear_str") + # no case matches return None - return (f"{scale}{hk_field['value']}" if hk_field["unit"] in ("yr", "year") - else hk_field["value"]) + # in the following cases, the calendar (B or J) is givent by the besselian flag + # We force to use the string representation to avoid breaking unit tests. + elif representation == "mjd": + time = Time(f"{timestamp}", format="mjd") + return (Time(time.byear_str) if besselian else time) + elif representation == "jd": + time = Time(f"{timestamp}", format="jd") + return (Time(time.byear_str) if besselian else time) + elif representation == "iso": + time = Time(f"{timestamp}", format="iso") + return (Time(time.byear_str) if besselian else time) + + return None - def _get_space_frame(self, obstime=None): + def _get_space_frame(self): """ Build an astropy space frame instance from the MIVOT annotations. - Equinox are supported for FK4/5 - Reference location is not supported - parameters - ---------- - obstime: str - Observation time is given to the space frame builder (this method) because - it must be set by the coordinate system constructor in case of FK4 frame. returns ------- FK2, FK5, ICRS or Galactic @@ -133,14 +212,15 @@ def _get_space_frame(self, obstime=None): if frame == 'fk4': self._map_coord_names = skycoord_param_default if "equinox" in coo_sys: - equinox = self._set_year_time_format(coo_sys["equinox"], True) - return FK4(equinox=equinox, obstime=obstime) + equinox = self._get_time_instance(coo_sys["equinox"], True) + # by FK4 takes obstime=equinox by default + return FK4(equinox=equinox) return FK4() if frame == 'fk5': self._map_coord_names = skycoord_param_default if "equinox" in coo_sys: - equinox = self._set_year_time_format(coo_sys["equinox"]) + equinox = self._get_time_instance(coo_sys["equinox"]) return FK5(equinox=equinox) return FK5() @@ -153,9 +233,7 @@ def _get_space_frame(self, obstime=None): def _build_sky_coord_from_mango(self): """ - Build silently a SkyCoord instance from the ``mango:EpochPosition instance``. - No error is trapped, unconsistencies in the ``mango:EpochPosition`` instance will - raise Astropy errors. + Build a SkyCoord instance from the ``mango:EpochPosition instance``. - The epoch (obstime) is meant to be given in year. - ICRS frame is taken by default @@ -170,26 +248,31 @@ def _build_sky_coord_from_mango(self): kwargs = {} kwargs["frame"] = self._get_space_frame() - for key, value in self._map_coord_names.items(): - # ignore not set parameters - if key not in self._mivot_instance_dict: + for mango_role, skycoord_field in self._map_coord_names.items(): + # ignore not mapped parameters + if mango_role not in self._mivot_instance_dict: continue - hk_field = self._mivot_instance_dict[key] - # format the observation time (J-year by default) - if value == "obstime": - # obstime must be set into the KK4 frame but not as an input parameter - fobstime = self._set_year_time_format(hk_field) - if isinstance(kwargs["frame"], FK4): - kwargs["frame"] = self._get_space_frame(obstime=fobstime) + hk_field = self._mivot_instance_dict[mango_role] + if mango_role == "obsDate": + besselian = isinstance(kwargs["frame"], FK4) + fobstime = self._get_time_instance(hk_field, + besselian=besselian) + # FK4 class has an obstime attribute which must be set at instanciation time + if besselian: + kwargs["frame"] = FK4(equinox=kwargs["frame"].equinox, obstime=fobstime) + # This is not the case for any other space frames else: - kwargs[value] = fobstime - # Convert the parallax (mango) into a distance - elif value == "distance": - kwargs[value] = (hk_field["value"] - * u.Unit(hk_field["unit"]).to(u.parsec, equivalencies=u.parallax())) - kwargs[value] = kwargs[value] * u.parsec - elif "unit" in hk_field and hk_field["unit"]: - kwargs[value] = hk_field["value"] * u.Unit(hk_field["unit"]) - else: - kwargs[value] = hk_field["value"] + kwargs[skycoord_field] = fobstime + # ignore not set parameters + elif (hk_value := hk_field["value"]) is not None: + # Convert the parallax (mango) into a distance + if skycoord_field == "distance": + kwargs[skycoord_field] = (hk_value + * u.Unit(hk_field["unit"]).to(u.parsec, equivalencies=u.parallax())) + kwargs[skycoord_field] = kwargs[skycoord_field] * u.parsec + elif "unit" in hk_field and hk_field["unit"]: + kwargs[skycoord_field] = hk_value * u.Unit(hk_field["unit"]) + else: + kwargs[skycoord_field] = hk_value + return SkyCoord(**kwargs) diff --git a/pyvo/mivot/tests/__init__.py b/pyvo/mivot/tests/__init__.py index eb1d89d1..37810e4f 100644 --- a/pyvo/mivot/tests/__init__.py +++ b/pyvo/mivot/tests/__init__.py @@ -31,7 +31,8 @@ def check_output(self, want, got): bool True if the two XML outputs are equal, False otherwise. """ - return self._format_xml(want.strip()) == self._format_xml(got.strip()) + return (self._format_xml(want.strip()) + == self._format_xml(got.strip())) def output_difference(self, want, got): """ @@ -121,7 +122,8 @@ def assertXmltreeEqualsFile(xmltree1, xmltree2_file): The path to the file containing the second XML tree. """ xmltree2 = XMLOutputChecker.xmltree_from_file(xmltree2_file).getroot() - xml_str1 = etree.tostring(xmltree1).decode("utf-8") - xml_str2 = etree.tostring(xmltree2).decode("utf-8") + xml_str1 = etree.tostring(xmltree1).decode("utf-8").strip() + xml_str2 = etree.tostring(xmltree2).decode("utf-8").strip() checker = XMLOutputChecker() + assert checker.check_output(xml_str1, xml_str2), f"XML trees differ:\n{xml_str1}\n---\n{xml_str2}" diff --git a/pyvo/mivot/tests/data/simbad-cone-mivot.xml b/pyvo/mivot/tests/data/simbad-cone-mivot.xml new file mode 100644 index 00000000..e101aa0e --- /dev/null +++ b/pyvo/mivot/tests/data/simbad-cone-mivot.xml @@ -0,0 +1,160 @@ + + + +IVOID of the service specification +Used access protocol and version +Data publisher +HTTP request URL +Query execution date +Publisher email address +Successful query + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +Distance (in degrees) between this object and the cone-search's target. + + +Main identifier for an object + + + +Right ascension + + +Declination + + +Object type + + + + +Coordinate error major axis + + +Coordinate error minor axis + + +Coordinate error angle + + +Proper motion in RA + + +Proper motion in DEC + + +Proper motion error major axis + + +Proper motion error minor axis + + +Proper motion error angle + + +Parallax + + +Radial Velocity + + +Angular size major axis + + +Angular size minor axis + + +Galaxy ellipse angle + + +MK spectral type + + +Morphological type + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
1.0409523503457668E-5NAME Barnard's Star c269.452076958619004.69336496657667Planet 17 57 48.4984700683 +04 41 36.1138796750.02620.029090-801.55110362.3940.0320.03690546.97591
+Truncated result + + diff --git a/pyvo/mivot/tests/test_sky_coord_builder.py b/pyvo/mivot/tests/test_sky_coord_builder.py index b38062a4..27bf4e42 100644 --- a/pyvo/mivot/tests/test_sky_coord_builder.py +++ b/pyvo/mivot/tests/test_sky_coord_builder.py @@ -7,10 +7,16 @@ for the output of this service. ''' import pytest +from astropy.utils.data import get_pkg_data_filename from pyvo.mivot.version_checker import check_astropy_version from pyvo.mivot.viewer.mivot_instance import MivotInstance from pyvo.mivot.features.sky_coord_builder import SkyCoordBuilder from pyvo.mivot.utils.exceptions import NoMatchingDMTypeError +from pyvo.mivot.viewer.mivot_viewer import MivotViewer +from pyvo.utils import activate_features + +# Enable MIVOT-specific features in the pyvo library +activate_features("MIVOT") # annotations generated by Vizier as given to the MivotInstance vizier_dict = { @@ -218,7 +224,7 @@ def test_vizier_output(): @pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") def test_vizier_output_with_equinox_and_parallax(): - """Test the SkyCoord issued from the modofier Vizier response * + """Test the SkyCoord issued from the modified Vizier response * (parallax added and FK5 + Equinox frame) """ mivot_instance = MivotInstance(**vizier_equin_dict) @@ -233,6 +239,22 @@ def test_vizier_output_with_equinox_and_parallax(): mivot_instance = MivotInstance(**vizier_equin_dict) scoo = mivot_instance.get_SkyCoord() assert (str(scoo).replace("\n", "").replace(" ", "") - == "") + + +@pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") +def test_simad_cs_output(): + """Test the SkyCoord issued from a Simbad SCS response + """ + filename = get_pkg_data_filename('data/simbad-cone-mivot.xml') + m_viewer = MivotViewer(filename, resolve_ref=True) + mivot_instance = m_viewer.dm_instance + scb = SkyCoordBuilder(mivot_instance.to_dict()) + scoo = scb.build_sky_coord() + + assert (str(scoo).replace("\n", "").replace(" ", "") + == "") From 300956682cd87d10791081352c72a0e0b4fdedcc Mon Sep 17 00:00:00 2001 From: lmichel Date: Fri, 26 Sep 2025 16:02:52 +0200 Subject: [PATCH 09/28] minor update to work with the Simbad output conesearch --- pyvo/mivot/features/sky_coord_builder.py | 3 +-- pyvo/mivot/tests/test_sky_coord_builder.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyvo/mivot/features/sky_coord_builder.py b/pyvo/mivot/features/sky_coord_builder.py index aaa59f9b..bfe988a1 100644 --- a/pyvo/mivot/features/sky_coord_builder.py +++ b/pyvo/mivot/features/sky_coord_builder.py @@ -154,7 +154,7 @@ def _build_time_instance(self, timestamp, representation, besselian=False): ------- Time instance or None """ - if representation in ["year", "yr"]: + if representation in ["year", "yr", "y"]: # it the timestamp is numeric, we infer its format from the besselian flag if isinstance(timestamp, numbers.Number): return Time(f"{('B' if besselian else 'J')}{timestamp}", @@ -274,5 +274,4 @@ def _build_sky_coord_from_mango(self): kwargs[skycoord_field] = hk_value * u.Unit(hk_field["unit"]) else: kwargs[skycoord_field] = hk_value - return SkyCoord(**kwargs) diff --git a/pyvo/mivot/tests/test_sky_coord_builder.py b/pyvo/mivot/tests/test_sky_coord_builder.py index 27bf4e42..eb05197c 100644 --- a/pyvo/mivot/tests/test_sky_coord_builder.py +++ b/pyvo/mivot/tests/test_sky_coord_builder.py @@ -258,3 +258,4 @@ def test_simad_cs_output(): == "") + assert str(scoo.obstime) == "J2000.000" \ No newline at end of file From f535b445224f782843fab69ea5ca8b415d4d8037 Mon Sep 17 00:00:00 2001 From: lmichel Date: Fri, 3 Oct 2025 13:52:37 +0200 Subject: [PATCH 10/28] make the MIVOT schema validation optional in order to prevent unit tests to access remote resources --- pyvo/mivot/tests/test_mango_annoter.py | 4 ++-- pyvo/mivot/writer/annotations.py | 2 +- pyvo/mivot/writer/instances_from_models.py | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pyvo/mivot/tests/test_mango_annoter.py b/pyvo/mivot/tests/test_mango_annoter.py index ee78bae6..f01ccbda 100644 --- a/pyvo/mivot/tests/test_mango_annoter.py +++ b/pyvo/mivot/tests/test_mango_annoter.py @@ -152,7 +152,7 @@ def test_all_properties(): add_color(builder) add_photometry(builder) add_epoch_positon(builder) - builder.pack_into_votable() + builder.pack_into_votable(schema_check=False) assert XmlUtils.strip_xml(builder._annotation.mivot_block) == ( XmlUtils.strip_xml(get_pkg_data_contents("data/reference/mango_object.xml")) ) @@ -170,7 +170,7 @@ def test_extraction_from_votable_header(): builder.extract_data_origin() epoch_position_mapping = builder.extract_epoch_position_parameters() builder.add_mango_epoch_position(**epoch_position_mapping) - builder.pack_into_votable() + builder.pack_into_votable(schema_check=False) assert XmlUtils.strip_xml(builder._annotation.mivot_block) == ( XmlUtils.strip_xml(get_pkg_data_contents("data/reference/test_header_extraction.xml")) ) diff --git a/pyvo/mivot/writer/annotations.py b/pyvo/mivot/writer/annotations.py index d1a2e57b..a85279ef 100644 --- a/pyvo/mivot/writer/annotations.py +++ b/pyvo/mivot/writer/annotations.py @@ -170,7 +170,7 @@ def build_mivot_block(self, *, templates_id=None, schema_check=True): ---------- templates_id : str, optional The ID to associate with the block. Defaults to None. - schema_check : boolean, optional + schema_check : boolean, optional (default True) Skip the XSD validation if False (use to make test working in local mode). Raises diff --git a/pyvo/mivot/writer/instances_from_models.py b/pyvo/mivot/writer/instances_from_models.py index f69a5105..bc7fd50b 100644 --- a/pyvo/mivot/writer/instances_from_models.py +++ b/pyvo/mivot/writer/instances_from_models.py @@ -696,7 +696,7 @@ def add_query_origin(self, mapping={}): self._annotation._dmids.append("_origin") return query_origin_instance - def pack_into_votable(self, *, report_msg="", sparse=False): + def pack_into_votable(self, *, report_msg="", sparse=False, schema_check=True): """ Pack all mapped objects in the annotation block and put it in the VOTable. @@ -707,6 +707,9 @@ def pack_into_votable(self, *, report_msg="", sparse=False): sparse: boolean, optional (default to False) If True, all properties are added in a independent way to the the TEMPLATES. They are packed in a MangoObject otherwise. + schema_check: boolean, optional (default to True) + If True the MIVOT block is validated against its schema. + This may test failing due to remote accesses. """ self._annotation.set_report(True, report_msg) if sparse is True: @@ -718,5 +721,5 @@ def pack_into_votable(self, *, report_msg="", sparse=False): self._annotation.add_templates(self._mango_instance.get_mango_object( with_origin=("_origin" in self._annotation._dmids))) - self._annotation.build_mivot_block() + self._annotation.build_mivot_block(schema_check=schema_check) self._annotation.insert_into_votable(self._votable, override=True) From c7ca9f093c0fad01b7597db619c968ca34d68906 Mon Sep 17 00:00:00 2001 From: lmichel Date: Fri, 3 Oct 2025 13:53:08 +0200 Subject: [PATCH 11/28] fix a big mistake in the parallax to distance computation --- pyvo/mivot/features/sky_coord_builder.py | 5 ++--- pyvo/mivot/tests/test_sky_coord_builder.py | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pyvo/mivot/features/sky_coord_builder.py b/pyvo/mivot/features/sky_coord_builder.py index bfe988a1..2e5d0bd9 100644 --- a/pyvo/mivot/features/sky_coord_builder.py +++ b/pyvo/mivot/features/sky_coord_builder.py @@ -267,9 +267,8 @@ def _build_sky_coord_from_mango(self): elif (hk_value := hk_field["value"]) is not None: # Convert the parallax (mango) into a distance if skycoord_field == "distance": - kwargs[skycoord_field] = (hk_value - * u.Unit(hk_field["unit"]).to(u.parsec, equivalencies=u.parallax())) - kwargs[skycoord_field] = kwargs[skycoord_field] * u.parsec + kwargs[skycoord_field] = ( + (hk_value * u.Unit(hk_field["unit"])).to(u.parsec, equivalencies=u.parallax())) elif "unit" in hk_field and hk_field["unit"]: kwargs[skycoord_field] = hk_value * u.Unit(hk_field["unit"]) else: diff --git a/pyvo/mivot/tests/test_sky_coord_builder.py b/pyvo/mivot/tests/test_sky_coord_builder.py index eb05197c..5ddbb04c 100644 --- a/pyvo/mivot/tests/test_sky_coord_builder.py +++ b/pyvo/mivot/tests/test_sky_coord_builder.py @@ -232,7 +232,7 @@ def test_vizier_output_with_equinox_and_parallax(): scoo = scb.build_sky_coord() assert (str(scoo).replace("\n", "").replace(" ", "") == "") vizier_equin_dict["spaceSys"]["frame"]["spaceRefFrame"]["value"] = "FK4" @@ -240,7 +240,7 @@ def test_vizier_output_with_equinox_and_parallax(): scoo = mivot_instance.get_SkyCoord() assert (str(scoo).replace("\n", "").replace(" ", "") == "") @@ -256,6 +256,6 @@ def test_simad_cs_output(): assert (str(scoo).replace("\n", "").replace(" ", "") == "") - assert str(scoo.obstime) == "J2000.000" \ No newline at end of file + assert str(scoo.obstime) == "J2000.000" From 50dd9340abce416b9e0baf4916b1f323576b767c Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Mon, 6 Oct 2025 13:46:05 +0200 Subject: [PATCH 12/28] mapping EpochPostion toSkyCoord moved frep SkyCoordBuilder to here --- pyvo/mivot/glossary.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/pyvo/mivot/glossary.py b/pyvo/mivot/glossary.py index 3950dedd..59c774c2 100644 --- a/pyvo/mivot/glossary.py +++ b/pyvo/mivot/glossary.py @@ -41,7 +41,8 @@ class Roles: correspond to the last path element of the ``dmroles`` as defined in VODML (VODML-ID) """ - #: Roles of the EpochPosition class that are supported + # Roles of the EpochPosition class that are supported + # Do not change the ordering: it used below to access fields EpochPosition = [ "longitude", "latitude", @@ -191,3 +192,22 @@ class EpochPositionAutoMapping: parallax = ["pos.parallax.trig"] #: first word of UCD-s accepted to map the radial velocity radialVelocity = "spect.dopplerVeloc.opt" + +class SkyCoordMapping: + """ + Mapping of the MANGO:EpochPositiob parameters to the SkyCoord parameters + """ + default_params = { + Roles.EpochPosition[0]: 'ra', Roles.EpochPosition[1]: 'dec', + Roles.EpochPosition[2]: 'distance', + Roles.EpochPosition[3]: 'radial_velocity', + Roles.EpochPosition[4]: 'pm_ra_cosdec', Roles.EpochPosition[5]: 'pm_dec', + Roles.EpochPosition[6]: 'obstime'} + + galactic_params = { + Roles.EpochPosition[0]: 'l', Roles.EpochPosition[1]: 'b', + Roles.EpochPosition[2]: 'distance', + Roles.EpochPosition[3]: 'radial_velocity', + Roles.EpochPosition[4]: 'pm_l_cosb', Roles.EpochPosition[5]: 'pm_b', + Roles.EpochPosition[6]: 'obstime'} + From 74e9c45df7745fe1206ef5d71a9d377404ca2f52 Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Mon, 6 Oct 2025 13:47:23 +0200 Subject: [PATCH 13/28] use of the glossary for the skyCoord fied mapping - improve the tyest coverage --- pyvo/mivot/features/sky_coord_builder.py | 37 +++------------------- pyvo/mivot/tests/test_sky_coord_builder.py | 32 +++++++++++++++++-- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/pyvo/mivot/features/sky_coord_builder.py b/pyvo/mivot/features/sky_coord_builder.py index 2e5d0bd9..87fe2dda 100644 --- a/pyvo/mivot/features/sky_coord_builder.py +++ b/pyvo/mivot/features/sky_coord_builder.py @@ -7,37 +7,10 @@ from astropy import units as u from astropy.coordinates import ICRS, Galactic, FK4, FK5 from astropy.time.core import Time +from pyvo.mivot.glossary import SkyCoordMapping from pyvo.mivot.utils.exceptions import NoMatchingDMTypeError, MappingError -class MangoRoles: - """ - Place holder for the roles (attribute names) of the mango:EpochPosition class - """ - LONGITUDE = "longitude" - LATITUDE = "latitude" - PM_LONGITUDE = "pmLongitude" - PM_LATITUDE = "pmLatitude" - PARALLAX = "parallax" - RADIAL_VELOCITY = "radialVelocity" - EPOCH = "obsDate" - FRAME = "frame" - EQUINOX = "equinox" - PMCOSDELTAPPLIED = "pmCosDeltApplied" - - -# Mapping of the MANGO parameters on the SkyCoord parameters -skycoord_param_default = { - MangoRoles.LONGITUDE: 'ra', MangoRoles.LATITUDE: 'dec', MangoRoles.PARALLAX: 'distance', - MangoRoles.PM_LONGITUDE: 'pm_ra_cosdec', MangoRoles.PM_LATITUDE: 'pm_dec', - MangoRoles.RADIAL_VELOCITY: 'radial_velocity', MangoRoles.EPOCH: 'obstime'} - -skycoord_param_galactic = { - MangoRoles.LONGITUDE: 'l', MangoRoles.LATITUDE: 'b', MangoRoles.PARALLAX: 'distance', - MangoRoles.PM_LONGITUDE: 'pm_l_cosb', MangoRoles.PM_LATITUDE: 'pm_b', - MangoRoles.RADIAL_VELOCITY: 'radial_velocity', MangoRoles.EPOCH: 'obstime'} - - class SkyCoordBuilder: ''' Utility generating SkyCoord instances from MIVOT annotations @@ -210,7 +183,7 @@ def _get_space_frame(self): frame = coo_sys["spaceRefFrame"]["value"].lower() if frame == 'fk4': - self._map_coord_names = skycoord_param_default + self._map_coord_names = SkyCoordMapping.default_params if "equinox" in coo_sys: equinox = self._get_time_instance(coo_sys["equinox"], True) # by FK4 takes obstime=equinox by default @@ -218,17 +191,17 @@ def _get_space_frame(self): return FK4() if frame == 'fk5': - self._map_coord_names = skycoord_param_default + self._map_coord_names = SkyCoordMapping.default_params if "equinox" in coo_sys: equinox = self._get_time_instance(coo_sys["equinox"]) return FK5(equinox=equinox) return FK5() if frame == 'galactic': - self._map_coord_names = skycoord_param_galactic + self._map_coord_names = SkyCoordMapping.galactic_params return Galactic() - self._map_coord_names = skycoord_param_default + self._map_coord_names = SkyCoordMapping.default_params return ICRS() def _build_sky_coord_from_mango(self): diff --git a/pyvo/mivot/tests/test_sky_coord_builder.py b/pyvo/mivot/tests/test_sky_coord_builder.py index 5ddbb04c..01f7000e 100644 --- a/pyvo/mivot/tests/test_sky_coord_builder.py +++ b/pyvo/mivot/tests/test_sky_coord_builder.py @@ -7,6 +7,7 @@ for the output of this service. ''' import pytest +from copy import deepcopy from astropy.utils.data import get_pkg_data_filename from pyvo.mivot.version_checker import check_astropy_version from pyvo.mivot.viewer.mivot_instance import MivotInstance @@ -234,9 +235,11 @@ def test_vizier_output_with_equinox_and_parallax(): == "") + - vizier_equin_dict["spaceSys"]["frame"]["spaceRefFrame"]["value"] = "FK4" - mivot_instance = MivotInstance(**vizier_equin_dict) + mydict = deepcopy(vizier_equin_dict) + mydict["spaceSys"]["frame"]["spaceRefFrame"]["value"] = "FK4" + mivot_instance = MivotInstance(**mydict) scoo = mivot_instance.get_SkyCoord() assert (str(scoo).replace("\n", "").replace(" ", "") == "") assert str(scoo.obstime) == "J2000.000" + +def test_time_representation(): + """ + Test various time representations + Inconsistent values are not tested since there are detected by ``astropy.core.Time`` + """ + # work with a copy to not alter other test functions + mydict = deepcopy(vizier_equin_dict) + mydict["obsDate"]["unit"] = "mjd" + scb = SkyCoordBuilder(mydict) + scoo = scb.build_sky_coord() + assert scoo.obstime.jyear_str == "J1864.331" + + mydict["obsDate"]["unit"] = "jd" + mydict["obsDate"]["value"] = "2460937.36" + scb = SkyCoordBuilder(mydict) + scoo = scb.build_sky_coord() + assert scoo.obstime.jyear_str == "J2025.715" + + mydict["obsDate"]["unit"] = "iso" + mydict["obsDate"]["value"] = "2025-05-03" + scoo = scb.build_sky_coord() + scb = SkyCoordBuilder(mydict) + assert scoo.obstime.jyear_str == "J2025.335" + From e7dcf132b0a1d78a602eab6ca7277afea9b7bdca Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Mon, 6 Oct 2025 13:46:05 +0200 Subject: [PATCH 14/28] mapping EpochPostion to SkyCoord moved from SkyCoordBuilder to the Glossary - improve the test coverage --- pyvo/mivot/features/sky_coord_builder.py | 37 +++------------------- pyvo/mivot/glossary.py | 22 ++++++++++++- pyvo/mivot/tests/test_sky_coord_builder.py | 32 +++++++++++++++++-- 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/pyvo/mivot/features/sky_coord_builder.py b/pyvo/mivot/features/sky_coord_builder.py index 2e5d0bd9..87fe2dda 100644 --- a/pyvo/mivot/features/sky_coord_builder.py +++ b/pyvo/mivot/features/sky_coord_builder.py @@ -7,37 +7,10 @@ from astropy import units as u from astropy.coordinates import ICRS, Galactic, FK4, FK5 from astropy.time.core import Time +from pyvo.mivot.glossary import SkyCoordMapping from pyvo.mivot.utils.exceptions import NoMatchingDMTypeError, MappingError -class MangoRoles: - """ - Place holder for the roles (attribute names) of the mango:EpochPosition class - """ - LONGITUDE = "longitude" - LATITUDE = "latitude" - PM_LONGITUDE = "pmLongitude" - PM_LATITUDE = "pmLatitude" - PARALLAX = "parallax" - RADIAL_VELOCITY = "radialVelocity" - EPOCH = "obsDate" - FRAME = "frame" - EQUINOX = "equinox" - PMCOSDELTAPPLIED = "pmCosDeltApplied" - - -# Mapping of the MANGO parameters on the SkyCoord parameters -skycoord_param_default = { - MangoRoles.LONGITUDE: 'ra', MangoRoles.LATITUDE: 'dec', MangoRoles.PARALLAX: 'distance', - MangoRoles.PM_LONGITUDE: 'pm_ra_cosdec', MangoRoles.PM_LATITUDE: 'pm_dec', - MangoRoles.RADIAL_VELOCITY: 'radial_velocity', MangoRoles.EPOCH: 'obstime'} - -skycoord_param_galactic = { - MangoRoles.LONGITUDE: 'l', MangoRoles.LATITUDE: 'b', MangoRoles.PARALLAX: 'distance', - MangoRoles.PM_LONGITUDE: 'pm_l_cosb', MangoRoles.PM_LATITUDE: 'pm_b', - MangoRoles.RADIAL_VELOCITY: 'radial_velocity', MangoRoles.EPOCH: 'obstime'} - - class SkyCoordBuilder: ''' Utility generating SkyCoord instances from MIVOT annotations @@ -210,7 +183,7 @@ def _get_space_frame(self): frame = coo_sys["spaceRefFrame"]["value"].lower() if frame == 'fk4': - self._map_coord_names = skycoord_param_default + self._map_coord_names = SkyCoordMapping.default_params if "equinox" in coo_sys: equinox = self._get_time_instance(coo_sys["equinox"], True) # by FK4 takes obstime=equinox by default @@ -218,17 +191,17 @@ def _get_space_frame(self): return FK4() if frame == 'fk5': - self._map_coord_names = skycoord_param_default + self._map_coord_names = SkyCoordMapping.default_params if "equinox" in coo_sys: equinox = self._get_time_instance(coo_sys["equinox"]) return FK5(equinox=equinox) return FK5() if frame == 'galactic': - self._map_coord_names = skycoord_param_galactic + self._map_coord_names = SkyCoordMapping.galactic_params return Galactic() - self._map_coord_names = skycoord_param_default + self._map_coord_names = SkyCoordMapping.default_params return ICRS() def _build_sky_coord_from_mango(self): diff --git a/pyvo/mivot/glossary.py b/pyvo/mivot/glossary.py index 3950dedd..59c774c2 100644 --- a/pyvo/mivot/glossary.py +++ b/pyvo/mivot/glossary.py @@ -41,7 +41,8 @@ class Roles: correspond to the last path element of the ``dmroles`` as defined in VODML (VODML-ID) """ - #: Roles of the EpochPosition class that are supported + # Roles of the EpochPosition class that are supported + # Do not change the ordering: it used below to access fields EpochPosition = [ "longitude", "latitude", @@ -191,3 +192,22 @@ class EpochPositionAutoMapping: parallax = ["pos.parallax.trig"] #: first word of UCD-s accepted to map the radial velocity radialVelocity = "spect.dopplerVeloc.opt" + +class SkyCoordMapping: + """ + Mapping of the MANGO:EpochPositiob parameters to the SkyCoord parameters + """ + default_params = { + Roles.EpochPosition[0]: 'ra', Roles.EpochPosition[1]: 'dec', + Roles.EpochPosition[2]: 'distance', + Roles.EpochPosition[3]: 'radial_velocity', + Roles.EpochPosition[4]: 'pm_ra_cosdec', Roles.EpochPosition[5]: 'pm_dec', + Roles.EpochPosition[6]: 'obstime'} + + galactic_params = { + Roles.EpochPosition[0]: 'l', Roles.EpochPosition[1]: 'b', + Roles.EpochPosition[2]: 'distance', + Roles.EpochPosition[3]: 'radial_velocity', + Roles.EpochPosition[4]: 'pm_l_cosb', Roles.EpochPosition[5]: 'pm_b', + Roles.EpochPosition[6]: 'obstime'} + diff --git a/pyvo/mivot/tests/test_sky_coord_builder.py b/pyvo/mivot/tests/test_sky_coord_builder.py index 5ddbb04c..01f7000e 100644 --- a/pyvo/mivot/tests/test_sky_coord_builder.py +++ b/pyvo/mivot/tests/test_sky_coord_builder.py @@ -7,6 +7,7 @@ for the output of this service. ''' import pytest +from copy import deepcopy from astropy.utils.data import get_pkg_data_filename from pyvo.mivot.version_checker import check_astropy_version from pyvo.mivot.viewer.mivot_instance import MivotInstance @@ -234,9 +235,11 @@ def test_vizier_output_with_equinox_and_parallax(): == "") + - vizier_equin_dict["spaceSys"]["frame"]["spaceRefFrame"]["value"] = "FK4" - mivot_instance = MivotInstance(**vizier_equin_dict) + mydict = deepcopy(vizier_equin_dict) + mydict["spaceSys"]["frame"]["spaceRefFrame"]["value"] = "FK4" + mivot_instance = MivotInstance(**mydict) scoo = mivot_instance.get_SkyCoord() assert (str(scoo).replace("\n", "").replace(" ", "") == "") assert str(scoo.obstime) == "J2000.000" + +def test_time_representation(): + """ + Test various time representations + Inconsistent values are not tested since there are detected by ``astropy.core.Time`` + """ + # work with a copy to not alter other test functions + mydict = deepcopy(vizier_equin_dict) + mydict["obsDate"]["unit"] = "mjd" + scb = SkyCoordBuilder(mydict) + scoo = scb.build_sky_coord() + assert scoo.obstime.jyear_str == "J1864.331" + + mydict["obsDate"]["unit"] = "jd" + mydict["obsDate"]["value"] = "2460937.36" + scb = SkyCoordBuilder(mydict) + scoo = scb.build_sky_coord() + assert scoo.obstime.jyear_str == "J2025.715" + + mydict["obsDate"]["unit"] = "iso" + mydict["obsDate"]["value"] = "2025-05-03" + scoo = scb.build_sky_coord() + scb = SkyCoordBuilder(mydict) + assert scoo.obstime.jyear_str == "J2025.335" + From a4c103dff5ccc5dc362f720421bb6ecf9c5cfb3c Mon Sep 17 00:00:00 2001 From: lmichel Date: Tue, 7 Oct 2025 14:09:54 +0200 Subject: [PATCH 15/28] Use of local copies of VOTable schemas to make XML validation working with local resources only --- pyvo/mivot/writer/mivot-v1.xsd | 6 +- pyvo/mivot/writer/v1.1.xsd | 471 ++++++++++++++++++++++++ pyvo/mivot/writer/v1.2.xsd | 562 +++++++++++++++++++++++++++++ pyvo/mivot/writer/v1.3.xsd | 635 +++++++++++++++++++++++++++++++++ 4 files changed, 1671 insertions(+), 3 deletions(-) create mode 100644 pyvo/mivot/writer/v1.1.xsd create mode 100644 pyvo/mivot/writer/v1.2.xsd create mode 100644 pyvo/mivot/writer/v1.3.xsd diff --git a/pyvo/mivot/writer/mivot-v1.xsd b/pyvo/mivot/writer/mivot-v1.xsd index ae80f5ba..28d19e09 100644 --- a/pyvo/mivot/writer/mivot-v1.xsd +++ b/pyvo/mivot/writer/mivot-v1.xsd @@ -9,9 +9,9 @@ - - - + + + diff --git a/pyvo/mivot/writer/v1.1.xsd b/pyvo/mivot/writer/v1.1.xsd new file mode 100644 index 00000000..7755bacd --- /dev/null +++ b/pyvo/mivot/writer/v1.1.xsd @@ -0,0 +1,471 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Deprecated in Version 1.1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyvo/mivot/writer/v1.2.xsd b/pyvo/mivot/writer/v1.2.xsd new file mode 100644 index 00000000..810c4491 --- /dev/null +++ b/pyvo/mivot/writer/v1.2.xsd @@ -0,0 +1,562 @@ + + + + + VOTable1.2 is meant to serialize tabular documents in the + context of Virtual Observatory applications. This schema + corresponds to the VOTable document available from + http://www.ivoa.net/Documents/latest/VOT.html + + + + + + + + + + + + + + + + + + + + Accept UCD1+ + Accept also old UCD1 (but not / + %) including SIAP convention (with :) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + content-role was previsouly restricted as: + + + + + + + + + ]]>; is now a name token. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Deprecated in Version 1.2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Deprecated in Version 1.1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Added in Version 1.2: INFO for diagnostics + + + + + + + + + + + + + + + + + + + + + + + + The 'encoding' attribute is added here to avoid + problems of code generators which do not properly + interpret the TR/TD structures. + 'encoding' was chosen because it appears in + appendix A.5 + + + + + + + + + The ID attribute is added here to the TR tag to avoid + problems of code generators which do not properly + interpret the TR/TD structures + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Added in Version 1.2: INFO for diagnostics + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Added in Version 1.2: INFO for diagnostics in several places + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyvo/mivot/writer/v1.3.xsd b/pyvo/mivot/writer/v1.3.xsd new file mode 100644 index 00000000..0979a2a3 --- /dev/null +++ b/pyvo/mivot/writer/v1.3.xsd @@ -0,0 +1,635 @@ + + + + + VOTable is meant to serialize tabular documents in the + context of Virtual Observatory applications. This schema + corresponds to the VOTable document available from + http://www.ivoa.net/Documents/latest/VOT.html + + + + + + + + + + + + + + + + + + + + Accept UCD1+ + Accept also old UCD1 (but not / + %) including SIAP convention (with :) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + content-role was previsouly restricted as: + + + + + + + + + ]]>; is now a token. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Values for this attribute must be taken from the IVOA + refframe vocabulary, http://www.ivoa.net/rdf/refframe + + + + + + + The reference position SHOULD be taken from the IVOA + refposition vocabulary (http://www.ivoa.net/rdf/refposition). + + + + + + + + + + + This is a time origin of a time coordinate, given as a + Julian Date for the the time scale and reference point + defined. It is usually given as a floating point + literal; for convenience, the magic strings “MJD-origin” + (standing for 2400000.5) and “JD-origin” (standing for 0) + are also allowed. + + + + + + + + + + + + + + + + The time origin is the offset or the time coordinate to Julian + Date. The timeorigin attribute MUST be given unless the time's + representation contains a year of a calendar era, in which case it + MUST NOT be present. + + + + + + + This is the time scale used. Values SHOULD be + taken from the IVOA timescale vocabulary (http://www.ivoa.net/rdf/timescale). + + + + + + + The reference position SHOULD be taken from the IVOA + refposition vocabulary (http://www.ivoa.net/rdf/refposition). + + + + + + + + + + Deprecated in Version 1.1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Added in Version 1.2: INFO for diagnostics + + + + + + + + + + + + + + + + + + + + + + + + + The 'encoding' attribute is added here to avoid + problems of code generators which do not properly + interpret the TR/TD structures. + 'encoding' was chosen because it appears in + appendix A.5 + + + + + + + + + The ID attribute is added here to the TR tag to avoid + problems of code generators which do not properly + interpret the TR/TD structures + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Added in Version 1.2: INFO for diagnostics + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Added in Version 1.2: INFO for diagnostics in several places + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 822662138e078a36595c53845825f1b02b494360 Mon Sep 17 00:00:00 2001 From: lmichel Date: Tue, 7 Oct 2025 14:26:03 +0200 Subject: [PATCH 16/28] update doc strings- remove useless methods - make some minor fixes --- pyvo/mivot/tests/test_mivot_viewer.py | 20 +++---- pyvo/mivot/viewer/mivot_viewer.py | 81 +++++++++------------------ 2 files changed, 37 insertions(+), 64 deletions(-) diff --git a/pyvo/mivot/tests/test_mivot_viewer.py b/pyvo/mivot/tests/test_mivot_viewer.py index 1447f5e6..01aad9ac 100644 --- a/pyvo/mivot/tests/test_mivot_viewer.py +++ b/pyvo/mivot/tests/test_mivot_viewer.py @@ -168,12 +168,14 @@ def test_get_first_instance_dmtype(path_to_first_instance): used to find the first INSTANCE/COLLECTION in TEMPLATES. """ m_viewer = MivotViewer(votable_path=path_to_first_instance) - assert m_viewer.get_first_instance_dmtype("one_instance") == "one_instance" - assert m_viewer.get_first_instance_dmtype("some_instances") == "first" - with pytest.raises(Exception, match="Can't find the first INSTANCE in TEMPLATES"): - m_viewer.get_first_instance_dmtype("empty") + assert m_viewer.get_dm_instance_dmtypes("one_instance")[0] == "one_instance" + assert m_viewer.get_dm_instance_dmtypes("some_instances")[0] == "first" + + with pytest.raises(Exception, match="Can't find INSTANCE in TEMPLATES"): + m_viewer.get_dm_instance_dmtypes("empty") + with pytest.raises(Exception, match="No TEMPLATES with tableref=not_existing_tableref"): - m_viewer.get_first_instance_dmtype("not_existing_tableref") + m_viewer.get_dm_instance_dmtypes("not_existing_tableref") @pytest.mark.skipif(not check_astropy_version(), reason="need astropy 6+") @@ -204,10 +206,9 @@ def test_global_getters(m_viewer): Test each getter for TEMPLATES of the model_viewer. """ assert m_viewer.get_table_ids() == ['_PKTable', 'Results'] - assert m_viewer.get_globals_models() == DictUtils.read_dict_from_file( + assert m_viewer.get_models() == DictUtils.read_dict_from_file( get_pkg_data_filename("data/reference/globals_models.json")) - assert m_viewer.get_templates_models() == DictUtils.read_dict_from_file( - get_pkg_data_filename("data/reference/templates_models.json")) + m_viewer._connect_table('_PKTable') row = m_viewer.next_table_row() assert row[0] == '5813181197970338560' @@ -228,9 +229,8 @@ def test_no_mivot(path_no_mivot): """ m_viewer = MivotViewer(path_no_mivot) assert m_viewer.get_table_ids() is None - assert m_viewer.get_globals_models() is None + assert m_viewer.get_models() is None - assert m_viewer.get_templates_models() is None with pytest.raises(MappingError): m_viewer._connect_table('_PKTable') with pytest.raises(MappingError): diff --git a/pyvo/mivot/viewer/mivot_viewer.py b/pyvo/mivot/viewer/mivot_viewer.py index 739d2d70..7283af37 100644 --- a/pyvo/mivot/viewer/mivot_viewer.py +++ b/pyvo/mivot/viewer/mivot_viewer.py @@ -61,12 +61,15 @@ def __init__(self, votable_path, tableref=None, resolve_ref=False): Parameters ---------- - votable_path : str, parsed VOTable or DALResults instance - VOTable that will be parsed with the parser of Astropy, - which extracts the annotation block. + DALResults, VOTableFile or votable_path : Reference of the VOTable from which Astropy, + will extracts the annotation block. tableref : str, optional Used to identify the table to process. If not specified, the first table is taken by default. + resolve_ref : boolean + Ask for references between MIVOT instances to be resolved (referenced instances + are copied into the host object). This is usually used to copy the coordinates + systems into the object that uses them. Parameters ---------- resolve_ref : bool, optional @@ -167,7 +170,8 @@ def dm_instance(self): first 'TEMPLATES' child, with the attribute values set according to the values of the current read data row. """ - return self._dm_instances[0] + dm_instances = self._dm_instances + return self.dm_instances[0] if dm_instances else None @property def dm_instances(self): @@ -183,12 +187,15 @@ def dm_instances(self): @property def dm_globals_instances(self): """ - @@@@@@@@@ Returns ------- [MivotInstance]: The list of Python objects (MivotInstance) built from the XML views of - the TEMPLATES children, whose attribute values are set from the values + the GLOBALS children, whose attribute values are set from the values of the current read data row. + This method allows to retrieve the GLOBALS (coordinates systems usually) + even when the viewer is in ``resolve_ref=False`` mode or if the reference + to the coordinates systems have not been setup in the objects representing + the mapped data. """ return self._dm_globals_instances @@ -225,25 +232,6 @@ def get_table_ids(self): return None return self.resource_seeker.get_table_ids() - def get_globals_models(self): - """ - Get collection types in GLOBALS. - Collection types are GLOBALS/COLLECTION/INSTANCE@dmtype: - used for collections of static objects. - - Returns - ------- - dict - A dictionary containing the dmtypes of all the top-level INSTANCE/COLLECTION of GLOBALS. - The structure of the dictionary is {'COLLECTION': [dmtypes], 'INSTANCE': [dmtypes]}. - """ - if self._annotation_seeker is None: - return None - globals_models = {} - globals_models[Ele.COLLECTION] = self._annotation_seeker.get_globals_collection_dmtypes() - globals_models[Ele.INSTANCE] = self._annotation_seeker.get_globals_instance_dmtypes() - return globals_models - def get_models(self): """ Get a dictionary of models and their URLs. @@ -257,24 +245,6 @@ def get_models(self): return None return self._annotation_seeker.get_models() - def get_templates_models(self): - """ - Get dmtypes (except ivoa:..) of all INSTANCE/COLLECTION of all TEMPLATES. - Note: COLLECTION not implemented yet. - - Returns - ------- - dict: A dictionary containing dmtypes of all INSTANCE/COLLECTION of all TEMPLATES. - The format is {'tableref': {'COLLECTIONS': [dmtypes], 'INSTANCE': [dmtypes]}, ...}. - """ - if self._annotation_seeker is None: - return None - templates_models = {} - gni = self._annotation_seeker.get_instance_dmtypes()[Ele.TEMPLATES] - for tid, tmplids in gni.items(): - templates_models[tid] = {Ele.COLLECTION: [], Ele.INSTANCE: tmplids} - return templates_models - def next_table_row(self): """ Iterate once on the table row @@ -294,7 +264,7 @@ def rewind(self): if self._table_iterator: self._table_iterator.rewind() - def get_templates_child_instances(self, tableref=None): + def _get_templates_child_instances(self, tableref=None): """ Returns ------- @@ -306,9 +276,9 @@ def get_templates_child_instances(self, tableref=None): templates_block = self._annotation_seeker.get_templates_block(tableref) return XPath.x_path(templates_block, ".//" + Ele.INSTANCE) - def get_first_instance_dmtype(self, tableref): + def get_dm_instance_dmtypes(self, tableref): """ - Return the dmtype of the head INSTANCE (first TEMPLATES child) of the + Return the dmtypes of the INSTANCEs children of the TEMPLATES block mapping the data table identified by tableref. Parameters @@ -318,23 +288,26 @@ def get_first_instance_dmtype(self, tableref): Returns ------- - string - dmtype of the selected INSTANCE + [string] + list of dmtypes Raises ------ MivotError if no INSTANCE can be found """ + dmtypes = [] templates_block = self._annotation_seeker.get_templates_block(tableref) instances = XPath.x_path(templates_block, ".//" + Ele.INSTANCE) for instance in instances: - return instance.get(Att.dmtype) + dmtypes.append(instance.get(Att.dmtype)) - raise MivotError( - "Can't find the first " + Ele.INSTANCE - + " in " + Ele.TEMPLATES - ) + if not dmtypes: + raise MivotError( + "Can't find " + Ele.INSTANCE + + " in " + Ele.TEMPLATES + ) + return dmtypes def _connect_table(self, tableref=None): """ @@ -419,7 +392,7 @@ def _init_instances(self): """ if not self._dm_instances: self.next_table_row() - xml_instances = self.get_templates_child_instances(self.connected_table_ref) + xml_instances = self._get_templates_child_instances(self.connected_table_ref) self._dm_instances = [] for xml_instance in xml_instances: self._dm_instances.append( From 8d73e8f5090255341b44285b4f7a21b64841c93d Mon Sep 17 00:00:00 2001 From: lmichel Date: Tue, 7 Oct 2025 14:28:10 +0200 Subject: [PATCH 17/28] SkyCoordBuilder can be instantiated from either a dict (for the tests) or from a MivotInstance --- pyvo/mivot/features/sky_coord_builder.py | 11 +++++++---- pyvo/mivot/tests/test_sky_coord_builder.py | 9 ++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/pyvo/mivot/features/sky_coord_builder.py b/pyvo/mivot/features/sky_coord_builder.py index 87fe2dda..927c1588 100644 --- a/pyvo/mivot/features/sky_coord_builder.py +++ b/pyvo/mivot/features/sky_coord_builder.py @@ -21,16 +21,19 @@ class SkyCoordBuilder: it contains the information required to compute the epoch propagation which is a major use-case ''' - def __init__(self, mivot_instance_dict): + def __init__(self, mivot_instance): ''' Constructor parameters ----------- - mivot_instance_dict: viewer.MivotInstance.to_dict() - Internal dictionary of the dynamic Python object generated from the MIVOT block + mivot_instance: dict or MivotInstance + Python object generated from the MIVOT block as either a Pyhon object or a dict ''' - self._mivot_instance_dict = mivot_instance_dict + if isinstance(mivot_instance, dict): + self._mivot_instance_dict = mivot_instance + else: + self._mivot_instance_dict = mivot_instance.to_dict() self._map_coord_names = None def build_sky_coord(self): diff --git a/pyvo/mivot/tests/test_sky_coord_builder.py b/pyvo/mivot/tests/test_sky_coord_builder.py index 01f7000e..21877550 100644 --- a/pyvo/mivot/tests/test_sky_coord_builder.py +++ b/pyvo/mivot/tests/test_sky_coord_builder.py @@ -235,7 +235,6 @@ def test_vizier_output_with_equinox_and_parallax(): == "") - mydict = deepcopy(vizier_equin_dict) mydict["spaceSys"]["frame"]["spaceRefFrame"]["value"] = "FK4" @@ -262,7 +261,8 @@ def test_simad_cs_output(): "(deg, deg, pc)(269.45207696, 4.69336497, 1.82823411) " "(pm_ra_cosdec, pm_dec) in mas / yr(-801.551, 10362.394)>") assert str(scoo.obstime) == "J2000.000" - + + def test_time_representation(): """ Test various time representations @@ -274,16 +274,15 @@ def test_time_representation(): scb = SkyCoordBuilder(mydict) scoo = scb.build_sky_coord() assert scoo.obstime.jyear_str == "J1864.331" - + mydict["obsDate"]["unit"] = "jd" mydict["obsDate"]["value"] = "2460937.36" scb = SkyCoordBuilder(mydict) scoo = scb.build_sky_coord() assert scoo.obstime.jyear_str == "J2025.715" - + mydict["obsDate"]["unit"] = "iso" mydict["obsDate"]["value"] = "2025-05-03" scoo = scb.build_sky_coord() scb = SkyCoordBuilder(mydict) assert scoo.obstime.jyear_str == "J2025.335" - From 0674c74fd0c8203b26da58caadc1127854c4390e Mon Sep 17 00:00:00 2001 From: lmichel Date: Tue, 7 Oct 2025 14:28:25 +0200 Subject: [PATCH 18/28] update test data --- ..._models.json => TRASH.templates_models.json} | 0 .../tests/data/reference/globals_models.json | 17 ++++++----------- 2 files changed, 6 insertions(+), 11 deletions(-) rename pyvo/mivot/tests/data/reference/{templates_models.json => TRASH.templates_models.json} (100%) diff --git a/pyvo/mivot/tests/data/reference/templates_models.json b/pyvo/mivot/tests/data/reference/TRASH.templates_models.json similarity index 100% rename from pyvo/mivot/tests/data/reference/templates_models.json rename to pyvo/mivot/tests/data/reference/TRASH.templates_models.json diff --git a/pyvo/mivot/tests/data/reference/globals_models.json b/pyvo/mivot/tests/data/reference/globals_models.json index cfdfbbde..88c97fa4 100644 --- a/pyvo/mivot/tests/data/reference/globals_models.json +++ b/pyvo/mivot/tests/data/reference/globals_models.json @@ -1,11 +1,6 @@ -{ - "COLLECTION": [ - "coords:TimeSys", - "coords:SpaceSys", - "mango:coordinates.PhotometryCoordSys", - "ds:experiment.ObsDataset" - ], - "INSTANCE": [ - "ds:experiment.Target" - ] -} \ No newline at end of file +{"coords": "https://www.ivoa.net/xml/STC/20200908/Coords-v1.0.vo-dml.xml", + "cube": "https://volute.g-vo.org/svn/trunk/projects/dm/Cube/vo-dml/Cube-1.0.vo-dml.xml", + "ds": "https://volute.g-vo.org/svn/trunk/projects/dm/DatasetMetadata/vo-dml/DatasetMetadata-1.0.vo-dml.xml", + "ivoa": "https://www.ivoa.net/xml/VODML/IVOA-v1.vo-dml.xml", + "mango": "file:/Users/sao/Documents/IVOA/GitHub/ivoa-dm-examples/tmp/Mango-v1.0.vo-dml.xml", + "meas": "https://www.ivoa.net/xml/Meas/20200908/Meas-v1.0.vo-dml.xml"} \ No newline at end of file From 359b04141351ecf39c2a918800a7a7ce1d124b6b Mon Sep 17 00:00:00 2001 From: lmichel Date: Tue, 7 Oct 2025 14:32:34 +0200 Subject: [PATCH 19/28] flake8 - revamp the viewer documentation --- docs/mivot/viewer.rst | 220 +++++++++++++++++-------- pyvo/mivot/glossary.py | 6 +- pyvo/mivot/tests/test_mango_annoter.py | 2 +- pyvo/mivot/viewer/mivot_instance.py | 5 +- 4 files changed, 156 insertions(+), 77 deletions(-) diff --git a/docs/mivot/viewer.rst b/docs/mivot/viewer.rst index 481a8daf..2c8d6f66 100644 --- a/docs/mivot/viewer.rst +++ b/docs/mivot/viewer.rst @@ -28,61 +28,129 @@ Integrated Readout The ``ModelViewer`` module manages access to data mapped to a model through dynamically generated objects (``MivotInstance`` class). -The example below shows how a VOTable result of a cone-search query can be parsed and data -mapped to the ``EpochPosition`` class. - - -.. doctest-remote-data:: - >>> import astropy.units as u - >>> from astropy.coordinates import SkyCoord - >>> from pyvo.dal.scs import SCSService - >>> from pyvo.utils.prototype import activate_features - >>> from pyvo.mivot.version_checker import check_astropy_version - >>> from pyvo.mivot.viewer.mivot_viewer import MivotViewer - >>> activate_features("MIVOT") - >>> if check_astropy_version() is False: - ... pytest.skip("MIVOT test skipped because of the astropy version.") - >>> scs_srv = SCSService("https://vizier.cds.unistra.fr/viz-bin/conesearch/V1.5/I/239/hip_main") - >>> m_viewer = MivotViewer( - ... scs_srv.search( - ... pos=SkyCoord(ra=52.26708 * u.degree, dec=59.94027 * u.degree, frame='icrs'), - ... radius=0.05 - ... ), - ... resolve_ref=True - ... ) - >>> mivot_instance = m_viewer.dm_instance - >>> print(mivot_instance.dmtype) - mango:EpochPosition - >>> print(mivot_instance.spaceSys.frame.spaceRefFrame.value) - ICRS - >>> while m_viewer.next_row_view(): - ... print(f"position: {mivot_instance.latitude.value} {mivot_instance.longitude.value}") - position: 59.94033461 52.26722684 - - -In this example, the data readout is totally managed by the ``MivotViewer`` instance. -The ``astropy.io.votable`` API is encapsulated in this module. - -Model leaves (class attributes) are complex types that provide additional information: +These objects can be used as standard Python instances, with the fields representing model elements. +They can also be used as Python dictionaries (provided by the ``to_dict()`` method), with the keys +representing the model elements. + +The code below, based on the processing of a cone-search query result, demonstrates both uses. + +The first step is to instanciate a viewer that will provide the API for browsing annotations. +The viewer can be built from a VOTable file path, a parsed VOtable (``VOTableFile`` object), +or a ``DALResults`` instance. + +.. code-block:: python + + import astropy.units as u + from astropy.coordinates import SkyCoord + from pyvo.dal.scs import SCSService + from pyvo.utils.prototype import activate_features + from pyvo.mivot.version_checker import check_astropy_version + from pyvo.mivot.viewer.mivot_viewer import MivotViewer + + activate_features("MIVOT") + if check_astropy_version() is False: + pytest.skip("MIVOT test skipped because of the astropy version.") + + scs_srv = SCSService("https://vizier.cds.unistra.fr/viz-bin/conesearch/V1.5/I/239/hip_main") + m_viewer = MivotViewer( + scs_srv.search( + pos=SkyCoord(ra=52.26708 * u.degree, dec=59.94027 * u.degree, + frame='icrs'), + radius=0.05 + ), + resolve_ref=True + ) + +In this example, the query result is mapped to the 'mango:EpochPosition' class, +but users do not need to know this in advance, since the API provides a way +to discover the mapped models. + +.. code-block:: python + + if m_viewer.get_models().get("mango"): + print("data is mapped to the MANGO data model") +.. code-block:: text + + data is mapped to the MANGO data model + +We can also check which classes the data is mapped to. + +.. code-block:: python + + mivot_instances = m_viewer.dm_instances + print(f"data is mapped to {len(mivot_instances)} model class(es)") + mivot_instance = m_viewer.dm_instances[0] + print(f"data is mapped to the {mivot_instance.dmtype} class") +.. code-block:: text + + data is mapped to 1 model clsss(es) + data is mapped to the mango:EpochPosition class + +At this point, we know that the data has been mapped to the ``MANGO`` model, +and that the data rows can be interpreted as instances of the ``mango:EpochPosition``. + +.. code-block:: python + + print(mivot_instance.spaceSys.frame.spaceRefFrame.value) + while m_viewer.next_row_view(): + print(f"position: {mivot_instance.latitude.value} {mivot_instance.longitude.value}") + +.. code-block:: text + + ICRS + position: 59.94033461 52.26722684 + ... + +.. important:: + + The coordinate systems are usually mapped in the GLOBALS MIVOT block. + This allows them to be referenced from any other MIVOT element. + These references are resolved by copying the coordinate system instance + into the host element when the viewer's ``resolve_ref`` flag (constructor parameter) + is True. + +The code below shows how to access GLOBALS instances independently of the mapped data. + +.. code-block:: python + + for globals_instance in globals_instances: + print(globals_instance) + +.. code-block:: json + + { + "dmtype": "coords:SpaceSys", + "dmid": "SpaceFrame_ICRS", + "frame": { + "dmrole": "coords:PhysicalCoordSys.frame", + "dmtype": "coords:SpaceFrame", + "spaceRefFrame": { + "dmtype": "ivoa:string", + "value": "ICRS" + } + } + } + +As you can see from the previous examples, model leaves (class attributes) are complex types. +This is because they contain additional metadata as well as values: - ``value``: attribute value - ``dmtype``: attribute type such as defined in the Mivot annotations - ``unit``: attribute unit such as defined in the Mivot annotations -- ``ref``: identifier of the table column mapped on the attribute The model view on a data row can also be passed as a Python dictionary -using the ``dict`` property of ``MivotInstance``. +using the ``to_dict()`` property of ``MivotInstance``. .. code-block:: python - :caption: Working with a model view as a dictionary - (the JSON layout has been squashed for display purpose) - from pyvo.mivot.utils.dict_utils import DictUtils + from pyvo.mivot.utils.dict_utils import DictUtils mivot_instance = m_viewer.dm_instance - mivot_object_dict = mivot_object.dict - + mivot_object_dict = mivot_object.to_dict() DictUtils.print_pretty_json(mivot_object_dict) + +.. code-block:: json + { "dmtype": "EpochPosition", "longitude": {"value": 359.94372764, "unit": "deg"}, @@ -98,8 +166,10 @@ using the ``dict`` property of ``MivotInstance``. }, } -- It is recommended to use a copy of the - dictionary as it will be rebuilt each time the ``dict`` property is invoked. +The ``to_hk_dict()`` method adds references to the mapped columns to the model leaves. + +- It is recommended to work with deepcopies of the + dictionaries as they are rebuilt each time the ``to_dict()`` property is invoked. - The default representation of ``MivotInstance`` instances is made with a pretty string serialization of this dictionary. @@ -110,7 +180,6 @@ The annotation schema can also be applied to table rows read outside of the ``Mi with the `astropy.io.votable` API: .. code-block:: python - :caption: Accessing the model view of Astropy table rows votable = parse(path_to_votable) table = votable.resources[0].tables[0] @@ -122,29 +191,43 @@ with the `astropy.io.votable` API: for rec in table.array: mivot_object.update(rec) read.append(mivot_object.longitude.value) - # show that the model retrieve the correct data values + # show that the model retrieve the correct values assert rec["RAICRS"] == mivot_object.longitude.value assert rec["DEICRS"] == mivot_object.latitude.value In this case, it is up to the user to ensure that the read data rows are those mapped by the Mivot annotations. -For XML Hackers ---------------- +Mivot/Mango as a direct gateway from data to astropy SkyCoord +------------------------------------------------------------- -The model instances can also be serialized as XML elements that can be parsed with XPath queries. +A straightforward way to make the most of the annotations is to use +them to build Astropy objects directly, without analysing the metadata, +whether from the annotation or the VOTable. .. code-block:: python - :caption: Accessing the XML view of the mapped model instances - with MivotViewer(path_to_votable) as mivot_viewer: - while mivot_viewer.next_row_view(): - xml_view = mivot_viewer.xml_view - # do whatever you want with this XML element + m_viewer.rewind() + while m_viewer.next_row_view(): + sky_coord_builder = SkyCoordBuilder(mivot_instance) + sky_coord = sky_coord_builder.build_sky_coord() + print(sky_coord) + +.. code-block:: text + + + +In the above example, we assume that the mapped model can be used as a ``SkyCoord`` precursor. +If this is not the case, an error is raised. + +.. important:: + + In the current implementation, the gateway only works from ``mango:EpochPosition`` objects + to the ``SkyCoord`` class. In future. We plan to use the same mechanism to instantiate + any property modelled by ``Mango``, as well as potentially other IVOA models. -It is to be noted that ``mivot_viewer.xml_view`` is a shortcut -for ``mivot_viewer.xml_view.view`` where ``mivot_viewer.xml_view`` -is is an instance of ``pyvo.mivot.viewer.XmlViewer``. -This object provides many functions facilitating the XML parsing. Class Generation in a Nutshell ------------------------------ @@ -173,25 +256,24 @@ identifiers, which have the following structure: ``model:a.b``. - Original ``@dmtype`` are kept as attributes of generated Python objects. - The structure of the ``MivotInstance`` objects can be inferred from the mapped model in 2 different ways: - - 1. From the MIVOT instance property ``MivotInstance.dict`` a shown above. + - 1. From the MIVOT instance property ``MivotInstance.to_dict()`` a shown above. This is a pure Python dictionary but its access can be slow because it is generated on the fly each time the property is invoked. - 2. From the internal class dictionary ``MivotInstance.__dict__`` (see the Python `data model `_). - .. code-block:: python - :caption: Exploring the MivotInstance structure with the internal dictionaries +.. code-block:: python - mivot_instance = mivot_viewer.dm_instance + mivot_instance = mivot_viewer.dm_instance - print(mivot_instance.__dict__.keys()) - dict_keys(['dmtype', 'longitude', 'latitude', 'pmLongitude', 'pmLatitude', 'epoch', 'Coordinate_coordSys']) + print(mivot_instance.__dict__.keys()) + dict_keys(['dmtype', 'longitude', 'latitude', 'pmLongitude', 'pmLatitude', 'epoch', 'Coordinate_coordSys']) - print(mivot_instance.Coordinate_coordSys.__dict__.keys()) - dict_keys(['dmtype', 'dmid', 'dmrole', 'spaceRefFrame']) + print(mivot_instance.Coordinate_coordSys.__dict__.keys()) + dict_keys(['dmtype', 'dmid', 'dmrole', 'spaceRefFrame']) - print(mivot_instance.Coordinate_coordSys.spaceRefFrame.__dict__.keys()) - dict_keys(['dmtype', 'value', 'unit', 'ref']) + print(mivot_instance.Coordinate_coordSys.spaceRefFrame.__dict__.keys()) + dict_keys(['dmtype', 'value', 'unit', 'ref']) Reference/API ============= diff --git a/pyvo/mivot/glossary.py b/pyvo/mivot/glossary.py index 59c774c2..e526332a 100644 --- a/pyvo/mivot/glossary.py +++ b/pyvo/mivot/glossary.py @@ -193,6 +193,7 @@ class EpochPositionAutoMapping: #: first word of UCD-s accepted to map the radial velocity radialVelocity = "spect.dopplerVeloc.opt" + class SkyCoordMapping: """ Mapping of the MANGO:EpochPositiob parameters to the SkyCoord parameters @@ -203,11 +204,10 @@ class SkyCoordMapping: Roles.EpochPosition[3]: 'radial_velocity', Roles.EpochPosition[4]: 'pm_ra_cosdec', Roles.EpochPosition[5]: 'pm_dec', Roles.EpochPosition[6]: 'obstime'} - + galactic_params = { Roles.EpochPosition[0]: 'l', Roles.EpochPosition[1]: 'b', Roles.EpochPosition[2]: 'distance', - Roles.EpochPosition[3]: 'radial_velocity', + Roles.EpochPosition[3]: 'radial_velocity', Roles.EpochPosition[4]: 'pm_l_cosb', Roles.EpochPosition[5]: 'pm_b', Roles.EpochPosition[6]: 'obstime'} - diff --git a/pyvo/mivot/tests/test_mango_annoter.py b/pyvo/mivot/tests/test_mango_annoter.py index f01ccbda..6c5c38c7 100644 --- a/pyvo/mivot/tests/test_mango_annoter.py +++ b/pyvo/mivot/tests/test_mango_annoter.py @@ -170,7 +170,7 @@ def test_extraction_from_votable_header(): builder.extract_data_origin() epoch_position_mapping = builder.extract_epoch_position_parameters() builder.add_mango_epoch_position(**epoch_position_mapping) - builder.pack_into_votable(schema_check=False) + builder.pack_into_votable(schema_check=True) assert XmlUtils.strip_xml(builder._annotation.mivot_block) == ( XmlUtils.strip_xml(get_pkg_data_contents("data/reference/test_header_extraction.xml")) ) diff --git a/pyvo/mivot/viewer/mivot_instance.py b/pyvo/mivot/viewer/mivot_instance.py index c642ea95..22bc9a71 100644 --- a/pyvo/mivot/viewer/mivot_instance.py +++ b/pyvo/mivot/viewer/mivot_instance.py @@ -10,7 +10,6 @@ Although attribute values can be changed by users, this class is first meant to provide a convenient access the mapped VOTable data """ -from pyvo.mivot.utils.vocabulary import Constant from pyvo.utils.prototype import prototype_feature from pyvo.mivot.utils.mivot_utils import MivotUtils from pyvo.mivot.utils.dict_utils import DictUtils @@ -75,9 +74,6 @@ def _create_class(self, **kwargs): """ for key, value in kwargs.items(): - # roles are used as key and the first element in a TEMPLATE has no role - if not key: - key = Constant.ROOT_OBJECT if isinstance(value, list): # COLLECTION setattr(self, self._remove_model_name(key), []) for item in value: @@ -186,6 +182,7 @@ def _get_class_dict(self, obj, classkey=None, slim=False, with_dmtypes=True): The serializable dictionary representation of the input. """ + # This case is likely not to occur because MIVOT does not support dictionaries if isinstance(obj, dict): data = {} for (k, v) in obj.items(): From 5c767a252ec94b09cd2c74290fb91dc24f9702e0 Mon Sep 17 00:00:00 2001 From: lmichel Date: Tue, 7 Oct 2025 14:46:29 +0200 Subject: [PATCH 20/28] changelog updated --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 7f5abdfa..47d1a50f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -12,6 +12,9 @@ Enhancements and Fixes - Fix DALOverflowWarning error message to only indicate the cause as limits from user or server. [#689] +- Improve the gateway between annotations and SkyCoord objects, simplify the viewer API (XML accessors removed), + support of mapping with multiple instances per row, revamp the viewer documentation. [#698] + Deprecations and Removals ------------------------- From cb982fb9c95a57664ba7df4aa68da8406e9252a3 Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Mon, 6 Oct 2025 13:47:23 +0200 Subject: [PATCH 21/28] use of the glossary for the skyCoord fied mapping - improve the test coverage --- pyvo/mivot/tests/test_sky_coord_builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyvo/mivot/tests/test_sky_coord_builder.py b/pyvo/mivot/tests/test_sky_coord_builder.py index 21877550..6bcef2d0 100644 --- a/pyvo/mivot/tests/test_sky_coord_builder.py +++ b/pyvo/mivot/tests/test_sky_coord_builder.py @@ -235,6 +235,7 @@ def test_vizier_output_with_equinox_and_parallax(): == "") + mydict = deepcopy(vizier_equin_dict) mydict["spaceSys"]["frame"]["spaceRefFrame"]["value"] = "FK4" From e831a02b24b392b7bfb4bc24035690658dcc7e16 Mon Sep 17 00:00:00 2001 From: lmichel Date: Tue, 7 Oct 2025 15:22:20 +0200 Subject: [PATCH 22/28] solve potential conflicts with astropy/main --- CHANGES.rst | 2 ++ tox.ini | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 47d1a50f..72bf2a65 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -12,6 +12,8 @@ Enhancements and Fixes - Fix DALOverflowWarning error message to only indicate the cause as limits from user or server. [#689] +- Add retry option to AsyncTAPJob.fetch_result for transient failures [#696] + - Improve the gateway between annotations and SkyCoord objects, simplify the viewer API (XML accessors removed), support of mapping with multiple instances per row, revamp the viewer documentation. [#698] diff --git a/tox.ini b/tox.ini index 8c29ceba..4fa94e65 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ # as oldestdeps and devastropy might not support the full python range # listed here envlist = - py{39,310,311,312,313}-test{,-alldeps,-oldestdeps,-devdeps}{,-online}{,-cov} + py{39,310,311,312,313,314}-test{,-alldeps,-oldestdeps,-devdeps}{,-online}{,-cov} linkcheck codestyle build_docs @@ -26,6 +26,8 @@ setenv = PYTEST_ARGS = -rsxf --show-capture=no online: PYTEST_ARGS = --remote-data=any --reruns=1 --reruns-delay 10 -rsxf --show-capture=no devdeps: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/scientific-python-nightly-wheels/simple https://pypi.anaconda.org/liberfa/simple https://pypi.anaconda.org/astropy/simple + # No astropy py314 wheels on pypi yet + py314: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/liberfa/simple https://pypi.anaconda.org/astropy/simple deps = cov: coverage From f7a6d54f47d472dbf6da4cc931228e8cefd27b0f Mon Sep 17 00:00:00 2001 From: lmichel Date: Tue, 7 Oct 2025 15:36:33 +0200 Subject: [PATCH 23/28] stylecheck --- pyvo/mivot/tests/test_sky_coord_builder.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyvo/mivot/tests/test_sky_coord_builder.py b/pyvo/mivot/tests/test_sky_coord_builder.py index 6bcef2d0..21877550 100644 --- a/pyvo/mivot/tests/test_sky_coord_builder.py +++ b/pyvo/mivot/tests/test_sky_coord_builder.py @@ -235,7 +235,6 @@ def test_vizier_output_with_equinox_and_parallax(): == "") - mydict = deepcopy(vizier_equin_dict) mydict["spaceSys"]["frame"]["spaceRefFrame"]["value"] = "FK4" From 97933f43ac9765188d034286f7a03aad143981a1 Mon Sep 17 00:00:00 2001 From: lmichel Date: Tue, 7 Oct 2025 16:27:33 +0200 Subject: [PATCH 24/28] add homework with Simbad --- docs/mivot/example.rst | 184 +++++++++++++++++++++++------------------ 1 file changed, 105 insertions(+), 79 deletions(-) diff --git a/docs/mivot/example.rst b/docs/mivot/example.rst index d2eb8432..2795cf20 100644 --- a/docs/mivot/example.rst +++ b/docs/mivot/example.rst @@ -22,32 +22,32 @@ to tell the server to annotate the queried data. (*Please read the comment inside the code snippet carefully to fully understand the process*) - .. code-block:: python +.. code-block:: python - import pytest - from pyvo.utils import activate_features - from pyvo.dal import TAPService - from pyvo.mivot.utils.xml_utils import XmlUtils - from pyvo.mivot.utils.dict_utils import DictUtils - from pyvo.mivot.viewer.mivot_viewer import MivotViewer + import pytest + from pyvo.utils import activate_features + from pyvo.dal import TAPService + from pyvo.mivot.utils.xml_utils import XmlUtils + from pyvo.mivot.utils.dict_utils import DictUtils + from pyvo.mivot.viewer.mivot_viewer import MivotViewer - # Enable MIVOT-specific features in the pyvo library - activate_features("MIVOT") + # Enable MIVOT-specific features in the pyvo library + activate_features("MIVOT") - service = TAPService('https://xcatdb.unistra.fr/xtapdb') - result = service.run_sync( + service = TAPService('https://xcatdb.unistra.fr/xtapdb') + result = service.run_sync( """ SELECT TOP 5 * FROM "public".mergedentry """, format="application/x-votable+xml;content=mivot" ) - # The MIVOT viewer generates the model view of the data - m_viewer = MivotViewer(result, resolve_ref=True) + # The MIVOT viewer generates the model view of the data + m_viewer = MivotViewer(result, resolve_ref=True) - # Print out the Mivot annotations read out of the VOtable - # This statement is just for a pedagogic purpose (access to a private attribute) - XmlUtils.pretty_print(m_viewer._mapping_block) + # Print out the Mivot annotations read out of the VOtable + # This statement is just for a pedagogic purpose (access to a private attribute) + XmlUtils.pretty_print(m_viewer._mapping_block) In this first step we just queried the service and we built the object that will process the Mivot annotations. @@ -65,46 +65,49 @@ The Mivot block printing output is too long to be listed here. However, the scre At instantiation time, the viewer reads the first data row, which must exist, in order to construct a Python object that reflects the mapped model. - .. code-block:: python +.. code-block:: python - # Build a Python object matching the TEMPLATES content and - # which leaves are set with the values of the first row - mango_object = m_viewer.dm_instance + # Build a Python object matching the TEMPLATES content and + # which leaves are set with the values of the first row + mango_object = m_viewer.dm_instance - # Print out the content of the Python object - # This statement is just for a pedagogic purpose - DictUtils.print_pretty_json(mango_object.to_dict()) + # Print out the content of the Python object + # This statement is just for a pedagogic purpose + DictUtils.print_pretty_json(mango_object.to_dict()) The annotations are consumed by this dynamic Python object which leaves are set with the data of the current row. You can explore the structure of this object by using the printed dictionary or standard object paths as shown below. Now, we can iterate through the table data and retrieve an updated Mivot instance for each row. - .. code-block:: python - - while m_viewer.next_row_view(): - if mango_object.dmtype == "mango:MangoObject": - print(f"Read source {mango_object.identifier.value} {mango_object.dmtype}") - for mango_property in mango_object.propertyDock: - if mango_property.dmtype == "mango:Brightness": - if mango_property.value.value: - mag_value = mango_property.value.value - mag_error = mango_property.error.sigma.value - phot_cal = mango_property.photCal - spectral_location = phot_cal.photometryFilter.spectralLocation - mag_filter = phot_cal.identifier.value - spectral_location = phot_cal.photometryFilter.spectralLocation - mag_wl = spectral_location.value.value - sunit = spectral_location.unitexpression.value - - print(f" flux at {mag_wl} {sunit} (filter {mag_filter}) is {mag_value:.2e} +/- {mag_error:.2e}") - - Read source 4XMM J054329.3-682106 mango:MangoObject +.. code-block:: python + + while m_viewer.next_row_view(): + if mango_object.dmtype == "mango:MangoObject": + print(f"Read source {mango_object.identifier.value} {mango_object.dmtype}") + for mango_property in mango_object.propertyDock: + if mango_property.dmtype == "mango:Brightness": + if mango_property.value.value: + mag_value = mango_property.value.value + mag_error = mango_property.error.sigma.value + phot_cal = mango_property.photCal + spectral_location = phot_cal.photometryFilter.spectralLocation + mag_filter = phot_cal.identifier.value + spectral_location = phot_cal.photometryFilter.spectralLocation + mag_wl = spectral_location.value.value + sunit = spectral_location.unitexpression.value + + print(f" flux at {mag_wl} {sunit} (filter {mag_filter}) is {mag_value:.2e} +/- {mag_error:.2e}") + + +.. code-block:: text + + Read source 4XMM J054329.3-682106 mango:MangoObject flux at 0.35 keV (filter XMM/EPIC/EB1) is 8.35e-14 +/- 3.15e-14 flux at 0.75 keV (filter XMM/EPIC/EB2) is 3.26e-15 +/- 5.45e-15 flux at 6.1 keV (filter XMM/EPIC/EB8) is 8.68e-14 +/- 6.64e-14 - ... - ... + ... + ... The same code can easily be connected with matplotlib to plot SEDs as shown below (code not provided). @@ -113,11 +116,11 @@ The same code can easily be connected with matplotlib to plot SEDs as shown belo :width: 500 :alt: XMM SED -It is to noted that the current table row keeps available through the Mivot viewer. +It is to be noted that the current table row keeps available through the Mivot viewer. - .. code-block:: python +.. code-block:: python - row = m_viewer.table_row + row = m_viewer.table_row .. important:: @@ -148,48 +151,47 @@ which models a full source's astrometry at a given date. In the first step below, we run a standard cone search query by using the standard PyVO API. - .. code-block:: python - - import pytest - import astropy.units as u - from astropy.coordinates import SkyCoord - from pyvo.dal.scs import SCSService +.. code-block:: python - from pyvo.utils import activate_features - from pyvo.mivot.viewer.mivot_viewer import MivotViewer - from pyvo.mivot.features.sky_coord_builder import SkyCoordBuilder - from pyvo.mivot.utils.dict_utils import DictUtils + import pytest + import astropy.units as u + from astropy.coordinates import SkyCoord + from pyvo.dal.scs import SCSService + from pyvo.utils import activate_features + from pyvo.mivot.viewer.mivot_viewer import MivotViewer + from pyvo.mivot.features.sky_coord_builder import SkyCoordBuilder + from pyvo.mivot.utils.dict_utils import DictUtils - # Enable MIVOT-specific features in the pyvo library - activate_features("MIVOT") + # Enable MIVOT-specific features in the pyvo library + activate_features("MIVOT") - scs_srv = SCSService("https://vizier.cds.unistra.fr/viz-bin/conesearch/V1.5/I/239/hip_main") + scs_srv = SCSService("https://vizier.cds.unistra.fr/viz-bin/conesearch/V1.5/I/239/hip_main") - query_result = scs_srv.search( + query_result = scs_srv.search( pos=SkyCoord(ra=52.26708 * u.degree, dec=59.94027 * u.degree, frame='icrs'), radius=0.5) - # The MIVOt viewer generates the model view of the data - m_viewer = MivotViewer(query_result, resolve_ref=True) + # The MIVOt viewer generates the model view of the data + m_viewer = MivotViewer(query_result, resolve_ref=True) Once the query is finished, we can get a reference to the object that will process the Mivot annotations. - .. code-block:: python +.. code-block:: python - # Build a Python object matching the TEMPLATES content and - # which leaves are set with the values of the first row - mango_property = m_viewer.dm_instance + # Build a Python object matching the TEMPLATES content and + # which leaves are set with the values of the first row + mango_property = m_viewer.dm_instance - # Print out the content of the Python object - # This statement is just for a pedagogic purpose - DictUtils.print_pretty_json(mango_property.to_dict()) + # Print out the content of the Python object + # This statement is just for a pedagogic purpose + DictUtils.print_pretty_json(mango_property.to_dict()) The annotations are consumed by this dynamic Python object which leaves are set with the data of the current row. You can explore the structure of this object by using standard object paths or by browsing the dictionary shown below. .. code-block:: json - { + { "dmtype": "mango:EpochPosition", "longitude": { "dmtype": "ivoa:RealQuantity", @@ -234,19 +236,19 @@ You can explore the structure of this object by using standard object paths or b } } } - } + } The reader can transform ``EpochPosition`` instances into ``SkyCoord`` instances. These can then be used for further scientific processing. - .. code-block:: python +.. code-block:: python - while m_viewer.next_row_view(): - if mango_property.dmtype == "mango:EpochPosition": - scb = SkyCoordBuilder(mango_property.to_dict()) - # do whatever process with the SkyCoord object - print(scb.build_sky_coord()) + while m_viewer.next_row_view(): + if mango_property.dmtype == "mango:EpochPosition": + scb = SkyCoordBuilder(mango_property.to_dict()) + # do whatever process with the SkyCoord object + print(scb.build_sky_coord()) .. important:: Similar to the previous example, this code can be used with any VOTable with data mapped to MANGO. @@ -255,5 +257,29 @@ You can explore the structure of this object by using standard object paths or b It avoids the need for users to build SkyCoord objects by hand from VOTable fields, which is never an easy task. +Homework +======== + +Simbad has released (Q3 2025) an annotated version of its Cone Search. +It's a good case to exercise this API. + + +.. code-block:: python + + SERVER = "https://simbad.cds.unistra.fr/cone?" + VERB = 2 + RA = 269.452076* u.degree + DEC = 4.6933649* u.degree + SR = 0.1* u.degree + MAXREC = 100 + + scs_srv = SCSService(SERVER) + + query_result = scs_srv.search( + pos=SkyCoord(ra=RA, dec=DEC, frame='icrs'), radius=SR + verbosity=VERB, + RESPONSEFORMAT="mivot", + MAXREC=MAXREC) + The next section provides some tips to use the API documented in the :ref:`annoter page `. From 15a6df7fdf5b0bd909c2f9375b630a971715f0b3 Mon Sep 17 00:00:00 2001 From: lmichel Date: Wed, 8 Oct 2025 09:26:49 +0200 Subject: [PATCH 25/28] Correct doc style a little bit --- docs/mivot/example.rst | 4 ++-- docs/mivot/viewer.rst | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/mivot/example.rst b/docs/mivot/example.rst index 2795cf20..af16e5a7 100644 --- a/docs/mivot/example.rst +++ b/docs/mivot/example.rst @@ -2,7 +2,7 @@ MIVOT (``pyvo.mivot``): How to use annotated data - Examples ************************************************************ -Photometric properties readout +Photometric Properties Readout ============================== This example is based on VOTables provided by the ``XTapDB`` service. @@ -132,7 +132,7 @@ It is to be noted that the current table row keeps available through the Mivot v The same client code can be reused in many places with many datasets, provided they are annotated. -EpochPosition property readout +EpochPosition Property Readout ============================== This example is based on a VOtable resulting on a Vizier cone search. diff --git a/docs/mivot/viewer.rst b/docs/mivot/viewer.rst index 2c8d6f66..7e236812 100644 --- a/docs/mivot/viewer.rst +++ b/docs/mivot/viewer.rst @@ -197,7 +197,7 @@ with the `astropy.io.votable` API: In this case, it is up to the user to ensure that the read data rows are those mapped by the Mivot annotations. -Mivot/Mango as a direct gateway from data to astropy SkyCoord +Mivot/Mango as a Direct Gateway from Data to Astropy SkyCoord ------------------------------------------------------------- A straightforward way to make the most of the annotations is to use From beed1d8c03e3c4db37d4ce73cfafcf948679241c Mon Sep 17 00:00:00 2001 From: lmichel Date: Wed, 8 Oct 2025 10:14:56 +0200 Subject: [PATCH 26/28] Some style improvement by DeepL --- docs/mivot/example.rst | 4 +++- docs/mivot/viewer.rst | 44 +++++++++++++++++++++--------------------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/docs/mivot/example.rst b/docs/mivot/example.rst index af16e5a7..910853df 100644 --- a/docs/mivot/example.rst +++ b/docs/mivot/example.rst @@ -1,3 +1,5 @@ +.. _mivot-examples: + ************************************************************ MIVOT (``pyvo.mivot``): How to use annotated data - Examples ************************************************************ @@ -282,4 +284,4 @@ It's a good case to exercise this API. MAXREC=MAXREC) -The next section provides some tips to use the API documented in the :ref:`annoter page `. +*The next section provides some tips to use the API documented in the* :ref:`annoter page `. diff --git a/docs/mivot/viewer.rst b/docs/mivot/viewer.rst index 7e236812..e1e1d9d3 100644 --- a/docs/mivot/viewer.rst +++ b/docs/mivot/viewer.rst @@ -32,7 +32,7 @@ These objects can be used as standard Python instances, with the fields represen They can also be used as Python dictionaries (provided by the ``to_dict()`` method), with the keys representing the model elements. -The code below, based on the processing of a cone-search query result, demonstrates both uses. +The code below, based on the processing of a cone-search output, demonstrates both uses. The first step is to instanciate a viewer that will provide the API for browsing annotations. The viewer can be built from a VOTable file path, a parsed VOtable (``VOTableFile`` object), @@ -61,8 +61,8 @@ or a ``DALResults`` instance. resolve_ref=True ) -In this example, the query result is mapped to the 'mango:EpochPosition' class, -but users do not need to know this in advance, since the API provides a way +In this example, the query result is mapped to the ``mango:EpochPosition`` class, +but users do not need to know this in advance, since the API provides tools to discover the mapped models. .. code-block:: python @@ -73,7 +73,7 @@ to discover the mapped models. data is mapped to the MANGO data model -We can also check which classes the data is mapped to. +We can also check which datamodel classes the data is mapped to. .. code-block:: python @@ -103,11 +103,10 @@ and that the data rows can be interpreted as instances of the ``mango:EpochPosit .. important:: - The coordinate systems are usually mapped in the GLOBALS MIVOT block. + Coordinate systems are usually mapped in the GLOBALS MIVOT block. This allows them to be referenced from any other MIVOT element. - These references are resolved by copying the coordinate system instance - into the host element when the viewer's ``resolve_ref`` flag (constructor parameter) - is True. + The viewer resolves such references when the constructor flag ``resolve_ref`` is set to ``True``. + In this case the coordinate system instances are copied into their host elements. The code below shows how to access GLOBALS instances independently of the mapped data. @@ -166,11 +165,11 @@ using the ``to_dict()`` property of ``MivotInstance``. }, } -The ``to_hk_dict()`` method adds references to the mapped columns to the model leaves. +The ``to_hk_dict()`` method extends the model leaves with the references of the mapped columns. - It is recommended to work with deepcopies of the dictionaries as they are rebuilt each time the ``to_dict()`` property is invoked. -- The default representation of ``MivotInstance`` instances is made with a pretty +- The Python representation (``__repr__()``) of ``MivotInstance`` instances is made with a pretty string serialization of this dictionary. Per-Row Readout @@ -186,23 +185,21 @@ with the `astropy.io.votable` API: # init the viewer mivot_viewer = MivotViewer(votable, resource_number=0) mivot_object = mivot_viewer.dm_instance - # and feed it with the table row - read = [] + # and feed it with the numpy table row for rec in table.array: + # apply the mapping to current row mivot_object.update(rec) - read.append(mivot_object.longitude.value) # show that the model retrieve the correct values + # ... or do whatever you want assert rec["RAICRS"] == mivot_object.longitude.value assert rec["DEICRS"] == mivot_object.latitude.value -In this case, it is up to the user to ensure that the read data rows are those mapped by the Mivot annotations. - Mivot/Mango as a Direct Gateway from Data to Astropy SkyCoord ------------------------------------------------------------- -A straightforward way to make the most of the annotations is to use -them to build Astropy objects directly, without analysing the metadata, -whether from the annotation or the VOTable. +A simple way to get the most out of annotations is to use them +to directly create Astropy objects, without having to parse the metadata, +whether it comes from the annotation or the VOTable. .. code-block:: python @@ -224,10 +221,10 @@ If this is not the case, an error is raised. .. important:: - In the current implementation, the gateway only works from ``mango:EpochPosition`` objects - to the ``SkyCoord`` class. In future. We plan to use the same mechanism to instantiate - any property modelled by ``Mango``, as well as potentially other IVOA models. - + In the current implementation, the only functioning gateway connects + ``Mango::EpochPosition`` objects with the ``SkyCoord`` class. In future, + we will implement the same mechanism for any property modelled by Mango, + as well as potentially for other IVOA models. Class Generation in a Nutshell ------------------------------ @@ -275,6 +272,9 @@ identifiers, which have the following structure: ``model:a.b``. print(mivot_instance.Coordinate_coordSys.spaceRefFrame.__dict__.keys()) dict_keys(['dmtype', 'value', 'unit', 'ref']) + +*More examples can be found* :ref:`here `. + Reference/API ============= From 71cde7b817132aba6c76dc4b4727d87dfea6c3d7 Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Thu, 9 Oct 2025 09:51:35 +0200 Subject: [PATCH 27/28] Brigita's review (2025-10-08) --- CHANGES.rst | 6 +++-- .../reference/TRASH.templates_models.json | 25 ------------------- pyvo/mivot/viewer/mivot_viewer.py | 5 +--- pyvo/mivot/writer/instances_from_models.py | 6 ++--- 4 files changed, 8 insertions(+), 34 deletions(-) delete mode 100644 pyvo/mivot/tests/data/reference/TRASH.templates_models.json diff --git a/CHANGES.rst b/CHANGES.rst index 72bf2a65..2ea22ff2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,8 +14,10 @@ Enhancements and Fixes - Add retry option to AsyncTAPJob.fetch_result for transient failures [#696] -- Improve the gateway between annotations and SkyCoord objects, simplify the viewer API (XML accessors removed), - support of mapping with multiple instances per row, revamp the viewer documentation. [#698] +- Upgrade of the ``MivotViewer`` API (``xml_viewer`` module removed, support of + multiple mapped objects per row, partial redesign of the public API) - + Improve the gateway between annotations and ``SkyCoord`` objects - + revamp the viewer documentation. [#698] Deprecations and Removals ------------------------- diff --git a/pyvo/mivot/tests/data/reference/TRASH.templates_models.json b/pyvo/mivot/tests/data/reference/TRASH.templates_models.json deleted file mode 100644 index 2eb7063e..00000000 --- a/pyvo/mivot/tests/data/reference/TRASH.templates_models.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "_PKTable": { - "COLLECTION": [], - "INSTANCE": [ - "cube:SparseCube" - ] - }, - "Results": { - "COLLECTION": [], - "INSTANCE": [ - "cube:NDPoint", - "cube:Observable", - "meas:Time", - "coords:MJD", - "cube:Observable", - "meas:GenericMeasure", - "coords:PhysicalCoordinate", - "cube:Observable", - "meas:GenericMeasure", - "coords:PhysicalCoordinate", - "meas:Error", - "meas:Symmetrical" - ] - } -} \ No newline at end of file diff --git a/pyvo/mivot/viewer/mivot_viewer.py b/pyvo/mivot/viewer/mivot_viewer.py index 7283af37..46485512 100644 --- a/pyvo/mivot/viewer/mivot_viewer.py +++ b/pyvo/mivot/viewer/mivot_viewer.py @@ -303,10 +303,7 @@ def get_dm_instance_dmtypes(self, tableref): dmtypes.append(instance.get(Att.dmtype)) if not dmtypes: - raise MivotError( - "Can't find " + Ele.INSTANCE - + " in " + Ele.TEMPLATES - ) + raise MivotError("Can't find " + Ele.INSTANCE + " in " + Ele.TEMPLATES) return dmtypes def _connect_table(self, tableref=None): diff --git a/pyvo/mivot/writer/instances_from_models.py b/pyvo/mivot/writer/instances_from_models.py index bc7fd50b..08c0269c 100644 --- a/pyvo/mivot/writer/instances_from_models.py +++ b/pyvo/mivot/writer/instances_from_models.py @@ -702,12 +702,12 @@ def pack_into_votable(self, *, report_msg="", sparse=False, schema_check=True): Parameters ---------- - report_msg: string, optional (default to an empty string) + report_msg : string, optional (default to an empty string) Content of the REPORT Mivot tag - sparse: boolean, optional (default to False) + sparse : boolean, optional (default to False) If True, all properties are added in a independent way to the the TEMPLATES. They are packed in a MangoObject otherwise. - schema_check: boolean, optional (default to True) + schema_check : boolean, optional (default to True) If True the MIVOT block is validated against its schema. This may test failing due to remote accesses. """ From 3ee4349328a7472175ea0bb1860ffd1decb9aa4d Mon Sep 17 00:00:00 2001 From: Laurent MICHEL Date: Thu, 9 Oct 2025 09:51:35 +0200 Subject: [PATCH 28/28] Brigita's review (2025-10-08) --- CHANGES.rst | 6 +++-- docs/mivot/viewer.rst | 4 +++ .../reference/TRASH.templates_models.json | 25 ------------------- pyvo/mivot/viewer/mivot_viewer.py | 5 +--- pyvo/mivot/writer/instances_from_models.py | 6 ++--- 5 files changed, 12 insertions(+), 34 deletions(-) delete mode 100644 pyvo/mivot/tests/data/reference/TRASH.templates_models.json diff --git a/CHANGES.rst b/CHANGES.rst index 72bf2a65..2ea22ff2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,8 +14,10 @@ Enhancements and Fixes - Add retry option to AsyncTAPJob.fetch_result for transient failures [#696] -- Improve the gateway between annotations and SkyCoord objects, simplify the viewer API (XML accessors removed), - support of mapping with multiple instances per row, revamp the viewer documentation. [#698] +- Upgrade of the ``MivotViewer`` API (``xml_viewer`` module removed, support of + multiple mapped objects per row, partial redesign of the public API) - + Improve the gateway between annotations and ``SkyCoord`` objects - + revamp the viewer documentation. [#698] Deprecations and Removals ------------------------- diff --git a/docs/mivot/viewer.rst b/docs/mivot/viewer.rst index e1e1d9d3..71463de3 100644 --- a/docs/mivot/viewer.rst +++ b/docs/mivot/viewer.rst @@ -23,6 +23,10 @@ Introduction Using the API ============= + .. attention:: + The module based on XPath queries and allowing to browse the XML + annotations (``viewer.XmlViewer``) has been removed from version 1.8 + Integrated Readout ------------------ The ``ModelViewer`` module manages access to data mapped to a model through dynamically diff --git a/pyvo/mivot/tests/data/reference/TRASH.templates_models.json b/pyvo/mivot/tests/data/reference/TRASH.templates_models.json deleted file mode 100644 index 2eb7063e..00000000 --- a/pyvo/mivot/tests/data/reference/TRASH.templates_models.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "_PKTable": { - "COLLECTION": [], - "INSTANCE": [ - "cube:SparseCube" - ] - }, - "Results": { - "COLLECTION": [], - "INSTANCE": [ - "cube:NDPoint", - "cube:Observable", - "meas:Time", - "coords:MJD", - "cube:Observable", - "meas:GenericMeasure", - "coords:PhysicalCoordinate", - "cube:Observable", - "meas:GenericMeasure", - "coords:PhysicalCoordinate", - "meas:Error", - "meas:Symmetrical" - ] - } -} \ No newline at end of file diff --git a/pyvo/mivot/viewer/mivot_viewer.py b/pyvo/mivot/viewer/mivot_viewer.py index 7283af37..46485512 100644 --- a/pyvo/mivot/viewer/mivot_viewer.py +++ b/pyvo/mivot/viewer/mivot_viewer.py @@ -303,10 +303,7 @@ def get_dm_instance_dmtypes(self, tableref): dmtypes.append(instance.get(Att.dmtype)) if not dmtypes: - raise MivotError( - "Can't find " + Ele.INSTANCE - + " in " + Ele.TEMPLATES - ) + raise MivotError("Can't find " + Ele.INSTANCE + " in " + Ele.TEMPLATES) return dmtypes def _connect_table(self, tableref=None): diff --git a/pyvo/mivot/writer/instances_from_models.py b/pyvo/mivot/writer/instances_from_models.py index bc7fd50b..08c0269c 100644 --- a/pyvo/mivot/writer/instances_from_models.py +++ b/pyvo/mivot/writer/instances_from_models.py @@ -702,12 +702,12 @@ def pack_into_votable(self, *, report_msg="", sparse=False, schema_check=True): Parameters ---------- - report_msg: string, optional (default to an empty string) + report_msg : string, optional (default to an empty string) Content of the REPORT Mivot tag - sparse: boolean, optional (default to False) + sparse : boolean, optional (default to False) If True, all properties are added in a independent way to the the TEMPLATES. They are packed in a MangoObject otherwise. - schema_check: boolean, optional (default to True) + schema_check : boolean, optional (default to True) If True the MIVOT block is validated against its schema. This may test failing due to remote accesses. """