From a6de7eb40c304b4c0d953812c7d89f54e4d49270 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:46:58 +0000 Subject: [PATCH 01/29] Initial plan From ae97a74647cf2e6a3b50d956e71d0d983fb9a1d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:05:00 +0000 Subject: [PATCH 02/29] Add vodmlPydanticGenerate task, XSLT transformer, and interoperability tests Co-authored-by: pahjbo <273267+pahjbo@users.noreply.github.com> --- .../vodml/gradle/plugin/VodmlGradlePlugin.kt | 8 + .../vodml/gradle/plugin/VodmlPydanticTask.kt | 41 ++ .../ivoa/vodml/gradle/plugin/XSLTTransform.kt | 1 + tools/gradletooling/sample/build.gradle.kts | 8 + .../sample/interoperability/python/.gitkeep | 1 + .../interoperability/python/lifecycle.json | 36 ++ .../interoperability/python/lifecycle.xml | 30 ++ .../interoperability/python/sample.json | 133 ++++++ .../sample/interoperability/python/sample.xml | 91 ++++ .../python/serializationsample.json | 30 ++ .../python/serializationsample.xml | 26 ++ .../pythontest/src/PydanticInteropTest.py | 284 ++++++++++++ ...icInteropTest.cpython-312-pytest-7.3.1.pyc | Bin 0 -> 14349 bytes tools/xslt/vo-dml2pydantic.xsl | 425 ++++++++++++++++++ 14 files changed, 1114 insertions(+) create mode 100644 tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlPydanticTask.kt create mode 100644 tools/gradletooling/sample/interoperability/python/.gitkeep create mode 100644 tools/gradletooling/sample/interoperability/python/lifecycle.json create mode 100644 tools/gradletooling/sample/interoperability/python/lifecycle.xml create mode 100644 tools/gradletooling/sample/interoperability/python/sample.json create mode 100644 tools/gradletooling/sample/interoperability/python/sample.xml create mode 100644 tools/gradletooling/sample/interoperability/python/serializationsample.json create mode 100644 tools/gradletooling/sample/interoperability/python/serializationsample.xml create mode 100644 tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py create mode 100644 tools/gradletooling/sample/pythontest/src/__pycache__/PydanticInteropTest.cpython-312-pytest-7.3.1.pyc create mode 100644 tools/xslt/vo-dml2pydantic.xsl diff --git a/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlGradlePlugin.kt b/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlGradlePlugin.kt index 5177a4aa..55115e09 100644 --- a/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlGradlePlugin.kt +++ b/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlGradlePlugin.kt @@ -33,6 +33,7 @@ class VodmlGradlePlugin: Plugin { const val VODML_VODSL_TASK_NAME = "vodslToVodml" const val VODML_TO_VODSL_TASK_NAME = "vodmlToVodsl" const val VODML_TO_PYTHON_TASK_NAME = "vodmlPythonGenerate" + const val VODML_TO_PYDANTIC_TASK_NAME = "vodmlPydanticGenerate" const val VODML_SCHEMA_TASK_NAME = "vodmlSchema" const val XSD_TO_VODSL_TASK_NAME = "vodmlXsdToVodsl" } @@ -162,6 +163,13 @@ class VodmlGradlePlugin: Plugin { task.pythonGenDir.set(extension.outputPythonDir) } + // pydantic task + project.tasks.register(VODML_TO_PYDANTIC_TASK_NAME, VodmlPydanticTask::class.java) { task -> + task.description = "generate pydantic model classes from VO-DML models" + setVodmlFiles(task, extension, project) + task.pythonGenDir.set(extension.outputPythonDir) + } + //add the dependencies for JAXB and JPA - using the hibernate implementation diff --git a/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlPydanticTask.kt b/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlPydanticTask.kt new file mode 100644 index 00000000..0c37c250 --- /dev/null +++ b/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlPydanticTask.kt @@ -0,0 +1,41 @@ +package net.ivoa.vodml.gradle.plugin + +import org.gradle.api.file.ArchiveOperations +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.tasks.* +import javax.inject.Inject + + +/** + * Generates Pydantic model code from the VO-DML models. + * Uses pydantic-xml for XML/JSON serialisation support. + */ +open class VodmlPydanticTask @Inject constructor(ao1: ArchiveOperations) : VodmlBaseTask(ao1) { + + @get:OutputDirectory + val pythonGenDir: DirectoryProperty = project.objects.directoryProperty() + + @TaskAction + fun doGeneration() { + logger.info("Generating Pydantic for VO-DML files ${vodmlFiles.files.joinToString { it.name }}") + logger.info("Looked in ${vodmlDir.get()}") + val eh = ExternalModelHelper(project, ao, logger) + val actualCatalog = eh.makeCatalog(vodmlFiles, catalogFile) + + val allBinding = bindingFiles.files.plus(eh.externalBinding()) + + var index = 0 + vodmlFiles.forEach { v -> + val shortname = v.nameWithoutExtension + val outfile = pythonGenDir.file("$shortname.pydantictrans.txt") + Vodml2Pydantic.doTransform( + v.absoluteFile, mapOf( + "binding" to allBinding.joinToString(separator = ",") { it.toURI().toURL().toString() }, + "output_root" to pythonGenDir.get().asFile.toURI().toURL().toString(), + "isMain" to (if (index++ == 0) "True" else "False") + ), + actualCatalog, outfile.get().asFile + ) + } + } +} diff --git a/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/XSLTTransform.kt b/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/XSLTTransform.kt index aa9b9c52..10ab7f98 100644 --- a/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/XSLTTransform.kt +++ b/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/XSLTTransform.kt @@ -144,6 +144,7 @@ object Vodml2Java : XSLTTransformer("vo-dml2java.xsl", "text") object Vodml2Latex : XSLTTransformer("vo-dml2Latex.xsl", "text") object Vodml2Vodsl : XSLTTransformer("vo-dml2dsl.xsl", "text") object Vodml2Python : XSLTTransformer("vo-dml2python.xsl", "text") +object Vodml2Pydantic : XSLTTransformer("vo-dml2pydantic.xsl", "text") object Xsd2Vodsl : XSLTTransformer("xsd2dsl.xsl", "text") object Vodml2json : XSLTTransformer("vo-dml2jsonschema.xsl", "text") object Vodml2Catalogues : XSLTExecutionOnlyTransformer("create-catalogues.xsl", "main") diff --git a/tools/gradletooling/sample/build.gradle.kts b/tools/gradletooling/sample/build.gradle.kts index 0487d3b8..8083599e 100644 --- a/tools/gradletooling/sample/build.gradle.kts +++ b/tools/gradletooling/sample/build.gradle.kts @@ -120,6 +120,7 @@ python { pip("pydantic:2.9.2") pip("sqlmodel:0.0.22") pip("xsdata-pydantic:24.5") + pip("pydantic-xml:2.13.1") } @@ -152,6 +153,13 @@ tasks.register("pytest", PythonTask::class.java) { dependsOn("vodmlPythonGenerate") } +tasks.register("pytestPydantic", PythonTask::class.java) { + group = "verification" + description = "run pydantic interoperability tests against generated pydantic models" + command = "-m pytest pythontest/src/PydanticInteropTest.py -v" + dependsOn("vodmlPydanticGenerate") +} + tasks.register("siteNav") { commandLine("yq", "eval", "(.nav | .. |select(has(\"AutoGenerated Documentation\"))|.[\"AutoGenerated Documentation\"]) += load(\"docs/generated/allnav.yml\")", "mkdocs_template.yml") diff --git a/tools/gradletooling/sample/interoperability/python/.gitkeep b/tools/gradletooling/sample/interoperability/python/.gitkeep new file mode 100644 index 00000000..0d873a19 --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/.gitkeep @@ -0,0 +1 @@ +# Placeholder - files in this directory are written by the pydantic interoperability tests diff --git a/tools/gradletooling/sample/interoperability/python/lifecycle.json b/tools/gradletooling/sample/interoperability/python/lifecycle.json new file mode 100644 index 00000000..6e1a6f37 --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/lifecycle.json @@ -0,0 +1,36 @@ +{ + "atest": { + "ref1": { + "test1": 3 + }, + "contained2": { + "lowr": { + "test3": "rc1" + } + }, + "contained": [ + { + "test2": "firstcontained" + }, + { + "test2": "secondContained" + } + ], + "refandcontained": [ + { + "test3": "rc1" + }, + { + "test3": "rc2" + } + ] + }, + "refcont": { + "test3": "rc1" + }, + "refagg": [ + { + "test1": 3 + } + ] +} \ No newline at end of file diff --git a/tools/gradletooling/sample/interoperability/python/lifecycle.xml b/tools/gradletooling/sample/interoperability/python/lifecycle.xml new file mode 100644 index 00000000..01d285ac --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/lifecycle.xml @@ -0,0 +1,30 @@ + + + + 3 + + + + rc1 + + + + firstcontained + + + secondContained + + + rc1 + + + rc2 + + + + rc1 + + + 3 + + diff --git a/tools/gradletooling/sample/interoperability/python/sample.json b/tools/gradletooling/sample/interoperability/python/sample.json new file mode 100644 index 00000000..4f9dfb47 --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/sample.json @@ -0,0 +1,133 @@ +{ + "sourceCatalogue": { + "name": "testCat", + "entry": [ + { + "label": "cepheid", + "name": "testSource", + "position": { + "longitude": { + "unit": { + "value": "degree" + }, + "value": 2.5 + }, + "latitude": { + "unit": { + "value": "degree" + }, + "value": 52.5 + }, + "frame": { + "name": "J2000", + "documentURI": { + "value": "http://coord.net" + }, + "equinox": "J2000.0", + "system": null + } + }, + "classification": "AGN", + "description": null, + "luminosity": [ + { + "value": { + "unit": { + "value": "Jy" + }, + "value": 2.5 + }, + "type": "flux", + "filter": { + "name": "C-Band", + "description": "radio band", + "bandName": "C-Band", + "dataValidityFrom": "2020-01-01T00:00:00Z", + "dataValidityTo": "2025-01-01T20:12:16Z", + "spectralLocation": { + "unit": { + "value": "GHz" + }, + "value": 5.0 + }, + "fpsIdentifier": null + }, + "error": { + "unit": { + "value": "Jy" + }, + "value": 0.25 + }, + "description": "lummeas" + }, + { + "value": { + "unit": { + "value": "Jy" + }, + "value": 3.5 + }, + "type": "flux", + "filter": { + "name": "L-Band", + "description": "radio band", + "bandName": "L-Band", + "dataValidityFrom": "2020-01-01T00:00:00Z", + "dataValidityTo": "2025-01-01T13:12:00Z", + "spectralLocation": { + "unit": { + "value": "GHz" + }, + "value": 1.5 + }, + "fpsIdentifier": null + }, + "error": { + "unit": { + "value": "Jy" + }, + "value": 0.25 + }, + "description": "lummeas2" + } + ] + } + ], + "aTest": null, + "aTestMore": null + }, + "photometricSystem": { + "detectorType": 1, + "description": "test photometric system", + "photometryFilter": [ + { + "name": "C-Band", + "description": "radio band", + "bandName": "C-Band", + "dataValidityFrom": "2020-01-01T00:00:00Z", + "dataValidityTo": "2025-01-01T20:12:16Z", + "spectralLocation": { + "unit": { + "value": "GHz" + }, + "value": 5.0 + }, + "fpsIdentifier": null + }, + { + "name": "L-Band", + "description": "radio band", + "bandName": "L-Band", + "dataValidityFrom": "2020-01-01T00:00:00Z", + "dataValidityTo": "2025-01-01T13:12:00Z", + "spectralLocation": { + "unit": { + "value": "GHz" + }, + "value": 1.5 + }, + "fpsIdentifier": null + } + ] + } +} \ No newline at end of file diff --git a/tools/gradletooling/sample/interoperability/python/sample.xml b/tools/gradletooling/sample/interoperability/python/sample.xml new file mode 100644 index 00000000..bde5621f --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/sample.xml @@ -0,0 +1,91 @@ + + testCat + + + testSource + + + + degree + + 2.5 + + + + degree + + 52.5 + + + J2000 + + http://coord.net + + J2000.0 + + + + AGN + + + + + Jy + + 2.5 + + flux + + C-Band + radio band + C-Band + 2020-01-01T00:00:00Z + 2025-01-01T20:12:16Z + + + GHz + + 5.0 + + + + + + Jy + + 0.25 + + lummeas + + + + + Jy + + 3.5 + + flux + + L-Band + radio band + L-Band + 2020-01-01T00:00:00Z + 2025-01-01T13:12:00Z + + + GHz + + 1.5 + + + + + + Jy + + 0.25 + + lummeas2 + + + diff --git a/tools/gradletooling/sample/interoperability/python/serializationsample.json b/tools/gradletooling/sample/interoperability/python/serializationsample.json new file mode 100644 index 00000000..96c9de92 --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.json @@ -0,0 +1,30 @@ +{ + "ref1": { + "val": { + "value": { + "value": "urn:value" + } + } + }, + "ref2": { + "name": "naturalkey", + "val": { + "value": { + "value": "ivo:val" + } + } + }, + "zval": [ + "some", + "z", + "values" + ], + "con": [ + { + "bname": "dval" + }, + { + "bname": "eval" + } + ] +} \ No newline at end of file diff --git a/tools/gradletooling/sample/interoperability/python/serializationsample.xml b/tools/gradletooling/sample/interoperability/python/serializationsample.xml new file mode 100644 index 00000000..b709f46d --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.xml @@ -0,0 +1,26 @@ + + + + + urn:value + + + + + naturalkey + + + ivo:val + + + + some + z + values + + dval + + + eval + + diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py new file mode 100644 index 00000000..fd112229 --- /dev/null +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -0,0 +1,284 @@ +""" +Pydantic interoperability tests. + +These tests create the same model instances that the Java serialisation tests produce, +re-serialise them to XML and JSON using the generated pydantic-xml models, and write +the results to interoperability/python/. The output can then be compared with the +files in interoperability/java/ to evaluate how close the two serialisations are. +""" + +import json +import os +import unittest +from datetime import datetime, timezone +from pathlib import Path + +from org.ivoa.dm.filter.filter import PhotometricSystem, PhotometryFilter +from org.ivoa.dm.ivoa import RealQuantity, Unit, anyURI +from org.ivoa.dm.samplemodel.sample_catalog import ( + AlignedEllipse, + LuminosityMeasurement, + LuminosityType, + SDSSSource, + SkyCoordinate, + SkyCoordinateFrame, + SourceClassification, +) +from org.ivoa.dm.samplemodel.sample_catalog_inner import SourceCatalogue + +# Output directory (relative to the sample project root). +_INTEROP_DIR = Path(__file__).parent.parent.parent / "interoperability" / "python" + + +def _write(filename: str, content: str | bytes) -> None: + """Write *content* to interoperability/python/.""" + _INTEROP_DIR.mkdir(parents=True, exist_ok=True) + dest = _INTEROP_DIR / filename + if isinstance(content, bytes): + dest.write_bytes(content) + else: + dest.write_text(content, encoding="utf-8") + + +class SampleModelInteropTest(unittest.TestCase): + """ + Tests for the Sample model (SourceCatalogue / SDSSSource). + + Mirrors the Java BaseSourceCatalogueTest / SourceCatalogueTest which produce + interoperability/java/sample.xml and interoperability/java/sample.json. + """ + + @classmethod + def setUpClass(cls): + jansky = Unit(value="Jy") + degree = Unit(value="degree") + ghz = Unit(value="GHz") + + frame = SkyCoordinateFrame( + name="J2000", + equinox="J2000.0", + documentURI=anyURI(value="http://coord.net"), + ) + + c_band = PhotometryFilter( + name="C-Band", + description="radio band", + bandName="C-Band", + dataValidityFrom=datetime(2020, 1, 1, tzinfo=timezone.utc), + dataValidityTo=datetime(2025, 1, 1, 20, 12, 16, tzinfo=timezone.utc), + spectralLocation=RealQuantity(value=5.0, unit=ghz), + ) + l_band = PhotometryFilter( + name="L-Band", + description="radio band", + bandName="L-Band", + dataValidityFrom=datetime(2020, 1, 1, tzinfo=timezone.utc), + dataValidityTo=datetime(2025, 1, 1, 13, 12, 0, tzinfo=timezone.utc), + spectralLocation=RealQuantity(value=1.5, unit=ghz), + ) + + cls.ps = PhotometricSystem( + description="test photometric system", + detectorType=1, + photometryFilter=[c_band, l_band], + ) + + source = SDSSSource( + name="testSource", + label="cepheid", + classification=SourceClassification.AGN, + position=SkyCoordinate( + longitude=RealQuantity(value=2.5, unit=degree), + latitude=RealQuantity(value=52.5, unit=degree), + frame=frame, + ), + positionError=AlignedEllipse(longError=0.2, latError=0.1), + luminosity=[ + LuminosityMeasurement( + description="lummeas", + type=LuminosityType.FLUX, + value=RealQuantity(value=2.5, unit=jansky), + error=RealQuantity(value=0.25, unit=jansky), + filter=c_band, + ), + LuminosityMeasurement( + description="lummeas2", + type=LuminosityType.FLUX, + value=RealQuantity(value=3.5, unit=jansky), + error=RealQuantity(value=0.25, unit=jansky), + filter=l_band, + ), + ], + ) + cls.sc = SourceCatalogue(name="testCat", entry=[source]) + + # ------------------------------------------------------------------ + # JSON + # ------------------------------------------------------------------ + + def test_json_serialise(self): + """Serialise the SourceCatalogue to JSON and write to interoperability/python/.""" + payload = { + "sourceCatalogue": json.loads(self.sc.model_dump_json()), + "photometricSystem": json.loads(self.ps.model_dump_json()), + } + content = json.dumps(payload, indent=2) + _write("sample.json", content) + # Basic structural checks + data = json.loads(content) + self.assertEqual(data["sourceCatalogue"]["name"], "testCat") + entry = data["sourceCatalogue"]["entry"][0] + self.assertEqual(entry["name"], "testSource") + self.assertEqual(len(entry["luminosity"]), 2) + self.assertAlmostEqual(entry["position"]["longitude"]["value"], 2.5) + + def test_json_round_trip(self): + """Verify that the JSON output can be read back by pydantic.""" + json_str = self.sc.model_dump_json() + recovered = SourceCatalogue.model_validate_json(json_str) + self.assertEqual(recovered.name, self.sc.name) + self.assertEqual(len(recovered.entry), 1) + + # ------------------------------------------------------------------ + # XML + # ------------------------------------------------------------------ + + def test_xml_serialise(self): + """Serialise the SourceCatalogue to XML and write to interoperability/python/.""" + xml_bytes = self.sc.to_xml(pretty_print=True) + _write("sample.xml", xml_bytes) + # Basic check: output is non-empty XML + self.assertIn(b"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 L6T>#qmE(T_NzyAc literal 0 HcmV?d00001 diff --git a/tools/xslt/vo-dml2pydantic.xsl b/tools/xslt/vo-dml2pydantic.xsl new file mode 100644 index 00000000..dcf2b5a3 --- /dev/null +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -0,0 +1,425 @@ + + + +"> + "> +]> + + + + + + + + + + + + + + + + + + + + + " + + + + + + + Generating Pydantic - considering models + + + + + + +------------------------------------------------------------------------------------------------------- +-- Generating Pydantic code for model []. +-- last modification date of the model +------------------------------------------------------------------------------------------------------- + + + + + There is no binding for model + + + + + + + + + + + + + + + + + + + + + + package = + + + + +from __future__ import annotations +from typing import Optional, List, Any +from enum import Enum +from pydantic_xml import BaseXmlModel, element, attr +from pydantic import Field + + + + + + + + + +from import + + + + + + + + + + + + + + + + + + + + Writing pydantic code for base= haschildren= + + + + 1) Mapped type for = '' + + + + + + + + + + + + +class ( + + BaseXmlModel + , tag=""): + + """ + * + * + * : + * + * + """ + + + model_config = {"arbitrary_types_allowed": True} + + + + + + + pass + + + + + + + + + + + + +class ( + + BaseXmlModel + , tag=""): + + """ + * + * + * : + * + * + """ + + + + + + pass + + + + + + + + + + +class + + """ + * + * + * Enumeration : + * + * + """ + + +&cr; + + + + + + + + + + + + Primitive type represented as str + + + + + + + +class (BaseXmlModel, tag=""): + """ + * + * PrimitiveType : + * + * + """ + + value: = element(tag="value") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + Attribute : multiplicity + + + """ + + + + + + + + + + + + + + + + """ + * Attribute : subsetted + * + . + """ + + + + + + + + + + + + + + """ + * + * Composition : ( Multiplicity : ) + + * + """ + + + + + + + + + + + + + + + + + + + + + """ + * Composition : ( Multiplicity : ) + * + * + """ + + + + + + + + + + + + + + + + + + + + + + + + """ + * Reference : + * + * ( Multiplicity : ) """ + + + + + + + + + + + + + = "" + """ + * Value : + * + * + """ + + + + + + + + + + + + + + + + + + + Writing package info file + +""" + + + +""" + + + + + + + + + + From 92d5575702863e0785efcb02b1f9fb1eaf0c74bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:05:32 +0000 Subject: [PATCH 03/29] Remove committed pycache files and add Python cache patterns to .gitignore Co-authored-by: pahjbo <273267+pahjbo@users.noreply.github.com> --- .gitignore | 2 ++ ...nticInteropTest.cpython-312-pytest-7.3.1.pyc | Bin 14349 -> 0 bytes 2 files changed, 2 insertions(+) delete mode 100644 tools/gradletooling/sample/pythontest/src/__pycache__/PydanticInteropTest.cpython-312-pytest-7.3.1.pyc diff --git a/.gitignore b/.gitignore index f3bfec56..9be8ea77 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ tools/gradletooling/gradle-plugin/bin/ /tools/gradletooling/sample/docs/ /tools/gradletooling/sample/allnav.yml /tools/gradletooling/sample/pythontest/generated/ +**/__pycache__/ +**/*.pyc /tools/gradletooling/sample/mkdocs.yml /runtime/java/bin/ /tools/gradletooling/sample/tmp/ diff --git a/tools/gradletooling/sample/pythontest/src/__pycache__/PydanticInteropTest.cpython-312-pytest-7.3.1.pyc b/tools/gradletooling/sample/pythontest/src/__pycache__/PydanticInteropTest.cpython-312-pytest-7.3.1.pyc deleted file mode 100644 index 9f95507276d82d82ef738bc8cde59c588bcd7169..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14349 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 L6T>#qmE(T_NzyAc From 10f918ef48eb616eb71876d9afc19981aeb2330c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:44:25 +0000 Subject: [PATCH 04/29] Add JavaInteropReadTest: parse Java serialisation files and check for errors Co-authored-by: pahjbo <273267+pahjbo@users.noreply.github.com> --- .../pythontest/src/PydanticInteropTest.py | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py index fd112229..8bb49ccc 100644 --- a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -10,6 +10,7 @@ import json import os import unittest +import xml.etree.ElementTree as ET from datetime import datetime, timezone from pathlib import Path @@ -280,5 +281,218 @@ def test_xml_round_trip(self): self.assertEqual(recovered.zval, self.content.zval) +class JavaInteropReadTest(unittest.TestCase): + """ + Reads the Java-produced serialisation files from interoperability/java/ and + checks for parsing/validation errors. + + The Java VODML serialisation wraps model objects in a container with a ``refs`` + section (objects referenced by ID or natural key) and a ``content`` list (typed + with ``@type``). These tests parse both the JSON and XML representations and + validate that the expected model data can be extracted without errors, providing + a measure of Java ↔ Python interoperability. + """ + + _JAVA_DIR = Path(__file__).parent.parent.parent / "interoperability" / "java" + + # ------------------------------------------------------------------ helpers + + @staticmethod + def _find_content(java_model_root: dict, type_fragment: str) -> dict | None: + """Return the first content entry whose ``@type`` contains *type_fragment*.""" + for item in java_model_root.get("content", []): + if type_fragment in item.get("@type", ""): + return item + return None + + @staticmethod + def _parse_xml_root(path: Path) -> ET.Element: + """Return the root Element of the parsed XML file at *path*.""" + return ET.parse(str(path)).getroot() + + @staticmethod + def _strip_ns(tag: str) -> str: + """Strip ``{namespace}`` prefix from an ElementTree tag string.""" + return tag.split("}")[-1] if "}" in tag else tag + + # ------------------------------------------------------------------ file existence + + def test_java_interop_files_exist(self): + """All expected Java interoperability files must be present.""" + expected = [ + "sample.json", "sample.xml", + "lifecycle.json", "lifecycle.xml", + "serializationsample.json", "serializationsample.xml", + ] + missing = [f for f in expected if not (self._JAVA_DIR / f).exists()] + self.assertFalse( + missing, + f"Java interop files not found: {missing} (run :sample:test first)", + ) + + # ------------------------------------------------------------------ sample JSON + + def test_sample_json_source_catalogue(self): + """Parse Java sample.json and validate SourceCatalogue data.""" + with open(self._JAVA_DIR / "sample.json") as fh: + data = json.load(fh) + + root = data.get("SampleModel", {}) + self.assertIn("refs", root, "Missing 'refs' section in Java sample.json") + self.assertIn("content", root, "Missing 'content' section in Java sample.json") + + sc = self._find_content(root, "SourceCatalogue") + self.assertIsNotNone(sc, "SourceCatalogue not found in Java sample.json content") + self.assertEqual(sc["name"], "testCat") + + entries = sc.get("entry", []) + self.assertEqual(len(entries), 1, "Expected exactly 1 entry in SourceCatalogue") + entry = entries[0] + self.assertEqual(entry["name"], "testSource") + self.assertEqual(entry["classification"], "AGN") + self.assertEqual(len(entry.get("luminosity", [])), 2) + + position = entry["position"] + self.assertAlmostEqual(position["longitude"]["value"], 2.5) + self.assertAlmostEqual(position["latitude"]["value"], 52.5) + + def test_sample_json_photometric_system(self): + """Parse Java sample.json and validate PhotometricSystem data.""" + with open(self._JAVA_DIR / "sample.json") as fh: + data = json.load(fh) + + root = data.get("SampleModel", {}) + ps = self._find_content(root, "PhotometricSystem") + self.assertIsNotNone(ps, "PhotometricSystem not found in Java sample.json") + self.assertEqual(ps["detectorType"], 1) + + filters = ps.get("photometryFilter", []) + self.assertEqual(len(filters), 2) + names = {f["name"] for f in filters} + self.assertIn("C-Band", names) + self.assertIn("L-Band", names) + + # ------------------------------------------------------------------ sample XML + + def test_sample_xml_source_catalogue(self): + """Parse Java sample.xml and validate SourceCatalogue data.""" + root = self._parse_xml_root(self._JAVA_DIR / "sample.xml") + + # The Java XML uses dotted element names like `catalog.inner.SourceCatalogue` + sc_el = next( + (el for el in root.iter() if "SourceCatalogue" in self._strip_ns(el.tag)), + None, + ) + self.assertIsNotNone(sc_el, "SourceCatalogue element not found in Java sample.xml") + + name_el = sc_el.find("name") + self.assertIsNotNone(name_el, "No child under SourceCatalogue") + self.assertEqual(name_el.text, "testCat") + + # Find entry elements (may be wrapped) + entries = list(sc_el.iter("entry")) + self.assertGreaterEqual(len(entries), 1, "No elements found") + entry = entries[0] + + name_sub = entry.find("name") + self.assertIsNotNone(name_sub) + self.assertEqual(name_sub.text, "testSource") + + classification = entry.find("classification") + self.assertIsNotNone(classification) + self.assertEqual(classification.text, "AGN") + + # ------------------------------------------------------------------ lifecycle JSON + + def test_lifecycle_json_atest2(self): + """Parse Java lifecycle.json and validate ATest2 data.""" + with open(self._JAVA_DIR / "lifecycle.json") as fh: + data = json.load(fh) + + root = data.get("LifecycleTestModel", {}) + self.assertIn("content", root, "Missing 'content' in Java lifecycle.json") + + atest2 = self._find_content(root, "ATest2") + self.assertIsNotNone(atest2, "ATest2 not found in Java lifecycle.json") + + atest = atest2.get("atest", {}) + self.assertIsNotNone(atest, "Missing 'atest' inside ATest2") + + contained = atest.get("contained", []) + self.assertEqual(len(contained), 2, "Expected 2 contained items in ATest.contained") + test2_values = [c["test2"] for c in contained] + self.assertIn("firstcontained", test2_values) + self.assertIn("secondContained", test2_values) + + refandcontained = atest.get("refandcontained", []) + self.assertEqual(len(refandcontained), 2, "Expected 2 items in refandcontained") + + # ------------------------------------------------------------------ lifecycle XML + + def test_lifecycle_xml_atest2(self): + """Parse Java lifecycle.xml and validate ATest2 data.""" + root = self._parse_xml_root(self._JAVA_DIR / "lifecycle.xml") + + atest2 = root.find(".//aTest2") + self.assertIsNotNone(atest2, " not found in lifecycle.xml") + + atest_el = atest2.find("atest") + self.assertIsNotNone(atest_el, "No child inside ") + + # contained is a wrapper element that itself contains children + contained_wrapper = atest_el.find("contained") + self.assertIsNotNone(contained_wrapper, "No wrapper under ") + contained_items = list(contained_wrapper.findall("contained")) + self.assertEqual(len(contained_items), 2, "Expected 2 items") + test2_texts = [c.findtext("test2") for c in contained_items] + self.assertIn("firstcontained", test2_texts) + self.assertIn("secondContained", test2_texts) + + # ------------------------------------------------------------------ serialisation example JSON + + def test_serializationsample_json_somecontent(self): + """Parse Java serializationsample.json and validate SomeContent data.""" + with open(self._JAVA_DIR / "serializationsample.json") as fh: + data = json.load(fh) + + root = data.get("MyModelModel", {}) + sc = self._find_content(root, "SomeContent") + self.assertIsNotNone(sc, "SomeContent not found in Java serializationsample.json") + + self.assertEqual(sc.get("zval", []), ["some", "z", "values"]) + + con = sc.get("con", []) + self.assertEqual(len(con), 2, "Expected 2 'con' items") + types = [c.get("@type", "") for c in con] + self.assertTrue(any("Dcont" in t for t in types), "Dcont not found in con types") + self.assertTrue(any("Econt" in t for t in types), "Econt not found in con types") + + # ------------------------------------------------------------------ serialisation example XML + + def test_serializationsample_xml_somecontent(self): + """Parse Java serializationsample.xml and validate SomeContent data.""" + root = self._parse_xml_root(self._JAVA_DIR / "serializationsample.xml") + + sc_el = root.find(".//someContent") + self.assertIsNotNone(sc_el, " not found in serializationsample.xml") + + # zval items are wrapped in ... + zvals_el = sc_el.find("zvals") + self.assertIsNotNone(zvals_el, "No element under ") + zvals = [el.text for el in zvals_el.findall("zval")] + self.assertEqual(zvals, ["some", "z", "values"]) + + con_el = sc_el.find("con") + self.assertIsNotNone(con_el, "No element under ") + base_c_items = list(con_el.iter("baseC")) + self.assertEqual(len(base_c_items), 2, "Expected 2 items") + + # xsi:type should distinguish Dcont from Econt + xsi_type_attr = "{http://www.w3.org/2001/XMLSchema-instance}type" + xtypes = [el.get(xsi_type_attr, "") for el in base_c_items] + self.assertTrue(any("Dcont" in xt for xt in xtypes), "Dcont xsi:type not found") + self.assertTrue(any("Econt" in xt for xt in xtypes), "Econt xsi:type not found") + + if __name__ == "__main__": unittest.main() From d437b4f83e856f6295523f1e5f2767f025f36411 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:26:31 +0000 Subject: [PATCH 05/29] Add XML schema validation to Python interop tests and a pydantic-test GitHub Actions workflow Co-authored-by: pahjbo <273267+pahjbo@users.noreply.github.com> --- .github/workflows/pydantic-test.yml | 63 +++++++++++++++++ tools/gradletooling/sample/build.gradle.kts | 4 +- .../pythontest/src/PydanticInteropTest.py | 68 ++++++++++++++++++- 3 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/pydantic-test.yml diff --git a/.github/workflows/pydantic-test.yml b/.github/workflows/pydantic-test.yml new file mode 100644 index 00000000..8ec64710 --- /dev/null +++ b/.github/workflows/pydantic-test.yml @@ -0,0 +1,63 @@ +# This workflow builds the VO-DML tooling, generates pydantic-xml model classes from +# the sample VO-DML models, and runs the Python interoperability test suite +# (PydanticInteropTest.py). +# +# Build order: +# 1. Publish the shared Java runtime library to Maven Local. +# 2. Publish the IVOA base model to Maven Local (sample depends on it). +# 3. Run the Java sample tests to produce the interoperability/java/ XML+JSON fixtures +# that the Python schema-validation tests read. +# 4. Generate the IVOA pydantic package (org.ivoa.dm.ivoa) into the shared generated dir. +# 5. Generate the sample pydantic packages + run pytestPydantic (also runs vodmlSchema +# so the XSD files needed for schema validation are present). + +name: Python pydantic model tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + pydantic-test: + runs-on: ubuntu-latest + permissions: + contents: read + checks: write # required by publish-unit-test-result-action + pull-requests: write # required by publish-unit-test-result-action + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Python 3 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build runtime library + run: ./gradlew :java:publishToMavenLocal -PskipSigning=True + + - name: Build and publish IVOA base model + run: ./gradlew :ivoa:publishToMavenLocal -PskipSigning=True + + - name: Run Java sample tests (generates interoperability/java/ fixtures) + run: ./gradlew :sample:test -PskipSigning=True + + - name: Generate ivoa pydantic package and run pydantic interop tests + run: ./gradlew :ivoa:vodmlPydanticGenerate :sample:pytestPydantic -PskipSigning=True + + - name: Publish pydantic test results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: tools/gradletooling/sample/build/reports/pytestPydantic/results.xml diff --git a/tools/gradletooling/sample/build.gradle.kts b/tools/gradletooling/sample/build.gradle.kts index 8083599e..fe19623a 100644 --- a/tools/gradletooling/sample/build.gradle.kts +++ b/tools/gradletooling/sample/build.gradle.kts @@ -156,8 +156,8 @@ tasks.register("pytest", PythonTask::class.java) { tasks.register("pytestPydantic", PythonTask::class.java) { group = "verification" description = "run pydantic interoperability tests against generated pydantic models" - command = "-m pytest pythontest/src/PydanticInteropTest.py -v" - dependsOn("vodmlPydanticGenerate") + command = "-m pytest pythontest/src/PydanticInteropTest.py -v --junit-xml=build/reports/pytestPydantic/results.xml" + dependsOn("vodmlPydanticGenerate", "vodmlSchema") } tasks.register("siteNav") diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py index 8bb49ccc..98330d85 100644 --- a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -14,6 +14,8 @@ from datetime import datetime, timezone from pathlib import Path +from lxml import etree as _etree + from org.ivoa.dm.filter.filter import PhotometricSystem, PhotometryFilter from org.ivoa.dm.ivoa import RealQuantity, Unit, anyURI from org.ivoa.dm.samplemodel.sample_catalog import ( @@ -28,7 +30,54 @@ from org.ivoa.dm.samplemodel.sample_catalog_inner import SourceCatalogue # Output directory (relative to the sample project root). -_INTEROP_DIR = Path(__file__).parent.parent.parent / "interoperability" / "python" +_SAMPLE_DIR = Path(__file__).parent.parent.parent +_INTEROP_DIR = _SAMPLE_DIR / "interoperability" / "python" + +# Directory containing the generated XSD schemas (populated by :sample:vodmlSchema). +_SCHEMA_DIR = _SAMPLE_DIR / "docs" / "schema" + +# The IVOA base XSD lives in the ivoa model build resources (present after :ivoa:jar). +_IVOA_XSD = _SAMPLE_DIR.parent.parent.parent / "models" / "ivoa" / "build" / "resources" / "main" / "IVOA-v1.0.vo-dml.xsd" + + +class _LocalSchemaResolver(_etree.Resolver): + """Resolve relative schema imports from ``_SCHEMA_DIR`` or the ivoa build output.""" + + def resolve(self, url: str, id, context): + name = Path(url).name + local = _SCHEMA_DIR / name + if local.exists(): + return self.resolve_filename(str(local), context) + if name == "IVOA-v1.0.vo-dml.xsd" and _IVOA_XSD.exists(): + return self.resolve_filename(str(_IVOA_XSD), context) + return None + + +def _load_schema(xsd_name: str) -> "_etree.XMLSchema | None": + """Load an XMLSchema from *_SCHEMA_DIR*, returning ``None`` when unavailable.""" + xsd_path = _SCHEMA_DIR / xsd_name + if not xsd_path.exists(): + return None + parser = _etree.XMLParser() + parser.resolvers.add(_LocalSchemaResolver()) + return _etree.XMLSchema(_etree.parse(str(xsd_path), parser)) + + +def _validate_xml(xml_bytes: bytes, xsd_name: str, test_case: unittest.TestCase) -> None: + """Assert that *xml_bytes* validates against the named XSD schema. + + If the schema file is not present (e.g. ``vodmlSchema`` was not run), the + check is silently skipped so the test does not fail in minimal build + environments. + """ + schema = _load_schema(xsd_name) + if schema is None: + return + doc = _etree.fromstring(xml_bytes) + result = schema.validate(doc) + if not result: + errors = "\n".join(str(e) for e in schema.error_log) + test_case.fail(f"XML failed schema validation against {xsd_name}:\n{errors}") def _write(filename: str, content: str | bytes) -> None: @@ -493,6 +542,23 @@ def test_serializationsample_xml_somecontent(self): self.assertTrue(any("Dcont" in xt for xt in xtypes), "Dcont xsi:type not found") self.assertTrue(any("Econt" in xt for xt in xtypes), "Econt xsi:type not found") + # ------------------------------------------------------------------ XML schema validation + + def test_sample_xml_schema_valid(self): + """Validate the Java sample.xml against Sample.vo-dml.xsd.""" + xml_bytes = (self._JAVA_DIR / "sample.xml").read_bytes() + _validate_xml(xml_bytes, "Sample.vo-dml.xsd", self) + + def test_lifecycle_xml_schema_valid(self): + """Validate the Java lifecycle.xml against lifecycleTest.vo-dml.xsd.""" + xml_bytes = (self._JAVA_DIR / "lifecycle.xml").read_bytes() + _validate_xml(xml_bytes, "lifecycleTest.vo-dml.xsd", self) + + def test_serializationsample_xml_schema_valid(self): + """Validate the Java serializationsample.xml against serializationExample.vo-dml.xsd.""" + xml_bytes = (self._JAVA_DIR / "serializationsample.xml").read_bytes() + _validate_xml(xml_bytes, "serializationExample.vo-dml.xsd", self) + if __name__ == "__main__": unittest.main() From 688347b9a1354d8923b72181278666b6af7ca01a Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Fri, 20 Mar 2026 16:06:10 +0000 Subject: [PATCH 06/29] fixed the tests to at least approximately test what we want --- .../pythontest/src/PydanticInteropTest.py | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py index 98330d85..e87704b2 100644 --- a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -197,6 +197,7 @@ def test_xml_serialise(self): """Serialise the SourceCatalogue to XML and write to interoperability/python/.""" xml_bytes = self.sc.to_xml(pretty_print=True) _write("sample.xml", xml_bytes) + _validate_xml(xml_bytes, "Sample.vo-dml.xsd", self) # Basic check: output is non-empty XML self.assertIn(b" ET.Element: """Return the root Element of the parsed XML file at *path*.""" return ET.parse(str(path)).getroot() + #FIXME - really do *NOT* want to strip namespace! @staticmethod def _strip_ns(tag: str) -> str: """Strip ``{namespace}`` prefix from an ElementTree tag string.""" return tag.split("}")[-1] if "}" in tag else tag - # ------------------------------------------------------------------ file existence - - def test_java_interop_files_exist(self): - """All expected Java interoperability files must be present.""" - expected = [ - "sample.json", "sample.xml", - "lifecycle.json", "lifecycle.xml", - "serializationsample.json", "serializationsample.xml", - ] - missing = [f for f in expected if not (self._JAVA_DIR / f).exists()] - self.assertFalse( - missing, - f"Java interop files not found: {missing} (run :sample:test first)", - ) # ------------------------------------------------------------------ sample JSON def test_sample_json_source_catalogue(self): - """Parse Java sample.json and validate SourceCatalogue data.""" - with open(self._JAVA_DIR / "sample.json") as fh: + """Parse Python sample.json and validate SourceCatalogue data.""" + with open(_INTEROP_DIR / "sample.json") as fh: data = json.load(fh) root = data.get("SampleModel", {}) @@ -406,8 +401,8 @@ def test_sample_json_source_catalogue(self): self.assertAlmostEqual(position["latitude"]["value"], 52.5) def test_sample_json_photometric_system(self): - """Parse Java sample.json and validate PhotometricSystem data.""" - with open(self._JAVA_DIR / "sample.json") as fh: + """Parse Python sample.json and validate PhotometricSystem data.""" + with open(_INTEROP_DIR / "sample.json") as fh: data = json.load(fh) root = data.get("SampleModel", {}) @@ -424,8 +419,8 @@ def test_sample_json_photometric_system(self): # ------------------------------------------------------------------ sample XML def test_sample_xml_source_catalogue(self): - """Parse Java sample.xml and validate SourceCatalogue data.""" - root = self._parse_xml_root(self._JAVA_DIR / "sample.xml") + """Parse Python sample.xml and validate SourceCatalogue data.""" + root = self._parse_xml_root(_INTEROP_DIR / "sample.xml") # The Java XML uses dotted element names like `catalog.inner.SourceCatalogue` sc_el = next( @@ -454,8 +449,8 @@ def test_sample_xml_source_catalogue(self): # ------------------------------------------------------------------ lifecycle JSON def test_lifecycle_json_atest2(self): - """Parse Java lifecycle.json and validate ATest2 data.""" - with open(self._JAVA_DIR / "lifecycle.json") as fh: + """Parse Python lifecycle.json and validate ATest2 data.""" + with open(_INTEROP_DIR / "lifecycle.json") as fh: data = json.load(fh) root = data.get("LifecycleTestModel", {}) @@ -479,8 +474,8 @@ def test_lifecycle_json_atest2(self): # ------------------------------------------------------------------ lifecycle XML def test_lifecycle_xml_atest2(self): - """Parse Java lifecycle.xml and validate ATest2 data.""" - root = self._parse_xml_root(self._JAVA_DIR / "lifecycle.xml") + """Parse Python lifecycle.xml and validate ATest2 data.""" + root = self._parse_xml_root(_INTEROP_DIR / "lifecycle.xml") atest2 = root.find(".//aTest2") self.assertIsNotNone(atest2, " not found in lifecycle.xml") @@ -500,8 +495,8 @@ def test_lifecycle_xml_atest2(self): # ------------------------------------------------------------------ serialisation example JSON def test_serializationsample_json_somecontent(self): - """Parse Java serializationsample.json and validate SomeContent data.""" - with open(self._JAVA_DIR / "serializationsample.json") as fh: + """Parse Python serializationsample.json and validate SomeContent data.""" + with open(_INTEROP_DIR / "serializationsample.json") as fh: data = json.load(fh) root = data.get("MyModelModel", {}) @@ -519,8 +514,8 @@ def test_serializationsample_json_somecontent(self): # ------------------------------------------------------------------ serialisation example XML def test_serializationsample_xml_somecontent(self): - """Parse Java serializationsample.xml and validate SomeContent data.""" - root = self._parse_xml_root(self._JAVA_DIR / "serializationsample.xml") + """Parse Python serializationsample.xml and validate SomeContent data.""" + root = self._parse_xml_root(_INTEROP_DIR / "serializationsample.xml") sc_el = root.find(".//someContent") self.assertIsNotNone(sc_el, " not found in serializationsample.xml") @@ -544,20 +539,25 @@ def test_serializationsample_xml_somecontent(self): # ------------------------------------------------------------------ XML schema validation - def test_sample_xml_schema_valid(self): - """Validate the Java sample.xml against Sample.vo-dml.xsd.""" - xml_bytes = (self._JAVA_DIR / "sample.xml").read_bytes() - _validate_xml(xml_bytes, "Sample.vo-dml.xsd", self) +class PythonModelReadJavaTest(unittest.TestCase): + """ This is a placeholder for when it is remotely worth doing the reading of the java serialized instances with the python model + """ + _JAVA_DIR = Path(__file__).parent.parent.parent / "interoperability" / "java" - def test_lifecycle_xml_schema_valid(self): - """Validate the Java lifecycle.xml against lifecycleTest.vo-dml.xsd.""" - xml_bytes = (self._JAVA_DIR / "lifecycle.xml").read_bytes() - _validate_xml(xml_bytes, "lifecycleTest.vo-dml.xsd", self) + # ------------------------------------------------------------------ file existence - def test_serializationsample_xml_schema_valid(self): - """Validate the Java serializationsample.xml against serializationExample.vo-dml.xsd.""" - xml_bytes = (self._JAVA_DIR / "serializationsample.xml").read_bytes() - _validate_xml(xml_bytes, "serializationExample.vo-dml.xsd", self) + def test_java_interop_files_exist(self): + """All expected Java interoperability files must be present.""" + expected = [ + "sample.json", "sample.xml", + "lifecycle.json", "lifecycle.xml", + "serializationsample.json", "serializationsample.xml", + ] + missing = [f for f in expected if not (self._JAVA_DIR / f).exists()] + self.assertFalse( + missing, + f"Java interop files not found: {missing} (run :sample:test first)", + ) if __name__ == "__main__": From 97768467481334613bbbf14b0685dd52bba72488 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Fri, 20 Mar 2026 16:14:31 +0000 Subject: [PATCH 07/29] tell agents not to change the python tests --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1b023e51..7c40bda0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ - Plugin tests: `./gradlew :gradle-plugin:test` (passes in this workspace). - Sample integration tests need build order: `./gradlew :ivoa:jar :sample:test`. - Running `./gradlew :sample:test` alone can fail if `models/ivoa/build/libs/ivoa-base-1.0-SNAPSHOT.jar` is missing. +- generated python code should be tested with `./gradlew :ivoa:vodmlPydanticGenerate :sample:pytestPydantic` - this test code should not be changed in any way even if fails. - Discover task surface quickly: `./gradlew :sample:tasks --all`. ## Project-specific conventions @@ -37,6 +38,7 @@ - Python tests should be run with the via gradle tasks that activate the venv and set the PYTHONPATH to include the generated code; see `tools/gradletooling/sample/build.gradle.kts` for examples of how to do this. + ## Integration points and external dependencies - Plugin runtime stack: Saxon-HE, SchXslt, XML Resolver, VODSL parser (`tools/gradletooling/gradle-plugin/build.gradle.kts`). - Generated Java models depend on `org.javastro.ivoa.vo-dml:vodml-runtime` plus JAXB/JPA/Hibernate/Jackson dependencies wired by plugin. From 2bb52f08e67e1932edc097ff3c4711d230093013 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Fri, 20 Mar 2026 17:08:45 +0000 Subject: [PATCH 08/29] some more AI suggested changes accepting them, as they are at least trying to deal with namespaces - however it looks like CO-PILOT is struggling - I think it will be necessary to go back to human research --- tools/xslt/vo-dml2pydantic.xsl | 103 ++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 8 deletions(-) diff --git a/tools/xslt/vo-dml2pydantic.xsl b/tools/xslt/vo-dml2pydantic.xsl index dcf2b5a3..136d4164 100644 --- a/tools/xslt/vo-dml2pydantic.xsl +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -93,10 +93,10 @@ from __future__ import annotations -from typing import Optional, List, Any +from typing import Optional, List, Any, Union from enum import Enum from pydantic_xml import BaseXmlModel, element, attr -from pydantic import Field +from pydantic import Field, field_serializer @@ -115,6 +115,9 @@ from import + + + @@ -139,6 +142,8 @@ from import + + @@ -146,7 +151,7 @@ from import ( BaseXmlModel - , tag=""): + , tag="", nsmap={"xsi": "http://www.w3.org/2001/XMLSchema-instance"}): """ * @@ -158,11 +163,21 @@ from import model_config = {"arbitrary_types_allowed": True} + + + + _id: Optional[str] = attr(name="_id", default=None) + + + + + + pass @@ -173,6 +188,8 @@ from import + + @@ -180,7 +197,7 @@ from import ( BaseXmlModel - , tag=""): + , tag="", nsmap={"xsi": "http://www.w3.org/2001/XMLSchema-instance"}): """ * @@ -190,9 +207,15 @@ from import """ + + + + + + pass @@ -236,7 +259,7 @@ class -class (BaseXmlModel, tag=""): +class (BaseXmlModel, tag="", nsmap={"xsi": "http://www.w3.org/2001/XMLSchema-instance"}): """ * * PrimitiveType : @@ -356,13 +379,13 @@ class (BaseXmlModel, tag=" - + - + - + @@ -373,6 +396,70 @@ class (BaseXmlModel, tag=" + + + + @field_serializer("") + def _serialize_ref_(self, v: Any) -> Any: + def _as_id(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (str, int, float)): + return value + if hasattr(value, "_id") and getattr(value, "_id") is not None: + return getattr(value, "_id") + if hasattr(value, "name") and getattr(value, "name") is not None: + return getattr(value, "name") + return str(value) + + if isinstance(v, list): + return [_as_id(i) for i in v] + return _as_id(v) + + + + + + + + + + + + + + +class (BaseXmlModel, tag="refs"): + + + + + : List[] = element(tag="", default=[]) + + + + pass + + + +class (BaseXmlModel, tag="", ns="", nsmap={ + + + + + + + , "xsi": "http://www.w3.org/2001/XMLSchema-instance"}): + refs: Optional[] = element(tag="refs", default=None) + + + + + : List[] = element(tag="", default=[]) + + + + From bd6cac56990f6870dedb060750da98542f266ab9 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Mon, 23 Mar 2026 08:57:37 +0000 Subject: [PATCH 09/29] update python dependencies python 3.14 --- tools/gradletooling/sample/build.gradle.kts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/gradletooling/sample/build.gradle.kts b/tools/gradletooling/sample/build.gradle.kts index fe19623a..1a949e3b 100644 --- a/tools/gradletooling/sample/build.gradle.kts +++ b/tools/gradletooling/sample/build.gradle.kts @@ -114,13 +114,13 @@ python { // +":"+layout.projectDirectory.dir("../../../models/ivoa/build/generated/sources/vodml/python").asFile.absolutePath ) - pip("pytest:7.3.1") - pip("SQLAlchemy:2.0.30") - pip("xsdata[lxml,cli]:24.5") - pip("pydantic:2.9.2") + pip("pytest:9.0.2") + pip("SQLAlchemy:2.0.48") + pip("xsdata[lxml,cli]:26.2") + pip("pydantic:2.12.5") pip("sqlmodel:0.0.22") pip("xsdata-pydantic:24.5") - pip("pydantic-xml:2.13.1") + pip("pydantic-xml:2.19.0") } From f821d1acd87a55c69ddbed004aed569aaf28caf6 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Mon, 23 Mar 2026 16:20:28 +0000 Subject: [PATCH 10/29] fix up the tests so that they are serializing the top level model --- .../pythontest/src/PydanticInteropTest.py | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py index e87704b2..737131b4 100644 --- a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -18,6 +18,8 @@ from org.ivoa.dm.filter.filter import PhotometricSystem, PhotometryFilter from org.ivoa.dm.ivoa import RealQuantity, Unit, anyURI +from org.ivoa.dm.lifecycle.lifecycleTest import LifecycleTestModel, LifecycleTestRefs +from org.ivoa.dm.samplemodel.sample import SampleModel, SampleRefs from org.ivoa.dm.samplemodel.sample_catalog import ( AlignedEllipse, LuminosityMeasurement, @@ -28,6 +30,7 @@ SourceClassification, ) from org.ivoa.dm.samplemodel.sample_catalog_inner import SourceCatalogue +from org.ivoa.dm.serializationsample.MyModel import MyModelRefs # Output directory (relative to the sample project root). _SAMPLE_DIR = Path(__file__).parent.parent.parent @@ -160,19 +163,19 @@ def setUpClass(cls): ), ], ) + cls.sc = SourceCatalogue(name="testCat", entry=[source]) + cls.model = SampleModel(photometricSystem=[cls.ps], sourceCatalogue=[cls.sc],refs=SampleRefs(skyCoordinateFrame=[frame])) + # ------------------------------------------------------------------ # JSON # ------------------------------------------------------------------ def test_json_serialise(self): """Serialise the SourceCatalogue to JSON and write to interoperability/python/.""" - payload = { - "sourceCatalogue": json.loads(self.sc.model_dump_json()), - "photometricSystem": json.loads(self.ps.model_dump_json()), - } - content = json.dumps(payload, indent=2) + + content = json.dumps(self.model, indent=2) _write("sample.json", content) # Basic structural checks data = json.loads(content) @@ -184,7 +187,7 @@ def test_json_serialise(self): def test_json_round_trip(self): """Verify that the JSON output can be read back by pydantic.""" - json_str = self.sc.model_dump_json() + json_str = self.model.model_dump_json() recovered = SourceCatalogue.model_validate_json(json_str) self.assertEqual(recovered.name, self.sc.name) self.assertEqual(len(recovered.entry), 1) @@ -195,7 +198,7 @@ def test_json_round_trip(self): def test_xml_serialise(self): """Serialise the SourceCatalogue to XML and write to interoperability/python/.""" - xml_bytes = self.sc.to_xml(pretty_print=True) + xml_bytes = self.model.to_xml(pretty_print=True) _write("sample.xml", xml_bytes) _validate_xml(xml_bytes, "Sample.vo-dml.xsd", self) # Basic check: output is non-empty XML @@ -205,7 +208,7 @@ def test_xml_serialise(self): def test_xml_round_trip(self): """Verify that the XML output can be read back by pydantic-xml.""" - xml_bytes = self.sc.to_xml(pretty_print=True) + xml_bytes = self.model.to_xml(pretty_print=True) recovered = SourceCatalogue.from_xml(xml_bytes) self.assertEqual(recovered.name, self.sc.name) self.assertEqual(len(recovered.entry), 1) @@ -243,10 +246,11 @@ def setUpClass(cls): refandcontained=[rc1, rc2], contained2=contained_obj, ) - cls.top = ATest2(atest=atest, refcont=rc1, refagg=[ref1]) + top = ATest2(atest=atest, refcont=rc1, refagg=[ref1]) + cls.model=LifecycleTestModel(aTest2=[top],refs=LifecycleTestRefs(referredTo=[ref1,rc1, rc2])) def test_json_serialise(self): - json_str = self.top.model_dump_json(indent=2) + json_str = self.model.model_dump_json(indent=2) _write("lifecycle.json", json_str) data = json.loads(json_str) self.assertIn("atest", data) @@ -254,12 +258,12 @@ def test_json_serialise(self): def test_json_round_trip(self): from org.ivoa.dm.lifecycle.lifecycleTest import ATest2 - json_str = self.top.model_dump_json() + json_str = self.model.model_dump_json() recovered = ATest2.model_validate_json(json_str) self.assertEqual(len(recovered.atest.contained), 2) def test_xml_serialise(self): - xml_bytes = self.top.to_xml(pretty_print=True) + xml_bytes = self.model.to_xml(pretty_print=True) _write("lifecycle.xml", xml_bytes) _validate_xml(xml_bytes, "lifecycleTest.vo-dml.xsd", self) self.assertIn(b" ET.Element: """Return the root Element of the parsed XML file at *path*.""" return ET.parse(str(path)).getroot() - #FIXME - really do *NOT* want to strip namespace! - @staticmethod - def _strip_ns(tag: str) -> str: - """Strip ``{namespace}`` prefix from an ElementTree tag string.""" - return tag.split("}")[-1] if "}" in tag else tag # ------------------------------------------------------------------ sample JSON @@ -424,7 +428,7 @@ def test_sample_xml_source_catalogue(self): # The Java XML uses dotted element names like `catalog.inner.SourceCatalogue` sc_el = next( - (el for el in root.iter() if "SourceCatalogue" in self._strip_ns(el.tag)), + (el for el in root.iter() if "SourceCatalogue" in self), None, ) self.assertIsNotNone(sc_el, "SourceCatalogue element not found in Java sample.xml") @@ -540,7 +544,7 @@ def test_serializationsample_xml_somecontent(self): # ------------------------------------------------------------------ XML schema validation class PythonModelReadJavaTest(unittest.TestCase): - """ This is a placeholder for when it is remotely worth doing the reading of the java serialized instances with the python model + """ This is a placeholder for when it is worth doing the reading of the java serialized instances with the python model - i.e. all the other test need to pass... """ _JAVA_DIR = Path(__file__).parent.parent.parent / "interoperability" / "java" From f0cc6fb89f6c75d0069d29a5c585b07626ffe134 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Mon, 23 Mar 2026 18:36:20 +0000 Subject: [PATCH 11/29] update to use xsdata-pydantic xsdata seems to be be trying harder to be XML compliant --- .../vodml/gradle/plugin/VodmlPydanticTask.kt | 2 +- .../interoperability/python/lifecycle.json | 18 +- .../interoperability/python/lifecycle.xml | 1 + .../interoperability/python/sample.json | 49 +--- .../sample/interoperability/python/sample.xml | 224 +++++++++++------- .../python/serializationsample.json | 57 +++-- .../python/serializationsample.xml | 74 ++++-- tools/xslt/vo-dml2pydantic.xsl | 145 ++++++++---- 8 files changed, 334 insertions(+), 236 deletions(-) diff --git a/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlPydanticTask.kt b/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlPydanticTask.kt index 0c37c250..d17e582e 100644 --- a/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlPydanticTask.kt +++ b/tools/gradletooling/gradle-plugin/src/main/kotlin/net/ivoa/vodml/gradle/plugin/VodmlPydanticTask.kt @@ -8,7 +8,7 @@ import javax.inject.Inject /** * Generates Pydantic model code from the VO-DML models. - * Uses pydantic-xml for XML/JSON serialisation support. + * Uses xsdata-pydantic for XML/JSON serialisation support. */ open class VodmlPydanticTask @Inject constructor(ao1: ArchiveOperations) : VodmlBaseTask(ao1) { diff --git a/tools/gradletooling/sample/interoperability/python/lifecycle.json b/tools/gradletooling/sample/interoperability/python/lifecycle.json index 6e1a6f37..e35fc1fb 100644 --- a/tools/gradletooling/sample/interoperability/python/lifecycle.json +++ b/tools/gradletooling/sample/interoperability/python/lifecycle.json @@ -1,12 +1,8 @@ { "atest": { - "ref1": { - "test1": 3 - }, + "ref1": "id=None test1=3", "contained2": { - "lowr": { - "test3": "rc1" - } + "lowr": "id=None test3='rc1'" }, "contained": [ { @@ -18,19 +14,17 @@ ], "refandcontained": [ { + "id": null, "test3": "rc1" }, { + "id": null, "test3": "rc2" } ] }, - "refcont": { - "test3": "rc1" - }, + "refcont": "id=None test3='rc1'", "refagg": [ - { - "test1": 3 - } + "id=None test1=3" ] } \ No newline at end of file diff --git a/tools/gradletooling/sample/interoperability/python/lifecycle.xml b/tools/gradletooling/sample/interoperability/python/lifecycle.xml index 01d285ac..db1f141b 100644 --- a/tools/gradletooling/sample/interoperability/python/lifecycle.xml +++ b/tools/gradletooling/sample/interoperability/python/lifecycle.xml @@ -1,3 +1,4 @@ + diff --git a/tools/gradletooling/sample/interoperability/python/sample.json b/tools/gradletooling/sample/interoperability/python/sample.json index 4f9dfb47..364e9aa4 100644 --- a/tools/gradletooling/sample/interoperability/python/sample.json +++ b/tools/gradletooling/sample/interoperability/python/sample.json @@ -10,22 +10,17 @@ "unit": { "value": "degree" }, + "xsi_type": "RealQuantity", "value": 2.5 }, "latitude": { "unit": { "value": "degree" }, + "xsi_type": "RealQuantity", "value": 52.5 }, - "frame": { - "name": "J2000", - "documentURI": { - "value": "http://coord.net" - }, - "equinox": "J2000.0", - "system": null - } + "frame": "J2000" }, "classification": "AGN", "description": null, @@ -35,27 +30,16 @@ "unit": { "value": "Jy" }, + "xsi_type": "RealQuantity", "value": 2.5 }, "type": "flux", - "filter": { - "name": "C-Band", - "description": "radio band", - "bandName": "C-Band", - "dataValidityFrom": "2020-01-01T00:00:00Z", - "dataValidityTo": "2025-01-01T20:12:16Z", - "spectralLocation": { - "unit": { - "value": "GHz" - }, - "value": 5.0 - }, - "fpsIdentifier": null - }, + "filter": "C-Band", "error": { "unit": { "value": "Jy" }, + "xsi_type": "RealQuantity", "value": 0.25 }, "description": "lummeas" @@ -65,27 +49,16 @@ "unit": { "value": "Jy" }, + "xsi_type": "RealQuantity", "value": 3.5 }, "type": "flux", - "filter": { - "name": "L-Band", - "description": "radio band", - "bandName": "L-Band", - "dataValidityFrom": "2020-01-01T00:00:00Z", - "dataValidityTo": "2025-01-01T13:12:00Z", - "spectralLocation": { - "unit": { - "value": "GHz" - }, - "value": 1.5 - }, - "fpsIdentifier": null - }, + "filter": "L-Band", "error": { "unit": { "value": "Jy" }, + "xsi_type": "RealQuantity", "value": 0.25 }, "description": "lummeas2" @@ -101,6 +74,7 @@ "description": "test photometric system", "photometryFilter": [ { + "id": null, "name": "C-Band", "description": "radio band", "bandName": "C-Band", @@ -110,11 +84,13 @@ "unit": { "value": "GHz" }, + "xsi_type": "RealQuantity", "value": 5.0 }, "fpsIdentifier": null }, { + "id": null, "name": "L-Band", "description": "radio band", "bandName": "L-Band", @@ -124,6 +100,7 @@ "unit": { "value": "GHz" }, + "xsi_type": "RealQuantity", "value": 1.5 }, "fpsIdentifier": null diff --git a/tools/gradletooling/sample/interoperability/python/sample.xml b/tools/gradletooling/sample/interoperability/python/sample.xml index bde5621f..9d4af10c 100644 --- a/tools/gradletooling/sample/interoperability/python/sample.xml +++ b/tools/gradletooling/sample/interoperability/python/sample.xml @@ -1,91 +1,133 @@ - - testCat - - - testSource - - - - degree - - 2.5 - - - - degree - - 52.5 - - - J2000 - - http://coord.net - - J2000.0 - - - - AGN - - - - - Jy - - 2.5 - - flux - - C-Band - radio band - C-Band - 2020-01-01T00:00:00Z - 2025-01-01T20:12:16Z - - - GHz - - 5.0 - - - - - - Jy - - 0.25 - - lummeas - - - - - Jy - - 3.5 - - flux - - L-Band - radio band - L-Band - 2020-01-01T00:00:00Z - 2025-01-01T13:12:00Z - - - GHz - - 1.5 - - - - - - Jy - - 0.25 - - lummeas2 - - - + + + + + J2000 + + http://coord.net + + J2000.0 + + + + testCat + + cepheid + testSource + + + + degree + + 2.5 + + + + degree + + 52.5 + + + J2000 + + http://coord.net + + J2000.0 + + + AGN + + + + Jy + + 2.5 + + flux + + C-Band + radio band + C-Band + 20-01-01T00:00:00Z + 25-01-01T20:12:16Z + + + GHz + + 5.0 + + + + + Jy + + 0.25 + + lummeas + + + + + Jy + + 3.5 + + flux + + L-Band + radio band + L-Band + 20-01-01T00:00:00Z + 25-01-01T13:12:00Z + + + GHz + + 1.5 + + + + + Jy + + 0.25 + + lummeas2 + + + 0.2 + 0.1 + + + + + 1 + test photometric system + + C-Band + radio band + C-Band + 20-01-01T00:00:00Z + 25-01-01T20:12:16Z + + + GHz + + 5.0 + + + + L-Band + radio band + L-Band + 20-01-01T00:00:00Z + 25-01-01T13:12:00Z + + + GHz + + 1.5 + + + + diff --git a/tools/gradletooling/sample/interoperability/python/serializationsample.json b/tools/gradletooling/sample/interoperability/python/serializationsample.json index 96c9de92..c64f786f 100644 --- a/tools/gradletooling/sample/interoperability/python/serializationsample.json +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.json @@ -1,30 +1,43 @@ { - "ref1": { - "val": { - "value": { - "value": "urn:value" + "refs": { + "refa": [ + { + "id": null, + "val": { + "value": { + "value": "urn:value" + } + } } - } - }, - "ref2": { - "name": "naturalkey", - "val": { - "value": { - "value": "ivo:val" + ], + "refb": [ + { + "name": "naturalkey", + "val": { + "value": { + "value": "ivo:val" + } + } } - } + ] }, - "zval": [ - "some", - "z", - "values" - ], - "con": [ - { - "bname": "dval" - }, + "someContent": [ { - "bname": "eval" + "ref1": "id=None val=altURL(value=anyURI(value='urn:value'))", + "ref2": "naturalkey", + "zval": [ + "some", + "z", + "values" + ], + "con": [ + { + "bname": "dval" + }, + { + "bname": "eval" + } + ] } ] } \ No newline at end of file diff --git a/tools/gradletooling/sample/interoperability/python/serializationsample.xml b/tools/gradletooling/sample/interoperability/python/serializationsample.xml index b709f46d..b8b08d93 100644 --- a/tools/gradletooling/sample/interoperability/python/serializationsample.xml +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.xml @@ -1,26 +1,48 @@ - - - - - urn:value - - - - - naturalkey - - - ivo:val - - - - some - z - values - - dval - - - eval - - + + + + + + + urn:value + + + + + naturalkey + + + ivo:val + + + + + + + + + urn:value + + + + + naturalkey + + + ivo:val + + + + some + z + values + + dval + N1 + + + eval + cube + + + diff --git a/tools/xslt/vo-dml2pydantic.xsl b/tools/xslt/vo-dml2pydantic.xsl index 136d4164..3d008584 100644 --- a/tools/xslt/vo-dml2pydantic.xsl +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -18,11 +18,11 @@ > @@ -44,6 +44,8 @@ " + + ' @@ -84,7 +86,7 @@ - + package = @@ -95,8 +97,32 @@ from __future__ import annotations from typing import Optional, List, Any, Union from enum import Enum -from pydantic_xml import BaseXmlModel, element, attr -from pydantic import Field, field_serializer +from pydantic import BaseModel, ConfigDict, field_serializer +from xsdata_pydantic.fields import field as xsfield +from xsdata.formats.dataclass.context import XmlContext +from xsdata.formats.dataclass.serializers import XmlSerializer +from xsdata.formats.dataclass.serializers.config import SerializerConfig +from xsdata.formats.dataclass.parsers import XmlParser +import xsdata_pydantic.hooks.class_type # register pydantic support with xsdata + + + +# base class to make swapping from pydantic-xml easier in tests - +# TODO this could go in runtime - anyway, does not need to be class method really +class _VodmlXmlBase(BaseModel): + """Base class providing Pydantic BaseModel with xsdata XML serialisation.""" + model_config = ConfigDict(arbitrary_types_allowed=True) + + def to_xml(self, pretty_print: bool = False) -> bytes: + config = SerializerConfig(pretty_print=pretty_print) + ctx = XmlContext(class_type="pydantic") + return XmlSerializer(config=config, context=ctx).render(self).encode("utf-8") + + @classmethod + def from_xml(cls, xml_bytes: bytes): + ctx = XmlContext(class_type="pydantic") + data = xml_bytes.decode("utf-8") if isinstance(xml_bytes, bytes) else xml_bytes + return XmlParser(context=ctx).from_string(data, cls) @@ -106,7 +132,7 @@ from pydantic import Field, field_serializer -from import + @@ -139,7 +165,7 @@ from import @@ -150,8 +176,10 @@ from import class ( - BaseXmlModel - , tag="", nsmap={"xsi": "http://www.w3.org/2001/XMLSchema-instance"}): + _VodmlXmlBase + ): + class Meta: + name = "" """ * @@ -161,16 +189,12 @@ from import """ - - model_config = {"arbitrary_types_allowed": True} - - - _id: Optional[str] = attr(name="_id", default=None) + id: Optional[str] = xsfield({'type': 'Attribute', 'name': '_id'}, default=None) # an identifier field to allow referring to this object from other objects (e.g. via reference fields) without needing to embed it - + @@ -185,7 +209,7 @@ from import @@ -196,8 +220,10 @@ from import class ( - BaseXmlModel - , tag="", nsmap={"xsi": "http://www.w3.org/2001/XMLSchema-instance"}): + _VodmlXmlBase + ): + class Meta: + name = "" """ * @@ -208,7 +234,7 @@ from import - + @@ -243,7 +269,7 @@ class - + @@ -259,7 +285,9 @@ class -class (BaseXmlModel, tag="", nsmap={"xsi": "http://www.w3.org/2001/XMLSchema-instance"}): +class (_VodmlXmlBase): + class Meta: + name = "" """ * * PrimitiveType : @@ -267,7 +295,7 @@ class (BaseXmlModel, tag=" """ - value: = element(tag="value") + value: = xsfield({'type': 'Element', 'name': 'value'}) @@ -280,6 +308,9 @@ class (BaseXmlModel, tag=" + + + @@ -287,13 +318,13 @@ class (BaseXmlModel, tag=" - + - + - + @@ -315,7 +346,7 @@ class (BaseXmlModel, tag=" - + """ * Attribute : subsetted @@ -333,7 +364,7 @@ class (BaseXmlModel, tag=" - + """ * @@ -354,10 +385,10 @@ class (BaseXmlModel, tag=" - + - + @@ -379,13 +410,13 @@ class (BaseXmlModel, tag=" - + - + - + @@ -427,14 +458,36 @@ class (BaseXmlModel, tag=" - -class (BaseXmlModel, tag="refs"): + + + + + + + + + + + + + + + + + + + + + +class (_VodmlXmlBase): + class Meta: + name = "refs" - - : List[] = element(tag="", default=[]) + + : List[] = xsfield({'type': 'Element', 'name': ''}, default_factory=list) @@ -442,20 +495,16 @@ class (BaseXmlModel, tag -class (BaseXmlModel, tag="", ns="", nsmap={ - - - - - - - , "xsi": "http://www.w3.org/2001/XMLSchema-instance"}): - refs: Optional[] = element(tag="refs", default=None) +class (_VodmlXmlBase): + class Meta: + name = "" + namespace = "" + refs: Optional[] = xsfield({'type': 'Element', 'name': 'refs'}, default=None) - - - : List[] = element(tag="", default=[]) + + + : List[] = xsfield({'type': 'Element', 'name': ''}, default_factory=list) From 9d33dae8e99216253c2127c15978efc7c5e5d8f4 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 24 Mar 2026 09:00:39 +0000 Subject: [PATCH 12/29] improve namespace handling --- tools/xslt/vo-dml2pydantic.xsl | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/tools/xslt/vo-dml2pydantic.xsl b/tools/xslt/vo-dml2pydantic.xsl index 3d008584..c2531175 100644 --- a/tools/xslt/vo-dml2pydantic.xsl +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -114,7 +114,7 @@ class _VodmlXmlBase(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) def to_xml(self, pretty_print: bool = False) -> bytes: - config = SerializerConfig(pretty_print=pretty_print) + config = SerializerConfig(indent=" " if pretty_print else None) ctx = XmlContext(class_type="pydantic") return XmlSerializer(config=config, context=ctx).render(self).encode("utf-8") @@ -295,7 +295,7 @@ class (_VodmlXmlBase): * """ - value: = xsfield({'type': 'Element', 'name': 'value'}) + value: = xsfield({'type': 'Element', 'name': 'value', 'namespace': ''}) @@ -318,13 +318,13 @@ class (_VodmlXmlBase): - + - + - + @@ -346,7 +346,7 @@ class (_VodmlXmlBase): - + """ * Attribute : subsetted @@ -364,7 +364,7 @@ class (_VodmlXmlBase): - + """ * @@ -385,10 +385,10 @@ class (_VodmlXmlBase): - + - + @@ -410,13 +410,13 @@ class (_VodmlXmlBase): - + - + - + @@ -437,6 +437,8 @@ class (_VodmlXmlBase): return None if isinstance(value, (str, int, float)): return value + if hasattr(value, "id") and getattr(value, "id") is not None: + return getattr(value, "id") if hasattr(value, "_id") and getattr(value, "_id") is not None: return getattr(value, "_id") if hasattr(value, "name") and getattr(value, "name") is not None: @@ -487,7 +489,7 @@ class (_VodmlXmlBase): - : List[] = xsfield({'type': 'Element', 'name': ''}, default_factory=list) + : List[] = xsfield({'type': 'Element', 'name': '', 'namespace': ''}, default_factory=list) @@ -499,12 +501,12 @@ class (_VodmlXmlBase): class Meta: name = "" namespace = "" - refs: Optional[] = xsfield({'type': 'Element', 'name': 'refs'}, default=None) + refs: Optional[] = xsfield({'type': 'Element', 'name': 'refs', 'namespace': ''}, default=None) - : List[] = xsfield({'type': 'Element', 'name': ''}, default_factory=list) + : List[] = xsfield({'type': 'Element', 'name': '', 'namespace': ''}, default_factory=list) From a78da96e5485ec9f6acc893160b864f5065e1215 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 24 Mar 2026 09:01:59 +0000 Subject: [PATCH 13/29] delete the python generated code on clean --- tools/gradletooling/sample/build.gradle.kts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/gradletooling/sample/build.gradle.kts b/tools/gradletooling/sample/build.gradle.kts index 1a949e3b..28ca7d31 100644 --- a/tools/gradletooling/sample/build.gradle.kts +++ b/tools/gradletooling/sample/build.gradle.kts @@ -120,10 +120,13 @@ python { pip("pydantic:2.12.5") pip("sqlmodel:0.0.22") pip("xsdata-pydantic:24.5") - pip("pydantic-xml:2.19.0") + // pip("pydantic-xml:2.19.0") } +tasks.named("clean") { + delete(vodml.outputPythonDir) //additional clean up of generated python code when doing a clean build - note that even though it looks like it default behaviour is not being overridden +} tasks.register("tpath") { group = "Other" From 70a2a0cd107081b7b60573e6e089595c3326b1d7 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 24 Mar 2026 11:08:33 +0000 Subject: [PATCH 14/29] remove the special reference serialization handling that co-pilot got wrong still do need proper reference handling though --- tools/xslt/vo-dml2pydantic.xsl | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/tools/xslt/vo-dml2pydantic.xsl b/tools/xslt/vo-dml2pydantic.xsl index c2531175..00e97c41 100644 --- a/tools/xslt/vo-dml2pydantic.xsl +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -429,26 +429,7 @@ class (_VodmlXmlBase): - - @field_serializer("") - def _serialize_ref_(self, v: Any) -> Any: - def _as_id(value: Any) -> Any: - if value is None: - return None - if isinstance(value, (str, int, float)): - return value - if hasattr(value, "id") and getattr(value, "id") is not None: - return getattr(value, "id") - if hasattr(value, "_id") and getattr(value, "_id") is not None: - return getattr(value, "_id") - if hasattr(value, "name") and getattr(value, "name") is not None: - return getattr(value, "name") - return str(value) - - if isinstance(v, list): - return [_as_id(i) for i in v] - return _as_id(v) - + From 6e57b1934a89d70966151295e6c31c4b26fe15d4 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 24 Mar 2026 12:06:05 +0000 Subject: [PATCH 15/29] add in the jpatest model --- .../pythontest/src/PydanticInteropTest.py | 607 +++++++++--------- 1 file changed, 309 insertions(+), 298 deletions(-) diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py index 737131b4..8cfefbc8 100644 --- a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -8,7 +8,6 @@ """ import json -import os import unittest import xml.etree.ElementTree as ET from datetime import datetime, timezone @@ -18,19 +17,13 @@ from org.ivoa.dm.filter.filter import PhotometricSystem, PhotometryFilter from org.ivoa.dm.ivoa import RealQuantity, Unit, anyURI +from org.ivoa.dm.jpatest.jpatest import JpatestModel + from org.ivoa.dm.lifecycle.lifecycleTest import LifecycleTestModel, LifecycleTestRefs from org.ivoa.dm.samplemodel.sample import SampleModel, SampleRefs -from org.ivoa.dm.samplemodel.sample_catalog import ( - AlignedEllipse, - LuminosityMeasurement, - LuminosityType, - SDSSSource, - SkyCoordinate, - SkyCoordinateFrame, - SourceClassification, -) + from org.ivoa.dm.samplemodel.sample_catalog_inner import SourceCatalogue -from org.ivoa.dm.serializationsample.MyModel import MyModelRefs +from org.ivoa.dm.serializationsample.MyModel import MyModelModel, MyModelRefs # Output directory (relative to the sample project root). _SAMPLE_DIR = Path(__file__).parent.parent.parent @@ -93,6 +86,36 @@ def _write(filename: str, content: str | bytes) -> None: dest.write_text(content, encoding="utf-8") +def _local_name(tag: str) -> str: + """Return the local part of an XML tag name.""" + return tag.split("}", 1)[-1] + + +def _find_first(root: ET.Element, local_name: str) -> ET.Element | None: + """Return the first element in *root* whose local name matches *local_name*.""" + return next((el for el in root.iter() if _local_name(el.tag) == local_name), None) + + +def _children_named(element: ET.Element, local_name: str) -> list[ET.Element]: + """Return the direct children of *element* with the given local name.""" + return [child for child in list(element) if _local_name(child.tag) == local_name] + + +def _first_child_text(element: ET.Element, local_name: str) -> str | None: + """Return the text of the first direct child called *local_name*.""" + child = next((c for c in list(element) if _local_name(c.tag) == local_name), None) + return child.text if child is not None else None + + +def _read_json(filename: str) -> dict: + with open(_INTEROP_DIR / filename, encoding="utf-8") as fh: + return json.load(fh) + + +def _read_xml_root(filename: str) -> ET.Element: + return ET.parse(str(_INTEROP_DIR / filename)).getroot() + + class SampleModelInteropTest(unittest.TestCase): """ Tests for the Sample model (SourceCatalogue / SDSSSource). @@ -103,6 +126,15 @@ class SampleModelInteropTest(unittest.TestCase): @classmethod def setUpClass(cls): + from org.ivoa.dm.samplemodel.sample_catalog import ( + AlignedEllipse, + LuminosityMeasurement, + LuminosityType, + SDSSSource, + SkyCoordinate, + SkyCoordinateFrame, + SourceClassification, + ) jansky = Unit(value="Jy") degree = Unit(value="degree") ghz = Unit(value="GHz") @@ -131,36 +163,36 @@ def setUpClass(cls): ) cls.ps = PhotometricSystem( - description="test photometric system", - detectorType=1, - photometryFilter=[c_band, l_band], - ) + description="test photometric system", + detectorType=1, + photometryFilter=[c_band, l_band], + ) source = SDSSSource( - name="testSource", - label="cepheid", - classification=SourceClassification.AGN, - position=SkyCoordinate( - longitude=RealQuantity(value=2.5, unit=degree), - latitude=RealQuantity(value=52.5, unit=degree), - frame=frame, - ), - positionError=AlignedEllipse(longError=0.2, latError=0.1), - luminosity=[ - LuminosityMeasurement( - description="lummeas", - type=LuminosityType.FLUX, - value=RealQuantity(value=2.5, unit=jansky), - error=RealQuantity(value=0.25, unit=jansky), - filter=c_band, - ), - LuminosityMeasurement( - description="lummeas2", - type=LuminosityType.FLUX, - value=RealQuantity(value=3.5, unit=jansky), - error=RealQuantity(value=0.25, unit=jansky), - filter=l_band, - ), + name="testSource", + label="cepheid", + classification=SourceClassification.AGN, + position=SkyCoordinate( + longitude=RealQuantity(value=2.5, unit=degree), + latitude=RealQuantity(value=52.5, unit=degree), + frame=frame, + ), + positionError=AlignedEllipse(longError=0.2, latError=0.1), + luminosity=[ + LuminosityMeasurement( + description="lummeas", + type=LuminosityType.FLUX, + value=RealQuantity(value=2.5, unit=jansky), + error=RealQuantity(value=0.25, unit=jansky), + filter=c_band, + ), + LuminosityMeasurement( + description="lummeas2", + type=LuminosityType.FLUX, + value=RealQuantity(value=3.5, unit=jansky), + error=RealQuantity(value=0.25, unit=jansky), + filter=l_band, + ), ], ) @@ -173,45 +205,40 @@ def setUpClass(cls): # ------------------------------------------------------------------ def test_json_serialise(self): - """Serialise the SourceCatalogue to JSON and write to interoperability/python/.""" - - content = json.dumps(self.model, indent=2) - _write("sample.json", content) - # Basic structural checks - data = json.loads(content) - self.assertEqual(data["sourceCatalogue"]["name"], "testCat") - entry = data["sourceCatalogue"]["entry"][0] + json_str = self.model.model_dump_json(indent=2) + _write("sample.json", json_str) + + data = json.loads(json_str) + self.assertEqual(data["sourceCatalogue"][0]["name"], "testCat") + entry = data["sourceCatalogue"][0]["entry"][0] self.assertEqual(entry["name"], "testSource") + self.assertEqual(entry["position"]["frame"], "J2000") self.assertEqual(len(entry["luminosity"]), 2) self.assertAlmostEqual(entry["position"]["longitude"]["value"], 2.5) + self.assertEqual(data["photometricSystem"][0]["photometryFilter"][1]["name"], "L-Band") def test_json_round_trip(self): - """Verify that the JSON output can be read back by pydantic.""" - json_str = self.model.model_dump_json() - recovered = SourceCatalogue.model_validate_json(json_str) - self.assertEqual(recovered.name, self.sc.name) - self.assertEqual(len(recovered.entry), 1) - - # ------------------------------------------------------------------ - # XML - # ------------------------------------------------------------------ + recovered = SampleModel.model_validate_json(self.model.model_dump_json()) + self.assertEqual(recovered.sourceCatalogue[0].name, "testCat") + self.assertEqual(recovered.sourceCatalogue[0].entry[0].name, "testSource") + self.assertEqual(recovered.refs.skyCoordinateFrame[0].name, "J2000") def test_xml_serialise(self): - """Serialise the SourceCatalogue to XML and write to interoperability/python/.""" xml_bytes = self.model.to_xml(pretty_print=True) _write("sample.xml", xml_bytes) _validate_xml(xml_bytes, "Sample.vo-dml.xsd", self) - # Basic check: output is non-empty XML - self.assertIn(b" dict | None: - """Return the first content entry whose ``@type`` contains *type_fragment*.""" - for item in java_model_root.get("content", []): - if type_fragment in item.get("@type", ""): - return item - return None + def test_json_round_trip(self): + recovered = JpatestModel.model_validate_json(self.model.model_dump_json()) + parent = recovered.parent[0] + self.assertEqual(parent.dval.intatt, "intatt") + self.assertEqual(parent.eval.intatt, "intatt_e") + self.assertEqual(parent.tval.p.x, 1.5) + self.assertEqual(parent.cval.rval, "jpatest-ReferredTo2_1004") - @staticmethod - def _parse_xml_root(path: Path) -> ET.Element: - """Return the root Element of the parsed XML file at *path*.""" - return ET.parse(str(path)).getroot() + def test_xml_serialise(self): + xml_bytes = self.model.to_xml(pretty_print=True) + _write("jpatest.xml", xml_bytes) + _validate_xml(xml_bytes, "jpatest.vo-dml.xsd", self) + root = ET.fromstring(xml_bytes) + self.assertEqual(_local_name(root.tag), "jpatestModel") + parent = _find_first(root, "parent") + self.assertIsNotNone(parent) + self.assertEqual(_first_child_text(parent, "rval"), "jpatest-ReferredTo1_1003") + dval = _find_first(parent, "dval") + self.assertEqual(_first_child_text(dval, "dvals"), "astring") + lval = _children_named(parent, "lval") + self.assertEqual(len(lval), 3) + self.assertEqual([_first_child_text(child, "sval") for child in lval], ["First", "Second", "Third"]) + def test_xml_round_trip(self): + recovered = JpatestModel.from_xml(self.model.to_xml(pretty_print=True)) + parent = recovered.parent[0] + self.assertEqual(parent.dval.basestr, "base") + self.assertEqual(parent.eval.evals, "evals") + self.assertEqual(parent.lval[1].ival, 2) + self.assertEqual(parent.tval.dt, "thing") - # ------------------------------------------------------------------ sample JSON +class PythonNonModelReadTest(unittest.TestCase): + """Validate the Python-written interoperability files without using the generated models.""" def test_sample_json_source_catalogue(self): - """Parse Python sample.json and validate SourceCatalogue data.""" - with open(_INTEROP_DIR / "sample.json") as fh: - data = json.load(fh) - - root = data.get("SampleModel", {}) - self.assertIn("refs", root, "Missing 'refs' section in Java sample.json") - self.assertIn("content", root, "Missing 'content' section in Java sample.json") - - sc = self._find_content(root, "SourceCatalogue") - self.assertIsNotNone(sc, "SourceCatalogue not found in Java sample.json content") - self.assertEqual(sc["name"], "testCat") + data = _read_json("sample.json") + self.assertIn("sourceCatalogue", data) + self.assertIn("photometricSystem", data) - entries = sc.get("entry", []) - self.assertEqual(len(entries), 1, "Expected exactly 1 entry in SourceCatalogue") - entry = entries[0] + catalogue = data["sourceCatalogue"][0] + self.assertEqual(catalogue["name"], "testCat") + entry = catalogue["entry"][0] self.assertEqual(entry["name"], "testSource") self.assertEqual(entry["classification"], "AGN") - self.assertEqual(len(entry.get("luminosity", [])), 2) - - position = entry["position"] - self.assertAlmostEqual(position["longitude"]["value"], 2.5) - self.assertAlmostEqual(position["latitude"]["value"], 52.5) + self.assertEqual(entry["position"]["frame"], "J2000") def test_sample_json_photometric_system(self): - """Parse Python sample.json and validate PhotometricSystem data.""" - with open(_INTEROP_DIR / "sample.json") as fh: - data = json.load(fh) - - root = data.get("SampleModel", {}) - ps = self._find_content(root, "PhotometricSystem") - self.assertIsNotNone(ps, "PhotometricSystem not found in Java sample.json") + data = _read_json("sample.json") + ps = data["photometricSystem"][0] self.assertEqual(ps["detectorType"], 1) - - filters = ps.get("photometryFilter", []) - self.assertEqual(len(filters), 2) - names = {f["name"] for f in filters} - self.assertIn("C-Band", names) - self.assertIn("L-Band", names) - - # ------------------------------------------------------------------ sample XML + names = [item["name"] for item in ps["photometryFilter"]] + self.assertEqual(names, ["C-Band", "L-Band"]) def test_sample_xml_source_catalogue(self): - """Parse Python sample.xml and validate SourceCatalogue data.""" - root = self._parse_xml_root(_INTEROP_DIR / "sample.xml") - - # The Java XML uses dotted element names like `catalog.inner.SourceCatalogue` - sc_el = next( - (el for el in root.iter() if "SourceCatalogue" in self), - None, - ) - self.assertIsNotNone(sc_el, "SourceCatalogue element not found in Java sample.xml") - - name_el = sc_el.find("name") - self.assertIsNotNone(name_el, "No child under SourceCatalogue") - self.assertEqual(name_el.text, "testCat") - - # Find entry elements (may be wrapped) - entries = list(sc_el.iter("entry")) - self.assertGreaterEqual(len(entries), 1, "No elements found") - entry = entries[0] - - name_sub = entry.find("name") - self.assertIsNotNone(name_sub) - self.assertEqual(name_sub.text, "testSource") - - classification = entry.find("classification") - self.assertIsNotNone(classification) - self.assertEqual(classification.text, "AGN") - - # ------------------------------------------------------------------ lifecycle JSON + root = _read_xml_root("sample.xml") + catalogue = _find_first(root, "sourceCatalogue") + self.assertIsNotNone(catalogue) + self.assertEqual(_first_child_text(catalogue, "name"), "testCat") + entry = _find_first(catalogue, "entry") + self.assertEqual(_first_child_text(entry, "name"), "testSource") + self.assertEqual(_first_child_text(entry, "classification"), "AGN") def test_lifecycle_json_atest2(self): - """Parse Python lifecycle.json and validate ATest2 data.""" - with open(_INTEROP_DIR / "lifecycle.json") as fh: - data = json.load(fh) - - root = data.get("LifecycleTestModel", {}) - self.assertIn("content", root, "Missing 'content' in Java lifecycle.json") - - atest2 = self._find_content(root, "ATest2") - self.assertIsNotNone(atest2, "ATest2 not found in Java lifecycle.json") - - atest = atest2.get("atest", {}) - self.assertIsNotNone(atest, "Missing 'atest' inside ATest2") - - contained = atest.get("contained", []) - self.assertEqual(len(contained), 2, "Expected 2 contained items in ATest.contained") - test2_values = [c["test2"] for c in contained] - self.assertIn("firstcontained", test2_values) - self.assertIn("secondContained", test2_values) - - refandcontained = atest.get("refandcontained", []) - self.assertEqual(len(refandcontained), 2, "Expected 2 items in refandcontained") - - # ------------------------------------------------------------------ lifecycle XML + data = _read_json("lifecycle.json") + atest2 = data["aTest2"][0] + self.assertEqual(atest2["refagg"], ["lifecycleTest-ReferredTo_1011"]) + self.assertEqual(len(atest2["atest"]["contained"]), 2) + self.assertEqual(atest2["atest"]["contained2"]["lowr"], "lifecycleTest-ReferredLifeCycle_1012") def test_lifecycle_xml_atest2(self): - """Parse Python lifecycle.xml and validate ATest2 data.""" - root = self._parse_xml_root(_INTEROP_DIR / "lifecycle.xml") - - atest2 = root.find(".//aTest2") - self.assertIsNotNone(atest2, " not found in lifecycle.xml") - - atest_el = atest2.find("atest") - self.assertIsNotNone(atest_el, "No child inside ") - - # contained is a wrapper element that itself contains children - contained_wrapper = atest_el.find("contained") - self.assertIsNotNone(contained_wrapper, "No wrapper under ") - contained_items = list(contained_wrapper.findall("contained")) - self.assertEqual(len(contained_items), 2, "Expected 2 items") - test2_texts = [c.findtext("test2") for c in contained_items] - self.assertIn("firstcontained", test2_texts) - self.assertIn("secondContained", test2_texts) - - # ------------------------------------------------------------------ serialisation example JSON + root = _read_xml_root("lifecycle.xml") + atest2 = _find_first(root, "aTest2") + self.assertIsNotNone(atest2) + self.assertEqual(_first_child_text(atest2, "refcont"), "lifecycleTest-ReferredLifeCycle_1012") + contained_values = [el.text for el in root.iter() if _local_name(el.tag) == "test2"] + self.assertEqual(contained_values, ["firstcontained", "secondContained"]) def test_serializationsample_json_somecontent(self): - """Parse Python serializationsample.json and validate SomeContent data.""" - with open(_INTEROP_DIR / "serializationsample.json") as fh: - data = json.load(fh) - - root = data.get("MyModelModel", {}) - sc = self._find_content(root, "SomeContent") - self.assertIsNotNone(sc, "SomeContent not found in Java serializationsample.json") - - self.assertEqual(sc.get("zval", []), ["some", "z", "values"]) - - con = sc.get("con", []) - self.assertEqual(len(con), 2, "Expected 2 'con' items") - types = [c.get("@type", "") for c in con] - self.assertTrue(any("Dcont" in t for t in types), "Dcont not found in con types") - self.assertTrue(any("Econt" in t for t in types), "Econt not found in con types") - - # ------------------------------------------------------------------ serialisation example XML + data = _read_json("serializationsample.json") + content = data["someContent"][0] + self.assertEqual(content["zval"], ["some", "z", "values"]) + self.assertEqual(content["ref1"], "refa-1") + self.assertEqual(content["ref2"], "naturalkey") + self.assertEqual(len(content["con"]), 2) def test_serializationsample_xml_somecontent(self): - """Parse Python serializationsample.xml and validate SomeContent data.""" - root = self._parse_xml_root(_INTEROP_DIR / "serializationsample.xml") - - sc_el = root.find(".//someContent") - self.assertIsNotNone(sc_el, " not found in serializationsample.xml") - - # zval items are wrapped in ... - zvals_el = sc_el.find("zvals") - self.assertIsNotNone(zvals_el, "No element under ") - zvals = [el.text for el in zvals_el.findall("zval")] + root = _read_xml_root("serializationsample.xml") + some_content = _find_first(root, "someContent") + self.assertIsNotNone(some_content) + self.assertEqual(_first_child_text(some_content, "ref1"), "refa-1") + zvals = [el.text for el in root.iter() if _local_name(el.tag) == "zval"] self.assertEqual(zvals, ["some", "z", "values"]) - con_el = sc_el.find("con") - self.assertIsNotNone(con_el, "No element under ") - base_c_items = list(con_el.iter("baseC")) - self.assertEqual(len(base_c_items), 2, "Expected 2 items") + def test_jpatest_json_parent(self): + data = _read_json("jpatest.json") + parent = data["parent"][0] + self.assertEqual(parent["dval"]["dvals"], "astring") + self.assertEqual(parent["eval"]["evals"], "evals") + self.assertEqual(parent["cval"]["rval"], "jpatest-ReferredTo2_1004") + self.assertEqual(parent["tval"]["dt"], "thing") - # xsi:type should distinguish Dcont from Econt - xsi_type_attr = "{http://www.w3.org/2001/XMLSchema-instance}type" - xtypes = [el.get(xsi_type_attr, "") for el in base_c_items] - self.assertTrue(any("Dcont" in xt for xt in xtypes), "Dcont xsi:type not found") - self.assertTrue(any("Econt" in xt for xt in xtypes), "Econt xsi:type not found") + def test_jpatest_xml_parent(self): + root = _read_xml_root("jpatest.xml") + parent = _find_first(root, "parent") + self.assertIsNotNone(parent) + self.assertEqual(_first_child_text(parent, "rval"), "jpatest-ReferredTo1_1003") + self.assertEqual(_first_child_text(_find_first(parent, "tval"), "dt"), "thing") + lvals = _children_named(parent, "lval") + self.assertEqual([_first_child_text(child, "sval") for child in lvals], ["First", "Second", "Third"]) - # ------------------------------------------------------------------ XML schema validation class PythonModelReadJavaTest(unittest.TestCase): - """ This is a placeholder for when it is worth doing the reading of the java serialized instances with the python model - i.e. all the other test need to pass... - """ - _JAVA_DIR = Path(__file__).parent.parent.parent / "interoperability" / "java" + """Sanity-check that the Java interoperability fixtures exist.""" - # ------------------------------------------------------------------ file existence + _JAVA_DIR = Path(__file__).parent.parent.parent / "interoperability" / "java" def test_java_interop_files_exist(self): - """All expected Java interoperability files must be present.""" expected = [ - "sample.json", "sample.xml", - "lifecycle.json", "lifecycle.xml", - "serializationsample.json", "serializationsample.xml", + "sample.json", + "sample.xml", + "lifecycle.json", + "lifecycle.xml", + "serializationsample.json", + "serializationsample.xml", + "jpatest.json", + "jpatest.xml", ] missing = [f for f in expected if not (self._JAVA_DIR / f).exists()] self.assertFalse( From cc77245581aa8e2d15ddbeba401691976afd7273 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 24 Mar 2026 12:06:46 +0000 Subject: [PATCH 16/29] add in namespace prefix for the model --- tools/xslt/vo-dml2pydantic.xsl | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/xslt/vo-dml2pydantic.xsl b/tools/xslt/vo-dml2pydantic.xsl index 00e97c41..238f2194 100644 --- a/tools/xslt/vo-dml2pydantic.xsl +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -87,7 +87,9 @@ - + + + package = @@ -116,7 +118,10 @@ class _VodmlXmlBase(BaseModel): def to_xml(self, pretty_print: bool = False) -> bytes: config = SerializerConfig(indent=" " if pretty_print else None) ctx = XmlContext(class_type="pydantic") - return XmlSerializer(config=config, context=ctx).render(self).encode("utf-8") + + + + return XmlSerializer(config=config, context=ctx).render(self, ns_map=ns_map).encode("utf-8") @classmethod def from_xml(cls, xml_bytes: bytes): From 5e2b5da8711ff295142bd6353d68b5c2a347bdd5 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 24 Mar 2026 12:34:40 +0000 Subject: [PATCH 17/29] added wrapper for attribute list --- tools/xslt/vo-dml2pydantic.xsl | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/xslt/vo-dml2pydantic.xsl b/tools/xslt/vo-dml2pydantic.xsl index 238f2194..a308f2e1 100644 --- a/tools/xslt/vo-dml2pydantic.xsl +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -199,7 +199,7 @@ class _VodmlXmlBase(BaseModel): - + @@ -239,7 +239,7 @@ class _VodmlXmlBase(BaseModel): """ - + @@ -316,6 +316,7 @@ class (_VodmlXmlBase): + @@ -323,7 +324,7 @@ class (_VodmlXmlBase): - + From acc4db0d090045544a5b1c61f945f0ed5dfe5c7a Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 24 Mar 2026 12:58:40 +0000 Subject: [PATCH 18/29] more improved namespace handling --- tools/xslt/vo-dml2pydantic.xsl | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tools/xslt/vo-dml2pydantic.xsl b/tools/xslt/vo-dml2pydantic.xsl index a308f2e1..44c6d3c4 100644 --- a/tools/xslt/vo-dml2pydantic.xsl +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -174,7 +174,7 @@ class _VodmlXmlBase(BaseModel): - + @@ -185,6 +185,7 @@ class _VodmlXmlBase(BaseModel): ): class Meta: name = "" + namespace = "" """ * @@ -218,7 +219,7 @@ class _VodmlXmlBase(BaseModel): - + @@ -229,6 +230,7 @@ class _VodmlXmlBase(BaseModel): ): class Meta: name = "" + namespace = "" """ * @@ -316,7 +318,7 @@ class (_VodmlXmlBase): - + @@ -370,7 +372,7 @@ class (_VodmlXmlBase): - + """ * From 8fd97f0c6cb7e2a1a15509f1fbdbc9bab6cc9f95 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 24 Mar 2026 12:59:42 +0000 Subject: [PATCH 19/29] latest baseline for the python test serializations --- .../interoperability/python/jpatest.json | 67 +++++ .../interoperability/python/jpatest.xml | 56 ++++ .../interoperability/python/lifecycle.json | 61 ++-- .../interoperability/python/lifecycle.xml | 55 ++-- .../interoperability/python/sample.json | 231 +++++++++------ .../sample/interoperability/python/sample.xml | 270 +++++++++--------- .../python/serializationsample.json | 4 +- .../python/serializationsample.xml | 85 +++--- 8 files changed, 507 insertions(+), 322 deletions(-) create mode 100644 tools/gradletooling/sample/interoperability/python/jpatest.json create mode 100644 tools/gradletooling/sample/interoperability/python/jpatest.xml diff --git a/tools/gradletooling/sample/interoperability/python/jpatest.json b/tools/gradletooling/sample/interoperability/python/jpatest.json new file mode 100644 index 00000000..d415ccd5 --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/jpatest.json @@ -0,0 +1,67 @@ +{ + "refs": { + "referredTo3": [ + { + "id": "jpatest-ReferredTo3_1002", + "sval": "ref in dtype", + "ival": 3 + } + ], + "referredTo2": [ + { + "id": "jpatest-ReferredTo2_1004", + "sval": "lower ref" + } + ], + "referredTo1": [ + { + "id": "jpatest-ReferredTo1_1003", + "sval": "top level ref" + } + ] + }, + "parent": [ + { + "dval": { + "basestr": "base", + "dref": "jpatest-ReferredTo3_1002", + "intatt": "intatt", + "dvalr": 1.1, + "dvals": "astring" + }, + "eval": { + "basestr": "basestre_e", + "dref": "jpatest-ReferredTo3_1002", + "intatt": "intatt_e", + "evalr": 1.2, + "evals": "evals" + }, + "rval": "jpatest-ReferredTo1_1003", + "cval": { + "rval": "jpatest-ReferredTo2_1004" + }, + "tval": { + "p": { + "x": 1.5, + "y": 3.0 + }, + "dt": "thing" + }, + "lval": [ + { + "sval": "First", + "ival": 1 + }, + { + "sval": "Second", + "ival": 2 + }, + { + "sval": "Third", + "ival": 3 + } + ] + } + ], + "sub": [] +} \ No newline at end of file diff --git a/tools/gradletooling/sample/interoperability/python/jpatest.xml b/tools/gradletooling/sample/interoperability/python/jpatest.xml new file mode 100644 index 00000000..2fe4e5d2 --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/jpatest.xml @@ -0,0 +1,56 @@ + + + + + ref in dtype + 3 + + + lower ref + + + top level ref + + + + + base + jpatest-ReferredTo3_1002 + intatt + 1.1 + astring + + + basestre_e + jpatest-ReferredTo3_1002 + intatt_e + 1.2 + evals + + jpatest-ReferredTo1_1003 + + jpatest-ReferredTo2_1004 + + +

