From: Jacob Lifshay Date: Wed, 9 Sep 2020 03:05:26 +0000 (-0700) Subject: finish adding Config parsing and tests X-Git-Url: https://git.libre-soc.org/?p=utils.git;a=commitdiff_plain;h=77e86bf4a668209e49c290e4dc9ad12ccc584da2 finish adding Config parsing and tests --- diff --git a/src/budget_sync/config.py b/src/budget_sync/config.py index 357857b..56162ba 100644 --- a/src/budget_sync/config.py +++ b/src/budget_sync/config.py @@ -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": diff --git a/src/budget_sync/test/test_config.py b/src/budget_sync/test/test_config.py index 6c27899..c16416f 100644 --- a/src/budget_sync/test/test_config.py +++ b/src/budget_sync/test/test_config.py @@ -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__":