1 # Copyright 2020 The Pigweed Authors
3 # Licensed under the Apache License, Version 2.0 (the "License"); you may not
4 # use this file except in compliance with the License. You may obtain a copy of
7 # https://www.apache.org/licenses/LICENSE-2.0
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12 # License for the specific language governing permissions and limitations under
14 """Script that preprocesses a Python command then runs it.
16 This script evaluates expressions in the Python command's arguments then invokes
21 from dataclasses import dataclass
25 from pathlib import Path
30 from typing import Callable, Dict, Iterable, Iterator, List, NamedTuple
31 from typing import Optional, Tuple
33 _LOG = logging.getLogger(__name__)
36 def _parse_args() -> argparse.Namespace:
37 """Parses arguments for this script, splitting out the command to run."""
39 parser = argparse.ArgumentParser(description=__doc__)
40 parser.add_argument('--gn-root',
43 help=('Path to the root of the GN tree; '
44 'value of rebase_path("//")'))
45 parser.add_argument('--current-path',
48 help='Value of rebase_path(".")')
49 parser.add_argument('--default-toolchain',
51 help='Value of default_toolchain')
52 parser.add_argument('--current-toolchain',
54 help='Value of current_toolchain')
55 parser.add_argument('--directory',
57 help='Execute the command from this directory')
58 parser.add_argument('--module', help='Run this module instead of a script')
59 parser.add_argument('--env',
61 help='Environment variables to set as NAME=VALUE')
65 help='File to touch after the command is run',
70 help='Capture subcommand output; display only on error',
74 nargs=argparse.REMAINDER,
75 help='Python script with arguments to run',
77 return parser.parse_args()
80 class GnPaths(NamedTuple):
81 """The set of paths needed to resolve GN paths to filesystem paths."""
86 # Toolchain label or '' if using the default toolchain
89 def resolve(self, gn_path: str) -> Path:
90 """Resolves a GN path to a filesystem path."""
91 if gn_path.startswith('//'):
92 return self.root.joinpath(gn_path.lstrip('/')).resolve()
94 return self.cwd.joinpath(gn_path).resolve()
96 def resolve_paths(self, gn_paths: str, sep: str = ';') -> str:
97 """Resolves GN paths to filesystem paths in a delimited string."""
99 str(self.resolve(path)) for path in gn_paths.split(sep))
102 @dataclass(frozen=True)
104 """Represents a GN label."""
108 toolchain: Optional['Label']
112 def __init__(self, paths: GnPaths, label: str):
113 # Use this lambda to set attributes on this frozen dataclass.
114 set_attr = lambda attr, val: object.__setattr__(self, attr, val)
116 # Handle explicitly-specified toolchains
117 if label.endswith(')'):
118 label, toolchain = label[:-1].rsplit('(', 1)
120 # Prevent infinite recursion for toolchains
121 toolchain = paths.toolchain if paths.toolchain != label else ''
123 set_attr('toolchain', Label(paths, toolchain) if toolchain else None)
125 # Split off the :target, if provided, or use the last part of the path.
127 directory, name = label.rsplit(':', 1)
129 directory, name = label, label.rsplit('/', 1)[-1]
131 set_attr('name', name)
133 # Resolve the directory to an absolute path
134 set_attr('dir', paths.resolve(directory))
135 set_attr('relative_dir', self.dir.relative_to(paths.root.resolve()))
139 paths.build / self.toolchain_name() / 'obj' / self.relative_dir)
142 paths.build / self.toolchain_name() / 'gen' / self.relative_dir)
144 def gn_label(self) -> str:
145 label = f'//{self.relative_dir.as_posix()}:{self.name}'
146 return f'{label}({self.toolchain!r})' if self.toolchain else label
148 def toolchain_name(self) -> str:
149 return self.toolchain.name if self.toolchain else ''
151 def __repr__(self) -> str:
152 return self.gn_label()
155 class _Artifact(NamedTuple):
157 variables: Dict[str, str]
160 # Matches a non-phony build statement.
161 _GN_NINJA_BUILD_STATEMENT = re.compile(r'^build (.+):[ \n](?!phony\b)')
164 def _parse_build_artifacts(build_dir: Path, fd) -> Iterator[_Artifact]:
165 """Partially parses the build statements in a Ninja file."""
171 except StopIteration:
174 # Serves as the parse state (only two states)
175 artifact: Optional[_Artifact] = None
179 while line is not None:
181 if line.startswith(' '): # build variable statements are indented
182 key, value = (a.strip() for a in line.split('=', 1))
183 artifact.variables[key] = value
189 match = _GN_NINJA_BUILD_STATEMENT.match(line)
191 artifact = _Artifact(build_dir / match.group(1), {})
199 def _search_target_ninja(ninja_file: Path, paths: GnPaths,
200 target: Label) -> Tuple[Optional[Path], List[Path]]:
201 """Parses the main output file and object files from <target>.ninja."""
203 artifact: Optional[Path] = None
204 objects: List[Path] = []
206 _LOG.debug('Parsing target Ninja file %s for %s', ninja_file, target)
208 with ninja_file.open() as fd:
209 for path, variables in _parse_build_artifacts(paths.build, fd):
210 # Older GN used .stamp files when there is no build artifact.
211 if path.suffix == '.stamp':
215 assert not artifact, f'Multiple artifacts for {target}!'
220 return artifact, objects
223 def _search_toolchain_ninja(ninja_file: Path, paths: GnPaths,
224 target: Label) -> Optional[Path]:
225 """Searches the toolchain.ninja file for outputs from the provided target.
227 Files created by an action appear in toolchain.ninja instead of in their own
228 <target>.ninja. If the specified target has a single output file in
229 toolchain.ninja, this function returns its path.
232 _LOG.debug('Searching toolchain Ninja file %s for %s', ninja_file, target)
234 # Older versions of GN used a .stamp file to signal completion of a target.
235 stamp_dir = target.out_dir.relative_to(paths.build).as_posix()
236 stamp_tool = f'{target.toolchain_name()}_stamp'
237 stamp_statement = f'build {stamp_dir}/{target.name}.stamp: {stamp_tool} '
239 # Newer GN uses a phony Ninja target to signal completion of a target.
240 phony_dir = Path(target.toolchain_name(), 'phony',
241 target.relative_dir).as_posix()
242 phony_statement = f'build {phony_dir}/{target.name}: phony '
244 with ninja_file.open() as fd:
246 for statement in (phony_statement, stamp_statement):
247 if line.startswith(statement):
248 output_files = line[len(statement):].strip().split()
249 if len(output_files) == 1:
250 return paths.build / output_files[0]
257 def _search_ninja_files(
259 target: Label) -> Tuple[bool, Optional[Path], List[Path]]:
260 ninja_file = target.out_dir / f'{target.name}.ninja'
261 if ninja_file.exists():
262 return (True, *_search_target_ninja(ninja_file, paths, target))
264 ninja_file = paths.build / target.toolchain_name() / 'toolchain.ninja'
265 if ninja_file.exists():
266 return True, _search_toolchain_ninja(ninja_file, paths, target), []
268 return False, None, []
271 @dataclass(frozen=True)
273 """Provides information about a target parsed from a .ninja file."""
276 generated: bool # True if the Ninja files for this target were generated.
277 artifact: Optional[Path]
278 object_files: Tuple[Path]
280 def __init__(self, paths: GnPaths, target: str):
281 object.__setattr__(self, 'label', Label(paths, target))
283 generated, artifact, objects = _search_ninja_files(paths, self.label)
285 object.__setattr__(self, 'generated', generated)
286 object.__setattr__(self, 'artifact', artifact)
287 object.__setattr__(self, 'object_files', tuple(objects))
289 def __repr__(self) -> str:
290 return repr(self.label)
293 class ExpressionError(Exception):
294 """An error occurred while parsing an expression."""
297 class _ArgAction(enum.Enum):
304 def __init__(self, match: re.Match, ending: int):
306 self._ending = ending
310 return self._match.string
313 def end(self) -> int:
314 return self._ending + len(_ENDING)
316 def contents(self) -> str:
317 return self.string[self._match.end():self._ending]
319 def expression(self) -> str:
320 return self.string[self._match.start():self.end]
323 _Actions = Iterator[Tuple[_ArgAction, str]]
326 def _target_file(paths: GnPaths, expr: _Expression) -> _Actions:
327 target = TargetInfo(paths, expr.contents())
329 if not target.generated:
330 raise ExpressionError(f'Target {target} has not been generated by GN!')
332 if target.artifact is None:
333 raise ExpressionError(f'Target {target} has no output file!')
335 yield _ArgAction.APPEND, str(target.artifact)
338 def _target_file_if_exists(paths: GnPaths, expr: _Expression) -> _Actions:
339 target = TargetInfo(paths, expr.contents())
342 if target.artifact is None:
343 raise ExpressionError(f'Target {target} has no output file!')
345 if Path(target.artifact).exists():
346 yield _ArgAction.APPEND, str(target.artifact)
349 yield _ArgAction.OMIT, ''
352 def _target_objects(paths: GnPaths, expr: _Expression) -> _Actions:
353 if expr.expression() != expr.string:
354 raise ExpressionError(
355 f'The expression "{expr.expression()}" in "{expr.string}" may '
356 'expand to multiple arguments, so it cannot be used alongside '
357 'other text or expressions')
359 target = TargetInfo(paths, expr.contents())
360 if not target.generated:
361 raise ExpressionError(f'Target {target} has not been generated by GN!')
363 for obj in target.object_files:
364 yield _ArgAction.EMIT_NEW, str(obj)
367 _FUNCTIONS: Dict['str', Callable[[GnPaths, _Expression], _Actions]] = {
368 'TARGET_FILE': _target_file,
369 'TARGET_FILE_IF_EXISTS': _target_file_if_exists,
370 'TARGET_OBJECTS': _target_objects,
373 _START_EXPRESSION = re.compile(fr'<({"|".join(_FUNCTIONS)})\(')
377 def _expand_arguments(paths: GnPaths, string: str) -> _Actions:
380 for match in _START_EXPRESSION.finditer(string):
381 if pos != match.start():
382 yield _ArgAction.APPEND, string[pos:match.start()]
384 ending = string.find(_ENDING, match.end())
386 raise ExpressionError(f'Parse error: no terminating "{_ENDING}" '
387 f'was found for "{string[match.start():]}"')
389 expression = _Expression(match, ending)
390 yield from _FUNCTIONS[match.group(1)](paths, expression)
394 if pos < len(string):
395 yield _ArgAction.APPEND, string[pos:]
398 def expand_expressions(paths: GnPaths, arg: str) -> Iterable[str]:
399 """Expands <FUNCTION(...)> expressions; yields zero or more arguments."""
403 expanded_args: List[List[str]] = [[]]
405 for action, piece in _expand_arguments(paths, arg):
406 if action is _ArgAction.OMIT:
409 expanded_args[-1].append(piece)
410 if action is _ArgAction.EMIT_NEW:
411 expanded_args.append([])
413 return (''.join(arg) for arg in expanded_args if arg)
419 directory: Optional[Path],
420 original_cmd: List[str],
421 default_toolchain: str,
422 current_toolchain: str,
423 module: Optional[str],
424 env: Optional[List[str]],
425 capture_output: bool,
426 touch: Optional[Path],
428 """Script entry point."""
430 if not original_cmd or original_cmd[0] != '--':
431 _LOG.error('%s requires a command to run', sys.argv[0])
434 # GN build scripts are executed from the root build directory.
435 root_build_dir = Path.cwd().resolve()
437 tool = current_toolchain if current_toolchain != default_toolchain else ''
438 paths = GnPaths(root=gn_root.resolve(),
439 build=root_build_dir,
440 cwd=current_path.resolve(),
443 command = [sys.executable]
445 if module is not None:
446 command += ['-m', module]
448 run_args: dict = dict(cwd=directory)
451 environment = os.environ.copy()
452 environment.update((k, v) for k, v in (a.split('=', 1) for a in env))
453 run_args['env'] = environment
456 # Combine stdout and stderr so that error messages are correctly
457 # interleaved with the rest of the output.
458 run_args['stdout'] = subprocess.PIPE
459 run_args['stderr'] = subprocess.STDOUT
462 for arg in original_cmd[1:]:
463 command += expand_expressions(paths, arg)
464 except ExpressionError as err:
465 _LOG.error('%s: %s', sys.argv[0], err)
468 _LOG.debug('RUN %s', ' '.join(shlex.quote(arg) for arg in command))
470 completed_process = subprocess.run(command, **run_args)
472 if completed_process.returncode != 0:
473 _LOG.debug('Command failed; exit code: %d',
474 completed_process.returncode)
476 sys.stdout.buffer.write(completed_process.stdout)
478 # If a stamp file is provided and the command executed successfully,
479 # touch the stamp file to indicate a successful run of the command.
480 _LOG.debug('TOUCH %s', touch)
483 return completed_process.returncode
486 if __name__ == '__main__':
487 sys.exit(main(**vars(_parse_args())))