diff --git a/ifex/model/ifex_ast_construction.py b/ifex/model/ifex_ast_construction.py new file mode 100644 index 0000000..21b06db --- /dev/null +++ b/ifex/model/ifex_ast_construction.py @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +""" +Light-weight support functions for creating an IFEX tree from program code, +likely to be used in X-to-IFEX model conversions. +""" + +# This module supports creating of an IFEX internal tree from python code. It +# is likely to be used in a -to-IFEX (model-to-model) conversion. +# It also adds a printout function so that an internal IFEX tree "AST" +# representation can be printed out in the IFEX Core IDL format (in YAML) + +# Many programs that need to create IFEX are better off using a input-to-model +# or model-to-model transformation (build the IFEX tree internally, and *then* +# print text), compared to immediately printing IFEX core IDL text (YAML). + +# This code gives primitive but useful support. It is simply implemented by +# adding constructor (__init__) functions for the @dataclass node definitions +# in ifex_ast.py. Object creation was of course already possible because +# @dataclasses have automatic __init__ functions. However, using the +# type_checking_constructor_mixin code, it performs some type-checking of +# the given inputs, which helps to avoid simply printing out a non-compliant +# YAML document. + +# With __init__ it is possible to create an object tree in a straight forward +# and expected way, including some type checks: +# +# from ifex.model import ifex_ast_construction +# from ifex.model.ifex_ast import Namespace, Interface, ... +# +# # Initialize support: +# ifex_ast_construction.add_constructors_to_ifex_ast_model() +# +# # Create objects and link them together. +# ns = Namespace('mynamespacename', description = 'this is it') +# if = Interface('the-interface-node') +# ns.interface = if +# +# # (Re)assign member fields on any object +# ns.interface.methods = [... method objects...] +# +# etc. + +from collections import OrderedDict +from dataclasses import is_dataclass, fields +from ifex.model import ifex_ast +from ifex.model.type_checking_constructor_mixin import add_constructor + +# Use the oyaml library because it supports OrderedDict. We can output keys in +# the order they are defined in the AST classes (e.g. "name" comes first!) +import oyaml + +def add_constructors_to_ifex_ast_model() -> None: + """ Mix in the type-checking constructor support into each of the ifex_ast classes: """ + for c in [cls for cls in ifex_ast.__dict__.values() if + isinstance(cls, type) and + is_dataclass(cls)]: + add_constructor(c) + + +def is_empty(node) -> bool: + if type(node) is str: + return node == "" + elif type(node) is list: + return node == [] + else: + return node is None + + +def ifex_ast_to_dict(node, debug_context="") -> OrderedDict: + + """Given a root node, return a key-value mapping dict (which represents the YAML equivalent). The function is recursive. """ + + if node is None: + raise TypeError(f"None-value should not be passed to function, {debug_context=}") + + # Strings and Ints are directly understood by the YAML output printer so just put them in. + if type(node) in [str, int]: + return node + + # In addition to dicts, we might have python lists, which will be output as lists in YAML + #if is_list(node) or type(node) == list: + if type(node) == list: + ret = [] + for listitem in node: + ret.append(ifex_ast_to_dict(listitem, debug_context=str(node))) + return ret + + # New dict containing all key-value pairs at this level + ret = OrderedDict() + + # Recursively process all fields in this object type. + # Empty fields should not be unnecessarily listed in the resulting YAML, so + # we skip them. Note that empty items can happen only on fields that are + # Optional, otherwise the type-checking constructor would have caught the + # error. + + for f in fields(node): + item = getattr(node, f.name) + if not is_empty(item): + ret[f.name] = ifex_ast_to_dict(item, debug_context=str(f)) + + return ret + + +def ifex_ast_as_yaml(node): + return oyaml.dump(ifex_ast_to_dict(node)) + +# ----------------- TEST CODE BELOW -------------------- + +if __name__ == '__main__': + from ifex.model.ifex_ast import AST, Namespace, Interface, Method, Argument + + # How to create a AST representation in code: + root = AST('test') + ns1 = Namespace('name_ns1') + root.namespaces = [ns1, Namespace('another_empty_namespace')] + if_a = Interface('name_if_a') + if_a.methods.append(Method('mymethod')) + m = Method('mymethod2', description = "This is the second method", input = [Argument(name='val', datatype='uint32')]) + if_a.methods.append(m) + ns1.interface = if_a + + print("\n--- Test objects converted to dict: ---") + print(ifex_ast_to_dict(root)) + print("\n--- Test objects as YAML: ---") + print(ifex_ast_as_yaml(root)) + diff --git a/ifex/model/type_checking_constructor_mixin.py b/ifex/model/type_checking_constructor_mixin.py new file mode 100644 index 0000000..ed36cb2 --- /dev/null +++ b/ifex/model/type_checking_constructor_mixin.py @@ -0,0 +1,144 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 MBition GmbH. +# SPDX-License-Identifier: MPL-2.0 + +""" +This module adds a type-checking constructor to dataclass definitions if they have type hints using the python typing module. +""" + +from dataclasses import dataclass, fields +from typing import get_type_hints, List, Optional +from ifex.model.ifex_ast_introspect import is_list, actual_type, inner_type, field_is_optional + +def is_correct_type(value, _type): + if type(value) is list and is_list(_type): + # In standard python code values placed _inside_ a list could be of any + # type (and different types!) We want to check that the specification + # is fulfilled, so we check that all values in list have the right type: + expected_type = inner_type(_type) + return all(type(v) == expected_type for v in value) + else: # Non-list field, just check single type: + return type(value) == actual_type(_type) + + +def add_constructor(cls): + """ + Adds a type-checking constructor (__init__) to a named dataclass, based on its member fields, and their type specifications. + """ + # Get the names and types of the member variables (fields) in the dataclass + arg_names = [f.name for f in fields(cls)] + arg_types = get_type_hints(cls) # (dict mapping name->type) + + # Store original init function. It will be called from within the new constructor and + # because of closure magic, the correct one will be called. + orig_init = cls.__init__ + + # Define the constructor function + def type_checking_constructor(self, *args, **kwargs): + + # Initialize object using the original __init__ first - this calls the default_factory stuff, for example + orig_init(self, *args, **kwargs) + + # First check that enough args are given + if len([f for f in fields(cls) if not field_is_optional(f)]) > (len(args) + len(kwargs)): + raise TypeError(f'Object construction error: Not all mandatory arguments were given, through positional or keyword arguments') + + # Now check arguments against their expected type: + + # 1. Check positional arguments with the name/value zip. It works because + # positional args are required to be in the same order as the fields. + # The zip function will create pairs, only for as many args that are + # given in *args and then stop. Therefore, this checks each of the + # positional args against the corresponding field. Any fields that + # remain are either optional (a default value in the dataclass will + # remain), or given as keyword arguments. + # 2. Next, check keyword arguments using the **kwargs (name/value pairs) + # 3. Since the check is identical for both, it is combined into one loop. + + for name, value in list(zip(arg_names, args)) + list(kwargs.items()): + if not is_correct_type(value, arg_types[name]): + raise TypeError(f'Object construction error: According to specification, value named \'{name}\' must be of type {arg_types[name]}, but instead {type(value)=}.') + + # Assign field value + setattr(self, name, value) + + + + # Add the constructor to the metaclass + cls.__init__ = type_checking_constructor + + return cls + + +# ---- TEST CODE ---- +if __name__ == '__main__': + + @add_constructor + @dataclass + class Special: + s: Optional[str] = 'special' + i: Optional[int] = 42 + + @add_constructor + @dataclass + class Person: + name: str + height: float + hobbies: List[str] + age: Optional[int] = 99 + special: Optional[Special] = None + + @dataclass + class Test: + name: str + height: float + hobbies: List[str] + age: Optional[int] = 99 + special: Optional[Special] = None + + # Example of a correct object + p = Person('Alice', 1.75, ['reading', 'swimming'], age=25) + print("If no error then this passed: ", p.name, p.age, p.height, p.hobbies, p.special) + + # Example of a correct object, with an inner complex type + p = Person('Bob', 1.75, ['reading', 'swimming'], 30, Special('foo',1000)) + print("If no error then this passed: ", p.name, p.age, p.height, p.hobbies, p.special) + + # Example of a correct object, skipping optional values + p = Person('Celine', 1.75, ['reading', 'swimming']) + print("If no error then this passed: ", p.name, p.age, p.height, p.hobbies, p.special) + + # Example of a correct object, with default values + p = Person('Darwin', 1.75, ['reading', 'swimming'], 45, Special('bar')) + print("If no error then this passed: ", p.name, p.age, p.height, p.hobbies, p.special) + + # Example of a correct object, using keyword arguments + p = Person('Eric', 1.75, special = Special(), age = 55, hobbies = ['reading', 'swimming']) + print("If no error then this passed: ", p.name, p.age, p.height, p.hobbies, p.special) + + # This example raises TypeError because we not all mandatory fields are specified + try: + p = Person('Fiona', hobbies = ['boxing']) + except TypeError as e: + print("---------------------------------------------------------------") + print(f"=> Passed test because we caught an /expected/ type exception:") + print(" " + str(e)) + print("---------------------------------------------------------------") + + # This example raises TypeError because age '35' is given as string instead of int: + try: + p = Person('George', 1.80, ['hiking', 'biking'], '35') + except TypeError as e: + print("---------------------------------------------------------------") + print(f"=> Passed test because we caught an /expected/ type exception:") + print(" " + str(e)) + print("---------------------------------------------------------------") + + # This example raises TypeError because we hobbies has a non-string element + try: + p = Person('Hilda', 1.85, ['running', 'basketball', 42], 45) + except TypeError as e: + print("---------------------------------------------------------------") + print(f"=> Passed test because we caught an /expected/ type exception:") + print(" " + str(e)) + print("---------------------------------------------------------------") +