Commit e0e0bac5 authored by Luke Mewburn's avatar Luke Mewburn
Browse files

compat_asn1: support comparing git commits

Update script usage to:
  usage: compat_asn1.py FILE1 [FILE2 [DEPENDENCY ...]]
  usage: compat_asn1.py -1 COMMIT1 [-2 COMMIT2] FILE1 [DEPENDENCY ...]
  usage: compat_asn1.py -h

Implement CompatCheck.compare_commits(
  self, commit1: str, commit2: str, asn_file: str, dependencies: list[str])
parent 85d8ed6a
Loading
Loading
Loading
Loading
+181 −38
Original line number Diff line number Diff line
#!/usr/bin/env python3

"""
Module to check backwards compatibility between two ASN.1 files.
"""
"""Module to check backwards compatibility between two ASN.1 files."""

import argparse
import copy
import logging
import os
import pathlib
import subprocess
import sys

import pycrate_asn1c.asnobj as pa_asnobj
import pycrate_asn1c.asnproc


# TODO: require python >= 3.10
# Note: requires python >= 3.10, pycrate >= 0.8.1


# pylint: disable=logging-fstring-interpolation

TYPE_CONTAINER_OF = (pa_asnobj.TYPE_SEQ_OF, pa_asnobj.TYPE_SET_OF)


@@ -121,17 +119,24 @@ def repr_type_tagpath(asnobj: pa_asnobj.ASN1Obj) -> str:
    return result


class CompatCheckException(Exception):
    """Exception raised by CompatCheck."""

    pass


class CompatCheck:
    """Check backwards compatibility between two ASN.1 modules."""

    def __init__(self):
        """Construct CompatCheck."""
        self._clear()

    def _clear(self) -> None:
        """Reset to a fresh state."""
        self._errors = list()
        self._fa_mod = None
        self._fb_mod = None
        self._fa_mod = None  # populated by _parse_asn1()
        self._fb_mod = None  # populated by _parse_asn1()
        self._seen = set()

    def _process_error(self, message: str) -> None:
@@ -139,18 +144,60 @@ class CompatCheck:
        logging.info(f"Test Failure: {message}")
        self._errors.append(message)

    def _parse_asn1(self, asn_files: list[str]) -> pa_asnobj.ASN1Dict:
        """Parse `asn_files` as ASN.1 using pycrate, returning the module object."""
        logging.info(f"Parsing {' '.join(asn_files)}")
        asn_text = []
        for filename in asn_files:
    def _read_commit(self, commit: str, filename: str):
        """Read file `filename` as at git commit `commit`, return text contents."""
        logging.debug(f"Reading file '{filename}' as at git commit '{commit}'")
        p = subprocess.run(
            ["git", "cat-file", "-p", f"{commit}:{filename}"],
            capture_output=True,
            text=True,
        )
        if p.returncode != 0:
            raise CompatCheckException(
                f"Error reading git commit '{commit}' for file '{filename}': {p.stderr}"
            )
        return p.stdout

    def _read_file(self, filename: str) -> str:
        """Read file `filename` and return text contents."""
        logging.debug(f"Reading file '{filename}'")
        try:
            filepath = pathlib.Path(filename)
            asn_text.append(filepath.read_text())
            return filepath.read_text()
        except Exception as ex:
            raise CompatCheckException(f"Error reading '{filename}': {ex}")

    def _parse_asn1_text(
        self, asn_text: str, filenames: list[str]
    ) -> pa_asnobj.ASN1Dict:
        """Parse `asn_text` using pycrate, returning the module object."""
        logging.debug("Parsing ASN.1 text '%s'" % asn_text)
        pycrate_asn1c.asnproc.GLOBAL.clear()
        pycrate_asn1c.asnproc.compile_text(asn_text, filenames=asn_files)
        pycrate_asn1c.asnproc.compile_text(text=asn_text, filenames=filenames)
        res = copy.deepcopy(pycrate_asn1c.asnproc.GLOBAL.MOD)
        return res

    def _parse_asn1_commit(
        self, commit: str, asn_file: str, dependencies: list[str]
    ) -> pa_asnobj.ASN1Dict:
        """Parse `asn_file` as at git commit `commit` as ASN.1 using pycrate, returning the module object."""
        logging.info(f"Parsing {asn_file} as at git commit {commit}")
        asn_text = []
        asn_text.append(self._read_commit(commit=commit, filename=asn_file))
        filenames = [asn_file]
        for filename in dependencies:
            asn_text.append(self._read_file(filename))
            filenames.append(filename)
        return self._parse_asn1_text(asn_text=asn_text, filenames=filenames)

    def _parse_asn1_files(self, asn_files: list[str]) -> pa_asnobj.ASN1Dict:
        """Parse `asn_files` as ASN.1 using pycrate, returning the module object."""
        logging.info(f"Parsing {' '.join(asn_files)}")
        asn_text = []
        for filename in asn_files:
            asn_text.append(self._read_file(filename))
        return self._parse_asn1_text(asn_text=asn_text, filenames=asn_files)

    def _container_to_tag_dict(self, container: pa_asnobj.ASN1Dict) -> dict:
        """Convert `container` to dict indexed on tag, value is type."""
        result = {}
