1 from budget_sync
.ordered_set
import OrderedSet
2 from bugzilla
.bug
import Bug
3 from typing
import Callable
, Set
, Dict
, Iterable
, Optional
, List
, Tuple
, Union
, Any
4 from budget_sync
.util
import BugStatus
, PrettyPrinter
5 from budget_sync
.money
import Money
6 from budget_sync
.config
import Config
, Person
, Milestone
10 from collections
import deque
11 from datetime
import date
, time
, datetime
13 from functools
import cached_property
14 except ImportError: # :nocov:
15 # compatibility with python < 3.8
16 from cached_property
import cached_property
# :nocov:
19 class BudgetGraphBaseError(Exception):
23 class BudgetGraphParseError(BudgetGraphBaseError
):
24 def __init__(self
, bug_id
: int):
28 class BudgetGraphPayeesParseError(BudgetGraphParseError
):
29 def __init__(self
, bug_id
: int, msg
: str):
30 super().__init
__(bug_id
)
34 return f
"Failed to parse cf_payees_list field of " \
35 f
"bug #{self.bug_id}: {self.msg}"
38 class BudgetGraphUnknownAssignee(BudgetGraphParseError
):
39 def __init__(self
, bug_id
: int, assignee
: str):
40 super().__init
__(bug_id
)
41 self
.assignee
= assignee
44 return f
"Bug #{self.bug_id} is assigned to an unknown person: " \
48 class BudgetGraphLoopError(BudgetGraphBaseError
):
49 def __init__(self
, bug_ids
: List
[int]):
50 self
.bug_ids
= bug_ids
53 retval
= f
"Detected Loop in Budget Graph: #{self.bug_ids[-1]} -> "
54 retval
+= " -> ".join((f
"#{i}" for i
in self
.bug_ids
))
58 class _NodeSimpleReprWrapper
:
59 def __init__(self
, node
: "Node"):
63 return f
"#{self.node.bug.id}"
65 def __lt__(self
, other
):
67 return self
.node
.bug
.id < other
.node
.bug
.id
70 class PayeeState(enum
.Enum
):
71 NotYetSubmitted
= "not yet submitted"
72 Submitted
= "submitted"
76 _Date
= Union
[date
, datetime
]
79 def _parse_money_from_toml(value
: Any
) -> Money
:
80 if not isinstance(value
, (int, str)):
81 msg
= f
"monetary amount is not a string or integer " \
82 f
"(to use fractional amounts such as 123.45, write " \
83 f
"\"123.45\"): {value!r}"
88 def _parse_date_time_or_none_from_toml(value
: Any
) -> Optional
[_Date
]:
89 if value
is None or isinstance(value
, (date
, datetime
)):
91 elif isinstance(value
, time
):
92 msg
= f
"just a time of day by itself is not enough," \
93 f
" a date must be included: {str(value)}"
95 elif isinstance(value
, bool):
96 msg
= f
"invalid date: {str(value).lower()}"
98 elif isinstance(value
, (str, int, float)):
99 msg
= f
"invalid date: {value!r}"
100 raise ValueError(msg
)
102 msg
= f
"invalid date"
103 raise ValueError(msg
)
111 paid
: Optional
[_Date
],
112 submitted
: Optional
[_Date
]):
114 self
.payee_key
= payee_key
117 self
.submitted
= submitted
120 def payee(self
) -> Person
:
122 return self
.node
.graph
.config
.all_names
[self
.payee_key
]
124 msg
= f
"unknown payee name: {self.payee_key!r} is not the name " \
125 f
"or an alias of any known person"
126 raise BudgetGraphPayeesParseError(self
.node
.bug
.id, msg
) \
127 .with_traceback(sys
.exc_info()[2])
131 if self
.paid
is not None:
132 return PayeeState
.Paid
133 if self
.submitted
is not None:
134 return PayeeState
.Submitted
135 return PayeeState
.NotYetSubmitted
138 def _from_toml(node
: "Node", payee_key
: str, toml_value
: Any
) -> "Payment":
141 known_keys
= ("paid", "submitted", "amount")
142 if isinstance(toml_value
, dict):
144 amount
= toml_value
['amount']
146 msg
= f
"value for key {payee_key!r} is missing the " \
147 f
"`amount` field which is required"
148 raise BudgetGraphPayeesParseError(node
.bug
.id, msg
) \
149 .with_traceback(sys
.exc_info()[2])
150 for k
, v
in toml_value
.items():
151 if k
in ("paid", "submitted"):
153 parsed_value
= _parse_date_time_or_none_from_toml(v
)
154 except ValueError as e
:
155 msg
= f
"failed to parse `{k}` field for" \
156 f
" key {payee_key!r}: {e}"
157 raise BudgetGraphPayeesParseError(
159 .with_traceback(sys
.exc_info()[2])
163 assert k
== "submitted"
164 submitted
= parsed_value
165 if k
not in known_keys
:
166 msg
= f
"value for key {payee_key!r} has an unknown" \
168 raise BudgetGraphPayeesParseError(node
.bug
.id, msg
) \
169 .with_traceback(sys
.exc_info()[2])
171 paid
= _parse_date_time_or_none_from_toml(
172 toml_value
.get('paid'))
173 except ValueError as e
:
174 msg
= f
"failed to parse `paid` field for" \
175 f
" key {payee_key!r}: {e}"
176 raise BudgetGraphPayeesParseError(
178 .with_traceback(sys
.exc_info()[2])
180 submitted
= _parse_date_time_or_none_from_toml(
181 toml_value
.get('submitted'))
182 except ValueError as e
:
183 msg
= f
"failed to parse `submitted` field for" \
184 f
" key {payee_key!r}: {e}"
185 raise BudgetGraphPayeesParseError(
187 .with_traceback(sys
.exc_info()[2])
188 elif isinstance(toml_value
, (int, str, float)):
189 # float included for better error messages
192 msg
= f
"value for key {payee_key!r} is invalid -- it should " \
193 f
"either be a monetary value or a table"
194 raise BudgetGraphPayeesParseError(node
.bug
.id, msg
)
196 amount
= _parse_money_from_toml(amount
)
197 except ValueError as e
:
198 msg
= f
"failed to parse monetary amount for key {payee_key!r}: {e}"
199 raise BudgetGraphPayeesParseError(
201 .with_traceback(sys
.exc_info()[2])
202 return Payment(node
=node
, payee_key
=payee_key
, amount
=amount
,
203 paid
=paid
, submitted
=submitted
)
207 payee
= f
"Person<{self.payee.identifier!r}>"
208 except BudgetGraphBaseError
:
209 payee
= "<unknown person>"
210 return (f
"Payment(node={_NodeSimpleReprWrapper(self.node)}, "
212 f
"payee_key={self.payee_key!r}, "
213 f
"amount={self.amount}, "
214 f
"state={self.state.name}, "
215 f
"paid={str(self.paid)}, "
216 f
"submitted={str(self.submitted)})")
220 class PaymentSummaryState(enum
.Enum
):
221 Submitted
= PayeeState
.Submitted
222 Paid
= PayeeState
.Paid
223 NotYetSubmitted
= PayeeState
.NotYetSubmitted
227 class PaymentSummary
:
228 total_submitted
: Money
229 """includes amount paid"""
231 def __init__(self
, payments
: Iterable
[Payment
]):
232 self
.payments
= tuple(payments
)
233 self
.total
= Money(0)
234 self
.total_paid
= Money(0)
235 self
.total_submitted
= Money(0)
236 self
.submitted_date
= None
237 self
.paid_date
= None
238 self
.not_submitted
= []
240 for payment
in self
.payments
:
241 if summary_state
is None:
242 summary_state
= PaymentSummaryState(payment
.state
)
243 self
.submitted_date
= payment
.submitted
244 self
.paid_date
= payment
.paid
245 elif summary_state
!= PaymentSummaryState(payment
.state
) \
246 or self
.submitted_date
!= payment
.submitted \
247 or self
.paid_date
!= payment
.paid
:
248 summary_state
= PaymentSummaryState
.Inconsistent
249 self
.paid_date
= None
250 self
.submitted_date
= None
251 self
.total
+= payment
.amount
252 if payment
.state
is PayeeState
.Submitted
:
253 self
.total_submitted
+= payment
.amount
254 elif payment
.state
is PayeeState
.Paid
:
255 self
.total_submitted
+= payment
.amount
256 self
.total_paid
+= payment
.amount
258 assert payment
.state
is PayeeState
.NotYetSubmitted
259 self
.not_submitted
.append(payment
.node
.bug
.id)
260 if summary_state
is None:
261 self
.state
= PaymentSummaryState
.NotYetSubmitted
263 self
.state
= summary_state
265 def get_not_submitted(self
):
266 return self
.not_submitted
268 def __repr__(self
) -> str:
269 return (f
"PaymentSummary(total={self.total}, "
270 f
"total_paid={self.total_paid}, "
271 f
"total_submitted={self.total_submitted}, "
272 f
"submitted_date={self.submitted_date}, "
273 f
"paid_date={self.paid_date}, "
274 f
"state={self.state}, "
275 f
"payments={self.payments})")
277 def __pretty_print__(self
, pp
: PrettyPrinter
):
278 with pp
.type_pp("PaymentSummary") as tpp
:
279 tpp
.field("total", self
.total
)
280 tpp
.field("total_submitted", self
.total_submitted
)
281 tpp
.field("submitted_date", self
.submitted_date
)
282 tpp
.field("paid_date", self
.paid_date
)
283 tpp
.field("state", self
.state
)
284 tpp
.field("payments", self
.payments
)
287 class BudgetGraphUnknownMilestone(BudgetGraphParseError
):
288 def __init__(self
, bug_id
: int, milestone_str
: str):
289 super().__init
__(bug_id
)
290 self
.milestone_str
= milestone_str
293 return f
"failed to parse cf_nlnet_milestone field of bug " \
294 f
"#{self.bug_id}: unknown milestone: {self.milestone_str!r}"
297 class BudgetGraphUnknownStatus(BudgetGraphParseError
):
298 def __init__(self
, bug_id
: int, status_str
: str):
299 super().__init
__(bug_id
)
300 self
.status_str
= status_str
303 return f
"failed to parse status field of bug " \
304 f
"#{self.bug_id}: unknown status: {self.status_str!r}"
310 parent_id
: Optional
[int]
311 immediate_children
: OrderedSet
["Node"]
312 budget_excluding_subtasks
: Money
313 budget_including_subtasks
: Money
314 fixed_budget_excluding_subtasks
: Money
315 fixed_budget_including_subtasks
: Money
316 milestone_str
: Optional
[str]
317 is_in_nlnet_mou
: bool
319 def __init__(self
, graph
: "BudgetGraph", bug
: Bug
):
322 self
.parent_id
= getattr(bug
, "cf_budget_parent", None)
323 self
.immediate_children
= OrderedSet()
324 self
.budget_excluding_subtasks
= Money
.from_str(bug
.cf_budget
)
325 self
.fixed_budget_excluding_subtasks
= self
.budget_excluding_subtasks
326 self
.budget_including_subtasks
= Money
.from_str(bug
.cf_total_budget
)
327 self
.fixed_budget_including_subtasks
= self
.budget_including_subtasks
328 self
.milestone_str
= bug
.cf_nlnet_milestone
329 if self
.milestone_str
== "---":
330 self
.milestone_str
= None
331 self
.is_in_nlnet_mou
= bug
.cf_is_in_nlnet_mou2
== "Yes"
334 def status(self
) -> BugStatus
:
336 return BugStatus
.cast(self
.bug
.status
)
338 new_err
= BudgetGraphUnknownStatus(self
.bug
.id, self
.bug
.status
)
339 raise new_err
.with_traceback(sys
.exc_info()[2])
342 def assignee(self
) -> Person
:
344 return self
.graph
.config
.all_names
[self
.bug
.assigned_to
]
346 raise BudgetGraphUnknownAssignee(self
.bug
.id,
347 self
.bug
.assigned_to
) \
348 .with_traceback(sys
.exc_info()[2])
351 def bug_url(self
) -> str:
352 return f
"{self.graph.config.bugzilla_url_stripped}/show_bug.cgi?" \
356 def milestone(self
) -> Optional
[Milestone
]:
357 if self
.milestone_str
is None:
360 return self
.graph
.config
.milestones
[self
.milestone_str
]
362 new_err
= BudgetGraphUnknownMilestone(
363 self
.bug
.id, self
.milestone_str
)
364 raise new_err
.with_traceback(sys
.exc_info()[2])
367 def payments(self
) -> Dict
[str, Payment
]:
369 parsed
= toml
.loads(self
.bug
.cf_payees_list
)
370 except toml
.TomlDecodeError
as e
:
371 new_err
= BudgetGraphPayeesParseError(
372 self
.bug
.id, f
"TOML parse error: {e}")
373 raise new_err
.with_traceback(sys
.exc_info()[2])
375 for key
, value
in parsed
.items():
376 if not isinstance(key
, str):
377 raise BudgetGraphPayeesParseError(
378 self
.bug
.id, f
"key is not a string: {key!r}")
379 retval
[key
] = Payment
._from
_toml
(self
, key
, value
)
383 def resolved_payments(self
) -> Dict
[Person
, List
[Payment
]]:
384 retval
: Dict
[Person
, List
[Payment
]] = {}
385 for payment
in self
.payments
.values():
386 if payment
.payee
not in retval
:
387 retval
[payment
.payee
] = []
388 retval
[payment
.payee
].append(payment
)
392 def payment_summaries(self
) -> Dict
[Person
, PaymentSummary
]:
393 return {person
: PaymentSummary(payments
)
394 for person
, payments
in self
.resolved_payments
.items()}
397 def submitted_excluding_subtasks(self
) -> Money
:
399 for payment
in self
.payments
.values():
400 if payment
.submitted
is not None or payment
.paid
is not None:
401 retval
+= payment
.amount
405 def paid_excluding_subtasks(self
) -> Money
:
407 for payment
in self
.payments
.values():
408 if payment
.paid
is not None:
409 retval
+= payment
.amount
413 def submitted_including_subtasks(self
) -> Money
:
414 retval
= self
.submitted_excluding_subtasks
415 for i
in self
.immediate_children
:
416 retval
+= i
.submitted_including_subtasks
420 def paid_including_subtasks(self
) -> Money
:
421 retval
= self
.paid_excluding_subtasks
422 for i
in self
.immediate_children
:
423 retval
+= i
.paid_including_subtasks
427 def parent(self
) -> Optional
["Node"]:
428 if self
.parent_id
is not None:
429 return self
.graph
.nodes
[self
.parent_id
]
432 def parents(self
) -> Iterable
["Node"]:
434 while parent
is not None:
436 parent
= parent
.parent
438 def _raise_loop_error(self
):
440 for parent
in self
.parents():
441 bug_ids
.append(parent
.bug
.id)
444 raise BudgetGraphLoopError(bug_ids
)
447 def root(self
) -> "Node":
448 # also checks for loop errors
450 for parent
in self
.parents():
453 self
._raise
_loop
_error
()
457 def closest_bug_in_mou(self
) -> Optional
["Node"]:
458 """returns the closest bug that is in a NLNet MoU, searching only in
459 this bug and parents.
461 if self
.is_in_nlnet_mou
:
463 for parent
in self
.parents():
464 if parent
.is_in_nlnet_mou
:
468 def children(self
) -> Iterable
["Node"]:
469 def visitor(node
: Node
) -> Iterable
[Node
]:
470 for i
in node
.immediate_children
:
472 yield from visitor(i
)
475 def children_breadth_first(self
) -> Iterable
["Node"]:
476 q
= deque(self
.immediate_children
)
482 q
.extend(node
.immediate_children
)
485 def __eq__(self
, other
):
486 return self
.bug
.id == other
.bug
.id
491 def __pretty_print__(self
, pp
: PrettyPrinter
):
492 with pp
.type_pp("Node") as tpp
:
493 tpp
.field("graph", ...)
494 tpp
.field("id", _NodeSimpleReprWrapper(self
))
495 tpp
.try_field("root",
496 lambda: _NodeSimpleReprWrapper(self
.root
),
497 BudgetGraphLoopError
)
498 parent
= f
"#{self.parent_id}" if self
.parent_id
is not None else None
499 tpp
.field("parent", parent
)
500 tpp
.field("budget_excluding_subtasks",
501 self
.budget_excluding_subtasks
)
502 tpp
.field("budget_including_subtasks",
503 self
.budget_including_subtasks
)
504 tpp
.field("fixed_budget_excluding_subtasks",
505 self
.fixed_budget_excluding_subtasks
)
506 tpp
.field("fixed_budget_including_subtasks",
507 self
.fixed_budget_including_subtasks
)
508 tpp
.field("milestone_str", self
.milestone_str
)
509 tpp
.field("is_in_nlnet_mou", self
.is_in_nlnet_mou
)
510 tpp
.try_field("milestone", lambda: self
.milestone
,
511 BudgetGraphBaseError
)
512 immediate_children
= [_NodeSimpleReprWrapper(i
)
513 for i
in self
.immediate_children
]
514 tpp
.field("immediate_children", immediate_children
)
515 tpp
.try_field("payments",
516 lambda: list(self
.payments
.values()),
517 BudgetGraphBaseError
)
519 status
= repr(self
.status
)
520 except BudgetGraphBaseError
:
521 status
= f
"<unknown status: {self.bug.status!r}>"
522 tpp
.field("status", status
)
524 assignee
= f
"Person<{self.assignee.identifier!r}>"
525 except BudgetGraphBaseError
:
526 assignee
= f
"<unknown assignee: {self.bug.assigned_to!r}>"
527 tpp
.field("assignee", assignee
)
528 tpp
.try_field("resolved_payments",
529 lambda: self
.resolved_payments
,
530 BudgetGraphBaseError
)
531 tpp
.try_field("payment_summaries",
532 lambda: self
.payment_summaries
,
533 BudgetGraphBaseError
)
537 root
= _NodeSimpleReprWrapper(self
.root
)
538 except BudgetGraphLoopError
:
539 root
= "<loop error>"
541 milestone
= repr(self
.milestone
)
542 except BudgetGraphBaseError
:
543 milestone
= "<unknown milestone>"
545 status
= repr(self
.status
)
546 except BudgetGraphBaseError
:
547 status
= f
"<unknown status: {self.bug.status!r}>"
549 assignee
= f
"Person<{self.assignee.identifier!r}>"
550 except BudgetGraphBaseError
:
551 assignee
= f
"<unknown assignee: {self.bug.assigned_to!r}>"
552 immediate_children
= []
553 for i
in self
.immediate_children
:
554 immediate_children
.append(_NodeSimpleReprWrapper(i
))
555 immediate_children
.sort()
556 parent
= f
"#{self.parent_id}" if self
.parent_id
is not None else None
557 payments
= list(self
.payments
.values())
558 resolved_payments
= self
.resolved_payments
559 payment_summaries
= self
.payment_summaries
560 return (f
"Node(graph=..., "
561 f
"id={_NodeSimpleReprWrapper(self)}, "
564 f
"budget_excluding_subtasks={self.budget_excluding_subtasks}, "
565 f
"budget_including_subtasks={self.budget_including_subtasks}, "
566 f
"fixed_budget_excluding_subtasks={self.fixed_budget_excluding_subtasks}, "
567 f
"fixed_budget_including_subtasks={self.fixed_budget_including_subtasks}, "
568 f
"milestone_str={self.milestone_str!r}, "
569 f
"is_in_nlnet_mou={self.is_in_nlnet_mou!r}, "
570 f
"milestone={milestone}, "
571 f
"immediate_children={immediate_children!r}, "
572 f
"payments={payments!r}, "
574 f
"assignee={assignee}, "
575 f
"resolved_payments={resolved_payments!r}, "
576 f
"payment_summaries={payment_summaries!r})")
579 class BudgetGraphError(BudgetGraphBaseError
):
580 def __init__(self
, bug_id
: int, root_bug_id
: int):
582 self
.root_bug_id
= root_bug_id
585 class BudgetGraphMoneyWithNoMilestone(BudgetGraphError
):
587 return (f
"Bug assigned money but without"
588 f
" any assigned milestone: #{self.bug_id}")
591 class BudgetGraphMilestoneMismatch(BudgetGraphError
):
593 return (f
"Bug's assigned milestone doesn't match the milestone "
594 f
"assigned to the root bug: descendant bug"
595 f
" #{self.bug_id}, root bug"
596 f
" #{self.root_bug_id}")
599 class BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(BudgetGraphError
):
600 def __init__(self
, bug_id
: int, root_bug_id
: int,
601 expected_budget_excluding_subtasks
: Money
):
602 super().__init
__(bug_id
, root_bug_id
)
603 self
.expected_budget_excluding_subtasks
= \
604 expected_budget_excluding_subtasks
607 return (f
"Budget assigned to task excluding subtasks "
608 f
"(cf_budget field) doesn't match calculated value: "
609 f
"bug #{self.bug_id}, calculated value"
610 f
" {self.expected_budget_excluding_subtasks}")
613 class BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(BudgetGraphError
):
614 def __init__(self
, bug_id
: int, root_bug_id
: int,
615 expected_budget_including_subtasks
: Money
):
616 super().__init
__(bug_id
, root_bug_id
)
617 self
.expected_budget_including_subtasks
= \
618 expected_budget_including_subtasks
621 return (f
"Budget assigned to task including subtasks "
622 f
"(cf_total_budget field) doesn't match calculated value: "
623 f
"bug #{self.bug_id}, calculated value"
624 f
" {self.expected_budget_including_subtasks}")
627 class BudgetGraphNegativeMoney(BudgetGraphError
):
629 return (f
"Budget assigned to task is less than zero: "
630 f
"bug #{self.bug_id}")
633 class BudgetGraphPayeesMoneyMismatch(BudgetGraphError
):
634 def __init__(self
, bug_id
: int, root_bug_id
: int, payees_total
: Money
,
635 expected_payees_total
: Money
):
636 super().__init
__(bug_id
, root_bug_id
)
637 self
.payees_total
= payees_total
638 self
.expected_payees_total
= expected_payees_total
641 return (f
"Total budget assigned to payees (cf_payees_list) doesn't "
642 f
"match expected value: bug #{self.bug_id}, calculated total "
643 f
"{self.payees_total}, expected value "
644 f
"{self.expected_payees_total}")
647 class BudgetGraphNegativePayeeMoney(BudgetGraphError
):
648 def __init__(self
, bug_id
: int, root_bug_id
: int, payee_key
: str):
649 super().__init
__(bug_id
, root_bug_id
)
650 self
.payee_key
= payee_key
653 return (f
"Budget assigned to payee for task is less than zero: "
654 f
"bug #{self.bug_id}, payee {self.payee_key!r}")
657 class BudgetGraphIncorrectRootForMilestone(BudgetGraphError
):
658 def __init__(self
, bug_id
: int, milestone
: str, milestone_canonical_bug_id
: int):
659 super().__init
__(bug_id
, bug_id
)
660 self
.milestone
= milestone
661 self
.milestone_canonical_bug_id
= milestone_canonical_bug_id
664 return (f
"Bug #{self.bug_id} is not the canonical root bug for "
665 f
"assigned milestone {self.milestone!r} but has no parent "
666 f
"bug set: the milestone's canonical root bug is "
667 f
"#{self.milestone_canonical_bug_id}")
670 class BudgetGraphRootWithMilestoneNotInMoU(BudgetGraphError
):
671 def __init__(self
, bug_id
: int, milestone
: str):
672 super().__init
__(bug_id
, bug_id
)
673 self
.milestone
= milestone
676 return (f
"Bug #{self.bug_id} has no parent bug set and has an "
677 f
"assigned milestone {self.milestone!r} but isn't set "
678 f
"to be part of the signed MoU")
681 class BudgetGraphInMoUButParentNotInMoU(BudgetGraphError
):
682 def __init__(self
, bug_id
: int, parent_bug_id
: int, root_bug_id
: int,
684 super().__init
__(bug_id
, root_bug_id
)
685 self
.parent_bug_id
= parent_bug_id
686 self
.milestone
= milestone
689 return (f
"Bug #{self.bug_id} is set to be part of the signed MoU for "
690 f
"milestone {self.milestone!r}, but its parent bug isn't set "
691 f
"to be part of the signed MoU")
694 class BudgetGraphInMoUWithoutMilestone(BudgetGraphError
):
696 return (f
"Bug #{self.bug_id} is set to be part of a signed MoU but "
697 f
"has no milestone set")
701 nodes
: Dict
[int, Node
]
703 def __init__(self
, bugs
: Iterable
[Bug
], config
: Config
):
707 self
.nodes
[bug
.id] = Node(self
, bug
)
708 for node
in self
.nodes
.values():
709 if node
.parent
is None:
711 node
.parent
.immediate_children
.add(node
)
712 # useful debug prints
714 # node = self.nodes[bug.id]
715 # print ("bug added", bug.id, node, node.parent.immediate_children)
718 def roots(self
) -> OrderedSet
[Node
]:
720 for node
in self
.nodes
.values():
721 # calling .root also checks for loop errors
726 def _get_node_errors(self
, root
: Node
, node
: Node
,
727 errors
: List
[BudgetGraphBaseError
]):
728 if node
.milestone_str
is None:
729 if node
.budget_including_subtasks
!= 0 \
730 or node
.budget_excluding_subtasks
!= 0:
731 errors
.append(BudgetGraphMoneyWithNoMilestone(
732 node
.bug
.id, root
.bug
.id))
735 # check for milestone errors
737 if root
== node
and node
.milestone
is not None:
738 if node
.milestone
.canonical_bug_id
!= node
.bug
.id:
739 if node
.budget_including_subtasks
!= 0 \
740 or node
.budget_excluding_subtasks
!= 0:
741 errors
.append(BudgetGraphIncorrectRootForMilestone(
742 node
.bug
.id, node
.milestone
.identifier
,
743 node
.milestone
.canonical_bug_id
745 elif not node
.is_in_nlnet_mou
:
746 errors
.append(BudgetGraphRootWithMilestoneNotInMoU(
747 node
.bug
.id, node
.milestone_str
))
748 except BudgetGraphBaseError
as e
:
752 # check for status errors
754 except BudgetGraphBaseError
as e
:
758 # check for assignee errors
760 except BudgetGraphBaseError
as e
:
763 if node
.milestone_str
!= root
.milestone_str
:
764 errors
.append(BudgetGraphMilestoneMismatch(
765 node
.bug
.id, root
.bug
.id))
767 if node
.is_in_nlnet_mou
:
768 if node
.milestone_str
is None:
769 errors
.append(BudgetGraphInMoUWithoutMilestone(node
.bug
.id,
771 elif node
.parent
is not None and \
772 not node
.parent
.is_in_nlnet_mou
:
773 errors
.append(BudgetGraphInMoUButParentNotInMoU(
774 node
.bug
.id, node
.parent
.bug
.id, root
.bug
.id,
777 if node
.budget_excluding_subtasks
< 0 \
778 or node
.budget_including_subtasks
< 0:
779 errors
.append(BudgetGraphNegativeMoney(
780 node
.bug
.id, root
.bug
.id))
783 subtasks_total
= Money(0)
784 for child
in node
.immediate_children
:
785 subtasks_total
+= child
.fixed_budget_including_subtasks
786 childlist
.append(child
.bug
.id)
787 # useful debug prints
788 # print ("subtask total", node.bug.id, root.bug.id, subtasks_total,
791 payees_total
= Money(0)
792 payee_payments
: Dict
[Person
, List
[Payment
]] = {}
793 for payment
in node
.payments
.values():
794 if payment
.amount
< 0:
795 errors
.append(BudgetGraphNegativePayeeMoney(
796 node
.bug
.id, root
.bug
.id, payment
.payee_key
))
797 payees_total
+= payment
.amount
799 # check for payee errors
801 previous_payment
= payee_payments
.get(payment
.payee
)
802 if previous_payment
is not None:
803 payee_payments
[payment
.payee
].append(payment
)
805 payee_payments
[payment
.payee
] = [payment
]
806 except BudgetGraphBaseError
as e
:
809 def set_including_from_excluding_and_error():
810 node
.fixed_budget_including_subtasks
= \
811 node
.budget_excluding_subtasks
+ subtasks_total
813 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
814 node
.bug
.id, root
.bug
.id,
815 node
.fixed_budget_including_subtasks
))
817 def set_including_from_payees_and_error():
818 node
.fixed_budget_including_subtasks
= \
819 payees_total
+ subtasks_total
821 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
822 node
.bug
.id, root
.bug
.id,
823 node
.fixed_budget_including_subtasks
))
825 def set_excluding_from_including_and_error():
826 v
= node
.budget_including_subtasks
- subtasks_total
828 return set_including_from_excluding_and_error()
829 node
.fixed_budget_excluding_subtasks
= v
831 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
832 node
.bug
.id, root
.bug
.id,
833 node
.fixed_budget_excluding_subtasks
))
835 def set_excluding_from_payees_and_error():
836 node
.fixed_budget_excluding_subtasks
= \
839 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
840 node
.bug
.id, root
.bug
.id,
841 node
.fixed_budget_excluding_subtasks
))
843 def set_payees_from_including_and_error():
844 fixed_payees_total
= \
845 node
.budget_including_subtasks
- subtasks_total
846 errors
.append(BudgetGraphPayeesMoneyMismatch(
847 node
.bug
.id, root
.bug
.id, payees_total
, fixed_payees_total
))
849 def set_payees_from_excluding_and_error():
850 fixed_payees_total
= \
851 node
.budget_excluding_subtasks
852 errors
.append(BudgetGraphPayeesMoneyMismatch(
853 node
.bug
.id, root
.bug
.id, payees_total
, fixed_payees_total
))
855 payees_matches_including
= \
856 node
.budget_including_subtasks
- subtasks_total
== payees_total
857 payees_matches_excluding
= \
858 node
.budget_excluding_subtasks
== payees_total
859 including_matches_excluding
= \
860 node
.budget_including_subtasks
- subtasks_total \
861 == node
.budget_excluding_subtasks
863 if payees_matches_including \
864 and payees_matches_excluding \
865 and including_matches_excluding
:
867 elif payees_matches_including
:
868 # can't have 2 match without all 3 matching
869 assert not payees_matches_excluding
870 assert not including_matches_excluding
871 if node
.budget_including_subtasks
== 0 and len(node
.payments
) == 0:
872 set_including_from_excluding_and_error()
874 set_excluding_from_including_and_error()
875 elif payees_matches_excluding
:
876 # can't have 2 match without all 3 matching
877 assert not payees_matches_including
878 assert not including_matches_excluding
879 if node
.budget_excluding_subtasks
== 0 and len(node
.payments
) == 0:
880 if node
.budget_including_subtasks
== 0:
881 set_including_from_excluding_and_error()
883 set_excluding_from_including_and_error()
885 set_including_from_excluding_and_error()
886 elif including_matches_excluding
:
887 # can't have 2 match without all 3 matching
888 assert not payees_matches_including
889 assert not payees_matches_excluding
890 if len(node
.payments
) == 0:
891 pass # no error -- payees is just not set
892 elif node
.budget_excluding_subtasks
== 0 \
893 and node
.budget_including_subtasks
== 0:
894 set_excluding_from_payees_and_error()
895 set_including_from_payees_and_error()
897 set_payees_from_excluding_and_error()
900 if len(node
.payments
) == 0:
901 # payees unset -- don't need to set payees
902 if node
.budget_including_subtasks
== 0:
903 set_including_from_excluding_and_error()
905 set_excluding_from_including_and_error()
906 elif node
.budget_excluding_subtasks
== 0 \
907 and node
.budget_including_subtasks
== 0:
908 set_excluding_from_payees_and_error()
909 set_including_from_payees_and_error()
910 elif node
.budget_excluding_subtasks
== 0:
911 set_excluding_from_including_and_error()
912 set_payees_from_including_and_error()
913 elif node
.budget_including_subtasks
== 0:
914 set_including_from_excluding_and_error()
915 set_payees_from_excluding_and_error()
917 set_including_from_excluding_and_error()
918 set_payees_from_excluding_and_error()
920 def get_errors(self
) -> List
[BudgetGraphBaseError
]:
924 except BudgetGraphBaseError
as e
:
930 for child
in reversed(list(root
.children_breadth_first())):
932 self
._get
_node
_errors
(root
, child
, errors
)
933 except BudgetGraphBaseError
as e
:
935 self
._get
_node
_errors
(root
, root
, errors
)
936 except BudgetGraphBaseError
as e
:
941 def assigned_nodes(self
) -> Dict
[Person
, List
[Node
]]:
942 retval
: Dict
[Person
, List
[Node
]]
943 retval
= {person
: [] for person
in self
.config
.people
.values()}
944 for node
in self
.nodes
.values():
945 retval
[node
.assignee
].append(node
)
949 def assigned_nodes_for_milestones(self
) -> Dict
[Milestone
, List
[Node
]]:
950 retval
: Dict
[Milestone
, List
[Node
]]
951 retval
= {milestone
: []
952 for milestone
in self
.config
.milestones
.values()}
953 for node
in self
.nodes
.values():
954 if node
.milestone
is not None:
955 retval
[node
.milestone
].append(node
)
959 def milestone_payments(self
) -> Dict
[Milestone
, List
[Payment
]]:
960 retval
: Dict
[Milestone
, List
[Payment
]] = {
961 milestone
: [] for milestone
in self
.config
.milestones
.values()
963 for node
in self
.nodes
.values():
964 if node
.milestone
is not None:
965 retval
[node
.milestone
].extend(node
.payments
.values())
969 def payments(self
) -> Dict
[Person
, Dict
[Milestone
, List
[Payment
]]]:
970 retval
: Dict
[Person
, Dict
[Milestone
, List
[Payment
]]] = {
973 for milestone
in self
.config
.milestones
.values()
975 for person
in self
.config
.people
.values()
977 for node
in self
.nodes
.values():
978 if node
.milestone
is not None:
979 for payment
in node
.payments
.values():
980 retval
[payment
.payee
][node
.milestone
].append(payment
)
984 def milestone_people(self
) -> Dict
[Milestone
, OrderedSet
[Person
]]:
985 """get a list of people associated with each milestone
987 payments
= list(self
.payments
) # just activate the payments
989 for milestone
in self
.milestone_payments
.keys():
990 retval
[milestone
] = OrderedSet()
991 for milestone
, payments
in self
.milestone_payments
.items():
992 for payment
in payments
:
993 retval
[milestone
].add(payment
.payee
)
996 def __pretty_print__(self
, pp
: PrettyPrinter
):
997 with pp
.type_pp("BudgetGraph") as tpp
:
998 tpp
.field("nodes", self
.nodes
)
999 tpp
.try_field("roots",
1000 lambda: [_NodeSimpleReprWrapper(i
)
1001 for i
in self
.roots
],
1002 BudgetGraphBaseError
)
1003 tpp
.try_field("assigned_nodes",
1006 _NodeSimpleReprWrapper(node
)
1009 for person
, nodes
in self
.assigned_nodes
.items()
1011 BudgetGraphBaseError
)
1012 tpp
.try_field("assigned_nodes_for_milestones",
1015 _NodeSimpleReprWrapper(node
)
1018 for milestone
, nodes
in self
.assigned_nodes_for_milestones
.items()
1020 BudgetGraphBaseError
)
1021 tpp
.try_field("payments",
1022 lambda: self
.payments
, BudgetGraphBaseError
)
1023 tpp
.try_field("milestone_people",
1024 lambda: self
.milestone_people
,
1025 BudgetGraphBaseError
)
1028 nodes
= [*self
.nodes
.values()]
1030 def repr_or_failed(f
: Callable
[[], Any
]) -> str:
1033 except BudgetGraphBaseError
:
1037 roots
= [_NodeSimpleReprWrapper(i
) for i
in self
.roots
]
1039 roots_str
= repr(roots
)
1040 except BudgetGraphBaseError
:
1041 roots_str
= "<failed>"
1042 assigned_nodes
= repr_or_failed(lambda: {
1044 _NodeSimpleReprWrapper(node
)
1047 for person
, nodes
in self
.assigned_nodes
.items()
1049 assigned_nodes_for_milestones
= repr_or_failed(lambda: {
1051 _NodeSimpleReprWrapper(node
)
1054 for milestone
, nodes
in self
.assigned_nodes_for_milestones
.items()
1056 milestone_payments
= repr_or_failed(lambda: self
.milestone_payments
)
1057 payments
= repr_or_failed(lambda: self
.payments
)
1058 milestone_people
= repr_or_failed(lambda: self
.milestone_people
)
1059 return (f
"BudgetGraph{{nodes={nodes!r}, "
1061 f
"assigned_nodes={assigned_nodes}, "
1062 f
"assigned_nodes_for_milestones={assigned_nodes_for_milestones}, "
1063 f
"milestone_payments={milestone_payments}, "
1064 f
"payments={payments}, "
1065 f
"milestone_people={milestone_people}}}")