annotations: support JSON format
authorAndrea Righi <andrea.righi@canonical.com>
Mon, 4 Dec 2023 11:53:42 +0000 (12:53 +0100)
committerAndrea Righi <andrea.righi@canonical.com>
Mon, 11 Dec 2023 10:23:21 +0000 (11:23 +0100)
Allow to read and dump all annotations data in pure JSON format.

With this change applied the "annotations" script is able to read either
the old custom format (format version 4) or a new pure-JSON format
(format version 5).

It is possible to convert an old annotations file to the newer format
simply by running "annotations" (no argument): the script will parse the
old annotations (format version 4) and it will dump in output the new
content in the new pure-JSON format (format version 5).

Signed-off-by: Andrea Righi <andrea.righi@canonical.com>
kconfig/annotations.py
kconfig/run.py
kconfig/version.py

index 5cd51ae2bb6b3168a5d552104041788a087b19d8..e3edfb57940a7ec38b3f614c2e77002a8db04cf3 100644 (file)
@@ -12,6 +12,8 @@ from abc import abstractmethod
 from ast import literal_eval
 from os.path import dirname, abspath
 
+from kconfig.version import ANNOTATIONS_FORMAT_VERSION
+
 
 class Config:
     def __init__(self, fname):
@@ -131,7 +133,7 @@ class Annotation(Config):
             # Invalid line
             raise SyntaxError(f"invalid line: {line}")
 
-    def _parse(self, data: str):
+    def _legacy_parse(self, data: str):
         """
         Parse main annotations file, individual config options can be accessed
         via self.config[<CONFIG_OPTION>]
@@ -161,6 +163,13 @@ class Annotation(Config):
             else:
                 break
 
+        # Return an error if architectures are not defined
+        if not self.arch:
+            raise SyntaxError(f"ARCH not defined in annotations")
+        # Return an error if flavours are not defined
+        if not self.flavour:
+            raise SyntaxError(f"FLAVOUR not defined in annotations")
+
         # Parse body (handle includes recursively)
         self._parse_body(data)
 
@@ -171,6 +180,51 @@ class Annotation(Config):
             if tgt not in self.include_flavour:
                 raise SyntaxError(f"Invalid target flavour in FLAVOUR_DEP: {tgt}")
 
+    def _json_parse(self, data, is_included=False):
+        data = json.loads(data)
+
+        # Check if version is supported
+        version = data['attributes']['_version']
+        if version > ANNOTATIONS_FORMAT_VERSION:
+                raise SyntaxError(f"annotations format version {version} not supported")
+
+        # Check for top-level annotations vs imported annotations
+        if not is_included:
+            self.config = data['config']
+            self.arch = data['attributes']['arch']
+            self.flavour = data['attributes']['flavour']
+            self.flavour_dep = data['attributes']['flavour_dep']
+            self.include = data['attributes']['include']
+            self.include_flavour = []
+        else:
+            # We are procesing an imported annotations, so merge all the
+            # configs and attributes.
+            try:
+                self.config |= data['config']
+            except TypeError:
+                self.config = {
+                    **self.config,
+                    **data['config']
+                }
+            self.arch = list(set(self.arch) | set(data['attributes']['arch']))
+            self.flavour = list(set(self.flavour) | set(data['attributes']['flavour']))
+            self.include_flavour = list(set(self.include_flavour) | set(data['attributes']['flavour']))
+            self.flavour_dep = list(set(self.flavour_dep) | set(data['attributes']['flavour_dep']))
+
+        # Handle recursive inclusions
+        for f in data['attributes']['include']:
+            include_fname = dirname(abspath(self.fname)) + "/" + f
+            data = self._load(include_fname)
+            self._json_parse(data, is_included=True)
+
+    def _parse(self, data: str):
+        # Try to parse the legacy format first, otherwise use the new JSON
+        # format.
+        try:
+            self._legacy_parse(data)
+        except SyntaxError:
+            self._json_parse(data, is_included=False)
+
     def _remove_entry(self, config: str):
         if self.config[config]:
             del self.config[config]
index 743ae7989cae3e720c04200dc28c9fc8e37c1479..992dc56b55adff8b74e8ff2e8a09e5db47e71335 100644 (file)
@@ -22,7 +22,7 @@ except ModuleNotFoundError:
 
 from kconfig.annotations import Annotation, KConfig
 from kconfig.utils import autodetect_annotations, arg_fail
-from kconfig.version import VERSION
+from kconfig.version import VERSION, ANNOTATIONS_FORMAT_VERSION
 
 
 SKIP_CONFIGS = (
@@ -127,11 +127,36 @@ def make_parser():
 
 _ARGPARSER = make_parser()
 
+def export_result(data):
+    # Dump metadata / attributes first
+    out = '{\n  "attributes": {\n'
+    for key, value in sorted(data['attributes'].items()):
+        out += f'     "{key}": {json.dumps(value)},\n'
+    out = out.rstrip(',\n')
+    out += '\n  },'
+    print(out)
+
+    configs_with_note = {key: value for key, value in data['config'].items() if 'note' in value}
+    configs_without_note = {key: value for key, value in data['config'].items() if 'note' not in value}
+
+    # Dump configs, sorted alphabetically, showing items with a note first
+    out = '  "config": {\n'
+    for key in sorted(configs_with_note) + sorted(configs_without_note):
+        policy = data['config'][key]['policy']
+        if 'note' in data['config'][key]:
+            note = data['config'][key]['note']
+            out += f'    "{key}": {{"policy": {json.dumps(policy)}, "note": {json.dumps(note)}}},\n'
+        else:
+            out += f'    "{key}": {{"policy": {json.dumps(policy)}}},\n'
+    out = out.rstrip(',\n')
+    out += '\n  }\n}'
+    print(out)
+
 
-def print_result(config, res):
-    if res is not None and config is not None and config not in res:
-        res = {config: res}
-    print(json.dumps(res, indent=4))
+def print_result(config, data):
+    if data is not None and config is not None and config not in data:
+        data = {config: data}
+    print(json.dumps(data, sort_keys=True, indent=2))
 
 
 def do_query(args):
@@ -142,12 +167,18 @@ def do_query(args):
     # If no arguments are specified dump the whole annotations structure
     if args.config is None and args.arch is None and args.flavour is None:
         res = {
-            "arch": a.arch,
-            "flavour": a.flavour,
-            "flavour_dep": a.flavour_dep,
+            "attributes": {
+                "arch": a.arch,
+                "flavour": a.flavour,
+                "flavour_dep": a.flavour_dep,
+                "include": a.include,
+                "_version": ANNOTATIONS_FORMAT_VERSION,
+            },
             "config": res,
         }
-    print_result(args.config, res)
+        export_result(res)
+    else:
+        print_result(args.config, res)
 
 
 def do_autocomplete(args):
index ac5ad54c6621afb6a42939c5787ba879b32ca3a1..b0d479a55ccaade1af422464fef6120d2c1cb0ef 100644 (file)
@@ -5,5 +5,7 @@
 
 VERSION = "0.1"
 
+ANNOTATIONS_FORMAT_VERSION = 5
+
 if __name__ == '__main__':
     print(VERSION)