| from __future__ import with_statement |
| |
| import __future__ |
| import functools |
| import imp |
| import inspect |
| import json |
| from pathlib import Path |
| import optparse |
| import os |
| import os.path |
| import sys |
| |
| |
| # When build files are executed, the functions in this file tagged with |
| # @provide_for_build will be provided in the build file's local symbol table. |
| # |
| # When these functions are called from a build file, they will be passed |
| # a keyword parameter, build_env, which is a object with information about |
| # the environment of the build file which is currently being processed. |
| # It contains the following attributes: |
| # |
| # "dirname" - The directory containing the build file. |
| # |
| # "base_path" - The base path of the build file. |
| |
| BUILD_FUNCTIONS = [] |
| |
| |
| class BuildContextType(object): |
| """ |
| Identifies the type of input file to the processor. |
| """ |
| |
| BUILD_FILE = 'build_file' |
| INCLUDE = 'include' |
| |
| |
| class BuildFileContext(object): |
| """ |
| The build context used when processing a build file. |
| """ |
| |
| type = BuildContextType.BUILD_FILE |
| |
| def __init__(self, base_path, dirname, allow_empty_globs): |
| self.globals = {} |
| self.includes = set() |
| self.base_path = base_path |
| self.dirname = dirname |
| self.allow_empty_globs = allow_empty_globs |
| self.rules = {} |
| |
| |
| class IncludeContext(object): |
| """ |
| The build context used when processing an include. |
| """ |
| |
| type = BuildContextType.INCLUDE |
| |
| def __init__(self): |
| self.globals = {} |
| self.includes = set() |
| |
| |
| class LazyBuildEnvPartial(object): |
| """Pairs a function with a build environment in which it will be executed. |
| |
| Note that while the function is specified via the constructor, the build |
| environment must be assigned after construction, for the build environment |
| currently being used. |
| |
| To call the function with its build environment, use the invoke() method of |
| this class, which will forward the arguments from invoke() to the |
| underlying function. |
| """ |
| |
| def __init__(self, func): |
| self.func = func |
| self.build_env = None |
| |
| def invoke(self, *args, **kwargs): |
| """Invokes the bound function injecting 'build_env' into **kwargs.""" |
| updated_kwargs = kwargs.copy() |
| updated_kwargs.update({'build_env': self.build_env}) |
| return self.func(*args, **updated_kwargs) |
| |
| |
| def provide_for_build(func): |
| BUILD_FUNCTIONS.append(func) |
| return func |
| |
| |
| def add_rule(rule, build_env): |
| assert build_env.type == BuildContextType.BUILD_FILE, ( |
| "Cannot use `{}()` at the top-level of an included file." |
| .format(rule['type'])) |
| |
| # Include the base path of the BUILD file so the reader consuming this |
| # JSON will know which BUILD file the rule came from. |
| if 'name' not in rule: |
| raise ValueError( |
| 'rules must contain the field \'name\'. Found %s.' % rule) |
| rule_name = rule['name'] |
| if rule_name in build_env.rules: |
| raise ValueError('Duplicate rule definition found. Found %s and %s' % |
| (rule, build_env.rules[rule_name])) |
| rule['buck.base_path'] = build_env.base_path |
| build_env.rules[rule_name] = rule |
| |
| |
| @provide_for_build |
| def glob(includes, excludes=[], include_dotfiles=False, build_env=None): |
| assert build_env.type == BuildContextType.BUILD_FILE, ( |
| "Cannot use `glob()` at the top-level of an included file.") |
| |
| search_base = Path(build_env.dirname) |
| return glob_internal( |
| includes, |
| excludes, |
| include_dotfiles, |
| build_env.allow_empty_globs, |
| search_base) |
| |
| |
| def glob_internal(includes, excludes, include_dotfiles, allow_empty, search_base): |
| # Ensure the user passes lists of strings rather than just a string. |
| assert not isinstance(includes, basestring), \ |
| "The first argument to glob() must be a list of strings." |
| assert not isinstance(excludes, basestring), \ |
| "The excludes argument must be a list of strings." |
| |
| def includes_iterator(): |
| for pattern in includes: |
| for path in search_base.glob(pattern): |
| # TODO(user): Handle hidden files on Windows. |
| if path.is_file() and (include_dotfiles or not path.name.startswith('.')): |
| yield path.relative_to(search_base) |
| |
| def is_special(pat): |
| return "*" in pat or "?" in pat or "[" in pat |
| |
| non_special_excludes = set() |
| match_excludes = set() |
| for pattern in excludes: |
| if is_special(pattern): |
| match_excludes.add(pattern) |
| else: |
| non_special_excludes.add(pattern) |
| |
| def exclusion(path): |
| if path.as_posix() in non_special_excludes: |
| return True |
| for pattern in match_excludes: |
| result = path.match(pattern, match_entire=True) |
| if result: |
| return True |
| return False |
| |
| results = sorted(set([str(p) for p in includes_iterator() if not exclusion(p)])) |
| assert allow_empty or results, ( |
| "glob(includes={includes}, excludes={excludes}, include_dotfiles={include_dotfiles}) " + |
| "returned no results. (allow_empty_globs is set to false in the Buck " + |
| "configuration)").format( |
| includes=includes, |
| excludes=excludes, |
| include_dotfiles=include_dotfiles) |
| |
| return results |
| |
| |
| @provide_for_build |
| def get_base_path(build_env=None): |
| """Get the base path to the build file that was initially evaluated. |
| |
| This function is intended to be used from within a build defs file that |
| likely contains macros that could be called from any build file. |
| Such macros may need to know the base path of the file in which they |
| are defining new build rules. |
| |
| Returns: a string, such as "java/com/facebook". Note there is no |
| trailing slash. The return value will be "" if called from |
| the build file in the root of the project. |
| """ |
| assert build_env.type == BuildContextType.BUILD_FILE, ( |
| "Cannot use `get_base_path()` at the top-level of an included file.") |
| return build_env.base_path |
| |
| |
| @provide_for_build |
| def add_deps(name, deps=[], build_env=None): |
| assert build_env.type == BuildContextType.BUILD_FILE, ( |
| "Cannot use `add_deps()` at the top-level of an included file.") |
| |
| if name not in build_env.rules: |
| raise ValueError( |
| 'Invoked \'add_deps\' on non-existent rule %s.' % name) |
| |
| rule = build_env.rules[name] |
| if 'deps' not in rule: |
| raise ValueError( |
| 'Invoked \'add_deps\' on rule %s that has no \'deps\' field' |
| % name) |
| rule['deps'] = rule['deps'] + deps |
| |
| |
| class BuildFileProcessor(object): |
| |
| def __init__(self, project_root, build_file_name, allow_empty_globs, implicit_includes=[]): |
| self._cache = {} |
| self._build_env_stack = [] |
| |
| self._project_root = project_root |
| self._build_file_name = build_file_name |
| self._implicit_includes = implicit_includes |
| self._allow_empty_globs = allow_empty_globs |
| |
| lazy_functions = {} |
| for func in BUILD_FUNCTIONS: |
| func_with_env = LazyBuildEnvPartial(func) |
| lazy_functions[func.__name__] = func_with_env |
| self._functions = lazy_functions |
| |
| def _merge_globals(self, src, dst): |
| """ |
| Copy the global definitions from one globals dict to another. |
| |
| Ignores special attributes and attributes starting with '_', which |
| typically denote module-level private attributes. |
| """ |
| |
| hidden = set([ |
| 'include_defs', |
| ]) |
| |
| for key, val in src.iteritems(): |
| if not key.startswith('_') and key not in hidden: |
| dst[key] = val |
| |
| def _update_functions(self, build_env): |
| """ |
| Updates the build functions to use the given build context when called. |
| """ |
| |
| for function in self._functions.itervalues(): |
| function.build_env = build_env |
| |
| def _install_functions(self, namespace): |
| """ |
| Installs the build functions, by their name, into the given namespace. |
| """ |
| |
| for name, function in self._functions.iteritems(): |
| namespace[name] = function.invoke |
| |
| def _get_include_path(self, name): |
| """ |
| Resolve the given include def name to a full path. |
| """ |
| |
| # Find the path from the include def name. |
| if not name.startswith('//'): |
| raise ValueError( |
| 'include_defs argument "%s" must begin with //' % name) |
| relative_path = name[2:] |
| return os.path.join(self._project_root, name[2:]) |
| |
| def _include_defs(self, name, implicit_includes=[]): |
| """ |
| Pull the named include into the current caller's context. |
| |
| This method is meant to be installed into the globals of any files or |
| includes that we process. |
| """ |
| |
| # Grab the current build context from the top of the stack. |
| build_env = self._build_env_stack[-1] |
| |
| # Resolve the named include to its path and process it to get its |
| # build context and module. |
| path = self._get_include_path(name) |
| inner_env, mod = self._process_include( |
| path, |
| implicit_includes=implicit_includes) |
| |
| # Look up the caller's stack frame and merge the include's globals |
| # into it's symbol table. |
| frame = inspect.currentframe() |
| while frame.f_globals['__name__'] == __name__: |
| frame = frame.f_back |
| self._merge_globals(mod.__dict__, frame.f_globals) |
| |
| # Pull in the include's accounting of its own referenced includes |
| # into the current build context. |
| build_env.includes.add(path) |
| build_env.includes.update(inner_env.includes) |
| |
| def _push_build_env(self, build_env): |
| """ |
| Set the given build context as the current context. |
| """ |
| |
| self._build_env_stack.append(build_env) |
| self._update_functions(build_env) |
| |
| def _pop_build_env(self): |
| """ |
| Restore the previous build context as the current context. |
| """ |
| |
| self._build_env_stack.pop() |
| if self._build_env_stack: |
| self._update_functions(self._build_env_stack[-1]) |
| |
| def _process(self, build_env, path, implicit_includes=[]): |
| """ |
| Process a build file or include at the given path. |
| """ |
| |
| # First check the cache. |
| cached = self._cache.get(path) |
| if cached is not None: |
| return cached |
| |
| # Install the build context for this input as the current context. |
| self._push_build_env(build_env) |
| |
| # The globals dict that this file will be executed under. |
| default_globals = {} |
| |
| # Install the implicit build functions and adding the 'include_defs' |
| # functions. |
| self._install_functions(default_globals) |
| default_globals['include_defs'] = functools.partial( |
| self._include_defs, |
| implicit_includes=implicit_includes) |
| |
| # If any implicit includes were specified, process them first. |
| for include in implicit_includes: |
| include_path = self._get_include_path(include) |
| inner_env, mod = self._process_include(include_path) |
| self._merge_globals(mod.__dict__, default_globals) |
| build_env.includes.add(include_path) |
| build_env.includes.update(inner_env.includes) |
| |
| # Build a new module for the given file, using the default globals |
| # created above. |
| module = imp.new_module(path) |
| module.__file__ = path |
| module.__dict__.update(default_globals) |
| |
| with open(path) as f: |
| contents = f.read() |
| |
| # Enable absolute imports. This prevents the compiler from trying to |
| # do a relative import first, and warning that this module doesn't |
| # exist in sys.modules. |
| future_features = __future__.absolute_import.compiler_flag |
| code = compile(contents, path, 'exec', future_features, 1) |
| exec(code, module.__dict__) |
| |
| # Restore the previous build context. |
| self._pop_build_env() |
| |
| self._cache[path] = build_env, module |
| return build_env, module |
| |
| def _process_include(self, path, implicit_includes=[]): |
| """ |
| Process the include file at the given path. |
| """ |
| |
| build_env = IncludeContext() |
| return self._process( |
| build_env, |
| path, |
| implicit_includes=implicit_includes) |
| |
| def _process_build_file(self, path, implicit_includes=[]): |
| """ |
| Process the build file at the given path. |
| """ |
| |
| # Create the build file context, including the base path and directory |
| # name of the given path. |
| relative_path_to_build_file = os.path.relpath( |
| path, self._project_root).replace('\\', '/') |
| len_suffix = -len('/' + self._build_file_name) |
| base_path = relative_path_to_build_file[:len_suffix] |
| dirname = os.path.dirname(path) |
| build_env = BuildFileContext(base_path, dirname, self._allow_empty_globs) |
| |
| return self._process( |
| build_env, |
| path, |
| implicit_includes=implicit_includes) |
| |
| def process(self, path): |
| """ |
| Process a build file returning a dict of it's rules and includes. |
| """ |
| |
| build_env, mod = self._process_build_file( |
| os.path.join(self._project_root, path), |
| implicit_includes=self._implicit_includes) |
| values = build_env.rules.values() |
| values.append({"__includes": [path] + sorted(build_env.includes)}) |
| return values |
| |
| |
| # Inexplicably, this script appears to run faster when the arguments passed |
| # into it are absolute paths. However, we want the "buck.base_path" property |
| # of each rule to be printed out to be the base path of the build target that |
| # identifies the rule. That means that when parsing a BUILD file, we must know |
| # its path relative to the root of the project to produce the base path. |
| # |
| # To that end, the first argument to this script must be an absolute path to |
| # the project root. It must be followed by one or more absolute paths to |
| # BUILD files under the project root. If no paths to BUILD files are |
| # specified, then it will traverse the project root for BUILD files, excluding |
| # directories of generated files produced by Buck. |
| # |
| # All of the build rules that are parsed from the BUILD files will be printed |
| # to stdout by a JSON parser. That means that printing out other information |
| # for debugging purposes will likely break the JSON parsing, so be careful! |
| |
| |
| def main(): |
| # Our parent expects to read JSON from our stdout, so if anyone |
| # uses print, buck will complain with a helpful "but I wanted an |
| # array!" message and quit. Redirect stdout to stderr so that |
| # doesn't happen. Actually dup2 the file handle so that writing |
| # to file descriptor 1, os.system, and so on work as expected too. |
| |
| to_parent = os.fdopen(os.dup(sys.stdout.fileno()), 'a') |
| os.dup2(sys.stderr.fileno(), sys.stdout.fileno()) |
| |
| parser = optparse.OptionParser() |
| parser.add_option( |
| '--project_root', |
| action='store', |
| type='string', |
| dest='project_root') |
| parser.add_option( |
| '--build_file_name', |
| action='store', |
| type='string', |
| dest="build_file_name") |
| parser.add_option( |
| '--allow_empty_globs', |
| action='store_true', |
| dest='allow_empty_globs', |
| help='Tells the parser not to raise an error when glob returns no results.') |
| parser.add_option( |
| '--include', |
| action='append', |
| dest='include') |
| (options, args) = parser.parse_args() |
| |
| # Even though project_root is absolute path, it may not be concise. For |
| # example, it might be like "C:\project\.\rule". |
| project_root = os.path.abspath(options.project_root) |
| |
| buildFileProcessor = BuildFileProcessor( |
| project_root, |
| options.build_file_name, |
| options.allow_empty_globs, |
| implicit_includes=options.include or []) |
| |
| for build_file in args: |
| values = buildFileProcessor.process(build_file) |
| to_parent.write(json.dumps(values)) |
| to_parent.flush() |
| |
| # "for ... in sys.stdin" in Python 2.x hangs until stdin is closed. |
| for build_file in iter(sys.stdin.readline, ''): |
| values = buildFileProcessor.process(build_file.rstrip()) |
| to_parent.write(json.dumps(values)) |
| to_parent.flush() |
| |
| # Python tries to flush/close stdout when it quits, and if there's a dead |
| # pipe on the other end, it will spit some warnings to stderr. This breaks |
| # tests sometimes. Prevent that by explicitly catching the error. |
| try: |
| to_parent.close() |
| except IOError: |
| pass |