add milestones to Config
[utils.git] / src / budget_sync / config.py
1 import toml
2 import sys
3 from typing import Set, Dict, Any, Optional
4 from functools import cached_property
5
6
7 class ConfigParseError(Exception):
8 pass
9
10
11 class Person:
12 aliases: Set[str]
13 email: Optional[str]
14
15 def __init__(self, config: "Config", identifier: str,
16 aliases: Optional[Set[str]] = None,
17 email: Optional[str] = None):
18 self.config = config
19 self.identifier = identifier
20 if aliases is None:
21 aliases = set()
22 self.aliases = aliases
23 self.email = email
24
25 @cached_property
26 def all_names(self) -> Set[str]:
27 retval = self.aliases.copy()
28 retval.add(self.identifier)
29 return retval
30
31 def __eq__(self, other):
32 return self.identifier == other.identifier
33
34 def __hash__(self):
35 return hash(self.identifier)
36
37 def __repr__(self):
38 return f"Person(config=..., identifier={self.identifier!r}, " \
39 f"aliases={self.aliases!r}, email={self.email!r})"
40
41
42 class Milestone:
43 def __init__(self, config: "Config",
44 identifier: str, canonical_bug_id: int):
45 self.config = config
46 self.identifier = identifier
47 self.canonical_bug_id = canonical_bug_id
48
49 def __repr__(self):
50 return f"Milestone(config=..., " \
51 f"identifier={self.identifier!r}, " \
52 f"canonical_bug_id={self.canonical_bug_id})"
53
54
55 class Config:
56 def __init__(self, bugzilla_url: str, people: Dict[str, Person],
57 milestones: Dict[str, Milestone]):
58 self.bugzilla_url = bugzilla_url
59 self.people = people
60 self.milestones = milestones
61
62 def __repr__(self):
63 return f"Config(bugzilla_url={self.bugzilla_url!r}, " \
64 f"people={self.people!r}, " \
65 f"milestones={self.milestones!r})"
66
67 @cached_property
68 def all_names(self) -> Dict[str, Person]:
69 # also checks for any name clashes and raises
70 # ConfigParseError if any are detected
71 retval = self.people.copy()
72
73 for person in self.people.values():
74 for alias in person.aliases:
75 other_person = retval.get(alias)
76 if other_person is not None:
77 if alias in self.people:
78 raise ConfigParseError(
79 f"alias is not allowed to be the same as any"
80 f" person's identifier: in person entry for "
81 f"{person.identifier!r}: {alias!r} is also the "
82 f"identifier for person"
83 f" {other_person.identifier!r}")
84 raise ConfigParseError(
85 f"alias is not allowed to be the same as another"
86 f" person's alias: in person entry for "
87 f"{person.identifier!r}: {alias!r} is also an alias "
88 f"for person {other_person.identifier!r}")
89 retval[alias] = person
90 return retval
91
92 @cached_property
93 def canonical_bug_ids(self) -> Dict[int, Milestone]:
94 # also checks for any bug id clashes and raises
95 # ConfigParseError if any are detected
96 retval = {}
97 for milestone in self.milestones.values():
98 other_milestone = retval.get(milestone.canonical_bug_id)
99 if other_milestone is not None:
100 raise ConfigParseError(
101 f"canonical_bug_id is not allowed to be the same as "
102 f"another milestone's canonical_bug_id: in milestone "
103 f"entry for {milestone.identifier!r}: "
104 f"{milestone.canonical_bug_id} is also the "
105 f"canonical_bug_id for milestone "
106 f"{other_milestone.identifier!r}")
107 retval[milestone.canonical_bug_id] = milestone
108 return retval
109
110 def _parse_person(self, identifier: str, value: Any) -> Person:
111 def raise_aliases_must_be_list_of_strings():
112 raise ConfigParseError(
113 f"`aliases` field in person entry for {identifier!r} must "
114 f"be a list of strings")
115
116 if not isinstance(value, dict):
117 raise ConfigParseError(
118 f"person entry for {identifier!r} must be a table")
119 aliases = set()
120 email = None
121 for k, v in value.items():
122 assert isinstance(k, str)
123 if k == "aliases":
124 if not isinstance(v, list):
125 raise_aliases_must_be_list_of_strings()
126 for alias in v:
127 if isinstance(alias, str):
128 if alias in aliases:
129 raise ConfigParseError(
130 f"duplicate alias in person entry for "
131 f"{identifier!r}: {alias!r}")
132 aliases.add(alias)
133 else:
134 raise_aliases_must_be_list_of_strings()
135 elif k == "email":
136 if not isinstance(v, str):
137 raise ConfigParseError(
138 f"`email` field in person entry for {identifier!r} "
139 f"must be a string")
140 email = v
141 else:
142 raise ConfigParseError(
143 f"unknown field in person entry for {identifier!r}: `{k}`")
144 return Person(config=self, identifier=identifier,
145 aliases=aliases, email=email)
146
147 def _parse_people(self, people: Any):
148 if not isinstance(people, dict):
149 raise ConfigParseError("`people` field must be a table")
150 for identifier, value in people.items():
151 assert isinstance(identifier, str)
152 self.people[identifier] = self._parse_person(identifier, value)
153
154 # self.all_names checks for name clashes and raises ConfigParseError
155 # if any are detected, so the following line is needed:
156 self.all_names
157
158 def _parse_milestone(self, identifier: str, value: Any) -> Milestone:
159 if not isinstance(value, dict):
160 raise ConfigParseError(
161 f"milestones entry for {identifier!r} must be a table")
162 canonical_bug_id = None
163 for k, v in value.items():
164 assert isinstance(k, str)
165 if k == "canonical_bug_id":
166 if not isinstance(v, int):
167 raise ConfigParseError(
168 f"`canonical_bug_id` field in milestones entry for "
169 f"{identifier!r} must be an integer")
170 canonical_bug_id = v
171 else:
172 raise ConfigParseError(f"unknown field in milestones entry "
173 f"for {identifier!r}: `{k}`")
174 if canonical_bug_id is None:
175 raise ConfigParseError(f"`canonical_bug_id` field is missing in "
176 f"milestones entry for {identifier!r}")
177 return Milestone(config=self, identifier=identifier,
178 canonical_bug_id=canonical_bug_id)
179
180 def _parse_milestones(self, milestones: Any):
181 if not isinstance(milestones, dict):
182 raise ConfigParseError("`milestones` field must be a table")
183 for identifier, value in milestones.items():
184 assert isinstance(identifier, str)
185 self.milestones[identifier] = \
186 self._parse_milestone(identifier, value)
187
188 # self.canonical_bug_ids checks for bug id clashes and raises
189 # ConfigParseError if any are detected, so the following line
190 # is needed:
191 self.canonical_bug_ids
192
193 @staticmethod
194 def _from_toml(parsed_toml: Dict[str, Any]) -> "Config":
195 people = None
196 bugzilla_url = None
197 milestones = None
198 for k, v in parsed_toml.items():
199 assert isinstance(k, str)
200 if k == "people":
201 people = v
202 elif k == "milestones":
203 milestones = v
204 elif k == "bugzilla_url":
205 if not isinstance(v, str):
206 raise ConfigParseError("`bugzilla_url` must be a string")
207 bugzilla_url = v
208 else:
209 raise ConfigParseError(f"unknown config entry: `{k}`")
210
211 if bugzilla_url is None:
212 raise ConfigParseError("`bugzilla_url` field is missing")
213
214 config = Config(bugzilla_url=bugzilla_url, people={}, milestones={})
215
216 if people is None:
217 raise ConfigParseError("`people` table is missing")
218 else:
219 config._parse_people(people)
220
221 if milestones is None:
222 raise ConfigParseError("`milestones` table is missing")
223 else:
224 config._parse_milestones(milestones)
225
226 return config
227
228 @staticmethod
229 def from_str(text: str) -> "Config":
230 try:
231 parsed_toml = toml.loads(text)
232 except toml.TomlDecodeError as e:
233 new_err = ConfigParseError(f"TOML parse error: {e}")
234 raise new_err.with_traceback(sys.exc_info()[2])
235 return Config._from_toml(parsed_toml)
236
237 @staticmethod
238 def from_file(file: Any) -> "Config":
239 if isinstance(file, list):
240 raise TypeError("list is not a valid file or path")
241 try:
242 parsed_toml = toml.load(file)
243 except toml.TomlDecodeError as e:
244 new_err = ConfigParseError(f"TOML parse error: {e}")
245 raise new_err.with_traceback(sys.exc_info()[2])
246 return Config._from_toml(parsed_toml)