finish adding Config parsing and tests
authorJacob Lifshay <programmerjake@gmail.com>
Wed, 9 Sep 2020 03:05:26 +0000 (20:05 -0700)
committerJacob Lifshay <programmerjake@gmail.com>
Wed, 9 Sep 2020 03:05:26 +0000 (20:05 -0700)
src/budget_sync/config.py
src/budget_sync/test/test_config.py

index 357857b..56162ba 100644 (file)
@@ -1,6 +1,7 @@
 import toml
 import sys
-from typing import Set, Dict, Any
+from typing import Set, Dict, Any, Optional
+from functools import cached_property
 
 
 class ConfigParseError(Exception):
@@ -8,9 +9,24 @@ class ConfigParseError(Exception):
 
 
 class Person:
-    def __init__(self, config: "Config", identifier: str):
+    aliases: Set[str]
+    email: Optional[str]
+
+    def __init__(self, config: "Config", identifier: str,
+                 aliases: Optional[Set[str]] = None,
+                 email: Optional[str] = None):
         self.config = config
         self.identifier = identifier
+        if aliases is None:
+            aliases = set()
+        self.aliases = aliases
+        self.email = email
+
+    @cached_property
+    def all_names(self) -> Set[str]:
+        retval = self.aliases.copy()
+        retval.add(self.identifier)
+        return retval
 
     def __eq__(self, other):
         return self.identifier == other.identifier
@@ -19,45 +35,100 @@ class Person:
         return hash(self.identifier)
 
     def __repr__(self):
-        return f"Person(config=..., identifier={self.identifier!r})"
+        return f"Person(config=..., identifier={self.identifier!r}, " \
+            f"aliases={self.aliases!r}, email={self.email!r})"
 
 
 class Config:
-    def __init__(self, bugzilla_url: str, people: Dict[str, Person], aliases: Dict[str, Person] = None):
+    def __init__(self, bugzilla_url: str, people: Dict[str, Person]):
         self.bugzilla_url = bugzilla_url
         self.people = people
-        if aliases is None:
-            aliases = {}
-        for i in people:
-            aliases[i.identifier] = i
-        self.aliases = aliases
 
     def __repr__(self):
         return f"Config(bugzilla_url={self.bugzilla_url!r}, " \
-            f"people={self.people!r}, aliases={self.aliases!r})"
-
-    @staticmethod
-    def _parse_people(people: Any) -> Dict[str, Person]:
-        raise NotImplementedError()
-
-    @staticmethod
-    def _parse_aliases(people: Dict[str, Person], aliases: Any) -> Dict[str, Person]:
-        if not isinstance(aliases, dict):
-            raise ConfigParseError("`aliases` entry must be a table")
-        retval = {}
-        raise NotImplementedError()
+            f"people={self.people!r})"
+
+    @cached_property
+    def all_names(self) -> Dict[str, Person]:
+        # also checks for any name clashes and raises
+        # ConfigParseError if any are detected
+        retval = self.people.copy()
+
+        for person in self.people.values():
+            for alias in person.aliases:
+                other_person = retval.get(alias)
+                if other_person is not None:
+                    if alias in self.people:
+                        raise ConfigParseError(
+                            f"alias is not allowed to be the same as any"
+                            f" person's identifier: in person entry for "
+                            f"{person.identifier!r}: {alias!r} is also the "
+                            f"identifier for person"
+                            f" {other_person.identifier!r}")
+                    raise ConfigParseError(
+                        f"alias is not allowed to be the same as another"
+                        f" person's alias: in person entry for "
+                        f"{person.identifier!r}: {alias!r} is also an alias "
+                        f"for person {other_person.identifier!r}")
+                retval[alias] = person
         return retval
 
