From c6d7752357a0fb93ea073612c33474d84d37d6c8 Mon Sep 17 00:00:00 2001 From: Daniel Neilson <53624638+ddneilson@users.noreply.github.com> Date: Wed, 17 Jan 2024 09:43:13 -0600 Subject: [PATCH] feat: adds model_to_object() function (#34) This library currently contains a lot of functionality for going from a dictionary to a model, but doesn't have an interface for going the other way. All of the models are currently Pydantic models and Pydantic makes a `dict()` method available on all models, but that method isn't sufficient for OpenJD models. OpenJD models may contain values of type Decimal, and Decimal types don't serialize in to json/yaml using the default encoders. This adds a method that can be used to convert a model to a dictionary in a way that is compatible with json/yaml encoders -- we convert all Decimal values in to strings. Signed-off-by: Daniel Neilson <53624638+ddneilson@users.noreply.github.com> --- README.md | 32 +++++++++++++++++++++++++ src/openjd/model/__init__.py | 8 +++++-- src/openjd/model/_parse.py | 35 +++++++++++++++++++++++++++- test/openjd/model/test_parse.py | 41 +++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4be428a..84397b3 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,38 @@ job_template = JobTemplate( ) ``` +### Converting a Template Model to a Dictionary + +```python +import json +from openjd.model import ( + decode_job_template, + model_to_object, +) +from openjd.model.v2023_09 import * + +job_template = JobTemplate( + specificationVersion="jobtemplate-2023-09", + name="DemoJob", + steps=[ + StepTemplate( + name="DemoStep", + script=StepScript( + actions=StepActions( + onRun=Action( + command="echo", + args=["Hello world"] + ) + ) + ) + ) + ] +) + +obj = model_to_object(model=job_template) +print(json.dumps(obj)) +``` + ### Creating a Job from a Job Template ```python diff --git a/src/openjd/model/__init__.py b/src/openjd/model/__init__.py index 34e1e65..5a8c3f9 100644 --- a/src/openjd/model/__init__.py +++ b/src/openjd/model/__init__.py @@ -16,6 +16,7 @@ decode_environment_template, decode_job_template, document_string_to_object, + model_to_object, parse_model, ) from ._step_dependency_graph import ( @@ -33,6 +34,7 @@ JobParameterInputValues, JobParameterValues, JobTemplate, + OpenJDModel, ParameterValue, ParameterValueType, SchemaVersion, @@ -48,10 +50,11 @@ "decode_environment_template", "decode_job_template", "document_string_to_object", - "validate_amount_capability_name", - "validate_attribute_capability_name", + "model_to_object", "parse_model", "preprocess_job_parameters", + "validate_amount_capability_name", + "validate_attribute_capability_name", "CompatibilityError", "DecodeValidationError", "DocumentType", @@ -64,6 +67,7 @@ "JobParameterValues", "JobTemplate", "ModelValidationError", + "OpenJDModel", "ParameterValue", "ParameterValueType", "SchemaVersion", diff --git a/src/openjd/model/_parse.py b/src/openjd/model/_parse.py index c0641f6..72e9c92 100644 --- a/src/openjd/model/_parse.py +++ b/src/openjd/model/_parse.py @@ -2,8 +2,9 @@ import json from dataclasses import is_dataclass +from decimal import Decimal from enum import Enum -from typing import Any, ClassVar, Type, TypeVar, cast +from typing import Any, ClassVar, Type, TypeVar, Union, cast import yaml from pydantic import BaseModel @@ -85,6 +86,38 @@ def document_string_to_object(*, document: str, document_type: DocumentType) -> ) +def model_to_object(*, model: OpenJDModel) -> dict[str, Any]: + """Given a model from this package, encode it as a dictionary such that it could + be written to a JSON/YAML document.""" + + as_dict = model.dict() + + # Some of the values in the model can be type 'Decimal', which doesn't + # encode into json/yaml without special handling. So, we convert those in to + # strings. + def decimal_to_str(data: Union[dict[str, Any], list[Any]]) -> None: + if isinstance(data, list): + for i, item in enumerate(data): + if isinstance(item, Decimal): + data[i] = str(item) + elif isinstance(item, (dict, list)): + decimal_to_str(item) + else: + delete_keys: list[str] = [] + for k, v in data.items(): + if isinstance(v, Decimal): + data[k] = str(v) + elif isinstance(v, (dict, list)): + decimal_to_str(v) + elif v is None: + delete_keys.append(k) + for k in delete_keys: + del data[k] + + decimal_to_str(as_dict) + return as_dict + + def decode_job_template(*, template: dict[str, Any]) -> JobTemplate: """Given a dictionary containing a Job Template, this will decode the template, run validation checks on it, and then return the decoded template. diff --git a/test/openjd/model/test_parse.py b/test/openjd/model/test_parse.py index 98247a0..bb09775 100644 --- a/test/openjd/model/test_parse.py +++ b/test/openjd/model/test_parse.py @@ -12,6 +12,7 @@ decode_environment_template, decode_job_template, document_string_to_object, + model_to_object, ) from openjd.model._types import OpenJDModel from openjd.model.v2023_09 import JobTemplate as JobTemplate_2023_09 @@ -62,6 +63,46 @@ def test_bad_parse(self, document: str, doctype: DocumentType) -> None: document_string_to_object(document=document, document_type=doctype) +class TestModelToObject: + @pytest.mark.parametrize( + "template", + [ + pytest.param( + { + "name": "DemoJob", + "specificationVersion": "jobtemplate-2023-09", + "parameterDefinitions": [{"name": "Foo", "type": "FLOAT", "default": "12"}], + "steps": [ + { + "name": "DemoStep", + "parameterSpace": { + "taskParameterDefinitions": [ + {"name": "Foo", "type": "FLOAT", "range": ["1.1", "1.2"]} + ] + }, + "script": { + "actions": { + "onRun": {"command": "echo", "args": ["Foo={{Param.Foo}}"]} + } + }, + } + ], + }, + id="translates Decimal to string", + ) + ], + ) + def test(self, template: dict[str, Any]) -> None: + # GIVEN + model = decode_job_template(template=template) + + # WHEN + result = model_to_object(model=model) + + # THEN + assert result == template + + class TestDecodeJobTemplate: @pytest.mark.parametrize( "template",