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/.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/.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/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. 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/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/models/sample/test/serializationExample.vo-dml.xml b/models/sample/test/serializationExample.vo-dml.xml index 16db494e..d69e78af 100644 --- a/models/sample/test/serializationExample.vo-dml.xml +++ b/models/sample/test/serializationExample.vo-dml.xml @@ -8,7 +8,7 @@ 1.0 - 2026-04-01T12:30:22Z + 2026-04-10T16:35:10Z ivoa 1.0 @@ -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 d378e98a..6432887a 100644 --- a/models/sample/test/serializationExample.vodsl +++ b/models/sample/test/serializationExample.vodsl @@ -24,6 +24,7 @@ package types "" { abstract otype BaseC { !xmlmeta isAttribute="true"! bname: ivoa:string ""; + } otype Dcont -> BaseC { @@ -38,7 +39,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/runtime/java/src/main/java/org/ivoa/vodml/validation/AbstractBaseValidation.java b/runtime/java/src/main/java/org/ivoa/vodml/validation/AbstractBaseValidation.java index ed63bbb2..1ad82712 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=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/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..d17e582e --- /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 xsdata-pydantic 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..28ca7d31 100644 --- a/tools/gradletooling/sample/build.gradle.kts +++ b/tools/gradletooling/sample/build.gradle.kts @@ -114,15 +114,19 @@ 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.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" @@ -152,6 +156,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 --junit-xml=build/reports/pytestPydantic/results.xml" + dependsOn("vodmlPydanticGenerate", "vodmlSchema") +} + 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/java/jpatest.xml b/tools/gradletooling/sample/interoperability/java/jpatest.xml index a9f72b49..703cec26 100644 --- a/tools/gradletooling/sample/interoperability/java/jpatest.xml +++ b/tools/gradletooling/sample/interoperability/java/jpatest.xml @@ -1,109 +1,55 @@ - - + - - 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.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 384830fa..c836428c 100644 --- a/tools/gradletooling/sample/interoperability/java/serializationsample.xml +++ b/tools/gradletooling/sample/interoperability/java/serializationsample.xml @@ -1,59 +1,31 @@ - - + - - - + urn:value - - - naturalkey - ivo:val - - - - - MyModel-Refa_1044 - + MyModel-Refa_1000 naturalkey - - some - z - values - - - - - + dval - N1 - - - - + eval - cube - - - + urn:uri - 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/jpatest.json b/tools/gradletooling/sample/interoperability/python/jpatest.json new file mode 100644 index 00000000..5e1a3f9e --- /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" + }, + "lval": [ + { + "sval": "First", + "ival": 1 + }, + { + "sval": "Second", + "ival": 2 + }, + { + "sval": "Third", + "ival": 3 + } + ], + "tval": { + "p": { + "x": 1.5, + "y": 3.0 + }, + "dt": "thing" + } + } + ], + "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..4fde8150 --- /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 + + + + First + 1 + + + Second + 2 + + + Third + 3 + + + +

+ 1.5 + 3.0 +

+
thing
+
+
+
diff --git a/tools/gradletooling/sample/interoperability/python/lifecycle.json b/tools/gradletooling/sample/interoperability/python/lifecycle.json new file mode 100644 index 00000000..6289d83c --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/lifecycle.json @@ -0,0 +1,43 @@ +{ + "refs": { + "referredTo": [ + { + "id": "lifecycleTest-ReferredTo_1011", + "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 new file mode 100644 index 00000000..5a2a0896 --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/lifecycle.xml @@ -0,0 +1,30 @@ + + + + + 3 + + + + + lifecycleTest-ReferredTo_1011 + + lifecycleTest-ReferredLifeCycle_1012 + + + firstcontained + + + secondContained + + + rc1 + + + 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 new file mode 100644 index 00000000..672f2a02 --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/sample.json @@ -0,0 +1,149 @@ +{ + "refs": { + "skyCoordinateFrame": [ + { + "name": "J2000", + "documentURI": "http://coord.net", + "equinox": "J2000.0", + "system": null + } + ] + }, + "sourceCatalogue": [ + { + "name": "testCat", + "entry": [ + { + "label": "cepheid", + "name": "testSource", + "description": null, + "position": { + "longitude": { + "unit": { + "value": "degree" + }, + "value": 2.5 + }, + "latitude": { + "unit": { + "value": "degree" + }, + "value": 52.5 + }, + "frame": { + "name": "J2000", + "documentURI": "http://coord.net", + "equinox": "J2000.0", + "system": null + } + }, + "classification": "AGN", + "luminosity": [ + { + "value": { + "unit": { + "value": "Jy" + }, + "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", + "dataValidityFrom": "2020-01-01T00:00:00Z", + "dataValidityTo": "2025-01-01T20:12:16Z", + "spectralLocation": { + "unit": { + "value": "GHz" + }, + "value": 5.0 + } + } + }, + { + "value": { + "unit": { + "value": "Jy" + }, + "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", + "dataValidityFrom": "2020-01-01T00:00:00Z", + "dataValidityTo": "2025-01-01T13:12:00Z", + "spectralLocation": { + "unit": { + "value": "GHz" + }, + "value": 1.5 + } + } + } + ] + } + ], + "aTest": null, + "aTestMore": null + } + ], + "photometricSystem": [ + { + "description": "test photometric system", + "detectorType": 1, + "photometryFilter": [ + { + "id": null, + "fpsIdentifier": 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" + }, + "value": 5.0 + } + }, + { + "id": null, + "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 + } + } + ] + } + ] +} \ 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..56b7f074 --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/sample.xml @@ -0,0 +1,113 @@ + + + + + http://coord.net + J2000.0 + + + + testCat + + + + testSource + + + degree + 2.5 + + + degree + 52.5 + + + http://coord.net + J2000.0 + + + AGN + + + + Jy + 2.5 + + + Jy + 0.25 + + lummeas + flux + + C-Band + radio band + C-Band + 20-01-01T00:00:00Z + 25-01-01T20:12:16Z + + GHz + 5.0 + + + + + + Jy + 3.5 + + + Jy + 0.25 + + lummeas2 + flux + + L-Band + radio band + L-Band + 20-01-01T00:00:00Z + 25-01-01T13:12:00Z + + GHz + 1.5 + + + + + + 0.2 + 0.1 + + + + + + test photometric system + 1 + + + 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 new file mode 100644 index 00000000..f8da862d --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.json @@ -0,0 +1,40 @@ +{ + "refs": { + "refa": [ + { + "id": "MyModel-Refa_1000", + "val": { + "value": "urn:value" + } + } + ], + "refb": [ + { + "name": "naturalkey", + "val": { + "value": "ivo:val" + } + } + ] + }, + "someContent": [ + { + "ref1": "MyModel-Refa_1000", + "ref2": "naturalkey", + "zval": [ + "some", + "z", + "values" + ], + "con": [ + { + "bname": "dval" + }, + { + "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 new file mode 100644 index 00000000..511fe9ed --- /dev/null +++ b/tools/gradletooling/sample/interoperability/python/serializationsample.xml @@ -0,0 +1,32 @@ + + + + + urn:value + + + naturalkey + ivo:val + + + + MyModel-Refa_1000 + naturalkey + + some + z + values + + + + dval + N1 + + + eval + cube + + + urn:uri + + diff --git a/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py new file mode 100644 index 00000000..938ffd10 --- /dev/null +++ b/tools/gradletooling/sample/pythontest/src/PydanticInteropTest.py @@ -0,0 +1,587 @@ +""" +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 unittest +import xml.etree.ElementTree as ET +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 +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_inner import SourceCatalogue +from org.ivoa.dm.serializationsample.MyModel import MyModelModel, MyModelRefs + +# 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" + +# 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: + """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") + + +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() + +def _read_java_file_as_bytes(path: str) -> bytes: + with open(_JAVA_DIR / path, "rb") as f: + return f.read() + +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): + 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") + + frame = SkyCoordinateFrame( + name="J2000", + equinox="J2000.0", + documentURI="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]) + + cls.model = SampleModel(photometricSystem=[cls.ps], sourceCatalogue=[cls.sc],refs=SampleRefs(skyCoordinateFrame=[frame])) + + # ------------------------------------------------------------------ + # JSON + # ------------------------------------------------------------------ + + def test_json_serialise(self): + 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): + 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): + 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) + self.assertEqual(_local_name(root.tag), "sampleModel") + source_catalogue = _find_first(root, "sourceCatalogue") + self.assertIsNotNone(source_catalogue) + self.assertEqual(_first_child_text(source_catalogue, "name"), "testCat") + self.assertEqual(_first_child_text(_find_first(source_catalogue, "entry"), "name"), "testSource") + + 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") + + +class LifecycleModelInteropTest(unittest.TestCase): + """ + Tests for the Lifecycle test model. + + Mirrors the Java LifecycleTestModelTest which produces + interoperability/java/lifecycle.xml and interoperability/java/lifecycle.json. + """ + + @classmethod + def setUpClass(cls): + from org.ivoa.dm.lifecycle.lifecycleTest import ( + ATest, + ATest2, + ATest4, + Contained, + ReferredLifeCycle, + ReferredTo, + ) + #FIXME really need to add equivalent of the java processReferences() runtime functionality to set up the ids for references + ref1 = ReferredTo(id="lifecycleTest-ReferredTo_1011", test1=3) + rc1 = ReferredLifeCycle(id="lifecycleTest-ReferredLifeCycle_1012", test3="rc1") + rc2 = ReferredLifeCycle(id="lifecycleTest-ReferredLifeCycle_1013", test3="rc2") + atest = ATest( + ref1=ref1, + contained=[ + Contained(test2="firstcontained"), + Contained(test2="secondContained"), + ], + refandcontained=[rc1, rc2], + contained2=ATest4(lowr=rc1.id), + ) + 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.model.model_dump_json(indent=2) + _write("lifecycle.json", json_str) + + data = json.loads(json_str) + self.assertEqual(data["refs"]["referredTo"][0]["test1"], 3) + atest2 = data["aTest2"][0] + self.assertEqual(atest2["refcont"], "lifecycleTest-ReferredLifeCycle_1012") + self.assertEqual(len(atest2["atest"]["contained"]), 2) + self.assertEqual(atest2["atest"]["refandcontained"][1]["test3"], "rc2") + + def test_json_round_trip(self): + recovered = LifecycleTestModel.model_validate_json(self.model.model_dump_json()) + self.assertEqual(recovered.aTest2[0].atest.contained[0].test2, "firstcontained") + self.assertEqual(recovered.aTest2[0].refcont, "lifecycleTest-ReferredLifeCycle_1012") + + def test_xml_serialise(self): + 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) + self.assertEqual(_local_name(root.tag), "lifecycleTestModel") + atest2 = _find_first(root, "aTest2") + self.assertIsNotNone(atest2) + 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_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") + + +class SerializationExampleInteropTest(unittest.TestCase): + """ + Tests for the serialisation example model (MyModel). + + Mirrors the Java SerializationExampleTest which produces + interoperability/java/serializationsample.xml and + interoperability/java/serializationsample.json. + """ + + @classmethod + 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="MyModel-Refa_1000", val=altURL(value="urn:value")) + refb = Refb(name="naturalkey", val=ivoid(value="ivo:val")) + cls.model = MyModelModel( + someContent=[ + SomeContent( + ref1=refa.id, + ref2=refb.name, + zval=["some", "z", "values"], + con=[ + Dcont(bname="dval", dval="N1"), + Econt(bname="eval", evalue="cube"), + ], + uri="urn:uri" + ) + ], + refs=MyModelRefs(refa=[refa], refb=[refb]), + ) + + def test_json_serialise(self): + json_str = self.model.model_dump_json(indent=2) + _write("serializationsample.json", json_str) + + data = json.loads(json_str) + self.assertEqual(data["refs"]["refa"][0]["id"], "MyModel-Refa_1000") + content = data["someContent"][0] + 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) + + def test_json_round_trip(self): + recovered = MyModelModel.model_validate_json(self.model.model_dump_json()) + self.assertEqual(recovered.someContent[0].zval, ["some", "z", "values"]) + self.assertEqual(recovered.refs.refb[0].name, "naturalkey") + + def test_xml_serialise(self): + 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"), "MyModel-Refa_1000") + zvals = [el.text for el in root.iter() if _local_name(el.tag) == "zval"] + self.assertEqual(zvals, ["some", "z", "values"]) + + + 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) + +class JpatestModelInteropTest(unittest.TestCase): + """Round-trip tests for the jpatest model wrapper.""" + + @classmethod + def setUpClass(cls): + from org.ivoa.dm.jpatest.jpatest import ( + ADtype, + AEtype, + Child, + DThing, + JpatestModel, + JpatestRefs, + LChild, + Parent, + Point, + ReferredTo1, + ReferredTo2, + ReferredTo3, + ) + ref3 = ReferredTo3(id="jpatest-ReferredTo3_1002", sval="ref in dtype", ival=3) + ref2 = ReferredTo2(id="jpatest-ReferredTo2_1004", sval="lower ref") + ref1 = ReferredTo1(id="jpatest-ReferredTo1_1003", sval="top level ref") + + parent = Parent( + dval=ADtype( + basestr="base", + dref=ref3.id, + intatt="intatt", + dvalr=1.1, + dvals="astring", + ), + eval=AEtype( + basestr="basestre_e", + dref=ref3.id, + intatt="intatt_e", + evalr=1.2, + evals="evals", + ), + rval=ref1.id, + cval=Child(rval=ref2.id), + lval=[ + LChild(sval="First", ival=1), + LChild(sval="Second", ival=2), + LChild(sval="Third", ival=3), + ], + tval=DThing(p=Point(x=1.5, y=3.0), dt="thing"), + ) + + cls.model = JpatestModel( + refs=JpatestRefs(referredTo3=[ref3], referredTo2=[ref2], referredTo1=[ref1]), + parent=[parent], + ) + + def test_json_serialise(self): + json_str = self.model.model_dump_json(indent=2) + _write("jpatest.json", json_str) + + data = json.loads(json_str) + self.assertEqual(data["refs"]["referredTo3"][0]["ival"], 3) + parent = data["parent"][0] + self.assertEqual(parent["dval"]["dref"], "jpatest-ReferredTo3_1002") + self.assertEqual(parent["rval"], "jpatest-ReferredTo1_1003") + self.assertEqual(parent["cval"]["rval"], "jpatest-ReferredTo2_1004") + self.assertEqual([child["sval"] for child in parent["lval"]], ["First", "Second", "Third"]) + self.assertAlmostEqual(parent["tval"]["p"]["x"], 1.5) + + 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") + + def test_xml_serialise(self): + 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) + 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), 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)) + 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") + + +class PythonNonModelReadTest(unittest.TestCase): + """Validate the Python-written interoperability files without using the generated models.""" + + def test_sample_json_source_catalogue(self): + data = _read_json("sample.json") + self.assertIn("sourceCatalogue", data) + self.assertIn("photometricSystem", data) + + 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(entry["position"]["frame"], "J2000") + + def test_sample_json_photometric_system(self): + data = _read_json("sample.json") + ps = data["photometricSystem"][0] + self.assertEqual(ps["detectorType"], 1) + names = [item["name"] for item in ps["photometryFilter"]] + self.assertEqual(names, ["C-Band", "L-Band"]) + + def test_sample_xml_source_catalogue(self): + root = _read_xml_root("sample.xml") + 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") + 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): + 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): + data = _read_json("serializationsample.json") + content = data["someContent"][0] + self.assertEqual(content["zval"], ["some", "z", "values"]) + self.assertEqual(content["ref1"], "MyModel-Refa_1000") + self.assertEqual(content["ref2"], "naturalkey") + self.assertEqual(len(content["con"]), 2) + + 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"), "MyModel-Refa_1000") + zvals = [el.text for el in root.iter() if _local_name(el.tag) == "zval"] + self.assertEqual(zvals, ["some", "z", "values"]) + + 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") + + 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(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): + """Sanity-check that the Java interoperability fixtures exist.""" + + def test_java_interop_files_exist(self): + expected = [ + "sample.json", + "sample.xml", + "lifecycle.json", + "lifecycle.xml", + "serializationsample.json", + "serializationsample.xml", + "jpatest.json", + "jpatest.xml", + ] + 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)", + ) + + +if __name__ == "__main__": + unittest.main() 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. 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 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; 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 new file mode 100644 index 00000000..707e99c0 --- /dev/null +++ b/tools/xslt/vo-dml2pydantic.xsl @@ -0,0 +1,558 @@ + + + +"> + "> +]> + + + + + + + + + + + + + + + + + + + + + " + + ' + + + + + + + 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, Union, ClassVar +from enum import Enum +from xsdata_pydantic.fields import field as xsfield +from vodml_runtime.VodmlXmlBase import _VodmlXmlBase + +import xsdata_pydantic.hooks.class_type # register pydantic support with xsdata + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Writing pydantic code for base= haschildren= + + + + 1) Mapped type for = '' + + + + + + + + + + + + + + +class ( + + _VodmlXmlBase + ): + class Meta: + name = "" + namespace = "" + + """ + * + * + * : + * + * + """ + + + 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]] = [, ""] + + + + + + + + + + + pass + + + + + + + + + + + + + + +class ( + + _VodmlXmlBase + ): + class Meta: + name = "" + namespace = "" + + """ + * + * + * : + * + * + """ + + + _vodml_refs: ClassVar[list[str]] = [, ""] + + + + + + + + + + + pass + + + + + + + + + + +class + + """ + * + * + * Enumeration : + * + * + """ + + +&cr; + + + + + + + + + + + + + Primitive type represented as str + + + + + + + +class (_VodmlXmlBase): + class Meta: + name = "" + """ + * + * PrimitiveType : + * + * + """ + + value: = xsfield({'type': 'Text'}) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + Attribute : multiplicity + + + """ + + + + + + + + + + + + + + + + """ + * Attribute : subsetted + * + . + """ + + + + + + + + + + + + + + """ + * + * Composition : ( Multiplicity : ) + + * + """ + + + + + + + + + + + + + + + + + + + + + """ + * Composition : ( Multiplicity : ) + * + * + """ + + + + + + + + + + + + + + + + + + + + + + + + """ + * Reference : + * + * ( Multiplicity : ) """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +class (_VodmlXmlBase): + class Meta: + name = "refs" + + + + + : List[] = xsfield({'type': 'Element', 'name': '', 'namespace': ''}, default_factory=list) + + + + pass + + + +class (_VodmlXmlBase): + class Meta: + name = "" + namespace = "" + refs: Optional[] = xsfield({'type': 'Element', 'name': 'refs', 'namespace': ''}, default=None) + + + + + : 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 + 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 + + + + + + + + + + + + + + = "" + """ + * Value : + * + * + """ + + + + + + + + + + + + + + + + + + + Writing package info file + +""" + + + +""" + + + + + + + + + + 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 - +