Skip to content

Commit

Permalink
ifex_construction = support for building IFEX AST trees
Browse files Browse the repository at this point in the history
Signed-off-by: Gunnar Andersson <[email protected]>
  • Loading branch information
gunnar-mb committed Jan 23, 2024
1 parent 01eae46 commit 5f79e1f
Show file tree
Hide file tree
Showing 2 changed files with 273 additions and 0 deletions.
129 changes: 129 additions & 0 deletions ifex/model/ifex_ast_construction.py
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))

144 changes: 144 additions & 0 deletions ifex/model/type_checking_constructor_mixin.py
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("---------------------------------------------------------------")

0 comments on commit 5f79e1f

Please sign in to comment.