pw_build: Add GnWriter and GnFile
This CL adds Python utilities for writing GN build and import files.
Change-Id: Ie0feb1e378cfb8d163e129137f3dfc748977d15f
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/138702
Commit-Queue: Aaron Green <aarongreen@google.com>
Reviewed-by: Carlos Chinchilla <cachinchilla@google.com>
diff --git a/pw_build/py/BUILD.gn b/pw_build/py/BUILD.gn
index 8fd9a7e..780fe98 100644
--- a/pw_build/py/BUILD.gn
+++ b/pw_build/py/BUILD.gn
@@ -44,6 +44,7 @@
"pw_build/gn_resolver.py",
"pw_build/gn_target.py",
"pw_build/gn_utils.py",
+ "pw_build/gn_writer.py",
"pw_build/host_tool.py",
"pw_build/merge_profraws.py",
"pw_build/mirror_tree.py",
@@ -68,6 +69,7 @@
"gn_config_test.py",
"gn_target_test.py",
"gn_utils_test.py",
+ "gn_writer_test.py",
"project_builder_prefs_test.py",
"python_runner_test.py",
"zip_test.py",
diff --git a/pw_build/py/gn_writer_test.py b/pw_build/py/gn_writer_test.py
new file mode 100644
index 0000000..b2139d8
--- /dev/null
+++ b/pw_build/py/gn_writer_test.py
@@ -0,0 +1,295 @@
+# Copyright 2023 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Tests for the pw_build.gn_writer module."""
+
+import os
+import unittest
+
+from io import StringIO
+from pathlib import PurePath
+from tempfile import TemporaryDirectory
+
+from pw_build.gn_config import GnConfig
+from pw_build.gn_writer import (
+ COPYRIGHT_HEADER,
+ GnFile,
+ GnWriter,
+)
+from pw_build.gn_target import GnTarget
+from pw_build.gn_utils import MalformedGnError
+
+
+class TestGnWriter(unittest.TestCase):
+ """Tests for gn_writer.GnWriter."""
+
+ def setUp(self):
+ """Creates a GnWriter that writes to a StringIO."""
+ self.reset()
+
+ def reset(self):
+ """Resets the writer and output."""
+ self.output = StringIO()
+ self.writer = GnWriter(self.output)
+
+ def test_write_comment(self):
+ """Writes a GN comment."""
+ self.writer.write_comment('hello, world!')
+ self.assertEqual(
+ self.output.getvalue(),
+ '# hello, world!\n',
+ )
+
+ def test_write_comment_wrap(self):
+ """Writes a GN comment that is exactly 80 characters."""
+ extra_long = (
+ "This line is a " + ("really, " * 5) + "REALLY extra long comment"
+ )
+ self.writer.write_comment(extra_long)
+ self.assertEqual(
+ self.output.getvalue(),
+ '# This line is a really, really, really, really, really, REALLY '
+ 'extra long\n# comment\n',
+ )
+
+ def test_write_comment_nowrap(self):
+ """Writes a long GN comment without whitespace to wrap on."""
+ no_breaks = 'A' + ('a' * 76) + 'h!'
+ self.writer.write_comment(no_breaks)
+ self.assertEqual(
+ self.output.getvalue(),
+ '# Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
+ 'aaaaaaaaaaaaah!\n',
+ )
+
+ def test_write_imports(self):
+ """Writes GN import statements."""
+ self.writer.write_import('foo.gni')
+ self.writer.write_imports(['bar.gni', 'baz.gni'])
+ lines = [
+ 'import("foo.gni")',
+ 'import("bar.gni")',
+ 'import("baz.gni")',
+ ]
+ self.assertEqual('\n'.join(lines), self.output.getvalue().strip())
+
+ def test_write_config(self):
+ """Writes a GN config."""
+ config = GnConfig(
+ json='''{
+ "label": "$dir_3p/test:my-config",
+ "cflags": ["-frobinator", "-fizzbuzzer"],
+ "defines": ["KEY=VAL"],
+ "public": true,
+ "usages": 1
+ }'''
+ )
+ self.writer.write_config(config)
+ lines = [
+ 'config("my-config") {',
+ ' cflags = [',
+ ' "-fizzbuzzer",',
+ ' "-frobinator",',
+ ' ]',
+ ' defines = [',
+ ' "KEY=VAL",',
+ ' ]',
+ '}',
+ ]
+ self.assertEqual('\n'.join(lines), self.output.getvalue().strip())
+
+ def test_write_target(self):
+ """Tests writing the target using a GnWriter."""
+ target = GnTarget(
+ '$build',
+ '$src',
+ json='''{
+ "target_type": "custom_type",
+ "target_name": "my-target",
+ "package": "my-package"
+ }''',
+ )
+ target.add_visibility(bazel='//visibility:private')
+ target.add_visibility(bazel='//foo:__subpackages__')
+ target.add_path('public', bazel='//foo:my-header.h')
+ target.add_path('sources', bazel='//foo:my-source.cc')
+ target.add_path('inputs', bazel='//bar:my.data')
+ target.config.add('cflags', '-frobinator')
+ target.add_dep(public=True, bazel='//my-package:foo')
+ target.add_dep(public=True, bazel='@com_corp_repo//bar')
+ target.add_dep(bazel='//other-pkg/baz')
+ target.add_dep(bazel='@com_corp_repo//:top-level')
+
+ output = StringIO()
+ writer = GnWriter(output)
+ writer.repos = {'com_corp_repo': 'repo'}
+ writer.aliases = {'$build/other-pkg/baz': '$build/another-pkg/baz'}
+ writer.write_target(target)
+
+ self.assertEqual(
+ output.getvalue(),
+ '''
+# Generated from //my-package:my-target
+custom_type("my-target") {
+ visibility = [
+ "../foo/*",
+ ":*",
+ ]
+ public = [
+ "$src/foo/my-header.h",
+ ]
+ sources = [
+ "$src/foo/my-source.cc",
+ ]
+ inputs = [
+ "$src/bar/my.data",
+ ]
+ cflags = [
+ "-frobinator",
+ ]
+ public_deps = [
+ "$dir_pw_third_party/repo/bar",
+ ":foo",
+ ]
+ deps = [
+ "$dir_pw_third_party/repo:top-level",
+ "../another-pkg/baz",
+ ]
+}
+'''.lstrip(),
+ )
+
+ def test_write_list(self):
+ """Writes a GN list assigned to a variable."""
+ self.writer.write_list('empty', [])
+ self.writer.write_list('items', ['foo', 'bar', 'baz'])
+ lines = [
+ 'items = [',
+ ' "bar",',
+ ' "baz",',
+ ' "foo",',
+ ']',
+ ]
+ self.assertEqual('\n'.join(lines), self.output.getvalue().strip())
+
+ def test_write_scope(self):
+ """Writes a GN scope assigned to a variable."""
+ self.writer.write_scope('outer')
+ self.writer.write('key1 = "val1"')
+ self.writer.write_scope('inner')
+ self.writer.write('key2 = "val2"')
+ self.writer.write_end()
+ self.writer.write('key3 = "val3"')
+ self.writer.write_end()
+ lines = [
+ 'outer = {',
+ ' key1 = "val1"',
+ ' inner = {',
+ ' key2 = "val2"',
+ ' }',
+ '',
+ ' key3 = "val3"',
+ '}',
+ ]
+ self.assertEqual('\n'.join(lines), self.output.getvalue().strip())
+
+ def test_write_if_else_end(self):
+ """Writes GN conditional statements."""
+ self.writer.write_if('current_os == "linux"')
+ self.writer.write('mascot = "penguin"')
+ self.writer.write_else_if('current_os == "mac"')
+ self.writer.write('mascot = "dogcow"')
+ self.writer.write_else_if('current_os == "win"')
+ self.writer.write('mascot = "clippy"')
+ self.writer.write_else()
+ self.writer.write('mascot = "dropbear"')
+ self.writer.write_end()
+ lines = [
+ 'if (current_os == "linux") {',
+ ' mascot = "penguin"',
+ '} else if (current_os == "mac") {',
+ ' mascot = "dogcow"',
+ '} else if (current_os == "win") {',
+ ' mascot = "clippy"',
+ '} else {',
+ ' mascot = "dropbear"',
+ '}',
+ ]
+ self.assertEqual('\n'.join(lines), self.output.getvalue().strip())
+
+ def test_write_unclosed_target(self):
+ """Triggers an error from an unclosed GN scope."""
+ self.writer.write_target_start('unclosed', 'target')
+ with self.assertRaises(MalformedGnError):
+ self.writer.seal()
+
+ def test_write_unclosed_scope(self):
+ """Triggers an error from an unclosed GN scope."""
+ self.writer.write_scope('unclosed_scope')
+ with self.assertRaises(MalformedGnError):
+ self.writer.seal()
+
+ def test_write_unclosed_if(self):
+ """Triggers an error from an unclosed GN condition."""
+ self.writer.write_if('var == "unclosed-if"')
+ with self.assertRaises(MalformedGnError):
+ self.writer.seal()
+
+ def test_write_unclosed_else_if(self):
+ """Triggers an error from an unclosed GN condition."""
+ self.writer.write_if('var == "closed-if"')
+ self.writer.write_else_if('var == "unclosed-else-if"')
+ with self.assertRaises(MalformedGnError):
+ self.writer.seal()
+
+ def test_write_unclosed_else(self):
+ """Triggers an error from an unclosed GN condition."""
+ self.writer.write_if('var == "closed-if"')
+ self.writer.write_else_if('var == "closed-else-if"')
+ self.writer.write_else()
+ with self.assertRaises(MalformedGnError):
+ self.writer.seal()
+
+
+class TestGnFile(unittest.TestCase):
+ """Tests for gn_writer.GnFile."""
+
+ def test_format_on_close(self):
+ """Verifies the GN file is formatted when the file is closed."""
+ with TemporaryDirectory() as tmpdirname:
+ with GnFile(PurePath(tmpdirname, 'BUILD.gn')) as build_gn:
+ build_gn.write(' correct = "indent"')
+ build_gn.write_comment('newline before comment')
+ build_gn.write_scope('no_newline_before_item')
+ build_gn.write_list('single_item', ['is.inlined'])
+ build_gn.write_end()
+
+ filename = PurePath('pw_build', 'gn_writer.py')
+ expected = (
+ COPYRIGHT_HEADER
+ + f'''
+# This file was automatically generated by {filename}
+
+correct = "indent"
+
+# newline before comment
+no_newline_before_item = {{
+ single_item = [ "is.inlined" ]
+}}'''
+ )
+ with open(os.path.join(tmpdirname, 'BUILD.gn'), 'r') as build_gn:
+ self.assertEqual(expected.strip(), build_gn.read().strip())
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/pw_build/py/pw_build/gn_writer.py b/pw_build/py/pw_build/gn_writer.py
new file mode 100644
index 0000000..515ca7b
--- /dev/null
+++ b/pw_build/py/pw_build/gn_writer.py
@@ -0,0 +1,368 @@
+# Copyright 2023 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Writes a formatted BUILD.gn _file."""
+
+import os
+import subprocess
+
+from datetime import datetime
+from pathlib import PurePath, PurePosixPath
+from types import TracebackType
+from typing import Dict, IO, Iterable, Iterator, List, Optional, Type, Union
+
+from pw_build.gn_config import GnConfig, GN_CONFIG_FLAGS
+from pw_build.gn_target import GnTarget
+from pw_build.gn_utils import GnLabel, GnPath, MalformedGnError
+
+COPYRIGHT_HEADER = f'''
+# Copyright {datetime.now().year} The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+# DO NOT MANUALLY EDIT!'''
+
+
+class GnWriter:
+ """Represents a partial BUILD.gn file being constructed.
+
+ Except for testing , callers should prefer using `GnFile`. That
+ object wraps this one, and ensures that the GN file produced includes the
+ relevant copyright header and is formatted correctly.
+
+ Attributes:
+ repos: A mapping of repository names to build args. These are used to
+ replace repository names when writing labels.
+ aliases: A mapping of label names to build args. These can be used to
+ rewrite labels with alternate names, e.g. "gtest" to "googletest".
+ """
+
+ def __init__(self, file: IO) -> None:
+ self._file: IO = file
+ self._scopes: List[str] = []
+ self._margin: str = ''
+ self._needs_blank: bool = False
+ self.repos: Dict[str, str] = {}
+ self.aliases: Dict[str, str] = {}
+
+ def write_comment(self, comment: Optional[str] = None) -> None:
+ """Adds a GN comment.
+
+ Args:
+ comment: The comment string to write.
+ """
+ if not comment:
+ self.write('#')
+ return
+ while len(comment) > 78:
+ index = comment.rfind(' ', 0, 78)
+ if index < 0:
+ break
+ self.write(f'# {comment[:index]}')
+ comment = comment[index + 1 :]
+ self.write(f'# {comment}')
+
+ def write_import(self, gni: Union[str, PurePosixPath, GnPath]) -> None:
+ """Adds a GN import.
+
+ Args:
+ gni: The source-relative path to a GN import file.
+ """
+ self._needs_blank = False
+ self.write(f'import("{str(gni)}")')
+ self._needs_blank = True
+
+ def write_imports(self, imports: Iterable[str]) -> None:
+ """Adds a list of GN imports.
+
+ Args:
+ imports: A list of GN import files.
+ """
+ for gni in imports:
+ self.write_import(gni)
+
+ def write_config(self, config: GnConfig) -> None:
+ """Adds a GN config.
+
+ Args:
+ config: The GN config data to write.
+ """
+ if not config:
+ return
+ if not config.label:
+ raise MalformedGnError('missing label for `config`')
+ self.write_target_start('config', config.label.name())
+ for flag in GN_CONFIG_FLAGS:
+ self.write_list(flag, config.get(flag))
+ self.write_end()
+
+ def write_target(self, target: GnTarget) -> None:
+ """Write a GN target.
+
+ Args:
+ target: The GN target data to write.
+ """
+ self.write_comment(
+ f'Generated from //{target.package()}:{target.name()}'
+ )
+ self.write_target_start(target.type(), target.name())
+ visibility = [
+ target.make_relative(scope) for scope in target.visibility
+ ]
+ self.write_list('visibility', visibility)
+ self.write_list('public', [str(path) for path in target.public])
+ self.write_list('sources', [str(path) for path in target.sources])
+ self.write_list('inputs', [str(path) for path in target.inputs])
+ for flag in GN_CONFIG_FLAGS:
+ self.write_list(flag, target.config.get(flag))
+ self._write_relative('public_configs', target, target.public_configs)
+ self._write_relative('configs', target, target.configs)
+ self._write_relative('remove_configs', target, target.remove_configs)
+ self._write_relative('public_deps', target, target.public_deps)
+ self._write_relative('deps', target, target.deps)
+ self.write_end()
+
+ def _write_relative(
+ self, var_name: str, target: GnTarget, labels: Iterable[GnLabel]
+ ) -> None:
+ """Write a list of labels relative to a target.
+
+ Args:
+ var_name: The name of the GN list variable.
+ target: The GN target to rebase the labels to.
+ labels: The labels to write to the list.
+ """
+ self.write_list(var_name, self._resolve(target, labels))
+
+ def _resolve(
+ self, target: GnTarget, labels: Iterable[GnLabel]
+ ) -> Iterator[str]:
+ """Returns rewritten labels.
+
+ If this label has a repo, it must be a key in this object's `repos` and
+ will be replaced by the corresponding value. If this label is a key in
+ this object's `aliases`, it will be replaced by the corresponding value.
+
+ Args:
+ labels: The labels to resolve.
+ """
+ for label in labels:
+ repo = label.repo()
+ if repo:
+ label.resolve_repo(self.repos[repo])
+ label = GnLabel(self.aliases.get(str(label), str(label)))
+ yield target.make_relative(label)
+
+ def write_target_start(
+ self, target_type: str, target_name: Optional[str] = None
+ ) -> None:
+ """Begins a GN target of the given type.
+
+ Args:
+ target_type: The type of the GN target.
+ target_name: The name of the GN target.
+ """
+ if target_name:
+ self.write(f'{target_type}("{target_name}") {{')
+ self._indent(target_name)
+ else:
+ self.write(f'{target_type}() {{')
+ self._indent(target_type)
+
+ def write_list(
+ self, var_name: str, items: Iterable[str], reorder: bool = True
+ ) -> None:
+ """Adds a named GN list of the given items, if non-empty.
+
+ Args:
+ var_name: The name of the GN list variable.
+ items: The list items to write as strings.
+ reorder: If true, the list is sorted lexicographically.
+ """
+ items = list(items)
+ if not items:
+ return
+ self.write(f'{var_name} = [')
+ self._indent(var_name)
+ if reorder:
+ items = sorted(items)
+ for item in items:
+ self.write(f'"{str(item)}",')
+ self._outdent()
+ self.write(']')
+
+ def write_scope(self, var_name: str) -> None:
+ """Begins a named GN scope.
+
+ Args:
+ var_name: The name of the GN scope variable.
+ """
+ self.write(f'{var_name} = {{')
+ self._indent(var_name)
+
+ def write_if(self, cond: str) -> None:
+ """Begins a GN 'if' condition.
+
+ Args:
+ cond: The conditional expression.
+ """
+ self.write(f'if ({cond}) {{')
+ self._indent(cond)
+
+ def write_else_if(self, cond: str) -> None:
+ """Adds another GN 'if' condition to a previous 'if' condition.
+
+ Args:
+ cond: The conditional expression.
+ """
+ self._outdent()
+ self.write(f'}} else if ({cond}) {{')
+ self._indent(cond)
+
+ def write_else(self) -> None:
+ """Adds a GN 'else' clause to a previous 'if' condition."""
+ last = self._outdent()
+ self.write('} else {')
+ self._indent(f'!({last})')
+
+ def write_end(self) -> None:
+ """Ends a target, scope, or 'if' condition'."""
+ self._outdent()
+ self.write('}')
+ self._needs_blank = True
+
+ def write_blank(self) -> None:
+ """Adds a blank line."""
+ print('', file=self._file)
+ self._needs_blank = False
+
+ def write_preformatted(self, preformatted: str) -> None:
+ """Adds text with minimal formatting.
+
+ The only formatting applied to the given text is to strip any leading
+ whitespace. This allows calls to be more readable by allowing
+ preformatted text to start on a new line, e.g.
+
+ _write_preformatted('''
+ preformatted line 1
+ preformatted line 2
+ preformatted line 3''')
+
+ Args:
+ preformatted: The text to write.
+ """
+ print(preformatted.lstrip(), file=self._file)
+
+ def write(self, text: str) -> None:
+ """Writes to the file, appropriately indented.
+
+ Args:
+ text: The text to indent and write.
+ """
+ if self._needs_blank:
+ self.write_blank()
+ print(f'{self._margin}{text}', file=self._file)
+
+ def _indent(self, scope: str) -> None:
+ """Increases the current margin.
+
+ Saves the scope of indent to aid in debugging. For example, trying to
+ use incorrect code such as
+
+ ```
+ self.write_if('foo')
+ self.write_comment('bar')
+ self.write_else_if('baz')
+ ```
+
+ will throw an exception due to the missing `write_end`. The exception
+ will note that 'baz' was opened but not closed.
+
+ Args:
+ scope: The name of the scope (for debugging).
+ """
+ self._scopes.append(scope)
+ self._margin += ' '
+
+ def _outdent(self) -> str:
+ """Decreases the current margin."""
+ if not self._scopes:
+ raise MalformedGnError('scope closed unexpectedly')
+ last = self._scopes.pop()
+ self._margin = self._margin[2:]
+ self._needs_blank = False
+ return last
+
+ def seal(self) -> None:
+ """Instructs the object that no more writes will occur."""
+ if self._scopes:
+ raise MalformedGnError(f'unclosed scope(s): {self._scopes}')
+
+
+class GnFile:
+ """Represents an open BUILD.gn file that is formatted on close.
+
+ Typical usage:
+
+ with GnFile('/path/to/BUILD.gn', 'my-package') as build_gn:
+ build_gn.write_...
+
+ where "write_..." refers to any of the "write" methods of `GnWriter`.
+ """
+
+ def __init__(
+ self, pathname: PurePath, package: Optional[str] = None
+ ) -> None:
+ if pathname.name != 'BUILD.gn' and pathname.suffix != '.gni':
+ raise MalformedGnError(f'invalid GN filename: {pathname}')
+ os.makedirs(pathname.parent, exist_ok=True)
+ self._pathname: PurePath = pathname
+ self._package: Optional[str] = package
+ self._file: IO
+ self._writer: GnWriter
+
+ def __enter__(self) -> GnWriter:
+ """Opens the GN file."""
+ self._file = open(self._pathname, 'w+')
+ self._writer = GnWriter(self._file)
+ self._writer.write_preformatted(COPYRIGHT_HEADER)
+ file = PurePath(*PurePath(__file__).parts[-2:])
+ self._writer.write_comment(
+ f'This file was automatically generated by {file}'
+ )
+ if self._package:
+ self._writer.write_comment(
+ f'It contains GN build targets for {self._package}.'
+ )
+ self._writer.write_blank()
+ return self._writer
+
+ def __exit__(
+ self,
+ exc_type: Optional[Type[BaseException]],
+ exc_val: Optional[BaseException],
+ exc_tb: Optional[TracebackType],
+ ) -> None:
+ """Closes the GN file and formats it."""
+ self._file.close()
+ subprocess.check_call(['gn', 'format', self._pathname])