util: Add a library to parse MAINTAINERS.yaml
authorAndreas Sandberg <andreas.sandberg@arm.com>
Wed, 4 Nov 2020 19:07:08 +0000 (19:07 +0000)
committerAndreas Sandberg <andreas.sandberg@arm.com>
Wed, 11 Nov 2020 09:50:22 +0000 (09:50 +0000)
Add a very simple library to parse MAINTAINERS.yaml. There are
currently no tools that use the library, but it can be tested using
`python3 -m "maint.lib.maintainers"` from within the util directory.

Change-Id: Id2edff94451f27e0b601994d198d0647325e4b35
Signed-off-by: Andreas Sandberg <andreas.sandberg@arm.com>
Reviewed-on: https://gem5-review.googlesource.com/c/public/gem5/+/37036
Maintainer: Gabe Black <gabe.black@gmail.com>
Reviewed-by: Hoa Nguyen <hoanguyen@ucdavis.edu>
Tested-by: kokoro <noreply+kokoro@google.com>
util/maint/lib/__init__.py [new file with mode: 0644]
util/maint/lib/maintainers.py [new file with mode: 0644]
util/maint/lib/tests/__init__.py [new file with mode: 0644]
util/maint/lib/tests/maintainers.py [new file with mode: 0644]

diff --git a/util/maint/lib/__init__.py b/util/maint/lib/__init__.py
new file mode 100644 (file)
index 0000000..e5a0d9b
--- /dev/null
@@ -0,0 +1 @@
+#!/usr/bin/env python3
diff --git a/util/maint/lib/maintainers.py b/util/maint/lib/maintainers.py
new file mode 100644 (file)
index 0000000..072c9b4
--- /dev/null
@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2020 Arm Limited
+# All rights reserved
+#
+# The license below extends only to copyright in the software and shall
+# not be construed as granting a license to any other intellectual
+# property including but not limited to intellectual property relating
+# to a hardware implementation of the functionality of the software
+# licensed hereunder.  You may use the software subject to the license
+# terms below provided that you ensure that this notice is replicated
+# unmodified and in its entirety in all distributions of the software,
+# modified or unmodified, in source code or in binary form.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met: redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer;
+# redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution;
+# neither the name of the copyright holders nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import email.utils
+import enum
+import os
+from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, \
+    TextIO, Tuple, Union
+
+import yaml
+
+PathOrFile = Union[TextIO, str]
+
+class FileFormatException(Exception):
+    pass
+
+class MissingFieldException(FileFormatException):
+    pass
+
+class IllegalValueException(FileFormatException):
+    pass
+
+class Status(enum.Enum):
+    MAINTAINED = enum.auto()
+    ORPHANED = enum.auto()
+
+    @classmethod
+    def from_str(cls, key: str) -> 'Status':
+        _status_dict = {
+            'maintained': cls.MAINTAINED,
+            'orphaned': cls.ORPHANED,
+        }
+        return _status_dict[key]
+
+    def __str__(self) -> str:
+        return {
+            Status.MAINTAINED: 'maintained',
+            Status.ORPHANED: 'orphaned',
+        }[self]
+
+class Subsystem(object):
+    tag: str
+    status: Status
+    maintainers: List[Tuple[str, str]] # Name, email
+    description: str
+
+    def __init__(self, tag: str,
+                 maintainers: Optional[Sequence[Tuple[str, str]]],
+                 description: str = '',
+                 status: Status = Status.ORPHANED):
+        self.tag = tag
+        self.status = status
+        self.maintainers = list(maintainers) if maintainers is not None else []
+        self.description = description if description is not None else ''
+
+class Maintainers(object):
+    DEFAULT_MAINTAINERS = os.path.join(os.path.dirname(__file__),
+                                       '../../../MAINTAINERS.yaml')
+
+    _subsystems: Dict[str, Subsystem] # tag -> Subsystem
+
+    def __init__(self, ydict: Mapping[str, Any]):
+        self._subsystems = {}
+        for tag, maint in ydict.items():
+            self._subsystems[tag] = Maintainers._parse_subsystem(tag, maint)
+
+    @classmethod
+    def from_file(cls, path_or_file: Optional[PathOrFile] = None) \
+        -> "Maintainers":
+
+        return cls(Maintainers._load_maintainers_file(path_or_file))
+
+    @classmethod
+    def from_yaml(cls, yaml_str: str) -> "Maintainers":
+        return cls(yaml.load(yaml_str))
+
+    @classmethod
+    def _load_maintainers_file(cls,
+                               path_or_file: Optional[PathOrFile] = None) \
+                               -> Mapping[str, Any]:
+        if path_or_file is None:
+            path_or_file = cls.DEFAULT_MAINTAINERS
+
+        if isinstance(path_or_file, str):
+            with open(path_or_file, 'r') as fin:
+                return yaml.load(fin)
+        else:
+            return yaml.load(path_or_file)
+
+    @classmethod
+    def _parse_subsystem(cls, tag: str, ydict: Mapping[str, Any]) -> Subsystem:
+        def required_field(name):
+            try:
+                return ydict[name]
+            except KeyError:
+                raise MissingFieldException(
+                    f"{tag}: Required field '{name}' is missing")
+
+        maintainers: List[Tuple[str, str]] = []
+        raw_maintainers = ydict.get('maintainers', [])
+        if not isinstance(raw_maintainers, Sequence):
+            raise IllegalValueException(
+                f"{tag}: Illegal field 'maintainers' isn't a list.")
+        for maintainer in raw_maintainers:
+            name, address = email.utils.parseaddr(maintainer)
+            if name == '' and address == '':
+                raise IllegalValueException(
+                    f"{tag}: Illegal maintainer field: '{maintainer}'")
+            maintainers.append((name, address))
+
+        try:
+            status = Status.from_str(required_field('status'))
+        except KeyError:
+            raise IllegalValueException(
+                f"{tag}: Invalid status '{ydict['status']}'")
+
+        return Subsystem(tag, maintainers=maintainers, status=status,
+                         description=ydict.get('desc', ''))
+
+    def __iter__(self) -> Iterator[Tuple[str, Subsystem]]:
+        return iter(self._subsystems.items())
+
+    def __getitem__(self, key: str) -> Subsystem:
+        return self._subsystems[key]
+
+def _main():
+    maintainers = Maintainers.from_file()
+    for tag, subsys in maintainers:
+        print(f'{tag}: {subsys.description}')
+        print(f'  Status: {subsys.status}')
+        print(f'  Maintainers:')
+        for maint in subsys.maintainers:
+            print(f'    - {maint[0]} <{maint[1]}>')
+        print()
+
+if __name__ == '__main__':
+    _main()
+
+__all__ = [
+    "FileFormatException",
+    "MissingFieldException",
+    "IllegalValueException",
+    "Status",
+    "Subsystem",
+    "Maintainers",
+]
diff --git a/util/maint/lib/tests/__init__.py b/util/maint/lib/tests/__init__.py
new file mode 100644 (file)
index 0000000..e5a0d9b
--- /dev/null
@@ -0,0 +1 @@
+#!/usr/bin/env python3
diff --git a/util/maint/lib/tests/maintainers.py b/util/maint/lib/tests/maintainers.py
new file mode 100644 (file)
index 0000000..cc71f21
--- /dev/null
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+#
+# Copyright (c) 2020 Arm Limited
+# All rights reserved
+#
+# The license below extends only to copyright in the software and shall
+# not be construed as granting a license to any other intellectual
+# property including but not limited to intellectual property relating
+# to a hardware implementation of the functionality of the software
+# licensed hereunder.  You may use the software subject to the license
+# terms below provided that you ensure that this notice is replicated
+# unmodified and in its entirety in all distributions of the software,
+# modified or unmodified, in source code or in binary form.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met: redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer;
+# redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution;
+# neither the name of the copyright holders nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from ..maintainers import *
+
+YAML_VALID = r"""
+maintained:
+  status: maintained
+  maintainers:
+    - John Doe <john.doe@test.gem5.org>
+    - Jane Doe <jane.doe@test.gem5.org>
+
+# Test that we can handle a subsystem without maintainers
+orphaned:
+  desc: Abandoned
+  status: orphaned
+"""
+
+YAML_MISSING_STATUS = r"""
+key:
+  maintainers:
+    - John Doe <john.doe@test.gem5.org>
+"""
+
+YAML_INVALID_STATUS = r"""
+key:
+  status: invalid_status_name
+  maintainers:
+    - John Doe <john.doe@test.gem5.org>
+"""
+
+YAML_MAINTAINERS_NOT_LIST = r"""
+key:
+  status: maintained
+  maintainers:
+"""
+
+class StatusTestSuite(unittest.TestCase):
+    """Test cases for maintainers.Status"""
+
+    def test_str_conv(self):
+        pairs = [
+            ("maintained", Status.MAINTAINED),
+            ("orphaned", Status.ORPHANED),
+        ]
+
+        for name, value in pairs:
+            assert value == Status.from_str(name)
+            assert str(value) == name
+
+class MaintainersTestSuite(unittest.TestCase):
+    """Test cases for Maintainers"""
+
+    def test_parser_valid(self):
+        maint = Maintainers.from_yaml(YAML_VALID)
+
+        subsys = maint['maintained']
+        self.assertEqual(subsys.status, Status.MAINTAINED)
+        self.assertEqual(subsys.description, '')
+        self.assertEqual(subsys.maintainers, [
+            ('John Doe', 'john.doe@test.gem5.org'),
+            ('Jane Doe', 'jane.doe@test.gem5.org'),
+        ])
+
+        subsys = maint['orphaned']
+        self.assertEqual(subsys.status, Status.ORPHANED)
+        self.assertEqual(subsys.description, 'Abandoned')
+        self.assertEqual(subsys.maintainers, [])
+
+    def test_parser_invalid(self):
+        with self.assertRaises(MissingFieldException):
+            Maintainers.from_yaml(YAML_MISSING_STATUS)
+
+        with self.assertRaises(IllegalValueException):
+            Maintainers.from_yaml(YAML_INVALID_STATUS)
+
+        with self.assertRaises(IllegalValueException):
+            Maintainers.from_yaml(YAML_MAINTAINERS_NOT_LIST)
+
+if __name__ == '__main__':
+    unittest.main()