-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
ifex_construction = support for building IFEX AST trees
Signed-off-by: Gunnar Andersson <[email protected]>
- Loading branch information
Showing
2 changed files
with
273 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <something>-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)) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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("---------------------------------------------------------------") | ||
|