+    def _parse_person(self, identifier: str, value: Any) -> Person:
+        def raise_aliases_must_be_list_of_strings():
+            raise ConfigParseError(
+                f"`aliases` field in person entry for {identifier!r} must "
+                f"be a list of strings")
+
+        if not isinstance(value, dict):
+            raise ConfigParseError(
+                f"person entry for {identifier!r} must be a table")
+        aliases = set()
+        email = None
+        for k, v in value.items():
+            assert isinstance(k, str)
+            if k == "aliases":
+                if not isinstance(v, list):
+                    raise_aliases_must_be_list_of_strings()
+                for alias in v:
+                    if isinstance(alias, str):
+                        if alias in aliases:
+                            raise ConfigParseError(
+                                f"duplicate alias in person entry for "
+                                f"{identifier!r}: {alias!r}")
+                        aliases.add(alias)
+                    else:
+                        raise_aliases_must_be_list_of_strings()
+            elif k == "email":
+                if not isinstance(v, str):
+                    raise ConfigParseError(
+                        f"`email` field in person entry for {identifier!r} "
+                        f"must be a string")
+                email = v
+            else:
+                raise ConfigParseError(
+                    f"unknown field in person entry for {identifier!r}: `{k}`")
+        return Person(config=self, identifier=identifier,
+                      aliases=aliases, email=email)
+
+    def _parse_people(self, people: Any):
+        if not isinstance(people, dict):
+            raise ConfigParseError("`people` field must be a table")
+        for identifier, value in people.items():
+            assert isinstance(identifier, str)
+            self.people[identifier] = self._parse_person(identifier, value)
+
+        # self.all_names checks for name clashes and raises ConfigParseError
+        # if any are detected, so the following line is needed:
+        self.all_names
+
     @staticmethod
     def _from_toml(parsed_toml: Dict[str, Any]) -> "Config":
         people = None
-        aliases = None
         bugzilla_url = None
         for k, v in parsed_toml.items():
+            assert isinstance(k, str)
             if k == "people":
-                people = Config._parse_people(v)
-            elif k == "aliases":
-                aliases = v
+                people = v
             elif k == "bugzilla_url":
                 if not isinstance(v, str):
                     raise ConfigParseError("`bugzilla_url` must be a string")
@@ -65,18 +136,17 @@ class Config:
             else:
                 raise ConfigParseError(f"unknown config entry: `{k}`")
 
-        if people is None:
-            raise ConfigParseError("`people` key is missing")
-
         if bugzilla_url is None:
-            raise ConfigParseError("`bugzilla_url` key is missing")
+            raise ConfigParseError("`bugzilla_url` field is missing")
 
-        if aliases is not None:
-            aliases = Config._parse_aliases(people, aliases)
+        config = Config(bugzilla_url=bugzilla_url, people={})
+
+        if people is None:
+            raise ConfigParseError("`people` table is missing")
+        else:
+            config._parse_people(people)
 
-        return Config(bugzilla_url=bugzilla_url,
-                      people=people,
-                      aliases=aliases)
+        return config
 
     @staticmethod
     def from_str(text: str) -> "Config":
index 6c27899..c16416f 100644 (file)
@@ -1,4 +1,5 @@
 import unittest
+import io
 from budget_sync.config import Config, ConfigParseError
 
 
@@ -12,7 +13,163 @@ class TestConfig(unittest.TestCase):
         def check(text: str, expected_repr_text: str):
             self.assertEqual(repr(Config.from_str(text)), expected_repr_text)
 
