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):
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
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")
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":
import unittest
+import io
from budget_sync.config import Config, ConfigParseError
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__":