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
11 class BudgetGraphBaseError(Exception):
15 class BudgetGraphParseError(BudgetGraphBaseError
):
16 def __init__(self
, bug_id
: int):
20 class BudgetGraphPayeesParseError(BudgetGraphParseError
):
21 def __init__(self
, bug_id
: int, msg
: str):
22 super().__init
__(bug_id
)
26 return f
"Failed to parse cf_payees_list field of bug #{self.bug_id}: {self.msg}"
29 class BudgetGraphLoopError(BudgetGraphBaseError
):
30 def __init__(self
, bug_ids
: List
[int]):
31 self
.bug_ids
= bug_ids
34 retval
= f
"Detected Loop in Budget Graph: #{self.bug_ids[-1]} -> "
35 retval
+= " -> ".join((f
"#{i}" for i
in self
.bug_ids
))
39 class _NodeSimpleReprWrapper
:
40 def __init__(self
, node
: "Node"):
44 return f
"#{self.node.bug.id}"
46 def __lt__(self
, other
):
48 return self
.node
.bug
.id < other
.node
.bug
.id
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]
60 def __init__(self
, graph
: "BudgetGraph", 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
72 def payees(self
) -> Dict
[str, Money
]:
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])
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
)
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(
95 .with_traceback(sys
.exc_info()[2])
96 retval
[key
] = money_value
100 def parent(self
) -> Optional
["Node"]:
101 if self
.parent_id
is not None:
102 return self
.graph
.nodes
[self
.parent_id
]
105 def parents(self
) -> Iterable
["Node"]:
107 while parent
is not None:
109 parent
= parent
.parent
111 def _raise_loop_error(self
):
113 for parent
in self
.parents():
114 bug_ids
.append(parent
.bug
.id)
117 raise BudgetGraphLoopError(bug_ids
)
120 def root(self
) -> "Node":
121 # also checks for loop errors
123 for parent
in self
.parents():
126 self
._raise
_loop
_error
()
129 def children(self
) -> Iterable
["Node"]:
130 def visitor(node
: Node
) -> Iterable
[Node
]:
131 for i
in node
.immediate_children
:
133 yield from visitor(i
)
136 def __eq__(self
, other
):
137 return self
.bug
.id == other
.bug
.id
139 def __ne__(self
, other
):
140 return self
.bug
.id != other
.bug
.id
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)}, "
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}")
166 class BudgetGraphError(BudgetGraphBaseError
):
167 def __init__(self
, bug_id
: int, root_bug_id
: int):
169 self
.root_bug_id
= root_bug_id
172 class BudgetGraphMoneyWithNoMilestone(BudgetGraphError
):
174 return (f
"Bug assigned money but without"
175 f
" any assigned milestone: #{self.bug_id}")
178 class BudgetGraphMilestoneMismatch(BudgetGraphError
):
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}")
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
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}")
200 class BudgetGraphNegativeMoney(BudgetGraphError
):
202 return (f
"Budget assigned to task is less than zero: "
203 f
"bug #{self.bug_id}")
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
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}")
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
225 return (f
"Budget assigned to payee for task is less than zero: "
226 f
"bug #{self.bug_id}, payee {self.payee_key!r}")
230 nodes
: Dict
[int, Node
]
232 def __init__(self
, bugs
: Iterable
[Bug
]):
235 self
.nodes
[bug
.id] = Node(self
, bug
)
236 for node
in self
.nodes
.values():
237 if node
.parent
is None:
239 node
.parent
.immediate_children
.add(node
)
242 def roots(self
) -> Set
[Node
]:
244 for node
in self
.nodes
.values():
245 # calling .root also checks for loop errors
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():
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
))
281 def get_errors(self
) -> List
[BudgetGraphBaseError
]:
285 except BudgetGraphBaseError
as e
:
291 self
._get
_node
_errors
(root
, root
, errors
)
292 for child
in root
.children():
294 self
._get
_node
_errors
(root
, child
, errors
)
295 except BudgetGraphBaseError
as e
:
297 except BudgetGraphBaseError
as e
:
302 def payee_keys(self
) -> Set
[str]:
304 for node
in self
.nodes
.values():
305 for payee_key
in node
.payees
.keys():
306 retval
.add(payee_key
)