fwtc: Create script to calculate a board's JSON

Currently, the ultimate fw-testing-config JSON is determined at runtime
based on logic that is duplicated in both FAFT and Tast. This has two
unfortunate effects:
1. Duplicated logic is hard to maintain, and can easily lead to
   inconsistent errors.
2. There is no easy way for a developer to see what the config values
   are for a given platform.

A first step toward solving both of those problems is to write a script
in fw-testing-configs that will calculate the final configs for a
platform.

Also re-runs consolidate.py, because https://crrev.com/c/2519483 did not
re-run the script for a late patch-set.

BUG=b:173118460
BUG=b:173118890
TEST=platform_json_unittest.py
TEST=platform_json.py fievel (has multiple inheritance)
TEST=platform_json.py octopus -m bobba360 (has model override)
TEST=platform_json.py octopus --condense-output

Change-Id: Id9927efe8f3730ad3e02a5986ad8c0b443f86512
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/fw-testing-configs/+/2534754
Tested-by: Greg Edelston <gredelston@google.com>
Reviewed-by: Andrew Luo <aluo@chromium.org>
Commit-Queue: Andrew Luo <aluo@chromium.org>
Auto-Submit: Greg Edelston <gredelston@google.com>
diff --git a/platform_json.py b/platform_json.py
new file mode 100755
index 0000000..7a24809
--- /dev/null
+++ b/platform_json.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Print one platform's full config data to stdout.
+
+This script takes one platform name (and optionally a model name) as inputs,
+calculates the full config for that platform, and prints the results as
+plaintext JSON.
+
+The full config is calculated based on the fw-testing-configs inheritance
+model.  Each platform config has an optional "parent" attribute. For each
+config field, if the platform does not explicitly set a value for that field,
+then the value is instead inherited from the parent config. This inheritance
+can be recursive: the parent may have a parent, and so on. At the top of the
+inheritance tree (when parent is None), any remaining fields inherit their
+values from DEFAULTS. Also, if the platform has a "models" attribute matching
+the passed-in model name, then its field-value pairs override everything else.
+"""
+
+import argparse
+import collections
+import json
+import os
+import re
+import sys
+
+
+# Regexes matching fields which describe config metadata: that is, they do not
+# contain actual config information.  When calculating the final config, fields
+# matching any of these regexes will be ignored.
+META_FIELD_REGEXES = (
+    re.compile(r'^models$'),
+    re.compile(r'\.DOC$')
+)
+
+
+class PlatformNotFoundError(AttributeError):
+    """Error class for when the requested platform name is not found."""
+    pass
+
+
+def parse_args(argv):
+    """Determine input dir and output file from command-line args.
+
+    Args:
+        argv: List of command-line args, excluding the invoked script.
+              Typically, this should be set to sys.argv[1:].
+
+    Returns:
+        An argparse.Namespace with the following attributes:
+            condense_output: A bool determining whether to remove pretty
+                whitespace from the script's final output.
+            consolidated: The filepath to CONSOLIDATED.json
+            platform: The name of the board whose config should be calculated
+            model: The name of the model for the board
+
+    Raises:
+        ValueError: If the passed-in platform name ends in '.json'
+    """
+    parser = argparse.ArgumentParser()
+    parser.add_argument('platform',
+                        help='The platform name to calculate a config for. '
+                             'Should not include ".json" suffix.')
+    parser.add_argument('-m', '--model', default=None,
+                        help='The model name of the board. If not specified, '
+                             'then no model overrides will be used.')
+    parser.add_argument('-c', '--consolidated', default='CONSOLIDATED.json',
+                        help='The filepath to CONSOLIDATED.json')
+    parser.add_argument('--condense-output', action='store_true',
+                        help='Print the output without pretty whitespace.')
+    args = parser.parse_args(argv)
+    return args
+
+
+def calculate_field_value(consolidated_json, field, platform, model=None):
+    """Calculate a platform's ultimate value for a single field.
+
+    Args:
+        consolidated_json: The key-value contents of CONSOLIDATED.json.
+        field: The name of the JSON field to calculate.
+        platform: The name of the platform to calculate for.  If the
+            platform does not define the field, then recursively check its
+            parent's config (or DEFAULTS).
+        model: The name of the model to check overrides for.
+
+    Raises:
+        PlatformNotFoundError: If platform is not in consolidated_json.
+    """
+    if platform not in consolidated_json:
+        raise PlatformNotFoundError(platform)
+
+    # Model overrides are most important.
+    # Not all models have config overrides, so it's OK for the model name not
+    # to be present in models_json.
+    models_json = consolidated_json[platform].get('models', {})
+    if field in models_json.get(model, {}):
+        return models_json[model][field]
+
+    # Then check if the platform explicitly defines the value.
+    if field in consolidated_json[platform]:
+        return consolidated_json[platform][field]
+
+    # Finally, inherit from the parent (or DEFAULTS).
+    # The DEFAULTS config contains every field name, so this will terminate the
+    # recursion.
+    parent = consolidated_json[platform].get('parent', 'DEFAULTS')
+    return calculate_field_value(consolidated_json, field, parent, model)
+
+
+def calculate_platform_json(platform, model, consolidated_fp):
+    """Calculate a platform's ultimate config values for all fields.
+
+    Args:
+        platform: The name of the platform to calculate values for.
+        model: The name of the model to check for overrides.
+    """
+    if not os.path.isfile(consolidated_fp):
+        raise FileNotFoundError(consolidated_fp)
+    with open(consolidated_fp) as consolidated_file:
+        consolidated_json = json.load(consolidated_file)
+    final_json = collections.OrderedDict()
+    for field in consolidated_json['DEFAULTS']:
+        if any(regex.search(field) for regex in META_FIELD_REGEXES):
+            continue
+        value = calculate_field_value(consolidated_json, field, platform, model)
+        final_json[field] = value
+    return final_json
+
+
+def main(argv):
+    """
+    Parse command-line args and print a JSON config to stdout.
+
+    Args:
+        argv: List of command-line args, excluding the invoked script.
+              Typically, this should be set to sys.argv[1:].
+    """
+    args = parse_args(argv)
+    j = calculate_platform_json(args.platform, args.model, args.consolidated)
+    if args.condense_output:
+        print(json.dumps(j), end='')
+    else:
+        print(json.dumps(j, indent=4))
+
+
+if __name__ == '__main__':
+    main(sys.argv[1:])