blob: f6be509851f805fcc93ac608ad700658cb6aff26 [file] [log] [blame] [edit]
# Copyright 2021 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Python module for creating depfiles that can be read by ninja.
ninja supports depfiles for tracking implicit dependencies that are needed by
future incremental rebuilds: https://ninja-build.org/manual.html#_depfile
The format of these is a Makefile, with a single output listed (see
https://gn.googlesource.com/gn/+/main/docs/reference.md#var_depfile)
Examples:
The simplest form is a single line for output and each inputs::
path/to/an/output: path/to/input_a, path/to/input_b
paths are all relative to the root build dir (GN's root_build_dir)
Ninja also supports a multiline format which uses backslashes as a continuation
character::
path/to/an/output: \
path/to/input_a \
path/to/input_b \
For readability, this is the format that is used by this module.
Basic usage:
>>> dep_file = DepFile("path/to/output")
>>> dep_file.add_input("path/to/input_a")
>>> dep_file.add_input("path/to/input_b")
>>> print(dep_file)
path/to/output: \
path/to/input_a \
path/to/input_b \
>>>
By default, paths are made relative to the current working directory, but the
paths can all be rebased (made relative from) a different absolute path:
Assuming that the current working dir is ``/foo/bar``, and the paths to the
output and inputs are relative
>>> dep_file = DepFile("baz/melon/output", rebase="/foo/bar/baz")
>>> dep_file.add_input("baz/input_a")
>>> dep_file.add_input("/foo/bar/baz/input_b")
>>> dep_file.add_input("/foo/bar/monkey/panda/input_c")
>>> print(dep_file)
melon/output: \
input_a input_b \
../monkey/panda/input/_c \
>>>
"""
import os
import shlex
from os import PathLike
from typing import Any, Iterable, Self, TextIO, Union
FilePath = Union[str, PathLike[Any]]
class DepFile:
"""A helper class for collecting implicit dependencies and writing them to
depfiles that ninja can read.
Each DepFile instance supports collecting the inputs used to create a single
output file.
"""
def __init__(
self, output: FilePath, rebase: None | FilePath = None
) -> None:
if rebase is not None:
self.rebase_from = rebase
else:
self.rebase_from = os.getcwd()
self.outputs = [self._rebase(output)]
self.deps: set[FilePath] = set()
def _rebase(self, path: FilePath) -> FilePath:
return os.path.relpath(path, start=self.rebase_from)
def add_output(self, output: str | FilePath) -> None:
if output not in self.outputs:
self.outputs.append(output)
def add_input(self, input: FilePath) -> None:
"""Add an input to the depfile"""
self.deps.add(self._rebase(input))
def update(self, other: Union[Self, Iterable[FilePath]]) -> None:
"""Add each input to this depfile"""
# If other is another DepFile, just snag the values from it's internal
# dict.
inputs: Iterable[FilePath] = set()
if isinstance(other, self.__class__):
inputs = other.deps
elif isinstance(other, Iterable):
inputs = other
else:
raise TypeError(
"update() can only accept a DepFile or an iterable of paths"
)
# Rebase them all in a bulk operation.
inputs = [self._rebase(input) for input in inputs]
# And then update the set of deps.
for input in inputs:
self.deps.add(input)
@classmethod
def from_deps(
cls,
output: FilePath,
inputs: Iterable[FilePath],
rebase: None | FilePath = None,
) -> "DepFile":
dep_file = cls(output, rebase)
dep_file.update(inputs)
return dep_file
@classmethod
def read_from(cls, file: TextIO) -> "DepFile":
depfile = None
found_continuation = False
for line in file.readlines():
line = line.strip()
# ignore empty lines
if not line:
continue
# ignore comment lines
if line.startswith("#"):
continue
# We currently don't allow consecutive backslashes in filenames to
# simplify depfile parsing. Support can be added if use cases come up.
#
# Ninja's implementation:
# https://github.com/ninja-build/ninja/blob/5993141c0977f563de5e064fbbe617f9dc34bb8d/src/depfile_parser.cc#L39
if r"\\" in line:
raise ValueError(
f'Consecutive backslashes found in depfile line "{line}", this is not supported by action tracer'
)
# if we haven't parsed out the outputs lines, do that and setup the
# depfile object
if not depfile:
outputs_string, sep, inputs_string = line.partition(":")
outputs = shlex.split(outputs_string)
inputs = shlex.split(inputs_string)
if not sep:
raise ValueError(
"Fail to parse depfile, no separator found on first line:\n"
+ line
)
if len(outputs) == 0:
raise ValueError(
"Failed to parse depfile, no outputs found:\n" + line
)
depfile = DepFile(outputs[0])
for output in outputs[1:]:
depfile.add_output(output)
if inputs[-1] in ["\\\n", "\\\r\n"]:
found_continuation = True
inputs.pop()
for input in inputs:
depfile.add_input(input)
else:
if not found_continuation:
raise ValueError(
"Found non-empty line without preceding continuation marker."
+ line
)
inputs = shlex.split(line)
if inputs[-1] in ["\\\n", "\\\r\n"]:
found_continuation = True
for input in inputs[:-1]:
depfile.add_input(input)
if depfile:
return depfile
else:
raise ValueError("Depfile was empty")
def __repr__(self) -> str:
formatted_outputs = " ".join([str(s) for s in self.outputs])
if self.deps:
sorted_deps = sorted(str(d) for d in self.deps)
input_continuation = " \\\n "
return f"{formatted_outputs}: \\\n {input_continuation.join(sorted_deps)}\n"
else:
return f"{formatted_outputs}:\n"
def write_to(self, file: TextIO) -> None:
"""Write out the depfile contents to the given writeable `file-like` object."""
file.write(str(self))