| # |
| # Copyright BitBake Contributors |
| # |
| # SPDX-License-Identifier: GPL-2.0-only |
| # |
| |
| """ |
| BitBake code parser |
| |
| Parses actual code (i.e. python and shell) for functions and in-line |
| expressions. Used mainly to determine dependencies on other functions |
| and variables within the BitBake metadata. Also provides a cache for |
| this information in order to speed up processing. |
| |
| (Not to be confused with the code that parses the metadata itself, |
| see lib/bb/parse/ for that). |
| |
| NOTE: if you change how the parsers gather information you will almost |
| certainly need to increment CodeParserCache.CACHE_VERSION below so that |
| any existing codeparser cache gets invalidated. Additionally you'll need |
| to increment __cache_version__ in cache.py in order to ensure that old |
| recipe caches don't trigger "Taskhash mismatch" errors. |
| |
| """ |
| |
| import ast |
| import sys |
| import codegen |
| import logging |
| import inspect |
| import bb.pysh as pysh |
| import bb.utils, bb.data |
| import hashlib |
| from itertools import chain |
| from bb.pysh import pyshyacc, pyshlex |
| from bb.cache import MultiProcessCache |
| |
| logger = logging.getLogger('BitBake.CodeParser') |
| |
| def bbhash(s): |
| return hashlib.sha256(s.encode("utf-8")).hexdigest() |
| |
| def check_indent(codestr): |
| """If the code is indented, add a top level piece of code to 'remove' the indentation""" |
| |
| i = 0 |
| while codestr[i] in ["\n", "\t", " "]: |
| i = i + 1 |
| |
| if i == 0: |
| return codestr |
| |
| if codestr[i-1] == "\t" or codestr[i-1] == " ": |
| if codestr[0] == "\n": |
| # Since we're adding a line, we need to remove one line of any empty padding |
| # to ensure line numbers are correct |
| codestr = codestr[1:] |
| return "if 1:\n" + codestr |
| |
| return codestr |
| |
| modulecode_deps = {} |
| |
| def add_module_functions(fn, functions, namespace): |
| import os |
| fstat = os.stat(fn) |
| fixedhash = fn + ":" + str(fstat.st_size) + ":" + str(fstat.st_mtime) |
| for f in functions: |
| name = "%s.%s" % (namespace, f) |
| parser = PythonParser(name, logger) |
| try: |
| parser.parse_python(None, filename=fn, lineno=1, fixedhash=fixedhash+f) |
| #bb.warn("Cached %s" % f) |
| except KeyError: |
| lines, lineno = inspect.getsourcelines(functions[f]) |
| src = "".join(lines) |
| parser.parse_python(src, filename=fn, lineno=lineno, fixedhash=fixedhash+f) |
| #bb.warn("Not cached %s" % f) |
| execs = parser.execs.copy() |
| # Expand internal module exec references |
| for e in parser.execs: |
| if e in functions: |
| execs.remove(e) |
| execs.add(namespace + "." + e) |
| modulecode_deps[name] = [parser.references.copy(), execs, parser.var_execs.copy(), parser.contains.copy()] |
| #bb.warn("%s: %s\nRefs:%s Execs: %s %s %s" % (name, fn, parser.references, parser.execs, parser.var_execs, parser.contains)) |
| |
| def update_module_dependencies(d): |
| for mod in modulecode_deps: |
| excludes = set((d.getVarFlag(mod, "vardepsexclude") or "").split()) |
| if excludes: |
| modulecode_deps[mod] = [modulecode_deps[mod][0] - excludes, modulecode_deps[mod][1] - excludes, modulecode_deps[mod][2] - excludes, modulecode_deps[mod][3]] |
| |
| # A custom getstate/setstate using tuples is actually worth 15% cachesize by |
| # avoiding duplication of the attribute names! |
| class SetCache(object): |
| def __init__(self): |
| self.setcache = {} |
| |
| def internSet(self, items): |
| |
| new = [] |
| for i in items: |
| new.append(sys.intern(i)) |
| s = frozenset(new) |
| h = hash(s) |
| if h in self.setcache: |
| return self.setcache[h] |
| self.setcache[h] = s |
| return s |
| |
| codecache = SetCache() |
| |
| class pythonCacheLine(object): |
| def __init__(self, refs, execs, contains): |
| self.refs = codecache.internSet(refs) |
| self.execs = codecache.internSet(execs) |
| self.contains = {} |
| for c in contains: |
| self.contains[c] = codecache.internSet(contains[c]) |
| |
| def __getstate__(self): |
| return (self.refs, self.execs, self.contains) |
| |
| def __setstate__(self, state): |
| (refs, execs, contains) = state |
| self.__init__(refs, execs, contains) |
| def __hash__(self): |
| l = (hash(self.refs), hash(self.execs)) |
| for c in sorted(self.contains.keys()): |
| l = l + (c, hash(self.contains[c])) |
| return hash(l) |
| def __repr__(self): |
| return " ".join([str(self.refs), str(self.execs), str(self.contains)]) |
| |
| |
| class shellCacheLine(object): |
| def __init__(self, execs): |
| self.execs = codecache.internSet(execs) |
| |
| def __getstate__(self): |
| return (self.execs) |
| |
| def __setstate__(self, state): |
| (execs) = state |
| self.__init__(execs) |
| def __hash__(self): |
| return hash(self.execs) |
| def __repr__(self): |
| return str(self.execs) |
| |
| class CodeParserCache(MultiProcessCache): |
| cache_file_name = "bb_codeparser.dat" |
| # NOTE: you must increment this if you change how the parsers gather information, |
| # so that an existing cache gets invalidated. Additionally you'll need |
| # to increment __cache_version__ in cache.py in order to ensure that old |
| # recipe caches don't trigger "Taskhash mismatch" errors. |
| CACHE_VERSION = 11 |
| |
| def __init__(self): |
| MultiProcessCache.__init__(self) |
| self.pythoncache = self.cachedata[0] |
| self.shellcache = self.cachedata[1] |
| self.pythoncacheextras = self.cachedata_extras[0] |
| self.shellcacheextras = self.cachedata_extras[1] |
| |
| # To avoid duplication in the codeparser cache, keep |
| # a lookup of hashes of objects we already have |
| self.pythoncachelines = {} |
| self.shellcachelines = {} |
| |
| def newPythonCacheLine(self, refs, execs, contains): |
| cacheline = pythonCacheLine(refs, execs, contains) |
| h = hash(cacheline) |
| if h in self.pythoncachelines: |
| return self.pythoncachelines[h] |
| self.pythoncachelines[h] = cacheline |
| return cacheline |
| |
| def newShellCacheLine(self, execs): |
| cacheline = shellCacheLine(execs) |
| h = hash(cacheline) |
| if h in self.shellcachelines: |
| return self.shellcachelines[h] |
| self.shellcachelines[h] = cacheline |
| return cacheline |
| |
| def init_cache(self, cachedir): |
| # Check if we already have the caches |
| if self.pythoncache: |
| return |
| |
| MultiProcessCache.init_cache(self, cachedir) |
| |
| # cachedata gets re-assigned in the parent |
| self.pythoncache = self.cachedata[0] |
| self.shellcache = self.cachedata[1] |
| |
| def create_cachedata(self): |
| data = [{}, {}] |
| return data |
| |
| codeparsercache = CodeParserCache() |
| |
| def parser_cache_init(cachedir): |
| codeparsercache.init_cache(cachedir) |
| |
| def parser_cache_save(): |
| codeparsercache.save_extras() |
| |
| def parser_cache_savemerge(): |
| codeparsercache.save_merge() |
| |
| Logger = logging.getLoggerClass() |
| class BufferedLogger(Logger): |
| def __init__(self, name, level=0, target=None): |
| Logger.__init__(self, name) |
| self.setLevel(level) |
| self.buffer = [] |
| self.target = target |
| |
| def handle(self, record): |
| self.buffer.append(record) |
| |
| def flush(self): |
| for record in self.buffer: |
| if self.target.isEnabledFor(record.levelno): |
| self.target.handle(record) |
| self.buffer = [] |
| |
| class DummyLogger(): |
| def flush(self): |
| return |
| |
| class PythonParser(): |
| getvars = (".getVar", ".appendVar", ".prependVar", "oe.utils.conditional") |
| getvarflags = (".getVarFlag", ".appendVarFlag", ".prependVarFlag") |
| containsfuncs = ("bb.utils.contains", "base_contains") |
| containsanyfuncs = ("bb.utils.contains_any", "bb.utils.filter") |
| execfuncs = ("bb.build.exec_func", "bb.build.exec_task") |
| |
| def warn(self, func, arg): |
| """Warn about calls of bitbake APIs which pass a non-literal |
| argument for the variable name, as we're not able to track such |
| a reference. |
| """ |
| |
| try: |
| funcstr = codegen.to_source(func) |
| argstr = codegen.to_source(arg) |
| except TypeError: |
| self.log.debug2('Failed to convert function and argument to source form') |
| else: |
| self.log.debug(self.unhandled_message % (funcstr, argstr)) |
| |
| def visit_Call(self, node): |
| name = self.called_node_name(node.func) |
| if name and (name.endswith(self.getvars) or name.endswith(self.getvarflags) or name in self.containsfuncs or name in self.containsanyfuncs): |
| if isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str): |
| varname = node.args[0].value |
| if name in self.containsfuncs and isinstance(node.args[1], ast.Constant): |
| if varname not in self.contains: |
| self.contains[varname] = set() |
| self.contains[varname].add(node.args[1].value) |
| elif name in self.containsanyfuncs and isinstance(node.args[1], ast.Constant): |
| if varname not in self.contains: |
| self.contains[varname] = set() |
| self.contains[varname].update(node.args[1].value.split()) |
| elif name.endswith(self.getvarflags): |
| if isinstance(node.args[1], ast.Constant): |
| self.references.add('%s[%s]' % (varname, node.args[1].value)) |
| else: |
| self.warn(node.func, node.args[1]) |
| else: |
| self.references.add(varname) |
| else: |
| self.warn(node.func, node.args[0]) |
| elif name and name.endswith(".expand"): |
| if isinstance(node.args[0], ast.Constant): |
| value = node.args[0].value |
| d = bb.data.init() |
| parser = d.expandWithRefs(value, self.name) |
| self.references |= parser.references |
| self.execs |= parser.execs |
| for varname in parser.contains: |
| if varname not in self.contains: |
| self.contains[varname] = set() |
| self.contains[varname] |= parser.contains[varname] |
| elif name in self.execfuncs: |
| if isinstance(node.args[0], ast.Constant): |
| self.var_execs.add(node.args[0].value) |
| else: |
| self.warn(node.func, node.args[0]) |
| elif name and isinstance(node.func, (ast.Name, ast.Attribute)): |
| self.execs.add(name) |
| |
| def called_node_name(self, node): |
| """Given a called node, return its original string form""" |
| components = [] |
| while node: |
| if isinstance(node, ast.Attribute): |
| components.append(node.attr) |
| node = node.value |
| elif isinstance(node, ast.Name): |
| components.append(node.id) |
| return '.'.join(reversed(components)) |
| else: |
| break |
| |
| def __init__(self, name, log): |
| self.name = name |
| self.var_execs = set() |
| self.contains = {} |
| self.execs = set() |
| self.references = set() |
| self._log = log |
| # Defer init as expensive |
| self.log = DummyLogger() |
| |
| self.unhandled_message = "in call of %s, argument '%s' is not a string literal" |
| self.unhandled_message = "while parsing %s, %s" % (name, self.unhandled_message) |
| |
| # For the python module code it is expensive to have the function text so it is |
| # uses a different fixedhash to cache against. We can take the hit on obtaining the |
| # text if it isn't in the cache. |
| def parse_python(self, node, lineno=0, filename="<string>", fixedhash=None): |
| if not fixedhash and (not node or not node.strip()): |
| return |
| |
| if fixedhash: |
| h = fixedhash |
| else: |
| h = bbhash(str(node)) |
| |
| if h in codeparsercache.pythoncache: |
| self.references = set(codeparsercache.pythoncache[h].refs) |
| self.execs = set(codeparsercache.pythoncache[h].execs) |
| self.contains = {} |
| for i in codeparsercache.pythoncache[h].contains: |
| self.contains[i] = set(codeparsercache.pythoncache[h].contains[i]) |
| return |
| |
| if h in codeparsercache.pythoncacheextras: |
| self.references = set(codeparsercache.pythoncacheextras[h].refs) |
| self.execs = set(codeparsercache.pythoncacheextras[h].execs) |
| self.contains = {} |
| for i in codeparsercache.pythoncacheextras[h].contains: |
| self.contains[i] = set(codeparsercache.pythoncacheextras[h].contains[i]) |
| return |
| |
| if fixedhash and not node: |
| raise KeyError |
| |
| # Need to parse so take the hit on the real log buffer |
| self.log = BufferedLogger('BitBake.Data.PythonParser', logging.DEBUG, self._log) |
| |
| # We can't add to the linenumbers for compile, we can pad to the correct number of blank lines though |
| node = "\n" * int(lineno) + node |
| code = compile(check_indent(str(node)), filename, "exec", |
| ast.PyCF_ONLY_AST) |
| |
| for n in ast.walk(code): |
| if n.__class__.__name__ == "Call": |
| self.visit_Call(n) |
| |
| self.execs.update(self.var_execs) |
| |
| codeparsercache.pythoncacheextras[h] = codeparsercache.newPythonCacheLine(self.references, self.execs, self.contains) |
| |
| class ShellParser(): |
| def __init__(self, name, log): |
| self.funcdefs = set() |
| self.allexecs = set() |
| self.execs = set() |
| self._name = name |
| self._log = log |
| # Defer init as expensive |
| self.log = DummyLogger() |
| |
| self.unhandled_template = "unable to handle non-literal command '%s'" |
| self.unhandled_template = "while parsing %s, %s" % (name, self.unhandled_template) |
| |
| def parse_shell(self, value): |
| """Parse the supplied shell code in a string, returning the external |
| commands it executes. |
| """ |
| |
| h = bbhash(str(value)) |
| |
| if h in codeparsercache.shellcache: |
| self.execs = set(codeparsercache.shellcache[h].execs) |
| return self.execs |
| |
| if h in codeparsercache.shellcacheextras: |
| self.execs = set(codeparsercache.shellcacheextras[h].execs) |
| return self.execs |
| |
| # Need to parse so take the hit on the real log buffer |
| self.log = BufferedLogger('BitBake.Data.%s' % self._name, logging.DEBUG, self._log) |
| |
| self._parse_shell(value) |
| self.execs = set(cmd for cmd in self.allexecs if cmd not in self.funcdefs) |
| |
| codeparsercache.shellcacheextras[h] = codeparsercache.newShellCacheLine(self.execs) |
| |
| return self.execs |
| |
| def _parse_shell(self, value): |
| try: |
| tokens, _ = pyshyacc.parse(value, eof=True, debug=False) |
| except Exception: |
| bb.error('Error during parse shell code, the last 5 lines are:\n%s' % '\n'.join(value.split('\n')[-5:])) |
| raise |
| |
| self.process_tokens(tokens) |
| |
| def process_tokens(self, tokens): |
| """Process a supplied portion of the syntax tree as returned by |
| pyshyacc.parse. |
| """ |
| |
| def function_definition(value): |
| self.funcdefs.add(value.name) |
| return [value.body], None |
| |
| def case_clause(value): |
| # Element 0 of each item in the case is the list of patterns, and |
| # Element 1 of each item in the case is the list of commands to be |
| # executed when that pattern matches. |
| words = chain(*[item[0] for item in value.items]) |
| cmds = chain(*[item[1] for item in value.items]) |
| return cmds, words |
| |
| def if_clause(value): |
| main = chain(value.cond, value.if_cmds) |
| rest = value.else_cmds |
| if isinstance(rest, tuple) and rest[0] == "elif": |
| return chain(main, if_clause(rest[1])) |
| else: |
| return chain(main, rest) |
| |
| def simple_command(value): |
| return None, chain(value.words, (assign[1] for assign in value.assigns)) |
| |
| token_handlers = { |
| "and_or": lambda x: ((x.left, x.right), None), |
| "async": lambda x: ([x], None), |
| "brace_group": lambda x: (x.cmds, None), |
| "for_clause": lambda x: (x.cmds, x.items), |
| "function_definition": function_definition, |
| "if_clause": lambda x: (if_clause(x), None), |
| "pipeline": lambda x: (x.commands, None), |
| "redirect_list": lambda x: ([x.cmd], None), |
| "subshell": lambda x: (x.cmds, None), |
| "while_clause": lambda x: (chain(x.condition, x.cmds), None), |
| "until_clause": lambda x: (chain(x.condition, x.cmds), None), |
| "simple_command": simple_command, |
| "case_clause": case_clause, |
| } |
| |
| def process_token_list(tokens): |
| for token in tokens: |
| if isinstance(token, list): |
| process_token_list(token) |
| continue |
| name, value = token |
| try: |
| more_tokens, words = token_handlers[name](value) |
| except KeyError: |
| raise NotImplementedError("Unsupported token type " + name) |
| |
| if more_tokens: |
| self.process_tokens(more_tokens) |
| |
| if words: |
| self.process_words(words) |
| |
| process_token_list(tokens) |
| |
| def process_words(self, words): |
| """Process a set of 'words' in pyshyacc parlance, which includes |
| extraction of executed commands from $() blocks, as well as grabbing |
| the command name argument. |
| """ |
| |
| words = list(words) |
| for word in list(words): |
| wtree = pyshlex.make_wordtree(word[1]) |
| for part in wtree: |
| if not isinstance(part, list): |
| continue |
| |
| if part[0] in ('`', '$('): |
| command = pyshlex.wordtree_as_string(part[1:-1]) |
| self._parse_shell(command) |
| |
| if word[0] in ("cmd_name", "cmd_word"): |
| if word in words: |
| words.remove(word) |
| |
| usetoken = False |
| for word in words: |
| if word[0] in ("cmd_name", "cmd_word") or \ |
| (usetoken and word[0] == "TOKEN"): |
| if "=" in word[1]: |
| usetoken = True |
| continue |
| |
| cmd = word[1] |
| if cmd.startswith("$"): |
| self.log.debug(self.unhandled_template % cmd) |
| elif cmd == "eval": |
| command = " ".join(word for _, word in words[1:]) |
| self._parse_shell(command) |
| else: |
| self.allexecs.add(cmd) |
| break |