1 from bugzilla
.bug
import Bug
2 from typing
import Set
, Dict
, Iterable
, Optional
, List
, Union
, Any
3 from budget_sync
.util
import BugStatus
4 from budget_sync
.money
import Money
5 from budget_sync
.config
import Config
, Person
, Milestone
6 from functools
import cached_property
10 from collections
import deque
11 from datetime
import date
, time
, datetime
14 class BudgetGraphBaseError(Exception):
18 class BudgetGraphParseError(BudgetGraphBaseError
):
19 def __init__(self
, bug_id
: int):
23 class BudgetGraphPayeesParseError(BudgetGraphParseError
):
24 def __init__(self
, bug_id
: int, msg
: str):
25 super().__init
__(bug_id
)
29 return f
"Failed to parse cf_payees_list field of " \
30 f
"bug #{self.bug_id}: {self.msg}"
33 class BudgetGraphLoopError(BudgetGraphBaseError
):
34 def __init__(self
, bug_ids
: List
[int]):
35 self
.bug_ids
= bug_ids
38 retval
= f
"Detected Loop in Budget Graph: #{self.bug_ids[-1]} -> "
39 retval
+= " -> ".join((f
"#{i}" for i
in self
.bug_ids
))
43 class _NodeSimpleReprWrapper
:
44 def __init__(self
, node
: "Node"):
48 return f
"#{self.node.bug.id}"
50 def __lt__(self
, other
):
52 return self
.node
.bug
.id < other
.node
.bug
.id
55 class PayeeState(enum
.Enum
):
56 NotYetSubmitted
= "not yet submitted"
57 Submitted
= "submitted"
61 _Date
= Union
[date
, datetime
]
64 def _parse_money_from_toml(value
: Any
) -> Money
:
65 if not isinstance(value
, (int, str)):
66 msg
= f
"monetary amount is not a string or integer " \
67 f
"(to use fractional amounts such as 123.45, write " \
68 f
"\"123.45\"): {value!r}"
73 def _parse_date_time_or_none_from_toml(value
: Any
) -> Optional
[_Date
]:
74 if value
is None or isinstance(value
, (date
, datetime
)):
76 elif isinstance(value
, time
):
77 msg
= f
"just a time of day by itself is not enough," \
78 f
" a date must be included: {str(value)}"
80 elif isinstance(value
, bool):
81 msg
= f
"invalid date: {str(value).lower()}"
83 elif isinstance(value
, (str, int, float)):
84 msg
= f
"invalid date: {value!r}"
96 paid
: Optional
[_Date
],
97 submitted
: Optional
[_Date
]):
99 self
.payee_key
= payee_key
102 self
.submitted
= submitted
105 def payee(self
) -> Person
:
107 return self
.node
.graph
.config
.all_names
[self
.payee_key
]
109 msg
= f
"unknown payee name: {self.payee_key!r} is not the name " \
110 f
"or an alias of any known person"
111 raise BudgetGraphPayeesParseError(self
.node
.bug
.id, msg
) \
112 .with_traceback(sys
.exc_info()[2])
116 if self
.paid
is not None:
117 return PayeeState
.Paid
118 if self
.submitted
is not None:
119 return PayeeState
.Submitted
120 return PayeeState
.NotYetSubmitted
123 def _from_toml(node
: "Node", payee_key
: str, toml_value
: Any
) -> "Payment":
126 known_keys
= ("paid", "submitted", "amount")
127 if isinstance(toml_value
, dict):
129 amount
= toml_value
['amount']
131 msg
= f
"value for key {payee_key!r} is missing the " \
132 f
"`amount` field which is required"
133 raise BudgetGraphPayeesParseError(node
.bug
.id, msg
) \
134 .with_traceback(sys
.exc_info()[2])
135 for k
, v
in toml_value
.items():
136 if k
in ("paid", "submitted"):
138 parsed_value
= _parse_date_time_or_none_from_toml(v
)
139 except ValueError as e
:
140 msg
= f
"failed to parse `{k}` field for" \
141 f
" key {payee_key!r}: {e}"
142 raise BudgetGraphPayeesParseError(
144 .with_traceback(sys
.exc_info()[2])
148 assert k
== "submitted"
149 submitted
= parsed_value
150 if k
not in known_keys
:
151 msg
= f
"value for key {payee_key!r} has an unknown" \
153 raise BudgetGraphPayeesParseError(node
.bug
.id, msg
) \
154 .with_traceback(sys
.exc_info()[2])
156 paid
= _parse_date_time_or_none_from_toml(
157 toml_value
.get('paid'))
158 except ValueError as e
:
159 msg
= f
"failed to parse `paid` field for" \
160 f
" key {payee_key!r}: {e}"
161 raise BudgetGraphPayeesParseError(
163 .with_traceback(sys
.exc_info()[2])
165 submitted
= _parse_date_time_or_none_from_toml(
166 toml_value
.get('submitted'))
167 except ValueError as e
:
168 msg
= f
"failed to parse `submitted` field for" \
169 f
" key {payee_key!r}: {e}"
170 raise BudgetGraphPayeesParseError(
172 .with_traceback(sys
.exc_info()[2])
173 elif isinstance(toml_value
, (int, str, float)):
174 # float included for better error messages
177 msg
= f
"value for key {payee_key!r} is invalid -- it should " \
178 f
"either be a monetary value or a table"
179 raise BudgetGraphPayeesParseError(node
.bug
.id, msg
)
181 amount
= _parse_money_from_toml(amount
)
182 except ValueError as e
:
183 msg
= f
"failed to parse monetary amount for key {payee_key!r}: {e}"
184 raise BudgetGraphPayeesParseError(
186 .with_traceback(sys
.exc_info()[2])
187 return Payment(node
=node
, payee_key
=payee_key
, amount
=amount
,
188 paid
=paid
, submitted
=submitted
)
192 payee
= f
"Person<{self.payee.identifier!r}>"
193 except BudgetGraphBaseError
:
194 payee
= "<unknown person>"
195 return (f
"Payment(node={_NodeSimpleReprWrapper(self.node)}, "
197 f
"payee_key={self.payee_key!r}, "
198 f
"amount={self.amount}, "
199 f
"state={self.state.name}, "
200 f
"paid={str(self.paid)}, "
201 f
"submitted={str(self.submitted)})")
204 class BudgetGraphUnknownMilestone(BudgetGraphParseError
):
205 def __init__(self
, bug_id
: int, milestone_str
: str):
206 super().__init
__(bug_id
)
207 self
.milestone_str
= milestone_str
210 return f
"failed to parse cf_nlnet_milestone field of bug " \
211 f
"#{self.bug_id}: unknown milestone: {self.milestone_str!r}"
214 class BudgetGraphUnknownStatus(BudgetGraphParseError
):
215 def __init__(self
, bug_id
: int, status_str
: str):
216 super().__init
__(bug_id
)
217 self
.status_str
= status_str
220 return f
"failed to parse status field of bug " \
221 f
"#{self.bug_id}: unknown status: {self.status_str!r}"
227 parent_id
: Optional
[int]
228 immediate_children
: Set
["Node"]
229 budget_excluding_subtasks
: Money
230 budget_including_subtasks
: Money
231 fixed_budget_excluding_subtasks
: Money
232 fixed_budget_including_subtasks
: Money
233 milestone_str
: Optional
[str]
235 def __init__(self
, graph
: "BudgetGraph", bug
: Bug
):
238 self
.parent_id
= getattr(bug
, "cf_budget_parent", None)
239 self
.immediate_children
= set()
240 self
.budget_excluding_subtasks
= Money
.from_str(bug
.cf_budget
)
241 self
.fixed_budget_excluding_subtasks
= self
.budget_excluding_subtasks
242 self
.budget_including_subtasks
= Money
.from_str(bug
.cf_total_budget
)
243 self
.fixed_budget_including_subtasks
= self
.budget_including_subtasks
244 self
.milestone_str
= bug
.cf_nlnet_milestone
245 if self
.milestone_str
== "---":
246 self
.milestone_str
= None
249 def status(self
) -> BugStatus
:
251 return BugStatus
.cast(self
.bug
.status
)
253 new_err
= BudgetGraphUnknownStatus(self
.bug
.id, self
.bug
.status
)
254 raise new_err
.with_traceback(sys
.exc_info()[2])
257 def bug_url(self
) -> str:
258 return f
"{self.graph.config.bugzilla_url_stripped}/show_bug.cgi?" \
262 def milestone(self
) -> Optional
[Milestone
]:
264 if self
.milestone_str
is not None:
265 return self
.graph
.config
.milestones
[self
.milestone_str
]
268 new_err
= BudgetGraphUnknownMilestone(
269 self
.bug
.id, self
.milestone_str
)
270 raise new_err
.with_traceback(sys
.exc_info()[2])
273 def payments(self
) -> Dict
[str, Payment
]:
275 parsed
= toml
.loads(self
.bug
.cf_payees_list
)
276 except toml
.TomlDecodeError
as e
:
277 new_err
= BudgetGraphPayeesParseError(
278 self
.bug
.id, f
"TOML parse error: {e}")
279 raise new_err
.with_traceback(sys
.exc_info()[2])
281 for key
, value
in parsed
.items():
282 if not isinstance(key
, str):
283 raise BudgetGraphPayeesParseError(
284 self
.bug
.id, f
"key is not a string: {key!r}")
285 retval
[key
] = Payment
._from
_toml
(self
, key
, value
)
289 def parent(self
) -> Optional
["Node"]:
290 if self
.parent_id
is not None:
291 return self
.graph
.nodes
[self
.parent_id
]
294 def parents(self
) -> Iterable
["Node"]:
296 while parent
is not None:
298 parent
= parent
.parent
300 def _raise_loop_error(self
):
302 for parent
in self
.parents():
303 bug_ids
.append(parent
.bug
.id)
306 raise BudgetGraphLoopError(bug_ids
)
309 def root(self
) -> "Node":
310 # also checks for loop errors
312 for parent
in self
.parents():
315 self
._raise
_loop
_error
()
318 def children(self
) -> Iterable
["Node"]:
319 def visitor(node
: Node
) -> Iterable
[Node
]:
320 for i
in node
.immediate_children
:
322 yield from visitor(i
)
325 def children_breadth_first(self
) -> Iterable
["Node"]:
326 q
= deque(self
.immediate_children
)
332 q
.extend(node
.immediate_children
)
335 def __eq__(self
, other
):
336 return self
.bug
.id == other
.bug
.id
343 root
= _NodeSimpleReprWrapper(self
.root
)
344 except BudgetGraphLoopError
:
345 root
= "<loop error>"
347 milestone
= repr(self
.milestone
)
348 except BudgetGraphBaseError
:
349 milestone
= "<unknown milestone>"
351 status
= repr(self
.status
)
352 except BudgetGraphBaseError
:
353 status
= f
"<unknown status: {self.bug.status!r}>"
354 immediate_children
= []
355 for i
in self
.immediate_children
:
356 immediate_children
.append(_NodeSimpleReprWrapper(i
))
357 immediate_children
.sort()
358 parent
= f
"#{self.parent_id}" if self
.parent_id
is not None else None
359 payments
= list(self
.payments
.values())
360 return (f
"Node(graph=..., "
361 f
"id={_NodeSimpleReprWrapper(self)}, "
364 f
"budget_excluding_subtasks={self.budget_excluding_subtasks}, "
365 f
"budget_including_subtasks={self.budget_including_subtasks}, "
366 f
"fixed_budget_excluding_subtasks={self.fixed_budget_excluding_subtasks}, "
367 f
"fixed_budget_including_subtasks={self.fixed_budget_including_subtasks}, "
368 f
"milestone_str={self.milestone_str!r}, "
369 f
"milestone={milestone}, "
370 f
"immediate_children={immediate_children!r}, "
371 f
"payments={payments!r}, "
375 class BudgetGraphError(BudgetGraphBaseError
):
376 def __init__(self
, bug_id
: int, root_bug_id
: int):
378 self
.root_bug_id
= root_bug_id
381 class BudgetGraphMoneyWithNoMilestone(BudgetGraphError
):
383 return (f
"Bug assigned money but without"
384 f
" any assigned milestone: #{self.bug_id}")
387 class BudgetGraphMilestoneMismatch(BudgetGraphError
):
389 return (f
"Bug's assigned milestone doesn't match the milestone "
390 f
"assigned to the root bug: descendant bug"
391 f
" #{self.bug_id}, root bug"
392 f
" #{self.root_bug_id}")
395 class BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(BudgetGraphError
):
396 def __init__(self
, bug_id
: int, root_bug_id
: int,
397 expected_budget_excluding_subtasks
: Money
):
398 super().__init
__(bug_id
, root_bug_id
)
399 self
.expected_budget_excluding_subtasks
= \
400 expected_budget_excluding_subtasks
403 return (f
"Budget assigned to task excluding subtasks "
404 f
"(cf_budget field) doesn't match calculated value: "
405 f
"bug #{self.bug_id}, calculated value"
406 f
" {self.expected_budget_excluding_subtasks}")
409 class BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(BudgetGraphError
):
410 def __init__(self
, bug_id
: int, root_bug_id
: int,
411 expected_budget_including_subtasks
: Money
):
412 super().__init
__(bug_id
, root_bug_id
)
413 self
.expected_budget_including_subtasks
= \
414 expected_budget_including_subtasks
417 return (f
"Budget assigned to task including subtasks "
418 f
"(cf_total_budget field) doesn't match calculated value: "
419 f
"bug #{self.bug_id}, calculated value"
420 f
" {self.expected_budget_including_subtasks}")
423 class BudgetGraphNegativeMoney(BudgetGraphError
):
425 return (f
"Budget assigned to task is less than zero: "
426 f
"bug #{self.bug_id}")
429 class BudgetGraphPayeesMoneyMismatch(BudgetGraphError
):
430 def __init__(self
, bug_id
: int, root_bug_id
: int, payees_total
: Money
,
431 expected_payees_total
: Money
):
432 super().__init
__(bug_id
, root_bug_id
)
433 self
.payees_total
= payees_total
434 self
.expected_payees_total
= expected_payees_total
437 return (f
"Total budget assigned to payees (cf_payees_list) doesn't "
438 f
"match expected value: bug #{self.bug_id}, calculated total "
439 f
"{self.payees_total}, expected value "
440 f
"{self.expected_payees_total}")
443 class BudgetGraphNegativePayeeMoney(BudgetGraphError
):
444 def __init__(self
, bug_id
: int, root_bug_id
: int, payee_key
: str):
445 super().__init
__(bug_id
, root_bug_id
)
446 self
.payee_key
= payee_key
449 return (f
"Budget assigned to payee for task is less than zero: "
450 f
"bug #{self.bug_id}, payee {self.payee_key!r}")
453 class BudgetGraphDuplicatePayeesForTask(BudgetGraphError
):
454 def __init__(self
, bug_id
: int, root_bug_id
: int, payee1_key
: str, payee2_key
: str):
455 super().__init
__(bug_id
, root_bug_id
)
456 self
.payee1_key
= payee1_key
457 self
.payee2_key
= payee2_key
460 return (f
"Budget assigned to multiple aliases of the same person in "
461 f
"a single task: bug #{self.bug_id}, budget assigned to both "
462 f
"{self.payee1_key!r} and {self.payee2_key!r}")
465 class BudgetGraphIncorrectRootForMilestone(BudgetGraphError
):
466 def __init__(self
, bug_id
: int, milestone
: str, milestone_canonical_bug_id
: int):
467 super().__init
__(bug_id
, bug_id
)
468 self
.milestone
= milestone
469 self
.milestone_canonical_bug_id
= milestone_canonical_bug_id
472 return (f
"Bug #{self.bug_id} is not the canonical root bug for "
473 f
"assigned milestone {self.milestone!r} but has no parent "
474 f
"bug set: the milestone's canonical root bug is "
475 f
"#{self.milestone_canonical_bug_id}")
479 nodes
: Dict
[int, Node
]
481 def __init__(self
, bugs
: Iterable
[Bug
], config
: Config
):
485 self
.nodes
[bug
.id] = Node(self
, bug
)
486 for node
in self
.nodes
.values():
487 if node
.parent
is None:
489 node
.parent
.immediate_children
.add(node
)
492 def roots(self
) -> Set
[Node
]:
494 for node
in self
.nodes
.values():
495 # calling .root also checks for loop errors
500 def _get_node_errors(self
, root
: Node
, node
: Node
,
501 errors
: List
[BudgetGraphBaseError
]):
502 if node
.milestone_str
is None:
503 if node
.budget_including_subtasks
!= 0 \
504 or node
.budget_excluding_subtasks
!= 0:
505 errors
.append(BudgetGraphMoneyWithNoMilestone(
506 node
.bug
.id, root
.bug
.id))
509 # check for milestone errors
511 if root
== node
and node
.milestone
is not None \
512 and node
.milestone
.canonical_bug_id
!= node
.bug
.id:
513 if node
.budget_including_subtasks
!= 0 \
514 or node
.budget_excluding_subtasks
!= 0:
515 errors
.append(BudgetGraphIncorrectRootForMilestone(
516 node
.bug
.id, node
.milestone
.identifier
,
517 node
.milestone
.canonical_bug_id
519 except BudgetGraphBaseError
as e
:
523 # check for status errors
525 except BudgetGraphBaseError
as e
:
528 if node
.milestone_str
!= root
.milestone_str
:
529 errors
.append(BudgetGraphMilestoneMismatch(
530 node
.bug
.id, root
.bug
.id))
532 if node
.budget_excluding_subtasks
< 0 \
533 or node
.budget_including_subtasks
< 0:
534 errors
.append(BudgetGraphNegativeMoney(
535 node
.bug
.id, root
.bug
.id))
537 subtasks_total
= Money(0)
538 for child
in node
.immediate_children
:
539 subtasks_total
+= child
.fixed_budget_including_subtasks
541 payees_total
= Money(0)
543 for payment
in node
.payments
.values():
544 if payment
.amount
< 0:
545 errors
.append(BudgetGraphNegativePayeeMoney(
546 node
.bug
.id, root
.bug
.id, payment
.payee_key
))
547 payees_total
+= payment
.amount
549 # check for payee errors
551 previous_payment
= payee_payments
.get(payment
.payee
)
552 if previous_payment
is not None:
553 errors
.append(BudgetGraphDuplicatePayeesForTask(
554 node
.bug
.id, root
.bug
.id,
555 previous_payment
.payee_key
, payment
.payee_key
557 payee_payments
[payment
.payee
] = payment
558 except BudgetGraphBaseError
as e
:
561 def set_including_from_excluding_and_error():
562 node
.fixed_budget_including_subtasks
= \
563 node
.budget_excluding_subtasks
+ subtasks_total
565 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
566 node
.bug
.id, root
.bug
.id,
567 node
.fixed_budget_including_subtasks
))
569 def set_including_from_payees_and_error():
570 node
.fixed_budget_including_subtasks
= \
571 payees_total
+ subtasks_total
573 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
574 node
.bug
.id, root
.bug
.id,
575 node
.fixed_budget_including_subtasks
))
577 def set_excluding_from_including_and_error():
578 node
.fixed_budget_excluding_subtasks
= \
579 node
.budget_including_subtasks
- subtasks_total
581 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
582 node
.bug
.id, root
.bug
.id,
583 node
.fixed_budget_excluding_subtasks
))
585 def set_excluding_from_payees_and_error():
586 node
.fixed_budget_excluding_subtasks
= \
589 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
590 node
.bug
.id, root
.bug
.id,
591 node
.fixed_budget_excluding_subtasks
))
593 def set_payees_from_including_and_error():
594 fixed_payees_total
= \
595 node
.budget_including_subtasks
- subtasks_total
596 errors
.append(BudgetGraphPayeesMoneyMismatch(
597 node
.bug
.id, root
.bug
.id, payees_total
, fixed_payees_total
))
599 def set_payees_from_excluding_and_error():
600 fixed_payees_total
= \
601 node
.budget_excluding_subtasks
602 errors
.append(BudgetGraphPayeesMoneyMismatch(
603 node
.bug
.id, root
.bug
.id, payees_total
, fixed_payees_total
))
605 payees_matches_including
= \
606 node
.budget_including_subtasks
- subtasks_total
== payees_total
607 payees_matches_excluding
= \
608 node
.budget_excluding_subtasks
== payees_total
609 including_matches_excluding
= \
610 node
.budget_including_subtasks
- subtasks_total \
611 == node
.budget_excluding_subtasks
613 if payees_matches_including \
614 and payees_matches_excluding \
615 and including_matches_excluding
:
617 elif payees_matches_including
:
618 # can't have 2 match without all 3 matching
619 assert not payees_matches_excluding
620 assert not including_matches_excluding
621 if node
.budget_including_subtasks
== 0 and len(node
.payments
) == 0:
622 set_including_from_excluding_and_error()
624 set_excluding_from_including_and_error()
625 elif payees_matches_excluding
:
626 # can't have 2 match without all 3 matching
627 assert not payees_matches_including
628 assert not including_matches_excluding
629 if node
.budget_excluding_subtasks
== 0 and len(node
.payments
) == 0:
630 if node
.budget_including_subtasks
== 0:
631 set_including_from_excluding_and_error()
633 set_excluding_from_including_and_error()
635 set_including_from_excluding_and_error()
636 elif including_matches_excluding
:
637 # can't have 2 match without all 3 matching
638 assert not payees_matches_including
639 assert not payees_matches_excluding
640 if len(node
.payments
) == 0:
641 pass # no error -- payees is just not set
642 elif node
.budget_excluding_subtasks
== 0 \
643 and node
.budget_including_subtasks
== 0:
644 set_excluding_from_payees_and_error()
645 set_including_from_payees_and_error()
647 set_payees_from_excluding_and_error()
650 if len(node
.payments
) == 0:
651 # payees unset -- don't need to set payees
652 if node
.budget_including_subtasks
== 0:
653 set_including_from_excluding_and_error()
655 set_excluding_from_including_and_error()
656 elif node
.budget_excluding_subtasks
== 0 \
657 and node
.budget_including_subtasks
== 0:
658 set_excluding_from_payees_and_error()
659 set_including_from_payees_and_error()
660 elif node
.budget_excluding_subtasks
== 0:
661 set_excluding_from_including_and_error()
662 set_payees_from_including_and_error()
663 elif node
.budget_including_subtasks
== 0:
664 set_including_from_excluding_and_error()
665 set_payees_from_excluding_and_error()
667 set_including_from_excluding_and_error()
668 set_payees_from_excluding_and_error()
670 def get_errors(self
) -> List
[BudgetGraphBaseError
]:
674 except BudgetGraphBaseError
as e
:
680 for child
in reversed(list(root
.children_breadth_first())):
682 self
._get
_node
_errors
(root
, child
, errors
)
683 except BudgetGraphBaseError
as e
:
685 self
._get
_node
_errors
(root
, root
, errors
)
686 except BudgetGraphBaseError
as e
:
691 def payments(self
) -> Dict
[Person
, Dict
[Milestone
, List
[Payment
]]]:
693 for person
in self
.config
.people
.values():
694 milestone_payments
= {}
695 for milestone
in self
.config
.milestones
.values():
696 milestone_payments
[milestone
] = []
697 retval
[person
] = milestone_payments
698 for node
in self
.nodes
.values():
699 if node
.milestone
is not None:
700 for payment
in node
.payments
.values():
701 retval
[payment
.payee
][node
.milestone
].append(payment
)
705 nodes
= [*self
.nodes
.values()]
707 roots
= [_NodeSimpleReprWrapper(i
) for i
in self
.roots
]
709 roots_str
= repr(roots
)
710 except BudgetGraphBaseError
:
711 roots_str
= "<failed>"
712 return f
"BudgetGraph{{nodes={nodes!r}, roots={roots}}}"