[dtschema PATCH] tools: Add tool to compare schemas for ABI changes

Rob Herring <[email protected]>
Newsgroups org.kernel.vger.devicetree-spec
Message-ID <[email protected]>
Add a new tool which compares 2 sets of schemas for possible ABI changes.
It's not complete nor 100% accurate, but it's a start.

This checks for the following kinds of changes:

- New required properties
- Minimum number of entries required increased
- Removed properties

Limitations:

Restructuring of schemas may result in false positives or missed ABI
changes. There's some support if a property moves from a schema to a
referenced schema.

Schemas underneath logic keywords (allOf, oneOf, anyOf) or if/then/else schemas
are not handled.

Signed-off-by: Rob Herring <[email protected]>
---
 pyproject.toml      |   1 +
 tools/dt-cmp-schema | 135 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 136 insertions(+)
 create mode 100755 tools/dt-cmp-schema

diff --git a/pyproject.toml b/pyproject.toml
index 6fc9bb7ae4b9..da5fcca421ce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,6 +36,7 @@ Source="https://github.com/devicetree-org/dt-schema"
 
 [tool.setuptools]
 script-files = [
+    'tools/dt-cmp-schema',
     'tools/dt-check-compatible',
     'tools/dt-validate',
     'tools/dt-doc-validate',
diff --git a/tools/dt-cmp-schema b/tools/dt-cmp-schema
new file mode 100755
index 000000000000..25ac3dd05274
--- /dev/null
+++ b/tools/dt-cmp-schema
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-2-Clause
+# Copyright 2023 Arm Ltd.
+
+import sys
+import argparse
+import signal
+import urllib
+
+import dtschema
+
+
+def _sigint_handler(signum, frame):
+    sys.exit(-2)
+
+
+signal.signal(signal.SIGINT, _sigint_handler)
+
+
+def path_list_to_str(path):
+    return '/' + '/'.join(path)
+
+
+def prop_generator(schema, path=[]):
+    if not isinstance(schema, dict):
+        return
+    for prop_key in ['properties', 'patternProperties']:
+        if prop_key in schema:
+            for p, sch in schema[prop_key].items():
+                yield path + [prop_key, p], sch
+                yield from prop_generator(sch, path=path + [prop_key, p])
+
+
+def _ref_to_id(id, ref):
+    ref = urllib.parse.urljoin(id, ref)
+    if '#/' not in ref:
+        ref += '#'
+    return ref
+
+
+def _prop_in_schema(prop, schema, schemas):
+    for p, sch in prop_generator(schema):
+        if p[1] == prop:
+            return True
+
+    if 'allOf' in schema:
+        for e in schema['allOf']:
+            if '$ref' in e:
+                ref_id = _ref_to_id(schema['$id'], e['$ref'])
+                if ref_id in schemas:
+                    if _prop_in_schema(prop, schemas[ref_id], schemas):
+                        return True
+
+    if '$ref' in schema:
+        ref_id = _ref_to_id(schema['$id'], schema['$ref'])
+        if ref_id in schemas and _prop_in_schema(prop, schemas[ref_id], schemas):
+            return True
+
+    return False
+
+
+def check_removed_property(id, base, schemas):
+    for p, sch in prop_generator(base):
+        if not _prop_in_schema(p[1], schemas[id], schemas):
+            print(f'{id}{path_list_to_str(p)}: existing property removed\n', file=sys.stderr)
+
+
+def schema_get_from_path(sch, path):
+    for p in path:
+        try:
+            sch = sch[p]
+        except:
+            return None
+    return sch
+
+
+def check_new_items(id, base, new, path=[]):
+    for p, sch in prop_generator(new):
+        if not isinstance(sch, dict) or 'minItems' not in sch:
+            continue
+
+        min = sch['minItems']
+        base_min = schema_get_from_path(base, p + ['minItems'])
+
+        if base_min and min > base_min:
+            print(f'{id}{path_list_to_str(p)}: new required entry added\n', file=sys.stderr)
+
+
+def _check_required(id, base, new, path=[]):
+    if not isinstance(base, dict) or not isinstance(new, dict):
+        return
+
+    if 'required' not in new:
+        return
+
+    if 'required' not in base:
+        print(f'{id}{path_list_to_str(path)}: new required properties added: {", ".join(new["required"])}\n', file=sys.stderr)
+        return
+
+    diff = set(new['required']) - set(base['required'])
+    if diff:
+        print(f'{id}{path_list_to_str(path)}: new required properties added: {", ".join(diff)}\n', file=sys.stderr)
+        return
+
+
+def check_required(id, base, new):
+    _check_required(id, base, new)
+
+    for p, sch in prop_generator(new):
+        _check_required(id, schema_get_from_path(base, p), sch, path=p)
+
+
+if __name__ == "__main__":
+    ap = argparse.ArgumentParser(description="Compare 2 sets of schemas for possible ABI differences")
+    ap.add_argument("baseline", type=str,
+                    help="Baseline schema directory or preprocessed schema file")
+    ap.add_argument("new", type=str,
+                    help="New schema directory or preprocessed schema file")
+    ap.add_argument('-V', '--version', help="Print version number",
+                    action="version", version=dtschema.__version__)
+    args = ap.parse_args()
+
+    base_schemas = dtschema.DTValidator([args.baseline]).schemas
+    schemas = dtschema.DTValidator([args.new]).schemas
+
+    if not schemas or not base_schemas:
+        sys.exit(-1)
+
+    for id, sch in schemas.items():
+        if id not in base_schemas or 'generated' in id:
+            continue
+
+        check_required(id, base_schemas[id], sch)
+        check_removed_property(id, base_schemas[id], schemas)
+        check_new_items(id, base_schemas[id], sch)
-- 
2.42.0
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.