-        raise NotImplementedError("finish adding test cases")
+        check_error(
+            "bad-toml=",
+            "TOML parse error: Empty value is invalid "
+            "(line 1 column 1 char 0)")
+        check_error(
+            """
+            """,
+            "`bugzilla_url` field is missing")
+        check_error(
+            """
+            [bugzilla_url]
+            """,
+            "`bugzilla_url` must be a string")
+        check_error(
+            """
+            bugzilla_url = ""
+            """,
+            "`people` table is missing")
+        check_error(
+            """
+            blah = ""
+            """,
+            "unknown config entry: `blah`")
+        check_error(
+            """
+            bugzilla_url = ""
+            people = []
+            """,
+            "`people` field must be a table")
+        check_error(
+            """
+            bugzilla_url = ""
+            [people]
+            person1 = 1
+            """,
+            "person entry for 'person1' must be a table")
+        check_error(
+            """
+            bugzilla_url = ""
+            [people."person1"]
+            aliases = ""
+            """,
+            "`aliases` field in person entry for 'person1' must be a list "
+            "of strings")
+        check_error(
+            """
+            bugzilla_url = ""
+            [people."person1"]
+            aliases = [1]
+            """,
+            "`aliases` field in person entry for 'person1' must be a list "
+            "of strings")
+        check_error(
+            """
+            bugzilla_url = ""
+            [people."person1"]
+            aliases = ["a", "a"]
+            """,
+            "duplicate alias in person entry for 'person1': 'a'")
+        check(
+            """
+            bugzilla_url = ""
+            [people."person1"]
+            aliases = ["a"]
+            [people."person2"]
+            aliases = ["b"]
+            """,
+            "Config(bugzilla_url='', people={"
+            "'person1': Person(config=..., identifier='person1', "
+            "aliases={'a'}, email=None), "
+            "'person2': Person(config=..., identifier='person2', "
+            "aliases={'b'}, email=None)})")
+        check_error(
+            """
+            bugzilla_url = ""
+            [people."person1"]
+            email = 123
+            """,
+            "`email` field in person entry for 'person1' must be a string")
+        check(
+            """
+            bugzilla_url = ""
+            [people]
+            """,
+            "Config(bugzilla_url='', people={})")
+        check(
+            """
+            bugzilla_url = ""
+            [people."person1"]
+            email = "email@example.com"
+            """,
+            "Config(bugzilla_url='', people={"
+            "'person1': Person(config=..., identifier='person1', "
+            "aliases=set(), email='email@example.com')})")
+        check_error(
+            """
+            bugzilla_url = ""
+            [people."person1"]
+            blah = 123
+            """,
+            "unknown field in person entry for 'person1': `blah`")
+        check_error(
+            """
+            bugzilla_url = ""
+            [people."person1"]
+            [people."person2"]
+            aliases = ["person1"]
+            """,
+            "alias is not allowed to be the same as any person's identifier: "
+            "in person entry for 'person2': 'person1' is also the identifier "
+            "for person 'person1'")
+        check_error(
+            """
+            bugzilla_url = ""
+            [people."person1"]
+            aliases = ["a"]
+            [people."person2"]
+            aliases = ["a"]
+            """,
+            "alias is not allowed to be the same as another person's alias: "
+            "in person entry for 'person2': 'a' is also an alias for person "
+            "'person1'")
+
+    def test_all_names(self):
+        config = Config.from_str(
+            """
+            bugzilla_url = ""
+            [people."person1"]
+            aliases = ["person1_alias1", "alias1"]
+            [people."person2"]
+            aliases = ["person2_alias2", "alias2"]
+            """)
+        person1 = config.people['person1']
+        person2 = config.people['person2']
+        self.assertEqual(config.all_names,
+                         {
+                             'person1': person1,
+                             'person1_alias1': person1,
+                             'alias1': person1,
+                             'person2': person2,
+                             'person2_alias2': person2,
+                             'alias2': person2,
+                         })
+
+    def test_from_file(self):
+        def load(text):
+            with io.StringIO(text) as file:
+                return Config.from_file(file)
+
+        with self.assertRaisesRegex(TypeError,
+                                    "^list is not a valid file or path$"):
+            Config.from_file([])
+
+        with self.assertRaisesRegex(
+                ConfigParseError,
+                "^TOML parse error: Empty value is invalid"):
+            load("""bad-toml=""")
 
 
 if __name__ == "__main__":