diff --git a/astropy/io/votable/data/VOTable.v1.5.xsd b/astropy/io/votable/data/VOTable.v1.5.xsd new file mode 100644 index 000000000000..84a7cba03712 --- /dev/null +++ b/astropy/io/votable/data/VOTable.v1.5.xsd @@ -0,0 +1,634 @@ + + + + + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/astropy/io/votable/exceptions.py b/astropy/io/votable/exceptions.py index 1050c330c735..629f6672904e 100644 --- a/astropy/io/votable/exceptions.py +++ b/astropy/io/votable/exceptions.py @@ -660,12 +660,12 @@ class W20(VOTableSpecWarning): class W21(UnimplementedWarning): """ Unknown issues may arise using ``astropy.io.votable`` with VOTable files - from a version other than 1.1, 1.2, 1.3, or 1.4. + from a version not in 1.1 through 1.5. """ message_template = ( - "astropy.io.votable is designed for VOTable version 1.1, 1.2, 1.3," - " and 1.4, but this file is {}" + "astropy.io.votable is designed for VOTable versions 1.1 through 1.5," + " but this file is {}" ) default_args = ("x",) @@ -1156,6 +1156,20 @@ class W56(VOTableSpecWarning): ) +class W57(VOTableSpecWarning): + """ + The ``refposition`` attribute on the ``COOSYS`` element is only allowed on + VOTABLE versions 1.5 and greater. + favor of a reference to the Space-Time Coordinate (STC) data + model (see `utype + `__ + and the IVOA note `referencing STC in VOTable + `__. + """ + + message_template = "refposition only allowed on VOTABLE v1.5 and greater" + + class E01(VOWarning, ValueError): """Invalid size specifier for a field. diff --git a/astropy/io/votable/tests/data/coosys.xml b/astropy/io/votable/tests/data/coosys.xml new file mode 100644 index 000000000000..eaab39880c89 --- /dev/null +++ b/astropy/io/votable/tests/data/coosys.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + +
010.68+41.27N 224
+
+
diff --git a/astropy/io/votable/tests/test_coosys.py b/astropy/io/votable/tests/test_coosys.py new file mode 100644 index 000000000000..9eaed9cd99c8 --- /dev/null +++ b/astropy/io/votable/tests/test_coosys.py @@ -0,0 +1,212 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +import io +from contextlib import nullcontext + +import pytest + +from astropy.io.votable import tree +from astropy.io.votable.exceptions import W07, W08, W21, W27, W41, W57 +from astropy.io.votable.table import parse +from astropy.io.votable.tree import Resource, VOTableFile +from astropy.tests.helper import PYTEST_LT_8_0 +from astropy.utils.data import get_pkg_data_filename + +COOSYS_TEMPLATE = """ + + + {} + + +""" + +COOSYS_REFPOSITION = ( + '' +) +COOSYS_NO_REFPOSITION = '' +NO_COOSYS = "" + + +def test_accept_refposition(): + for ns_version, version, coosys, err in [ + ("1.1", "1.1", COOSYS_NO_REFPOSITION, None), + ("1.2", "1.2", COOSYS_NO_REFPOSITION, (W27)), + ("1.3", "1.3", COOSYS_NO_REFPOSITION, None), + ("1.3", "1.4", COOSYS_NO_REFPOSITION, None), + ("1.3", "1.5", COOSYS_NO_REFPOSITION, None), + ("1.1", "1.1", COOSYS_REFPOSITION, (W57)), + ("1.2", "1.2", COOSYS_REFPOSITION, (W27, W57)), + ("1.3", "1.3", COOSYS_REFPOSITION, (W57)), + ("1.3", "1.4", COOSYS_REFPOSITION, (W57)), + ("1.3", "1.5", COOSYS_REFPOSITION, None), + ]: + xml_string = COOSYS_TEMPLATE.format(ns_version, version, coosys) + input = io.BytesIO(bytes(xml_string, "utf-8")) + if err is not None: + with pytest.warns() as record: + vot = parse(input, verify="warn") + else: + vot = parse(input, verify="warn") + + +def _coosys_tests(votable): + assert len(list(votable.iter_coosys())) == 2 + + coosys = votable.get_coosys_by_id("coosys1") + assert coosys.system == "ICRS" + assert coosys.equinox == "J2000" + assert coosys.epoch == "J2015.5" + assert coosys.refposition == "BARYCENTER" + + coosys = votable.get_coosys_by_id("coosys2") + assert coosys.system == "FK4" + assert coosys.equinox == "B1950" + assert coosys.epoch == "B1950.0" + assert coosys.refposition == "UNKNOWN" + + +def test_coosys(): + votable = parse(get_pkg_data_filename("data/coosys.xml")) + _coosys_tests(votable) + + +def test_coosys_roundtrip(): + orig_votable = parse(get_pkg_data_filename("data/coosys.xml")) + bio = io.BytesIO() + orig_votable.to_xml(bio) + bio.seek(0) + votable = parse(bio) + _coosys_tests(votable) + + +if __name__ == "__main__": + test_accept_refposition() + + +def test_check_astroyear_fail(): + config = {"verify": "exception"} + field = tree.Field(None, name="astroyear", arraysize="1") + with pytest.raises(W07): + tree.check_astroyear("X2100", field, config) + + +def test_string_fail(): + config = {"verify": "exception"} + with pytest.raises(W08): + tree.check_string(42, "foo", config) + + +def test_make_Fields(): + votable = VOTableFile() + # ...with one resource... + resource = Resource() + votable.resources.append(resource) + + # ... with one table + table = tree.TableElement(votable) + resource.tables.append(table) + + table.fields.extend( + [tree.Field(votable, name="Test", datatype="float", unit="mag")] + ) + + +def test_unit_format(): + data = parse(get_pkg_data_filename("data/irsa-nph-error.xml")) + assert data._config["version"] == "1.0" + assert tree._get_default_unit_format(data._config) == "cds" + data = parse(get_pkg_data_filename("data/names.xml")) + assert data._config["version"] == "1.1" + assert tree._get_default_unit_format(data._config) == "cds" + data = parse(get_pkg_data_filename("data/gemini.xml")) + assert data._config["version"] == "1.2" + assert tree._get_default_unit_format(data._config) == "cds" + data = parse(get_pkg_data_filename("data/binary2_masked_strings.xml")) + assert data._config["version"] == "1.3" + assert tree._get_default_unit_format(data._config) == "cds" + data = parse(get_pkg_data_filename("data/timesys.xml")) + assert data._config["version"] == "1.4" + assert tree._get_default_unit_format(data._config) == "vounit" + + +def test_namespace_warning(): + """ + A version 1.4 VOTable must use the same namespace as 1.3. + (see https://www.ivoa.net/documents/VOTable/20191021/REC-VOTable-1.4-20191021.html#ToC16). + """ + bad_namespace = b""" + + + + """ + with pytest.warns(W41): + parse(io.BytesIO(bad_namespace), verify="exception") + + good_namespace_14 = b""" + + + + """ + parse(io.BytesIO(good_namespace_14), verify="exception") + + good_namespace_13 = b""" + + + + """ + parse(io.BytesIO(good_namespace_13), verify="exception") + + +def test_version(): + """ + VOTableFile.__init__ allows versions of '1.1' through '1.5'. + VOTableFile.__init__ does not allow version of '1.0' anymore and now raises a ValueError as it does to other versions not supported. + """ + # Exercise the checks in __init__ + for version in ("1.1", "1.2", "1.3", "1.4", "1.5"): + VOTableFile(version=version) + for version in ("0.9", "1.0", "1.6", "2.0"): + with pytest.raises( + ValueError, match=r"should be in \('1.1', '1.2', '1.3', '1.4', '1.5'\)." + ): + VOTableFile(version=version) + + # Exercise the checks in the setter + vot = VOTableFile() + for version in ("1.1", "1.2", "1.3", "1.4"): + vot.version = version + for version in ("1.0", "1.6", "2.0"): + with pytest.raises( + ValueError, + match=r"supports VOTable versions '1.1', '1.2', '1.3', '1.4', '1.5'$", + ): + vot.version = version + + # Exercise the checks in the parser. + begin = b'' + ) + + # Valid versions + for bversion in (b"1.1", b"1.2", b"1.3"): + parse( + io.BytesIO(begin + bversion + middle + bversion + end), verify="exception" + ) + parse(io.BytesIO(begin + b"1.4" + middle + b"1.3" + end), verify="exception") + + if PYTEST_LT_8_0: + ctx = nullcontext() + else: + ctx = pytest.warns(W41) + + # Invalid versions + for bversion in (b"1.0", b"2.0"): + with pytest.warns(W21), ctx: + parse( + io.BytesIO(begin + bversion + middle + bversion + end), + verify="exception", + ) diff --git a/astropy/io/votable/tests/test_schema_versions.py b/astropy/io/votable/tests/test_schema_versions.py new file mode 100644 index 000000000000..cd986ad2f763 --- /dev/null +++ b/astropy/io/votable/tests/test_schema_versions.py @@ -0,0 +1,82 @@ +import re +import sys + +import pytest + +from astropy.io.votable.xmlutil import validate_schema +from astropy.utils.data import get_pkg_data_filename + +# Each test file uses a feature that is new with its version. +V_1_2_TABLE = get_pkg_data_filename("data/empty_table.xml") +V_1_3_TABLE = get_pkg_data_filename("data/binary2_masked_strings.xml") +V_1_4_TABLE = get_pkg_data_filename("data/timesys.xml") +V_1_5_TABLE = get_pkg_data_filename("data/coosys.xml") + +# Schema versions to test against +V_1_2 = "1.2" +V_1_3 = "1.3" +V_1_4 = "1.4" +V_1_5 = "1.5" +V_1_6 = "1.6" # Next (unimplemented) schema + +# Starting with version 1.4, schemas were upward compatible. +test_cases = [ + (V_1_2_TABLE, V_1_2, 0, None), + ( + V_1_2_TABLE, + V_1_3, + 3, + b"No matching global declaration available for the validation root", + ), + (V_1_3_TABLE, V_1_3, 0, None), + (V_1_3_TABLE, V_1_4, 0, None), + (V_1_3_TABLE, V_1_5, 0, None), + ( + V_1_3_TABLE, + V_1_6, + 3, + b"No matching global declaration available for the validation root", + ), + (V_1_4_TABLE, V_1_3, 3, b"TIMESYS.*element is not expected"), + (V_1_4_TABLE, V_1_4, 0, None), + (V_1_4_TABLE, V_1_5, 0, None), + ( + V_1_4_TABLE, + V_1_6, + 3, + b"No matching global declaration available for the validation root", + ), + (V_1_5_TABLE, V_1_3, 3, b"attribute 'refposition' is not allowed"), + (V_1_5_TABLE, V_1_4, 3, b"attribute 'refposition' is not allowed"), + (V_1_5_TABLE, V_1_5, 0, None), + ( + V_1_5_TABLE, + V_1_6, + 3, + b"No matching global declaration available for the validation root", + ), +] + + +@pytest.mark.parametrize( + "votable_file,schema_version,expected_return_code,expected_msg_re", test_cases +) +def test_schema_versions( + votable_file, schema_version, expected_return_code, expected_msg_re +): + """Test that xmllint gives expected results for the given file and schema version.""" + + # We need xmllint so won't try with Windows. + if sys.platform.startswith("win"): + return + + try: + rc, stdout, stderr = validate_schema(votable_file, schema_version) + except OSError: + # If xmllint is not installed, we want the test to pass anyway + return + + assert rc == expected_return_code + if rc == 3: + # Schema validation error. Check the error message content. + assert re.search(expected_msg_re, stderr) diff --git a/astropy/io/votable/tests/test_tree.py b/astropy/io/votable/tests/test_tree.py index 224d189a33f0..3625b216494a 100644 --- a/astropy/io/votable/tests/test_tree.py +++ b/astropy/io/votable/tests/test_tree.py @@ -117,25 +117,26 @@ def test_votable_values_empty_min_max(): def test_version(): """ - VOTableFile.__init__ allows versions of '1.1', '1.2', '1.3' and '1.4'. + VOTableFile.__init__ allows versions of '1.1' through '1.5'. VOTableFile.__init__ does not allow version of '1.0' anymore and now raises a ValueError as it does to other versions not supported. """ # Exercise the checks in __init__ - for version in ("1.1", "1.2", "1.3", "1.4"): + for version in ("1.1", "1.2", "1.3", "1.4", "1.5"): VOTableFile(version=version) - for version in ("0.9", "1.0", "2.0"): + for version in ("0.9", "1.0", "1.6", "2.0"): with pytest.raises( - ValueError, match=r"should be in \('1.1', '1.2', '1.3', '1.4'\)." + ValueError, match=r"should be in \('1.1', '1.2', '1.3', '1.4', '1.5'\)." ): VOTableFile(version=version) # Exercise the checks in the setter vot = VOTableFile() - for version in ("1.1", "1.2", "1.3", "1.4"): + for version in ("1.1", "1.2", "1.3", "1.4", "1.5"): vot.version = version - for version in ("1.0", "2.0"): + for version in ("1.0", "1.6", "2.0"): with pytest.raises( - ValueError, match=r"supports VOTable versions '1.1', '1.2', '1.3', '1.4'$" + ValueError, + match=r"supports VOTable versions '1.1', '1.2', '1.3', '1.4', '1.5'$", ): vot.version = version @@ -152,6 +153,7 @@ def test_version(): io.BytesIO(begin + bversion + middle + bversion + end), verify="exception" ) parse(io.BytesIO(begin + b"1.4" + middle + b"1.3" + end), verify="exception") + parse(io.BytesIO(begin + b"1.5" + middle + b"1.3" + end), verify="exception") if PYTEST_LT_8_0: ctx = nullcontext() @@ -198,6 +200,11 @@ def test_votable_tag(): assert 'xsi:schemaLocation="http://www.ivoa.net/xml/VOTable/v1.3 ' in xml assert 'http://www.ivoa.net/xml/VOTable/VOTable-1.4.xsd"' in xml + xml = votable_xml_string("1.5") + assert 'xmlns="http://www.ivoa.net/xml/VOTable/v1.3"' in xml + assert 'xsi:schemaLocation="http://www.ivoa.net/xml/VOTable/v1.3 ' in xml + assert 'http://www.ivoa.net/xml/VOTable/VOTable-1.5.xsd"' in xml + def _squash_xml(data): """ diff --git a/astropy/io/votable/tree.py b/astropy/io/votable/tree.py index 2a040c19888c..303a8d97e21c 100644 --- a/astropy/io/votable/tree.py +++ b/astropy/io/votable/tree.py @@ -79,6 +79,7 @@ W53, W54, W56, + W57, vo_raise, vo_reraise, vo_warn, @@ -1810,7 +1811,7 @@ class CooSys(SimpleElement): name, documented below. """ - _attr_list = ["ID", "equinox", "epoch", "system"] + _attr_list = ["ID", "equinox", "epoch", "system", "refposition"] _element_name = "COOSYS" def __init__( @@ -1819,6 +1820,7 @@ def __init__( equinox=None, epoch=None, system=None, + refposition=None, id=None, config=None, pos=None, @@ -1841,6 +1843,11 @@ def __init__( self.equinox = equinox self.epoch = epoch self.system = system + self.refposition = refposition + + # refposition introduced in v1.5. + if self.refposition is not None and not config.get("version_1_5_or_later"): + warn_or_raise(W57, W57, (), config, pos) warn_unknown_attrs("COOSYS", extra.keys(), config, pos) @@ -1886,7 +1893,10 @@ def system(self, system): "barycentric", "geo_app", ): - warn_or_raise(E16, E16, system, self._config, self._pos) + if not self._config.get("version_1_5_or_later"): + # Starting in v1.5, system values come from the IVOA refframe vocabulary + # (http://www.ivoa.net/rdf/refframe). For now we are not checking those values. + warn_or_raise(E16, E16, system, self._config, self._pos) self._system = system @system.deleter @@ -4143,6 +4153,7 @@ def _get_version_checks(self): config["version_1_2_or_later"] = util.version_compare(self.version, "1.2") >= 0 config["version_1_3_or_later"] = util.version_compare(self.version, "1.3") >= 0 config["version_1_4_or_later"] = util.version_compare(self.version, "1.4") >= 0 + config["version_1_5_or_later"] = util.version_compare(self.version, "1.5") >= 0 return config # Map VOTable version numbers to namespace URIs and schema information. @@ -4186,6 +4197,14 @@ def _get_version_checks(self): " http://www.ivoa.net/xml/VOTable/VOTable-1.4.xsd" ), }, + "1.5": { + "namespace_uri": "http://www.ivoa.net/xml/VOTable/v1.3", + "schema_location_attr": "xsi:schemaLocation", + "schema_location_value": ( + "http://www.ivoa.net/xml/VOTable/v1.3" + " http://www.ivoa.net/xml/VOTable/VOTable-1.5.xsd" + ), + }, } def parse(self, iterator, config): diff --git a/astropy/io/votable/validator/result.py b/astropy/io/votable/validator/result.py index cb9d6fcc1fc2..b047a80ef73a 100644 --- a/astropy/io/votable/validator/result.py +++ b/astropy/io/votable/validator/result.py @@ -243,6 +243,9 @@ def get_result_subsets(results, root, s=None): version_10 = [] version_11 = [] version_12 = [] + version_13 = [] + version_14 = [] + version_15 = [] version_unknown = [] has_warnings = [] warning_set = {} @@ -286,6 +289,12 @@ def get_result_subsets(results, root, s=None): version_11.append(x) elif version == "1.2": version_12.append(x) + elif version == "1.3": + version_13.append(x) + elif version == "1.4": + version_14.append(x) + elif version == "1.5": + version_15.append(x) else: version_unknown.append(x) if x["nwarnings"] > 0: @@ -332,6 +341,9 @@ def get_result_subsets(results, root, s=None): ("version1.0", "Version 1.0", version_10), ("version1.1", "Version 1.1", version_11), ("version1.2", "Version 1.2", version_12), + ("version1.3", "Version 1.3", version_13), + ("version1.4", "Version 1.4", version_14), + ("version1.5", "Version 1.5", version_15), ("version_unknown", "Version unknown", version_unknown), ("warnings", "Warnings", has_warnings), ] diff --git a/astropy/io/votable/xmlutil.py b/astropy/io/votable/xmlutil.py index 2b98fe5767bd..c27c1611d38e 100644 --- a/astropy/io/votable/xmlutil.py +++ b/astropy/io/votable/xmlutil.py @@ -112,13 +112,14 @@ def validate_schema(filename, version="1.1"): Returns the returncode from xmllint and the stdout and stderr as strings """ - if version not in ("1.0", "1.1", "1.2", "1.3"): - log.info(f"{filename} has version {version}, using schema 1.1") - version = "1.1" + supported_schemas = ["1.1", "1.2", "1.3", "1.4", "1.5"] - if version in ("1.1", "1.2", "1.3"): - schema_path = data.get_pkg_data_filename(f"data/VOTable.v{version}.xsd") - else: + if version == "1.0": schema_path = data.get_pkg_data_filename("data/VOTable.dtd") + else: + if version not in supported_schemas: + log.info(f"{filename} has version {version}, using schema 1.1") + version = "1.1" + schema_path = data.get_pkg_data_filename(f"data/VOTable.v{version}.xsd") return validate.validate_schema(filename, schema_path)