Skip to content

Commit

Permalink
Merge pull request #8 from linkml/transform_examples
Browse files Browse the repository at this point in the history
Simple example transforms
  • Loading branch information
cmungall authored May 27, 2023
2 parents df40ae6 + b7319dc commit 9066675
Show file tree
Hide file tree
Showing 17 changed files with 713 additions and 152 deletions.
20 changes: 19 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ readme = "README.md"
[tool.poetry.dependencies]
python = "^3.8"
linkml-runtime = ">=1.5.2"
asteval = "^0.9.29"

[tool.poetry.dev-dependencies]
pytest = "^7.3.1"
Expand Down
28 changes: 24 additions & 4 deletions src/linkml_transformer/compiler/python_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,41 @@
{%- if sd.populated_from -%}
source_object.{{ sd.populated_from }}
{%- elif sd.expr -%}
{%- if '\n' in sd.expr -%}
gen_{{ sd.name }}(source_object)
{%- elif '{' in sd.expr and '}' in sd.expr -%}
{{ sd.expr|replace('{', '')|replace('}', '') }}
{%- else -%}
{{ sd.expr }}
{%- endif -%}
{%- else -%}
None
{%- endif -%}
{%- endif -%}
{%- endmacro %}
{% macro gen_slot_derivation_defs(sd) -%}
{% if sd.expr and '\n' in sd.expr %}
def gen_{{ sd.name }}(src):
target = None
{%- for line in sd.expr.split('\n') %}
{{ line }}
{%- endfor -%}
return target
{% endif %}
{%- endmacro %}
def derive_{{ cd.name }}(
source_object: {{ source_module }}.{{ cd.populated_from }}
) -> {{ target_module }}.{{ cd.name }}:
{%- for sd in cd.slot_derivations.values() -%}
{{ gen_slot_derivation_defs(sd) }}
{%- endfor %}
return {{ cd.populated_from }}(
{%- for sd in cd.slot_derivations.values() %}
{{ sd.name }}={{ gen_slot_derivation(sd) }},
{%- endfor %}
{%- for sd in cd.slot_derivations.values() %}
{{ sd.name }}={{ gen_slot_derivation(sd) }},
{%- endfor %}
)
"""


Expand Down
201 changes: 201 additions & 0 deletions src/linkml_transformer/transformer/eval_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""
meta-circular interpreter for evaluating python expressions
- See `<https://stackoverflow.com/questions/2371436/evaluating-a-mathematical-expression-in-a-string>`_
"""

import ast
import operator as op

# supported operators
from typing import Any, List, Tuple

operators = {
ast.Add: op.add,
ast.Sub: op.sub,
ast.Mult: op.mul,
ast.Div: op.truediv,
ast.Pow: op.pow,
ast.BitXor: op.xor,
ast.USub: op.neg,
}
compare_operators = {ast.Eq: op.eq, ast.Lt: op.lt, ast.LtE: op.le, ast.Gt: op.gt, ast.GtE: op.ge}


def eval_conditional(*conds: List[Tuple[bool, Any]]) -> Any:
"""
>>> cond(x < 25 : 'low', x > 25 : 'high', True: 'low')
:param subj:
:return:
"""
for is_true, val in conds:
if is_true:
return val


funcs = {
"max": (True, max),
"min": (True, min),
"len": (True, len),
"str": (False, str),
"strlen": (False, len),
"case": (False, eval_conditional),
}


class UnsetValueError(Exception):
pass


