Repository: hedyorg/hedy

Issue:
Move helpers into transpiler class
See: 

https://github.com/Felienne/hedy/blob/a950da6cb798b5476ee337aa3ce89b2bd1bef586/hedy.py#L1011


Discussion:

Resolving patch:
diff --git a/hedy.py b/hedy.py
--- a/hedy.py
+++ b/hedy.py
@@ -545,7 +545,7 @@ def integer(self, tree):
     def text(self, tree):
         # under level 12 integers appear as text, so we parse them
         if self.level < 12:
-            type_ = HedyType.integer if is_int(tree.children[0]) else HedyType.string
+            type_ = HedyType.integer if ConvertToPython.is_int(tree.children[0]) else HedyType.string
         else:
             type_ = HedyType.string
         return self.to_typed_tree(tree, type_)
@@ -564,9 +564,9 @@ def var(self, tree):
 
     def number(self, tree):
         number = tree.children[0]
-        if is_int(number):
+        if ConvertToPython.is_int(number):
             return self.to_typed_tree(tree, HedyType.integer)
-        if is_float(number):
+        if ConvertToPython.is_float(number):
             return self.to_typed_tree(tree, HedyType.float)
         # We managed to parse a number that cannot be parsed by python
         raise exceptions.ParseException(level=self.level, location='', found=number)
@@ -969,19 +969,118 @@ def decorator(c):
         return c
     return decorator
 
-@hedy_transpiler(level=1)
-class ConvertToPython_1(Transformer):
-
+class ConvertToPython(Transformer):
     def __init__(self, punctuation_symbols, lookup):
-        self.punctuation_symbols = punctuation_symbols
         self.lookup = lookup
-        __class__.level = 1
+
+    def is_variable(self, name):
+        all_names = [a.name for a in self.lookup]
+        return hash_var(name) in all_names
+
+    def process_variable(self, arg):
+        #processes a variable by hashing and escaping when needed
+        if self.is_variable(arg):
+            return hash_var(arg)
+        elif ConvertToPython.is_quoted(arg): #sometimes kids accidentally quote strings, then we do not want them quoted again
+            return f"{arg}"
+        else:
+            return f"'{arg}'"
+
+    def process_variable_for_fstring(self, name):
+        if self.is_variable(name):
+            return "{" + hash_var(name) + "}"
+        else:
+            return name
+
+    def process_variable_for_fstring_padded(self, name):
+        # used to transform variables in comparisons
+        if self.is_variable(name):
+            return f"str({hash_var(name)}).zfill(100)"
+        elif ConvertToPython.is_float(name):
+            return f"str({name}).zfill(100)"
+        elif ConvertToPython.is_quoted(name):
+            return f"{name}.zfill(100)"
+        else:
+            raise hedy.exceptions.UndefinedVarException(name)
+
+    def make_f_string(self, args):
+        argument_string = ''
+        for argument in args:
+            if self.is_variable(argument):
+                # variables are placed in {} in the f string
+                argument_string += "{" + hash_var(argument) + "}"
+            else:
+                # strings are written regularly
+                # however we no longer need the enclosing quotes in the f-string
+                # the quotes are only left on the argument to check if they are there.
+                argument_string += argument.replace("'", '')
+
+        return f"print(f'{argument_string}')"
 
     def get_fresh_var(self, name):
-        while is_variable(name, self.lookup):
+        while self.is_variable(name):
             name = '_' + name
         return name
 
