blob: e8338b2514264db2b95967041be0322ce07c73bc [file] [log] [blame]
Corentin Wallez59382b72020-04-17 20:43:07 +00001#!/usr/bin/env python3
Austin Engcc2516a2023-10-17 20:57:54 +00002# Copyright 2019 The Dawn & Tint Authors
Corentin Wallez0c38e922019-06-07 08:59:17 +00003#
Austin Engcc2516a2023-10-17 20:57:54 +00004# Redistribution and use in source and binary forms, with or without
5# modification, are permitted provided that the following conditions are met:
Corentin Wallez0c38e922019-06-07 08:59:17 +00006#
Austin Engcc2516a2023-10-17 20:57:54 +00007# 1. Redistributions of source code must retain the above copyright notice, this
8# list of conditions and the following disclaimer.
Corentin Wallez0c38e922019-06-07 08:59:17 +00009#
Austin Engcc2516a2023-10-17 20:57:54 +000010# 2. Redistributions in binary form must reproduce the above copyright notice,
11# this list of conditions and the following disclaimer in the documentation
12# and/or other materials provided with the distribution.
13#
14# 3. Neither the name of the copyright holder nor the names of its
15# contributors may be used to endorse or promote products derived from
16# this software without specific prior written permission.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +000028"""Module to create generators that render multiple Jinja2 templates for GN.
29
30A helper module that can be used to create generator scripts (clients)
31that expand one or more Jinja2 templates, without outputs usable from
32GN and Ninja build-based systems. See generator_lib.gni as well.
33
34Clients should create a Generator sub-class, then call run_generator()
35with a proper derived class instance.
36
37Clients specify a list of FileRender operations, each one of them will
38output a file into a temporary output directory through Jinja2 expansion.
39All temporary output files are then grouped and written to into a single JSON
40file, that acts as a convenient single GN output target. Use extract_json.py
41to extract the output files from the JSON tarball in another GN action.
42
43--depfile can be used to specify an output Ninja dependency file for the
44JSON tarball, to ensure it is regenerated any time one of its dependencies
45changes.
46
47Finally, --expected-output-files can be used to check the list of generated
48output files.
49"""
50
Corentin Wallez0c38e922019-06-07 08:59:17 +000051import argparse, json, os, re, sys
52from collections import namedtuple
53
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +000054# A FileRender represents a single Jinja2 template render operation:
55#
56# template: Jinja2 template name, relative to --template-dir path.
57#
58# output: Output file path, relative to temporary output directory.
59#
60# params_dicts: iterable of (name:string -> value:string) dictionaries.
61# All of them will be merged before being sent as Jinja2 template
62# expansion parameters.
63#
64# Example:
65# FileRender('api.c', 'src/project_api.c', [{'PROJECT_VERSION': '1.0.0'}])
66#
67FileRender = namedtuple('FileRender', ['template', 'output', 'params_dicts'])
68
Kai Ninomiya01aeca22020-07-15 19:51:17 +000069
Corentin Wallez0c38e922019-06-07 08:59:17 +000070# The interface that must be implemented by generators.
71class Generator:
72 def get_description(self):
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +000073 """Return generator description for --help."""
Corentin Wallez0c38e922019-06-07 08:59:17 +000074 return ""
75
76 def add_commandline_arguments(self, parser):
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +000077 """Add generator-specific argparse arguments."""
Corentin Wallez0c38e922019-06-07 08:59:17 +000078 pass
79
80 def get_file_renders(self, args):
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +000081 """Return the list of FileRender objects to process."""
Corentin Wallez0c38e922019-06-07 08:59:17 +000082 return []
83
84 def get_dependencies(self, args):
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +000085 """Return a list of extra input dependencies."""
Corentin Wallez0c38e922019-06-07 08:59:17 +000086 return []
87
Kai Ninomiya01aeca22020-07-15 19:51:17 +000088
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +000089# Allow custom Jinja2 installation path through an additional python
90# path from the arguments if present. This isn't done through the regular
91# argparse because PreprocessingLoader uses jinja2 in the global scope before
92# "main" gets to run.
93#
94# NOTE: If this argument appears several times, this only uses the first
95# value, while argparse would typically keep the last one!
96kJinja2Path = '--jinja2-path'
Corentin Wallez45f91852019-09-18 00:59:40 +000097try:
98 jinja2_path_argv_index = sys.argv.index(kJinja2Path)
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +000099 # Add parent path for the import to succeed.
100 path = os.path.join(sys.argv[jinja2_path_argv_index + 1], os.pardir)
Corentin Wallez0c38e922019-06-07 08:59:17 +0000101 sys.path.insert(1, path)
Corentin Wallez45f91852019-09-18 00:59:40 +0000102except ValueError:
103 # --jinja2-path isn't passed, ignore the exception and just import Jinja2
104 # assuming it already is in the Python PATH.
105 pass
dan sinclair6b67a902023-04-07 07:52:36 +0000106kMarkupSafePath = '--markupsafe-path'
107try:
108 markupsafe_path_argv_index = sys.argv.index(kMarkupSafePath)
109 # Add parent path for the import to succeed.
110 path = os.path.join(sys.argv[markupsafe_path_argv_index + 1], os.pardir)
111 sys.path.insert(1, path)
112except ValueError:
113 # --markupsafe-path isn't passed, ignore the exception and just import
114 # assuming it already is in the Python PATH.
115 pass
Corentin Wallez0c38e922019-06-07 08:59:17 +0000116
117import jinja2
118
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000119
Corentin Wallez0c38e922019-06-07 08:59:17 +0000120# A custom Jinja2 template loader that removes the extra indentation
121# of the template blocks so that the output is correctly indented
Corentin Wallezdf69f242019-06-13 10:22:32 +0000122class _PreprocessingLoader(jinja2.BaseLoader):
Corentin Wallez0c38e922019-06-07 08:59:17 +0000123 def __init__(self, path):
124 self.path = path
125
126 def get_source(self, environment, template):
127 path = os.path.join(self.path, template)
128 if not os.path.exists(path):
129 raise jinja2.TemplateNotFound(template)
130 mtime = os.path.getmtime(path)
131 with open(path) as f:
132 source = self.preprocess(f.read())
133 return source, path, lambda: mtime == os.path.getmtime(path)
134
Ho Cheung1281bdb2023-10-26 18:41:50 +0000135 blockstart = re.compile(r'{%-?\s*(if|elif|else|for|block|macro)[^}]*%}')
136 blockend = re.compile(r'{%-?\s*(end(if|for|block|macro)|elif|else)[^}]*%}')
Corentin Wallez0c38e922019-06-07 08:59:17 +0000137
138 def preprocess(self, source):
139 lines = source.split('\n')
140
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000141 # Compute the current indentation level of the template blocks and
142 # remove their indentation
Corentin Wallez0c38e922019-06-07 08:59:17 +0000143 result = []
144 indentation_level = 0
145
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000146 # Filter lines that are pure comments. line_comment_prefix is not
147 # enough because it removes the comment but doesn't completely remove
148 # the line, resulting in more verbose output.
Corentin Wallez1bf31672020-01-15 15:39:12 +0000149 lines = filter(lambda line: not line.strip().startswith('//*'), lines)
150
151 # Remove indentation templates have for the Jinja control flow.
Corentin Wallez0c38e922019-06-07 08:59:17 +0000152 for line in lines:
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000153 # The capture in the regex adds one element per block start or end,
154 # so we divide by two. There is also an extra line chunk
155 # corresponding to the line end, so we subtract it.
Corentin Wallez0c38e922019-06-07 08:59:17 +0000156 numends = (len(self.blockend.split(line)) - 1) // 2
157 indentation_level -= numends
158
159 result.append(self.remove_indentation(line, indentation_level))
160
161 numstarts = (len(self.blockstart.split(line)) - 1) // 2
162 indentation_level += numstarts
163
164 return '\n'.join(result) + '\n'
165
166 def remove_indentation(self, line, n):
167 for _ in range(n):
168 if line.startswith(' '):
169 line = line[4:]
170 elif line.startswith('\t'):
171 line = line[1:]
172 else:
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000173 assert line.strip() == ''
Corentin Wallez0c38e922019-06-07 08:59:17 +0000174 return line
175
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000176
Corentin Wallez0c38e922019-06-07 08:59:17 +0000177_FileOutput = namedtuple('FileOutput', ['name', 'content'])
178
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000179
Corentin Wallez0c38e922019-06-07 08:59:17 +0000180def _do_renders(renders, template_dir):
Corentin Wallezdf69f242019-06-13 10:22:32 +0000181 loader = _PreprocessingLoader(template_dir)
Loko Kung7c8dfbc2023-06-09 23:55:39 +0000182 env = jinja2.Environment(
183 extensions=['jinja2.ext.do', 'jinja2.ext.loopcontrols'],
184 loader=loader,
185 lstrip_blocks=True,
186 trim_blocks=True,
187 line_comment_prefix='//*')
Corentin Wallez0c38e922019-06-07 08:59:17 +0000188
Kai Ninomiya8d524b22023-09-02 01:11:30 +0000189 def do_assert(expr, message=''):
190 assert expr, message
Corentin Wallez031fbbb2019-06-11 18:03:05 +0000191 return ''
192
193 def debug(text):
194 print(text)
195
196 base_params = {
197 'enumerate': enumerate,
198 'format': format,
199 'len': len,
200 'debug': debug,
201 'assert': do_assert,
202 }
203
Corentin Wallez0c38e922019-06-07 08:59:17 +0000204 outputs = []
205 for render in renders:
206 params = {}
Corentin Wallez031fbbb2019-06-11 18:03:05 +0000207 params.update(base_params)
Corentin Wallez0c38e922019-06-07 08:59:17 +0000208 for param_dict in render.params_dicts:
209 params.update(param_dict)
210 content = env.get_template(render.template).render(**params)
211 outputs.append(_FileOutput(render.output, content))
212
213 return outputs
214
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000215
Corentin Wallez0c38e922019-06-07 08:59:17 +0000216# Compute the list of imported, non-system Python modules.
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +0000217# It assumes that any path outside of the root directory is system.
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000218def _compute_python_dependencies(root_dir=None):
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +0000219 if not root_dir:
220 # Assume this script is under generator/ by default.
221 root_dir = os.path.join(os.path.dirname(__file__), os.pardir)
222 root_dir = os.path.abspath(root_dir)
Corentin Wallez0c38e922019-06-07 08:59:17 +0000223
224 module_paths = (module.__file__ for module in sys.modules.values()
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000225 if module and hasattr(module, '__file__'))
Corentin Wallez0c38e922019-06-07 08:59:17 +0000226
227 paths = set()
228 for path in module_paths:
dan sinclaire8022ea2020-10-20 14:46:10 +0000229 # Builtin/namespaced modules may return None for the file path.
230 if not path:
231 continue
232
Corentin Wallez0c38e922019-06-07 08:59:17 +0000233 path = os.path.abspath(path)
234
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +0000235 if not path.startswith(root_dir):
Corentin Wallez0c38e922019-06-07 08:59:17 +0000236 continue
237
238 if (path.endswith('.pyc')
239 or (path.endswith('c') and not os.path.splitext(path)[1])):
240 path = path[:-1]
241
242 paths.add(path)
243
244 return paths
245
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000246
Corentin Wallez08e0fd72023-09-18 16:55:31 +0000247# Computes the string representing a cmake list of paths.
248def _cmake_path_list(paths):
249 if os.name == "nt":
250 # On Windows CMake still expects paths to be separated by forward
251 # slashes
252 return (";".join(paths)).replace("\\", "/")
253 else:
254 return ";".join(paths)
255
256
Corentin Wallez0c38e922019-06-07 08:59:17 +0000257def run_generator(generator):
258 parser = argparse.ArgumentParser(
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000259 description=generator.get_description(),
260 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
Corentin Wallez0c38e922019-06-07 08:59:17 +0000261 )
262
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000263 generator.add_commandline_arguments(parser)
264 parser.add_argument('--template-dir',
265 default='templates',
266 type=str,
267 help='Directory with template files.')
268 parser.add_argument(
269 kJinja2Path,
270 default=None,
271 type=str,
272 help='Additional python path to set before loading Jinja2')
273 parser.add_argument(
dan sinclair6b67a902023-04-07 07:52:36 +0000274 kMarkupSafePath,
275 default=None,
276 type=str,
277 help='Additional python path to set before loading MarkupSafe')
278 parser.add_argument(
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000279 '--output-json-tarball',
280 default=None,
281 type=str,
282 help=('Name of the "JSON tarball" to create (tar is too annoying '
283 'to use in python).'))
284 parser.add_argument(
285 '--depfile',
286 default=None,
287 type=str,
288 help='Name of the Ninja depfile to create for the JSON tarball')
289 parser.add_argument(
290 '--expected-outputs-file',
291 default=None,
292 type=str,
293 help="File to compare outputs with and fail if it doesn't match")
294 parser.add_argument(
295 '--root-dir',
296 default=None,
297 type=str,
298 help=('Optional source root directory for Python dependency '
299 'computations'))
300 parser.add_argument(
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000301 '--print-cmake-dependencies',
302 default=False,
303 action="store_true",
304 help=("Prints a semi-colon separated list of dependencies to "
305 "stdout and exits."))
306 parser.add_argument(
307 '--print-cmake-outputs',
308 default=False,
309 action="store_true",
310 help=("Prints a semi-colon separated list of outputs to "
311 "stdout and exits."))
312 parser.add_argument('--output-dir',
313 default=None,
314 type=str,
315 help='Directory where to output generate files.')
Corentin Wallez0c38e922019-06-07 08:59:17 +0000316
317 args = parser.parse_args()
318
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000319 renders = generator.get_file_renders(args)
Corentin Wallez0c38e922019-06-07 08:59:17 +0000320
Corentin Wallez7fe6efb2020-02-05 17:16:05 +0000321 # Output a list of all dependencies for CMake or the tarball for GN/Ninja.
322 if args.depfile != None or args.print_cmake_dependencies:
323 dependencies = generator.get_dependencies(args)
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000324 dependencies += [
325 args.template_dir + os.path.sep + render.template
326 for render in renders
327 ]
Corentin Wallez7fe6efb2020-02-05 17:16:05 +0000328 dependencies += _compute_python_dependencies(args.root_dir)
329
330 if args.depfile != None:
331 with open(args.depfile, 'w') as f:
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000332 f.write(args.output_json_tarball + ": " +
333 " ".join(dependencies))
Corentin Wallez7fe6efb2020-02-05 17:16:05 +0000334
335 if args.print_cmake_dependencies:
Corentin Wallez08e0fd72023-09-18 16:55:31 +0000336 sys.stdout.write(_cmake_path_list(dependencies))
Corentin Wallez7fe6efb2020-02-05 17:16:05 +0000337 return 0
338
Corentin Wallez0c38e922019-06-07 08:59:17 +0000339 # The caller wants to assert that the outputs are what it expects.
340 # Load the file and compare with our renders.
341 if args.expected_outputs_file != None:
342 with open(args.expected_outputs_file) as f:
343 expected = set([line.strip() for line in f.readlines()])
344
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +0000345 actual = {render.output for render in renders}
Corentin Wallez0c38e922019-06-07 08:59:17 +0000346
347 if actual != expected:
Kai Ninomiya01aeca22020-07-15 19:51:17 +0000348 print("Wrong expected outputs, caller expected:\n " +
349 repr(sorted(expected)))
David 'Digit' Turner5dee3e82019-06-24 14:31:06 +0000350 print("Actual output:\n " + repr(sorted(actual)))
Corentin Wallez0c38e922019-06-07 08:59:17 +0000351 return 1
352
Corentin Wallez7fe6efb2020-02-05 17:16:05 +0000353 # Print the list of all the outputs for cmake.
354 if args.print_cmake_outputs:
Corentin Wallez08e0fd72023-09-18 16:55:31 +0000355 sys.stdout.write(
356 _cmake_path_list([
357 os.path.join(args.output_dir, render.output)
358 for render in renders
359 ]))
Corentin Wallez7fe6efb2020-02-05 17:16:05 +0000360 return 0
361
Corentin Wallez0c38e922019-06-07 08:59:17 +0000362 outputs = _do_renders(renders, args.template_dir)
363
Corentin Wallez7fe6efb2020-02-05 17:16:05 +0000364 # Output the JSON tarball
Corentin Wallez0c38e922019-06-07 08:59:17 +0000365 if args.output_json_tarball != None:
366 json_root = {}
367 for output in outputs:
368 json_root[output.name] = output.content
369
370 with open(args.output_json_tarball, 'w') as f:
371 f.write(json.dumps(json_root))
372
Corentin Wallez7fe6efb2020-02-05 17:16:05 +0000373 # Output the files directly.
374 if args.output_dir != None:
375 for output in outputs:
376 output_path = os.path.join(args.output_dir, output.name)
Corentin Wallez0c38e922019-06-07 08:59:17 +0000377
Corentin Wallez7fe6efb2020-02-05 17:16:05 +0000378 directory = os.path.dirname(output_path)
Corentin Wallez20a6ca02022-12-19 11:14:54 +0000379 os.makedirs(directory, exist_ok=True)
Corentin Wallez7fe6efb2020-02-05 17:16:05 +0000380
381 with open(output_path, 'w') as outfile:
382 outfile.write(output.content)