pw_bloat: GN template to collect binary size report data

This adds a `pw_size_report_aggregation` template to collect JSON data
from several size reports into a single size report file.

Bug: 266717927
Change-Id: I0081fe7f338ddce1283fa7e51a725c50547ae787
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/127042
Reviewed-by: Chris Kenyon <chriskenyon@google.com>
Pigweed-Auto-Submit: Alexei Frolov <frolv@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
diff --git a/pw_bloat/bloat.gni b/pw_bloat/bloat.gni
index b156b09..52a3a3f 100644
--- a/pw_bloat/bloat.gni
+++ b/pw_bloat/bloat.gni
@@ -1,4 +1,4 @@
-# Copyright 2022 The Pigweed Authors
+# 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
@@ -171,6 +171,51 @@
   }
 }
 
+# Aggregates JSON size report data from several pw_size_report targets into a
+# single output file.
+#
+# Args:
+#   deps: List of pw_size_report targets whose data to collect.
+#   output: Path to the output JSON file.
+#
+# Example:
+#   pw_size_report_aggregation("image_sizes") {
+#      deps = [
+#        ":app_image_size_report",
+#        ":bootloader_image_size_report",
+#      ]
+#      output = "$root_gen_dir/artifacts/image_sizes.json"
+#   }
+#
+template("pw_size_report_aggregation") {
+  assert(defined(invoker.deps) && invoker.deps != [],
+         "pw_size_report_aggregation requires size report dependencies")
+  assert(defined(invoker.output),
+         "pw_size_report_aggregation requires an output file path")
+
+  _input_json_files = []
+
+  foreach(_dep, invoker.deps) {
+    _gen_dir = get_label_info(_dep, "target_gen_dir")
+    _dep_name = get_label_info(_dep, "name")
+    _input_json_files +=
+        [ rebase_path("$_gen_dir/${_dep_name}.binary_sizes.json",
+                      root_build_dir) ]
+  }
+
+  pw_python_action(target_name) {
+    script = "$dir_pw_bloat/py/pw_bloat/binary_size_aggregator.py"
+    python_deps = [ "$dir_pw_bloat/py" ]
+    args = [
+             "--output",
+             rebase_path(invoker.output, root_build_dir),
+           ] + _input_json_files
+    outputs = [ invoker.output ]
+    deps = invoker.deps
+    forward_variables_from(invoker, [ "visibility" ])
+  }
+}
+
 # Creates a target which runs a size report diff on a set of executables.
 #
 # Args:
@@ -190,7 +235,7 @@
 #         Overrides global source_filter argument.
 #       data_sources: Optional List of datasources from bloaty config file
 #         Overrides global data_sources argument.
-
+#
 #
 # Example:
 #   pw_size_diff("foo_bloat") {
diff --git a/pw_bloat/docs.rst b/pw_bloat/docs.rst
index 88267c9..85441e8 100644
--- a/pw_bloat/docs.rst
+++ b/pw_bloat/docs.rst
@@ -231,6 +231,35 @@
 (``pigweed/pw_bloat/bloat.gni``), set the ``pw_bloat_SHOW_SIZE_REPORTS``
 build arg to ``true``.
 
+Collecting size report data
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Each ``pw_size_report`` target outputs a JSON file containing the sizes of all
+top-level labels in the binary. (By default, this represents "segments", i.e.
+ELF program headers.) If a build produces multiple images, it may be useful to
+collect all of their sizes into a single file to provide a snapshot of sizes at
+some point in time --- for example, to display per-commit size deltas through
+CI.
+
+The ``pw_size_report_aggregation`` template is provided to collect multiple size
+reports' data into a single JSON file.
+
+**Arguments**
+
+* ``deps``: List of ``pw_size_report`` targets whose data to collect.
+* ``output``: Path to the output JSON file.
+
+.. code::
+
+  import("$dir_pw_bloat/bloat.gni")
+
+  pw_size_report_aggregation("image_sizes") {
+     deps = [
+       ":app_image_size_report",
+       ":bootloader_image_size_report",
+     ]
+     output = "$root_gen_dir/artifacts/image_sizes.json"
+  }
+
 Documentation integration
 =========================
 Bloat reports are easy to add to documentation files. All ``pw_size_diff``
diff --git a/pw_bloat/py/BUILD.gn b/pw_bloat/py/BUILD.gn
index 67a9598..2f75579 100644
--- a/pw_bloat/py/BUILD.gn
+++ b/pw_bloat/py/BUILD.gn
@@ -25,6 +25,7 @@
   sources = [
     "pw_bloat/__init__.py",
     "pw_bloat/__main__.py",
+    "pw_bloat/binary_size_aggregator.py",
     "pw_bloat/bloat.py",
     "pw_bloat/bloaty_config.py",
     "pw_bloat/label.py",
diff --git a/pw_bloat/py/pw_bloat/binary_size_aggregator.py b/pw_bloat/py/pw_bloat/binary_size_aggregator.py
new file mode 100644
index 0000000..d590c3c
--- /dev/null
+++ b/pw_bloat/py/pw_bloat/binary_size_aggregator.py
@@ -0,0 +1,75 @@
+# 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.
+"""
+Collects binary size JSON outputs from bloat targets into a single file.
+"""
+
+import argparse
+import json
+import logging
+from pathlib import Path
+import sys
+
+from typing import Dict, List
+
+import pw_cli.log
+
+_LOG = logging.getLogger(__package__)
+
+
+def _parse_args() -> argparse.Namespace:
+    """Parses the script's arguments."""
+
+    parser = argparse.ArgumentParser(__doc__)
+    parser.add_argument(
+        '--output',
+        type=Path,
+        required=True,
+        help='Output JSON file',
+    )
+    parser.add_argument(
+        'inputs',
+        type=Path,
+        nargs='+',
+        help='Input JSON files',
+    )
+
+    return parser.parse_args()
+
+
+def main(inputs: List[Path], output: Path) -> int:
+    all_data: Dict[str, int] = {}
+
+    for file in inputs:
+        try:
+            all_data |= json.loads(file.read_text())
+        except FileNotFoundError:
+            target_name = file.name.split('.')[0]
+            _LOG.error('')
+            _LOG.error('JSON input file %s does not exist', file)
+            _LOG.error('')
+            _LOG.error(
+                'Check that the build target "%s" is a pw_size_report template',
+                target_name,
+            )
+            _LOG.error('')
+            return 1
+
+    output.write_text(json.dumps(all_data, sort_keys=True, indent=2))
+    return 0
+
+
+if __name__ == '__main__':
+    pw_cli.log.install()
+    sys.exit(main(**vars(_parse_args())))