+    def check_var_usage(self, args):
+        # this function checks whether arguments are valid
+        # we can proceed if all arguments are either quoted OR all variables
+
+        args_to_process = [a for a in args if not isinstance(a, Tree)]#we do not check trees (calcs) they are always ok
+
+        unquoted_args = [a for a in args_to_process if not ConvertToPython.is_quoted(a)]
+        unquoted_in_lookup = [self.is_variable(a) for a in unquoted_args]
+
+        if unquoted_in_lookup == [] or all(unquoted_in_lookup):
+            # all good? return for further processing
+            return args
+        else:
+            # return first name with issue
+            # note this is where issue #832 can be addressed by checking whether
+            # first_unquoted_var ius similar to something in the lookup list
+            first_unquoted_var = unquoted_args[0]
+            raise exceptions.UndefinedVarException(name=first_unquoted_var)
+
+    # static methods
+    @staticmethod
+    def is_quoted(s):
+        return s[0] == "'" and s[-1] == "'"
+
+    @staticmethod
+    def is_int(n):
+        try:
+            int(n)
+            return True
+        except ValueError:
+            return False
+
+    @staticmethod
+    def is_float(n):
+        try:
+            float(n)
+            return True
+        except ValueError:
+            return False
+
+    @staticmethod
+    def is_random(s):
+        return 'random.choice' in s
+
+    @staticmethod
+    def indent(s):
+        lines = s.split('\n')
+        return '\n'.join(['  ' + l for l in lines])
+
+
+
+@hedy_transpiler(level=1)
+class ConvertToPython_1(ConvertToPython):
+
+    def __init__(self, punctuation_symbols, lookup):
+        self.punctuation_symbols = punctuation_symbols
+        self.lookup = lookup
+        __class__.level = 1
+
     def program(self, args):
         return '\n'.join([str(c) for c in args])
     def command(self, args):
@@ -1029,7 +1128,7 @@ def turn(self, args):
             return "t.right(90)"  # no arguments defaults to a right turn
 
         arg = args[0]
-        if is_variable(arg, self.lookup) or arg.isnumeric():
+        if self.is_variable(arg) or arg.isnumeric():
             return f"t.right({arg})"
         elif arg == 'left':
             return "t.left(90)"
@@ -1041,36 +1140,7 @@ def turn(self, args):
                                                           allowed_types=get_allowed_types(Command.turn, self.level))
 
 
-# todo: could be moved into the transpiler class
-def is_variable(name, lookup):
-    all_names = [a.name for a in lookup]
-    return hash_var(name) in all_names
 
-def process_variable(arg, lookup):
-    #processes a variable by hashing and escaping when needed
-    if is_variable(arg, lookup):
-        return hash_var(arg)
-    elif is_quoted(arg): #sometimes kids accidentally quote strings, then we do not want them quoted again
-        return f"{arg}"
-    else:
-        return f"'{arg}'"
-
-def process_variable_for_fstring(name, lookup):
-    if is_variable(name, lookup):
-        return "{" + hash_var(name) + "}"
-    else:
-        return name
-
-def process_variable_for_fstring_padded(name, lookup):
-    # used to transform variables in comparisons
-    if is_variable(name, lookup):
-        return f"str({hash_var(name)}).zfill(100)"
-    elif is_float(name):
-        return f"str({name}).zfill(100)"
-    elif is_quoted(name):
-        return f"{name}.zfill(100)"
-    else:
-        raise hedy.exceptions.UndefinedVarException(name)
 
 @hedy_transpiler(level=2)
 class ConvertToPython_2(ConvertToPython_1):
@@ -1084,24 +1154,6 @@ def error_echo_dep_2(self, args):
         # ask_needs_var is an entry in lang.yaml in texts where we can add extra info on this error
         raise hedy.exceptions.WrongLevelException(1,  'echo', "echo_out")
 
-    def check_var_usage(self, args):
-        # this function checks whether arguments are valid
-        # we can proceed if all arguments are either quoted OR all variables
-
-        args_to_process = [a for a in args if not isinstance(a, Tree)]#we do not check trees (calcs) they are always ok
-
-        unquoted_args = [a for a in args_to_process if not is_quoted(a)]
-        unquoted_in_lookup = [is_variable(a, self.lookup) for a in unquoted_args]
-
-        if unquoted_in_lookup == [] or all(unquoted_in_lookup):
-            # all good? return for further processing
-            return args
-        else:
-            # return first name with issue
-            # note this is where issue #832 can be addressed by checking whether
-            # first_unquoted_var ius similar to something in the lookup list
-            first_unquoted_var = unquoted_args[0]
-            raise exceptions.UndefinedVarException(name=first_unquoted_var)
 
     def punctuation(self, args):
         return ''.join([str(c) for c in args])
