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 BudgetGraphUnknownAssignee(BudgetGraphParseError
):
34 def __init__(self
, bug_id
: int, assignee
: str):
35 super().__init
__(bug_id
)
36 self
.assignee
= assignee
39 return f
"Bug #{self.bug_id} is assigned to an unknown person: " \
43 class BudgetGraphLoopError(BudgetGraphBaseError
):
44 def __init__(self
, bug_ids
: List
[int]):
45 self
.bug_ids
= bug_ids
48 retval
= f
"Detected Loop in Budget Graph: #{self.bug_ids[-1]} -> "
49 retval
+= " -> ".join((f
"#{i}" for i
in self
.bug_ids
))
53 class _NodeSimpleReprWrapper
:
54 def __init__(self
, node
: "Node"):
58 return f
"#{self.node.bug.id}"
60 def __lt__(self
, other
):
62 return self
.node
.bug
.id < other
.node
.bug
.id
65 class PayeeState(enum
.Enum
):
66 NotYetSubmitted
= "not yet submitted"
67 Submitted
= "submitted"
71 _Date
= Union
[date
, datetime
]
74 def _parse_money_from_toml(value
: Any
) -> Money
:
75 if not isinstance(value
, (int, str)):
76 msg
= f
"monetary amount is not a string or integer " \
77 f
"(to use fractional amounts such as 123.45, write " \
78 f
"\"123.45\"): {value!r}"
83 def _parse_date_time_or_none_from_toml(value
: Any
) -> Optional
[_Date
]:
84 if value
is None or isinstance(value
, (date
, datetime
)):
86 elif isinstance(value
, time
):
87 msg
= f
"just a time of day by itself is not enough," \
88 f
" a date must be included: {str(value)}"
90 elif isinstance(value
, bool):
91 msg
= f
"invalid date: {str(value).lower()}"
93 elif isinstance(value
, (str, int, float)):
94 msg
= f
"invalid date: {value!r}"
106 paid
: Optional
[_Date
],
107 submitted
: Optional
[_Date
]):
109 self
.payee_key
= payee_key
112 self
.submitted
= submitted
115 def payee(self
) -> Person
:
117 return self
.node
.graph
.config
.all_names
[self
.payee_key
]
119 msg
= f
"unknown payee name: {self.payee_key!r} is not the name " \
120 f
"or an alias of any known person"
121 raise BudgetGraphPayeesParseError(self
.node
.bug
.id, msg
) \
122 .with_traceback(sys
.exc_info()[2])
126 if self
.paid
is not None:
127 return PayeeState
.Paid
128 if self
.submitted
is not None:
129 return PayeeState
.Submitted
130 return PayeeState
.NotYetSubmitted
133 def _from_toml(node
: "Node", payee_key
: str, toml_value
: Any
) -> "Payment":
136 known_keys
= ("paid", "submitted", "amount")
137 if isinstance(toml_value
, dict):
139 amount
= toml_value
['amount']
141 msg
= f
"value for key {payee_key!r} is missing the " \
142 f
"`amount` field which is required"
143 raise BudgetGraphPayeesParseError(node
.bug
.id, msg
) \
144 .with_traceback(sys
.exc_info()[2])
145 for k
, v
in toml_value
.items():
146 if k
in ("paid", "submitted"):
148 parsed_value
= _parse_date_time_or_none_from_toml(v
)
149 except ValueError as e
:
150 msg
= f
"failed to parse `{k}` field for" \
151 f
" key {payee_key!r}: {e}"
152 raise BudgetGraphPayeesParseError(
154 .with_traceback(sys
.exc_info()[2])
158 assert k
== "submitted"
159 submitted
= parsed_value
160 if k
not in known_keys
:
161 msg
= f
"value for key {payee_key!r} has an unknown" \
163 raise BudgetGraphPayeesParseError(node
.bug
.id, msg
) \
164 .with_traceback(sys
.exc_info()[2])
166 paid
= _parse_date_time_or_none_from_toml(
167 toml_value
.get('paid'))
168 except ValueError as e
:
169 msg
= f
"failed to parse `paid` field for" \
170 f
" key {payee_key!r}: {e}"
171 raise BudgetGraphPayeesParseError(
173 .with_traceback(sys
.exc_info()[2])
175 submitted
= _parse_date_time_or_none_from_toml(
176 toml_value
.get('submitted'))
177 except ValueError as e
:
178 msg
= f
"failed to parse `submitted` field for" \
179 f
" key {payee_key!r}: {e}"
180 raise BudgetGraphPayeesParseError(
182 .with_traceback(sys
.exc_info()[2])
183 elif isinstance(toml_value
, (int, str, float)):
184 # float included for better error messages
187 msg
= f
"value for key {payee_key!r} is invalid -- it should " \
188 f
"either be a monetary value or a table"
189 raise BudgetGraphPayeesParseError(node
.bug
.id, msg
)
191 amount
= _parse_money_from_toml(amount
)
192 except ValueError as e
:
193 msg
= f
"failed to parse monetary amount for key {payee_key!r}: {e}"
194 raise BudgetGraphPayeesParseError(
196 .with_traceback(sys
.exc_info()[2])
197 return Payment(node
=node
, payee_key
=payee_key
, amount
=amount
,
198 paid
=paid
, submitted
=submitted
)
202 payee
= f
"Person<{self.payee.identifier!r}>"
203 except BudgetGraphBaseError
:
204 payee
= "<unknown person>"
205 return (f
"Payment(node={_NodeSimpleReprWrapper(self.node)}, "
207 f
"payee_key={self.payee_key!r}, "
208 f
"amount={self.amount}, "
209 f
"state={self.state.name}, "
210 f
"paid={str(self.paid)}, "
211 f
"submitted={str(self.submitted)})")
214 class BudgetGraphUnknownMilestone(BudgetGraphParseError
):
215 def __init__(self
, bug_id
: int, milestone_str
: str):
216 super().__init
__(bug_id
)
217 self
.milestone_str
= milestone_str
220 return f
"failed to parse cf_nlnet_milestone field of bug " \
221 f
"#{self.bug_id}: unknown milestone: {self.milestone_str!r}"
224 class BudgetGraphUnknownStatus(BudgetGraphParseError
):
225 def __init__(self
, bug_id
: int, status_str
: str):
226 super().__init
__(bug_id
)
227 self
.status_str
= status_str
230 return f
"failed to parse status field of bug " \
231 f
"#{self.bug_id}: unknown status: {self.status_str!r}"
237 parent_id
: Optional
[int]
238 immediate_children
: Set
["Node"]
239 budget_excluding_subtasks
: Money
240 budget_including_subtasks
: Money
241 fixed_budget_excluding_subtasks
: Money
242 fixed_budget_including_subtasks
: Money
243 milestone_str
: Optional
[str]
245 def __init__(self
, graph
: "BudgetGraph", bug
: Bug
):
248 self
.parent_id
= getattr(bug
, "cf_budget_parent", None)
249 self
.immediate_children
= set()
250 self
.budget_excluding_subtasks
= Money
.from_str(bug
.cf_budget
)
251 self
.fixed_budget_excluding_subtasks
= self
.budget_excluding_subtasks
252 self
.budget_including_subtasks
= Money
.from_str(bug
.cf_total_budget
)
253 self
.fixed_budget_including_subtasks
= self
.budget_including_subtasks
254 self
.milestone_str
= bug
.cf_nlnet_milestone
255 if self
.milestone_str
== "---":
256 self
.milestone_str
= None
259 def status(self
) -> BugStatus
:
261 return BugStatus
.cast(self
.bug
.status
)
263 new_err
= BudgetGraphUnknownStatus(self
.bug
.id, self
.bug
.status
)
264 raise new_err
.with_traceback(sys
.exc_info()[2])
267 def assignee(self
) -> Person
:
269 return self
.graph
.config
.all_names
[self
.bug
.assigned_to
]
271 raise BudgetGraphUnknownAssignee(self
.bug
.id,
272 self
.bug
.assigned_to
) \
273 .with_traceback(sys
.exc_info()[2])
276 def bug_url(self
) -> str:
277 return f
"{self.graph.config.bugzilla_url_stripped}/show_bug.cgi?" \
281 def milestone(self
) -> Optional
[Milestone
]:
283 if self
.milestone_str
is not None:
284 return self
.graph
.config
.milestones
[self
.milestone_str
]
287 new_err
= BudgetGraphUnknownMilestone(
288 self
.bug
.id, self
.milestone_str
)
289 raise new_err
.with_traceback(sys
.exc_info()[2])
292 def payments(self
) -> Dict
[str, Payment
]:
294 parsed
= toml
.loads(self
.bug
.cf_payees_list
)
295 except toml
.TomlDecodeError
as e
:
296 new_err
= BudgetGraphPayeesParseError(
297 self
.bug
.id, f
"TOML parse error: {e}")
298 raise new_err
.with_traceback(sys
.exc_info()[2])
300 for key
, value
in parsed
.items():
301 if not isinstance(key
, str):
302 raise BudgetGraphPayeesParseError(
303 self
.bug
.id, f
"key is not a string: {key!r}")
304 retval
[key
] = Payment
._from
_toml
(self
, key
, value
)
308 def parent(self
) -> Optional
["Node"]:
309 if self
.parent_id
is not None:
310 return self
.graph
.nodes
[self
.parent_id
]
313 def parents(self
) -> Iterable
["Node"]:
315 while parent
is not None:
317 parent
= parent
.parent
319 def _raise_loop_error(self
):
321 for parent
in self
.parents():
322 bug_ids
.append(parent
.bug
.id)
325 raise BudgetGraphLoopError(bug_ids
)
328 def root(self
) -> "Node":
329 # also checks for loop errors
331 for parent
in self
.parents():
334 self
._raise
_loop
_error
()
337 def children(self
) -> Iterable
["Node"]:
338 def visitor(node
: Node
) -> Iterable
[Node
]:
339 for i
in node
.immediate_children
:
341 yield from visitor(i
)
344 def children_breadth_first(self
) -> Iterable
["Node"]:
345 q
= deque(self
.immediate_children
)
351 q
.extend(node
.immediate_children
)
354 def __eq__(self
, other
):
355 return self
.bug
.id == other
.bug
.id
362 root
= _NodeSimpleReprWrapper(self
.root
)
363 except BudgetGraphLoopError
:
364 root
= "<loop error>"
366 milestone
= repr(self
.milestone
)
367 except BudgetGraphBaseError
:
368 milestone
= "<unknown milestone>"
370 status
= repr(self
.status
)
371 except BudgetGraphBaseError
:
372 status
= f
"<unknown status: {self.bug.status!r}>"
374 assignee
= f
"Person<{self.assignee.identifier!r}>"
375 except BudgetGraphBaseError
:
376 assignee
= f
"<unknown assignee: {self.bug.assigned_to!r}>"
377 immediate_children
= []
378 for i
in self
.immediate_children
:
379 immediate_children
.append(_NodeSimpleReprWrapper(i
))
380 immediate_children
.sort()
381 parent
= f
"#{self.parent_id}" if self
.parent_id
is not None else None
382 payments
= list(self
.payments
.values())
383 return (f
"Node(graph=..., "
384 f
"id={_NodeSimpleReprWrapper(self)}, "
387 f
"budget_excluding_subtasks={self.budget_excluding_subtasks}, "
388 f
"budget_including_subtasks={self.budget_including_subtasks}, "
389 f
"fixed_budget_excluding_subtasks={self.fixed_budget_excluding_subtasks}, "
390 f
"fixed_budget_including_subtasks={self.fixed_budget_including_subtasks}, "
391 f
"milestone_str={self.milestone_str!r}, "
392 f
"milestone={milestone}, "
393 f
"immediate_children={immediate_children!r}, "
394 f
"payments={payments!r}, "
396 f
"assignee={assignee})")
399 class BudgetGraphError(BudgetGraphBaseError
):
400 def __init__(self
, bug_id
: int, root_bug_id
: int):
402 self
.root_bug_id
= root_bug_id
405 class BudgetGraphMoneyWithNoMilestone(BudgetGraphError
):
407 return (f
"Bug assigned money but without"
408 f
" any assigned milestone: #{self.bug_id}")
411 class BudgetGraphMilestoneMismatch(BudgetGraphError
):
413 return (f
"Bug's assigned milestone doesn't match the milestone "
414 f
"assigned to the root bug: descendant bug"
415 f
" #{self.bug_id}, root bug"
416 f
" #{self.root_bug_id}")
419 class BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(BudgetGraphError
):
420 def __init__(self
, bug_id
: int, root_bug_id
: int,
421 expected_budget_excluding_subtasks
: Money
):
422 super().__init
__(bug_id
, root_bug_id
)
423 self
.expected_budget_excluding_subtasks
= \
424 expected_budget_excluding_subtasks
427 return (f
"Budget assigned to task excluding subtasks "
428 f
"(cf_budget field) doesn't match calculated value: "
429 f
"bug #{self.bug_id}, calculated value"
430 f
" {self.expected_budget_excluding_subtasks}")
433 class BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(BudgetGraphError
):
434 def __init__(self
, bug_id
: int, root_bug_id
: int,
435 expected_budget_including_subtasks
: Money
):
436 super().__init
__(bug_id
, root_bug_id
)
437 self
.expected_budget_including_subtasks
= \
438 expected_budget_including_subtasks
441 return (f
"Budget assigned to task including subtasks "
442 f
"(cf_total_budget field) doesn't match calculated value: "
443 f
"bug #{self.bug_id}, calculated value"
444 f
" {self.expected_budget_including_subtasks}")
447 class BudgetGraphNegativeMoney(BudgetGraphError
):
449 return (f
"Budget assigned to task is less than zero: "
450 f
"bug #{self.bug_id}")
453 class BudgetGraphPayeesMoneyMismatch(BudgetGraphError
):
454 def __init__(self
, bug_id
: int, root_bug_id
: int, payees_total
: Money
,
455 expected_payees_total
: Money
):
456 super().__init
__(bug_id
, root_bug_id
)
457 self
.payees_total
= payees_total
458 self
.expected_payees_total
= expected_payees_total
461 return (f
"Total budget assigned to payees (cf_payees_list) doesn't "
462 f
"match expected value: bug #{self.bug_id}, calculated total "
463 f
"{self.payees_total}, expected value "
464 f
"{self.expected_payees_total}")
467 class BudgetGraphNegativePayeeMoney(BudgetGraphError
):
468 def __init__(self
, bug_id
: int, root_bug_id
: int, payee_key
: str):
469 super().__init
__(bug_id
, root_bug_id
)
470 self
.payee_key
= payee_key
473 return (f
"Budget assigned to payee for task is less than zero: "
474 f
"bug #{self.bug_id}, payee {self.payee_key!r}")
477 class BudgetGraphDuplicatePayeesForTask(BudgetGraphError
):
478 def __init__(self
, bug_id
: int, root_bug_id
: int, payee1_key
: str, payee2_key
: str):
479 super().__init
__(bug_id
, root_bug_id
)
480 self
.payee1_key
= payee1_key
481 self
.payee2_key
= payee2_key
484 return (f
"Budget assigned to multiple aliases of the same person in "
485 f
"a single task: bug #{self.bug_id}, budget assigned to both "
486 f
"{self.payee1_key!r} and {self.payee2_key!r}")
489 class BudgetGraphIncorrectRootForMilestone(BudgetGraphError
):
490 def __init__(self
, bug_id
: int, milestone
: str, milestone_canonical_bug_id
: int):
491 super().__init
__(bug_id
, bug_id
)
492 self
.milestone
= milestone
493 self
.milestone_canonical_bug_id
= milestone_canonical_bug_id
496 return (f
"Bug #{self.bug_id} is not the canonical root bug for "
497 f
"assigned milestone {self.milestone!r} but has no parent "
498 f
"bug set: the milestone's canonical root bug is "
499 f
"#{self.milestone_canonical_bug_id}")
503 nodes
: Dict
[int, Node
]
505 def __init__(self
, bugs
: Iterable
[Bug
], config
: Config
):
509 self
.nodes
[bug
.id] = Node(self
, bug
)
510 for node
in self
.nodes
.values():
511 if node
.parent
is None:
513 node
.parent
.immediate_children
.add(node
)
514 # useful debug prints
516 # node = self.nodes[bug.id]
517 # print ("bug added", bug.id, node, node.parent.immediate_children)
520 def roots(self
) -> Set
[Node
]:
522 for node
in self
.nodes
.values():
523 # calling .root also checks for loop errors
528 def _get_node_errors(self
, root
: Node
, node
: Node
,
529 errors
: List
[BudgetGraphBaseError
]):
530 if node
.milestone_str
is None:
531 if node
.budget_including_subtasks
!= 0 \
532 or node
.budget_excluding_subtasks
!= 0:
533 errors
.append(BudgetGraphMoneyWithNoMilestone(
534 node
.bug
.id, root
.bug
.id))
537 # check for milestone errors
539 if root
== node
and node
.milestone
is not None \
540 and node
.milestone
.canonical_bug_id
!= node
.bug
.id:
541 if node
.budget_including_subtasks
!= 0 \
542 or node
.budget_excluding_subtasks
!= 0:
543 errors
.append(BudgetGraphIncorrectRootForMilestone(
544 node
.bug
.id, node
.milestone
.identifier
,
545 node
.milestone
.canonical_bug_id
547 except BudgetGraphBaseError
as e
:
551 # check for status errors
553 except BudgetGraphBaseError
as e
:
557 # check for assignee errors
559 except BudgetGraphBaseError
as e
:
562 if node
.milestone_str
!= root
.milestone_str
:
563 errors
.append(BudgetGraphMilestoneMismatch(
564 node
.bug
.id, root
.bug
.id))
566 if node
.budget_excluding_subtasks
< 0 \
567 or node
.budget_including_subtasks
< 0:
568 errors
.append(BudgetGraphNegativeMoney(
569 node
.bug
.id, root
.bug
.id))
572 subtasks_total
= Money(0)
573 for child
in node
.immediate_children
:
574 subtasks_total
+= child
.fixed_budget_including_subtasks
575 childlist
.append(child
.bug
.id)
576 # useful debug prints
577 #print ("subtask total", node.bug.id, root.bug.id, subtasks_total,
580 payees_total
= Money(0)
582 for payment
in node
.payments
.values():
583 if payment
.amount
< 0:
584 errors
.append(BudgetGraphNegativePayeeMoney(
585 node
.bug
.id, root
.bug
.id, payment
.payee_key
))
586 payees_total
+= payment
.amount
588 # check for payee errors
590 previous_payment
= payee_payments
.get(payment
.payee
)
591 if previous_payment
is not None:
592 payee_payments
[payment
.payee
].append(payment
)
594 print ("NOT AN ERROR", BudgetGraphDuplicatePayeesForTask(
595 node
.bug
.id, root
.bug
.id,
596 previous_payment
.payee_key
, payment
.payee_key
599 payee_payments
[payment
.payee
] = [payment
]
600 except BudgetGraphBaseError
as e
:
603 def set_including_from_excluding_and_error():
604 node
.fixed_budget_including_subtasks
= \
605 node
.budget_excluding_subtasks
+ subtasks_total
607 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
608 node
.bug
.id, root
.bug
.id,
609 node
.fixed_budget_including_subtasks
))
611 def set_including_from_payees_and_error():
612 node
.fixed_budget_including_subtasks
= \
613 payees_total
+ subtasks_total
615 BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
616 node
.bug
.id, root
.bug
.id,
617 node
.fixed_budget_including_subtasks
))
619 def set_excluding_from_including_and_error():
620 node
.fixed_budget_excluding_subtasks
= \
621 node
.budget_including_subtasks
- subtasks_total
623 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
624 node
.bug
.id, root
.bug
.id,
625 node
.fixed_budget_excluding_subtasks
))
627 def set_excluding_from_payees_and_error():
628 node
.fixed_budget_excluding_subtasks
= \
631 BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
632 node
.bug
.id, root
.bug
.id,
633 node
.fixed_budget_excluding_subtasks
))
635 def set_payees_from_including_and_error():
636 fixed_payees_total
= \
637 node
.budget_including_subtasks
- subtasks_total
638 errors
.append(BudgetGraphPayeesMoneyMismatch(
639 node
.bug
.id, root
.bug
.id, payees_total
, fixed_payees_total
))
641 def set_payees_from_excluding_and_error():
642 fixed_payees_total
= \
643 node
.budget_excluding_subtasks
644 errors
.append(BudgetGraphPayeesMoneyMismatch(
645 node
.bug
.id, root
.bug
.id, payees_total
, fixed_payees_total
))
647 payees_matches_including
= \
648 node
.budget_including_subtasks
- subtasks_total
== payees_total
649 payees_matches_excluding
= \
650 node
.budget_excluding_subtasks
== payees_total
651 including_matches_excluding
= \
652 node
.budget_including_subtasks
- subtasks_total \
653 == node
.budget_excluding_subtasks
655 if payees_matches_including \
656 and payees_matches_excluding \
657 and including_matches_excluding
:
659 elif payees_matches_including
:
660 # can't have 2 match without all 3 matching
661 assert not payees_matches_excluding
662 assert not including_matches_excluding
663 if node
.budget_including_subtasks
== 0 and len(node
.payments
) == 0:
664 set_including_from_excluding_and_error()
666 set_excluding_from_including_and_error()
667 elif payees_matches_excluding
:
668 # can't have 2 match without all 3 matching
669 assert not payees_matches_including
670 assert not including_matches_excluding
671 if node
.budget_excluding_subtasks
== 0 and len(node
.payments
) == 0:
672 if node
.budget_including_subtasks
== 0:
673 set_including_from_excluding_and_error()
675 set_excluding_from_including_and_error()
677 set_including_from_excluding_and_error()
678 elif including_matches_excluding
:
679 # can't have 2 match without all 3 matching
680 assert not payees_matches_including
681 assert not payees_matches_excluding
682 if len(node
.payments
) == 0:
683 pass # no error -- payees is just not set
684 elif node
.budget_excluding_subtasks
== 0 \
685 and node
.budget_including_subtasks
== 0:
686 set_excluding_from_payees_and_error()
687 set_including_from_payees_and_error()
689 set_payees_from_excluding_and_error()
692 if len(node
.payments
) == 0:
693 # payees unset -- don't need to set payees
694 if node
.budget_including_subtasks
== 0:
695 set_including_from_excluding_and_error()
697 set_excluding_from_including_and_error()
698 elif node
.budget_excluding_subtasks
== 0 \
699 and node
.budget_including_subtasks
== 0:
700 set_excluding_from_payees_and_error()
701 set_including_from_payees_and_error()
702 elif node
.budget_excluding_subtasks
== 0:
703 set_excluding_from_including_and_error()
704 set_payees_from_including_and_error()
705 elif node
.budget_including_subtasks
== 0:
706 set_including_from_excluding_and_error()
707 set_payees_from_excluding_and_error()
709 set_including_from_excluding_and_error()
710 set_payees_from_excluding_and_error()
712 def get_errors(self
) -> List
[BudgetGraphBaseError
]:
716 except BudgetGraphBaseError
as e
:
722 for child
in reversed(list(root
.children_breadth_first())):
724 self
._get
_node
_errors
(root
, child
, errors
)
725 except BudgetGraphBaseError
as e
:
727 self
._get
_node
_errors
(root
, root
, errors
)
728 except BudgetGraphBaseError
as e
:
733 def assigned_nodes(self
) -> Dict
[Person
, List
[Node
]]:
734 retval
= {person
: [] for person
in self
.config
.people
.values()}
735 for node
in self
.nodes
.values():
736 retval
[node
.assignee
].append(node
)
740 def payments(self
) -> Dict
[Person
, Dict
[Milestone
, List
[Payment
]]]:
742 for person
in self
.config
.people
.values():
743 milestone_payments
= {}
744 for milestone
in self
.config
.milestones
.values():
745 milestone_payments
[milestone
] = []
746 retval
[person
] = milestone_payments
747 for node
in self
.nodes
.values():
748 if node
.milestone
is not None:
749 for payment
in node
.payments
.values():
750 retval
[payment
.payee
][node
.milestone
].append(payment
)
754 nodes
= [*self
.nodes
.values()]
756 roots
= [_NodeSimpleReprWrapper(i
) for i
in self
.roots
]
758 roots_str
= repr(roots
)
759 except BudgetGraphBaseError
:
760 roots_str
= "<failed>"
761 return f
"BudgetGraph{{nodes={nodes!r}, roots={roots}}}"