def eval_expr(expr: str, **kwargs) -> Any:
"""
Evaluates a given expression, with restricted syntax
>>> eval_expr('2^6')
4
>>> eval_expr('2**6')
64
>>> eval_expr('1 + 2*3**(4^5) / (6 + -7)')
-5.0
Variables:
variables can be passed
>>> eval_expr('{x} + {y}', x=1, y=2)
3
Nulls:
- If a variable is enclosed in {}s then entire expression will eval to None if variable is unset
>>> eval_expr('{x} + {y}', x=None, y=2)
None
Functions:
- only a small set of functions are currently supported. All SPARQL functions will be supported in future
>>> eval_expr('strlen("a" + "bc")')
3
Paths:
- Expressions such as `person.name` can be used on objects to lookup by attribute/slot
- Paths can be chained, e.g. `person.address.street`
- Operations on lists are distributed, e.g `container.persons.name` will return a list of names
- Similarly `strlen(container.persons.name)` will return a list whose members are the lengths of all names
:param expr: expression to evaluate
"""
# if kwargs:
# expr = expr.format(**kwargs)
if expr == "None":
# TODO: do this as part of parsing
return None
try:
return eval_(ast.parse(expr, mode="eval").body, kwargs)
except UnsetValueError:
return None


def eval_(node, bindings=None):
if isinstance(node, ast.Num):
return node.n
elif isinstance(node, ast.Str):
if "s" in vars(node):
return node.s
else:
return node.value
elif isinstance(node, ast.Constant):
return node.value
elif isinstance(node, ast.NameConstant):
# can be removed when python 3.7 is no longer supported
return node.value
elif isinstance(node, ast.Name):
if not bindings:
bindings = {}
return bindings.get(node.id)
elif isinstance(node, ast.Subscript):
if isinstance(node.slice, ast.Index):
# required for python 3.7
k = eval_(node.slice.value, bindings)
else:
k = eval_(node.slice, bindings)
v = eval_(node.value, bindings)
return v[k]
elif isinstance(node, ast.Attribute):
# e.g. for person.name, this returns the val of person
v = eval_(node.value, bindings)

# lookup attribute, potentially distributing the results over collections
def _get(obj: Any, k: str, recurse=True) -> Any:
if isinstance(obj, dict):
# dicts are treated as collections; distribute results
if recurse:
return [_get(e, k, False) for e in obj.values()]
else:
return obj.get(k)
elif isinstance(obj, list):
# attributes are distributed over lists
return [_get(e, k, False) for e in obj]
elif obj is None:
return None
else:
return getattr(obj, k)

return _get(v, node.attr)
elif isinstance(node, ast.List):
return [eval_(x, bindings) for x in node.elts]
elif isinstance(node, ast.Set):
# sets are not part of the language; we use {x} as notation for x
if len(node.elts) != 1:
raise ValueError("The {} must enclose a single variable")
e = node.elts[0]
if not isinstance(e, ast.Name):
raise ValueError("The {} must enclose a variable")
v = eval_(e, bindings)
if v is None:
raise UnsetValueError(f"{e} is not set")
else:
return v
elif isinstance(node, ast.Tuple):
return tuple([eval_(x, bindings) for x in node.elts])
elif isinstance(node, ast.Dict):
return {eval_(k, bindings): eval_(v, bindings) for k, v in zip(node.keys, node.values)}
elif isinstance(node, ast.Compare): # <left> <operator> <right>
if len(node.ops) != 1:
raise ValueError(f"Must be exactly one op in {node}")
if type(node.ops[0]) not in compare_operators:
raise NotImplementedError(f"Not implemented: {node.ops[0]} in {node}")
py_op = compare_operators[type(node.ops[0])]
if len(node.comparators) != 1:
raise ValueError(f"Must be exactly one comparator in {node}")
right = node.comparators[0]
return py_op(eval_(node.left, bindings), eval_(right, bindings))
elif isinstance(node, ast.BinOp): # <left> <operator> <right>
return operators[type(node.op)](eval_(node.left, bindings), eval_(node.right, bindings))
elif isinstance(node, ast.UnaryOp): # <operator> <operand> e.g., -1
return operators[type(node.op)](eval_(node.operand, bindings))
# elif isinstance(node, ast.Match):
# # implementing this would restrict python version to 3.10
# # https://stackoverflow.com/questions/60208/replacements-for-switch-statement-in-python
# raise NotImplementedError(f'Not supported')
elif isinstance(node, ast.IfExp):
if eval_(node.test, bindings):
return eval_(node.body, bindings)
else:
return eval_(node.orelse, bindings)
elif isinstance(node, ast.Call):
if isinstance(node.func, ast.Name):
fn = node.func.id
if fn in funcs:
takes_list, func = funcs[fn]
args = [eval_(x, bindings) for x in node.args]
if isinstance(args[0], list) and not takes_list:
return [func(*[x] + args[1:]) for x in args[0]]
else:
return func(*args)
raise NotImplementedError(f"Call {node.func} not implemented. node = {node}")
else:
raise TypeError(node)
42 changes: 32 additions & 10 deletions src/linkml_transformer/transformer/object_transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
from dataclasses import dataclass
from typing import Any, Dict, Optional, Type, Union