@@ -1126,7 +1178,7 @@ def print(self, args):
             else:
                 space = " "
 
-            argument_string += process_variable_for_fstring(argument, self.lookup) + space
+            argument_string += self.process_variable_for_fstring(argument) + space
 
             i = i + 1
 
@@ -1141,7 +1193,7 @@ def forward(self, args):
         if len(args) == 0:
             return self.make_forward(50)
 
-        if is_int(args[0]):
+        if ConvertToPython.is_int(args[0]):
             parameter = int(args[0])
         else:
             # if not an int, then it is a variable
@@ -1154,7 +1206,7 @@ def turn(self, args):
             return "t.right(90)"
 
         arg = hash_var(args[0])
-        if is_variable(arg, self.lookup) or arg.isnumeric():
+        if self.is_variable(arg) or arg.isnumeric():
             return f"t.right({arg})"
 
         # the TypeValidator should protect against reaching this line:
@@ -1164,7 +1216,7 @@ def turn(self, args):
     def assign(self, args):
         parameter = args[0]
         value = args[1]
-        if is_random(value):
+        if self.is_random(value):
             return parameter + " = " + value
         else:
             # if the assigned value contains single quotes, escape them
@@ -1179,23 +1231,6 @@ def sleep(self, args):
             return f"time.sleep({args[0]})"
 
 
-def is_quoted(s):
-    return s[0] == "'" and s[-1] == "'"
-
-def make_f_string(args, lookup):
-    argument_string = ''
-    for argument in args:
-        if is_variable(argument, lookup):
-            # variables are placed in {} in the f string
-            argument_string += "{" + hash_var(argument) + "}"
-        else:
-            # strings are written regularly
-            # however we no longer need the enclosing quotes in the f-string
-            # the quotes are only left on the argument to check if they are there.
-            argument_string += argument.replace("'", '')
-
-    return f"print(f'{argument_string}')"
-
 @hedy_transpiler(level=3)
 class ConvertToPython_3(ConvertToPython_2):
     def assign_list(self, args):
@@ -1240,7 +1275,7 @@ def print_ask_args(self, args):
         result = ''
         for argument in args:
             argument = argument.replace("'", '')  # no quotes needed in fstring
-            result += process_variable_for_fstring(argument, self.lookup)
+            result += self.process_variable_for_fstring(argument)
         return result
 
     def print(self, args):
@@ -1255,9 +1290,6 @@ def ask(self, args):
     def error_print_nq(self, args):
         return ConvertToPython_2.print(self, args)
 
-def indent(s):
-    lines = s.split('\n')
-    return '\n'.join(['  ' + l for l in lines])
 
 @hedy_transpiler(level=5)
 class ConvertToPython_5(ConvertToPython_4):
@@ -1270,25 +1302,25 @@ def list_access_var(self, args):
 
     def ifs(self, args):
         return f"""if {args[0]}:
-{indent(args[1])}"""
+{ConvertToPython.indent(args[1])}"""
 
     def ifelse(self, args):
         return f"""if {args[0]}:
-{indent(args[1])}
+{ConvertToPython.indent(args[1])}
 else:
-{indent(args[2])}"""
+{ConvertToPython.indent(args[2])}"""
     def condition(self, args):
         return ' and '.join(args)
     def equality_check(self, args):
-        arg0 = process_variable(args[0], self.lookup)
+        arg0 = self.process_variable(args[0])
         remaining_text = ' '.join(args[1:])
-        arg1 = process_variable(remaining_text, self.lookup)
+        arg1 = self.process_variable(remaining_text)
         return f"{arg0} == {arg1}"
         #TODO, FH 2021: zelfde change moet ik ook nog ff maken voor equal. check in hogere levels
 
     def in_list_check(self, args):
