# Part of the A-A-P recipe executive: Parse and execute recipe commands # Copyright (C) 2002 Stichting NLnet Labs # Permission to copy and use this file is specified in the file COPYING. # If this file is missing you can find it here: http://www.a-a-p.org/COPYING import string import sys import traceback from Util import * from Work import setrpstack from Error import * from RecPos import rpcopy import Global # ":" commands that only work at the toplevel aap_cmd_toplevel = [ "autodepend", "recipe", "rule", "variant", ] # All possible names of ":" commands in a recipe, including toplevel-only ones. aap_cmd_names = [ "add", "attr", "attribute", "cat", "checkin", "checkout", "child", "commit", "commitall", "copy", "del", "delete", "error", "export", "filetype", "include", "mkdir", "move", "print", "publish", "publishall", "refresh", "remove", "removeall", "require", "sys", "system", "touch", "unlock", "update", "verscont", ] + aap_cmd_toplevel # marker for recipe line number in Python script line_marker = '#@recipe=' line_marker_len = len(line_marker) def assert_var_name(name, rpstack): """Check if "name" is a valid variable name. If it isn't, throw a user exception.""" for c in name: if not varchar(c): recipe_error(rpstack, _("Invalid character in variable name")) def get_var_name(fp): """Get the name of a variable from the current position at "fp" and get the index of the next non-white after it. Returns an empty string if there is no valid variable name.""" idx = fp.idx while idx < fp.line_len and varchar(fp.line[idx]): idx = idx + 1 return fp.line[fp.idx:idx], skip_white(fp.line, idx) class ArgItem: """Object used as smallest part in the arglist.""" def __init__(self, isexpr, str): self.isexpr = isexpr # 0 for string, 1 for Python expression self.str = str # the string itself def getarg(fp, stop, globals): """Get an argument starting at fp.line[fp.idx] and ending at a character in stop[]. Quotes are used to include stop characters in the argument. Backticks are handled here. `` is reduced to a single `. A Python expression `python` is translated to '" + expr2str(python) + "'. Returns the resulting string and fp.idx is updated to the character after the argument (the stop character or past the end of line). """ res = '' # argument collected so far inquote = '' # quote we're inside of inbraces = 0 # inside {} count while 1: if fp.idx >= fp.line_len: # end of line break # Python `expression`? if fp.line[fp.idx] == '`': # `` isn't the start of an expression, reduce it to a single `. if fp.idx + 1 < fp.line_len and fp.line[fp.idx + 1] == '`': res = res + '`' fp.idx = fp.idx + 2 continue # Append the Python expression. res = res + '" + expr2str(' + get_py_expr(fp) + ') + "' continue # End of quoted string? if inquote: if fp.line[fp.idx] == inquote: inquote = '' # Start of quoted string? elif fp.line[fp.idx] == '"' or fp.line[fp.idx] == "'": inquote = fp.line[fp.idx] else: # start/end of {}? if fp.line[fp.idx] == '{': inbraces = inbraces + 1 elif fp.line[fp.idx] == '}': inbraces = inbraces - 1 if inbraces < 0: # TODO: recipe_error(fp.rpstack, _("Unmatched }")) inbraces = 0 # Stop character found? # A ':' must be followed by white space to be recongized. # A '=' must not be inside {}. if string.find(stop, fp.line[fp.idx]) != -1 \ and (fp.line[fp.idx] != ':' or fp.idx + 1 == fp.line_len or fp.line[fp.idx + 1] == ' ' or fp.line[fp.idx + 1] == '\t') \ and (fp.line[fp.idx] != '=' or inbraces == 0): break # Need to escape backslash and double quote. c = fp.line[fp.idx] if c == '"' or c == '\\': res = res + '\\' res = res + c fp.idx = fp.idx + 1 # Skip over $$ and $#. if (c == '$' and fp.idx < fp.line_len and (fp.line[fp.idx] == '$' or fp.line[fp.idx] == '#')): res = res + fp.line[fp.idx] fp.idx = fp.idx + 1 # Remove trailing white space. e = len(res) while e > 0 and is_white(res[e - 1]): e = e - 1 return res[:e] def get_func_args(fp, indent, globals): """Get the arguments for an aap_ function or assignment from the recipe line(s). Stop at a line with an indent of "indent" or less. Input lines: cmdname arg ` `arg arg Result: "arg " + expr2str() + "arg arg" Return the argument string, advance fp to the following line.""" res = '' fp.idx = skip_white(fp.line, fp.idx) while 1: if fp.idx >= fp.line_len or fp.line[fp.idx] == '#': # Read the next line fp.nextline() if fp.line is None: break # end of file fp.idx = skip_white(fp.line, 0) if get_indent(fp.line) > indent: continue # A line with less indent finishes the list of arguments break # Get the argument, stop at a comment, handle python expression. # A line break is changed into a space. if res: res = res + ' ' res = res + getarg(fp, "#", globals) return '"' + res + '"' def esc_quote(s): """Escape double quotes and backslash with a backslash.""" return string.replace(string.replace(s, '\\', '\\\\'), '"', '\\"') def get_commands(fp, indent): """Read command lines for a dependency or a rule. Stop when the indent is at or below "indent". Returns the string of commands, each line ending in '\n'.""" s = '' while 1: if fp.line is None or get_indent(fp.line) <= indent: break # end of commands reached s = s + fp.line + '\n' fp.nextline() return '"""' + esc_quote(s) + '"""' def get_py_expr(fp): """Get a Python expression from ` to matching `. Reduce `` to `. fp.idx points to the ` and is advanced to after the matching `. Returns the expression excluding the ` before and after.""" # Remember the RecPos where the first ` was found; need to make a copy, # because fp.nextline() will change it. rpstack = rpcopy(fp.rpstack, fp.rpstack[-1].line_nr) res = '' fp.idx = fp.idx + 1 while 1: if fp.idx >= fp.line_len: # Python expression continues in the next line. fp.nextline() if fp.line is None: recipe_error(rpstack, _("Missing `")) res = res + '\n' if fp.line[fp.idx] == '`': # Either the matching ` or `` that stands for a single `. fp.idx = fp.idx + 1 if fp.idx >= fp.line_len or fp.line[fp.idx] != '`': break # found matching ` res = res + '`' else: # Append a character to the Python expression. res = res + fp.line[fp.idx] fp.idx = fp.idx + 1 return res def recipe_error(rpstack, msg): """Throw an exception for an error in a recipe: Error: Unknown command in recipe "main.aap" line 88: :foobar asdf included from "main.aap" line 33 When "rpstack" is empty it's not mentioned, useful for errors not related to a specific line.""" # Note: These messages is not translated, so that a parser for the # messages isn't confused by various languages. if len(rpstack) == 0: e = 'Error in recipe: %s\n' % msg else: e = 'Error in recipe "%s" line %d: %s\n' \ % (rpstack[-1].name, rpstack[-1].line_nr, msg) if len(rpstack) > 1: for i in range(len(rpstack) - 2, 0, -1): e = e + 'included from "%s" line %d\n' \ % (rpstack[i].name, rpstack[i].line_nr) e = e[:-1] # remove trailing \n raise UserError, e def script_error(rpstack, script, e): """Handle an error raised while executing the Python script produced for a recipe. The error is probably in the recipe, try to give a useful error message.""" etype, evalue, tb = sys.exc_info() # A SyntaxError is special: it's not the last frame in the traceback but # only in the "etype" and "evalue". When there is a filename it must have # been an internal error, otherwise it's an error in the converted recipe. py_line_nr = -1 if etype is SyntaxError: try: msg, (filename, py_line_nr, offset, line) = evalue if not filename is None: py_line_nr = -2 except: pass if py_line_nr < 0: # Find the line number in the last traceback frame. while tb.tb_next: tb = tb.tb_next fname = tb.tb_frame.f_code.co_filename if py_line_nr == -2 or (fname and not fname == ""): # If there is a filename, it's not an error in the script. from Main import error_msg error_msg(_("Internal Error")) traceback.print_exc() sys.exit(1) py_line_nr = traceback.tb_lineno(tb) # Translate the line number in the Python script to the line number # in the recipe. i = 0 script_len = len(script) rec_line_nr = 1 while 1: if py_line_nr == 1: break while i < script_len: if script[i] == '\n': break i = i + 1 i = i + 1 if i >= script_len: break if script[i : i + line_marker_len] == line_marker: i = i + line_marker_len j = i while script[j] in string.digits: j = j + 1 rec_line_nr = string.atoi(script[i:j]) py_line_nr = py_line_nr - 1 # Give the exception error with the line number in the recipe. recipe_py_error(rpcopy(rpstack, rec_line_nr), '') def recipe_py_error(rpstack, msg): """Turn the list from format_exception_only() into a simple string and pass it to recipe_error().""" etype, evalue, tb = sys.exc_info() lines = traceback.format_exception_only(etype, evalue) # For a syntax error remove the "" and line number that the # Python script causes. if etype is SyntaxError: try: emsg, (filename, lineno, offset, line) = evalue if filename is None: lines[0] = '\n' except: pass str = msg for line in lines[:-1]: str = str + line + ' ' str = str + lines[-1] recipe_error(rpstack, str) def Process(fp, globals): """Read all the lines in ParsePos "fp", convert it into a Python script and execute it. When "fp.string" is empty, the source is a recipe file, otherwise it is a string (commands from a dependency or rule).""" # Need to be able to find the RecPos stack in globals. setrpstack(globals, fp.rpstack) class Variant: """Class used to remember nested ":variant" commands in variant_stack.""" def __init__(self, name, indent): self.name = name self.min_indent = indent self.val_indent = 0 self.had_star = 0 # encountered * item # # At the start of the loop "fp.line" contains the next line to be # processsed. "fp.rpstack[-1].line_nr" is the number of this line in the # recipe. # script = "" shell_cmd = "" # shell command collected so far variant_stack = [] # nested ":variant" commands had_recipe_cmd = 0 # encountered ":recipe" command fp.nextline() # read the first line while 1: # Skip leading white space (unless at end of file). if not fp.line is None: indent = get_indent(fp.line) fp.idx = skip_white(fp.line, 0) # If it's not a shell command and the previous line was, generate the # collected shell commands now. if shell_cmd: if (fp.line is None \ or indent < shell_cmd_indent \ or fp.line[fp.idx:fp.idx + 4] != ":sys"): script = script + (' ' * shell_cmd_indent) \ + ('aap_shell(%d, globals(), "%s")\n' % (shell_cmd_line_nr, shell_cmd)) shell_cmd = '' elif not fp.line is None: # Append the recipe line number, used for error messages. script = script + ("%s%d\n" % (line_marker, fp.rpstack[-1].line_nr)) # # Handle the end of commands in a variant or the end of a variant # if len(variant_stack) > 0: v = variant_stack[-1] if fp.line is None or indent <= v.min_indent: # End of the :variant command. if v.val_indent == 0: recipe_error(fp.rpstack, _("Exepected list of values after :variant")) script = script + (' ' * v.min_indent) + ( "BDIR = BDIR + '-' + %s\n" % v.name) del variant_stack[-1] if len(variant_stack) > 0: continue # another may end here as well else: if v.val_indent == 0: v.val_indent = indent first = 1 else: first = 0 if indent <= v.val_indent: # Start of a variant value: "debug [ condition ]" # We simply ignore the condition here. if v.had_star: recipe_error(fp.rpstack, _("Variant item * must be last one")) if fp.idx < fp.line_len and fp.line[fp.idx] == '*': if (fp.idx + 1 < fp.line_len and not is_white(fp.line[fp.idx + 1])): recipe_error(fp.rpstack, _("* must be by itself")) if not first: script = script + (' ' * v.min_indent) + "else:\n" v.had_star = 1 else: val, n = get_var_name(fp) if val == '': recipe_error(fp.rpstack, _("Exepected variant value")) if first: # Specify the default value script = script + (' ' * v.min_indent) + ( 'if not globals().has_key("%s"):\n' % v.name) script = script + (' ' * v.min_indent) + ( ' %s = "%s"\n' % (v.name, val)) script = script + (' ' * v.min_indent) + ( "if %s == '%s':\n" % (v.name, val)) fp.nextline() if fp.line is None or get_indent(fp.line) <= v.val_indent: script = script + (' ' * v.min_indent) + "pass\n" continue # # Stop at the end of the file. # if fp.line is None: break # # A Python block # # recipe: :python <<< # command # command # <<< # Python: if 1: # command # command # if fp.line[fp.idx:fp.idx + 7] == ":python": fp.idx = skip_white(fp.line, fp.idx + 7) if fp.idx >= fp.line_len or fp.line[fp.idx] == '#': term = None else: n = skip_to_white(fp.line, fp.idx) term = fp.line[fp.idx:n] term_len = len(term) n = skip_white(fp.line, n) if n < fp.line_len and fp.line[n] != '#': recipe_error(fp.rpstack, _("Too many arguments for :python")) start_line_nr = fp.rpstack[-1].line_nr first = 1 while 1: fp.nextline() if fp.line is None: if not term: break fp.rpstack[-1].line_nr = start_line_nr recipe_error(fp.rpstack, _("Unterminated :python block")) if first: first = 0 # If the indent of the Python block is more than the # current indent, insert an ":if 1". if get_indent(fp.line) > indent: script = script + (indent * ' ') + "if 1:" + '\n' if not term: # No terminator defined: end when indent is smaller. if get_indent(fp.line) <= indent: break else: # Terminator defined: end when it's found. n = skip_white(fp.line, 0) if n < fp.line_len and fp.line[n:n + term_len] == term: n = skip_white(fp.line, n + term_len) if n >= fp.line_len or fp.line[n] == "#": fp.nextline() break # Append the recipe line number, used for error messages. script = script + ("%s%d\n%s\n" % (line_marker, fp.rpstack[-1].line_nr, fp.line)) continue # # An A-A-P command # # recipe: :cmd arg arg # arg # Python: aap_cmd(123, globals(), "arg arg arg") # if fp.line[fp.idx] == ":": s = fp.idx fp.idx = fp.idx + 1 e = skip_to_white(fp.line, fp.idx) cmd_name = fp.line[fp.idx:e] fp.idx = skip_white(fp.line, e) # Check if this is a valid command name. The error is postponed # until executing the line, so that "@if aapversion > nr" can be # used before it. if cmd_name not in aap_cmd_names: cmd_name = "unknown" fp.idx = s if fp.string and cmd_name in aap_cmd_toplevel: cmd_name = "nothere" fp.idx = s # # To avoid starting a shell for every single command, collect # system commands until encountering another command. # # recipe: :system one-shell-command # :sys two-shell-command # Python: aap_shell(123, globals(), # "one-shell_command\ntwo_shell_command\n") # if cmd_name == "system" or cmd_name == "sys": if not shell_cmd: shell_cmd_line_nr = fp.rpstack[-1].line_nr shell_cmd_indent = indent shell_cmd = shell_cmd + getarg(fp, "#", globals) + '\\n' # get the next line fp.nextline() continue # recipe: :variant VAR # foo [ condition ] # cmds # * [ condition ] # cmds # Python: if VAR == "foo": # cmds # else: # cmds # BDIR = BDIR + '-' + VAR # This is complicated, because "cmds" can be any command, and # variants may nest. Store the info about the variant in # variant_stack and continue, the rest is handled above. if cmd_name == "variant": var_name, n = get_var_name(fp) if var_name == '' or (n < fp.line_len and fp.line[n] != '#'): recipe_error(fp.rpstack, _("Expected variable name after :variant")) variant_stack.append(Variant(var_name, indent)) # get the next line fp.nextline() continue # Generate a call to the Python function for this command. script = script + (indent * ' ') + ('aap_%s(%d, globals(), ' % (cmd_name, fp.rpstack[-1].line_nr)) if cmd_name == "rule" or cmd_name == "autodepend": # recipe: :rule target : {attr} source # commands # Python: aap_rule(123, globals(), "target", "source", # 124, """commands""") # # recipe: :autodepend {attr} source # commands # Python: aap_autodepend(123, globals(), "source", # 124, """commands""") if cmd_name == "rule": target = getarg(fp, ":#", globals) if fp.idx >= fp.line_len or fp.line[fp.idx] != ':': recipe_error(fp.rpstack, _("Missing ':' after :%s") % cmd_name) fp.idx = fp.idx + 1 script = script + ('"%s", ' % target) source = getarg(fp, "#", globals) cmd_line_nr = fp.rpstack[-1].line_nr fp.nextline() cmds = get_commands(fp, indent) script = script + ('"%s", %d, %s)\n' % (source, cmd_line_nr, cmds)) elif cmd_name == "filetype": # recipe: :filetype [filename] # detection-lines # Python: aap_filetype_python(123, globals(), "arg", # """detection-lines""") # arg = getarg(fp, "#", globals) cmd_line_nr = fp.rpstack[-1].line_nr fp.nextline() cmds = get_commands(fp, indent) script = script + '"%s", %d, %s)\n' % (arg, cmd_line_nr, cmds) else: # get arguments that may continue on the next line script = script + get_func_args(fp, indent, globals) + ")\n" # When a ":recipe" command is encountered that will probably # be executed, make a copy of the globals at the start, so that # this can be restored when executing the updated recipe. # This is a "heavy" command, only do it when needed. from Commands import do_recipe_cmd if (cmd_name == "recipe" and not had_recipe_cmd and do_recipe_cmd(fp.rpstack)): had_recipe_cmd = 1 script = ('globals()["_start_globals"] = globals().copy()\n' + script) continue # # A Python command # # recipe: @command args # Python: command args # if fp.line[fp.idx] == "@": if fp.idx + 1 < fp.line_len: if fp.line[fp.idx + 1] == ' ' \ or fp.line[fp.idx + 1] == '\t': # followed by white space: replace @ with a space script = script \ + string.replace(fp.line, '@', ' ', 1) + '\n' else: # followed by text: remove the @ script = script \ + string.replace(fp.line, '@', '', 1) + '\n' # get the next line fp.nextline() continue # # Assignment # # recipe: name = $VAR {attr=val} ` glob("*.c") ` # two # Python: name = aap_eval(123, globals(), # "$VAR {attr=val} " + glob("*.c") + " two", 1) # # var = value assign # var += value append (assign if not set yet) # var ?= value only assign when not set yet # var $= value evaluate when used # var $+= value append, evaluate when used # var $?= value only when not set, evaluate when used var_name, n = get_var_name(fp) if n < fp.line_len: nc = fp.line[n] ec = nc if ec == '$' and n + 1 < fp.line_len: ne = n + 1 ec = fp.line[ne] else: ne = n if (ec == '+' or ec == '?') and ne + 1 < fp.line_len: lc = ec ne = ne + 1 ec = fp.line[ne] else: lc = '' if var_name != '' and ec == '=': # When changing $CACHE need to flush the cache and reload it. if var_name == "CACHE": script = script + (indent * ' ') + "flush_cache()\n" fp.idx = skip_white(fp.line, ne + 1) script = script + (indent * ' ') + ( "aap_assign(%d, globals(), '%s', " % (fp.rpstack[-1].line_nr, var_name)) args = get_func_args(fp, indent, globals) script = script + args + (", '%s', '%s')\n" % (nc, lc)) continue # # If there is no ":" following we don't know what it is. # targets = getarg(fp, ":#", globals) if fp.idx >= fp.line_len or fp.line[fp.idx] != ':': recipe_error(fp.rpstack, _("No recognized item")) if fp.string: recipe_error(fp.rpstack, _("Dependency not allowed here")) # # Dependency # # recipe: target target : source source # commands # Python: aap_depend(123, globals(), list-of-targets, # list-of-sources, "commands") # else: # Skip the ':' and get the list of sources. fp.idx = skip_white(fp.line, fp.idx + 1) sources = getarg(fp, '#', globals) nr = fp.rpstack[-1].line_nr fp.nextline() script = script + ('aap_depend(%d, globals(), "%s", "%s", %d, ' % (nr, targets, sources, fp.rpstack[-1].line_nr)) # get the commands and the following line cmds = get_commands(fp, indent) script = script + cmds + ')\n' # # End of loop over all lines in recipe. # if fp.string: # When parsing a string need to take care of the indent. if is_white(fp.string[0]): script = "if 1:\n" + script else: # Close the file before executing the script, so that ":recipe" can # overwrite the file. fp.file.close() # Prepend the default imports. script = "from Commands import *\n" \ + "from glob import glob\n" \ + script # DEBUG #print script # # Execute the resulting Python script. # Give a useful error message when something is wrong. # try: exec script in globals, globals except StandardError, e: script_error(fp.rpstack, script, e) # vim: set sw=4 sts=4 tw=79 fo+=l: .