from asteval import Interpreter
from linkml_runtime.index.object_index import ObjectIndex
from linkml_runtime.utils.eval_utils import eval_expr
from linkml_runtime.utils.yamlutils import YAMLRoot
from pydantic import BaseModel

from linkml_transformer.transformer.eval_utils import eval_expr
from linkml_transformer.transformer.transformer import OBJECT_TYPE, Transformer
from linkml_transformer.utils.dynamic_object import dynamic_object

Expand Down Expand Up @@ -54,9 +55,10 @@ def transform(
"""
Transform a source object into a target object.
:param source_obj:
:param source_type:
:return:
:param source_obj: source data structure
:param source_type: name of the object type to cast the source object as
:param target_type: type to return the transformed object as
:return: transformed data, either as type target_type or a dictionary
"""
sv = self.source_schemaview
if source_type is None:
Expand All @@ -77,11 +79,10 @@ def transform(
if source_type in sv.all_enums():
# TODO: enum derivations
return str(source_obj)
source_obj_typed = None
if isinstance(source_obj, (BaseModel, YAMLRoot)):
source_obj_typed = source_obj
source_obj = vars(source_obj)
else:
source_obj_typed = None
if not isinstance(source_obj, dict):
logger.warning(f"Unexpected: {source_obj} for type {source_type}")
return source_obj
Expand Down Expand Up @@ -110,8 +111,16 @@ def transform(
}
else:
do = dynamic_object(source_obj, sv, source_type)
ctxt_obj = do
ctxt_dict = vars(do)
v = eval_expr(slot_derivation.expr, **ctxt_dict, NULL=None)

try:
v = eval_expr(slot_derivation.expr, **ctxt_dict, NULL=None)
except Exception:
aeval = Interpreter(usersyms={"src": ctxt_obj, "target": None})
aeval(slot_derivation.expr)
v = aeval.symtable["target"]

else:
source_class_slot = sv.induced_slot(slot_derivation.name, source_type)
v = source_obj.get(slot_derivation.name, None)
Expand Down Expand Up @@ -150,13 +159,26 @@ def transform_object(
source_obj: Union[YAMLRoot, BaseModel],
target_class: Optional[Union[Type[YAMLRoot], Type[BaseModel]]] = None,
) -> Union[YAMLRoot, BaseModel]:
typ = type(source_obj)
typ_name = typ.__name__
"""
Transform an object into an object of class target_class.
:param source_obj: source object
:type source_obj: Union[YAMLRoot, BaseModel]
:param target_class: class to transform the object into, defaults to None
:type target_class: Optional[Union[Type[YAMLRoot], Type[BaseModel]]], optional
:return: transformed object of class target_class
:rtype: Union[YAMLRoot, BaseModel]
"""
if not target_class:
raise ValueError("No target_class specified for transform_object")

source_type = type(source_obj)
source_type_name = source_type.__name__
# if isinstance(source_obj, YAMLRoot):
# source_obj_dict = json_dumper.to_dict(source_obj)
# elif isinstance(source_obj, BaseModel):
# source_obj_dict = source_obj.dict()
# else:
# raise ValueError(f"Do not know how to handle type: {typ}")
tr_obj_dict = self.transform(source_obj, typ_name)
tr_obj_dict = self.transform(source_obj, source_type_name)
return target_class(**tr_obj_dict)
Loading

0 comments on commit 9066675

Please sign in to comment.