-        arg0 = process_variable(args[0], self.lookup)
-        arg1 = process_variable(args[1], self.lookup)
+        arg0 = self.process_variable(args[0])
+        arg1 = self.process_variable(args[1])
         return f"{arg0} in {arg1}"
 
 @hedy_transpiler(level=6)
@@ -1305,14 +1337,14 @@ def print_ask_args(self, args):
                 args_new.append("{" + a.children[0] + "}")
             else:
                 a = a.replace("'", "")  # no quotes needed in fstring
-                args_new.append(process_variable_for_fstring(a, self.lookup))
+                args_new.append(self.process_variable_for_fstring(a))
 
         return ''.join(args_new)
 
     def equality_check(self, args):
-        arg0 = process_variable(args[0], self.lookup)
+        arg0 = self.process_variable(args[0])
         remaining_text = ' '.join(args[1:])
-        arg1 = process_variable(remaining_text, self.lookup)
+        arg1 = self.process_variable(remaining_text)
 
         return f"str({arg0}) == str({arg1})"
 
@@ -1391,12 +1423,12 @@ def sleep_after(commands, indent=True):
 class ConvertToPython_7(ConvertToPython_6):
     def repeat(self, args):
         var_name = self.get_fresh_var('i')
-        times = process_variable(args[0], self.lookup)
+        times = self.process_variable(args[0])
         command = args[1]
         # in level 7, repeats can only have 1 line as their arguments
         command = sleep_after(command, False)
         return f"""for {var_name} in range(int({str(times)})):
-{indent(command)}"""
+{ConvertToPython.indent(command)}"""
 
 @hedy_transpiler(level=8)
 @hedy_transpiler(level=9)
@@ -1409,7 +1441,7 @@ def command(self, args):
         return "".join(args)
 
     def repeat(self, args):
-        all_lines = [indent(x) for x in args[1:]]
+        all_lines = [ConvertToPython.indent(x) for x in args[1:]]
         body = "\n".join(all_lines)
         body = sleep_after(body)
 
@@ -1418,14 +1450,14 @@ def repeat(self, args):
     def ifs(self, args):
         args = [a for a in args if a != ""] # filter out in|dedent tokens
 
-        all_lines = [indent(x) for x in args[1:]]
+        all_lines = [ConvertToPython.indent(x) for x in args[1:]]
 
         return "if " + args[0] + ":\n" + "\n".join(all_lines)
 
     def elses(self, args):
         args = [a for a in args if a != ""] # filter out in|dedent tokens
 
-        all_lines = [indent(x) for x in args]
+        all_lines = [ConvertToPython.indent(x) for x in args]
 
         return "\nelse:\n" + "\n".join(all_lines)
 
@@ -1441,7 +1473,7 @@ class ConvertToPython_10(ConvertToPython_8_9):
     def repeat_list(self, args):
       args = [a for a in args if a != ""]  # filter out in|dedent tokens
 
-      body = "\n".join([indent(x) for x in args[2:]])
+      body = "\n".join([ConvertToPython.indent(x) for x in args[2:]])
 
       body = sleep_after(body, True)
 
@@ -1451,7 +1483,7 @@ def repeat_list(self, args):
 class ConvertToPython_11(ConvertToPython_10):
     def for_loop(self, args):
         args = [a for a in args if a != ""]  # filter out in|dedent tokens
-        body = "\n".join([indent(x) for x in args[3:]])
+        body = "\n".join([ConvertToPython.indent(x) for x in args[3:]])
         body = sleep_after(body)
         stepvar_name = self.get_fresh_var('step')
         return f"""{stepvar_name} = 1 if int({args[1]}) < int({args[2]}) else -1
