blob: 0f58a5256581eb6d90d968fd7ec092d6fc0d9733 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2019 The Dawn & Tint Authors
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""Module to create generators that render multiple Jinja2 templates for GN.
A helper module that can be used to create generator scripts (clients)
that expand one or more Jinja2 templates, without outputs usable from
GN and Ninja build-based systems. See generator_lib.gni as well.
Clients should create a Generator sub-class, then call run_generator()
with a proper derived class instance.
Clients specify a list of FileRender operations, each one of them will
output a file into a temporary output directory through Jinja2 expansion.
All temporary output files are then grouped and written to into a single JSON
file, that acts as a convenient single GN output target. Use extract_json.py
to extract the output files from the JSON tarball in another GN action.
--depfile can be used to specify an output Ninja dependency file for the
JSON tarball, to ensure it is regenerated any time one of its dependencies
changes.
Finally, --expected-output-files can be used to check the list of generated
output files.
"""
import argparse, json, os, re, sys
from collections import namedtuple
# A FileRender represents a single Jinja2 template render operation:
#
# template: Jinja2 template name, relative to --template-dir path.
#
# output: Output file path, relative to temporary output directory.
#
# params_dicts: iterable of (name:string -> value:string) dictionaries.
# All of them will be merged before being sent as Jinja2 template
# expansion parameters.
#
# Example:
# FileRender('api.c', 'src/project_api.c', [{'PROJECT_VERSION': '1.0.0'}])
#
FileRender = namedtuple('FileRender', ['template', 'output', 'params_dicts'])
# A GeneratorOutput represent everything an invocation of the generator will
# produce.
#
# renders: an iterable of FileRenders.
#
# imported_templates: paths to additional templates that will be imported.
# Trying to import with {% from %} will enforce that the file is listed
# to ensure the dependency information produced is correct.
GeneratorOutput = namedtuple('GeneratorOutput',
['renders', 'imported_templates'])
# The interface that must be implemented by generators.
class Generator:
def get_description(self):
"""Return generator description for --help."""
return ""
def add_commandline_arguments(self, parser):
"""Add generator-specific argparse arguments."""
pass
def get_outputs(self, args):
"""Return the list of FileRender objects to process."""
return []
def get_dependencies(self, args):
"""Return a list of extra input dependencies."""
return []
# Allow custom Jinja2 installation path through an additional python
# path from the arguments if present. This isn't done through the regular
# argparse because PreprocessingLoader uses jinja2 in the global scope before
# "main" gets to run.
#
# NOTE: If this argument appears several times, this only uses the first
# value, while argparse would typically keep the last one!
kJinja2Path = '--jinja2-path'
try:
jinja2_path_argv_index = sys.argv.index(kJinja2Path)
# Add parent path for the import to succeed.
path = os.path.join(sys.argv[jinja2_path_argv_index + 1], os.pardir)
sys.path.insert(1, path)
except ValueError:
# --jinja2-path isn't passed, ignore the exception and just import Jinja2
# assuming it already is in the Python PATH.
pass
kMarkupSafePath = '--markupsafe-path'
try:
markupsafe_path_argv_index = sys.argv.index(kMarkupSafePath)
# Add parent path for the import to succeed.
path = os.path.join(sys.argv[markupsafe_path_argv_index + 1], os.pardir)
sys.path.insert(1, path)
except ValueError:
# --markupsafe-path isn't passed, ignore the exception and just import
# assuming it already is in the Python PATH.
pass
import jinja2
# A custom Jinja2 template loader that removes the extra indentation
# of the template blocks so that the output is correctly indented
class _PreprocessingLoader(jinja2.BaseLoader):
def __init__(self, path, allow_list):
self.path = path
self.allow_list = set(allow_list)
# Check that all the listed templates exist.
for template in self.allow_list:
if not os.path.exists(os.path.join(self.path, template)):
raise jinja2.TemplateNotFound(template)
def get_source(self, environment, template):
if not template in self.allow_list:
raise jinja2.TemplateNotFound(template)
path = os.path.join(self.path, template)
mtime = os.path.getmtime(path)
with open(path) as f:
source = self.preprocess(f.read())
return source, path, lambda: mtime == os.path.getmtime(path)
blockstart = re.compile(r'{%-?\s*(if|elif|else|for|block|macro)[^}]*%}')
blockend = re.compile(r'{%-?\s*(end(if|for|block|macro)|elif|else)[^}]*%}')
def preprocess(self, source):
lines = source.split('\n')
# Compute the current indentation level of the template blocks and
# remove their indentation
result = []
indentation_level = 0
# Filter lines that are pure comments. line_comment_prefix is not
# enough because it removes the comment but doesn't completely remove
# the line, resulting in more verbose output.
lines = filter(lambda line: not line.strip().startswith('//*'), lines)
# Remove indentation templates have for the Jinja control flow.
for line in lines:
# The capture in the regex adds one element per block start or end,
# so we divide by two. There is also an extra line chunk
# corresponding to the line end, so we subtract it.
numends = (len(self.blockend.split(line)) - 1) // 2
indentation_level -= numends
result.append(self.remove_indentation(line, indentation_level))
numstarts = (len(self.blockstart.split(line)) - 1) // 2
indentation_level += numstarts
return '\n'.join(result) + '\n'
def remove_indentation(self, line, n):
for _ in range(n):
if line.startswith(' '):
line = line[4:]
elif line.startswith('\t'):
line = line[1:]
else:
assert line.strip() == ''
return line
_FileOutput = namedtuple('FileOutput', ['name', 'content'])
def _do_renders(output, template_dir):
template_allow_list = [render.template for render in output.renders
] + list(output.imported_templates)
loader = _PreprocessingLoader(template_dir, template_allow_list)
env = jinja2.Environment(
extensions=['jinja2.ext.do', 'jinja2.ext.loopcontrols'],
loader=loader,
lstrip_blocks=True,
trim_blocks=True,
line_comment_prefix='//*')
def do_assert(expr, message=''):
assert expr, message
return ''
def debug(text):
print(text)
base_params = {
'enumerate': enumerate,
'format': format,
'len': len,
'debug': debug,
'assert': do_assert,
}
outputs = []
for render in output.renders:
params = {}
params.update(base_params)
for param_dict in render.params_dicts:
params.update(param_dict)
content = env.get_template(render.template).render(**params)
outputs.append(_FileOutput(render.output, content))
return outputs
# Compute the list of imported, non-system Python modules.
# It assumes that any path outside of the root directory is system.
def _compute_python_dependencies(root_dir=None):
if not root_dir:
# Assume this script is under generator/ by default.
root_dir = os.path.join(os.path.dirname(__file__), os.pardir)
root_dir = os.path.abspath(root_dir)
module_paths = (module.__file__ for module in sys.modules.values()
if module and hasattr(module, '__file__'))
paths = set()
for path in module_paths:
# Builtin/namespaced modules may return None for the file path.
if not path:
continue
path = os.path.abspath(path)
if not path.startswith(root_dir):
continue
if (path.endswith('.pyc')
or (path.endswith('c') and not os.path.splitext(path)[1])):
path = path[:-1]
paths.add(path)
return paths
# Computes the string representing a cmake list of paths.
def _cmake_path_list(paths):
if os.name == "nt":
# On Windows CMake still expects paths to be separated by forward
# slashes
return (";".join(paths)).replace("\\", "/")
else:
return ";".join(paths)
def run_generator(generator):
parser = argparse.ArgumentParser(
description=generator.get_description(),
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
generator.add_commandline_arguments(parser)
parser.add_argument('--template-dir',
default='templates',
type=str,
help='Directory with template files.')
parser.add_argument(
kJinja2Path,
default=None,
type=str,
help='Additional python path to set before loading Jinja2')
parser.add_argument(
kMarkupSafePath,
default=None,
type=str,
help='Additional python path to set before loading MarkupSafe')
parser.add_argument(
'--output-json-tarball',
default=None,
type=str,
help=('Name of the "JSON tarball" to create (tar is too annoying '
'to use in python).'))
parser.add_argument(
'--depfile',
default=None,
type=str,
help='Name of the Ninja depfile to create for the JSON tarball')
parser.add_argument(
'--expected-outputs-file',
default=None,
type=str,
help="File to compare outputs with and fail if it doesn't match")
parser.add_argument(
'--root-dir',
default=None,
type=str,
help=('Optional source root directory for Python dependency '
'computations'))
parser.add_argument(
'--print-cmake-dependencies',
default=False,
action="store_true",
help=("Prints a semi-colon separated list of dependencies to "
"stdout and exits."))
parser.add_argument(
'--print-cmake-outputs',
default=False,
action="store_true",
help=("Prints a semi-colon separated list of outputs to "
"stdout and exits."))
parser.add_argument('--output-dir',
default=None,
type=str,
help='Directory where to output generate files.')
args = parser.parse_args()
output = generator.get_outputs(args)
# Output a list of all dependencies for CMake or the tarball for GN/Ninja.
if args.depfile != None or args.print_cmake_dependencies:
dependencies = generator.get_dependencies(args)
dependencies += [
args.template_dir + os.path.sep + render.template
for render in output.renders
]
dependencies += [
args.template_dir + os.path.sep + template
for template in output.imported_templates
]
dependencies += _compute_python_dependencies(args.root_dir)
if args.depfile != None:
with open(args.depfile, 'w') as f:
f.write(args.output_json_tarball + ": " +
" ".join(dependencies))
if args.print_cmake_dependencies:
sys.stdout.write(_cmake_path_list(dependencies))
return 0
# The caller wants to assert that the outputs are what it expects.
# Load the file and compare with our renders.
if args.expected_outputs_file != None:
with open(args.expected_outputs_file) as f:
expected = set([line.strip() for line in f.readlines()])
actual = {render.output for render in output.renders}
if actual != expected:
print("Wrong expected outputs, caller expected:\n " +
repr(sorted(expected)))
print("Actual output:\n " + repr(sorted(actual)))
return 1
# Print the list of all the outputs for cmake.
if args.print_cmake_outputs:
sys.stdout.write(
_cmake_path_list([
os.path.join(args.output_dir, render.output)
for render in output.renders
]))
return 0
render_outputs = _do_renders(output, args.template_dir)
# Output the JSON tarball
if args.output_json_tarball != None:
json_root = {}
for output in render_outputs:
json_root[output.name] = output.content
with open(args.output_json_tarball, 'w') as f:
f.write(json.dumps(json_root))
# Output the files directly.
if args.output_dir != None:
for output in render_outputs:
output_path = os.path.join(args.output_dir, output.name)
directory = os.path.dirname(output_path)
os.makedirs(directory, exist_ok=True)
with open(output_path, 'w') as outfile:
outfile.write(output.content)