start adding cf_payees_list handling
[utils.git] / src / budget_sync / budget_graph.py
1 from bugzilla.bug import Bug
2 from bugzilla import Bugzilla
3 from typing import Set, Dict, Iterable, Optional, List
4 from budget_sync.util import all_bugs
5 from budget_sync.money import Money
6 from functools import cached_property
7 import toml
8 import sys
9
10
11 class BudgetGraphBaseError(Exception):
12 pass
13
14
15 class BudgetGraphParseError(BudgetGraphBaseError):
16 def __init__(self, bug_id: int):
17 self.bug_id = bug_id
18
19
20 class BudgetGraphPayeesParseError(BudgetGraphParseError):
21 def __init__(self, bug_id: int, msg: str):
22 super().__init__(bug_id)
23 self.msg = msg
24
25 def __str__(self):
26 return f"Failed to parse cf_payees_list field of bug #{self.bug_id}: {self.msg}"
27
28
29 class BudgetGraphLoopError(BudgetGraphBaseError):
30 def __init__(self, bug_ids: List[int]):
31 self.bug_ids = bug_ids
32
33 def __str__(self):
34 retval = f"Detected Loop in Budget Graph: #{self.bug_ids[-1]} -> "
35 retval += " -> ".join((f"#{i}" for i in self.bug_ids))
36 return retval
37
38
39 class _NodeSimpleReprWrapper:
40 def __init__(self, node: "Node"):
41 self.node = node
42
43 def __repr__(self):
44 return f"#{self.node.bug.id}"
45
46 def __lt__(self, other):
47 # for list.sort()
48 return self.node.bug.id < other.node.bug.id
49
50
51 class Node:
52 graph: "BudgetGraph"
53 bug: Bug
54 parent_id: Optional[int]
55 immediate_children: Set["Node"]
56 budget_excluding_subtasks: Money
57 budget_including_subtasks: Money
58 nlnet_milestone: Optional[str]
59
60 def __init__(self, graph: "BudgetGraph", bug: Bug):
61 self.graph = graph
62 self.bug = bug
63 self.parent_id = getattr(bug, "cf_budget_parent", None)
64 self.immediate_children = set()
65 self.budget_excluding_subtasks = Money.from_str(bug.cf_budget)
66 self.budget_including_subtasks = Money.from_str(bug.cf_total_budget)
67 self.nlnet_milestone = bug.cf_nlnet_milestone
68 if self.nlnet_milestone == "---":
69 self.nlnet_milestone = None
70
71 @cached_property
72 def payees(self) -> Dict[str, Money]:
73 try:
74 parsed = toml.loads(self.bug.cf_payees_list)
75 except toml.TomlDecodeError as e:
76 new_err = BudgetGraphPayeesParseError(
77 self.bug.id, f"TOML parse error: {e}")
78 raise new_err.with_traceback(sys.exc_info()[2])
79 retval = {}
80 for key, value in parsed.items():
81 if not isinstance(key, str):
82 raise BudgetGraphPayeesParseError(
83 self.bug.id, f"key is not a string: {key!r}")
84 if not isinstance(value, (int, str)):
85 msg = f"value for key {key!r} is not a string or integer " \
86 f"(to use fractional values such as 123.45, write " \
87 f"\"123.45\"): {value!r}"
88 raise BudgetGraphPayeesParseError(self.bug.id, msg)
89 try:
90 money_value = Money(value)
91 except ValueError as e:
92 msg = f"failed to parse Money value for key {key!r}: {e}"
93 raise BudgetGraphPayeesParseError(
94 self.bug.id, msg) \
95 .with_traceback(sys.exc_info()[2])
96 retval[key] = money_value
97 return retval
98
99 @property
100 def parent(self) -> Optional["Node"]:
101 if self.parent_id is not None:
102 return self.graph.nodes[self.parent_id]
103 return None
104
105 def parents(self) -> Iterable["Node"]:
106 parent = self.parent
107 while parent is not None:
108 yield parent
109 parent = parent.parent
110
111 def _raise_loop_error(self):
112 bug_ids = []
113 for parent in self.parents():
114 bug_ids.append(parent.bug.id)
115 if parent == self:
116 break
117 raise BudgetGraphLoopError(bug_ids)
118
119 @cached_property
120 def root(self) -> "Node":
121 # also checks for loop errors
122 retval = self
123 for parent in self.parents():
124 retval = parent
125 if parent == self:
126 self._raise_loop_error()
127 return retval
128
129 def children(self) -> Iterable["Node"]:
130 def visitor(node: Node) -> Iterable[Node]:
131 for i in node.immediate_children:
132 yield i
133 yield from visitor(i)
134 return visitor(self)
135
136 def __eq__(self, other):
137 return self.bug.id == other.bug.id
138
139 def __ne__(self, other):
140 return self.bug.id != other.bug.id
141
142 def __hash__(self):
143 return self.bug.id
144
145 def __repr__(self):
146 try:
147 root = _NodeSimpleReprWrapper(self.root)
148 except BudgetGraphLoopError:
149 root = "<loop error>"
150 immediate_children = []
151 for i in self.immediate_children:
152 immediate_children.append(_NodeSimpleReprWrapper(i))
153 immediate_children.sort()
154 parent = f"#{self.parent_id}" if self.parent_id is not None else None
155 return (f"Node(graph=..., "
156 f"id={_NodeSimpleReprWrapper(self)}, "
157 f"root={root}, "
158 f"parent={parent}, "
159 f"budget_excluding_subtasks={self.budget_excluding_subtasks}, "
160 f"budget_including_subtasks={self.budget_including_subtasks}, "
161 f"nlnet_milestone={self.nlnet_milestone!r}, "
162 f"immediate_children={immediate_children!r}, "
163 f"payees={self.payees!r}")
164
165
166 class BudgetGraphError(BudgetGraphBaseError):
167 def __init__(self, bug_id: int, root_bug_id: int):
168 self.bug_id = bug_id
169 self.root_bug_id = root_bug_id
170
171
172 class BudgetGraphMoneyWithNoMilestone(BudgetGraphError):
173 def __str__(self):
174 return (f"Bug assigned money but without"
175 f" any assigned milestone: #{self.bug_id}")
176
177
178 class BudgetGraphMilestoneMismatch(BudgetGraphError):
179 def __str__(self):
180 return (f"Bug's assigned milestone doesn't match the milestone "
181 f"assigned to the root bug: descendant bug"
182 f" #{self.bug_id}, root bug"
183 f" #{self.root_bug_id}")
184
185
186 class BudgetGraphMoneyMismatch(BudgetGraphError):
187 def __init__(self, bug_id: int, root_bug_id: int,
188 expected_budget_excluding_subtasks: Money):
189 super().__init__(bug_id, root_bug_id)
190 self.expected_budget_excluding_subtasks = \
191 expected_budget_excluding_subtasks
192
193 def __str__(self):
194 return (f"Budget assigned to task excluding subtasks "
195 f"(cf_budget field) doesn't match calculated value: "
196 f"bug #{self.bug_id}, calculated value"
197 f" {self.expected_budget_excluding_subtasks}")
198
199
200 class BudgetGraphNegativeMoney(BudgetGraphError):
201 def __str__(self):
202 return (f"Budget assigned to task is less than zero: "
203 f"bug #{self.bug_id}")
204
205
206 class BudgetGraphPayeesMoneyMismatch(BudgetGraphError):
207 def __init__(self, bug_id: int, root_bug_id: int, payees_total: Money):
208 super().__init__(bug_id, root_bug_id)
209 self.payees_total = payees_total
210
211 def __str__(self):
212 return (f"Budget assigned to task excluding subtasks "
213 f"(cf_budget field) doesn't match total value "
214 f"assigned to payees (cf_payees_list): "
215 f"bug #{self.bug_id}, calculated total"
216 f" {self.payees_total}")
217
218
219 class BudgetGraphNegativePayeeMoney(BudgetGraphError):
220 def __init__(self, bug_id: int, root_bug_id: int, payee_key: str):
221 super().__init__(bug_id, root_bug_id)
222 self.payee_key = payee_key
223
224 def __str__(self):
225 return (f"Budget assigned to payee for task is less than zero: "
226 f"bug #{self.bug_id}, payee {self.payee_key!r}")
227
228
229 class BudgetGraph:
230 nodes: Dict[int, Node]
231
232 def __init__(self, bugs: Iterable[Bug]):
233 self.nodes = {}
234 for bug in bugs:
235 self.nodes[bug.id] = Node(self, bug)
236 for node in self.nodes.values():
237 if node.parent is None:
238 continue
239 node.parent.immediate_children.add(node)
240
241 @cached_property
242 def roots(self) -> Set[Node]:
243 roots = set()
244 for node in self.nodes.values():
245 # calling .root also checks for loop errors
246 root = node.root
247 roots.add(root)
248 return roots
249
250 def _get_node_errors(self, root: Node, node: Node,
251 errors: List[BudgetGraphBaseError]):
252 if node.nlnet_milestone is None:
253 if node.budget_including_subtasks != 0 \
254 or node.budget_excluding_subtasks != 0:
255 errors.append(BudgetGraphMoneyWithNoMilestone(
256 node.bug.id, root.bug.id))
257 if node.nlnet_milestone != root.nlnet_milestone:
258 errors.append(BudgetGraphMilestoneMismatch(
259 node.bug.id, root.bug.id))
260 if node.budget_excluding_subtasks < 0 \
261 or node.budget_including_subtasks < 0:
262 errors.append(BudgetGraphNegativeMoney(
263 node.bug.id, root.bug.id))
264 budget = node.budget_including_subtasks
265 for child in node.immediate_children:
266 budget -= child.budget_including_subtasks
267 if node.budget_excluding_subtasks != budget:
268 errors.append(BudgetGraphMoneyMismatch(
269 node.bug.id, root.bug.id, budget))
270 payees_total = Money(0)
271 for payee_key, payee_value in node.payees.items():
272 if payee_value < 0:
273 errors.append(BudgetGraphNegativePayeeMoney(
274 node.bug.id, root.bug.id, payee_key))
275 payees_total += payee_value
276 if node.budget_excluding_subtasks != payees_total \
277 and len(node.payees) != 0:
278 errors.append(BudgetGraphPayeesMoneyMismatch(
279 node.bug.id, root.bug.id, payees_total))
280
281 def get_errors(self) -> List[BudgetGraphBaseError]:
282 errors = []
283 try:
284 roots = self.roots
285 except BudgetGraphBaseError as e:
286 errors.append(e)
287 return errors
288
289 for root in roots:
290 try:
291 self._get_node_errors(root, root, errors)
292 for child in root.children():
293 try:
294 self._get_node_errors(root, child, errors)
295 except BudgetGraphBaseError as e:
296 errors.append(e)
297 except BudgetGraphBaseError as e:
298 errors.append(e)
299 return errors
300
301 @cached_property
302 def payee_keys(self) -> Set[str]:
303 retval = set()
304 for node in self.nodes.values():
305 for payee_key in node.payees.keys():
306 retval.add(payee_key)
307 return retval