@@ -1459,24 +1491,6 @@ def for_loop(self, args):
 {body}"""
 
 
-def is_int(n):
-    try:
-        int(n)
-        return True
-    except ValueError:
-        return False
-
-
-def is_float(n):
-    try:
-        float(n)
-        return True
-    except ValueError:
-        return False
-
-
-def is_random(s):
-    return 'random.choice' in s
 
 
 @hedy_transpiler(level=12)
@@ -1547,7 +1561,7 @@ def assign(self, args):
         except exceptions.UndefinedVarException as E:
             # is the text a number? then no quotes are fine. if not, raise maar!
 
-            if not (is_int(right_hand_side) or is_float(right_hand_side) or is_random(right_hand_side)):
+            if not (ConvertToPython.is_int(right_hand_side) or ConvertToPython.is_float(right_hand_side) or ConvertToPython.is_random(right_hand_side)):
                 raise exceptions.UnquotedAssignTextException(text = args[1])
 
         if isinstance(right_hand_side, Tree):
@@ -1583,8 +1597,8 @@ class ConvertToPython_14(ConvertToPython_13):
     def process_comparison(self, args, operator):
 
         # we are generating an fstring now
-        arg0 = process_variable_for_fstring_padded(args[0], self.lookup)
-        arg1 = process_variable_for_fstring_padded(args[1], self.lookup)
+        arg0 = self.process_variable_for_fstring_padded(args[0])
+        arg1 = self.process_variable_for_fstring_padded(args[1])
 
         # zfill(100) in process_variable_for_fstring_padded leftpads variables to length 100 with zeroes (hence the z fill)
         # that is to make sure that string comparison works well "ish" for numbers
@@ -1619,7 +1633,7 @@ def not_equal(self, args):
 class ConvertToPython_15(ConvertToPython_14):
     def while_loop(self, args):
         args = [a for a in args if a != ""]  # filter out in|dedent tokens
-        all_lines = [indent(x) for x in args[1:]]
+        all_lines = [ConvertToPython.indent(x) for x in args[1:]]
         body = "\n".join(all_lines)
         body = sleep_after(body)
         return "while " + args[0] + ":\n" + body
@@ -1638,7 +1652,7 @@ def change_list_item(self, args):
 class ConvertToPython_17(ConvertToPython_16):
     def elifs(self, args):
         args = [a for a in args if a != ""]  # filter out in|dedent tokens
-        all_lines = [indent(x) for x in args[1:]]
+        all_lines = [ConvertToPython.indent(x) for x in args[1:]]
         return "\nelif " + args[0] + ":\n" + "\n".join(all_lines)
 
 @hedy_transpiler(level=18)
@@ -1647,118 +1661,6 @@ class ConvertToPython_18(ConvertToPython_17):
     def input(self, args):
         return self.ask(args)
 
-# @hedy_transpiler(level=13)
-# class ConvertToPython_13(ConvertToPython_12):
-#     def print(self, args):
-#         # we only check non-Tree (= non calculation) arguments
-#         self.check_var_usage(args)
-#         self.check_arg_types(args, 'print', self.level)
-#
-#         #force all to be printed as strings (since there can not be int arguments)
-#         args_new = []
-#         for a in args:
-#             if type(a) is Tree:
-#                 args_new.append("{" + a.children + "}")
-#             else:
-#                 args_new.append(process_variable_for_fstring(a, self.lookup).replace("'",""))
-#
-#         arguments = ''.join(args_new)
-#         return "print(f'" + arguments + "')"
-
-# @hedy_transpiler(level=14)
-# class ConvertToPython_14(ConvertToPython_13):
-#     def print(self,args):
-#         return ConvertToPython_5.print(self,args)
-#     def assign(self, args):  # TODO: needs to be merged with 6, when 6 is improved to with printing expressions directly
-#         if len(args) == 2:
-#             parameter = args[0]
-#             value = args[1]
-#             if type(value) is Tree:
-#                 return parameter + " = " + value.children
-#             else:
-#                 if "'" in value or 'random.choice' in value:  # TODO: should be a call to wrap nonvarargument is quotes!
-#                     return parameter + " = " + value
-#                 else:
-#                     # FH, June 21 the addition of _true/false is a bit of a hack. cause they are first seen as vars that at reserved words, they are then hashed and we undo that here
-#                     # could/should be fixed in the grammar!
-#
-#                     if value == 'true' or value == 'True' or value == hash_var('True') or value == hash_var('true'):
-#                         return parameter + " = True"
-#                     elif value == 'false' or value == 'False' or value == hash_var('False') or value == hash_var('false'):
-#                         return parameter + " = False"
-#                     else:
-#                         return parameter + " = '" + value + "'"
-#         else:
-#             parameter = args[0]
-#             values = args[1:]
-#             return parameter + " = [" + ", ".join(values) + "]"
-#
-#     def equality_check(self, args):
-#         arg0 = process_variable(args[0], self.lookup)
-#         arg1 = process_variable(args[1], self.lookup)
-#         if arg1 == '\'True\'' or arg1 == '\'true\'':
-#             return f"{arg0} == True"
-#         elif arg1 == '\'False\'' or arg1 == '\'false\'':
-#             return f"{arg0} == False"
-#         else:
-#             return f"str({arg0}) == str({arg1})" #no and statements
-#
-
-# @hedy_transpiler(level=19)
-# @hedy_transpiler(level=20)
-# class ConvertToPython_19_20(ConvertToPython_18):
-#     def length(self, args):
-#         arg0 = args[0]
-#         length_string = f"len({arg0})"
-#
-#         # when accessing len we need to know it is a var
-#         # todo (could be done in the AllAssignments?)
-#         self.lookup.append(Assignment(length_string, 'operation'))
-#         return length_string
-#
-#     def assign(self, args):  # TODO: needs to be merged with 6, when 6 is improved to with printing expressions directly
-#         if len(args) == 2:
-#             parameter = args[0]
-#             value = args[1]
-#             if type(value) is Tree:
-#                 return parameter + " = " + value.children
-#             else:
-#                 if "'" in value or 'random.choice' in value:  # TODO: should be a call to wrap nonvarargument is quotes!
-#                     return parameter + " = " + value
-#                 elif "len(" in value:
-#                     return parameter + " = " + value
-#                 else:
-#                     if value == 'true' or value == 'True':
-#                         return parameter + " = True"
-#                     elif value == 'false' or value == 'False':
-#                         return parameter + " = False"
-#                     else:
-#                         return parameter + " = '" + value + "'"
-#         else:
-#             parameter = args[0]
-#             values = args[1:]
-#             return parameter + " = [" + ", ".join(values) + "]"
-#
-# @hedy_transpiler(level=21)
-# class ConvertToPython_21(ConvertToPython_19_20):
-#     def equality_check(self, args):
-#         if type(args[0]) is Tree:
-#             return args[0].children + " == int(" + args[1] + ")"
-#         if type(args[1]) is Tree:
-#             return "int(" + args[0] + ") == " + args[1].children
-#         arg0 = process_variable(args[0], self.lookup)
-#         arg1 = process_variable(args[1], self.lookup)
-#         if arg1 == '\'True\'' or arg1 == '\'true\'':
-#             return f"{arg0} == True"
-#         elif arg1 == '\'False\'' or arg1 == '\'false\'':
-#             return f"{arg0} == False"
-#         else:
-#             return f"str({arg0}) == str({arg1})"  # no and statements
-#
-
-
-
-
 def merge_grammars(grammar_text_1, grammar_text_2, level):
     # this function takes two grammar files and merges them into one
     # rules that are redefined in the second file are overridden


Test patch:
diff --git a/tests/test_level_03.py b/tests/test_level_03.py
--- a/tests/test_level_03.py
+++ b/tests/test_level_03.py
@@ -221,25 +221,6 @@ def test_ask_with_comma(self):
 
     self.single_level_tester(code=code, expected=expected)
 
-  #sleep tests
-  def test_sleep_with_number(self):
-    code = "sleep 2"
-    expected = "time.sleep(2)"
-
-    self.multi_level_tester(
-      code=code,
-      expected=expected
-    )
-  def test_sleep_without_number(self):
-    code = "sleep"
-    expected = "time.sleep(1)"
-
-    self.multi_level_tester(
-      code=code,
-      expected=expected
-    )
-
-  #turn tests
   def test_turn_number(self):
     code = textwrap.dedent("""\
     print Turtle race