diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml
new file mode 100644
index 00000000..59fbb434
--- /dev/null
+++ b/.github/workflows/python-package-conda.yml
@@ -0,0 +1,38 @@
+name: Build and Release Wheel
+
+on:
+ push:
+ tags:
+ - '**' # Trigger on tags like v1.0.0, v2.1.3, etc.
+
+jobs:
+ build-and-release:
+ name: Build Wheel and Create Release
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Needed for setuptools_scm to determine version from git tags
+
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install build dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install build setuptools setuptools_scm wheel pytest-runner
+
+ - name: Build wheel
+ run: |
+ python -m build --wheel
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: dist/*.whl
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index a4e674d3..8e237ab6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,13 @@ website/.docusaurus
website/npm-debug.log*
website/yarn-debug.log*
-website/yarn-error.log*
\ No newline at end of file
+website/yarn-error.log*
+
+# python related artefacts and directories
+__pycache__
+.pydevenv
+build
+src/rune.runtime.egg-info
+rune.runtime-*.whl
+src/rune/runtime/version.py
+/.coverage
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 00000000..6b76b4fa
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,15 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Python Debugger: Current File",
+ "type": "debugpy",
+ "request": "launch",
+ "program": "${file}",
+ "console": "integratedTerminal"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000..3e99ede3
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,7 @@
+{
+ "python.testing.pytestArgs": [
+ "."
+ ],
+ "python.testing.unittestEnabled": false,
+ "python.testing.pytestEnabled": true
+}
\ No newline at end of file
diff --git a/NOTICE b/NOTICE
index 98a1ba44..79eb4b48 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,5 +1,5 @@
-{project name} - FINOS
-Copyright {yyyy} - {current year} {name of copyright owner} {email of copyright holder}
+Rune Python Runtime - FINOS
+Copyright 2023 - 2025 {name of copyright owner} {email of copyright holder}
This product includes software developed at the Fintech Open Source Foundation (https://www.finos.org/).
diff --git a/README.md b/README.md
index 60f73f80..7a27bfec 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,78 @@
-
+[](https://community.finos.org/docs/governance/Software-Projects/stages/incubating)
-# FINOS Software Project Blueprint
+# Rune Python Runtime
-Project blueprint is a GitHub repository template for all [Fintech Open Source Foundation (FINOS)](https://www.finos.org/) hosted GitHub repositories, contributed and maintained by FINOS.
+The Rune Python Runtime supports and is an integral part of Python code generated from a [Rune DSL](https://github.com/finos/rune-dsl) defined model. Rune DSL is a Domain-Specific Language used to model Financial Markets activities including the [Common Domain Model (CDM)](https://github.com/finos/common-domain-model).
-## How to use this blueprint
+The Rune Python Runtime is used in collaboration with the [Rune Python Code Generator](https://github.com/REGnosys/rosetta-code-generators) to translate a Rune DSL model into a fully usable Python package.
-Please follow instructions on [community.finos.org/docs/collaboration-infrastructure#finos-project-blueprint](https://community.finos.org/docs/collaboration-infrastructure#finos-project-blueprint)
-## Blueprint preview
+## Installation
-A preview of the blueprint can be found on [project-blueprint.finos.org](https://project-blueprint.finos.org)
+The runtime is not generally installed on a stand alone basis but rather comes as part of a generated package such as CDM.
-## Governance
-This blueprint implements https://community.finos.org/docs/governance/#open-source-software-projects
+Regardless, to install the package standalone:
+
+1. Fetch the latest release data from the GitHub API
+```sh
+release_data=$(curl -s https://api.github.com/repos/REGnosys/rune-python-runtime/releases/latest)
+```
+2. Extract the download URL of the first asset
+```sh
+download_url=$(echo "$release_data" | grep '"browser_download_url":' | head -n 1 | sed -E 's/.*"([^"]+)".*/\1/')
+```
+3. Download the artifact using wget or curl
+```sh
+wget "$download_url"
+```
+
+4. Install the Runtime
+```sh
+python -m pip install rune.runtime*-py3-*.whl
+```
+
+## Development setup
+
+Use [dev_clean_setup.sh](https://github.com/Cloudrisk/rune-python-runtime/blob/main/dev_clean_setup.sh) to setup a development environment.
+
+```sh
+./dev_clean_setup.sh
+```
+Use [build_wheel.sh](https://github.com/Cloudrisk/rune-python-runtime/blob/main/build_wheel.sh) to build the package
+```sh
+./build_wheel.sh
+```
+To run the unit tests:
+```sh
+test/run_runtime_tests.sh
+```
+
+## Roadmap
+
+The Roadmap will be aligned to the Rune DSL and [CDM](https://github.com/finos/common-domain-model/blob/master/ROADMAP.md) roadmaps.
+
+In addition, the intention is to make future releases available at [PyPi](https://pypi.org)
+
+## Contributing
+For any questions, bugs or feature requests please open an [issue](https://github.com/finos/{project slug}/issues)
+For anything else please send an email to {project mailing list}.
+
+To submit a contribution:
+1. Fork it ()
+2. Create your feature branch (`git checkout -b feature/fooBar`)
+3. Read our [contribution guidelines](.github/CONTRIBUTING.md) and [Community Code of Conduct](https://www.finos.org/code-of-conduct)
+4. Commit your changes (`git commit -am 'Add some fooBar'`)
+5. Push to the branch (`git push origin feature/fooBar`)
+6. Create a new Pull Request
+
+_NOTE:_ Commits and pull requests to FINOS repositories will only be accepted from those contributors with an active, executed Individual Contributor License Agreement (ICLA) with FINOS OR who are covered under an existing and active Corporate Contribution License Agreement (CCLA) executed with FINOS. Commits from individuals not covered under an ICLA or CCLA will be flagged and blocked by the FINOS Clabot tool (or [EasyCLA](https://community.finos.org/docs/governance/Software-Projects/easycla)). Please note that some CCLAs require individuals/employees to be explicitly named on the CCLA.
+
+*Need an ICLA? Unsure if you are covered under an existing CCLA? Email [help@finos.org](mailto:help@finos.org)*
## License
-Copyright 2019 Fintech Open Source Foundation
+Copyright {yyyy} {name of copyright owner}
Distributed under the [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0).
-SPDX-License-Identifier: [Apache-2.0](https://spdx.org/licenses/Apache-2.0)
+SPDX-License-Identifier: [Apache-2.0](https://spdx.org/licenses/Apache-2.0)
\ No newline at end of file
diff --git a/build_wheel.sh b/build_wheel.sh
new file mode 100755
index 00000000..d505da1d
--- /dev/null
+++ b/build_wheel.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+
+function error
+{
+ echo
+ echo "***************************************************************************"
+ echo "* *"
+ echo "* WHEEL Build FAILED! *"
+ echo "* *"
+ echo "***************************************************************************"
+ echo
+ exit -1
+}
+
+MY_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
+cd ${MY_PATH} || error
+
+if [ -d ".pydevenv" ]; then
+ export PATH=`echo $PATH | tr ":" "\n" | grep -v ".pydevenv" | tr "\n" ":"`
+fi
+type -P python > /dev/null && PY_EXE=python || PY_EXE=python3
+
+${PY_EXE} -m pip wheel --no-deps --only-binary :all: . || error
\ No newline at end of file
diff --git a/dev_clean_setup.sh b/dev_clean_setup.sh
new file mode 100755
index 00000000..9df050cd
--- /dev/null
+++ b/dev_clean_setup.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+
+function error
+{
+ echo
+ echo "***************************************************************************"
+ echo "* *"
+ echo "* DEV ENV Initialization FAILED! *"
+ echo "* *"
+ echo "***************************************************************************"
+ echo
+ exit -1
+}
+
+MY_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
+cd ${MY_PATH} || error
+
+
+if [ -d ".pydevenv" ]; then
+ export PATH=`echo $PATH | tr ":" "\n" | grep -v ".pydevenv" | tr "\n" ":"`
+fi
+
+
+type -P python > /dev/null && PY_EXE=python || PY_EXE=python3
+if [ -z "${WINDIR}" ]; then
+ PY_SCRIPTS='bin'
+else
+ PY_SCRIPTS='Scripts'
+fi
+
+
+${PY_EXE} -m venv --clear .pydevenv || error
+. .pydevenv/${PY_SCRIPTS}/activate || error
+
+
+pip install -r dev_requirements.txt || error
+python -m pip install -e . --config-settings editable_mode=compat || error
diff --git a/dev_requirements.txt b/dev_requirements.txt
new file mode 100644
index 00000000..55b033e9
--- /dev/null
+++ b/dev_requirements.txt
@@ -0,0 +1 @@
+pytest
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..438a7333
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,51 @@
+[build-system]
+requires = ["setuptools>=64", "setuptools_scm>=8", "wheel", "pytest-runner"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "rune.runtime"
+dynamic = ["version"]
+requires-python = ">=3.11"
+dependencies = [
+ "pydantic>=2.10.3"
+]
+optional-dependencies.dev = [
+ "pytest",
+ "pytest-cov",
+ "pytest-mock"
+]
+description = "rune-runtime: the Rune DSL runtime for Python"
+readme = "README.md"
+keywords = [
+ "rune",
+ "rune runtime",
+]
+license = { text = "APACHE 2.0" }
+authors = [
+ { name = "Daniel Schwartz" },
+ { name = "Plamen Neykov" },
+ { name = "Others (See AUTHORS)" }
+]
+classifiers = [
+ "Development Status :: 6 - Mature",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: Apache 2.0 License",
+ "Operating System :: MacOS",
+ "Operating System :: Microsoft :: Windows",
+ "Operating System :: POSIX",
+ "Operating System :: Unix",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Software Development :: Libraries",
+ "Topic :: Software Development :: Rune DSL",
+ "Topic :: Software Development :: Rune DSL Runtime"
+]
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.setuptools_scm]
+version_file = "src/rune/runtime/version.py"
diff --git a/src/rune/runtime/__init__.py b/src/rune/runtime/__init__.py
new file mode 100644
index 00000000..db184a7e
--- /dev/null
+++ b/src/rune/runtime/__init__.py
@@ -0,0 +1,5 @@
+'''rune dsl runtime package'''
+# PEP 396 module version attribute in PEP 440 version format
+from .version import __version__
+
+# EOF
diff --git a/src/rune/runtime/base_data_class.py b/src/rune/runtime/base_data_class.py
new file mode 100644
index 00000000..034bc477
--- /dev/null
+++ b/src/rune/runtime/base_data_class.py
@@ -0,0 +1,400 @@
+'''Base class for all Rune type classes'''
+import logging
+import importlib
+import json
+from typing import get_args, get_origin, Any, Literal
+from typing_extensions import Self
+from pydantic import (BaseModel, ValidationError, ConfigDict, model_serializer,
+ model_validator, ModelWrapValidatorHandler)
+from pydantic.main import IncEx
+from rune.runtime.conditions import ConditionViolationError
+from rune.runtime.conditions import get_conditions
+from rune.runtime.metadata import (ComplexTypeMetaDataMixin, Reference,
+ UnresolvedReference, BaseMetaDataMixin,
+ _EnumWrapper, RUNE_OBJ_MAPS)
+
+ROOT_CONTAINER = '__rune_root_metadata'
+
+
+class BaseDataClass(BaseModel, ComplexTypeMetaDataMixin):
+ ''' A base class for all cdm generated classes. It is derived from
+ `pydantic.BaseModel` which provides type checking at object creation
+ for all cdm classes. It provides as well the `validate_model`,
+ `validate_conditions` and `validate_attribs` methods which perform the
+ conditions, cardinality and type checks as specified in the rune
+ type model. The method `validate_model` is not invoked automatically,
+ but is left to the user to determine when to check the validity of the
+ cdm model.
+ '''
+ model_config = ConfigDict(extra='ignore',
+ revalidate_instances='always',
+ arbitrary_types_allowed=True)
+
+ def __setattr__(self, name: str, value: Any) -> None:
+ if isinstance(value, Reference):
+ self._bind_property_to(name, value)
+ else:
+ # replace reference with an object
+ if name in self._get_rune_refs_container():
+ self._remove_rune_ref(name)
+ if isinstance(self.__dict__[name], _EnumWrapper):
+ self.__dict__[name] = _EnumWrapper()
+ # if the value is an enum, pass it to the EnumWrapper
+ if (isinstance(self.__dict__[name], _EnumWrapper)
+ and not isinstance(value, _EnumWrapper)):
+ value = _EnumWrapper(value)
+ # if the value is a "model", register as rune_parent
+ if isinstance(value, BaseMetaDataMixin):
+ value._set_rune_parent(self)
+ super().__setattr__(name, value)
+
+ @model_serializer(mode='wrap')
+ def _serialize_refs(self, serializer, info):
+ '''should replace objects with refs while serializing'''
+ res = serializer(self, info)
+ refs = self._get_rune_refs_container()
+ for property_nm, (key, ref_type) in refs.items():
+ res[property_nm] = {ref_type.rune_ref_tag: key}
+ res = self.__dict__.get(ROOT_CONTAINER, {}) | res
+ return res
+
+ @model_validator(mode='wrap')
+ @classmethod
+ def _deserialize_refs(cls, data: Any,
+ handler: ModelWrapValidatorHandler[Self]) -> Self:
+ '''should resolve refs after creation'''
+ obj = handler(data)
+ obj._init_rune_parent() # pylint: disable=protected-access
+ obj.resolve_references(ignore_dangling=True, recurse=False)
+ return obj
+
+ def _init_rune_parent(self):
+ '''sets the rune parent in all properties'''
+ refs = self._get_rune_refs_container()
+ if not self.get_rune_parent() and RUNE_OBJ_MAPS not in self.__dict__:
+ self.__dict__[RUNE_OBJ_MAPS] = {}
+
+ for prop_nm, obj in self.__dict__.items():
+ if (isinstance(obj, BaseMetaDataMixin)
+ and not prop_nm.startswith('__') and prop_nm not in refs):
+ obj._set_rune_parent(self) # pylint: disable=protected-access
+
+ def rune_serialize(
+ self,
+ *,
+ validate_model: bool = True,
+ check_rune_constraints: bool = True,
+ strict: bool = True,
+ raise_validation_errors: bool = True,
+ indent: int | None = None,
+ include: IncEx | None = None,
+ exclude: IncEx | None = None,
+ exclude_unset: bool = True,
+ exclude_defaults: bool = True,
+ exclude_none: bool = False,
+ round_trip: bool = False,
+ warnings: bool | Literal['none', 'warn', 'error'] = True,
+ serialize_as_any: bool = False,
+ ) -> str:
+ '''Rune conform serialization to json string. To be invoked on the model
+ root.
+
+ #### Args:
+ `validate_model (bool, optional):` Validate the model prior
+ serialization. It checks also all Rune type constraints.
+ Defaults to True.
+
+ `check_rune_constraints (bool, optional):` If `validate_model` is
+ set to `True`, executes all model defined Rune constraints after
+ deserialization. Defaults to True.
+
+ `strict (bool, optional):` Perform strict attribute validation.
+ Defaults to True.
+
+ `raise_validation_errors (bool, optional):` Raise an exception in
+ case a validation error has occurred. Defaults to True.
+
+ `indent (int | None, optional):` Indentation to use in the JSON
+ output. If None is passed, the output will be compact. Defaults to
+ None.
+
+ `include (IncEx | None, optional):` Field(s) to include in the JSON
+ output. Defaults to None.
+
+ `exclude (IncEx | None, optional):` Field(s) to exclude from the
+ JSON output. Defaults to None.
+
+ `exclude_unset (bool, optional):` Whether to exclude fields that
+ have not been explicitly set. Defaults to True.
+
+ `exclude_defaults (bool, optional):` Whether to exclude fields that
+ are set to their default value. Defaults to True.
+
+ `exclude_none (bool, optional):` Whether to exclude fields that have
+ a value of `None`. Defaults to False.
+
+ `round_trip (bool, optional):` If True, dumped values should be
+ valid as input for non-idempotent types such as Json[T]. Defaults to
+ False.
+
+ `warnings (bool | Literal['none', 'warn', 'error'], optional):` How
+ to handle serialization errors. False/"none" ignores them,
+ True/"warn" logs errors, "error" raises a
+ `PydanticSerializationError`. Defaults to True.
+
+ `serialize_as_any (bool, optional):` Whether to serialize fields
+ with duck-typing serialization behavior. Defaults to False.
+
+ #### Returns:
+ `str:` A Rune conforming JSON string representation of the model.
+ '''
+ try:
+ if validate_model:
+ self.validate_model(
+ check_rune_constraints=check_rune_constraints,
+ strict=strict,
+ raise_exc=raise_validation_errors)
+
+ root_meta = self.__dict__.setdefault(ROOT_CONTAINER, {})
+ root_meta['@type'] = self._FQRTN
+ root_meta['@model'] = self._FQRTN.split('.', maxsplit=1)[0]
+ root_meta['@version'] = self.get_model_version()
+
+ return self.model_dump_json(indent=indent,
+ include=include,
+ exclude=exclude,
+ exclude_unset=exclude_unset,
+ exclude_defaults=exclude_defaults,
+ exclude_none=exclude_none,
+ round_trip=round_trip,
+ warnings=warnings,
+ serialize_as_any=serialize_as_any)
+ finally:
+ self.__dict__.pop(ROOT_CONTAINER)
+
+ @classmethod
+ def rune_deserialize(cls,
+ rune_data: str | dict[str, Any],
+ validate_model: bool = True,
+ check_rune_constraints: bool = True,
+ strict: bool = True,
+ raise_validation_errors: bool = True) -> BaseModel:
+ # pylint: disable=line-too-long
+ '''Rune compliant deserialization
+
+ #### Args:
+ `rune_json (str):` A JSON string.
+
+ `validate_model (bool, optional):` Validate the model after
+ deserialization. It checks also all Rune type constraints. Defaults
+ to True.
+
+ `check_rune_constraints (bool, optional):` If `validate_model` is
+ set to `True`, executes all model defined Rune constraints after
+ deserialization. Defaults to True.
+
+ `strict (bool, optional):` Perform strict attribute validation.
+ Defaults to True.
+
+ `raise_validation_errors (bool, optional):` Raise an exception in
+ case a validation error has occurred. Defaults to True.
+
+ #### Returns:
+ `BaseModel:` The Rune model.
+ '''
+ if isinstance(rune_data, str):
+ rune_data = json.loads(rune_data)
+ elif not isinstance(rune_data, dict):
+ raise ValueError(f'rune_data is of type {type(rune_data)}, '
+ 'alas it has to be either dict or str!')
+ rune_data.pop('@version', None)
+ rune_data.pop('@model', None)
+ rune_cls = cls._type_to_cls(rune_data)
+ model = rune_cls.model_validate(rune_data, strict=strict)
+ model.resolve_references(ignore_dangling=False, recurse=True)
+ if validate_model:
+ model.validate_model(check_rune_constraints=check_rune_constraints,
+ strict=strict,
+ raise_exc=raise_validation_errors)
+ return model
+
+ def resolve_references(self, ignore_dangling=False, recurse=True):
+ '''resolves all attributes which are references'''
+ if recurse:
+ for prop_nm, obj in self.__dict__.items():
+ if (isinstance(obj, BaseDataClass)
+ and not prop_nm.startswith('__')):
+ obj.resolve_references(ignore_dangling=ignore_dangling,
+ recurse=recurse)
+
+ refs = []
+ for prop_nm, obj in self.__dict__.items():
+ if isinstance(obj, (UnresolvedReference, Reference)):
+ try:
+ refs.append((prop_nm, obj.get_reference(self)))
+ except KeyError:
+ if not ignore_dangling:
+ raise
+
+ for prop_nm, ref in refs:
+ self._bind_property_to(prop_nm, ref)
+
+ def validate_model(self,
+ check_rune_constraints=True,
+ recursively: bool = True,
+ raise_exc: bool = True,
+ strict: bool = True) -> list:
+ ''' This method performs full model validation. It will validate all
+ attributes and it will also invoke `validate_conditions` to check
+ all conditions and the cardinality of all attributes of this object.
+ The parameter `raise_exc` controls whether an exception should be
+ thrown if a validation or condition is violated or if a list with
+ all encountered violations should be returned instead.
+ '''
+ try:
+ self.disable_meta_checks()
+ att_errors = self.validate_attribs(raise_exc=raise_exc,
+ strict=strict)
+ if check_rune_constraints:
+ att_errors.extend(
+ self.validate_conditions(recursively=recursively,
+ raise_exc=raise_exc))
+ return att_errors
+ finally:
+ self.enable_meta_checks()
+
+ def validate_attribs(self,
+ raise_exc: bool = True,
+ strict: bool = True) -> list:
+ ''' This method performs attribute type validation.
+ The parameter `raise_exc` controls whether an exception should be
+ thrown if a validation or condition is violated or if a list with
+ all encountered violations should be returned instead.
+ '''
+ try:
+ self.model_validate(self, strict=strict)
+ except ValidationError as validation_error:
+ if raise_exc:
+ raise validation_error
+ return [validation_error]
+ return []
+
+ def validate_conditions(self,
+ recursively: bool = True,
+ raise_exc: bool = True) -> list:
+ ''' This method will check all conditions and the cardinality of all
+ attributes of this object. This includes conditions and cardinality
+ of properties specified in the base classes. If the parameter
+ `recursively` is set to `True`, it will invoke the validation on the
+ rune defined attributes of this object too.
+ The parameter `raise_exc` controls whether an exception should be
+ thrown if a condition is not met or if a list with all encountered
+ condition violations should be returned instead.
+ '''
+ self_rep = object.__repr__(self)
+ logging.info('Checking conditions for %s ...', self_rep)
+ exceptions = []
+ for name, condition in get_conditions(self.__class__, BaseDataClass):
+ logging.info('Checking condition %s for %s...', name, self_rep)
+ if not condition(self):
+ msg = f'Condition "{name}" for {repr(self)} failed!'
+ logging.error(msg)
+ exc = ConditionViolationError(msg)
+ if raise_exc:
+ raise exc
+ exceptions.append(exc)
+ else:
+ logging.info('Condition %s for %s satisfied.', name, self_rep)
+ if recursively:
+ for k, v in self.__dict__.items():
+ if k.startswith('__'): # ignore *all* private vars!
+ continue
+ logging.info('Validating conditions of property %s', k)
+ exceptions += _validate_conditions_recursively(
+ v, raise_exc=raise_exc)
+ err = f'with {len(exceptions)}' if exceptions else 'without'
+ logging.info('Done conditions checking for %s %s errors.', self_rep,
+ err)
+ return exceptions
+
+ def add_to_list_attribute(self, attr_name: str, value) -> None:
+ '''
+ Adds a value to a list attribute, ensuring the value is of an allowed
+ type.
+
+ Parameters:
+ attr_name (str): Name of the list attribute.
+ value: Value to add to the list.
+
+ Raises:
+ AttributeError: If the attribute name is not found or not a list.
+ TypeError: If the value type is not one of the allowed types.
+ '''
+ if not hasattr(self, attr_name):
+ raise AttributeError(f"Attribute {attr_name} not found.")
+
+ attr = getattr(self, attr_name)
+ if not isinstance(attr, list):
+ raise AttributeError(f"Attribute {attr_name} is not a list.")
+
+ # Get allowed types for the list elements
+ allowed_types = self.get_allowed_types_for_list_field(attr_name)
+
+ # Check if value is an instance of one of the allowed types
+ if not isinstance(value, allowed_types):
+ raise TypeError(f"Value must be an instance of {allowed_types}, "
+ f"not {type(value)}")
+
+ attr.append(value)
+
+ @classmethod
+ def get_allowed_types_for_list_field(cls, field_name: str):
+ '''
+ Gets the allowed types for a list field in a Pydantic model, supporting
+ both Union and | operator.
+
+ Parameters:
+ cls (type): The Pydantic model class.
+ field_name (str): The field name.
+
+ Returns:
+ tuple: A tuple of allowed types.
+ '''
+ field_type = cls.__annotations__.get(field_name)
+ if field_type and get_origin(field_type) is list:
+ list_elem_type = get_args(field_type)[0]
+ if get_origin(list_elem_type):
+ return get_args(list_elem_type)
+ return (list_elem_type, ) # Single type or | operator used
+ return ()
+
+ @classmethod
+ def get_model_version(cls):
+ ''' Attempt to obtain the Rune model version, in case of a failure,
+ 0.0.0 will be returned
+ '''
+ try:
+ module = importlib.import_module(
+ cls.__module__.split('.', maxsplit=1)[0])
+ return getattr(module, 'rune_model_version', default='0.0.0')
+ # pylint: disable=bare-except
+ except: # noqa
+ return '0.0.0'
+
+
+def _validate_conditions_recursively(obj, raise_exc=True):
+ '''Helper to execute conditions recursively on a model.'''
+ if not obj:
+ return []
+ if isinstance(obj, BaseDataClass):
+ return obj.validate_conditions(
+ recursively=True, # type:ignore
+ raise_exc=raise_exc)
+ if isinstance(obj, (list, tuple)):
+ exc = []
+ for item in obj:
+ exc += _validate_conditions_recursively(item, raise_exc=raise_exc)
+ return exc
+ return []
+
+# EOF
diff --git a/src/rune/runtime/conditions.py b/src/rune/runtime/conditions.py
new file mode 100644
index 00000000..ac48876c
--- /dev/null
+++ b/src/rune/runtime/conditions.py
@@ -0,0 +1,57 @@
+'''facilities for rune conditions'''
+from collections import defaultdict
+from typing import Any
+
+_CONDITIONS_REGISTRY: defaultdict[str, dict[str, Any]] = defaultdict(dict)
+
+
+class ConditionViolationError(ValueError):
+ '''Exception thrown on violation of a constraint'''
+
+
+def rune_condition(condition):
+ '''Wrapper to register all constraint functions in the global registry'''
+ path_components = condition.__qualname__.split('.')
+ path = '.'.join([condition.__module__ or ''] + path_components[:-1])
+ name = path_components[-1]
+ _CONDITIONS_REGISTRY[path][name] = condition
+
+ return condition
+
+
+def rune_local_condition(registry: dict):
+ '''Registers a condition function in a local registry.'''
+
+ def decorator(condition):
+ path_components = condition.__qualname__.split('.')
+ path = '.'.join([condition.__module__ or ''] + path_components)
+ registry[path] = condition
+
+ return condition
+
+ return decorator
+
+
+def rune_execute_local_conditions(registry: dict, cond_type: str):
+ '''Executes all registered in a local registry.'''
+ for condition_path, condition_func in registry.items():
+ if not condition_func():
+ raise ConditionViolationError(
+ f"{cond_type} '{condition_path}' failed.")
+
+
+def get_conditions(cls, base_class) -> list:
+ '''returns the conditions registered for the passed in class'''
+ res = []
+ index = cls.__mro__.index(base_class)
+ for c in reversed(cls.__mro__[:index]):
+ fqcn = _fqcn(c)
+ res += [('.'.join([fqcn, k]), v)
+ for k, v in _CONDITIONS_REGISTRY.get(fqcn, {}).items()]
+ return res
+
+
+def _fqcn(cls) -> str:
+ return '.'.join([cls.__module__ or '', cls.__qualname__])
+
+# EOF
diff --git a/src/rune/runtime/func_proxy.py b/src/rune/runtime/func_proxy.py
new file mode 100644
index 00000000..99821d28
--- /dev/null
+++ b/src/rune/runtime/func_proxy.py
@@ -0,0 +1,72 @@
+'''func proxy'''
+import inspect
+import functools
+
+__all__ = ['FuncProxy', 'replaceable', 'create_module_attr_guardian']
+
+
+class FuncProxy:
+ '''A callable proxy allowing functions to be replaced at runtime'''
+ __slots__ = ('_func',)
+
+ def __init__(self, func):
+ self._func = func
+
+ def __call__(self, *args, **kwargs):
+ '''pass the call to the current function'''
+ return self._func(*args, **kwargs)
+
+ @property
+ def func(self):
+ '''current function'''
+ return self._func
+
+ @func.setter
+ def func(self, func):
+ '''replace the current function with a new one'''
+ self.__assign__(func)
+
+ def __assign__(self, func):
+ '''assigns the new function and checks parameter list compatibility'''
+ if not callable(func):
+ raise ValueError(f'Need a callable, but got {str(func)}')
+
+ curr_params = inspect.signature(self._func).parameters
+ new_params = inspect.signature(func).parameters
+ if curr_params.keys() != new_params.keys():
+ raise ValueError(
+ 'Replacement function parameter list do not match the current '
+ f'parameter list of {str(self._func)}'
+ )
+ self._func = func
+
+
+def replaceable(func):
+ '''wrapper for a function which can be replaced at runtime'''
+ proxy = FuncProxy(func)
+
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ return proxy(*args, **kwargs)
+
+ wrapper.__assign__ = proxy.__assign__ # type: ignore
+ return wrapper
+
+
+def create_module_attr_guardian(module):
+ '''Returns a module setter class derived from the invoking module'''
+ # pylint: disable=too-few-public-methods
+ class ModuleAttrSetter(module):
+ ''' Redirects the assignment of an attribute to its __assign__ method
+ if defined, otherwise the default functionality is used and the
+ attribute is just replaced.
+ '''
+ def __setattr__(self, attr, val):
+ exists = getattr(self, attr, None)
+ if exists is not None and hasattr(exists, '__assign__'):
+ exists.__assign__(val)
+ else:
+ super().__setattr__(attr, val)
+ return ModuleAttrSetter
+
+# EOF
diff --git a/src/rune/runtime/metadata.py b/src/rune/runtime/metadata.py
new file mode 100644
index 00000000..c9321007
--- /dev/null
+++ b/src/rune/runtime/metadata.py
@@ -0,0 +1,656 @@
+'''Classes representing annotated basic Rune types'''
+import uuid
+import datetime
+import importlib
+from enum import Enum
+from functools import partial, lru_cache
+from decimal import Decimal
+from typing import Any, Never, get_args, Iterable
+from typing_extensions import Self, Tuple
+from pydantic import (PlainSerializer, PlainValidator, WrapValidator,
+ WrapSerializer)
+from pydantic_core import PydanticCustomError
+# from rune.runtime.object_registry import get_object
+
+DEFAULT_META = '_ALLOWED_METADATA'
+META_CONTAINER = '__rune_metadata'
+REFS_CONTAINER = '__rune_references'
+PARENT_PROP = '__rune_parent'
+RUNE_OBJ_MAPS = '__rune_object_maps'
+
+
+def _replaceable(prop):
+ return isinstance(prop, (BaseMetaDataMixin, UnresolvedReference, Reference))
+
+
+def _py_to_ser_key(key: str) -> str:
+ if key[0] == '@':
+ return key
+ return '@' + key.replace('_', ':')
+
+
+def _get_basic_type(annotated_type):
+ embedded_type = get_args(annotated_type)
+ if embedded_type:
+ return _get_basic_type(embedded_type[0])
+ return annotated_type
+
+
+class KeyType(Enum):
+ '''Enum for the currently supported by Rune external keys/refs'''
+ INTERNAL = 'internal'
+ EXTERNAL = 'external'
+ SCOPED = 'scoped'
+
+ @property
+ def key_tag(self):
+ '''the key tag as used internally'''
+ if self == KeyType.INTERNAL:
+ return 'key'
+ return f'key_{self.value}'
+
+ @property
+ def rune_key_tag(self):
+ '''the key tag as represented in rune'''
+ if self == KeyType.INTERNAL:
+ return '@key'
+ return f'@key:{self.value}'
+
+ @property
+ def ref_tag(self):
+ '''the ref tag as used internally'''
+ if self == KeyType.INTERNAL:
+ return 'ref'
+ return f'ref_{self.value}'
+
+ @property
+ def rune_ref_tag(self):
+ '''the ref tag as represented in rune'''
+ if self == KeyType.INTERNAL:
+ return '@ref'
+ return f'@ref:{self.value}'
+
+ @classmethod
+ def from_rune(cls, rune_item: str):
+ '''returns an enum instance for the passed in rune key/ref'''
+ rune_consts = rune_item.split(':')
+ rune_type = rune_consts[-1] if len(rune_consts) > 1 else 'internal'
+ return KeyType(rune_type)
+
+
+class Reference:
+ '''manages a reference to a object with a key'''
+ def __init__(self,
+ target: str | Any,
+ ext_key: str | None = None,
+ key_type: KeyType | None = None,
+ parent=None):
+ if not isinstance(target, BaseMetaDataMixin) and ext_key:
+ raise ValueError('Need to pass an object as target when specifying '
+ 'an external key!')
+ if ext_key:
+ key_type = key_type or KeyType.EXTERNAL
+ target.set_external_key(ext_key, key_type) # type: ignore
+ self.target = target
+ self.target_key = ext_key
+ self.key_type = key_type
+ elif isinstance(target, BaseMetaDataMixin):
+ if key_type and key_type != KeyType.INTERNAL:
+ raise ValueError('key_type should be None or INTERNAL when '
+ 'passing in an object without a key!')
+ self.target = target
+ self.target_key = target.get_or_create_key()
+ self.key_type = KeyType.INTERNAL
+ else:
+ key_type = key_type or KeyType.EXTERNAL
+ self.target_key = target
+ self.key_type = key_type
+ self.target = parent.get_object_by_key(target, key_type)
+
+ def get_reference(self, _):
+ '''returns itself reference'''
+ return self
+
+
+class UnresolvedReference:
+ '''used by the deserialization to hold temporarily unresolved references'''
+ def __init__(self, key):
+ rune_type, self.key = list(key.items())[0]
+ self.key_type = KeyType.from_rune(rune_type)
+
+ def get_reference(self, parent):
+ '''convert to a resolved reference'''
+ return Reference(self.key, key_type=self.key_type, parent=parent)
+
+
+class BaseMetaDataMixin:
+ '''Base class for the meta data support of basic amd complex types'''
+ _DEFAULT_SCOPE_TYPE = 'cdm.event.common.TradeState'
+ __meta_check_disabled = False
+
+ @classmethod
+ def enable_meta_checks(cls):
+ '''enables the metadata checks in deserialize'''
+ BaseMetaDataMixin.__meta_check_disabled = False
+
+ @classmethod
+ def disable_meta_checks(cls):
+ '''disables the metadata checks in deserialize'''
+ BaseMetaDataMixin.__meta_check_disabled = True
+
+ @classmethod
+ def meta_checks_enabled(cls):
+ '''is metadata checked during deserialize'''
+ return not BaseMetaDataMixin.__meta_check_disabled
+
+ def is_scope_instance(self):
+ '''is this object a scope for `scoped` keys/references'''
+ if not (scope := self._get_rune_scope_type()):
+ scope = self._DEFAULT_SCOPE_TYPE
+ if not (fqcn := getattr(self, '_FQRTN', None)):
+ fqcn = f'{self.__class__.__module__}.{self.__class__.__qualname__}'
+ return fqcn == scope
+
+ def set_meta(self, check_allowed=True, **kwds):
+ '''set some/all metadata properties'''
+ props = {_py_to_ser_key(k): v for k, v in kwds.items()}
+ if check_allowed:
+ self._check_props_allowed(props)
+ meta = self.__dict__.setdefault(META_CONTAINER, {})
+ meta |= props
+
+ def get_meta(self, name: str):
+ '''get a metadata property'''
+ return self._get_meta_container().get(_py_to_ser_key(name))
+
+ def serialise_meta(self) -> dict:
+ '''used as serialisation method with pydantic'''
+ metadata = self._get_meta_container()
+ return {key: value for key, value in metadata.items() if value}
+
+ def get_or_create_key(self) -> str:
+ '''gets or creates the key associated with this object'''
+ if not (key := self.get_meta('key')):
+ key = str(uuid.uuid4())
+ self.set_meta(key=key)
+ try:
+ self._get_object_map(KeyType.INTERNAL)[key] = self
+ except: # noqa
+ self.set_meta(key=None)
+ raise
+ return key
+
+ def set_external_key(self, key: str, key_type: KeyType):
+ '''registers this object under the provided external key'''
+ aux = self.get_meta(key_type.key_tag)
+ if aux and aux != key:
+ raise ValueError(f'This object already has an external key {aux}!'
+ f'Can\'t change it to {key}')
+ if aux == key:
+ return
+
+ self.set_meta(**{key_type.key_tag: key})
+ try:
+ self._get_object_map(key_type)[key] = self
+ except: # noqa
+ self.set_meta(**{key_type.key_tag: None})
+ raise
+
+ def get_object_by_key(self, key: str, key_type: KeyType):
+ '''retrieve an object with a key an key type'''
+ return self._get_object_map(key_type)[key]
+
+ def get_rune_parent(self) -> Self | None:
+ '''the parent object'''
+ return self.__dict__.get(PARENT_PROP)
+
+ def _get_meta_container(self) -> dict[str, Any]:
+ return self.__dict__.get(META_CONTAINER, {})
+
+ def _merged_allowed_meta(
+ self, allowed_meta: set[str] | Iterable[str]) -> set[str]:
+ default_meta: set[str] = getattr(self, DEFAULT_META, set())
+ return set(allowed_meta) | default_meta
+
+ def _check_props_allowed(self, props: dict[str, Any]):
+ if not props:
+ return
+ allowed = self._merged_allowed_meta(self._get_meta_container().keys())
+ prop_keys = set(props.keys())
+ if not prop_keys.issubset(allowed):
+ raise ValueError('Not allowed metadata provided: '
+ f'{prop_keys - allowed}')
+
+ def _init_meta(self, allowed_meta: set[str]):
+ ''' if not initialised, just creates empty meta slots. If the metadata
+ container is not empty, it will check if the already present keys
+ are conform to the allowed keys.
+ '''
+ allowed_meta = self._merged_allowed_meta(allowed_meta)
+ meta = self.__dict__.setdefault(META_CONTAINER, {})
+ current_meta = set(meta.keys())
+ if not current_meta.issubset(allowed_meta):
+ raise ValueError(f'Allowed meta {allowed_meta} differs from the '
+ f'currently existing meta slots: {current_meta}')
+ meta |= {k: None for k in allowed_meta - current_meta}
+
+ def _bind_property_to(self, property_nm: str, ref: Reference):
+ '''set the property to reference the object referenced by the key'''
+ old_val = getattr(self, property_nm)
+ allowed_ref_types = getattr(self, '_KEY_REF_CONSTRAINTS', {})
+ if (ref.key_type.rune_ref_tag not in allowed_ref_types.get(
+ property_nm, {}) and not _replaceable(old_val)):
+ raise ValueError(f'Ref of type {ref.key_type} '
+ f'not allowed for {property_nm}. Allowed types '
+ f'are: {allowed_ref_types.get(property_nm, {})}')
+
+ field_type = self.__class__.__annotations__.get(property_nm)
+ allowed_type = _get_basic_type(field_type)
+ if not (isinstance(allowed_type, str)
+ or isinstance(ref.target, allowed_type)):
+ raise ValueError("Can't set reference. Incompatible types: "
+ f"expected {allowed_type}, "
+ f"got {ref.target.__class__}")
+
+ refs = self.__dict__.setdefault(REFS_CONTAINER, {})
+ if property_nm not in refs:
+ # not a reference - check if allowed to replace with one
+ if not _replaceable(old_val):
+ raise ValueError(f'Property {property_nm} of type '
+ f"{type(old_val)} can't be a reference")
+ # pylint: disable=protected-access
+ if isinstance(old_val, BaseMetaDataMixin):
+ old_val._check_props_allowed({ref.key_type.rune_ref_tag: ''})
+
+ # setattr(self, property_nm, ref.target) # nope - need to avoid here!
+ self.__dict__[property_nm] = ref.target # NOTE: avoid here setattr
+ refs[property_nm] = (ref.target_key, ref.key_type)
+
+ def _register_keys(self, metadata):
+ keys = {k: v for k, v in metadata.items() if k.startswith('@key') and v}
+ for key_t, key_v in keys.items():
+ self._get_object_map(KeyType.from_rune(key_t))[key_v] = self
+
+ def _get_object_map(self, key_type: KeyType) -> dict[str, Any]:
+ if not self.get_rune_parent():
+ object_maps = self.__dict__.setdefault(RUNE_OBJ_MAPS, {})
+ return object_maps.setdefault(key_type, {})
+ if local_map := self.__dict__.get(RUNE_OBJ_MAPS, {}).get(key_type):
+ return local_map
+ # pylint: disable=protected-access
+ return self.get_rune_parent()._get_object_map(key_type) # type:ignore
+
+ def _set_rune_parent(self, parent: Self):
+ '''sets the parent object'''
+ self.__dict__[PARENT_PROP] = parent
+ if obj_maps := self.__dict__.pop(RUNE_OBJ_MAPS, None):
+ # pylint: disable=protected-access
+ self._update_object_maps(obj_maps)
+
+ def _extract_scoped_map(self, maps):
+ scoped = None
+ if self.is_scope_instance():
+ scoped = maps.pop(KeyType.SCOPED, None)
+ return scoped, maps
+
+ def _update_object_maps(self, new_maps):
+ if parent := self.get_rune_parent():
+ scoped, reduced_maps = self._extract_scoped_map(new_maps)
+ # pylint: disable=protected-access
+ parent._update_object_maps(reduced_maps)
+ if not scoped:
+ return
+ new_maps = {KeyType.SCOPED: scoped}
+
+ obj_maps = self.__dict__.setdefault(RUNE_OBJ_MAPS, {})
+ for map_type, new_map in new_maps.items():
+ local_map = obj_maps.setdefault(map_type, {})
+ if dup_keys := set(local_map.keys()).intersection(
+ set(new_map.keys())):
+ raise ValueError('Duplicated keys detected in updating the '
+ f'object map {map_type}. '
+ f'Duplicated keys {dup_keys}')
+ local_map |= new_map
+
+ def _get_rune_refs_container(self):
+ '''return the dictionary of the refs held'''
+ return self.__dict__.get(REFS_CONTAINER, {})
+
+ def _remove_rune_ref(self, name):
+ '''remove a reference'''
+ return self.__dict__[REFS_CONTAINER].pop(name)
+
+ @classmethod
+ def _create_unresolved_ref(cls, metadata) -> UnresolvedReference | None:
+ if ref := {k: v for k, v in metadata.items() if k.startswith('@ref')}:
+ if len(ref) != 1:
+ ref.pop(KeyType.INTERNAL.rune_ref_tag, None)
+ if len(ref) != 1:
+ ref.pop(KeyType.EXTERNAL.rune_ref_tag, None)
+ if len(ref) != 1:
+ raise ValueError(f'Multiple references found: {ref}!')
+ return UnresolvedReference(ref)
+ return None
+
+ @classmethod
+ @lru_cache
+ def _get_rune_scope_type(cls):
+ ''' Attempt to obtain the name of the rune scoping type,
+ in case of a failure, None will be returned.
+ '''
+ try:
+ module = importlib.import_module(
+ cls.__module__.split('.', maxsplit=1)[0])
+ return getattr(module, 'rune_scope_type', None)
+ # pylint: disable=bare-except
+ except: # noqa
+ return None
+
+
+class ComplexTypeMetaDataMixin(BaseMetaDataMixin):
+ '''metadata support for complex types'''
+ @classmethod
+ def _type_to_cls(cls, metadata:dict[str, Any]):
+ if rune_type:= metadata.pop('@type', None):
+ rune_class_name = rune_type.rsplit('.', maxsplit=1)[-1]
+ rune_module = importlib.import_module(rune_type)
+ return getattr(rune_module, rune_class_name)
+ return cls # support for legacy json
+
+ @classmethod
+ def serialise(cls, obj) -> dict:
+ '''used as serialisation method with pydantic'''
+ res = obj.serialise_meta()
+ res |= obj.model_dump(exclude_unset=True, exclude_defaults=True)
+ if cls != obj.__class__:
+ # pylint: disable=protected-access
+ res = {'@type': obj._FQRTN} | res
+ return res
+
+ @classmethod
+ def deserialize(cls, obj, allowed_meta: set[str]):
+ '''method used as pydantic `validator`'''
+ if isinstance(obj, cls):
+ if cls.meta_checks_enabled():
+ obj._init_meta(allowed_meta) # pylint: disable=protected-access
+ return obj
+
+ if isinstance(obj, Reference):
+ return obj
+
+ if not isinstance(obj, dict):
+ raise PydanticCustomError('Input Validation Error',
+ 'Expected either {my_type} or dict but '
+ 'got {type}.',
+ {'type': type(obj), 'my_type': cls})
+ metadata = {k: obj[k] for k in obj.keys() if k.startswith('@')}
+
+ # References deserialization treatment
+ if aux := cls._create_unresolved_ref(metadata):
+ return aux
+
+ # Model creation
+ for k in metadata.keys():
+ obj.pop(k)
+
+ rune_cls = cls._type_to_cls(metadata)
+ if rune_cls != cls and not issubclass(rune_cls, cls):
+ raise ValueError(f'{rune_cls} has to be a child class of {cls}!')
+ model = rune_cls.model_validate(obj) # type: ignore
+ model.__dict__[META_CONTAINER] = metadata
+ if cls.meta_checks_enabled():
+ model._init_meta(allowed_meta) # pylint: disable=protected-access
+
+ # Keys deserialization treatment
+ model._register_keys(metadata) # pylint: disable=protected-access
+ return model
+
+ @classmethod
+ @lru_cache
+ def serializer(cls):
+ '''should return the validator for the specific class'''
+ return PlainSerializer(cls.serialise, return_type=dict)
+
+ @classmethod
+ @lru_cache
+ def validator(cls, allowed_meta: tuple[str] | tuple[Never, ...] = tuple()):
+ '''default validator for the specific class'''
+ allowed = set(allowed_meta)
+ return PlainValidator(partial(cls.deserialize, allowed_meta=allowed),
+ json_schema_input_type=dict)
+
+
+class BasicTypeMetaDataMixin(BaseMetaDataMixin):
+ '''holds the metadata associated with an instance'''
+ _INPUT_TYPES: Any | Tuple[Any, ...] = str # to be overridden by subclasses
+ _OUTPUT_TYPE: Any = str # to be overridden by subclasses
+ _JSON_OUTPUT = str | dict
+
+ @classmethod
+ def _check_type(cls, value):
+ if not isinstance(value, cls._INPUT_TYPES):
+ raise ValueError(f'{cls.__name__} can be instantiated only with '
+ f'one of the following type(s): {cls._INPUT_TYPES},'
+ f' however the value is of type {type(value)}')
+
+ @classmethod
+ def serialise(cls, obj, base_type) -> dict:
+ '''used as serialisation method with pydantic'''
+ res = obj.serialise_meta()
+ res['@data'] = base_type(obj)
+ return res
+
+ @classmethod
+ def deserialize(cls, obj, handler, base_types, allowed_meta: set[str]):
+ '''method used as pydantic `validator`'''
+ model = obj
+ if isinstance(obj, base_types) and not isinstance(obj, cls):
+ model = cls(obj) # type: ignore
+ elif isinstance(obj, dict):
+ if ref := cls._create_unresolved_ref(obj):
+ return ref
+ data = obj.pop('@data')
+ model = cls(data, **obj) # type: ignore
+ model._register_keys(obj)
+ if cls.meta_checks_enabled():
+ model._init_meta(allowed_meta) # pylint: disable=protected-access
+ return handler(model)
+
+ @classmethod
+ @lru_cache
+ def serializer(cls):
+ '''should return the validator for the specific class'''
+ ser_fn = partial(cls.serialise, base_type=cls._OUTPUT_TYPE)
+ return PlainSerializer(ser_fn, return_type=dict)
+
+ @classmethod
+ @lru_cache
+ def validator(cls, allowed_meta: tuple[str]):
+ '''default validator for the specific class'''
+ allowed = set(allowed_meta)
+ return WrapValidator(partial(cls.deserialize,
+ base_types=cls._INPUT_TYPES,
+ allowed_meta=allowed),
+ json_schema_input_type=cls._JSON_OUTPUT)
+
+
+class DateWithMeta(datetime.date, BasicTypeMetaDataMixin):
+ '''date with metadata'''
+ _INPUT_TYPES = (datetime.date, str)
+
+ def __new__(cls, value, **kwds): # pylint: disable=signature-differs
+ cls._check_type(value)
+ if isinstance(value, str):
+ value = datetime.date.fromisoformat(value)
+ ymd = value.timetuple()[:3]
+ obj = datetime.date.__new__(cls, *ymd)
+ obj.set_meta(check_allowed=False, **kwds)
+ return obj
+
+
+class TimeWithMeta(datetime.time, BasicTypeMetaDataMixin):
+ '''annotated time'''
+ _INPUT_TYPES = (datetime.time, str)
+
+ def __new__(cls, value, **kwds): # pylint: disable=signature-differs
+ cls._check_type(value)
+ if isinstance(value, str):
+ value = datetime.time.fromisoformat(value)
+ obj = datetime.time.__new__(cls,
+ value.hour,
+ value.minute,
+ value.second,
+ value.microsecond,
+ value.tzinfo,
+ fold=value.fold)
+ obj.set_meta(check_allowed=False, **kwds)
+ return obj
+
+
+class DateTimeWithMeta(datetime.datetime, BasicTypeMetaDataMixin):
+ '''annotated datetime'''
+ _INPUT_TYPES = (datetime.datetime, str)
+
+ def __new__(cls, value, **kwds): # pylint: disable=signature-differs
+ cls._check_type(value)
+ if isinstance(value, str):
+ value = datetime.datetime.fromisoformat(value)
+ obj = datetime.datetime.__new__(cls,
+ value.year,
+ value.month,
+ value.day,
+ value.hour,
+ value.minute,
+ value.second,
+ value.microsecond,
+ value.tzinfo,
+ fold=value.fold)
+ obj.set_meta(check_allowed=False, **kwds)
+ return obj
+
+ def __str__(self):
+ return self.isoformat()
+
+
+class StrWithMeta(str, BasicTypeMetaDataMixin):
+ '''string with metadata'''
+ def __new__(cls, value, **kwds):
+ obj = str.__new__(cls, value)
+ obj.set_meta(check_allowed=False, **kwds)
+ return obj
+
+
+class IntWithMeta(int, BasicTypeMetaDataMixin):
+ '''annotated integer'''
+ _INPUT_TYPES = int
+ _OUTPUT_TYPE = int
+ _JSON_OUTPUT = int | dict
+
+ def __new__(cls, value, **kwds):
+ obj = int.__new__(cls, value)
+ obj.set_meta(check_allowed=False, **kwds)
+ return obj
+
+
+class NumberWithMeta(Decimal, BasicTypeMetaDataMixin):
+ '''annotated number'''
+ _INPUT_TYPES = (Decimal, float, int, str)
+ _OUTPUT_TYPE = Decimal
+ _JSON_OUTPUT = float | int | str | dict
+
+ def __new__(cls, value, **kwds):
+ # NOTE: it could be necessary to convert the value to str if it is a
+ # float
+ obj = Decimal.__new__(cls, value)
+ obj.set_meta(check_allowed=False, **kwds)
+ return obj
+
+
+class _EnumWrapperDefaultVal(Enum):
+ '''marker for not set value in enum wrapper'''
+ NOT_SET = "NOT_SET"
+
+
+class _EnumWrapper(BaseMetaDataMixin):
+ '''wrapper for enums with metadata'''
+ def __init__(self, enum_instance=_EnumWrapperDefaultVal.NOT_SET):
+ if not isinstance(enum_instance, Enum):
+ raise ValueError("enum_instance must be an instance of an Enum")
+ self._enum_instance = enum_instance
+
+ @property
+ def enum_instance(self):
+ '''the actual enum instance'''
+ return self._enum_instance
+
+ @property
+ def name(self):
+ '''enum name - pass through'''
+ return self._enum_instance.name
+
+ @property
+ def value(self):
+ '''enum value - pass through'''
+ return self._enum_instance.value
+
+ def __str__(self):
+ return str(self._enum_instance)
+
+ def __repr__(self):
+ return repr(self._enum_instance)
+
+ def __eq__(self, other):
+ if isinstance(other, _EnumWrapper):
+ return self._enum_instance == other._enum_instance
+ return self._enum_instance == other
+
+ def __hash__(self):
+ return hash(self._enum_instance)
+
+
+class EnumWithMetaMixin:
+ '''holds the metadata associated with a Rune Enum'''
+ @classmethod
+ def serialise(cls, obj, handler, info) -> dict:
+ '''used as serialisation method with pydantic'''
+ res = obj.serialise_meta()
+ res['@data'] = handler(obj.enum_instance, info)
+ return res
+
+ @classmethod
+ def deserialize(cls, obj, allowed_meta: set[str]):
+ '''method used as pydantic `validator`'''
+ model = obj
+ if (isinstance(obj, str)
+ and not isinstance(obj, _EnumWrapper)):
+ model = _EnumWrapper(cls(obj)) # type: ignore
+ if (isinstance(obj, EnumWithMetaMixin)
+ and not isinstance(obj, _EnumWrapper)):
+ model = _EnumWrapper(obj) # type: ignore
+ elif isinstance(obj, dict):
+ # pylint: disable=protected-access
+ if ref := _EnumWrapper._create_unresolved_ref(obj):
+ return ref
+ data = obj.pop('@data')
+ model = _EnumWrapper(cls(data)) # type: ignore
+ model.set_meta(check_allowed=False, **obj)
+ model._register_keys(obj) # pylint: disable=protected-access
+ if _EnumWrapper.meta_checks_enabled():
+ model._init_meta(allowed_meta) # pylint: disable=protected-access
+ return model
+
+ @classmethod
+ @lru_cache
+ def serializer(cls):
+ '''should return the validator for the specific class'''
+ return WrapSerializer(cls.serialise, return_type=dict)
+
+ @classmethod
+ @lru_cache
+ def validator(cls, allowed_meta: tuple[str] | tuple[Never, ...] = tuple()):
+ '''default validator for the specific class'''
+ allowed = set(allowed_meta)
+ return PlainValidator(partial(cls.deserialize, allowed_meta=allowed),
+ json_schema_input_type=str | dict)
+
+# EOF
diff --git a/src/rune/runtime/object_registry.py b/src/rune/runtime/object_registry.py
new file mode 100644
index 00000000..19e76468
--- /dev/null
+++ b/src/rune/runtime/object_registry.py
@@ -0,0 +1,17 @@
+'''functions to work with the global object registry'''
+
+_OBJECT_REGISTRY: dict[str, tuple[object, str]] = {}
+
+
+def get_object(key: str) -> tuple[object, str]:
+ '''retrieve an object, if not found, an exception will be thrown'''
+ return _OBJECT_REGISTRY[key]
+
+
+def register_object(obj: tuple[object, str], key: str):
+ '''register and object in the global registry'''
+ if key in _OBJECT_REGISTRY:
+ raise ValueError(f"Key {key} already exists! Can't register {obj}!")
+ _OBJECT_REGISTRY[key] = obj
+
+# EOD
diff --git a/src/rune/runtime/py.typed b/src/rune/runtime/py.typed
new file mode 100644
index 00000000..e69de29b
diff --git a/src/rune/runtime/utils.py b/src/rune/runtime/utils.py
new file mode 100644
index 00000000..fd335bb7
--- /dev/null
+++ b/src/rune/runtime/utils.py
@@ -0,0 +1,326 @@
+'''Utility functions (runtime) for rune models.'''
+from __future__ import annotations
+import logging
+import keyword
+import inspect
+from enum import Enum
+from typing import Callable, Any
+
+__all__ = [
+ 'if_cond_fn', 'Multiprop', 'rune_any_elements', 'rune_get_only_element',
+ 'rune_filter', 'rune_all_elements', 'rune_contains', 'rune_disjoint',
+ 'rune_join', 'rune_flatten_list', 'rune_resolve_attr',
+ 'rune_resolve_deep_attr', 'rune_count', 'rune_attr_exists',
+ '_get_rune_object', 'rune_set_attr', 'rune_add_attr',
+ 'rune_check_cardinality', 'rune_str', 'rune_check_one_of'
+]
+
+
+# def if_cond(ifexpr, thenexpr: str, elseexpr: str, obj: object):
+# '''A helper to return the value of the ternary operator.'''
+# expr = thenexpr if ifexpr else elseexpr
+# return eval(expr, globals(), {'self': obj}) # pylint: disable=eval-used
+
+
+def if_cond_fn(ifexpr: bool, thenexpr: Callable, elseexpr: Callable) -> Any:
+ ''' A helper to return the value of the ternary operator
+ (functional version).
+ '''
+ expr = thenexpr if ifexpr else elseexpr
+ return expr()
+
+
+def _to_list(obj) -> list | tuple:
+ if isinstance(obj, (list, tuple)):
+ return obj
+ return (obj, )
+
+
+def rune_mangle_name(attrib: str) -> str:
+ ''' Mangle any attrib that is a Python keyword, is a Python soft keyword
+ or begins with _
+ '''
+ if (keyword.iskeyword(attrib) or keyword.issoftkeyword(attrib)
+ or attrib.startswith('_')):
+ return 'rune_attr_' + attrib
+ return attrib
+
+
+def rune_resolve_attr(obj: Any | None, attrib: str) -> Any | list[Any] | None:
+ ''' rune semantics compliant attribute resolver.
+ Lists and mangled attributes are treated as defined by
+ the rune definition (list flattening).
+ '''
+ if obj is None:
+ return None
+
+ if isinstance(obj, (list, tuple)):
+ res = [
+ item for elem in obj
+ for item in _to_list(rune_resolve_attr(elem, attrib))
+ if item is not None
+ ]
+ return res if res else None
+
+ attrib = rune_mangle_name(attrib)
+
+ if inspect.isframe(obj):
+ obj = getattr(obj, 'f_locals')
+ elif isinstance(obj, dict):
+ return obj[attrib]
+
+ return getattr(obj, attrib, None)
+
+
+def rune_resolve_deep_attr(obj: Any | None,
+ attrib: str) -> Any | list[Any] | None:
+ ''' Resolves a "deep path" attribute. If the attribute or the object is
+ not a "deep path" one, the function falls back to the regular
+ `rosetta_resolve_attr`.
+ '''
+ # pylint: disable=protected-access
+ if obj is None:
+ return None
+ # if not a "deep path" object or attribute, fall back to the std function
+ if (not hasattr(obj, '_CHOICE_ALIAS_MAP')
+ or attrib not in obj._CHOICE_ALIAS_MAP):
+ return rune_resolve_attr(obj, attrib)
+
+ for container_nm, getter_fn in obj._CHOICE_ALIAS_MAP[attrib]:
+ if container_obj := rune_resolve_attr(obj, container_nm):
+ return getter_fn(container_obj, attrib)
+ return None
+
+
+def rune_check_one_of(obj, *attr_names, necessity=True) -> bool:
+ '''Checks that one and only one attribute is set.'''
+ if inspect.isframe(obj):
+ values = getattr(obj, 'f_locals')
+ else:
+ values = obj.model_dump()
+ vals = [values.get(n) for n in attr_names]
+ n_attr = sum(1 for v in vals if v is not None and v != [])
+ if necessity and n_attr != 1:
+ logging.error('One and only one of %s should be set!', attr_names)
+ return False
+ if not necessity and n_attr > 1:
+ logging.error('Only one of %s can be set!', attr_names)
+ return False
+ return True
+
+
+def rune_count(obj: Any | None) -> int:
+ '''Implements the lose count semantics of the rune DSL'''
+ if not obj:
+ return 0
+ try:
+ return len(obj)
+ except TypeError:
+ return 1
+
+
+def rune_attr_exists(val: Any) -> bool:
+ '''Implements the rune semantics of property existence'''
+ if val is None or val == []:
+ return False
+ return True
+
+
+def rune_str(x: Any) -> str:
+ '''Returns a rune conform string representation'''
+ if isinstance(x, Enum):
+ x = x.value
+ return str(x)
+
+
+def _get_rune_object(base_model: str, attribute: str, value: Any) -> Any:
+ model_class = globals()[base_model]
+ instance_kwargs = {attribute: value}
+ instance = model_class(**instance_kwargs)
+ return instance
+
+
+class Multiprop(list):
+ ''' A class allowing for dot access to a attribute of all elements of a
+ list.
+ '''
+ def __getattr__(self, attr):
+ # return multiprop(getattr(x, attr) for x in self)
+ res = Multiprop()
+ for x in self:
+ if isinstance(x, Multiprop):
+ res.extend(x.__getattr__(attr))
+ else:
+ res.append(getattr(x, attr))
+ return res
+
+
+def _ntoz(v):
+ '''Support the lose rune treatment of None in comparisons'''
+ if v is None:
+ return 0
+ return v
+
+
+_cmp = {
+ '=': lambda x, y: _ntoz(x) == _ntoz(y),
+ '<>': lambda x, y: _ntoz(x) != _ntoz(y),
+ '>=': lambda x, y: _ntoz(x) >= _ntoz(y),
+ '<=': lambda x, y: _ntoz(x) <= _ntoz(y),
+ '>': lambda x, y: _ntoz(x) > _ntoz(y),
+ '<': lambda x, y: _ntoz(x) < _ntoz(y)
+}
+
+
+def rune_all_elements(lhs, op, rhs) -> bool:
+ '''Checks that two lists have the same elements'''
+ cmp = _cmp[op]
+ op1 = _to_list(lhs)
+ op2 = _to_list(rhs)
+
+ return all(
+ cmp(el1, el2)
+ for el1, el2 in zip(op1, op2)) if len(op1) == len(op2) else False
+
+
+def rune_disjoint(op1, op2):
+ '''Checks if two lists have no common elements'''
+ op1 = set(_to_list(op1))
+ op2 = set(_to_list(op2))
+ return not op1 & op2
+
+
+def rune_contains(op1, op2):
+ ''' Checks if op2 is contained in op1
+ (e.g. every element of op2 is in op1)
+ '''
+ op1 = set(_to_list(op1))
+ op2 = set(_to_list(op2))
+
+ return op2.issubset(op1)
+
+
+def rune_join(lst, sep=''):
+ ''' Joins the string representation of the list elements, optionally
+ separated.
+ '''
+ return sep.join([str(el) for el in lst])
+
+
+def rune_any_elements(lhs, op, rhs) -> bool:
+ '''Checks if to lists have any common element(s)'''
+ cmp = _cmp[op]
+ op1 = _to_list(lhs)
+ op2 = _to_list(rhs)
+
+ return any(cmp(el1, el2) for el1 in op1 for el2 in op2)
+
+
+def rune_check_cardinality(prop, inf: int, sup: int | None = None) -> bool:
+ ''' If the supremum is not supplied (e.g. is None), the property is
+ unbounded (e.g. it corresponds to (x..*) in rune).
+ '''
+ if not prop:
+ prop_card = 0
+ elif isinstance(prop, (list, tuple)):
+ prop_card = len(prop)
+ else:
+ prop_card = 1
+
+ if sup is None:
+ sup = prop_card
+
+ return inf <= prop_card <= sup
+
+
+def rune_get_only_element(collection):
+ ''' Returns the single element of a collection, if the list contains more
+ more than one element or is empty, None is returned.
+ '''
+ if isinstance(collection, (list, tuple)) and len(collection) == 1:
+ return collection[0]
+ return None
+
+
+def rune_flatten_list(nested_list):
+ '''flattens the list of lists (no-recursively)'''
+ return [
+ item for sublist in _to_list(nested_list) for item in _to_list(sublist)
+ ]
+
+
+def rune_filter(items, filter_func):
+ '''
+ Filters a list of items based on a specified filtering criteria provided as
+ a boolean lambda function.
+
+ :param items: List of items to be filtered.
+ :param filter_func: A lambda function representing the boolean expression
+ for filtering.
+ :param item_name: The name used to refer to each item in the boolean
+ expression.
+ :return: Filtered list.
+ '''
+ return [item for item in (items or []) if filter_func(item)]
+
+
+def rune_set_attr(obj: Any, path: str, value: Any) -> None:
+ '''
+ Sets an attribute of a rune model object to a specified value using a
+ path.
+
+ Parameters:
+ obj (Any): The object whose attribute is to be set.
+ path (str): The path to the attribute, with components separated by '->'.
+ value (Any): The value to set the attribute to.
+
+ Raises:
+ ValueError: If the object or attribute at any level in the path is None.
+ AttributeError: If an invalid attribute path is provided.
+ '''
+ if obj is None:
+ raise ValueError(
+ "Cannot set attribute on a None object in set_rune_attr.")
+
+ path_components = path.split('->') # Use '->' for splitting the path
+ parent_obj = obj
+
+ # Iterate through the path components, except the last one
+ for attrib in path_components[:-1]:
+ parent_obj = rune_resolve_attr(parent_obj, attrib)
+ if parent_obj is None:
+ raise ValueError(
+ f"Attribute '{attrib}' in the path is None, cannot "
+ "proceed to set value.")
+
+ # Set the value to the last attribute in the path
+ final_attr = path_components[-1]
+ if hasattr(parent_obj, final_attr):
+ setattr(parent_obj, final_attr, value)
+ else:
+ raise AttributeError(f"Invalid attribute '{final_attr}' for object of "
+ f"type {type(parent_obj).__name__}")
+
+
+def rune_add_attr(obj: Any, attrib: str, value: Any) -> None:
+ '''
+ Adds a value to a list-like attribute of a rune model object.
+
+ Parameters:
+ obj (Any): The object whose attribute is to be modified.
+ attrib (str): The list-like attribute to add the value to.
+ value (Any): The value to add to the attribute.
+ '''
+ if obj is not None:
+ if hasattr(obj, attrib):
+ current_attr = getattr(obj, attrib)
+ if isinstance(current_attr, list):
+ current_attr.append(value)
+ else:
+ raise TypeError(f"Attribute {attrib} is not list-like.")
+ else:
+ setattr(obj, attrib, [value])
+ else:
+ raise ValueError("Object for add_rune_attr cannot be None.")
+
+# EOF
diff --git a/test/cdm/EUR-Vanilla-account.json b/test/cdm/EUR-Vanilla-account.json
new file mode 100644
index 00000000..f918622e
--- /dev/null
+++ b/test/cdm/EUR-Vanilla-account.json
@@ -0,0 +1,476 @@
+{
+ "@model": "Just another Rosetta model",
+ "@type": "cdm.event.common.TradeState",
+ "@version": "0.0.0.master-SNAPSHOT",
+ "trade": {
+ "tradeLot": [
+ {
+ "priceQuantity": [
+ {
+ "price": [
+ {
+ "value": 0.006982,
+ "unit": {
+ "currency": {
+ "@data": "EUR"
+ }
+ },
+ "perUnitOf": {
+ "currency": {
+ "@data": "EUR"
+ }
+ },
+ "priceType": "InterestRate",
+ "@type": "cdm.observable.asset.PriceSchedule",
+ "@key:scoped": "price-1"
+ }
+ ],
+ "quantity": [
+ {
+ "value": 10000000,
+ "unit": {
+ "currency": {
+ "@data": "EUR"
+ }
+ },
+ "@type": "cdm.base.math.NonNegativeQuantitySchedule",
+ "@key:scoped": "quantity-2"
+ }
+ ]
+ },
+ {
+ "quantity": [
+ {
+ "value": 10000000,
+ "unit": {
+ "currency": {
+ "@data": "EUR"
+ }
+ },
+ "@type": "cdm.base.math.NonNegativeQuantitySchedule",
+ "@key:scoped": "quantity-1"
+ }
+ ],
+ "observable": {
+ "Index": {
+ "InterestRateIndex": {
+ "FloatingRateIndex": {
+ "identifier": [
+ {
+ "identifier": {
+ "@data": "EUR-EURIBOR-Reuters"
+ },
+ "identifierType": "Other"
+ }
+ ],
+ "floatingRateIndex": {
+ "@data": "EUR-EURIBOR-Reuters"
+ },
+ "indexTenor": {
+ "periodMultiplier": 6,
+ "period": "M"
+ },
+ "@type": "cdm.observable.asset.FloatingRateIndex"
+ },
+ "@key:scoped": "InterestRateIndex-1"
+ }
+ },
+ "@key:scoped": "observable-1"
+ }
+ }
+ ]
+ }
+ ],
+ "product": {
+ "identifier": [
+ {
+ "identifier": {
+ "@data": "InterestRate:IRSwap:FixedFloat",
+ "@scheme": "http://www.fpml.org/coding-scheme/product-taxonomy"
+ },
+ "source": "Other"
+ }
+ ],
+ "taxonomy": [
+ {
+ "primaryAssetClass": {
+ "@data": "InterestRate",
+ "@scheme": "http://www.fpml.org/coding-scheme/asset-class-simple"
+ },
+ "@type": "cdm.base.staticdata.asset.common.ProductTaxonomy"
+ },
+ {
+ "source": "ISDA",
+ "value": {
+ "name": {
+ "@data": "InterestRate:IRSwap:FixedFloat",
+ "@scheme": "http://www.fpml.org/coding-scheme/product-taxonomy"
+ }
+ },
+ "@type": "cdm.base.staticdata.asset.common.ProductTaxonomy"
+ },
+ {
+ "source": "ISDA",
+ "productQualifier": "InterestRate_IRSwap_FixedFloat",
+ "@type": "cdm.base.staticdata.asset.common.ProductTaxonomy"
+ }
+ ],
+ "economicTerms": {
+ "payout": [
+ {
+ "InterestRatePayout": {
+ "payerReceiver": {
+ "payer": "Party1",
+ "receiver": "Party2"
+ },
+ "priceQuantity": {
+ "quantitySchedule": {
+ "@ref:scoped": "quantity-2"
+ }
+ },
+ "rateSpecification": {
+ "FixedRateSpecification": {
+ "rateSchedule": {
+ "price": {
+ "@ref:scoped": "price-1"
+ }
+ }
+ }
+ },
+ "dayCountFraction": {
+ "@data": "30/360"
+ },
+ "calculationPeriodDates": {
+ "effectiveDate": {
+ "adjustableDate": {
+ "unadjustedDate": "2015-03-06",
+ "dateAdjustments": {
+ "businessDayConvention": "NONE"
+ }
+ }
+ },
+ "terminationDate": {
+ "adjustableDate": {
+ "unadjustedDate": "2025-03-06",
+ "dateAdjustments": {
+ "businessDayConvention": "MODFOLLOWING",
+ "businessCenters": {
+ "businessCenter": [
+ {
+ "@data": "EUTA"
+ }
+ ]
+ }
+ }
+ }
+ },
+ "calculationPeriodDatesAdjustments": {
+ "businessDayConvention": "MODFOLLOWING",
+ "businessCenters": {
+ "businessCenter": [
+ {
+ "@data": "EUTA"
+ }
+ ]
+ }
+ },
+ "calculationPeriodFrequency": {
+ "periodMultiplier": 1,
+ "period": "Y",
+ "rollConvention": "6",
+ "@type": "cdm.base.datetime.CalculationPeriodFrequency"
+ },
+ "@key:external": "fixedCalcPeriodDates1"
+ },
+ "paymentDates": {
+ "paymentFrequency": {
+ "periodMultiplier": 1,
+ "period": "Y"
+ },
+ "payRelativeTo": "CalculationPeriodEndDate",
+ "paymentDatesAdjustments": {
+ "businessDayConvention": "MODFOLLOWING",
+ "businessCenters": {
+ "businessCenter": [
+ {
+ "@data": "EUTA"
+ }
+ ]
+ }
+ },
+ "@key:external": "paymentDates1"
+ },
+ "@type": "cdm.product.asset.InterestRatePayout"
+ }
+ },
+ {
+ "InterestRatePayout": {
+ "payerReceiver": {
+ "payer": "Party2",
+ "receiver": "Party1"
+ },
+ "priceQuantity": {
+ "quantitySchedule": {
+ "@ref:scoped": "quantity-1"
+ }
+ },
+ "rateSpecification": {
+ "FloatingRateSpecification": {
+ "rateOption": {
+ "@ref:scoped": "InterestRateIndex-1"
+ },
+ "@type": "cdm.product.asset.FloatingRateSpecification"
+ }
+ },
+ "dayCountFraction": {
+ "@data": "ACT/360"
+ },
+ "calculationPeriodDates": {
+ "effectiveDate": {
+ "adjustableDate": {
+ "unadjustedDate": "2015-03-06",
+ "dateAdjustments": {
+ "businessDayConvention": "NONE"
+ }
+ }
+ },
+ "terminationDate": {
+ "adjustableDate": {
+ "unadjustedDate": "2025-03-06",
+ "dateAdjustments": {
+ "businessDayConvention": "MODFOLLOWING",
+ "businessCenters": {
+ "businessCenter": [
+ {
+ "@data": "EUTA"
+ }
+ ]
+ }
+ }
+ }
+ },
+ "calculationPeriodDatesAdjustments": {
+ "businessDayConvention": "MODFOLLOWING",
+ "businessCenters": {
+ "businessCenter": [
+ {
+ "@data": "EUTA"
+ }
+ ]
+ }
+ },
+ "calculationPeriodFrequency": {
+ "periodMultiplier": 6,
+ "period": "M",
+ "rollConvention": "6",
+ "@type": "cdm.base.datetime.CalculationPeriodFrequency"
+ },
+ "@key:external": "floatingCalcPeriodDates2"
+ },
+ "paymentDates": {
+ "paymentFrequency": {
+ "periodMultiplier": 6,
+ "period": "M"
+ },
+ "payRelativeTo": "CalculationPeriodEndDate",
+ "paymentDatesAdjustments": {
+ "businessDayConvention": "MODFOLLOWING",
+ "businessCenters": {
+ "businessCenter": [
+ {
+ "@data": "EUTA"
+ }
+ ]
+ }
+ },
+ "@key:external": "paymentDates2"
+ },
+ "resetDates": {
+ "calculationPeriodDatesReference": {
+ "@ref:external": "floatingCalcPeriodDates2"
+ },
+ "resetRelativeTo": "CalculationPeriodStartDate",
+ "fixingDates": {
+ "periodMultiplier": -2,
+ "period": "D",
+ "dayType": "Business",
+ "businessDayConvention": "NONE",
+ "businessCenters": {
+ "businessCenter": [
+ {
+ "@data": "EUTA"
+ }
+ ]
+ },
+ "dateRelativeTo": {
+ "@ref": "1163732c",
+ "@ref:external": "resetDates2"
+ },
+ "@type": "cdm.base.datetime.RelativeDateOffset"
+ },
+ "resetFrequency": {
+ "periodMultiplier": 6,
+ "period": "M",
+ "@type": "cdm.product.common.schedule.ResetFrequency"
+ },
+ "resetDatesAdjustments": {
+ "businessDayConvention": "MODFOLLOWING",
+ "businessCenters": {
+ "businessCenter": [
+ {
+ "@data": "EUTA"
+ }
+ ]
+ }
+ },
+ "@key:external": "resetDates2"
+ },
+ "@type": "cdm.product.asset.InterestRatePayout"
+ }
+ }
+ ]
+ }
+ },
+ "counterparty": [
+ {
+ "role": "Party1",
+ "partyReference": {
+ "@ref:external": "p1"
+ }
+ },
+ {
+ "role": "Party2",
+ "partyReference": {
+ "@ref:external": "p2"
+ }
+ }
+ ],
+ "tradeIdentifier": [
+ {
+ "issuer": {
+ "@data": "54930084UKLVMY22DS16",
+ "@scheme": "http://www.fpml.org/coding-scheme/external/iso17442"
+ },
+ "assignedIdentifier": [
+ {
+ "identifier": {
+ "@data": "UITD7895394",
+ "@scheme": "http://www.fpml.org/coding-scheme/external/uti"
+ }
+ }
+ ],
+ "identifierType": "UniqueTransactionIdentifier",
+ "@type": "cdm.event.common.TradeIdentifier"
+ }
+ ],
+ "tradeDate": {
+ "@data": "2018-01-29"
+ },
+ "party": [
+ {
+ "partyId": [
+ {
+ "identifier": {
+ "@data": "54930084UKLVMY22DS16",
+ "@scheme": "http://www.fpml.org/coding-scheme/external/iso17442"
+ },
+ "identifierType": "LEI"
+ }
+ ],
+ "name": {
+ "@data": "Party A"
+ },
+ "@key:external": "p1"
+ },
+ {
+ "partyId": [
+ {
+ "identifier": {
+ "@data": "48750084UKLVTR22DS78",
+ "@scheme": "http://www.fpml.org/coding-scheme/external/iso17442"
+ },
+ "identifierType": "LEI"
+ }
+ ],
+ "name": {
+ "@data": "Party B"
+ },
+ "@key:external": "p2"
+ }
+ ],
+ "contractDetails": {
+ "documentation": [
+ {
+ "legalAgreementIdentification": {
+ "agreementName": {
+ "agreementType": "MasterAgreement",
+ "masterAgreementType": {
+ "@data": "ISDAMaster",
+ "@scheme": "http://www.fpml.org/coding-scheme/master-agreement-type"
+ }
+ }
+ },
+ "contractualParty": [
+ {
+ "@ref:external": "p1"
+ },
+ {
+ "@ref:external": "p2"
+ }
+ ],
+ "@type": "cdm.legaldocumentation.common.LegalAgreement"
+ },
+ {
+ "legalAgreementIdentification": {
+ "agreementName": {
+ "agreementType": "Confirmation",
+ "contractualDefinitionsType": [
+ {
+ "@data": "ISDA2006",
+ "@scheme": "http://www.fpml.org/coding-scheme/contractual-definitions"
+ }
+ ]
+ }
+ },
+ "contractualParty": [
+ {
+ "@ref:external": "p1"
+ },
+ {
+ "@ref:external": "p2"
+ }
+ ],
+ "@type": "cdm.legaldocumentation.common.LegalAgreement"
+ }
+ ]
+ },
+ "account": [
+ {
+ "partyReference": {
+ "@ref:external": "p1"
+ },
+ "accountNumber": {
+ "@data": "p1-account-a"
+ },
+ "accountBeneficiary": {
+ "@ref:external": "p1"
+ },
+ "@key:external": "p1-acc"
+ },
+ {
+ "partyReference": {
+ "@ref:external": "p2"
+ },
+ "accountNumber": {
+ "@data": "p2-account-a"
+ },
+ "accountBeneficiary": {
+ "@ref:external": "p2"
+ },
+ "@key:external": "p2-acc"
+ }
+ ],
+ "@type": "cdm.event.common.Trade"
+ },
+ "@key": "3d6d5a8f"
+}
\ No newline at end of file
diff --git a/test/cdm/test_create_irs.py b/test/cdm/test_create_irs.py
new file mode 100644
index 00000000..b33b5551
--- /dev/null
+++ b/test/cdm/test_create_irs.py
@@ -0,0 +1,127 @@
+'''create a test irs'''
+# pylint: disable=invalid-name
+import uuid
+import os
+from datetime import date
+import pytest
+from rune.runtime.base_data_class import BaseDataClass
+try:
+ # pylint: disable=unused-import
+ # type: ignore
+ from cdm.event.common.Trade import Trade
+ from cdm.event.common.TradeIdentifier import TradeIdentifier
+ from cdm.product.template.TradableProduct import TradableProduct
+ from cdm.product.template.Product import Product
+ from cdm.product.template.TradeLot import TradeLot
+ from cdm.observable.asset.PriceQuantity import PriceQuantity
+ from cdm.base.staticdata.party.Party import Party
+ from cdm.base.staticdata.party.PartyIdentifier import PartyIdentifier
+ from cdm.base.staticdata.party.Counterparty import Counterparty
+ from cdm.base.staticdata.party.CounterpartyRoleEnum import CounterpartyRoleEnum
+ # from cdm_observable_asset_Index import Index
+ from cdm.base.staticdata.identifier.AssignedIdentifier import AssignedIdentifier
+ from cdm.base.staticdata.party.PartyIdentifierTypeEnum import PartyIdentifierTypeEnum
+ from cdm.event.common.TradeIdentifier import TradeIdentifier
+ from cdm.base.staticdata.identifier.TradeIdentifierTypeEnum import TradeIdentifierTypeEnum
+ from cdm.base.staticdata.identifier.AssignedIdentifier import AssignedIdentifier
+ from cdm.product.template.Product import Product
+ # from cdm.product.template.ContractualProduct import ContractualProduct
+ from cdm.base.staticdata.asset.common.ProductTaxonomy import ProductTaxonomy
+ from cdm.base.staticdata.asset.common.AssetClassEnum import AssetClassEnum
+ from cdm.base.staticdata.asset.common.TaxonomySourceEnum import TaxonomySourceEnum
+ from cdm.base.staticdata.asset.common.TaxonomyValue import TaxonomyValue
+ from cdm.base.staticdata.asset.common.ProductIdentifier import ProductIdentifier
+ from cdm.base.staticdata.asset.common.ProductIdTypeEnum import ProductIdTypeEnum
+ from cdm.product.template.EconomicTerms import EconomicTerms
+ from cdm.product.template.Payout import Payout
+ from cdm.product.asset.InterestRatePayout import InterestRatePayout
+ from cdm.base.staticdata.party.PayerReceiver import PayerReceiver
+ from cdm.base.staticdata.party.CounterpartyRoleEnum import CounterpartyRoleEnum
+ NO_SER_TEST_MOD = False
+except ImportError:
+ NO_SER_TEST_MOD = True
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='CDM package not found')
+def test_create_irs():
+ '''minimal IRS trade'''
+ party = [None, None]
+ partyId = PartyIdentifier(identifier='54930084UKLVMY22DS16',
+ identifierType=PartyIdentifierTypeEnum.LEI)
+ partyId.identifier.set_meta( # pylint: disable=no-member
+ scheme='http://www.fpml.org/coding-scheme/external/iso17442')
+ party[0] = Party(partyId=[partyId], name='Party A')
+
+ partyId1 = PartyIdentifier(identifier='851WYGNLUQLFZBSYGB56',
+ identifierType=PartyIdentifierTypeEnum.LEI)
+ partyId1.identifier.set_meta( # pylint: disable=no-member
+ scheme='http://www.fpml.org/coding-scheme/external/iso17442')
+ party[1] = Party(partyId=[partyId1], name='Party B')
+
+ assignedIdentifier = AssignedIdentifier(identifier=str(uuid.uuid4()))
+ assignedIdentifier.identifier.set_meta( # pylint: disable=no-member
+ scheme='http://www.fpml.org/coding-scheme/external/uti')
+ tradeIdentifier = TradeIdentifier(
+ issuer='54930084UKLVMY22DS16',
+ assignedIdentifier=[assignedIdentifier],
+ identifierType=TradeIdentifierTypeEnum.UNIQUE_TRANSACTION_IDENTIFIER)
+ tradeIdentifier.issuer.set_meta( # pylint: disable=no-member
+ scheme='http://www.fpml.org/coding-scheme/external/iso17442')
+
+ val = tradeIdentifier.rune_serialize()
+
+ productTaxonomy = [None, None, None]
+ productTaxonomy[0] = ProductTaxonomy(
+ primaryAssetClass=AssetClassEnum.INTEREST_RATE)
+ productTaxonomy[1] = ProductTaxonomy(
+ source=TaxonomySourceEnum.ISDA,
+ value=TaxonomyValue(name='InterestRate:IRSwap:FixedFloat'))
+ productTaxonomy[2] = ProductTaxonomy(
+ source=TaxonomySourceEnum.ISDA,
+ productQualifier='InterestRate_IRSwap_FixedFloat')
+
+ productIdentifier = ProductIdentifier(
+ identifier='InterestRate:IRSwap:FixedFloat',
+ source=ProductIdTypeEnum.OTHER)
+ val = productIdentifier.rune_serialize()
+
+
+ # priceQuantity = quantitySchedule
+
+ # interestRatePayout = [None, None]
+ # interestRatePayout[0] = InterestRatePayout(
+ # payerReceiver=PayerReceiver(payer=CounterpartyRoleEnum.PARTY_1,
+ # receiver=CounterpartyRoleEnum.PARTY_2))
+
+
+ # payout = Payout()
+ # economicTerms = EconomicTerms(payout=payout)
+
+ # contractualProduct = ContractualProduct(
+ # productTaxonomy=productTaxonomy,
+ # productIdentifier=[productIdentifier],
+ # economicTerms=economicTerms)
+ # val = contractualProduct.rune_serialize()
+
+ # cdm_product_template_EconomicTerms
+ #
+ # product = Product()
+ # tradableProduct = TradableProduct()
+
+ # trade = Trade(tradeIdentifier=[tradeIdentifier],
+ # tradeDate=date.today().isoformat(),
+ # party=party,
+ # tradableProduct=tradableProduct)
+ # val = trade.rune_serialize()
+ assert val
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='CDM package not found')
+def test_rune_deserialize():
+ '''no doc'''
+ path = os.path.join(os.path.dirname(__file__), 'EUR-Vanilla-account.json')
+ fp = open(path, 'rt', encoding='utf-8')
+ obj = BaseDataClass.rune_deserialize(fp.read(), validate_model=False)
+ assert obj
+
+# EOF
diff --git a/test/cdm/test_trade_creation.py b/test/cdm/test_trade_creation.py
new file mode 100644
index 00000000..bd6588a8
--- /dev/null
+++ b/test/cdm/test_trade_creation.py
@@ -0,0 +1,60 @@
+'''test the if condition runtime functionality'''
+# pylint: disable=invalid-name
+from datetime import date
+import pytest
+try:
+ # pylint: disable=unused-import
+ # type: ignore
+ from cdm.event.common.Trade import Trade
+ from cdm.event.common.TradeIdentifier import TradeIdentifier
+ from cdm.product.template.TradableProduct import TradableProduct
+ from cdm.product.template.Product import Product
+ from cdm.product.template.NonTransferableProduct import NonTransferableProduct
+ from cdm.product.template.TradeLot import TradeLot
+ from cdm.observable.asset.PriceQuantity import PriceQuantity
+ from cdm.base.staticdata.party.Party import Party
+ from cdm.base.staticdata.party.PartyIdentifier import PartyIdentifier
+ from cdm.base.staticdata.party.Counterparty import Counterparty
+ from cdm.base.staticdata.party.CounterpartyRoleEnum import CounterpartyRoleEnum
+ from cdm.observable.asset.Index import Index
+ from cdm.base.staticdata.identifier.AssignedIdentifier import AssignedIdentifier
+ NO_SER_TEST_MOD = False
+except ImportError:
+ NO_SER_TEST_MOD = True
+
+
+# @pytest.mark.skipif(NO_SER_TEST_MOD, reason='CDM package not found')
+@pytest.mark.skip(reason='We cannot distinguish CDM 6 vs 5 yet')
+def test_simple_trade():
+ '''Constructs a simple Trade in memory and validates the model.'''
+ price_quantity = PriceQuantity()
+ trade_lot = TradeLot(priceQuantity=[price_quantity])
+ product = NonTransferableProduct(index=Index())
+ counterparty = [
+ Counterparty(role=CounterpartyRoleEnum.PARTY_1,
+ partyReference=Party(
+ partyId=[PartyIdentifier(identifier='Acme Corp')])),
+ Counterparty(
+ role=CounterpartyRoleEnum.PARTY_2,
+ partyReference=Party(
+ partyId=[PartyIdentifier(identifier='Wile E. Coyote')]))
+ ]
+ tradable_product = TradableProduct(product=product,
+ tradeLot=[trade_lot],
+ counterparty=counterparty)
+ assigned_identifier = AssignedIdentifier(identifier='BIG DEAL!')
+ trade_identifier = [
+ TradeIdentifier(issuer='Acme Corp',
+ assignedIdentifier=[assigned_identifier])
+ ]
+
+ # t = Trade(tradeDate=DateWithMeta(str(date(2023, 1, 1))),
+ t = Trade(tradeDate=date(2023, 1, 1),
+ tradableProduct=tradable_product,
+ tradeIdentifier=trade_identifier)
+ with pytest.raises(NameError):
+ exceptions = t.validate_model(raise_exc=False)
+ exceptions = t.validate_model(raise_exc=False, check_rune_constraints=False)
+ assert not exceptions
+
+# EOF
diff --git a/test/cdm/test_validation.py b/test/cdm/test_validation.py
new file mode 100644
index 00000000..6f928dd5
--- /dev/null
+++ b/test/cdm/test_validation.py
@@ -0,0 +1,23 @@
+'''Full attribute validation - pydantic and constraints'''
+import pytest
+from pydantic import ValidationError
+try:
+ # pylint: disable=unused-import
+ # type: ignore
+ from cdm.base.math.NonNegativeQuantity import NonNegativeQuantity
+ from cdm.base.math.UnitType import UnitType
+ NO_SER_TEST_MOD = False
+except ImportError:
+ NO_SER_TEST_MOD = True
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='CDM package not found')
+def test_bad_attrib_validation():
+ '''Invalid attribute assigned'''
+ unit = UnitType(currency='EUR')
+ mq = NonNegativeQuantity(value=10, unit=unit)
+ mq.frequency = 'Blah'
+ with pytest.raises(ValidationError):
+ mq.validate_model()
+
+# EOF
diff --git a/test/run_runtime_tests.sh b/test/run_runtime_tests.sh
new file mode 100755
index 00000000..a9718f6d
--- /dev/null
+++ b/test/run_runtime_tests.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+type -P python > /dev/null && PY_EXE=python || PY_EXE=python3
+if ! $PY_EXE -c 'import sys; assert sys.version_info >= (3,10)' > /dev/null 2>&1; then
+ echo "Found $($PY_EXE -V)"
+ echo "Expecting at least python 3.10 - exiting!"
+ exit 1
+fi
+
+export PYTHONDONTWRITEBYTECODE=1
+
+MY_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
+cd $MY_PATH/..
+
+$PY_EXE -m venv --clear .pytest
+AC_DIR=$($PY_EXE -c "import sys;print('Scripts' if sys.platform.startswith('win') else 'bin')")
+source .pytest/$AC_DIR/activate
+# after the above, use directly python as it will be on the path
+
+python -m pip install pytest
+python -m pip install rune.runtime*-py3-*.whl
+
+# run tests
+python -m pytest -p no:cacheprovider test/
+rm -rf .pytest
\ No newline at end of file
diff --git a/test/serializer-round-trip/test_basic.py b/test/serializer-round-trip/test_basic.py
new file mode 100644
index 00000000..2b29122d
--- /dev/null
+++ b/test/serializer-round-trip/test_basic.py
@@ -0,0 +1,127 @@
+'''
+Testing basic types using the following Rune definitions:
+
+typeAlias ParameterisedNumberType:
+ number(digits: 18, fractionalDigits: 2)
+
+typeAlias ParameterisedStringType:
+ string(minLength: 1, maxLength: 20, pattern: "[a-zA-Z]")
+
+type BasicSingle:
+ booleanType boolean (1..1)
+ numberType number (1..1)
+ parameterisedNumberType ParameterisedNumberType (1..1)
+ parameterisedStringType ParameterisedStringType (1..1)
+ stringType string (1..1)
+ timeType time (1..1)
+
+type BasicList:
+ booleanTypes boolean (1..*)
+ numberTypes number (1..*)
+ parameterisedNumberTypes ParameterisedNumberType (1..*)
+ parameterisedStringTypes ParameterisedStringType (1..*)
+ stringTypes string (1..*)
+ timeTypes time (1..*)
+
+type Root:
+ [rootType]
+ basicSingle BasicSingle (0..1)
+ basicList BasicList (0..1)
+
+'''
+import datetime
+from decimal import Decimal
+from typing import Optional, Annotated
+from pydantic import Field
+from rune.runtime.base_data_class import BaseDataClass
+# from rune.runtime.metadata import NumberWithMeta
+# pylint: disable=invalid-name
+
+
+class BasicSingle(BaseDataClass):
+ '''no doc'''
+ booleanType: bool = Field(..., description='')
+ numberType: Decimal = Field(..., description='')
+ parameterisedNumberType: Decimal = Field(...,
+ description='',
+ max_digits=18,
+ decimal_places=2)
+ # NOTE: the addition of a prefix and suffix to the regular expression!!!
+ parameterisedStringType: str = Field(...,
+ description='',
+ min_length=1,
+ max_length=20,
+ pattern=r'^[a-zA-Z]*$')
+ stringType: str = Field(..., description='')
+ timeType: datetime.time = Field(..., description='')
+
+
+class BasicList(BaseDataClass):
+ '''no doc'''
+ booleanTypes: list[bool] = Field([], description='', min_length=1)
+ numberTypes: list[Decimal] = Field([], description='', min_length=1)
+ # parameterisedNumberTypes: list[Annotated[
+ # NumberWithMeta,
+ # NumberWithMeta.serializer(),
+ # NumberWithMeta.validator(('@ref', )),
+ # Field(decimal_places=2, max_digits=6)]] = Field(
+ # [],
+ # description='',
+ # min_length=1
+ # )
+ parameterisedNumberTypes: list[Annotated[
+ Decimal,
+ Field(decimal_places=2, max_digits=5)]] = Field([],
+ description='',
+ min_length=1)
+ # NOTE: the addition of a prefix and suffix to the regular expression!!!
+ parameterisedStringTypes: list[Annotated[
+ str,
+ Field(min_length=1, max_length=20, pattern=r'^[a-zA-Z]*$')]] = Field(
+ [], description='', min_length=1)
+ stringTypes: list[str] = Field([], description='', min_length=1)
+ timeTypes: list[datetime.time] = Field([], description='', min_length=1)
+
+
+class Root(BaseDataClass):
+ '''no doc'''
+ basicSingle: Optional[BasicSingle] = Field(None, description='')
+ basicList: Optional[BasicList] = Field(None, description='')
+
+
+def test_basic_types_single():
+ '''basic-types-single.json'''
+ json_str = '''
+ {
+ "basicSingle" : {
+ "booleanType" : true,
+ "numberType" : 123.456,
+ "parameterisedNumberType" : 123.99,
+ "parameterisedStringType" : "abcDEF",
+ "stringType" : "foo",
+ "timeType" : "12:00:00"
+ }
+ }
+ '''
+ model = Root.model_validate_json(json_str)
+ model.validate_model()
+
+
+def test_basic_types_list():
+ '''basic-types-list.json'''
+ json_str = '''
+ {
+ "basicList" : {
+ "booleanTypes" : [ true, false, true ],
+ "numberTypes" : [ 123.456, 789, 345.123 ],
+ "parameterisedNumberTypes" : [ 123.99, 456, 99.12 ],
+ "parameterisedStringTypes" : [ "abcDEF", "foo", "foo" ],
+ "stringTypes" : [ "foo", "bar", "Baz123" ],
+ "timeTypes" : [ "12:00:00" ]
+ }
+ }
+ '''
+ model = Root.model_validate_json(json_str)
+ model.validate_model()
+
+# EOF
diff --git a/test/serializer-round-trip/test_conditions.py b/test/serializer-round-trip/test_conditions.py
new file mode 100644
index 00000000..c4fef7b3
--- /dev/null
+++ b/test/serializer-round-trip/test_conditions.py
@@ -0,0 +1,28 @@
+# pylint: disable=invalid-name
+'''testing basic conditions compliance'''
+import datetime
+import pytest
+from pydantic import Field, ValidationError
+from rune.runtime.base_data_class import BaseDataClass
+
+
+class cdm_base_datetime_DateList(BaseDataClass):
+ """
+ List of dates.
+ """
+ _FQRTN = 'cdm.base.datetime.DateList'
+ date: list[datetime.date] = Field(..., description='', min_length=1)
+
+
+def test_min_list_length_all_defaults():
+ '''no doc'''
+ with pytest.raises(ValidationError):
+ cdm_base_datetime_DateList()
+
+
+def test_min_list_length_empty_list():
+ '''no doc'''
+ with pytest.raises(ValidationError):
+ cdm_base_datetime_DateList(date=[])
+
+# EOF
diff --git a/test/serializer-round-trip/test_enumtype.py b/test/serializer-round-trip/test_enumtype.py
new file mode 100644
index 00000000..db13daad
--- /dev/null
+++ b/test/serializer-round-trip/test_enumtype.py
@@ -0,0 +1,59 @@
+'''Serialization Enum tests'''
+import json
+import pytest
+from rune.runtime.base_data_class import BaseDataClass
+
+try:
+ # pylint: disable=unused-import
+ # type: ignore
+ import serialization # noqa
+ NO_SER_TEST_MOD = False
+except ImportError:
+ NO_SER_TEST_MOD = True
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_enum_types_single():
+ '''no doc'''
+ # import serialization.test.passing.enumtypes.Root
+ # import serialization.test.passing.enumtypes.EnumSingle
+ # import serialization.test.passing.enumtypes.EnumType
+
+ # root = serialization.test.passing.enumtypes.Root.Root(
+ # enumSingle=serialization.test.passing.enumtypes.EnumSingle.EnumSingle(
+ # enumType=serialization.test.passing.enumtypes.EnumType.EnumType.A
+ # ))
+ # resp_json = root.rune_serialize()
+ json_str = '''
+ {
+ "@model": "serialization",
+ "@type": "serialization.test.passing.enumtypes.Root",
+ "@version": "0.0.0",
+ "enumSingle": {
+ "enumType": "A"
+ }
+ }
+ '''
+ model = BaseDataClass.rune_deserialize(json_str)
+ resp_json = model.rune_serialize()
+ assert json.loads(resp_json) == json.loads(json_str)
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_enum_types_list():
+ '''no doc'''
+ json_str = '''
+ {
+ "@model": "serialization",
+ "@type": "serialization.test.passing.enumtypes.Root",
+ "@version": "0.0.0",
+ "enumList": {
+ "enumType": ["A", "B", "C", "B"]
+ }
+ }
+ '''
+ model = BaseDataClass.rune_deserialize(json_str)
+ resp_json = model.rune_serialize()
+ assert json.loads(resp_json) == json.loads(json_str)
+
+# EOF
diff --git a/test/serializer-round-trip/test_extension.py b/test/serializer-round-trip/test_extension.py
new file mode 100644
index 00000000..e1dcac18
--- /dev/null
+++ b/test/serializer-round-trip/test_extension.py
@@ -0,0 +1,126 @@
+'''tests based on the extension folder in rune-serializer-round-trip-test'''
+# pylint: disable=import-outside-toplevel
+import json
+import pytest
+from rune.runtime.base_data_class import BaseDataClass
+
+try:
+ # pylint: disable=unused-import
+ # type: ignore
+ import serialization # noqa
+ NO_SER_TEST_MOD = False
+except ImportError:
+ NO_SER_TEST_MOD = True
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_base_type():
+ '''no doc'''
+ json_str = '''{
+ "@model": "serialization",
+ "@type": "serialization.test.passing.extension.Root",
+ "@version": "0.0.0",
+ "typeA": {
+ "fieldA": "foo"
+ }
+ }'''
+ model = BaseDataClass.rune_deserialize(json_str)
+ resp_json = model.rune_serialize()
+ assert json.loads(resp_json) == json.loads(json_str)
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_extended_type_concrete():
+ '''no doc'''
+ json_str = '''{
+ "@model": "serialization",
+ "@type": "serialization.test.passing.extension.Root",
+ "@version": "0.0.0",
+ "typeB": {
+ "fieldA": "foo",
+ "fieldB": "foo",
+ "@type": "serialization.test.passing.extension.B"
+ }
+ }'''
+ model = BaseDataClass.rune_deserialize(json_str)
+ resp_json = model.rune_serialize()
+ org_dict = json.loads(json_str)
+ org_dict['typeB'].pop('@type')
+ assert org_dict == json.loads(resp_json)
+ # assert json.loads(resp_json) == json.loads(json_str)
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_extended_type_polymorphic():
+ '''no doc'''
+ json_str = '''{
+ "@model": "serialization",
+ "@type": "serialization.test.passing.extension.Root",
+ "@version": "0.0.0",
+ "typeA": {
+ "fieldA": "bar",
+ "fieldB": "foo",
+ "@type": "serialization.test.passing.extension.B"
+ }
+ }'''
+ # import serialization.test.passing.extension.Root
+ model = BaseDataClass.rune_deserialize(json_str)
+ resp_json = model.rune_serialize()
+ assert json.loads(resp_json) == json.loads(json_str)
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_at_type():
+ '''no doc'''
+ from serialization.test.passing.extension.B import B
+ json_str = '''
+ {
+ "@type": "serialization.test.passing.extension.Root",
+ "typeA" : {
+ "fieldA" : "bar",
+ "fieldB" : "foo",
+ "@type" : "serialization.test.passing.extension.B"
+ }
+ }
+ '''
+ model = BaseDataClass.rune_deserialize(json_str)
+ assert isinstance(model.typeA, B)
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_temp():
+ '''no doc'''
+ from serialization.test.passing.metakey.Root import Root
+ json_str = '''
+ {
+ "nodeRef" : {
+ "typeA" : {
+ "fieldA" : "foo",
+ "@key" : "someKey1x"
+ },
+ "aReference" : {
+ "@ref" : "someKey1x"
+ }
+ }
+ }
+ '''
+ model = Root.model_validate_json(json_str)
+ assert id(model.nodeRef.typeA) == id(model.nodeRef.aReference)
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_enums():
+ '''no doc'''
+ from serialization.test.passing.enumtypes.Root import Root
+ json_str = '{"enumSingle":{"enumType":"A"}}'
+ json_dict = json.loads(json_str)
+ model1 = Root.model_validate(json_dict)
+ model2 = Root.model_validate_json(json_str)
+ model1.validate_model()
+ model2.validate_model()
+ resp_json1 = model1.model_dump_json(exclude_unset=True)
+ resp_json2 = model2.model_dump_json(exclude_unset=True)
+ assert resp_json1 == json_str
+ assert resp_json2 == json_str
+
+# EOF
diff --git a/test/serializer-round-trip/test_metakey.py b/test/serializer-round-trip/test_metakey.py
new file mode 100644
index 00000000..cb1ca8a7
--- /dev/null
+++ b/test/serializer-round-trip/test_metakey.py
@@ -0,0 +1,256 @@
+'''
+Testing the following Rune definitions:
+
+type A:
+ [metadata key]
+ fieldA string (1..1)
+
+type NodeRef:
+ typeA A (0..1)
+ aReference A (0..1)
+ [metadata reference]
+
+type AttributeRef:
+ dateField date (0..1)
+ [metadata id]
+ dateReference date (0..1)
+ [metadata reference]
+
+type Root:
+ [rootType]
+ nodeRef NodeRef (0..1)
+ attributeRef AttributeRef (0..1)
+'''
+import datetime
+import json
+from typing_extensions import Annotated, Optional
+import pytest
+from pydantic import Field
+from rune.runtime.base_data_class import BaseDataClass
+from rune.runtime.metadata import DateWithMeta
+try:
+ # pylint: disable=unused-import
+ # type: ignore
+ import serialization # noqa
+ NO_SER_TEST_MOD = False
+except ImportError:
+ NO_SER_TEST_MOD = True
+
+
+class A(BaseDataClass):
+ '''no doc'''
+ fieldA: str = Field(..., description="") # type: ignore
+
+
+class NodeRef(BaseDataClass):
+ '''no doc'''
+ # NOTE: the @key is generated for all instances of A as it is annotated
+ # in the type definition with key!!
+ typeA: Optional[Annotated[
+ A, # type: ignore
+ A.serializer(),
+ A.validator(('@key', ))]] = Field(None, description='')
+ # NOTE: the @key is generated for all instances of A as it is annotated
+ # in the type definition with key!!
+ aReference: Optional[Annotated[
+ A, # type: ignore
+ A.serializer(),
+ A.validator(('@key', '@ref'))]] = Field(None, description='')
+
+ _KEY_REF_CONSTRAINTS = {
+ 'aReference': {'@ref', '@ref:external'}
+ }
+
+class AttributeRef(BaseDataClass):
+ '''no doc'''
+ # type: ignore
+ dateField: Optional[Annotated[ # type: ignore
+ DateWithMeta,
+ DateWithMeta.serializer(),
+ DateWithMeta.validator(
+ ('@key', '@key:external'))]] = Field(None, description='')
+ dateReference: Optional[Annotated[ # type: ignore
+ DateWithMeta,
+ DateWithMeta.serializer(),
+ DateWithMeta.validator(
+ ('@ref', '@ref:external'))]] = Field(None, description='')
+
+ _KEY_REF_CONSTRAINTS = {
+ 'dateReference': {'@ref', '@ref:external'}
+ }
+
+
+class Root(BaseDataClass):
+ '''no doc'''
+ # type: ignore
+ nodeRef: Optional[NodeRef] = Field(None, description='') # type: ignore
+ attributeRef: Optional[AttributeRef] = Field( # type: ignore
+ None, description='')
+
+
+def test_attribute_ref():
+ '''attribute-ref.json'''
+ json_str = '''
+ {
+ "attributeRef" : {
+ "dateField" : {
+ "@data" : "2024-12-12",
+ "@key" : "someKey",
+ "@key:external" : "someExternalKey"
+ },
+ "dateReference" : {
+ "@ref" : "someKey",
+ "@ref:external" : "someExternalKey"
+ }
+ }
+ }
+ '''
+ model = Root.model_validate_json(json_str)
+ model.validate_model()
+ assert model.attributeRef.dateField == datetime.date(2024, 12, 12)
+ assert model.attributeRef.dateReference == datetime.date(2024, 12, 12)
+ assert (id(model.attributeRef.dateField) == id(
+ model.attributeRef.dateReference))
+
+
+def test_node_ref():
+ '''node-ref.json'''
+ json_str = '''
+ {
+ "nodeRef" : {
+ "typeA" : {
+ "fieldA" : "foo",
+ "@key" : "someKey1"
+ },
+ "aReference" : {
+ "@ref" : "someKey1"
+ }
+ }
+ }
+ '''
+ model = Root.model_validate_json(json_str)
+ model.validate_model()
+ assert model.nodeRef.typeA.fieldA == 'foo'
+ assert model.nodeRef.aReference.fieldA == 'foo'
+ assert id(model.nodeRef.typeA.fieldA) == id(model.nodeRef.aReference.fieldA)
+
+
+def test_dangling_attribute_ref():
+ '''dangling-attribute-ref.json'''
+ json_str = '''
+ {
+ "attributeRef" : {
+ "dateReference" : {
+ "@ref" : "someKey2"
+ }
+ }
+ }
+ '''
+ with pytest.raises(KeyError):
+ Root.rune_deserialize(json_str)
+
+
+def test_dangling_node_ref():
+ '''dangling-node-ref.json'''
+ json_str = '''
+ {
+ "nodeRef" : {
+ "aReference" : {
+ "@ref" : "someKey3"
+ }
+ }
+ }
+ '''
+ with pytest.raises(KeyError):
+ Root.rune_deserialize(json_str)
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_generated_attribute_ref():
+ '''attribute-ref.json'''
+ json_str = '''{
+ "@model": "serialization",
+ "@type": "serialization.test.passing.metakey.Root",
+ "@version": "0.0.0",
+ "attributeRef": {
+ "dateField": {
+ "@data": "2024-12-12",
+ "@key": "someKey",
+ "@key:external": "someExternalKey"
+ },
+ "dateReference": {
+ "@ref": "someKey",
+ "@ref:external": "someExternalKey"
+ }
+ }
+ }'''
+ model = BaseDataClass.rune_deserialize(json_str)
+ resp_json = model.rune_serialize()
+ org_dict = json.loads(json_str)
+ org_dict['attributeRef']['dateReference'].pop('@ref')
+ assert json.loads(resp_json) == org_dict
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_generated_node_ref():
+ '''attribute-ref.json'''
+ json_str = '''{
+ "@model": "serialization",
+ "@type": "serialization.test.passing.metakey.Root",
+ "@version": "0.0.0",
+ "nodeRef": {
+ "typeA": {
+ "fieldA": "foo",
+ "@key": "someKey",
+ "@key:external": "someExternalKey"
+ },
+ "aReference": {
+ "@ref": "someKey",
+ "@ref:external": "someExternalKey"
+ }
+ }
+ }'''
+ model = BaseDataClass.rune_deserialize(json_str)
+ resp_json = model.rune_serialize()
+ org_dict = json.loads(json_str)
+ org_dict['nodeRef']['aReference'].pop('@ref')
+ assert json.loads(resp_json) == org_dict
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_generated_dangling_node_ref():
+ '''attribute-ref.json'''
+ json_str = '''{
+ "@model": "serialization",
+ "@type": "serialization.test.passing.metakey.Root",
+ "@version": "0.0.0",
+ "nodeRef": {
+ "aReference": {
+ "@ref": "someKey",
+ "@ref:external": "someExternalKey"
+ }
+ }
+ }'''
+ with pytest.raises(KeyError):
+ BaseDataClass.rune_deserialize(json_str)
+
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_generated_dangling_attribute_ref():
+ '''attribute-ref.json'''
+ json_str = '''{
+ "@model": "serialization",
+ "@type": "serialization.test.passing.metakey.Root",
+ "@version": "0.0.0",
+ "attributeRef": {
+ "dateReference": {
+ "@ref": "someKey",
+ "@ref:external": "someExternalKey"
+ }
+ }
+ }'''
+ with pytest.raises(KeyError):
+ BaseDataClass.rune_deserialize(json_str)
+
+# EOF
diff --git a/test/serializer-round-trip/test_metalocation.py b/test/serializer-round-trip/test_metalocation.py
new file mode 100644
index 00000000..b0e0bc53
--- /dev/null
+++ b/test/serializer-round-trip/test_metalocation.py
@@ -0,0 +1,37 @@
+'''tests based on the extension folder in rune-serializer-round-trip-test'''
+# pylint: disable=import-outside-toplevel
+import json
+import pytest
+from rune.runtime.base_data_class import BaseDataClass
+
+try:
+ # pylint: disable=unused-import
+ # type: ignore
+ import serialization # noqa
+ NO_SER_TEST_MOD = False
+except ImportError:
+ NO_SER_TEST_MOD = True
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_address():
+ '''no doc'''
+ json_str = ''' {
+ "@model": "serialization",
+ "@type": "serialization.test.passing.metalocation.Root",
+ "@version": "0.0.0",
+ "typeA": {
+ "b": {
+ "fieldB": "foo",
+ "@key:scoped": "someLocation"
+ }
+ },
+ "bAddress": {
+ "@ref:scoped": "someLocation"
+ }
+ }'''
+ model = BaseDataClass.rune_deserialize(json_str)
+ resp_json = model.rune_serialize()
+ assert json.loads(resp_json) == json.loads(json_str)
+
+# EOF
diff --git a/test/serializer-round-trip/test_metascheme.py b/test/serializer-round-trip/test_metascheme.py
new file mode 100644
index 00000000..bf4f9361
--- /dev/null
+++ b/test/serializer-round-trip/test_metascheme.py
@@ -0,0 +1,58 @@
+'''Serialization Enum tests'''
+import json
+import pytest
+from rune.runtime.base_data_class import BaseDataClass
+
+try:
+ # pylint: disable=unused-import
+ # type: ignore
+ import serialization # noqa
+ NO_SER_TEST_MOD = False
+except ImportError:
+ NO_SER_TEST_MOD = True
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_enum_single():
+ '''enums with meta'''
+ json_str = '''
+ {
+ "@model": "serialization",
+ "@type": "serialization.test.passing.metascheme.Root",
+ "@version": "0.0.0",
+ "enumType": {
+ "@data": "A",
+ "@scheme": "https://www.example.com/scheme"
+ }
+ }
+ '''
+ model = BaseDataClass.rune_deserialize(json_str)
+ resp_json = model.rune_serialize()
+ assert json.loads(resp_json) == json.loads(json_str)
+
+
+@pytest.mark.skipif(NO_SER_TEST_MOD, reason='Generated test package not found')
+def test_enum_list():
+ '''list of enums with meta'''
+ json_str = '''
+ {
+ "@model": "serialization",
+ "@type": "serialization.test.passing.metascheme.Root",
+ "@version": "0.0.0",
+ "enumTypeList": [{
+ "@data": "A",
+ "@scheme": "https://www.example.com/scheme1"
+ }, {
+ "@data": "B",
+ "@scheme": "https://www.example.com/scheme2"
+ }, {
+ "@data": "C",
+ "@scheme": "https://www.example.com/scheme3"
+ }]
+ }
+ '''
+ model = BaseDataClass.rune_deserialize(json_str)
+ resp_json = model.rune_serialize()
+ assert json.loads(resp_json) == json.loads(json_str)
+
+# EOF
diff --git a/test/test_basic_types_with_meta.py b/test/test_basic_types_with_meta.py
new file mode 100644
index 00000000..166e0408
--- /dev/null
+++ b/test/test_basic_types_with_meta.py
@@ -0,0 +1,369 @@
+'''test module for the annotated base rune types'''
+from datetime import date, time, datetime
+from enum import Enum
+from decimal import Decimal
+import json
+import pytest
+from typing_extensions import Annotated
+from pydantic import Field, ValidationError
+
+from rune.runtime.base_data_class import BaseDataClass
+from rune.runtime.metadata import NumberWithMeta
+from rune.runtime.metadata import DateWithMeta
+from rune.runtime.metadata import DateTimeWithMeta
+from rune.runtime.metadata import TimeWithMeta
+from rune.runtime.metadata import StrWithMeta
+from rune.runtime.metadata import EnumWithMetaMixin
+from rune.runtime.metadata import _EnumWrapper
+
+
+class AnnotatedStringModel(BaseDataClass):
+ '''string test class'''
+ currency: Annotated[StrWithMeta,
+ StrWithMeta.serializer(),
+ StrWithMeta.validator(('@scheme',))
+ ] = Field(..., description="Test currency")
+
+
+class AnnotatedNumberModel(BaseDataClass):
+ '''number test class'''
+ amount: Annotated[NumberWithMeta,
+ NumberWithMeta.serializer(),
+ NumberWithMeta.validator(('@scheme',))
+ ] = Field(..., description="Test amount", decimal_places=3)
+
+
+class AnnotatedDateModel(BaseDataClass):
+ '''date test class'''
+ date: Annotated[DateWithMeta,
+ DateWithMeta.serializer(),
+ DateWithMeta.validator(('@scheme',))
+ ] = Field(..., description="Test date")
+
+
+class AnnotatedDateTimeModel(BaseDataClass):
+ '''datetime test class'''
+ datetime: Annotated[DateTimeWithMeta,
+ DateTimeWithMeta.serializer(),
+ DateTimeWithMeta.validator(('@scheme',))
+ ] = Field(..., description="Test datetime")
+
+
+class AnnotatedTimeModel(BaseDataClass):
+ '''datetime test class'''
+ time: Annotated[TimeWithMeta,
+ TimeWithMeta.serializer(),
+ TimeWithMeta.validator(('@scheme',))
+ ] = Field(..., description="Test time")
+
+
+class StrWithMetaModel(BaseDataClass):
+ '''generic meta support test case'''
+ currency: Annotated[StrWithMeta,
+ StrWithMeta.serializer(),
+ StrWithMeta.validator(('@scheme', '@key'))
+ ] = Field(..., description="Test currency")
+
+
+class StrWithMetaAndConstraintsModel(BaseDataClass):
+ '''meta and string constraints test case'''
+ currency: Annotated[StrWithMeta,
+ StrWithMeta.serializer(),
+ StrWithMeta.validator(('@scheme', '@key'))
+ ] = Field(..., description="Test currency",
+ min_length=3, max_length=5, pattern=r'^[A-Z]*$')
+
+
+class ConstrainedNumberModel(BaseDataClass):
+ '''number test class'''
+ amount: Annotated[NumberWithMeta,
+ NumberWithMeta.serializer(),
+ NumberWithMeta.validator(('@scheme',))
+ ] = Field(..., description="Test amount", decimal_places=3, ge=0)
+
+
+class EnumType(EnumWithMetaMixin, Enum):
+ '''test rune enum'''
+ A = "A"
+ B = "B"
+ C = "C"
+
+
+class AnnotatedEnumModel(BaseDataClass):
+ '''string test class'''
+ enum_t: Annotated[EnumType,
+ EnumType.serializer(),
+ EnumType.validator(
+ ('@scheme', ))] = Field(..., description='')
+
+
+def test_dump_annotated_string_simple():
+ '''test the annotated string'''
+ model = AnnotatedStringModel(currency='EUR')
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"currency":{"@data":"EUR"}}', 'explicit string failed'
+
+ model = AnnotatedStringModel(currency=StrWithMeta('EUR'))
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"currency":{"@data":"EUR"}}', 'annotated string failed'
+
+
+def test_annotated_string_with_forbidden_meta():
+ '''test exception when extra meta is passed'''
+ with pytest.raises(ValidationError):
+ AnnotatedStringModel(currency=StrWithMeta(
+ 'EUR', scheme='http://fpml.org', key='currency-1'))
+
+
+def test_dump_annotated_string_scheme():
+ '''test the scheme treatment'''
+ model = AnnotatedStringModel(
+ currency=StrWithMeta('EUR', scheme='http://fpml.org'))
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert (
+ json_str == '{"currency":{"@scheme":"http://fpml.org","@data":"EUR"}}')
+
+
+def test_load_annotated_string_simple():
+ '''test the loading of annotated strings'''
+ simple_json = '{"currency":{"@data":"EUR"}}'
+ model = AnnotatedStringModel.model_validate_json(simple_json)
+ assert model.currency == 'EUR', 'currency differs'
+ assert model.currency.get_meta('@scheme') is None, 'scheme is not None'
+
+
+def test_load_annotated_string_scheme():
+ '''test the loading of annotated with a scheme strings'''
+ scheme_json = '{"currency":{"@data":"EUR","@scheme":"http://fpml.org"}}'
+ model = AnnotatedStringModel.model_validate_json(scheme_json)
+ assert model.currency == 'EUR', 'currency differs'
+ assert model.currency.get_meta('@scheme') == 'http://fpml.org'
+
+
+def test_dump_annotated_number_simple():
+ '''test the annotated string'''
+ model = AnnotatedNumberModel(amount=10)
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"amount":{"@data":"10"}}', 'explicit int failed'
+
+ model = AnnotatedNumberModel(amount="10.344")
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"amount":{"@data":"10.344"}}', 'explicit string failed'
+
+ model = AnnotatedNumberModel(amount=NumberWithMeta(10))
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"amount":{"@data":"10"}}', 'annotated number failed'
+
+
+def test_dump_annotated_number_scheme():
+ '''test the annotated string'''
+ model = AnnotatedNumberModel(
+ amount=NumberWithMeta(10, scheme='http://fpml.org'))
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"amount":{"@scheme":"http://fpml.org","@data":"10"}}'
+
+
+def test_load_annotated_number():
+ '''test the loading of annotated with a scheme strings'''
+ scheme_json = '{"amount":{"@data":"10"}}'
+ model = AnnotatedNumberModel.model_validate_json(scheme_json)
+ assert model.amount == 10, 'string amount differs'
+
+ scheme_json = '{"amount":{"@data":10}}'
+ model = AnnotatedNumberModel.model_validate_json(scheme_json)
+ assert model.amount == 10, 'int amount differs'
+
+ scheme_json = '{"amount":{"@data":"10.3"}}'
+ model = AnnotatedNumberModel.model_validate_json(scheme_json)
+ assert model.amount == Decimal("10.3"), 'float amount differs'
+
+
+def test_load_annotated_number_scheme():
+ '''test the loading of annotated with a scheme strings'''
+ scheme_json = '{"amount":{"@data":"10","@scheme":"http://fpml.org"}}'
+ model = AnnotatedNumberModel.model_validate_json(scheme_json)
+ assert model.amount == 10, 'amount differs'
+ assert model.amount.get_meta('@scheme') == 'http://fpml.org'
+
+
+def test_fail_load_annotated_number():
+ '''test the loading of annotated with a scheme strings'''
+ scheme_json = '{"amount":{"@data":"10.1234","@scheme":"http://fpml.org"}}'
+ with pytest.raises(ValidationError):
+ AnnotatedNumberModel.model_validate_json(scheme_json)
+
+
+def test_fail_create_annotated_number():
+ '''test the loading of annotated with a scheme strings'''
+ with pytest.raises(ValidationError):
+ AnnotatedNumberModel(amount=NumberWithMeta("1.1234"))
+
+
+def test_fail_create_annotated_number_():
+ '''test the loading of annotated with a scheme strings'''
+ with pytest.raises(ValidationError):
+ AnnotatedNumberModel(amount="10.1234")
+
+
+def test_dump_annotated_date_simple():
+ '''test the annotated string'''
+ model = AnnotatedDateModel(date="2024-10-10")
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"date":{"@data":"2024-10-10"}}'
+
+ model = AnnotatedDateModel(date=DateWithMeta("2024-10-10"))
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"date":{"@data":"2024-10-10"}}'
+
+
+def test_dump_annotated_date_date():
+ '''test the annotated string'''
+ model = AnnotatedDateModel(date=date(2024, 10, 10))
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"date":{"@data":"2024-10-10"}}'
+
+ model = AnnotatedDateModel(date=DateWithMeta(date(2024, 10, 10)))
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"date":{"@data":"2024-10-10"}}'
+
+
+def test_annotated_date_fail():
+ '''test instantiation failure with an incorrect type'''
+ with pytest.raises(AttributeError):
+ AnnotatedDateModel(date=10)
+
+
+def test_date_with_meta_fail():
+ '''test instantiation failure with an incorrect type'''
+ with pytest.raises(ValueError):
+ DateWithMeta(10)
+
+
+def test_load_annotated_date_scheme():
+ '''test the loading of annotated with a scheme strings'''
+ scheme_json = '{"date":{"@data":"2024-10-10","@scheme":"http://fpml.org"}}'
+ model = AnnotatedDateModel.model_validate_json(scheme_json)
+ assert model.date == date(2024, 10, 10), 'date differs'
+ assert model.date.get_meta('scheme') == 'http://fpml.org', 'scheme differs'
+
+
+def test_dump_annotated_datetime_simple():
+ '''test the annotated string'''
+ model = AnnotatedDateTimeModel(datetime="2024-10-10T01:01:01")
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"datetime":{"@data":"2024-10-10T01:01:01"}}'
+
+ model = AnnotatedDateTimeModel(
+ datetime=DateTimeWithMeta("2024-10-10T01:01:01"))
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"datetime":{"@data":"2024-10-10T01:01:01"}}'
+
+
+def test_load_annotated_datetime_scheme():
+ '''test the loading of annotated with a scheme strings'''
+ scheme_json = ('{"datetime":{"@data":"2024-10-10T01:01:01",'
+ '"@scheme":"http://fpml.org"}}')
+ model = AnnotatedDateTimeModel.model_validate_json(scheme_json)
+ assert model.datetime == datetime(2024, 10, 10, 1, 1, 1), 'datetime differs'
+ assert model.datetime.get_meta('scheme') == 'http://fpml.org'
+
+
+def test_dump_annotated_time_simple():
+ '''test the annotated string'''
+ model = AnnotatedTimeModel(time="01:01:01.000087")
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"time":{"@data":"01:01:01.000087"}}'
+
+ model = AnnotatedTimeModel(time=TimeWithMeta("01:01:01"))
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str == '{"time":{"@data":"01:01:01"}}'
+
+
+def test_load_annotated_time_scheme():
+ '''test the loading of annotated with a scheme strings'''
+ scheme_json = (
+ '{"time":{"@data":"01:01:01.000087","@scheme":"http://fpml.org"}}')
+ model = AnnotatedTimeModel.model_validate_json(scheme_json)
+ assert model.time == time(1, 1, 1, 87), 'time differs'
+ assert model.time.get_meta('scheme') == 'http://fpml.org', 'scheme differs'
+
+
+def test_generic_string_with_meta():
+ '''generic meta support'''
+ model = StrWithMetaModel(currency='EUR')
+ assert model.currency == 'EUR'
+
+ model = StrWithMetaModel(currency=StrWithMeta(
+ 'EUR', scheme='http://fpml.org', key='currency-1'))
+ assert model.currency == 'EUR'
+ # pylint: disable=no-member
+ assert model.currency.get_meta('@scheme') == 'http://fpml.org'
+ assert model.currency.get_meta('@key') == 'currency-1'
+
+ json_str = model.model_dump_json(exclude_unset=True)
+ j_dict = json.loads(json_str)
+ assert j_dict['currency']['@data'] == 'EUR'
+ assert j_dict['currency']['@scheme'] == 'http://fpml.org'
+ assert j_dict['currency']['@key'] == 'currency-1'
+
+
+def test_load_generic_string_with_meta():
+ '''load json with generic meta support'''
+ simple_json = (
+ '{"currency":{"@data":"EUR","@scheme":"http://fpml.org",'
+ '"@key":"currency-1"}}'
+ )
+ model = StrWithMetaModel.model_validate_json(simple_json)
+ assert model.currency == 'EUR'
+ assert model.currency.get_meta('@scheme') == 'http://fpml.org'
+ assert model.currency.get_meta('@key') == 'currency-1'
+
+
+def test_generic_string_with_forbidden_meta():
+ '''test exception when extra meta is passed'''
+ with pytest.raises(ValidationError):
+ StrWithMetaModel(currency=StrWithMeta(
+ 'EUR', scheme='http://fpml.org', key='currency-1', ref='blah'))
+
+
+def test_create_constrained_str_model():
+ '''test the creation of the constrained str model'''
+ model = StrWithMetaAndConstraintsModel(currency='EUR')
+ assert model.currency == 'EUR'
+
+
+def test_fail_min_create_constrained_str_model():
+ '''test the creation of the constrained str model'''
+ with pytest.raises(ValidationError):
+ StrWithMetaAndConstraintsModel(currency='EU')
+
+
+def test_fail_max_create_constrained_str_model():
+ '''test the creation of the constrained str model'''
+ with pytest.raises(ValidationError):
+ StrWithMetaAndConstraintsModel(currency='EUROOO')
+
+
+def test_fail_pattern_create_constrained_str_model():
+ '''test the creation of the constrained str model'''
+ with pytest.raises(ValidationError):
+ StrWithMetaAndConstraintsModel(currency='EUR1')
+
+
+def test_fail_create_constrained_num_model():
+ '''test the creation of the constrained str model'''
+ with pytest.raises(ValidationError):
+ ConstrainedNumberModel(amount=NumberWithMeta(-1))
+
+
+def test_annotated_enum_assignment():
+ '''test assignment'''
+ model = AnnotatedEnumModel(enum_t=EnumType.B)
+ assert model.enum_t == EnumType.B
+ assert isinstance(model.enum_t, _EnumWrapper)
+
+ model.enum_t = EnumType.C
+ assert model.enum_t != EnumType.B
+ assert isinstance(model.enum_t, _EnumWrapper)
+
+# EOF
diff --git a/test/test_complex_types_with_meta.py b/test/test_complex_types_with_meta.py
new file mode 100644
index 00000000..cdbb1d49
--- /dev/null
+++ b/test/test_complex_types_with_meta.py
@@ -0,0 +1,69 @@
+'''complex types tests'''
+from decimal import Decimal
+from typing_extensions import Annotated
+import pytest
+from pydantic import Field
+
+from rune.runtime.base_data_class import BaseDataClass
+
+
+class CashFlow(BaseDataClass):
+ '''test cashflow'''
+ currency: str = Field(...,
+ description='currency',
+ min_length=3,
+ max_length=3)
+ amount: Decimal = Field(..., description='payment amount', ge=0)
+
+
+class DummyLoan(BaseDataClass):
+ '''some more complex data structure'''
+ loan: CashFlow = Field(..., description='loaned amount')
+ repayment: CashFlow = Field(..., description='repaid amount')
+
+
+class DummyLoan2(BaseDataClass):
+ '''some more complex data structure'''
+ loan: Annotated[CashFlow,
+ CashFlow.serializer(),
+ CashFlow.validator(allowed_meta=('@key', '@ref'))] = Field(
+ ..., description='loaned amount')
+ repayment: Annotated[CashFlow,
+ CashFlow.serializer(),
+ CashFlow.validator(
+ allowed_meta=('@key', '@ref'))] = Field(
+ ..., description='repaid amount')
+
+
+def test_create_loan_no_meta():
+ '''tests the creation of a simple model - no meta'''
+ model = DummyLoan(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ json_str = model.model_dump_json(exclude_unset=True)
+ assert json_str
+
+
+def test_create_loan_no_meta_exc():
+ '''tests the creation of a simple model - no meta'''
+ model = DummyLoan(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ with pytest.raises(ValueError):
+ model.loan.set_meta(key='cf-1-1') # pylint: disable=no-member
+
+
+def test_create_loan_with_meta():
+ '''tests the creation of a simple model with some meta'''
+ model = DummyLoan2(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ model.loan.set_meta(key='cf-1-1') # pylint: disable=no-member
+ assert model.loan.get_meta('key') == 'cf-1-1' # pylint: disable=no-member
+
+
+def test_load_loan_with_meta():
+ '''test load a simple model with json with some meta'''
+ json_str = ('{"loan":{"@key":"cf-1-1","currency":"EUR","amount":"100"},'
+ '"repayment":{"currency":"EUR","amount":"101"}}')
+ model = DummyLoan2.model_validate_json(json_str)
+ assert model.loan.get_meta('key') == 'cf-1-1'
+
+# EOF
diff --git a/test/test_deep_keys_and_references.py b/test/test_deep_keys_and_references.py
new file mode 100644
index 00000000..78f1e869
--- /dev/null
+++ b/test/test_deep_keys_and_references.py
@@ -0,0 +1,72 @@
+'''test module for ref lifecycle'''
+from typing import Optional, Annotated
+from pydantic import Field
+import pytest
+from rune.runtime.metadata import Reference, KeyType
+from rune.runtime.base_data_class import BaseDataClass
+
+
+class B(BaseDataClass):
+ '''no doc'''
+ fieldB: str = Field(..., description='')
+
+
+class A(BaseDataClass):
+ '''no doc'''
+ b: Annotated[B, B.serializer(),
+ B.validator(('@key:scoped', ))] = Field(..., description='')
+
+
+class Root(BaseDataClass):
+ '''no doc'''
+ typeA: Optional[Annotated[A, A.serializer(),
+ A.validator()]] = Field(None, description='')
+ bAddress: Optional[Annotated[B,
+ B.serializer(),
+ B.validator(('@ref:scoped', ))]] = Field(
+ None, description='')
+ _KEY_REF_CONSTRAINTS = {
+ 'bAddress': {'@ref:scoped'}
+ }
+
+class DeepRef(BaseDataClass):
+ '''no doc'''
+ root: Annotated[Root, Root.serializer(),
+ Root.validator()] = Field(..., description='')
+
+
+def test_ref_creation():
+ '''no doc'''
+ b = B(fieldB='some b content')
+ a = A(b=b)
+ root = Root(typeA=a, bAddress=Reference(a.b, 'aKey', KeyType.SCOPED))
+ # pylint: disable=no-member
+ assert id(root.typeA.b) == id(root.bAddress)
+
+
+def test_deep_ref_creation():
+ '''no doc'''
+ b = B(fieldB='some b content')
+ a = A(b=b)
+ root = Root(typeA=a, bAddress=Reference(a.b, 'aKey2', KeyType.SCOPED))
+ deep_ref = DeepRef(root=root)
+ # pylint: disable=no-member
+ assert id(deep_ref.root.typeA.b) == id(deep_ref.root.bAddress)
+
+
+def test_fail_wrong_key_ext():
+ '''no doc'''
+ b = B(fieldB='some b content')
+ a = A(b=b)
+ with pytest.raises(ValueError):
+ Root(typeA=a, bAddress=Reference(a.b, 'aKey', KeyType.EXTERNAL))
+
+
+def test_fail_wrong_key_int():
+ '''no doc'''
+ b = B(fieldB='some b content')
+ a = A(b=b)
+ with pytest.raises(ValueError):
+ Root(typeA=a, bAddress=Reference(a.b))
+
+# EOF
diff --git a/test/test_keys_and_references.py b/test/test_keys_and_references.py
new file mode 100644
index 00000000..c033f07e
--- /dev/null
+++ b/test/test_keys_and_references.py
@@ -0,0 +1,322 @@
+'''test key generation/retrieval runtime functions'''
+from decimal import Decimal
+from typing_extensions import Annotated
+import pytest
+from pydantic import Field, ValidationError
+
+from rune.runtime.base_data_class import BaseDataClass
+from rune.runtime.metadata import Reference, KeyType
+from rune.runtime.metadata import NumberWithMeta, StrWithMeta
+
+
+class CashFlow(BaseDataClass):
+ '''test cashflow'''
+ _ALLOWED_METADATA = {'@key', '@key:external'}
+ currency: str = Field(...,
+ description='currency',
+ min_length=3,
+ max_length=3)
+ amount: Decimal = Field(..., description='payment amount', ge=0)
+
+
+class CashFlowNoKey(BaseDataClass):
+ '''test cashflow'''
+ currency: str = Field(...,
+ description='currency',
+ min_length=3,
+ max_length=3)
+ amount: Decimal = Field(..., description='payment amount', ge=0)
+
+
+class DummyLoanNoKey(BaseDataClass):
+ '''some more complex data structure'''
+ loan: CashFlowNoKey = Field(..., description='loaned amount')
+ repayment: CashFlowNoKey = Field(..., description='repaid amount')
+
+
+class DummyLoan(BaseDataClass):
+ '''some more complex data structure'''
+ loan: CashFlow = Field(..., description='loaned amount')
+ repayment: CashFlow = Field(..., description='repaid amount')
+
+
+class DummyLoan2(BaseDataClass):
+ '''some more complex data structure'''
+ loan: Annotated[CashFlow,
+ CashFlow.serializer(),
+ CashFlow.validator(
+ allowed_meta=('@key', '@key:external', '@ref:external',
+ '@ref'))] = Field(
+ ..., description='loaned amount')
+ repayment: Annotated[CashFlow,
+ CashFlow.serializer(),
+ CashFlow.validator(
+ allowed_meta=('@key', '@key:external',
+ '@ref:external', '@ref'))] = Field(
+ ...,
+ description='repaid amount')
+
+ _KEY_REF_CONSTRAINTS = {
+ 'loan': {'@ref', '@ref:external'},
+ 'repayment': {'@ref', '@ref:external'}
+ }
+
+class DummyLoan3(BaseDataClass):
+ '''number test class'''
+ loan: Annotated[NumberWithMeta,
+ NumberWithMeta.serializer(),
+ NumberWithMeta.validator(
+ ('@key', '@key:external'))] = Field(...,
+ description="Test amount",
+ decimal_places=3)
+ repayment: Annotated[NumberWithMeta,
+ NumberWithMeta.serializer(),
+ NumberWithMeta.validator(
+ ('@ref', '@ref:external'))] = Field(...,
+ description="Test amount",
+ decimal_places=3)
+
+ _KEY_REF_CONSTRAINTS = {
+ 'loan': {'@ref', '@ref:external'},
+ 'repayment': {'@ref', '@ref:external'}
+ }
+
+
+class DummyLoan4(BaseDataClass):
+ '''number test class'''
+ loan: Annotated[NumberWithMeta,
+ NumberWithMeta.serializer(),
+ NumberWithMeta.validator(
+ ('@key', '@key:external'))] = Field(...,
+ description="Test amount",
+ decimal_places=3)
+ repayment: Annotated[NumberWithMeta,
+ NumberWithMeta.serializer(),
+ NumberWithMeta.validator(
+ ('@ref', '@ref:external'))] = Field(...,
+ description="Test amount",
+ decimal_places=3, gt=0)
+
+ _KEY_REF_CONSTRAINTS = {
+ 'loan': {'@ref', '@ref:external'},
+ 'repayment': {'@ref', '@ref:external'}
+ }
+
+class DummyTradeParties(BaseDataClass):
+ '''number test class'''
+ party1: Annotated[StrWithMeta,
+ StrWithMeta.serializer(),
+ StrWithMeta.validator(
+ ('@key',
+ '@key:external'))] = Field(..., description="cpty1")
+ party2: Annotated[StrWithMeta,
+ StrWithMeta.serializer(),
+ StrWithMeta.validator(
+ ('@ref',
+ '@ref:external'))] = Field(..., description="cpty2")
+
+ _KEY_REF_CONSTRAINTS = {
+ 'party2': {'@ref', '@ref:external'}
+ }
+
+
+class DummyBiLoan(BaseDataClass):
+ '''more complex model'''
+ loan1: DummyLoan2
+ loan2: DummyLoan2
+
+
+def test_key_generation():
+ '''generate a key for an object'''
+ model = DummyLoan2(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ key = model.loan.get_or_create_key() # pylint: disable=no-member
+ assert key
+
+
+def test_use_ref_from_key():
+ '''test use a ref'''
+ model = DummyLoan2(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ key = model.loan.get_or_create_key() # pylint: disable=no-member
+ ref = Reference(key, key_type=KeyType.INTERNAL, parent=model)
+ # pylint: disable=protected-access
+ model._bind_property_to('repayment', ref)
+ assert id(model.loan) == id(model.repayment)
+
+
+def test_use_ref_from_object():
+ '''test use a ref'''
+ model = DummyLoan2(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ # pylint: disable=protected-access
+ model._bind_property_to('repayment', Reference(model.loan))
+ assert id(model.loan) == id(model.repayment)
+
+
+def test_bad_key_generation():
+ '''generate a key for an object which can't be referenced'''
+ model = DummyLoanNoKey(loan=CashFlowNoKey(currency='EUR', amount=100),
+ repayment=CashFlowNoKey(currency='EUR', amount=101))
+ with pytest.raises(ValueError):
+ model.loan.get_or_create_key() # pylint: disable=no-member
+
+
+def test_invalid_property():
+ '''Attempts to bind a property when not allowed'''
+ model = DummyLoan(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ model2 = DummyLoan2(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+
+ with pytest.raises(ValueError):
+ # pylint: disable=protected-access
+ model._bind_property_to('repayment', Reference(model2.loan))
+
+
+def test_ref_assign():
+ '''test use a ref'''
+ model = DummyLoan2(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ model.repayment = Reference(model.loan)
+ assert id(model.loan) == id(model.repayment)
+
+
+def test_ref_in_constructor():
+ '''test use a ref'''
+ cf = CashFlow(currency='EUR', amount=100)
+ model = DummyLoan2(loan=cf, repayment=Reference(cf))
+ assert id(model.loan) == id(model.repayment)
+
+
+def test_ref_re_assign():
+ '''test use a ref'''
+ model = DummyLoan2(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ old_cf = model.repayment
+ model.repayment = Reference(model.loan)
+ assert id(model.loan) == id(model.repayment)
+ model.repayment = old_cf
+ assert 'repayment' not in model.__dict__['__rune_references']
+ assert id(model.repayment) == id(old_cf)
+
+
+def test_ref_ext_assign():
+ '''test use a ext key and ref'''
+ model = DummyLoan2(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ model.repayment = Reference(model.loan, 'ext_key1')
+ assert id(model.loan) == id(model.repayment)
+
+
+def test_ref_ext_assign_2():
+ '''test use a ext key and ref'''
+ model = DummyLoan2(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ # pylint: disable=no-member
+ model.loan.set_external_key('ext_key3', KeyType.EXTERNAL)
+ model.repayment = Reference('ext_key3',
+ key_type=KeyType.EXTERNAL,
+ parent=model)
+ assert id(model.loan) == id(model.repayment)
+
+
+def test_init_ref_assign():
+ '''test use a ref'''
+ loan = CashFlow(currency='EUR', amount=100)
+ # repayment = Reference(loan, True)
+ model = DummyLoan2(loan=loan, repayment=loan)
+ assert id(model.loan) == id(model.repayment)
+
+
+def test_basic_ref_assign():
+ '''test use a ref'''
+ model = DummyLoan3(loan=100, repayment=101)
+ model.repayment = Reference(model.loan)
+ assert id(model.loan) == id(model.repayment)
+
+
+def test_basic_str_ref_assign():
+ '''test use a ref'''
+ model = DummyTradeParties(party1='p1', party2='p2')
+ model.party2 = Reference(model.party1)
+ assert id(model.party1) == id(model.party2)
+
+
+def test_dump_key_ref():
+ '''test dump a ref'''
+ model = DummyLoan2(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ model.repayment = Reference(model.loan)
+ dict_ = model.model_dump(exclude_unset=True)
+ assert dict_['loan']['@key'] == dict_['repayment']['@ref']
+ assert len(dict_['repayment']) == 1
+
+
+def test_dump_ref_ext():
+ '''test use a ext key and ref'''
+ model = DummyLoan2(loan=CashFlow(currency='EUR', amount=100),
+ repayment=CashFlow(currency='EUR', amount=101))
+ model.repayment = Reference(model.loan, 'ext_key2')
+ dict_ = model.model_dump(exclude_unset=True)
+ assert dict_['loan']['@key:external'] == dict_['repayment']['@ref:external']
+
+
+def test_dump_key_ref_2():
+ '''test dump a ref'''
+ model = DummyBiLoan(loan1=DummyLoan2(loan=CashFlow(currency='EUR',
+ amount=100),
+ repayment=CashFlow(currency='EUR',
+ amount=101)),
+ loan2=DummyLoan2(loan=CashFlow(currency='EUR',
+ amount=100),
+ repayment=CashFlow(currency='EUR',
+ amount=101)))
+ model.loan1.repayment = Reference(model.loan1.loan)
+ dict_ = model.model_dump(exclude_unset=True)
+ assert dict_['loan1']['loan']['@key'] == dict_['loan1']['repayment']['@ref']
+ assert len(dict_['loan1']['repayment']) == 1
+
+
+def test_load_loan_with_key_ref():
+ '''test load a simple model with json with some meta'''
+ json_str = '''{
+ "loan":{"@key":"cf-1-2","currency":"EUR","amount":"100"},
+ "repayment":{"@ref":"cf-1-2"}
+ }'''
+ model = DummyLoan2.model_validate_json(json_str)
+ assert id(model.loan) == id(model.repayment)
+
+
+def test_load_basic_type_loan_with_key_ref():
+ '''test load a simple model with json with some meta'''
+ json_str = '''{
+ "loan": {"@key":"8e50b68b-6426-44a8-bbfd-cbe3b833131c","@data":"100"},
+ "repayment":{"@ref":"8e50b68b-6426-44a8-bbfd-cbe3b833131c"}
+ }'''
+ model = DummyLoan3.model_validate_json(json_str)
+ assert id(model.loan) == id(model.repayment)
+
+
+def test_load_basic_type_loan_with_key_ref_and_constraints():
+ '''test load a simple model with json with some meta'''
+ json_str = '''{
+ "loan": {"@key":"8e50b68b-6426-44a8-bbfd-cbe3b833131a","@data":"100"},
+ "repayment":{"@ref":"8e50b68b-6426-44a8-bbfd-cbe3b833131a"}
+ }'''
+ model = DummyLoan4.model_validate_json(json_str)
+ model.validate_model()
+ assert id(model.loan) == id(model.repayment)
+
+
+def test_load_basic_type_loan_with_key_ref_and_broken_constraints():
+ '''test load a simple model with json with some meta'''
+ json_str = '''{
+ "loan": {"@key":"8e50b68b-6426-44a8-bbfd-cbe3b833131b","@data":"-100"},
+ "repayment":{"@ref":"8e50b68b-6426-44a8-bbfd-cbe3b833131b"}
+ }'''
+ model = DummyLoan4.model_validate_json(json_str)
+ with pytest.raises(ValidationError):
+ model.validate_model()
+
+# EOF
diff --git a/test/test_operations.py b/test/test_operations.py
new file mode 100644
index 00000000..9d3415ca
--- /dev/null
+++ b/test/test_operations.py
@@ -0,0 +1,212 @@
+'''Tests of various rune runtime functions'''
+import datetime
+from rune.runtime.utils import (rune_any_elements, rune_join,
+ rune_all_elements, rune_count, rune_filter,
+ rune_resolve_attr, rune_attr_exists,
+ rune_flatten_list)
+# pylint: disable=invalid-name
+
+
+def test_binary_operations():
+ '''tests some binary ops'''
+ class T:
+ '''Test class'''
+ def __init__(self):
+ self.cleared = 'Y'
+ self.counterparty1FinancialEntityIndicator = None
+ self.counterparty1FinancialEntityIndicator = None
+ self.actionType = "NEWT"
+ self.eventType = "CLRG"
+ self.originalSwapUTI = 1
+ self.originalSwapUSI = 'OKI'
+ self.openTradeStates = 2
+
+ self = T()
+ # equals
+ res = rune_all_elements(self.eventType, "=", "CLRG")
+ assert res
+ res = self.eventType == "CLRG"
+ assert res
+ # not_equals
+ res = rune_any_elements(self.actionType, "<>", "NEWT")
+ assert not res
+ res = self.actionType != "NEWT"
+ assert not res
+ # greater than
+ res = rune_all_elements(self.openTradeStates, ">", 1)
+ assert res
+
+
+def test_count_operation():
+ '''tests count function'''
+ class T:
+ '''test class'''
+ def __init__(self):
+ self.tradeState = None
+ self.openTradeStates = [self.tradeState, self.tradeState]
+ self.closedTradeStates = 1
+
+ self = T()
+ res = rune_count(self.openTradeStates)
+ assert res == 2
+
+
+def test_sum_operation():
+ '''test the sum operation'''
+ class T:
+ '''test class'''
+ def __init__(self):
+ self.tradeState = None
+ self.openTradeStates = [self.tradeState, self.tradeState]
+ self.closedTradeStates = 1
+
+ self = T()
+ res = sum(1 for ots in self.openTradeStates if ots is None)
+ assert res == 2
+
+
+def test_filter_operation_currency():
+ '''tests the filter'''
+ class T:
+ '''test class'''
+ def __init__(self):
+ self.unit1 = {'currency': "USD"}
+ self.quantity1 = {'unit': self.unit1}
+ self.unit2 = {'currency': None}
+ self.quantity2 = {'unit': self.unit2}
+ self.quantities = [self.quantity1, self.quantity2]
+
+ self = T()
+ res = rune_filter(
+ rune_resolve_attr(self, "quantities"),
+ lambda item: rune_attr_exists(
+ rune_resolve_attr(rune_resolve_attr(item, "unit"), "currency"
+ )))
+
+ assert len(res) == 1
+
+
+def test_distinct_operation():
+ '''test distinct'''
+ class T:
+ '''test class'''
+ def __init__(self):
+ self.businessCenterEnums = ["A", "B", "B", "C"]
+
+ self = T()
+ res = set(rune_resolve_attr(self, "businessCenterEnums"))
+ assert len(res) == 3
+
+
+def test_ascending_sort_operation():
+ '''test sort'''
+ class T:
+ '''test class'''
+ def __init__(self):
+ self.date1 = datetime.date(2021, 2, 2)
+ self.date2 = datetime.date(2021, 2, 4)
+ self.date3 = datetime.date(2019, 11, 24)
+ self.adjustedValuationDates = [self.date1, self.date2, self.date3]
+ self.sortedAdjustedValuationDates = None
+
+ self = T()
+ self.sortedAdjustedValuationDates = sorted(self.adjustedValuationDates)
+ firstExpectedDate = datetime.date(2019, 11, 24)
+ assert self.sortedAdjustedValuationDates[0] == firstExpectedDate
+
+
+def test_descending_sort_operation():
+ '''tets sort'''
+ class T:
+ '''test class'''
+ def __init__(self):
+ self.date1 = datetime.date(2021, 2, 2)
+ self.date2 = datetime.date(2021, 2, 4)
+ self.date3 = datetime.date(2019, 11, 24)
+ self.adjustedValuationDates = [self.date1, self.date2, self.date3]
+ self.sortedAdjustedValuationDates = None
+
+ self = T()
+ self.sortedAdjustedValuationDates = sorted(self.adjustedValuationDates,
+ reverse=True)
+ firstExpectedDate = datetime.date(2021, 2, 4)
+ assert self.sortedAdjustedValuationDates[0] == firstExpectedDate
+
+
+def test_last_operation():
+ '''test get last element'''
+ class T:
+ '''test class'''
+ def __init__(self):
+ self.date1 = datetime.date(2021, 2, 2)
+ self.date2 = datetime.date(2021, 2, 4)
+ self.date3 = datetime.date(2019, 11, 24)
+ self.adjustedValuationDates = [self.date1, self.date2, self.date3]
+ self.sortedAdjustedValuationDates = None
+
+ self = T()
+ self.sortedAdjustedValuationDates = sorted(self.adjustedValuationDates,
+ reverse=True)
+ expectedLastDate = datetime.date(2019, 11, 24)
+ assert self.sortedAdjustedValuationDates[-1] == expectedLastDate
+
+
+def test_flatten_operation():
+ '''test the flatten operation'''
+ class T:
+ '''test class'''
+ def __init__(self):
+ self.date1 = datetime.date(2021, 2, 2)
+ self.date2 = datetime.date(2021, 2, 4)
+ self.date3 = datetime.date(2019, 11, 24)
+ self.date4 = datetime.date(2024, 4, 15)
+ self.adjustedValuationDates1 = [self.date1, self.date2]
+ self.adjustedValuationDates2 = [self.date3, self.date4]
+ self.adjustedValuationDates = [
+ self.adjustedValuationDates1, self.adjustedValuationDates2
+ ]
+
+ self = T()
+ res = rune_flatten_list(self.adjustedValuationDates)
+ assert len(res) == 4
+
+
+def test_reverse_operation():
+ '''reverse function'''
+ class T:
+ '''test class'''
+ def __init__(self):
+ self.businessCenters = ['AEAB', 'BBBR', 'INKO']
+
+ self = T()
+ res = list(reversed(self.businessCenters))
+ assert res[0] == 'INKO'
+
+
+def test_join_operation():
+ '''test the joi function'''
+ class T:
+ '''test class'''
+ def __init__(self):
+ self.businessCenters = ['AEAB', 'BBBR', 'INKO']
+
+ self = T()
+ res = rune_join(self.businessCenters, 'CAVA')
+ assert 'CAVA' in res
+
+
+if __name__ == '__main__':
+ test_binary_operations()
+ test_join_operation()
+ test_last_operation()
+ test_sum_operation()
+ test_ascending_sort_operation()
+ test_descending_sort_operation()
+ test_count_operation()
+ test_distinct_operation()
+ test_flatten_operation()
+ test_reverse_operation()
+ test_filter_operation_currency()
+ print('...passed')
+
+# EOF
diff --git a/test/test_parametrized_basic_types.py b/test/test_parametrized_basic_types.py
new file mode 100644
index 00000000..28202c7c
--- /dev/null
+++ b/test/test_parametrized_basic_types.py
@@ -0,0 +1,77 @@
+'''test module for the parametrized rune basic types'''
+from decimal import Decimal
+import pytest
+from pydantic import Field, ValidationError
+
+from rune.runtime.base_data_class import BaseDataClass
+
+
+class NumberWithConstraintsModel(BaseDataClass):
+ '''test class for parametrized numbers'''
+ amount: Decimal = Field(...,
+ description='a test amount',
+ max_digits=5,
+ decimal_places=3,
+ ge=0,
+ le=98)
+
+
+class StringWithConstraintsModel(BaseDataClass):
+ '''string constraints test case'''
+ currency: str = Field(...,
+ description="Test currency",
+ min_length=3,
+ max_length=5,
+ pattern=r'^[A-Z]*$')
+
+
+def test_excess_decimal_places_number():
+ '''test various conditions'''
+ with pytest.raises(ValidationError):
+ NumberWithConstraintsModel(amount=1.2001)
+
+
+def test_excess_max_digits_number():
+ """ should fail as decimal is 3 and max digits is 5 - digits can't be more
+ than 2
+ """
+ with pytest.raises(ValidationError):
+ NumberWithConstraintsModel(amount=100)
+
+
+def test_excess_max_val():
+ '''test max value condition'''
+ with pytest.raises(ValidationError):
+ NumberWithConstraintsModel(amount=98.1)
+
+
+def test_excess_min_val():
+ '''test max value condition'''
+ with pytest.raises(ValidationError):
+ NumberWithConstraintsModel(amount=-1)
+
+
+def test_create_str_model():
+ '''test the creation of the constrained str model'''
+ model = StringWithConstraintsModel(currency='EUR')
+ assert model.currency == 'EUR'
+
+
+def test_fail_min_create_constrained_str_model():
+ '''test the creation of the constrained str model'''
+ with pytest.raises(ValidationError):
+ StringWithConstraintsModel(currency='EU')
+
+
+def test_fail_max_create_constrained_str_model():
+ '''test the creation of the constrained str model'''
+ with pytest.raises(ValidationError):
+ StringWithConstraintsModel(currency='EUROOO')
+
+
+def test_fail_pattern_create_constrained_str_model():
+ '''test the creation of the constrained str model'''
+ with pytest.raises(ValidationError):
+ StringWithConstraintsModel(currency='EUR1')
+
+# EOF
diff --git a/test/test_rune_parent.py b/test/test_rune_parent.py
new file mode 100644
index 00000000..0b8bbd6f
--- /dev/null
+++ b/test/test_rune_parent.py
@@ -0,0 +1,195 @@
+'''test module for rune root lifecycle'''
+from typing import Optional, Annotated
+from pydantic import Field
+from rune.runtime.metadata import Reference, KeyType
+from rune.runtime.base_data_class import BaseDataClass
+
+
+class B(BaseDataClass):
+ '''no doc'''
+ fieldB: str = Field(..., description='')
+
+
+class A(BaseDataClass):
+ '''no doc'''
+ b: Annotated[B, B.serializer(),
+ B.validator(('@key:scoped', ))] = Field(..., description='')
+
+
+class Root(BaseDataClass):
+ '''no doc'''
+ typeA: Optional[Annotated[A, A.serializer(),
+ A.validator()]] = Field(None, description='')
+ bAddress: Optional[Annotated[B,
+ B.serializer(),
+ B.validator(('@ref:scoped', ))]] = Field(
+ None, description='')
+ _KEY_REF_CONSTRAINTS = {
+ 'bAddress': {'@ref:scoped'}
+ }
+
+
+class Bplus(BaseDataClass):
+ '''no doc'''
+ bAddress: Optional[Annotated[B,
+ B.serializer(),
+ B.validator(('@ref:scoped', ))]] = Field(
+ None, description='')
+
+ _KEY_REF_CONSTRAINTS = {
+ 'bAddress': {'@ref:scoped'}
+ }
+
+
+class RootDeep(BaseDataClass):
+ '''no doc'''
+ typeA: Optional[Annotated[A, A.serializer(),
+ A.validator()]] = Field(None, description='')
+ bplus: Optional[Annotated[Bplus,
+ Bplus.serializer(),
+ Bplus.validator()]] = Field(None, description='')
+
+
+class DeepRef(BaseDataClass):
+ '''no doc'''
+ _FQRTN = 'test_rune_parent.DeepRef'
+ root: Annotated[Root, Root.serializer(),
+ Root.validator()] = Field(..., description='')
+
+
+class DeepRef2(BaseDataClass):
+ '''no doc'''
+ root: Annotated[Root, Root.serializer(),
+ Root.validator()] = Field(..., description='')
+ root2: Annotated[Root, Root.serializer(),
+ Root.validator()] = Field(..., description='')
+
+
+def test_root_creation():
+ '''no doc'''
+ b = B(fieldB='some b content')
+ a = A(b=b)
+ root = Root(typeA=a, bAddress=Reference(a.b, 'aKey3', KeyType.SCOPED))
+ # pylint: disable=no-member
+ assert root.get_rune_parent() is None
+ assert root == root.typeA.get_rune_parent()
+ assert root.typeA == root.typeA.b.get_rune_parent()
+ assert root.typeA == root.bAddress.get_rune_parent()
+ assert root.typeA.b == root.bAddress
+
+
+def test_deep_creation():
+ '''no doc'''
+ b = B(fieldB='some b content')
+ a = A(b=b)
+ root = Root(typeA=a, bAddress=Reference(a.b, 'aKey3', KeyType.SCOPED))
+ deep = DeepRef(root=root)
+ # pylint: disable=no-member
+ assert deep.get_rune_parent() is None
+ assert deep == deep.root.get_rune_parent()
+ assert deep.root == deep.root.typeA.get_rune_parent()
+ assert deep.root.typeA == deep.root.typeA.b.get_rune_parent()
+ assert deep.root.typeA == deep.root.bAddress.get_rune_parent()
+ assert deep.root.typeA.b == deep.root.bAddress
+
+
+def test_deep2_creation(mocker):
+ '''no doc'''
+ mocker.patch('rune.runtime.metadata.BaseMetaDataMixin._DEFAULT_SCOPE_TYPE',
+ 'test_rune_parent.Root')
+ b = B(fieldB='some b content')
+ a = A(b=b)
+ b2 = B(fieldB='2 some other b content')
+ a2 = A(b=b2)
+ root = Root(typeA=a, bAddress=Reference(a.b, 'aKey3', KeyType.SCOPED))
+ root2 = Root(typeA=a2, bAddress=Reference(a2.b, 'aKey3', KeyType.SCOPED))
+ deep = DeepRef2(root=root, root2=root2)
+ # pylint: disable=no-member
+ assert deep.get_rune_parent() is None
+ assert deep == deep.root.get_rune_parent()
+ assert deep.root == deep.root.typeA.get_rune_parent()
+ assert deep.root.typeA == deep.root.typeA.b.get_rune_parent()
+ assert deep.root.typeA == deep.root.bAddress.get_rune_parent()
+ assert deep.root.typeA.b == deep.root.bAddress
+
+ assert deep == deep.root2.get_rune_parent()
+ assert deep.root2 == deep.root2.typeA.get_rune_parent()
+ assert deep.root2.typeA == deep.root2.typeA.b.get_rune_parent()
+ assert deep.root2.typeA == deep.root2.bAddress.get_rune_parent()
+ assert deep.root2.typeA.b == deep.root2.bAddress
+
+
+def test_root_deserialization():
+ '''no doc'''
+ rune_dict = {
+ "bAddress": {
+ "@ref:scoped": "aKey3"
+ },
+ "typeA": {
+ "b": {
+ "@key:scoped": "aKey3",
+ "fieldB": "some b content"
+ }
+ },
+ }
+ root = Root.model_validate(rune_dict)
+ assert root.get_rune_parent() is None
+ assert root == root.typeA.get_rune_parent()
+ assert root.typeA == root.typeA.b.get_rune_parent()
+ assert root.typeA == root.bAddress.get_rune_parent()
+ assert root.typeA.b == root.bAddress
+
+
+
+def test_root_deep_deserialization():
+ '''no doc'''
+ rune_dict = {
+ "bplus": {
+ "bAddress": {
+ "@ref:scoped": "aKey3"
+ }
+ },
+ "typeA": {
+ "b": {
+ "@key:scoped": "aKey3",
+ "fieldB": "some b content"
+ }
+ },
+ # "bplus": {
+ # "bAddress": {
+ # "@ref:scoped": "aKey3"
+ # }
+ # },
+ }
+ root = RootDeep.rune_deserialize(rune_dict)
+ assert root.get_rune_parent() is None
+ assert root == root.typeA.get_rune_parent()
+ assert root.typeA == root.typeA.b.get_rune_parent()
+ assert root.typeA == root.bplus.bAddress.get_rune_parent()
+ assert root.typeA.b == root.bplus.bAddress
+
+
+def test_deep_deserialization():
+ '''no doc'''
+ rune_dict = {
+ "root": {
+ "bAddress": {
+ "@ref:scoped": "aKey3"
+ },
+ "typeA": {
+ "b": {
+ "@key:scoped": "aKey3",
+ "fieldB": "some b content"
+ }
+ },
+ }
+ }
+ deep = DeepRef.model_validate(rune_dict)
+ assert deep.get_rune_parent() is None
+ assert deep == deep.root.get_rune_parent()
+ assert deep.root == deep.root.typeA.get_rune_parent()
+ assert deep.root.typeA == deep.root.typeA.b.get_rune_parent()
+ assert deep.root.typeA == deep.root.bAddress.get_rune_parent()
+ assert deep.root.typeA.b == deep.root.bAddress
+
+# EOF
diff --git a/test/test_rune_str.py b/test/test_rune_str.py
new file mode 100644
index 00000000..3956b428
--- /dev/null
+++ b/test/test_rune_str.py
@@ -0,0 +1,31 @@
+'''enum conversion tests'''
+from enum import Enum
+from rune.runtime.utils import rune_str
+
+
+def test_simple_conv():
+ '''test simple conversions'''
+ assert "1" == rune_str(1)
+ assert "9.5" == rune_str(9.5)
+ assert "abcdef" == rune_str("abcdef")
+
+
+def test_enum_str():
+ '''test the stringification of an enum'''
+ class Test(Enum):
+ '''mixture of string and non string values'''
+ _1 = 'One'
+ _2 = 2
+
+ x = Test('One')
+ assert x.value == rune_str(x)
+
+ x = Test(2)
+ assert '2' == rune_str(x)
+
+
+if __name__ == '__main__':
+ test_simple_conv()
+ test_enum_str()
+
+# EOF