+ 1.5 + 3.0 +

+
thing
+
+ + + First + 1 + + + Second + 2 + + + Third + 3 + + +
+
diff --git a/tools/gradletooling/sample/interoperability/python/lifecycle.json b/tools/gradletooling/sample/interoperability/python/lifecycle.json index e35fc1fb..6289d83c 100644 --- a/tools/gradletooling/sample/interoperability/python/lifecycle.json +++ b/tools/gradletooling/sample/interoperability/python/lifecycle.json @@ -1,30 +1,43 @@ { - "atest": { - "ref1": "id=None test1=3", - "contained2": { - "lowr": "id=None test3='rc1'" - }, - "contained": [ + "refs": { + "referredTo": [ { - "test2": "firstcontained" - }, - { - "test2": "secondContained" - } - ], - "refandcontained": [ - { - "id": null, - "test3": "rc1" - }, - { - "id": null, - "test3": "rc2" + "id": "lifecycleTest-ReferredTo_1011", + "test1": 3 } ] }, - "refcont": "id=None test3='rc1'", - "refagg": [ - "id=None test1=3" - ] + "aTest2": [ + { + "atest": { + "ref1": "lifecycleTest-ReferredTo_1011", + "contained2": { + "lowr": "lifecycleTest-ReferredLifeCycle_1012" + }, + "contained": [ + { + "test2": "firstcontained" + }, + { + "test2": "secondContained" + } + ], + "refandcontained": [ + { + "id": "lifecycleTest-ReferredLifeCycle_1012", + "test3": "rc1" + }, + { + "id": "lifecycleTest-ReferredLifeCycle_1013", + "test3": "rc2" + } + ] + }, + "refcont": "lifecycleTest-ReferredLifeCycle_1012", + "refagg": [ + "lifecycleTest-ReferredTo_1011" + ] + } + ], + "aTest3": [] } \ No newline at end of file diff --git a/tools/gradletooling/sample/interoperability/python/lifecycle.xml b/tools/gradletooling/sample/interoperability/python/lifecycle.xml index db1f141b..5a2a0896 100644 --- a/tools/gradletooling/sample/interoperability/python/lifecycle.xml +++ b/tools/gradletooling/sample/interoperability/python/lifecycle.xml @@ -1,31 +1,30 @@ - - - + + + 3 - - - + + + + + lifecycleTest-ReferredTo_1011 + + lifecycleTest-ReferredLifeCycle_1012 + + + firstcontained + + + secondContained + + rc1 - - - - firstcontained - - - secondContained - - - rc1 - - - rc2 - - - - rc1 - - - 3 - - + + + rc2 + +
+ lifecycleTest-ReferredLifeCycle_1012 + lifecycleTest-ReferredTo_1011 +
+ diff --git a/tools/gradletooling/sample/interoperability/python/sample.json b/tools/gradletooling/sample/interoperability/python/sample.json index 364e9aa4..f0a7e81c 100644 --- a/tools/gradletooling/sample/interoperability/python/sample.json +++ b/tools/gradletooling/sample/interoperability/python/sample.json @@ -1,110 +1,163 @@ { - "sourceCatalogue": { - "name": "testCat", - "entry": [ + "refs": { + "skyCoordinateFrame": [ { - "label": "cepheid", - "name": "testSource", - "position": { - "longitude": { - "unit": { - "value": "degree" - }, - "xsi_type": "RealQuantity", - "value": 2.5 - }, - "latitude": { - "unit": { - "value": "degree" - }, - "xsi_type": "RealQuantity", - "value": 52.5 - }, - "frame": "J2000" + "name": "J2000", + "documentURI": { + "value": "http://coord.net" }, - "classification": "AGN", - "description": null, - "luminosity": [ - { - "value": { + "equinox": "J2000.0", + "system": null + } + ] + }, + "sourceCatalogue": [ + { + "name": "testCat", + "entry": [ + { + "label": "cepheid", + "name": "testSource", + "position": { + "longitude": { "unit": { - "value": "Jy" + "value": "degree" }, "xsi_type": "RealQuantity", "value": 2.5 }, - "type": "flux", - "filter": "C-Band", - "error": { + "latitude": { "unit": { - "value": "Jy" + "value": "degree" }, "xsi_type": "RealQuantity", - "value": 0.25 + "value": 52.5 }, - "description": "lummeas" + "frame": { + "name": "J2000", + "documentURI": { + "value": "http://coord.net" + }, + "equinox": "J2000.0", + "system": null + } }, - { - "value": { - "unit": { - "value": "Jy" + "classification": "AGN", + "description": null, + "luminosity": [ + { + "value": { + "unit": { + "value": "Jy" + }, + "xsi_type": "RealQuantity", + "value": 2.5 }, - "xsi_type": "RealQuantity", - "value": 3.5 + "type": "flux", + "filter": { + "id": null, + "name": "C-Band", + "description": "radio band", + "bandName": "C-Band", + "dataValidityFrom": "2020-01-01T00:00:00Z", + "dataValidityTo": "2025-01-01T20:12:16Z", + "spectralLocation": { + "unit": { + "value": "GHz" + }, + "xsi_type": "RealQuantity", + "value": 5.0 + }, + "fpsIdentifier": null + }, + "error": { + "unit": { + "value": "Jy" + }, + "xsi_type": "RealQuantity", + "value": 0.25 + }, + "description": "lummeas" }, - "type": "flux", - "filter": "L-Band", - "error": { - "unit": { - "value": "Jy" + { + "value": { + "unit": { + "value": "Jy" + }, + "xsi_type": "RealQuantity", + "value": 3.5 }, - "xsi_type": "RealQuantity", - "value": 0.25 + "type": "flux", + "filter": { + "id": null, + "name": "L-Band", + "description": "radio band", + "bandName": "L-Band", + "dataValidityFrom": "2020-01-01T00:00:00Z", + "dataValidityTo": "2025-01-01T13:12:00Z", + "spectralLocation": { + "unit": { + "value": "GHz" + }, + "xsi_type": "RealQuantity", + "value": 1.5 + }, + "fpsIdentifier": null + }, + "error": { + "unit": { + "value": "Jy" + }, + "xsi_type": "RealQuantity", + "value": 0.25 + }, + "description": "lummeas2" + } + ] + } + ], + "aTest": null, + "aTestMore": null + } + ], + "photometricSystem": [ + { + "detectorType": 1, + "description": "test photometric system", + "photometryFilter": [ + { + "id": null, + "name": "C-Band", + "description": "radio band", + "bandName": "C-Band", + "dataValidityFrom": "2020-01-01T00:00:00Z", + "dataValidityTo": "2025-01-01T20:12:16Z", + "spectralLocation": { + "unit": { + "value": "GHz" }, - "description": "lummeas2" - } - ] - } - ], - "aTest": null, - "aTestMore": null - }, - "photometricSystem": { - "detectorType": 1, - "description": "test photometric system", - "photometryFilter": [ - { - "id": null, - "name": "C-Band", - "description": "radio band", - "bandName": "C-Band", - "dataValidityFrom": "2020-01-01T00:00:00Z", - "dataValidityTo": "2025-01-01T20:12:16Z", - "spectralLocation": { - "unit": { - "value": "GHz" + "xsi_type": "RealQuantity", + "value": 5.0 }, - "xsi_type": "RealQuantity", - "value": 5.0 + "fpsIdentifier": null }, - "fpsIdentifier": null - }, - { - "id": null, - "name": "L-Band", - "description": "radio band", - "bandName": "L-Band", - "dataValidityFrom": "2020-01-01T00:00:00Z", - "dataValidityTo": "2025-01-01T13:12:00Z", - "spectralLocation": { - "unit": { - "value": "GHz" + { + "id": null, + "name": "L-Band", + "description": "radio band", + "bandName": "L-Band", + "dataValidityFrom": "2020-01-01T00:00:00Z", + "dataValidityTo": "2025-01-01T13:12:00Z", + "spectralLocation": { + "unit": { + "value": "GHz" + }, + "xsi_type": "RealQuantity", + "value": 1.5 }, - "xsi_type": "RealQuantity", - "value": 1.5 - }, - "fpsIdentifier": null - } - ] - } + "fpsIdentifier": null + } + ] + } + ] } \ No newline at end of file diff --git a/tools/gradletooling/sample/interoperability/python/sample.xml b/tools/gradletooling/sample/interoperability/python/sample.xml index 9d4af10c..f40cf402 100644 --- a/tools/gradletooling/sample/interoperability/python/sample.xml +++ b/tools/gradletooling/sample/interoperability/python/sample.xml @@ -1,133 +1,139 @@ - - - - J2000 - - http://coord.net - - J2000.0 - - - - testCat - - cepheid - testSource - - - - degree - - 2.5 - - - - degree - - 52.5 - - - J2000 - - http://coord.net - - J2000.0 - - - AGN - - - - Jy - - 2.5 - - flux - - C-Band - radio band - C-Band - 20-01-01T00:00:00Z - 25-01-01T20:12:16Z - - - GHz - - 5.0 - - - - - Jy - - 0.25 - - lummeas - - - - - Jy - - 3.5 - - flux - - L-Band - radio band - L-Band - 20-01-01T00:00:00Z - 25-01-01T13:12:00Z - - - GHz - - 1.5 - - - - - Jy - - 0.25 - - lummeas2 - - - 0.2 - 0.1 - - - - - 1 - test photometric system - - C-Band - radio band - C-Band - 20-01-01T00:00:00Z - 25-01-01T20:12:16Z - - - GHz - - 5.0 - - - - L-Band - radio band - L-Band - 20-01-01T00:00:00Z - 25-01-01T13:12:00Z - - - GHz - - 1.5 - - - - + + + + J2000 + + http://coord.net + + J2000.0 + + + + testCat + + + + testSource + + + + degree + + 2.5 + + + + degree + + 52.5 + + + J2000 + + http://coord.net + + J2000.0 + + + AGN + + + + + Jy + + 2.5 + + flux + + C-Band + radio band + C-Band + 20-01-01T00:00:00Z + 25-01-01T20:12:16Z + + + GHz + + 5.0 + + + + + Jy + + 0.25 + + lummeas + + + + + Jy + + 3.5 + + flux + + L-Band + radio band + L-Band + 20-01-01T00:00:00Z + 25-01-01T13:12:00Z + + + GHz + + 1.5 + + + + + Jy + + 0.25 + + lummeas2 + + + + 0.2 + 0.1 + + + + + + 1 + test photometric system + + + C-Band + radio band + C-Band + 20-01-01T00:00:00Z + 25-01-01T20:12:16Z + + + GHz + + 5.0 + + + + L-Band + radio band + L-Band + 20-01-01T00:00:00Z + 25-01-01T13:12:00Z + + + GHz + + 1.5 + + + + + diff --git a/tools/gradletooling/sample/interoperability/python/serializationsample.json b/tools/gradletooling/sample/interoperability/python/serializationsample.json index c64f786f..f843114e 100644 --- a/tools/gradletooling/sample/interoperability/python/serializationsample.json +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.json @@ -2,7 +2,7 @@ "refs": { "refa": [ { - "id": null, + "id": "refa-1", "val": { "value": { "value": "urn:value" @@ -23,7 +23,7 @@ }, "someContent": [ { - "ref1": "id=None val=altURL(value=anyURI(value='urn:value'))", + "ref1": "refa-1", "ref2": "naturalkey", "zval": [ "some", diff --git a/tools/gradletooling/sample/interoperability/python/serializationsample.xml b/tools/gradletooling/sample/interoperability/python/serializationsample.xml index b8b08d93..8901e39c 100644 --- a/tools/gradletooling/sample/interoperability/python/serializationsample.xml +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.xml @@ -1,48 +1,39 @@ - - - - - - urn:value - - - - - naturalkey - - - ivo:val - - - - - - - - - urn:value - - - - - naturalkey - - - ivo:val - - - - some - z - values - - dval - N1 - - - eval - cube - - - + + + + + + urn:value + + + + + naturalkey + + + ivo:val + + + + + + refa-1 + naturalkey + + some + z + values + + + + dval + N1 + + + eval + cube + + + + From 966006cf1228901aab7fa1e682917c2ba1d7abfc Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Fri, 10 Apr 2026 14:37:57 +0100 Subject: [PATCH 20/29] remove the spurious newlines from XML test output --- .../validation/AbstractBaseValidation.java | 4 +- .../sample/interoperability/java/jpatest.xml | 56 +----------- .../interoperability/java/lifecycle.xml | 34 +------ .../interoperability/java/notstccoords.json | 32 +++---- .../interoperability/java/notstccoords.xml | 91 +++---------------- .../sample/interoperability/java/sample.xml | 88 +----------------- .../java/serializationsample.xml | 35 +------ 7 files changed, 37 insertions(+), 303 deletions(-) diff --git a/runtime/java/src/main/java/org/ivoa/vodml/validation/AbstractBaseValidation.java b/runtime/java/src/main/java/org/ivoa/vodml/validation/AbstractBaseValidation.java index c779186b..7bdfc0fe 100644 --- a/runtime/java/src/main/java/org/ivoa/vodml/validation/AbstractBaseValidation.java +++ b/runtime/java/src/main/java/org/ivoa/vodml/validation/AbstractBaseValidation.java @@ -128,13 +128,13 @@ protected > RoundTripResult roundtripXML(VodmlModel - + - - ref in dtype - 3 - - - lower ref - - - top level ref - - - - - base - jpatest-ReferredTo3_1002 - intatt - 1.1 - astring - - - basestre_e - jpatest-ReferredTo3_1002 - intatt_e - 1.2 - evals - - jpatest-ReferredTo1_1003 - - jpatest-ReferredTo2_1004 - - - - First - 1 - - - Second - 2 - - - Third - 3 - - - -

- 1.5 - 3.0 -

-
thing
-
-
-
diff --git a/tools/gradletooling/sample/interoperability/java/lifecycle.xml b/tools/gradletooling/sample/interoperability/java/lifecycle.xml index dd645b14..3e644736 100644 --- a/tools/gradletooling/sample/interoperability/java/lifecycle.xml +++ b/tools/gradletooling/sample/interoperability/java/lifecycle.xml @@ -1,65 +1,33 @@ - - + - - 3 - - - - lifecycleTest-ReferredTo_1011 - - lifecycleTest-ReferredTo_1011 - - - firstcontained - - - secondContained - - - - - rc1 - - - rc2 - - - - lifecycleTest-ReferredLifeCycle_1012 - - - lifecycleTest-ReferredLifeCycle_1012 - - diff --git a/tools/gradletooling/sample/interoperability/java/notstccoords.json b/tools/gradletooling/sample/interoperability/java/notstccoords.json index 64bbf541..e5842789 100644 --- a/tools/gradletooling/sample/interoperability/java/notstccoords.json +++ b/tools/gradletooling/sample/interoperability/java/notstccoords.json @@ -2,6 +2,20 @@ "CoordsModel" : { "refs" : { "coords:CoordSys" : [ { + "@type" : "coords:SpaceSys", + "_id" : 1026, + "frame" : { + "@type" : "coords:SpaceFrame", + "_id" : 0, + "refPosition" : { + "@type" : "coords:StdRefLocation", + "_id" : 0, + "position" : "TOPOCENTER" + }, + "spaceRefFrame" : "ICRS", + "planetaryEphem" : "DE432" + } + }, { "@type" : "coords:TimeSys", "_id" : 1027, "frame" : { @@ -19,21 +33,7 @@ "epoch" : "J2014.25", "position" : { "@type" : "coords:LonLatPoint", - "coordSys" : { - "@type" : "coords:SpaceSys", - "_id" : 1026, - "frame" : { - "@type" : "coords:SpaceFrame", - "_id" : 0, - "refPosition" : { - "@type" : "coords:StdRefLocation", - "_id" : 0, - "position" : "TOPOCENTER" - }, - "spaceRefFrame" : "ICRS", - "planetaryEphem" : "DE432" - } - }, + "coordSys" : 1026, "lon" : { "@type" : "ivoa:RealQuantity", "unit" : { @@ -58,7 +58,7 @@ } } } - }, 1026 ] + } ] }, "content" : [ { "@type" : "coords:AnObject", diff --git a/tools/gradletooling/sample/interoperability/java/notstccoords.xml b/tools/gradletooling/sample/interoperability/java/notstccoords.xml index 6b6276f8..b570182a 100644 --- a/tools/gradletooling/sample/interoperability/java/notstccoords.xml +++ b/tools/gradletooling/sample/interoperability/java/notstccoords.xml @@ -1,135 +1,68 @@ - - + - - - - - - - - TOPOCENTER - - - - ICRS - - DE432 - - - - - - - + - - TOPOCENTER - - TT - - J2014.25 - - coords-SpaceSys_1023 - - deg - 6.752477 - - - deg - -16.716116 - - - ly - 8.6 - - - - - - + + + + TOPOCENTER + + ICRS + DE432 + + - - - coords-SpaceSys_1023 - - deg - 45.0 - - - deg - 15.0 - - - Mpc - 1.5 - - - - - - - + - TOPOCENTER - - DE432 - - - - diff --git a/tools/gradletooling/sample/interoperability/java/sample.xml b/tools/gradletooling/sample/interoperability/java/sample.xml index c18c6368..d531bdc4 100644 --- a/tools/gradletooling/sample/interoperability/java/sample.xml +++ b/tools/gradletooling/sample/interoperability/java/sample.xml @@ -1,169 +1,85 @@ - - + - - http://coord.net - J2000.0 - - - - test photometric system - 1 - - - C-Band - radio band - C-Band - 2020-01-01T00:00:00Z - 2025-01-01T20:12:16Z - - GHz - 5.0 - - - - L-Band - radio band - L-Band - 2020-01-01T00:00:00Z - 2025-01-01T13:12:00Z - - GHz - 1.5 - - - - - - testCat - - - + - testSource - - - degree - 2.5 - - - degree - 52.5 - - J2000 - - - 0.2 - 0.1 - - AGN - - - Jy - 2.5 - - - Jy - 0.25 - - lummeas - flux - filter-PhotometryFilter_1032 - - - - Jy - 3.5 - - - Jy - 0.25 - - lummeas2 - flux - filter-PhotometryFilter_1033 - - - - diff --git a/tools/gradletooling/sample/interoperability/java/serializationsample.xml b/tools/gradletooling/sample/interoperability/java/serializationsample.xml index 384830fa..fdaa403a 100644 --- a/tools/gradletooling/sample/interoperability/java/serializationsample.xml +++ b/tools/gradletooling/sample/interoperability/java/serializationsample.xml @@ -1,59 +1,30 @@ - - + - - urn:value - - - naturalkey - ivo:val - - - - MyModel-Refa_1044 - naturalkey - - some - z - values - - - - - + dval - N1 - - - - + eval - cube - - - - From 79d5cf9f9ec4be7ff03811f5ebc2db352bfc570c Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Fri, 10 Apr 2026 17:29:19 +0100 Subject: [PATCH 21/29] add in an anyURI attribute for testing --- .../sample/test/serializationExample.vo-dml.xml | 16 ++++++++++++++-- models/sample/test/serializationExample.vodsl | 5 ++++- .../java/serializationsample.json | 7 ++++--- .../java/serializationsample.xml | 5 +++-- .../SerializationExampleTest.java | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/models/sample/test/serializationExample.vo-dml.xml b/models/sample/test/serializationExample.vo-dml.xml index 126f6ae8..f2bf09be 100644 --- a/models/sample/test/serializationExample.vo-dml.xml +++ b/models/sample/test/serializationExample.vo-dml.xml @@ -8,7 +8,7 @@ 1.0 - 2026-02-20T12:34:33Z + 2026-04-10T16:35:10Z ivoa 1.0 @@ -26,7 +26,7 @@ altURL altURL - different URI specialization + different URI specialization ivoa:anyURI @@ -132,6 +132,18 @@ -1 + + SomeContent.uri + uri + + + ivoa:anyURI + + + 1 + 1 + + types diff --git a/models/sample/test/serializationExample.vodsl b/models/sample/test/serializationExample.vodsl index 3acfd538..8426958a 100644 --- a/models/sample/test/serializationExample.vodsl +++ b/models/sample/test/serializationExample.vodsl @@ -23,6 +23,7 @@ otype Refb "" package types "" { abstract otype BaseC { bname: ivoa:string ""; + } otype Dcont -> BaseC { @@ -37,7 +38,9 @@ otype SomeContent "" { ref1 references Refa ""; ref2 references Refb ""; zval : ivoa:string @+ ""; - con: types:BaseC @+ as composition ""; + con: types:BaseC @+ as composition ""; + uri : ivoa:anyURI ""; + } primitive ivoid -> ivoa:anyURI "a specialization for IVOIDs" diff --git a/tools/gradletooling/sample/interoperability/java/serializationsample.json b/tools/gradletooling/sample/interoperability/java/serializationsample.json index ebc30fbf..59b28bf9 100644 --- a/tools/gradletooling/sample/interoperability/java/serializationsample.json +++ b/tools/gradletooling/sample/interoperability/java/serializationsample.json @@ -2,7 +2,7 @@ "MyModelModel" : { "refs" : { "MyModel:Refa" : [ { - "_id" : 1045, + "_id" : 1001, "val" : "urn:value" } ], "MyModel:Refb" : [ { @@ -13,7 +13,7 @@ "content" : [ { "@type" : "MyModel:SomeContent", "_id" : 0, - "ref1" : 1045, + "ref1" : 1001, "ref2" : "naturalkey", "zval" : [ "some", "z", "values" ], "con" : [ { @@ -26,7 +26,8 @@ "_id" : 0, "bname" : "eval", "evalue" : "cube" - } ] + } ], + "uri" : "urn:uri" } ] } } \ No newline at end of file diff --git a/tools/gradletooling/sample/interoperability/java/serializationsample.xml b/tools/gradletooling/sample/interoperability/java/serializationsample.xml index fdaa403a..c836428c 100644 --- a/tools/gradletooling/sample/interoperability/java/serializationsample.xml +++ b/tools/gradletooling/sample/interoperability/java/serializationsample.xml @@ -1,6 +1,6 @@ - + urn:value @@ -9,7 +9,7 @@ - MyModel-Refa_1044 + MyModel-Refa_1000 naturalkey some @@ -26,5 +26,6 @@ cube + urn:uri diff --git a/tools/gradletooling/sample/src/test/java/org/ivoa/dm/serializationsample/SerializationExampleTest.java b/tools/gradletooling/sample/src/test/java/org/ivoa/dm/serializationsample/SerializationExampleTest.java index 8f97fd36..6b1aad01 100644 --- a/tools/gradletooling/sample/src/test/java/org/ivoa/dm/serializationsample/SerializationExampleTest.java +++ b/tools/gradletooling/sample/src/test/java/org/ivoa/dm/serializationsample/SerializationExampleTest.java @@ -37,7 +37,7 @@ public MyModelModel createModel() { List clist = List.of(new Dcont("N1", "dval"), new Econt("cube", "eval")); - someContent = new SomeContent(refa, refb, List.of("some","z","values"), clist); + someContent = new SomeContent(refa, refb, List.of("some","z","values"), clist, "urn:uri"); themodel.addContent(someContent); return themodel; From 196019fb5500ffd674e4e8b415456a2ed206add3 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Fri, 10 Apr 2026 18:10:25 +0100 Subject: [PATCH 22/29] make python anyURI also a str for java compatibility --- .../ivoa/vo-dml/ivoa_base.vodml-binding.xml | 1 + .../interoperability/python/sample.json | 18 ++---------- .../sample/interoperability/python/sample.xml | 28 ++++++++----------- .../python/serializationsample.json | 9 ++---- .../python/serializationsample.xml | 9 ++---- .../pythontest/src/PydanticInteropTest.py | 9 +++--- .../pythontest/src/SourceCatalogueTest.py | 2 +- 7 files changed, 27 insertions(+), 49 deletions(-) diff --git a/models/ivoa/vo-dml/ivoa_base.vodml-binding.xml b/models/ivoa/vo-dml/ivoa_base.vodml-binding.xml index 177e0265..5a03cf90 100644 --- a/models/ivoa/vo-dml/ivoa_base.vodml-binding.xml +++ b/models/ivoa/vo-dml/ivoa_base.vodml-binding.xml @@ -46,6 +46,7 @@ anyURI String + str xsd:anyURI string diff --git a/tools/gradletooling/sample/interoperability/python/sample.json b/tools/gradletooling/sample/interoperability/python/sample.json index f0a7e81c..fbb631b6 100644 --- a/tools/gradletooling/sample/interoperability/python/sample.json +++ b/tools/gradletooling/sample/interoperability/python/sample.json @@ -3,9 +3,7 @@ "skyCoordinateFrame": [ { "name": "J2000", - "documentURI": { - "value": "http://coord.net" - }, + "documentURI": "http://coord.net", "equinox": "J2000.0", "system": null } @@ -23,21 +21,17 @@ "unit": { "value": "degree" }, - "xsi_type": "RealQuantity", "value": 2.5 }, "latitude": { "unit": { "value": "degree" }, - "xsi_type": "RealQuantity", "value": 52.5 }, "frame": { "name": "J2000", - "documentURI": { - "value": "http://coord.net" - }, + "documentURI": "http://coord.net", "equinox": "J2000.0", "system": null } @@ -50,7 +44,6 @@ "unit": { "value": "Jy" }, - "xsi_type": "RealQuantity", "value": 2.5 }, "type": "flux", @@ -65,7 +58,6 @@ "unit": { "value": "GHz" }, - "xsi_type": "RealQuantity", "value": 5.0 }, "fpsIdentifier": null @@ -74,7 +66,6 @@ "unit": { "value": "Jy" }, - "xsi_type": "RealQuantity", "value": 0.25 }, "description": "lummeas" @@ -84,7 +75,6 @@ "unit": { "value": "Jy" }, - "xsi_type": "RealQuantity", "value": 3.5 }, "type": "flux", @@ -99,7 +89,6 @@ "unit": { "value": "GHz" }, - "xsi_type": "RealQuantity", "value": 1.5 }, "fpsIdentifier": null @@ -108,7 +97,6 @@ "unit": { "value": "Jy" }, - "xsi_type": "RealQuantity", "value": 0.25 }, "description": "lummeas2" @@ -136,7 +124,6 @@ "unit": { "value": "GHz" }, - "xsi_type": "RealQuantity", "value": 5.0 }, "fpsIdentifier": null @@ -152,7 +139,6 @@ "unit": { "value": "GHz" }, - "xsi_type": "RealQuantity", "value": 1.5 }, "fpsIdentifier": null diff --git a/tools/gradletooling/sample/interoperability/python/sample.xml b/tools/gradletooling/sample/interoperability/python/sample.xml index f40cf402..9f54f859 100644 --- a/tools/gradletooling/sample/interoperability/python/sample.xml +++ b/tools/gradletooling/sample/interoperability/python/sample.xml @@ -3,9 +3,7 @@ J2000 - - http://coord.net - + http://coord.net J2000.0 @@ -16,13 +14,13 @@ testSource - + degree 2.5 - + degree @@ -30,16 +28,14 @@ J2000 - - http://coord.net - + http://coord.net J2000.0 AGN - + Jy @@ -52,14 +48,14 @@ C-Band 20-01-01T00:00:00Z 25-01-01T20:12:16Z - + GHz 5.0 - + Jy @@ -68,7 +64,7 @@ lummeas - + Jy @@ -81,14 +77,14 @@ L-Band 20-01-01T00:00:00Z 25-01-01T13:12:00Z - + GHz 1.5 - + Jy @@ -114,7 +110,7 @@ C-Band 20-01-01T00:00:00Z 25-01-01T20:12:16Z - + GHz @@ -127,7 +123,7 @@ L-Band 20-01-01T00:00:00Z 25-01-01T13:12:00Z - + GHz diff --git a/tools/gradletooling/sample/interoperability/python/serializationsample.json b/tools/gradletooling/sample/interoperability/python/serializationsample.json index f843114e..a018c7ef 100644 --- a/tools/gradletooling/sample/interoperability/python/serializationsample.json +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.json @@ -4,9 +4,7 @@ { "id": "refa-1", "val": { - "value": { - "value": "urn:value" - } + "value": "urn:value" } } ], @@ -14,9 +12,7 @@ { "name": "naturalkey", "val": { - "value": { - "value": "ivo:val" - } + "value": "ivo:val" } } ] @@ -25,6 +21,7 @@ { "ref1": "refa-1", "ref2": "naturalkey", + "uri": "urn:uri", "zval": [ "some", "z", diff --git a/tools/gradletooling/sample/interoperability/python/serializationsample.xml b/tools/gradletooling/sample/interoperability/python/serializationsample.xml index 8901e39c..a4dbceca 100644 --- a/tools/gradletooling/sample/interoperability/python/serializationsample.xml +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.xml @@ -3,23 +3,20 @@ - - urn:value - + urn:value naturalkey - - ivo:val - + ivo:val refa-1 naturalkey + urn:uri some z diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py index 8cfefbc8..2c72a01d 100644 --- a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -16,7 +16,7 @@ from lxml import etree as _etree from org.ivoa.dm.filter.filter import PhotometricSystem, PhotometryFilter -from org.ivoa.dm.ivoa import RealQuantity, Unit, anyURI +from org.ivoa.dm.ivoa import RealQuantity, Unit from org.ivoa.dm.jpatest.jpatest import JpatestModel from org.ivoa.dm.lifecycle.lifecycleTest import LifecycleTestModel, LifecycleTestRefs @@ -142,7 +142,7 @@ def setUpClass(cls): frame = SkyCoordinateFrame( name="J2000", equinox="J2000.0", - documentURI=anyURI(value="http://coord.net"), + documentURI="http://coord.net", ) c_band = PhotometryFilter( @@ -322,8 +322,8 @@ def setUpClass(cls): from org.ivoa.dm.serializationsample.MyModel import Refa, Refb, SomeContent, altURL, ivoid from org.ivoa.dm.serializationsample.MyModel_types import Dcont, Econt - refa = Refa(id="refa-1", val=altURL(value=anyURI(value="urn:value"))) - refb = Refb(name="naturalkey", val=ivoid(value=anyURI(value="ivo:val"))) + refa = Refa(id="refa-1", val=altURL(value="urn:value")) + refb = Refb(name="naturalkey", val=ivoid(value="ivo:val")) cls.model = MyModelModel( someContent=[ SomeContent( @@ -334,6 +334,7 @@ def setUpClass(cls): Dcont(bname="dval", dval="N1"), Econt(bname="eval", evalue="cube"), ], + uri="urn:uri" ) ], refs=MyModelRefs(refa=[refa], refb=[refb]), diff --git a/tools/gradletooling/sample/pythontest/src/SourceCatalogueTest.py b/tools/gradletooling/sample/pythontest/src/SourceCatalogueTest.py index 8c002d5b..f9df8945 100644 --- a/tools/gradletooling/sample/pythontest/src/SourceCatalogueTest.py +++ b/tools/gradletooling/sample/pythontest/src/SourceCatalogueTest.py @@ -28,7 +28,7 @@ def setUpClass(cls): jansky = Unit("Jy") degree = Unit("degree") GHz = Unit("GHz") - frame = SkyCoordinateFrame(name="J2000", equinox="J2000.0", documentURI=anyURI("http://coord.net")) + frame = SkyCoordinateFrame(name="J2000", equinox="J2000.0", documentURI="http://coord.net") ellipseError = AlignedEllipse(longError=.2, latError=.1) # sdss = SDSSSource(positionError=ellipseError)# UNUSED, but just checking position error subsetting. From e81439a472d32dcd0cc439300ee86d763c8d665b Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Mon, 13 Apr 2026 09:39:00 +0100 Subject: [PATCH 23/29] redo so that fields are just processed in declaration order --- .../interoperability/python/jpatest.json | 16 ++--- .../interoperability/python/jpatest.xml | 14 ++--- .../interoperability/python/sample.json | 52 ++++++++--------- .../sample/interoperability/python/sample.xml | 58 ++++++------------- .../python/serializationsample.json | 8 +-- .../python/serializationsample.xml | 14 ++--- tools/xslt/vo-dml2pydantic.xsl | 15 ++--- 7 files changed, 75 insertions(+), 102 deletions(-) diff --git a/tools/gradletooling/sample/interoperability/python/jpatest.json b/tools/gradletooling/sample/interoperability/python/jpatest.json index d415ccd5..5e1a3f9e 100644 --- a/tools/gradletooling/sample/interoperability/python/jpatest.json +++ b/tools/gradletooling/sample/interoperability/python/jpatest.json @@ -40,13 +40,6 @@ "cval": { "rval": "jpatest-ReferredTo2_1004" }, - "tval": { - "p": { - "x": 1.5, - "y": 3.0 - }, - "dt": "thing" - }, "lval": [ { "sval": "First", @@ -60,7 +53,14 @@ "sval": "Third", "ival": 3 } - ] + ], + "tval": { + "p": { + "x": 1.5, + "y": 3.0 + }, + "dt": "thing" + } } ], "sub": [] diff --git a/tools/gradletooling/sample/interoperability/python/jpatest.xml b/tools/gradletooling/sample/interoperability/python/jpatest.xml index 2fe4e5d2..4fde8150 100644 --- a/tools/gradletooling/sample/interoperability/python/jpatest.xml +++ b/tools/gradletooling/sample/interoperability/python/jpatest.xml @@ -31,13 +31,6 @@ jpatest-ReferredTo2_1004 - -

- 1.5 - 3.0 -

-
thing
-
First @@ -52,5 +45,12 @@ 3 + +

+ 1.5 + 3.0 +

+
thing
+
diff --git a/tools/gradletooling/sample/interoperability/python/sample.json b/tools/gradletooling/sample/interoperability/python/sample.json index fbb631b6..672f2a02 100644 --- a/tools/gradletooling/sample/interoperability/python/sample.json +++ b/tools/gradletooling/sample/interoperability/python/sample.json @@ -16,6 +16,7 @@ { "label": "cepheid", "name": "testSource", + "description": null, "position": { "longitude": { "unit": { @@ -37,7 +38,6 @@ } }, "classification": "AGN", - "description": null, "luminosity": [ { "value": { @@ -46,9 +46,17 @@ }, "value": 2.5 }, + "error": { + "unit": { + "value": "Jy" + }, + "value": 0.25 + }, + "description": "lummeas", "type": "flux", "filter": { "id": null, + "fpsIdentifier": null, "name": "C-Band", "description": "radio band", "bandName": "C-Band", @@ -59,16 +67,8 @@ "value": "GHz" }, "value": 5.0 - }, - "fpsIdentifier": null - }, - "error": { - "unit": { - "value": "Jy" - }, - "value": 0.25 - }, - "description": "lummeas" + } + } }, { "value": { @@ -77,9 +77,17 @@ }, "value": 3.5 }, + "error": { + "unit": { + "value": "Jy" + }, + "value": 0.25 + }, + "description": "lummeas2", "type": "flux", "filter": { "id": null, + "fpsIdentifier": null, "name": "L-Band", "description": "radio band", "bandName": "L-Band", @@ -90,16 +98,8 @@ "value": "GHz" }, "value": 1.5 - }, - "fpsIdentifier": null - }, - "error": { - "unit": { - "value": "Jy" - }, - "value": 0.25 - }, - "description": "lummeas2" + } + } } ] } @@ -110,11 +110,12 @@ ], "photometricSystem": [ { - "detectorType": 1, "description": "test photometric system", + "detectorType": 1, "photometryFilter": [ { "id": null, + "fpsIdentifier": null, "name": "C-Band", "description": "radio band", "bandName": "C-Band", @@ -125,11 +126,11 @@ "value": "GHz" }, "value": 5.0 - }, - "fpsIdentifier": null + } }, { "id": null, + "fpsIdentifier": null, "name": "L-Band", "description": "radio band", "bandName": "L-Band", @@ -140,8 +141,7 @@ "value": "GHz" }, "value": 1.5 - }, - "fpsIdentifier": null + } } ] } diff --git a/tools/gradletooling/sample/interoperability/python/sample.xml b/tools/gradletooling/sample/interoperability/python/sample.xml index 9f54f859..f43fde33 100644 --- a/tools/gradletooling/sample/interoperability/python/sample.xml +++ b/tools/gradletooling/sample/interoperability/python/sample.xml @@ -15,15 +15,11 @@ testSource - - degree - + degree 2.5 - - degree - + degree 52.5 @@ -36,11 +32,14 @@ - - Jy - + Jy 2.5 + + Jy + 0.25 + + lummeas flux C-Band @@ -49,27 +48,21 @@ 20-01-01T00:00:00Z 25-01-01T20:12:16Z - - GHz - + GHz 5.0 - - - Jy - - 0.25 - - lummeas - - Jy - + Jy 3.5 + + Jy + 0.25 + + lummeas2 flux L-Band @@ -78,19 +71,10 @@ 20-01-01T00:00:00Z 25-01-01T13:12:00Z - - GHz - + GHz 1.5 - - - Jy - - 0.25 - - lummeas2 @@ -101,8 +85,8 @@
- 1 test photometric system + 1 C-Band @@ -111,9 +95,7 @@ 20-01-01T00:00:00Z 25-01-01T20:12:16Z - - GHz - + GHz 5.0 @@ -124,9 +106,7 @@ 20-01-01T00:00:00Z 25-01-01T13:12:00Z - - GHz - + GHz 1.5 diff --git a/tools/gradletooling/sample/interoperability/python/serializationsample.json b/tools/gradletooling/sample/interoperability/python/serializationsample.json index a018c7ef..f8da862d 100644 --- a/tools/gradletooling/sample/interoperability/python/serializationsample.json +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.json @@ -2,7 +2,7 @@ "refs": { "refa": [ { - "id": "refa-1", + "id": "MyModel-Refa_1000", "val": { "value": "urn:value" } @@ -19,9 +19,8 @@ }, "someContent": [ { - "ref1": "refa-1", + "ref1": "MyModel-Refa_1000", "ref2": "naturalkey", - "uri": "urn:uri", "zval": [ "some", "z", @@ -34,7 +33,8 @@ { "bname": "eval" } - ] + ], + "uri": "urn:uri" } ] } \ No newline at end of file diff --git a/tools/gradletooling/sample/interoperability/python/serializationsample.xml b/tools/gradletooling/sample/interoperability/python/serializationsample.xml index a4dbceca..511fe9ed 100644 --- a/tools/gradletooling/sample/interoperability/python/serializationsample.xml +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.xml @@ -1,22 +1,17 @@ - - - urn:value - + + urn:value naturalkey - - ivo:val - + ivo:val - refa-1 + MyModel-Refa_1000 naturalkey - urn:uri some z @@ -32,5 +27,6 @@ cube + urn:uri diff --git a/tools/xslt/vo-dml2pydantic.xsl b/tools/xslt/vo-dml2pydantic.xsl index 44c6d3c4..943a71eb 100644 --- a/tools/xslt/vo-dml2pydantic.xsl +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -204,10 +204,8 @@ class _VodmlXmlBase(BaseModel): - - - - + + pass @@ -245,9 +243,7 @@ class _VodmlXmlBase(BaseModel): - - - + pass @@ -278,10 +274,11 @@ class + - + Primitive type represented as str @@ -302,7 +299,7 @@ class (_VodmlXmlBase): * """ - value: = xsfield({'type': 'Element', 'name': 'value', 'namespace': ''}) + value: = xsfield({'type': 'Text'}) From 1f57cfcaf714bf621af22bd0782af040dcdddb97 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Mon, 13 Apr 2026 09:42:32 +0100 Subject: [PATCH 24/29] add test to show where xsdata is still deficient compared to JAXB --- models/ivoa/build.gradle.kts | 2 +- .../sample/pythontest/src/PydanticInteropTest.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/models/ivoa/build.gradle.kts b/models/ivoa/build.gradle.kts index f442dee8..b7558032 100644 --- a/models/ivoa/build.gradle.kts +++ b/models/ivoa/build.gradle.kts @@ -14,7 +14,7 @@ version = "1.0-SNAPSHOT" vodml { vodmlDir.set(file("vo-dml")) bindingFiles.setFrom(file("vo-dml/ivoa_base.vodml-binding.xml")) - outputPythonDir.set(layout.projectDirectory.dir("../../tools/gradletooling/sample/pythontest/generated")) + outputPythonDir.set(layout.projectDirectory.dir("../../tools/gradletooling/sample/pythontest/generated")) // FIXME when this is eventually packaged it should be local to this project, but for now this is just to allow testing of the generated code in the python test project } diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py index 2c72a01d..91d79cdd 100644 --- a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -28,6 +28,7 @@ # Output directory (relative to the sample project root). _SAMPLE_DIR = Path(__file__).parent.parent.parent _INTEROP_DIR = _SAMPLE_DIR / "interoperability" / "python" +_JAVA_DIR = _SAMPLE_DIR / "interoperability" / "java" # Directory containing the generated XSD schemas (populated by :sample:vodmlSchema). _SCHEMA_DIR = _SAMPLE_DIR / "docs" / "schema" @@ -115,6 +116,9 @@ def _read_json(filename: str) -> dict: def _read_xml_root(filename: str) -> ET.Element: return ET.parse(str(_INTEROP_DIR / filename)).getroot() +def _read_java_file_as_bytes(path: str) -> bytes: + with open(_JAVA_DIR / path, "rb") as f: + return f.read() class SampleModelInteropTest(unittest.TestCase): """ @@ -322,7 +326,7 @@ def setUpClass(cls): from org.ivoa.dm.serializationsample.MyModel import Refa, Refb, SomeContent, altURL, ivoid from org.ivoa.dm.serializationsample.MyModel_types import Dcont, Econt - refa = Refa(id="refa-1", val=altURL(value="urn:value")) + refa = Refa(id="MyModel-Refa_1000", val=altURL(value="urn:value")) refb = Refb(name="naturalkey", val=ivoid(value="ivo:val")) cls.model = MyModelModel( someContent=[ @@ -374,6 +378,11 @@ def test_xml_round_trip(self): self.assertEqual(recovered.someContent[0].ref1, "refa-1") self.assertEqual(recovered.someContent[0].zval, ["some", "z", "values"]) + def test_read_java_serializationsample_xml(self): + from org.ivoa.dm.serializationsample.MyModel import Refa + from_java = MyModelModel.from_xml( _read_java_file_as_bytes("serializationsample.xml")) + self.assertIsInstance(from_java.someContent[0].ref1, Refa) #FIXME this should be Refa object rather than string - want the xml reading to create a dicttionary of the references as they are read and then replace refefnce + class JpatestModelInteropTest(unittest.TestCase): """Round-trip tests for the jpatest model wrapper.""" From ef112a41822eaff3679665db5a9288c79c445fd8 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Mon, 13 Apr 2026 11:38:26 +0100 Subject: [PATCH 25/29] added processing similar to JAXB IDREF processing also added python runtime as separate lib for first time --- .github/workflows/pubpyruntime.yml | 89 ++++++ runtime/python/README.md | 51 ++++ runtime/python/pyproject.toml | 45 +++ runtime/python/vodml_runtime/VodmlXmlBase.py | 23 ++ runtime/python/vodml_runtime/__init__.py | 9 + runtime/python/vodml_runtime/py.typed | 0 runtime/python/vodml_runtime/references.py | 257 ++++++++++++++++++ .../pythontest/src/PydanticInteropTest.py | 27 +- tools/xslt/vo-dml2pydantic.xsl | 64 ++--- 9 files changed, 515 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/pubpyruntime.yml create mode 100644 runtime/python/README.md create mode 100644 runtime/python/pyproject.toml create mode 100644 runtime/python/vodml_runtime/VodmlXmlBase.py create mode 100644 runtime/python/vodml_runtime/py.typed create mode 100644 runtime/python/vodml_runtime/references.py diff --git a/.github/workflows/pubpyruntime.yml b/.github/workflows/pubpyruntime.yml new file mode 100644 index 00000000..542fc028 --- /dev/null +++ b/.github/workflows/pubpyruntime.yml @@ -0,0 +1,89 @@ +# Publish the vodml-runtime Python package to PyPI using trusted publishing (OIDC). +# +# Prerequisites (one-time setup on PyPI): +# 1. Create the project "vodml-runtime" on https://pypi.org +# 2. Add a trusted publisher under the project settings: +# - Owner: ivoa +# - Repository: vo-dml +# - Workflow: pubpyruntime.yml +# - Environment: pypi +# +# Triggering: +# Push a tag matching "pyruntime*" (e.g. pyruntime-0.1.0). + +name: Publish Python Runtime to PyPI + +on: + push: + tags: + - 'pyruntime*' + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + if: github.repository == 'ivoa/vo-dml' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build tools + run: python -m pip install --upgrade pip build + + - name: Build sdist and wheel + working-directory: runtime/python + run: python -m build + + - name: Upload distribution artifacts + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: runtime/python/dist/ + + publish-pypi: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/vodml-runtime + permissions: + id-token: write # required for trusted publishing via OIDC + + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + publish-testpypi: + name: Publish to TestPyPI + needs: build + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/vodml-runtime + permissions: + id-token: write + + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + diff --git a/runtime/python/README.md b/runtime/python/README.md new file mode 100644 index 00000000..bb557691 --- /dev/null +++ b/runtime/python/README.md @@ -0,0 +1,51 @@ +# vodml-runtime + +Python runtime support library for code generated from [VO-DML](https://www.ivoa.net/documents/VODML/index.html) models. + +This is the Python counterpart of the Java +[vodml-runtime](https://search.maven.org/artifact/org.javastro.ivoa.vo-dml/vodml-runtime/) +library. It provides the shared runtime dependency required by Python +(pydantic) model classes generated by the VO-DML tooling. + +## Installation + +```bash +pip install vodml-runtime +``` + +If you need the optional SQLAlchemy registry support: + +```bash +pip install vodml-runtime[sqlalchemy] +``` + +## Use + +In general, you should not need to import anything from this library directly. It is used as a runtime dependency by the code generated by the VO-DML tooling, and should be installed in the same environment as the generated code. + +## How to publish + +To release: Push a tag like pyruntime-0.1.0: +```shell +git tag pyruntime-0.1.0 +git push origin pyruntime-0.1.0 +``` + +Local build (for testing): +```shell + +cd runtime/python && python -m build +``` + +## Links + +* [VO-DML tooling guide](https://ivoa.github.io/vo-dml/) +* [GitHub repository](https://github.com/ivoa/vo-dml) +* [Java runtime library](https://search.maven.org/artifact/org.javastro.ivoa.vo-dml/vodml-runtime/) + +## License + +This project is licensed under the +[Creative Commons Attribution-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-sa/4.0/) +license — see the [LICENSE](../../LICENSE) file for details. + diff --git a/runtime/python/pyproject.toml b/runtime/python/pyproject.toml new file mode 100644 index 00000000..450711ac --- /dev/null +++ b/runtime/python/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "vodml-runtime" +version = "0.1.0" +description = "Python runtime support library for code generated from VO-DML models" +readme = "README.md" +license = "CC-BY-SA-4.0" +requires-python = ">=3.10" +authors = [ + { name = "Paul Harrison", email = "paul.harrison@manchester.ac.uk" }, +] +keywords = ["ivoa", "vo-dml", "astronomy", "data-model"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Astronomy", + "Operating System :: OS Independent", +] +dependencies = [ + "pydantic>=2.0", + "xsdata-pydantic>=24.0", +] + +[project.optional-dependencies] +sqlalchemy = [ + "sqlalchemy>=2.0", +] + +[project.urls] +Homepage = "https://github.com/ivoa/vo-dml" +Documentation = "https://ivoa.github.io/vo-dml/" +Repository = "https://github.com/ivoa/vo-dml" +Issues = "https://github.com/ivoa/vo-dml/issues" + +[build-system] +requires = ["setuptools>=77.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["vodml_runtime*"] + diff --git a/runtime/python/vodml_runtime/VodmlXmlBase.py b/runtime/python/vodml_runtime/VodmlXmlBase.py new file mode 100644 index 00000000..cab84f2e --- /dev/null +++ b/runtime/python/vodml_runtime/VodmlXmlBase.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026. Paul Harrison, University of Manchester +from pydantic import BaseModel, ConfigDict, field_serializer +from xsdata.formats.dataclass.context import XmlContext +from xsdata.formats.dataclass.serializers import XmlSerializer +from xsdata.formats.dataclass.serializers.config import SerializerConfig +from xsdata.formats.dataclass.parsers import XmlParser + +class _VodmlXmlBase(BaseModel): + """Base class providing Pydantic BaseModel with xsdata XML serialisation.""" + model_config = ConfigDict(arbitrary_types_allowed=True) + + + def to_xml(self, nsmap: dict[str, str] | None = None, pretty_print: bool = False) -> bytes: + config = SerializerConfig(indent=" " if pretty_print else None) + ctx = XmlContext(class_type="pydantic") + return XmlSerializer(config=config, context=ctx).render(self, ns_map=nsmap).encode("utf-8") + + @classmethod + def from_xml(cls, xml_bytes: bytes): + ctx = XmlContext(class_type="pydantic") + data = xml_bytes.decode("utf-8") if isinstance(xml_bytes, bytes) else xml_bytes + return XmlParser(context=ctx).from_string(data, cls) + diff --git a/runtime/python/vodml_runtime/__init__.py b/runtime/python/vodml_runtime/__init__.py index e69de29b..89175519 100644 --- a/runtime/python/vodml_runtime/__init__.py +++ b/runtime/python/vodml_runtime/__init__.py @@ -0,0 +1,9 @@ +"""VO-DML Python runtime support library.""" + +from vodml_runtime.references import resolve_references, VodmlIdRegistry +from vodml_runtime.VodmlXmlBase import _VodmlXmlBase + +__all__ = ["resolve_references", "VodmlIdRegistry", "_VodmlXmlBase" ] + +__version__ = "0.1.0" + diff --git a/runtime/python/vodml_runtime/py.typed b/runtime/python/vodml_runtime/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/runtime/python/vodml_runtime/references.py b/runtime/python/vodml_runtime/references.py new file mode 100644 index 00000000..af42418a --- /dev/null +++ b/runtime/python/vodml_runtime/references.py @@ -0,0 +1,257 @@ +""" +XML ID/IDREF resolution for VO-DML pydantic models. + +This module provides the Python equivalent of the Java JAXB ``@XmlID`` / +``@XmlIDREF`` mechanism. After xsdata deserialises an XML document into +pydantic model instances, ``resolve_references`` walks the object tree, +builds a registry of all objects that carry an identifier, and replaces +every string-valued IDREF field with the actual referenced object. + +The resolution uses explicit metadata emitted by the XSLT code generator +rather than runtime type introspection heuristics: + +1. Referenceable objects declare ``_vodml_id_field`` (a ``ClassVar[str]``) + naming the field that carries their identity — either a natural-key + attribute (e.g. ``"name"``) or the surrogate ``id`` field mapped to the + ``_id`` XML attribute. + +2. Reference fields are listed in ``_vodml_refs`` (a ``ClassVar[list[str]]``) + on each class that contains VO-DML ```` elements. This is + emitted on both ``objectType`` and ``dataType`` classes. + +Typical usage (called automatically by the generated ``from_xml`` override on +model-wrapper classes):: + + model = MyModelModel.from_xml(xml_bytes) + # model.someContent[0].ref1 is now a Refa instance, not a string + +Note that this file was largely AI generated - it might be over complex... +""" + +# Copyright (c) 2026. Paul Harrison, University of Manchester + +from __future__ import annotations + +import logging +from typing import Any + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# ID registry +# --------------------------------------------------------------------------- + +class VodmlIdRegistry: + """Maps XML ID strings to deserialised model objects.""" + + def __init__(self) -> None: + self._by_id: dict[str, Any] = {} + + def register(self, id_value: str, obj: Any) -> None: + """Register *obj* under the given *id_value*.""" + if id_value is not None: + self._by_id[id_value] = obj + + def resolve(self, id_value: str) -> Any | None: + """Return the object registered for *id_value*, or ``None``.""" + return self._by_id.get(id_value) + + def __len__(self) -> int: + return len(self._by_id) + + def __repr__(self) -> str: + return f"VodmlIdRegistry({len(self._by_id)} entries)" + + +# --------------------------------------------------------------------------- +# Metadata helpers +# --------------------------------------------------------------------------- + +def _find_id_field(cls: type) -> str | None: + """Walk the MRO to find ``_vodml_id_field``. + + ``_vodml_id_field`` is a ``ClassVar[str]`` emitted by the XSLT code + generator on every referenceable type. It names the field that carries + the object's XML ID — either a natural-key attribute or the surrogate + ``id`` field. + + Because Python attribute lookup follows the MRO, a subclass that does + not itself declare ``_vodml_id_field`` will inherit it from its parent + (e.g. ``ReferredTo1(Refbase)`` inherits ``Refbase._vodml_id_field``). + """ + for klass in cls.__mro__: + field = klass.__dict__.get('_vodml_id_field') + if field is not None: + return field + return None + + +def _collect_vodml_refs(cls: type) -> set[str]: + """Collect all ``_vodml_refs`` entries from the class's MRO. + + Each class in the hierarchy may declare its own ``_vodml_refs`` listing + the reference field names defined at *that* level. This helper merges + them all so that a subclass inherits reference metadata from its parents. + """ + refs: set[str] = set() + for klass in cls.__mro__: + r = klass.__dict__.get('_vodml_refs') + if r is not None: + refs.update(r) + return refs + + +def _get_object_id(obj: Any) -> str | None: + """Return the XML ID string for a model object, or ``None``. + + Uses the ``_vodml_id_field`` class-level metadata emitted by the + XSLT code generator to determine which field carries the object's + identity. + """ + id_field = _find_id_field(obj.__class__) + if id_field is not None: + val = getattr(obj, id_field, None) + if val is not None: + return str(val) + return None + + +# --------------------------------------------------------------------------- +# Registry builder +# --------------------------------------------------------------------------- + +def _build_registry_from_refs(refs_obj: Any, registry: VodmlIdRegistry) -> None: + """Walk every list-valued field in a *Refs* container and register items.""" + if refs_obj is None: + return + for field_name in refs_obj.__class__.model_fields: + value = getattr(refs_obj, field_name, None) + if value is None: + continue + if isinstance(value, list): + for item in value: + obj_id = _get_object_id(item) + if obj_id is not None: + registry.register(obj_id, item) + elif isinstance(value, BaseModel): + obj_id = _get_object_id(value) + if obj_id is not None: + registry.register(obj_id, value) + + +# --------------------------------------------------------------------------- +# Recursive reference resolver +# --------------------------------------------------------------------------- + +def _resolve_fields(obj: Any, registry: VodmlIdRegistry) -> Any: + """Recursively resolve string IDREFs inside *obj* to actual objects. + + Uses the ``_vodml_refs`` class metadata to identify which fields are + VO-DML references. For each such field, if the current value is a + ``str``, it is looked up in the *registry* and replaced with the + resolved object. Non-reference fields that contain nested + ``BaseModel`` instances are recursed into. + + Returns the (possibly mutated) *obj*. + """ + if not isinstance(obj, BaseModel): + return obj + + ref_fields = _collect_vodml_refs(obj.__class__) + updates: dict[str, Any] = {} + + for field_name in obj.__class__.model_fields: + value = getattr(obj, field_name, None) + if value is None: + continue + + if field_name in ref_fields: + # --- reference field --- + if isinstance(value, str): + resolved = registry.resolve(value) + if resolved is not None: + updates[field_name] = resolved + elif isinstance(value, list): + new_list = [] + changed = False + for item in value: + if isinstance(item, str): + resolved = registry.resolve(item) + if resolved is not None: + new_list.append(resolved) + changed = True + else: + new_list.append(item) + else: + if isinstance(item, BaseModel): + _resolve_fields(item, registry) + new_list.append(item) + if changed: + updates[field_name] = new_list + elif isinstance(value, BaseModel): + # Value is already an object (e.g. embedded inline rather + # than via IDREF) — recurse in case it has nested refs. + _resolve_fields(value, registry) + else: + # --- non-reference field: recurse into nested models --- + if isinstance(value, BaseModel): + _resolve_fields(value, registry) + elif isinstance(value, list): + for item in value: + if isinstance(item, BaseModel): + _resolve_fields(item, registry) + + if updates: + for k, v in updates.items(): + object.__setattr__(obj, k, v) + + return obj + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def resolve_references(model_instance: Any) -> Any: + """Post-deserialisation pass: replace string IDREFs with actual objects. + + This is the Python equivalent of Java's ``processReferences()`` (read + direction). It inspects the ``refs`` field of the model wrapper, builds + a :class:`VodmlIdRegistry`, then recursively walks the model tree + replacing every ``str``-valued reference field with the looked-up object. + + The resolution is driven entirely by two pieces of class-level metadata + emitted by the XSLT code generator: + + * ``_vodml_id_field`` — names the identity field on referenceable types + * ``_vodml_refs`` — lists the reference field names on each class + + Parameters + ---------- + model_instance + A top-level model wrapper (e.g. ``MyModelModel``) that has a ``refs`` + attribute containing the referenceable objects. + + Returns + ------- + The same *model_instance*, mutated in place. + """ + registry = VodmlIdRegistry() + + # Phase 1: build ID registry from the refs section + refs = getattr(model_instance, 'refs', None) + _build_registry_from_refs(refs, registry) + + if len(registry) == 0: + return model_instance + + logger.debug("Built ID registry with %d entries, resolving references …", len(registry)) + + # Phase 2: resolve IDREF strings in reference fields + _resolve_fields(model_instance, registry) + + return model_instance + diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py index 91d79cdd..43e37a91 100644 --- a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -238,8 +238,8 @@ def test_xml_serialise(self): self.assertEqual(_first_child_text(source_catalogue, "name"), "testCat") self.assertEqual(_first_child_text(_find_first(source_catalogue, "entry"), "name"), "testSource") - def test_xml_round_trip(self): - recovered = SampleModel.from_xml(self.model.to_xml(pretty_print=True)) + def test_read_java_serialization_xml(self): + recovered = SampleModel.from_xml( _read_java_file_as_bytes("sample.xml")) self.assertEqual(recovered.sourceCatalogue[0].name, "testCat") self.assertEqual(recovered.sourceCatalogue[0].entry[0].name, "testSource") self.assertEqual(recovered.photometricSystem[0].photometryFilter[0].name, "C-Band") @@ -306,8 +306,8 @@ def test_xml_serialise(self): self.assertEqual(_first_child_text(atest2, "refcont"), "lifecycleTest-ReferredLifeCycle_1012") self.assertIn("firstcontained", "".join(el.text or "" for el in root.iter() if _local_name(el.tag) == "test2")) - def test_xml_round_trip(self): - recovered = LifecycleTestModel.from_xml(self.model.to_xml(pretty_print=True)) + def test_read_java_serialization_xml(self): + recovered = LifecycleTestModel.from_xml( _read_java_file_as_bytes("lifecycle.xml")) self.assertEqual(len(recovered.aTest2[0].atest.contained), 2) self.assertEqual(recovered.aTest2[0].atest.contained2.lowr, "lifecycleTest-ReferredLifeCycle_1012") @@ -349,9 +349,9 @@ def test_json_serialise(self): _write("serializationsample.json", json_str) data = json.loads(json_str) - self.assertEqual(data["refs"]["refa"][0]["id"], "refa-1") + self.assertEqual(data["refs"]["refa"][0]["id"], "MyModel-Refa_1000") content = data["someContent"][0] - self.assertEqual(content["ref1"], "refa-1") + self.assertEqual(content["ref1"], "MyModel-Refa_1000") self.assertEqual(content["ref2"], "naturalkey") self.assertEqual(content["zval"], ["some", "z", "values"]) self.assertEqual(len(content["con"]), 2) @@ -373,12 +373,8 @@ def test_xml_serialise(self): zvals = [el.text for el in root.iter() if _local_name(el.tag) == "zval"] self.assertEqual(zvals, ["some", "z", "values"]) - def test_xml_round_trip(self): - recovered = MyModelModel.from_xml(self.model.to_xml(pretty_print=True)) - self.assertEqual(recovered.someContent[0].ref1, "refa-1") - self.assertEqual(recovered.someContent[0].zval, ["some", "z", "values"]) - def test_read_java_serializationsample_xml(self): + def test_read_java_serialization_xml(self): from org.ivoa.dm.serializationsample.MyModel import Refa from_java = MyModelModel.from_xml( _read_java_file_as_bytes("serializationsample.xml")) self.assertIsInstance(from_java.someContent[0].ref1, Refa) #FIXME this should be Refa object rather than string - want the xml reading to create a dicttionary of the references as they are read and then replace refefnce @@ -470,8 +466,9 @@ def test_xml_serialise(self): dval = _find_first(parent, "dval") self.assertEqual(_first_child_text(dval, "dvals"), "astring") lval = _children_named(parent, "lval") - self.assertEqual(len(lval), 3) - self.assertEqual([_first_child_text(child, "sval") for child in lval], ["First", "Second", "Third"]) + self.assertEqual(len(lval), 1) + lchildren = _children_named(lval[0], "lChild") + self.assertEqual([_first_child_text(child, "sval") for child in lchildren], ["First", "Second", "Third"]) def test_xml_round_trip(self): recovered = JpatestModel.from_xml(self.model.to_xml(pretty_print=True)) @@ -565,8 +562,6 @@ def test_jpatest_xml_parent(self): class PythonModelReadJavaTest(unittest.TestCase): """Sanity-check that the Java interoperability fixtures exist.""" - _JAVA_DIR = Path(__file__).parent.parent.parent / "interoperability" / "java" - def test_java_interop_files_exist(self): expected = [ "sample.json", @@ -578,7 +573,7 @@ def test_java_interop_files_exist(self): "jpatest.json", "jpatest.xml", ] - missing = [f for f in expected if not (self._JAVA_DIR / f).exists()] + missing = [f for f in expected if not (_JAVA_DIR / f).exists()] self.assertFalse( missing, f"Java interop files not found: {missing} (run :sample:test first)", diff --git a/tools/xslt/vo-dml2pydantic.xsl b/tools/xslt/vo-dml2pydantic.xsl index 943a71eb..78c0699b 100644 --- a/tools/xslt/vo-dml2pydantic.xsl +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -86,7 +86,7 @@ - + @@ -97,39 +97,12 @@ from __future__ import annotations -from typing import Optional, List, Any, Union +from typing import Optional, List, Any, Union, ClassVar from enum import Enum -from pydantic import BaseModel, ConfigDict, field_serializer from xsdata_pydantic.fields import field as xsfield -from xsdata.formats.dataclass.context import XmlContext -from xsdata.formats.dataclass.serializers import XmlSerializer -from xsdata.formats.dataclass.serializers.config import SerializerConfig -from xsdata.formats.dataclass.parsers import XmlParser -import xsdata_pydantic.hooks.class_type # register pydantic support with xsdata - - - -# base class to make swapping from pydantic-xml easier in tests - -# TODO this could go in runtime - anyway, does not need to be class method really -class _VodmlXmlBase(BaseModel): - """Base class providing Pydantic BaseModel with xsdata XML serialisation.""" - model_config = ConfigDict(arbitrary_types_allowed=True) - - def to_xml(self, pretty_print: bool = False) -> bytes: - config = SerializerConfig(indent=" " if pretty_print else None) - ctx = XmlContext(class_type="pydantic") - - - - return XmlSerializer(config=config, context=ctx).render(self, ns_map=ns_map).encode("utf-8") - - @classmethod - def from_xml(cls, xml_bytes: bytes): - ctx = XmlContext(class_type="pydantic") - data = xml_bytes.decode("utf-8") if isinstance(xml_bytes, bytes) else xml_bytes - return XmlParser(context=ctx).from_string(data, cls) - +from vodml_runtime.VodmlXmlBase import _VodmlXmlBase +import xsdata_pydantic.hooks.class_type # register pydantic support with xsdata @@ -196,7 +169,16 @@ class _VodmlXmlBase(BaseModel): """ - id: Optional[str] = xsfield({'type': 'Attribute', 'name': '_id'}, default=None) # an identifier field to allow referring to this object from other objects (e.g. via reference fields) without needing to embed it + id: Optional[str] = xsfield({'type': 'Attribute', 'name': '_id'}, default=None) # surrogate identifier for XML IDREF resolution + _vodml_id_field: ClassVar[str] = "id" + + + + _vodml_id_field: ClassVar[str] = "" + + + + _vodml_refs: ClassVar[list[str]] = [, ""] @@ -238,6 +220,10 @@ class _VodmlXmlBase(BaseModel): * """ + + _vodml_refs: ClassVar[list[str]] = [, ""] + + @@ -447,7 +433,7 @@ class (_VodmlXmlBase): - + @@ -465,7 +451,6 @@ class (_VodmlXmlBase): - class (_VodmlXmlBase): @@ -495,6 +480,17 @@ class (_VodmlXmlBase): : List[] = xsfield({'type': 'Element', 'name': '', 'namespace': ''}, default_factory=list) + + + @classmethod + def from_xml(cls, xml_bytes: bytes): + """Deserialise from XML, resolving IDREF strings to object instances.""" + instance = super().from_xml(xml_bytes) + from vodml_runtime.references import resolve_references + resolve_references(instance) + return instance + + From 80c1652d12c5df2042f2073f5b89107809417c6b Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 14 Apr 2026 09:10:35 +0100 Subject: [PATCH 26/29] create function for when an XML attribute and use in pydantic generation --- .../pythontest/src/PydanticInteropTest.py | 21 ++++++++++--------- tools/xslt/common-binding.xsl | 6 +++++- tools/xslt/jaxb.xsl | 4 ++-- tools/xslt/vo-dml2pydantic.xsl | 17 +++++++++++++-- tools/xslt/vo-dml2xsdNew.xsl | 12 +++++------ 5 files changed, 39 insertions(+), 21 deletions(-) diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py index 43e37a91..c60e3159 100644 --- a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -228,7 +228,7 @@ def test_json_round_trip(self): self.assertEqual(recovered.refs.skyCoordinateFrame[0].name, "J2000") def test_xml_serialise(self): - xml_bytes = self.model.to_xml(pretty_print=True) + xml_bytes = self.model.full_model_to_xml(pretty_print=True) _write("sample.xml", xml_bytes) _validate_xml(xml_bytes, "Sample.vo-dml.xsd", self) root = ET.fromstring(xml_bytes) @@ -296,7 +296,7 @@ def test_json_round_trip(self): self.assertEqual(recovered.aTest2[0].refcont, "lifecycleTest-ReferredLifeCycle_1012") def test_xml_serialise(self): - xml_bytes = self.model.to_xml(pretty_print=True) + xml_bytes = self.model.full_model_to_xml(pretty_print=True) _write("lifecycle.xml", xml_bytes) _validate_xml(xml_bytes, "lifecycleTest.vo-dml.xsd", self) root = ET.fromstring(xml_bytes) @@ -362,14 +362,14 @@ def test_json_round_trip(self): self.assertEqual(recovered.refs.refb[0].name, "naturalkey") def test_xml_serialise(self): - xml_bytes = self.model.to_xml(pretty_print=True) + xml_bytes = self.model.full_model_to_xml(pretty_print=True) _write("serializationsample.xml", xml_bytes) _validate_xml(xml_bytes, "serializationExample.vo-dml.xsd", self) root = ET.fromstring(xml_bytes) self.assertEqual(_local_name(root.tag), "MyModelModel") some_content = _find_first(root, "someContent") self.assertIsNotNone(some_content) - self.assertEqual(_first_child_text(some_content, "ref1"), "refa-1") + self.assertEqual(_first_child_text(some_content, "ref1"), "MyModel-Refa_1000") zvals = [el.text for el in root.iter() if _local_name(el.tag) == "zval"] self.assertEqual(zvals, ["some", "z", "values"]) @@ -377,8 +377,7 @@ def test_xml_serialise(self): def test_read_java_serialization_xml(self): from org.ivoa.dm.serializationsample.MyModel import Refa from_java = MyModelModel.from_xml( _read_java_file_as_bytes("serializationsample.xml")) - self.assertIsInstance(from_java.someContent[0].ref1, Refa) #FIXME this should be Refa object rather than string - want the xml reading to create a dicttionary of the references as they are read and then replace refefnce - + self.assertIsInstance(from_java.someContent[0].ref1, Refa) class JpatestModelInteropTest(unittest.TestCase): """Round-trip tests for the jpatest model wrapper.""" @@ -455,7 +454,7 @@ def test_json_round_trip(self): self.assertEqual(parent.cval.rval, "jpatest-ReferredTo2_1004") def test_xml_serialise(self): - xml_bytes = self.model.to_xml(pretty_print=True) + xml_bytes = self.model.full_model_to_xml(pretty_print=True) _write("jpatest.xml", xml_bytes) _validate_xml(xml_bytes, "jpatest.vo-dml.xsd", self) root = ET.fromstring(xml_bytes) @@ -529,7 +528,7 @@ def test_serializationsample_json_somecontent(self): data = _read_json("serializationsample.json") content = data["someContent"][0] self.assertEqual(content["zval"], ["some", "z", "values"]) - self.assertEqual(content["ref1"], "refa-1") + self.assertEqual(content["ref1"], "MyModel-Refa_1000") self.assertEqual(content["ref2"], "naturalkey") self.assertEqual(len(content["con"]), 2) @@ -537,7 +536,7 @@ def test_serializationsample_xml_somecontent(self): root = _read_xml_root("serializationsample.xml") some_content = _find_first(root, "someContent") self.assertIsNotNone(some_content) - self.assertEqual(_first_child_text(some_content, "ref1"), "refa-1") + self.assertEqual(_first_child_text(some_content, "ref1"), "MyModel-Refa_1000") zvals = [el.text for el in root.iter() if _local_name(el.tag) == "zval"] self.assertEqual(zvals, ["some", "z", "values"]) @@ -556,7 +555,9 @@ def test_jpatest_xml_parent(self): self.assertEqual(_first_child_text(parent, "rval"), "jpatest-ReferredTo1_1003") self.assertEqual(_first_child_text(_find_first(parent, "tval"), "dt"), "thing") lvals = _children_named(parent, "lval") - self.assertEqual([_first_child_text(child, "sval") for child in lvals], ["First", "Second", "Third"]) + self.assertEqual(len(lvals), 1) + lchildren = _children_named(lvals[0], "lChild") + self.assertEqual([_first_child_text(child, "sval") for child in lchildren], ["First", "Second", "Third"]) class PythonModelReadJavaTest(unittest.TestCase): diff --git a/tools/xslt/common-binding.xsl b/tools/xslt/common-binding.xsl index 4dd5db57..dc1fd6c0 100644 --- a/tools/xslt/common-binding.xsl +++ b/tools/xslt/common-binding.xsl @@ -594,12 +594,16 @@ Long - + + + + + diff --git a/tools/xslt/jaxb.xsl b/tools/xslt/jaxb.xsl index 56859206..a298878b 100644 --- a/tools/xslt/jaxb.xsl +++ b/tools/xslt/jaxb.xsl @@ -106,7 +106,7 @@ - + @jakarta.xml.bind.annotation.XmlAttribute(name = "", required =) @@ -128,7 +128,7 @@ - + cannot map to attribute with multiplicity > 1 diff --git a/tools/xslt/vo-dml2pydantic.xsl b/tools/xslt/vo-dml2pydantic.xsl index 78c0699b..707e99c0 100644 --- a/tools/xslt/vo-dml2pydantic.xsl +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -315,7 +315,14 @@ class (_VodmlXmlBase): - + + + + + + + + @@ -400,7 +407,7 @@ class (_VodmlXmlBase): - + @@ -480,6 +487,12 @@ class (_VodmlXmlBase): : List[] = xsfield({'type': 'Element', 'name': '', 'namespace': ''}, default_factory=list) + + def full_model_to_xml(self, pretty_print: bool = False) -> bytes: + """Serialise the whole model to XML, converting any object references to IDREF strings. with namespace declarations on the root element.""" + return self.to_xml(, pretty_print=pretty_print) + + @classmethod diff --git a/tools/xslt/vo-dml2xsdNew.xsl b/tools/xslt/vo-dml2xsdNew.xsl index 8f8e94c7..43336b24 100644 --- a/tools/xslt/vo-dml2xsdNew.xsl +++ b/tools/xslt/vo-dml2xsdNew.xsl @@ -211,9 +211,9 @@ note that this schema is substantially different from the era when this code was - + - + @@ -256,9 +256,9 @@ note that this schema is substantially different from the era when this code was - + - + @@ -317,7 +317,7 @@ note that this schema is substantially different from the era when this code was - + @@ -342,7 +342,7 @@ note that this schema is substantially different from the era when this code was - + From 3e1080eb468c366014865195d0d60f5ad6577f30 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 14 Apr 2026 10:06:17 +0100 Subject: [PATCH 27/29] add link to runtime not the nicest way to achieve this, but is quick and dirty until the runtime has evolved enough to be versioned. --- tools/gradletooling/sample/pythontest/src/vodml_runtime | 1 + 1 file changed, 1 insertion(+) create mode 120000 tools/gradletooling/sample/pythontest/src/vodml_runtime diff --git a/tools/gradletooling/sample/pythontest/src/vodml_runtime b/tools/gradletooling/sample/pythontest/src/vodml_runtime new file mode 120000 index 00000000..8fc4d971 --- /dev/null +++ b/tools/gradletooling/sample/pythontest/src/vodml_runtime @@ -0,0 +1 @@ +../../../../../runtime/python/vodml_runtime \ No newline at end of file From 6121f7ae44fa7ff95d0ad92fe506c62d3c015d5b Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 14 Apr 2026 10:25:24 +0100 Subject: [PATCH 28/29] this is now produced with an xml attribute --- .../gradletooling/sample/interoperability/python/sample.xml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/gradletooling/sample/interoperability/python/sample.xml b/tools/gradletooling/sample/interoperability/python/sample.xml index f43fde33..56b7f074 100644 --- a/tools/gradletooling/sample/interoperability/python/sample.xml +++ b/tools/gradletooling/sample/interoperability/python/sample.xml @@ -1,8 +1,7 @@ - - J2000 + http://coord.net J2000.0 @@ -22,8 +21,7 @@ degree 52.5 - - J2000 + http://coord.net J2000.0 From 30d66e35f7a1cde28f11a0c0eec9afa160d375a1 Mon Sep 17 00:00:00 2001 From: Paul Harrison Date: Tue, 14 Apr 2026 10:29:05 +0100 Subject: [PATCH 29/29] amend the test to express what is produced by the java better --- .../sample/pythontest/src/PydanticInteropTest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py index c60e3159..938ffd10 100644 --- a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -502,12 +502,14 @@ def test_sample_json_photometric_system(self): def test_sample_xml_source_catalogue(self): root = _read_xml_root("sample.xml") - catalogue = _find_first(root, "sourceCatalogue") + catalogue = _find_first(root, "catalog.inner.SourceCatalogue") # note name includes package parts.... self.assertIsNotNone(catalogue) self.assertEqual(_first_child_text(catalogue, "name"), "testCat") entry = _find_first(catalogue, "entry") self.assertEqual(_first_child_text(entry, "name"), "testSource") self.assertEqual(_first_child_text(entry, "classification"), "AGN") + position = _find_first(entry, "position") + self.assertEqual(_first_child_text(position, "frame"), "J2000") #note that this should be a reference tpo the frame not a frame instance... def test_lifecycle_json_atest2(self): data = _read_json("lifecycle.json")