From c43a3ef9460d21e6d05a94265f8cb08df75ddc6c Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Thu, 11 Jun 2020 17:36:43 +0200 Subject: [PATCH] In PythonEvaluator, I now evaluate concept and/or concept body --- src/core/sheerka/Sheerka.py | 1 + src/core/utils.py | 28 ++++ src/evaluators/PythonEvaluator.py | 184 +++++++++++++++-------- tests/core/test_utils.py | 19 ++- tests/evaluators/test_PythonEvaluator.py | 95 +++++++----- tests/non_reg/test_sheerka_non_reg.py | 1 + 6 files changed, 226 insertions(+), 102 deletions(-) diff --git a/src/core/sheerka/Sheerka.py b/src/core/sheerka/Sheerka.py index 818f0c4..e9214ca 100644 --- a/src/core/sheerka/Sheerka.py +++ b/src/core/sheerka/Sheerka.py @@ -340,6 +340,7 @@ class Sheerka(Concept): self.cache_manager.clear() self.printer_handler.reset() self.sdp.reset() + self.locals = {} def evaluate_user_input(self, text: str, user_name="kodjo"): """ diff --git a/src/core/utils.py b/src/core/utils.py index 6086354..37aca86 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -214,6 +214,34 @@ def product(a, b): return res +def dict_product(a, b): + """ + Cartesian product like where a and b are list of dictionaries + >>> a = [{"a": "a", "b":"b", "c":"c"}] + >>> b = [{"d":"d1"}, {"d":"d2"}] + >>> + >>> assert dict_product(a, b) == [{"a": "a", "b":"b", "c":"c", "d":"d1"}, {"a": "a", "b":"b", "c":"c", "d":"d2"}] + + :param a: + :param b: + :return: + """ + if a is None or len(a) == 0: + return b + if b is None or len(b) == 0: + return a + + res = [] + for item_a in a: + for item_b in b: + items = item_a.copy() + items.update(item_b) + res.append(items) + + return res + + + def strip_quotes(text): if not isinstance(text, str): return text diff --git a/src/evaluators/PythonEvaluator.py b/src/evaluators/PythonEvaluator.py index 85263a2..c37d386 100644 --- a/src/evaluators/PythonEvaluator.py +++ b/src/evaluators/PythonEvaluator.py @@ -1,14 +1,14 @@ import ast import copy import traceback -from functools import partial, update_wrapper +from dataclasses import dataclass, field import core.ast.nodes -from core.sheerka.services.SheerkaFilter import Pipe import core.utils from core.ast.visitors import UnreferencedNamesVisitor from core.builtin_concepts import BuiltinConcepts, ParserResultConcept from core.concept import ConceptParts, Concept +from core.sheerka.services.SheerkaFilter import Pipe from evaluators.BaseEvaluator import OneReturnValueEvaluator from parsers.PythonParser import PythonNode @@ -35,6 +35,13 @@ class Expando: setattr(self, k, v) +@dataclass +class PythonEvalError: + error: Exception + traceback: str = field(repr=False) + concepts: dict = field(repr=False) + + class PythonEvaluator(OneReturnValueEvaluator): NAME = "Python" @@ -54,37 +61,59 @@ class PythonEvaluator(OneReturnValueEvaluator): def eval(self, context, return_value): sheerka = context.sheerka node = return_value.value.value - try: - context.log(f"Evaluating python node {node}.", self.name) - # Do not evaluate if the ast refers to a concept (leave it to ConceptEvaluator) - if isinstance(node.ast_, ast.Expression) and isinstance(node.ast_.body, ast.Name): - c = context.sheerka.resolve(node.ast_.body.id) - if c is not None: - context.log("It's a simple concept. Not for me.", self.name) - not_for_me = context.sheerka.new(BuiltinConcepts.NOT_FOR_ME, body=node) - return sheerka.ret(self.name, False, not_for_me, parents=[return_value]) + context.log(f"Evaluating python node {node}.", self.name) - # get globals - my_globals = self.get_globals(context, node) - context.log(f"globals={my_globals}", self.name) + # Do not evaluate if the ast refers to a concept (leave it to ConceptEvaluator) + # TODO: Remove this section when this check will be implemented in the AFTER_PARSING step + if isinstance(node.ast_, ast.Expression) and isinstance(node.ast_.body, ast.Name): + c = context.sheerka.resolve(node.ast_.body.id) + if c is not None: + context.log("It's a simple concept. Not for me.", self.name) + not_for_me = context.sheerka.new(BuiltinConcepts.NOT_FOR_ME, body=node) + return sheerka.ret(self.name, False, not_for_me, parents=[return_value]) - # eval - if isinstance(node.ast_, ast.Expression): - context.log("Evaluating using 'eval'.", self.name) - compiled = compile(node.ast_, "", "eval") - evaluated = eval(compiled, my_globals, sheerka.locals) - else: - context.log("Evaluating using 'exec'.", self.name) - evaluated = self.exec_with_return(node.ast_, my_globals, sheerka.locals) + # get globals + my_globals = self.get_globals(context, node) + context.log(f"globals={my_globals}", self.name) - context.log(f"{evaluated=}", self.name) - return sheerka.ret(self.name, True, evaluated, parents=[return_value]) + all_possible_globals = self.get_all_possible_globals(context, my_globals) + concepts_entries = None + evaluated = BuiltinConcepts.NOT_INITIALIZED + errors = [] + for globals_ in all_possible_globals: + try: + # eval + if isinstance(node.ast_, ast.Expression): + context.log("Evaluating using 'eval'.", self.name) + compiled = compile(node.ast_, "", "eval") + evaluated = eval(compiled, globals_, sheerka.locals) + else: + context.log("Evaluating using 'exec'.", self.name) + evaluated = self.exec_with_return(node.ast_, globals_, sheerka.locals) - except Exception as error: - context.log_error(error, who=self.name, exc=traceback.format_exc()) - error = sheerka.new(BuiltinConcepts.ERROR, body=error) - return sheerka.ret(self.name, False, error, parents=[return_value]) + break # in this first version, we stop once a success is found + except Exception as ex: + if concepts_entries is None: + concepts_entries = self.get_concepts_entries_from_globals(my_globals) + errors.append(PythonEvalError(ex, + traceback.format_exc(), + self.get_concepts_values_from_globals(globals_, concepts_entries))) + + if evaluated == BuiltinConcepts.NOT_INITIALIZED: + if len(errors) == 1: + context.log_error(errors[0].error, who=self.name, exc=errors[0].traceback) + one_error = sheerka.new(BuiltinConcepts.ERROR, body=errors[0]) + return sheerka.ret(self.name, False, one_error, parents=[return_value]) + + if len(errors) > 1: + for eval_error in errors: + context.log_error(eval_error.error, who=self.name, exc=eval_error.traceback) + too_many_errors = sheerka.new(BuiltinConcepts.TOO_MANY_ERRORS, body=errors) + return sheerka.ret(self.name, False, too_many_errors, parents=[return_value]) + + context.log(f"{evaluated=}", self.name) + return sheerka.ret(self.name, True, evaluated, parents=[return_value]) def get_globals(self, context, node): my_globals = { @@ -94,9 +123,9 @@ class PythonEvaluator(OneReturnValueEvaluator): # has to be the first, to allow override method_from_sheerka = self.update_globals_with_sheerka_methods(my_globals, context) - self.update_globals_with_context(my_globals, context) - self.update_globals_with_node(my_globals, context, node) + already_know = set(my_globals.keys()) + self.update_globals_with_node(my_globals, context, node, already_know) if self.globals: # when extra values are given. Add them my_globals.update(self.globals) @@ -125,20 +154,23 @@ class PythonEvaluator(OneReturnValueEvaluator): return methods_from_sheerka # to allow access using prefix "sheerka." - def update_globals_with_context(self, my_locals, context): + def update_globals_with_context(self, my_globals, context): if context.obj: context.log(f"Concept '{context.obj}' is in context. Adding it and its properties to locals.", self.name) for prop_name in context.obj.variables(): - prop_value = context.obj.get_value(prop_name) - if isinstance(prop_value, Concept): - my_locals[prop_name] = context.sheerka.objvalue(prop_value) - else: - my_locals[prop_name] = prop_value + my_globals[prop_name] = context.obj.get_value(prop_name) + my_globals["self"] = context.obj - my_locals["self"] = context.obj.body - - def update_globals_with_node(self, my_locals, context, node): + def update_globals_with_node(self, my_globals, context, node, already_known): + """ + Try to find concepts using the names that appear in the AST of the node. + :param my_globals: dictionary to update + :param context: + :param node: + :param already_known: if the name is in this list, do no try to instantiate it again + :return: + """ node_concept = core.ast.nodes.python_to_concept(node.ast_) unreferenced_names_visitor = UnreferencedNamesVisitor(context.sheerka) unreferenced_names_visitor.visit(node_concept) @@ -146,14 +178,15 @@ class PythonEvaluator(OneReturnValueEvaluator): for name in unreferenced_names_visitor.names: context.log(f"Resolving '{name}'.", self.name) + # get the concept if name in node.concepts: + # use it, even if it already in already_known + # This concept take precedence other the outer world context.log(f"Using value from node.", self.name) concept = self.resolve_concept(context, node.concepts[name]) - - elif name in my_locals: - context.log(f"Using value from property.", self.name) + elif name in already_known: + context.log(f"Already known. Skipping.", self.name) continue - else: context.log(f"Instantiating new concept with {name}.", self.name) concept = self.resolve_concept(context, name) @@ -162,9 +195,9 @@ class PythonEvaluator(OneReturnValueEvaluator): context.log(f"Concept '{name}' is not found or cannot be instantiated. Skipping.", self.name) continue + # evaluate it if needed if concept.metadata.is_evaluated: context.log(f"Concept {name} is already evaluated.", self.name) - else: context.log(f"Evaluating '{concept}'", self.name) with context.push(self.name, desc=f"Evaluating '{concept}'", obj=concept) as sub_context: @@ -177,7 +210,49 @@ class PythonEvaluator(OneReturnValueEvaluator): continue concept = evaluated - my_locals[name] = context.sheerka.objvalue(concept) + my_globals[name] = concept + + @staticmethod + def get_all_possible_globals(context, my_globals): + """ + From a dictionary of globals (str, obj) + Creates as many globals as there are combination between a concept and its body + Example: + if the entry 'foo': Concept("foo", body="something") + 2 globals will be created + one with foo: Concept("foo") # we keep the concept as an object + one with foo: 'something' # we substitute its value + :param context: + :param my_globals: + :return: + """ + + # first pass, get all the non concept or concept with no body + # Note that we consider that all concepts are evaluated + # In the future, it may be a good optimisation to defer the evaluation of the body + # until the python evaluation fails + fixed_values = {} + concepts_with_body = {} + for k, v in my_globals.items(): + if not isinstance(v, Concept) or context.sheerka.objvalue(v) == v: + fixed_values[k] = v + else: + concepts_with_body[k] = v + + # make the product the rest as cartesian product + res = [fixed_values] + for k, v in concepts_with_body.items(): + res = core.utils.dict_product(res, [{k: context.sheerka.objvalue(v)}, {k: v}]) + + return res + + @staticmethod + def get_concepts_entries_from_globals(my_globals): + return [k for k, v in my_globals.items() if isinstance(v, Concept)] + + @staticmethod + def get_concepts_values_from_globals(my_globals, names): + return {name: my_globals[name] for name in names} @staticmethod def resolve_concept(context, concept_hint): @@ -189,30 +264,13 @@ class PythonEvaluator(OneReturnValueEvaluator): return None new_instance = context.sheerka.new_from_template(concept, concept.key) if isinstance(concept_hint, tuple): - # It's means that it comes from PythonParser which have found a concept token (c:xxx:) + # It's means that it was requested by PythonParser which have found a concept token (c:xxx:) # So a concept was explicitly required, not its value # We mark the concept as already evaluated, so it's body will not be evaluated new_instance.metadata.is_evaluated = True return new_instance - @staticmethod - def resolve_name(to_resolve): - """ - Try to match - __C__concept_key__C__ - or - __C__concept_key__concept_id__C__ - - :param to_resolve: - :return: - """ - key, id_, use_concept = core.utils.decode_concept(to_resolve) - if key or id_: - return key, id_, use_concept - else: - return to_resolve, None, False - @staticmethod def expr_to_expression(expr): expr.lineno = 0 diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index 875846f..7325b83 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -1,7 +1,6 @@ import core.utils import pytest from core.concept import ConceptParts, Concept - from core.tokenizer import Token, TokenKind @@ -212,3 +211,21 @@ def test_decode_concept_key_id(): assert core.utils.decode_concept("__C__KEY_key__ID_id__C__") == ("key", "id") assert core.utils.decode_concept("__C__KEY_00None00__ID_id__C__") == (None, "id") assert core.utils.decode_concept("__C__KEY_key__ID_00None00__C__") == ("key", None) + + +@pytest.mark.parametrize("a,b,expected", [ + ([], [], []), + ([{"a": "a", "b": "b"}], [], [{"a": "a", "b": "b"}]), + ([], [{"a": "a", "b": "b"}], [{"a": "a", "b": "b"}]), + ([{"a": "a", "b": "b"}], [{"d": "d1"}, {"d": "d2"}], [{"a": "a", "b": "b", "d": "d1"}, + {"a": "a", "b": "b", "d": "d2"}]), + ([{"d": "d1"}, {"d": "d2"}], [{"a": "a", "b": "b"}], [{"a": "a", "b": "b", "d": "d1"}, + {"a": "a", "b": "b", "d": "d2"}]), + ([{"a": "a", "b": "b"}], [{"d": "d", "e": "e"}], [{"a": "a", "b": "b", "d": "d", "e": "e"}]), + ([{"a": "a"}, {"b": "b"}], [{"d": "d"}, {"e": "e"}], [{"a": "a", "d": "d"}, + {"a": "a", "e": "e"}, + {"b": "b", "d": "d"}, + {"b": "b", "e": "e"}]) +]) +def test_dict_product(a, b, expected): + assert core.utils.dict_product(a, b) == expected diff --git a/tests/evaluators/test_PythonEvaluator.py b/tests/evaluators/test_PythonEvaluator.py index 5f17ff0..23e8aa0 100644 --- a/tests/evaluators/test_PythonEvaluator.py +++ b/tests/evaluators/test_PythonEvaluator.py @@ -1,10 +1,10 @@ import pytest - from core.builtin_concepts import ReturnValueConcept, ParserResultConcept, BuiltinConcepts -from core.concept import Concept, DEFINITION_TYPE_DEF +from core.concept import Concept, CB from core.sheerka.services.SheerkaExecute import ParserInput -from evaluators.PythonEvaluator import PythonEvaluator +from evaluators.PythonEvaluator import PythonEvaluator, PythonEvalError from parsers.PythonParser import PythonNode, PythonParser + from tests.TestUsingMemoryBasedSheerka import TestUsingMemoryBasedSheerka @@ -63,7 +63,7 @@ class TestPythonEvaluator(TestUsingMemoryBasedSheerka): Concept("foo").def_var("prop", "'a'"), Concept("foo", body="bar") ]) - def test_i_cannot_eval_simple_concept(self, concept): + def test_simple_concepts_are_not_for_me(self, concept): context = self.get_context() context.sheerka.add_in_cache(Concept("foo")) @@ -73,7 +73,7 @@ class TestPythonEvaluator(TestUsingMemoryBasedSheerka): assert not evaluated.status assert context.sheerka.isinstance(evaluated.value, BuiltinConcepts.NOT_FOR_ME) - def test_i_can_eval_expression_with_that_references_concepts(self): + def test_i_can_eval_ast_expression_that_references_concepts(self): """ I can test modules with variables :return: @@ -87,7 +87,7 @@ class TestPythonEvaluator(TestUsingMemoryBasedSheerka): assert evaluated.status assert evaluated.value == 3 - def test_i_can_eval_module_with_that_references_concepts(self): + def test_i_can_eval_ast_module_that_references_concepts(self): """ I can test modules with variables :return: @@ -101,13 +101,12 @@ class TestPythonEvaluator(TestUsingMemoryBasedSheerka): assert evaluated.status assert evaluated.value == Concept("foo").init_key() - def test_i_can_eval_module_with_that_references_concepts_with_body(self): + def test_i_can_eval_ast_module_that_references_concepts_with_body(self): """ I can test modules with variables :return: """ - context = self.get_context() - context.sheerka.add_in_cache(Concept("foo", body="2")) + sheerka, context, foo = self.init_concepts(Concept("foo", body="2")) parsed = PythonParser().parse(context, ParserInput("def a(b):\n return b\na(foo)")) evaluated = PythonEvaluator().eval(context, parsed) @@ -127,15 +126,6 @@ class TestPythonEvaluator(TestUsingMemoryBasedSheerka): assert evaluated.status assert evaluated.value == "foo" - # sanity, does not work otherwise - parsed = PythonParser().parse(context, ParserInput("get_concept_name(foo)")) - python_evaluator = PythonEvaluator() - python_evaluator.globals["get_concept_name"] = get_concept_name - evaluated = python_evaluator.eval(context, parsed) - - assert not evaluated.status - assert evaluated.body.body.args[0] == "'int' object has no attribute 'name'" - def test_i_can_call_function_with_complex_concepts(self): sheerka, context, plus, mult = self.init_concepts( self.from_def_concept("plus", "a plus b", ["a", "b"]), @@ -163,23 +153,52 @@ class TestPythonEvaluator(TestUsingMemoryBasedSheerka): assert evaluated.status assert evaluated.body == 10 - # @pytest.mark.parametrize("text, concept_key, concept_id, use_concept", [ - # ("__C__key__C__", "key", None, False), - # ("__C__key__id__C__", "key", "id", False), - # ("__C__USE_CONCEPT__key__id__C__", "key", "id", True), - # ("__C__USE_CONCEPT__key__id__C__", "key", "id", True), - # ]) - # def test_i_can_resolve_name(self, text, concept_key, concept_id, use_concept): - # context = self.get_context() - # assert PythonEvaluator().resolve_name(context, text) == (concept_key, concept_id, use_concept) - # - # @pytest.mark.parametrize("text", [ - # "__C__", - # "__C__key", - # "__C__key____", - # "__C____", - # "__C__USE_CONCEPT__", - # ]) - # def test_i_cannot_resolve_name(self, text): - # context = self.get_context() - # assert PythonEvaluator().resolve_name(context, text) is None + def test_i_can_get_all_possibles_globals(self): + sheerka, context, foo = self.init_concepts(Concept("foo", body="foo").auto_init()) + python_evaluator = PythonEvaluator() + my_globals = { + "a": "a string", + "b": self.test_i_can_get_all_possibles_globals, + "foo": foo + } + + all_globals = python_evaluator.get_all_possible_globals(context, my_globals) + assert len(all_globals) == 2 + assert all_globals[0]["foo"] == 'foo' + assert all_globals[1]["foo"] == CB(foo, "foo") # body is evaluated + + def test_i_can_detect_one_error(self): + sheerka, context, foo = self.init_concepts("foo") + + parsed = PythonParser().parse(context, ParserInput("foo + 1")) + evaluated = PythonEvaluator().eval(context, parsed) + + assert not evaluated.status + assert context.sheerka.isinstance(evaluated.value, BuiltinConcepts.ERROR) + + error = evaluated.body.body + assert isinstance(error, PythonEvalError) + assert isinstance(error.error, TypeError) + assert error.error.args[0] == "unsupported operand type(s) for +: 'Concept' and 'int'" + assert error.concepts == {'foo': foo} + + def test_i_can_detect_multiple_errors(self): + sheerka, context, foo = self.init_concepts(Concept("foo", body="'string'")) + + parsed = PythonParser().parse(context, ParserInput("foo + 1")) + evaluated = PythonEvaluator().eval(context, parsed) + + assert not evaluated.status + assert context.sheerka.isinstance(evaluated.value, BuiltinConcepts.TOO_MANY_ERRORS) + + error0 = evaluated.body.body[0] + assert isinstance(error0, PythonEvalError) + assert isinstance(error0.error, TypeError) + assert error0.error.args[0] == 'can only concatenate str (not "int") to str' + assert error0.concepts == {'foo': 'string'} + + error1 = evaluated.body.body[1] + assert isinstance(error1, PythonEvalError) + assert isinstance(error1.error, TypeError) + assert error1.error.args[0] == "unsupported operand type(s) for +: 'Concept' and 'int'" + assert error1.concepts == {'foo': CB(foo, 'string')} diff --git a/tests/non_reg/test_sheerka_non_reg.py b/tests/non_reg/test_sheerka_non_reg.py index 6a16e25..eab0f56 100644 --- a/tests/non_reg/test_sheerka_non_reg.py +++ b/tests/non_reg/test_sheerka_non_reg.py @@ -921,6 +921,7 @@ as: assert not res[0].status + class TestSheerkaNonRegFile(TestUsingFileBasedSheerka): def test_i_can_def_several_concepts(self): sheerka = self.get_sheerka()