@@ -318,26 +365,25 @@ class CompatCheck:
        else:
            logging.debug(f"Unimplemented check of type '{fa_type.TYPE}'")

    def compare_files(self, a_files: list[str], b_files: list[str]) -> list(str):
        """Compare ASN.1 in files `a_files` with files `b_files` for
        backwards compatibility issues:
        - Module is the same
    def _compare_modules(self) -> list(str):
        """Compare ASN.1 for backwards compatibility issues between
        modules in self._fa_mod and self._fb_mod.

        Issues checked:
        - Each module is the same:
          - Each type in the module:
             - Exists.
             - Has the same member types.
             - Each complex member type has:
             - Has the same type.
             - Has the same contraints.
             - Each member in a complex type is checked for:
               - Same inner name at the tag.
               - Same inner type at the tag.
             - New tags in SEQUENCE and SET are OPTIONAL.

        Returns a list of error messages (if any).
        """
        logging.info(f"Comparing file {a_files[0]} with {b_files[0]}")

        self._clear()

        self._fa_mod = self._parse_asn1(a_files)
        self._fb_mod = self._parse_asn1(b_files)
        assert self._fa_mod
        assert self._fb_mod

        for module_name, fa_module in self._fa_mod.items():
            if module_name.startswith("_"):
@@ -363,6 +409,45 @@ class CompatCheck:

        return self._errors

    def compare_commits(
        self, commit1: str, commit2: str, asn_file: str, dependencies: list[str]
    ) -> list(str):
        """Compare ASN.1 for backwards compatibility issues between
        `asn_file` at git commit `commit1` and `asn_file` at git commit `commit2`,
        using optional list of dependencies in `dependencies` at git commit HEAD.

        Returns a list of error messages (if any).
        """
        logging.info(
            f"Comparing file {asn_file} between git commit {commit1} and {commit2}"
        )

        self._clear()

        self._fa_mod = self._parse_asn1_commit(
            commit=commit1, asn_file=asn_file, dependencies=dependencies
        )
        self._fb_mod = self._parse_asn1_commit(
            commit=commit2, asn_file=asn_file, dependencies=dependencies
        )

        return self._compare_modules()

    def compare_files(self, a_files: list[str], b_files: list[str]) -> list(str):
        """Compare ASN.1 for backwards compatibility issues between
        files `a_files` and files `b_files`.

        Returns a list of error messages (if any).
        """
        logging.info(f"Comparing file {a_files[0]} with {b_files[0]}")

        self._clear()

        self._fa_mod = self._parse_asn1_files(a_files)
        self._fb_mod = self._parse_asn1_files(b_files)

        return self._compare_modules()


def main():
    """standalone main."""
@@ -370,20 +455,78 @@ def main():
    logging.basicConfig(level=loglevel)
    pycrate_asn1c.utils.logger.setLevel(loglevel)

    if len(sys.argv) < 3:
        raise RuntimeError(f"Usage: {sys.argv[0]} FILE1.ASN FILE2.ASN [DEP.ASN ...]")
    a_files = [sys.argv[1]]
    b_files = [sys.argv[2]]
    a_files.extend(sys.argv[3:])
    b_files.extend(sys.argv[3:])
    argparser = argparse.ArgumentParser(
        usage="""\
%(prog)s FILE1 [FILE2 [DEPENDENCY ...]]
usage: %(prog)s -1 COMMIT1 [-2 COMMIT2] FILE1 [DEPENDENCY ...]
usage: %(prog)s -h
"""
    )
    argparser.add_argument(
        "-1",
        "--commit1",
        help="first commit to check FILE1.asn against",
    )
    argparser.add_argument(
        "-2",
        "--commit2",
        help="second commit to check FILE1.asn against. [HEAD]",
    )
    argparser.add_argument(
        "file1",
        metavar="FILE1",
        help="first ASN.1 specification to check",
    )
    argparser.add_argument(
        "file2",
        metavar="FILE2",
        nargs="?",
        help="second ASN.1 specification to check (if not using -1)",
    )
    argparser.add_argument(
        "dependencies",
        metavar="DEPENDENCIES",
        nargs=argparse.REMAINDER,
        help="optional dependencies for FILE1 and FILE2",
    )
    args = argparser.parse_args()

    compatCheck = CompatCheck()
    compatErrors = compatCheck.compare_files(a_files=a_files, b_files=b_files)
    compatErrors = []

    if args.commit1 is not None:
        if args.file2 is not None:
            args.dependencies.insert(0, args.file2)
            args.file2 = None
        if args.commit2 is None:
            args.commit2 = "HEAD"
        compatErrors.extend(
            compatCheck.compare_commits(
                commit1=args.commit1,
                commit2=args.commit2,
                asn_file=args.file1,
                dependencies=args.dependencies,
            )
        )
    else:
        if args.commit2 is not None:
            argparser.error("-2 COMMIT2 provided without -1 COMMIT1")
        a_files = [args.file1]
        b_files = [args.file2]
        a_files.extend(args.dependencies)
        b_files.extend(args.dependencies)
        compatErrors.extend(compatCheck.compare_files(a_files=a_files, b_files=b_files))

    if compatErrors:
        print("-----------------------------")
        print(f"File 1:       {a_files[0]}")
        print(f"File 2:       {b_files[0]}")
        print(f"Dependencies: {' '.join(a_files[1:])}")
        if args.commit1:
            print(f"File:         {args.file1}")
            print(f"Commit 1:     {args.commit1}")
            print(f"Commit 2:     {args.commit2}")
        else:
            print(f"File 1:       {args.file1}")
            print(f"File 2:       {args.file2}")
        print(f"Dependencies: {' '.join(args.dependencies)}")
        print("Errors:")
        for error in compatErrors:
            print(f"  {error}")