Skip to content

Commit

Permalink
Support multivalued UWS parameters (#24)
Browse files Browse the repository at this point in the history
Adds support for multi-valued job parameters.

Fixes #21

Co-authored-by: Joshua Fraustro <[email protected]>
  • Loading branch information
rra and jwfraustro authored Sep 16, 2024
1 parent dd55cbb commit 5ead16e
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 18 deletions.
5 changes: 4 additions & 1 deletion docs/source/pages/protocols/uws.rst
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ Represents a single parameter of a UWS job.
:start-after: parameter-xml-start
:end-before: parameter-xml-end

For multi-valued attributes, use the type `vo_models.uws.models.MultiValuedParameter` instead of ``list[Parameter]``.
This is equivalent to ``list[Parameter]`` but adds some special validation support required for multi-valued UWS job parameters.

Parameters
**********

Expand Down Expand Up @@ -225,4 +228,4 @@ The following simple types are provided in the ``vo_models.uws`` package for use

- :py:class:`vo_models.uws.types.ErrorType`
- :py:class:`vo_models.uws.types.ExecutionPhase`
- :py:class:`vo_models.uws.types.UWSVersion`
- :py:class:`vo_models.uws.types.UWSVersion`
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ readme = "README.md"
requires-python = ">=3.10"

dependencies = [
"pydantic>2",
"pydantic-xml[lxml]>=2.6.0",
]

Expand Down
56 changes: 49 additions & 7 deletions tests/uws/uws_models_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
ErrorSummary,
Jobs,
JobSummary,
MultiValuedParameter,
Parameter,
Parameters,
ResultReference,
Expand Down Expand Up @@ -257,37 +258,74 @@ class TestParameters(Parameters):

param1: Optional[Parameter] = element(tag="parameter", default=None)
param2: Optional[Parameter] = element(tag="parameter", default=None)
param3: Optional[Parameter] = element(tag="parameter", default=None)
param3: Optional[MultiValuedParameter] = element(tag="parameter", default=None)
param4: Optional[MultiValuedParameter] = element(tag="parameter", default=None)
param5: MultiValuedParameter = element(tag="parameter")

test_parameters_xml = (
f"<uws:parameters {UWS_NAMESPACE_HEADER}>"
'<uws:parameter byReference="false" isPost="false" id="param1">value1</uws:parameter>'
'<uws:parameter byReference="false" isPost="false" id="param2">value2</uws:parameter>'
'<uws:parameter byReference="false" isPost="false" id="param3">value3</uws:parameter>'
'<uws:parameter byReference="false" isPost="false" id="param4">value4</uws:parameter>'
'<uws:parameter byReference="false" isPost="false" id="param4">second value4</uws:parameter>'
'<uws:parameter byReference="false" isPost="false" id="param5">value5</uws:parameter>'
"</uws:parameters>"
)

# Test that the same parameters are parsed correctly when given out of order.
test_parameters_xml_reordered = (
f"<uws:parameters {UWS_NAMESPACE_HEADER}>"
'<uws:parameter byReference="false" isPost="false" id="param5">value5</uws:parameter>'
'<uws:parameter byReference="false" isPost="false" id="param4">value4</uws:parameter>'
'<uws:parameter byReference="false" isPost="false" id="param1">value1</uws:parameter>'
'<uws:parameter byReference="false" isPost="false" id="param4">second value4</uws:parameter>'
'<uws:parameter byReference="false" isPost="false" id="param2">value2</uws:parameter>'
"</uws:parameters>"
)

def test_read_from_xml(self):
"""Test reading from XML"""

parameters = self.TestParameters.from_xml(self.test_parameters_xml)
self.assertEqual(len(parameters.model_dump()), 3)

self.assertEqual(len(parameters.dict()), 5)

self.assertEqual(parameters.param3, None)
self.assertEqual(len(parameters.param4), 2)
self.assertEqual(len(parameters.param5), 1)

self.assertEqual(parameters.param1.id, "param1")
self.assertEqual(parameters.param2.id, "param2")
self.assertEqual(parameters.param3.id, "param3")
self.assertEqual(parameters.param4[0].id, "param4")
self.assertEqual(parameters.param4[1].id, "param4")
self.assertEqual(parameters.param5[0].id, "param5")

self.assertEqual(parameters.param1.value, "value1")
self.assertEqual(parameters.param2.value, "value2")
self.assertEqual(parameters.param3.value, "value3")
self.assertEqual(parameters.param4[0].value, "value4")
self.assertEqual(parameters.param4[1].value, "second value4")
self.assertEqual(parameters.param5[0].value, "value5")

def test_read_from_xml_reordered(self):
"""Test reading from XML out of order"""

parameters = self.TestParameters.from_xml(self.test_parameters_xml)
parameters_reordered = self.TestParameters.from_xml(self.test_parameters_xml_reordered)

assert parameters == parameters_reordered

def test_write_to_xml(self):
"""Test writing to XML"""

parameters_element = self.TestParameters(
param1=Parameter(id="param1", value="value1"),
param2=Parameter(id="param2", value="value2"),
param3=Parameter(id="param3", value="value3"),
param3=None,
param4=[
Parameter(id="param4", value="value4"),
Parameter(id="param4", value="second value4"),
],
param5=[Parameter(id="param5", value="value5")],
)
parameters_xml = parameters_element.to_xml(skip_empty=True, encoding=str)

Expand All @@ -302,7 +340,11 @@ def test_validate(self):
parameters = self.TestParameters(
param1=Parameter(id="param1", value="value1"),
param2=Parameter(id="param2", value="value2"),
param3=Parameter(id="param3", value="value3"),
param4=[
Parameter(id="param4", value="value4"),
Parameter(id="param4", value="second value4"),
],
param5=[Parameter(id="param5", value="value5")],
)
parameters_xml = etree.fromstring(
parameters.to_xml(skip_empty=True, encoding=str)
Expand Down
1 change: 1 addition & 0 deletions vo_models/uws/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Job,
Jobs,
JobSummary,
MultiValuedParameter,
Parameter,
Parameters,
ParametersType,
Expand Down
61 changes: 51 additions & 10 deletions vo_models/uws/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""UWS Job Schema using Pydantic-XML models"""
from typing import Dict, Generic, Optional, TypeVar
from typing import Annotated, Dict, Generic, Optional, TypeAlias, TypeVar


from pydantic import BeforeValidator
from pydantic import ConfigDict

from pydantic_xml import BaseXmlModel, attr, element

from vo_models.uws.types import ErrorType, ExecutionPhase, UWSVersion
Expand Down Expand Up @@ -42,23 +45,61 @@ class Parameter(BaseXmlModel, tag="parameter", ns="uws", nsmap=NSMAP):
is_post: Optional[bool] = attr(name="isPost", default=False)


MultiValuedParameter: TypeAlias = Annotated[
list[Parameter],
BeforeValidator(lambda v: v if isinstance(v, list) else [v])
]
"""Type for a multi-valued parameter.
This type must be used instead of ``list[Parameter]`` for parameters that may
take multiple values. The resulting model attribute will be a list of
`Parameter` objects with the same ``id``.
"""


class Parameters(BaseXmlModel, tag="parameters", ns="uws", nsmap=NSMAP):
"""
An abstract holder of UWS parameters.
"""An abstract holder of UWS parameters.
The list of input parameters to the job - if the job description language does not naturally have
parameters, then this list should contain one element which is the content of the original POST that created the
job.
The input parameters to the job. For simple key/value pair parameters, there must be one model field per key, with
a type of either `Parameter` or `MultiValuedParameter` depending on whether it can be repeated. If the job
description language does not naturally have parameters, then this model should contain one element, which is the
content of the original POST that created the job.
"""

def __init__(__pydantic_self__, **data) -> None: # pylint: disable=no-self-argument
# during init -- especially if reading from xml -- we may not get the parameters in the order
# pydantic-xml expects. This will remap the dict with keys based on the parameter id.
parameter_vals = [val for val in data.values() if val is not None]
# pydantic-xml's from_xml method only knows how to gather parameters by tag, not by attribute, but UWS job
# parameters all use the same tag and distinguish between different parameters by the id attribute. Therefore,
# the input data that we get from pydantic-xml will have assigned the Parameter objects to the model attributes
# in model declaration order. This may be wildly incorrect if the input parameters were specified in a
# different order than the model.
#
# This processing collapses all of the parameters down to a simple list, turns that into a dict to Parameter
# objects or lists of Parameter objects by id attribute values, and then calls the parent constructor with that
# as the input data instead. This should cause Pydantic to associate the job parameters with the correct model
# attributes.
parameter_vals = []
for val in data.values():
if val is None:
continue
elif isinstance(val, list):
parameter_vals.extend(v for v in val if v is not None)
else:
parameter_vals.append(val)

# Reconstruct the proper input parameters to the model based on the id attribute of the parameters. First
# convert each parameter to a Parameter object, and then turn the parameters into a dict mapping the id to a
# value or list of values. If we see multiple parameters with the same id, assume the parameter may be
# multivalued and build a list. If this assumption is incorrect, Pydantic will reject the list during input
# validation.
remapped_vals = {}
for param in parameter_vals:
if isinstance(param, dict):
remapped_vals[param["id"]] = Parameter(**param)
param = Parameter(**param)
if param.id in remapped_vals:
if isinstance(remapped_vals[param.id], list):
remapped_vals[param.id].append(param)
else:
remapped_vals[param.id] = [remapped_vals[param.id], param]
else:
remapped_vals[param.id] = param
data = remapped_vals
Expand Down

0 comments on commit 5ead16e

Please sign in to comment.