from bugzilla.bug import Bug
from bugzilla import Bugzilla
-from typing import Set, Dict, Iterable, Optional
+from typing import Set, Dict, Iterable, Optional, List, Union, Any
from budget_sync.util import all_bugs
from budget_sync.money import Money
+from functools import cached_property
+import toml
+import sys
+import enum
+from collections import deque
+from datetime import date, time, datetime
+
+
+class BudgetGraphBaseError(Exception):
+ pass
+
+
+class BudgetGraphParseError(BudgetGraphBaseError):
+ def __init__(self, bug_id: int):
+ self.bug_id = bug_id
+
+
+class BudgetGraphPayeesParseError(BudgetGraphParseError):
+ def __init__(self, bug_id: int, msg: str):
+ super().__init__(bug_id)
+ self.msg = msg
+
+ def __str__(self):
+ return f"Failed to parse cf_payees_list field of bug #{self.bug_id}: {self.msg}"
+
+
+class BudgetGraphLoopError(BudgetGraphBaseError):
+ def __init__(self, bug_ids: List[int]):
+ self.bug_ids = bug_ids
+
+ def __str__(self):
+ retval = f"Detected Loop in Budget Graph: #{self.bug_ids[-1]} -> "
+ retval += " -> ".join((f"#{i}" for i in self.bug_ids))
+ return retval
+
+
+class _NodeSimpleReprWrapper:
+ def __init__(self, node: "Node"):
+ self.node = node
+
+ def __repr__(self):
+ return f"#{self.node.bug.id}"
+
+ def __lt__(self, other):
+ # for list.sort()
+ return self.node.bug.id < other.node.bug.id
+
+
+class PayeeState(enum.Enum):
+ NotYetSubmitted = "not yet submitted"
+ Submitted = "submitted"
+ Paid = "paid"
+
+
+_Date = Union[date, datetime]
+
+
+def _parse_money_from_toml(value: Any) -> Money:
+ if not isinstance(value, (int, str)):
+ msg = f"monetary amount is not a string or integer " \
+ f"(to use fractional amounts such as 123.45, write " \
+ f"\"123.45\"): {value!r}"
+ raise ValueError(msg)
+ return Money(value)
+
+
+def _parse_date_time_or_none_from_toml(value: Any) -> Optional[_Date]:
+ if value is None or isinstance(value, (date, datetime)):
+ return value
+ elif isinstance(value, time):
+ msg = f"just a time of day by itself is not enough," \
+ f" a date must be included: {str(value)}"
+ raise ValueError(msg)
+ elif isinstance(value, bool):
+ msg = f"invalid date: {str(value).lower()}"
+ raise ValueError(msg)
+ elif isinstance(value, (str, int, float)):
+ msg = f"invalid date: {value!r}"
+ raise ValueError(msg)
+ else:
+ msg = f"invalid date"
+ raise ValueError(msg)
+
+
+class Payment:
+ def __init__(self,
+ node: "Node",
+ payee_key: str,
+ amount: Money,
+ paid: Optional[_Date],
+ submitted: Optional[_Date]):
+ self.node = node
+ self.payee_key = payee_key
+ self.amount = amount
+ self.paid = paid
+ self.submitted = submitted
+
+ @property
+ def state(self):
+ if self.paid is not None:
+ return PayeeState.Paid
+ if self.submitted is not None:
+ return PayeeState.Submitted
+ return PayeeState.NotYetSubmitted
+
+ @staticmethod
+ def from_toml(node: "Node", payee_key: str, toml_value: Any):
+ paid = None
+ submitted = None
+ known_keys = ("paid", "submitted", "amount")
+ if isinstance(toml_value, dict):
+ try:
+ amount = toml_value['amount']
+ except KeyError:
+ msg = f"value for key {payee_key!r} is missing the " \
+ f"`amount` field which is required"
+ raise BudgetGraphPayeesParseError(node.bug.id, msg) \
+ .with_traceback(sys.exc_info()[2])
+ for k, v in toml_value.items():
+ if k in ("paid", "submitted"):
+ try:
+ parsed_value = _parse_date_time_or_none_from_toml(v)
+ except ValueError as e:
+ msg = f"failed to parse `{k}` field for" \
+ f" key {payee_key!r}: {e}"
+ raise BudgetGraphPayeesParseError(
+ node.bug.id, msg) \
+ .with_traceback(sys.exc_info()[2])
+ if k == "paid":
+ paid = parsed_value
+ else:
+ assert k == "submitted"
+ submitted = parsed_value
+ if k not in known_keys:
+ msg = f"value for key {payee_key!r} has an unknown" \
+ f" field: `{k}`"
+ raise BudgetGraphPayeesParseError(node.bug.id, msg) \
+ .with_traceback(sys.exc_info()[2])
+ try:
+ paid = _parse_date_time_or_none_from_toml(
+ toml_value.get('paid'))
+ except ValueError as e:
+ msg = f"failed to parse `paid` field for" \
+ f" key {payee_key!r}: {e}"
+ raise BudgetGraphPayeesParseError(
+ node.bug.id, msg) \
+ .with_traceback(sys.exc_info()[2])
+ try:
+ submitted = _parse_date_time_or_none_from_toml(
+ toml_value.get('submitted'))
+ except ValueError as e:
+ msg = f"failed to parse `submitted` field for" \
+ f" key {payee_key!r}: {e}"
+ raise BudgetGraphPayeesParseError(
+ node.bug.id, msg) \
+ .with_traceback(sys.exc_info()[2])
+ elif isinstance(toml_value, (int, str, float)):
+ # float included for better error messages
+ amount = toml_value
+ else:
+ msg = f"value for key {payee_key!r} is invalid -- it should " \
+ f"either be a monetary value or a table"
+ raise BudgetGraphPayeesParseError(node.bug.id, msg)
+ try:
+ amount = _parse_money_from_toml(amount)
+ except ValueError as e:
+ msg = f"failed to parse monetary amount for key {payee_key!r}: {e}"
+ raise BudgetGraphPayeesParseError(
+ node.bug.id, msg) \
+ .with_traceback(sys.exc_info()[2])
+ return Payment(node=node, payee_key=payee_key, amount=amount,
+ paid=paid, submitted=submitted)
+
+ def __repr__(self):
+ return (f"Payment(node={_NodeSimpleReprWrapper(self.node)}, "
+ f"payee_key={self.payee_key!r}, "
+ f"amount={self.amount}, "
+ f"state={self.state.name}, "
+ f"paid={str(self.paid)}, "
+ f"submitted={str(self.submitted)})")
class Node:
- parent: Optional["Node"]
+ graph: "BudgetGraph"
+ bug: Bug
parent_id: Optional[int]
+ immediate_children: Set["Node"]
budget_excluding_subtasks: Money
budget_including_subtasks: Money
+ fixed_budget_excluding_subtasks: Money
+ fixed_budget_including_subtasks: Money
nlnet_milestone: Optional[str]
- def __init__(self, bug: Bug, bug_set: Set[Bug] = None):
+ def __init__(self, graph: "BudgetGraph", bug: Bug):
+ self.graph = graph
self.bug = bug
- if bug_set is None:
- bug_set = {bug}
- self.bug_set = bug_set
- self.parent = None
self.parent_id = getattr(bug, "cf_budget_parent", None)
+ self.immediate_children = set()
self.budget_excluding_subtasks = Money.from_str(bug.cf_budget)
+ self.fixed_budget_excluding_subtasks = self.budget_excluding_subtasks
self.budget_including_subtasks = Money.from_str(bug.cf_total_budget)
+ self.fixed_budget_including_subtasks = self.budget_including_subtasks
self.nlnet_milestone = bug.cf_nlnet_milestone
if self.nlnet_milestone == "---":
self.nlnet_milestone = None
+ @cached_property
+ def payments(self) -> Dict[str, Payment]:
+ try:
+ parsed = toml.loads(self.bug.cf_payees_list)
+ except toml.TomlDecodeError as e:
+ new_err = BudgetGraphPayeesParseError(
+ self.bug.id, f"TOML parse error: {e}")
+ raise new_err.with_traceback(sys.exc_info()[2])
+ retval = {}
+ for key, value in parsed.items():
+ if not isinstance(key, str):
+ raise BudgetGraphPayeesParseError(
+ self.bug.id, f"key is not a string: {key!r}")
+ retval[key] = Payment.from_toml(self, key, value)
+ return retval
+
+ @property
+ def parent(self) -> Optional["Node"]:
+ if self.parent_id is not None:
+ return self.graph.nodes[self.parent_id]
+ return None
+
+ def parents(self) -> Iterable["Node"]:
+ parent = self.parent
+ while parent is not None:
+ yield parent
+ parent = parent.parent
+
+ def _raise_loop_error(self):
+ bug_ids = []
+ for parent in self.parents():
+ bug_ids.append(parent.bug.id)
+ if parent == self:
+ break
+ raise BudgetGraphLoopError(bug_ids)
+
+ @cached_property
+ def root(self) -> "Node":
+ # also checks for loop errors
+ retval = self
+ for parent in self.parents():
+ retval = parent
+ if parent == self:
+ self._raise_loop_error()
+ return retval
+
+ def children(self) -> Iterable["Node"]:
+ def visitor(node: Node) -> Iterable[Node]:
+ for i in node.immediate_children:
+ yield i
+ yield from visitor(i)
+ return visitor(self)
+
+ def children_breadth_first(self) -> Iterable["Node"]:
+ q = deque(self.immediate_children)
+ while True:
+ try:
+ node = q.popleft()
+ except IndexError:
+ return
+ q.extend(node.immediate_children)
+ yield node
+
+ def __eq__(self, other):
+ return self.bug.id == other.bug.id
+
+ def __hash__(self):
+ return self.bug.id
+
+ def __repr__(self):
+ try:
+ root = _NodeSimpleReprWrapper(self.root)
+ except BudgetGraphLoopError:
+ root = "<loop error>"
+ immediate_children = []
+ for i in self.immediate_children:
+ immediate_children.append(_NodeSimpleReprWrapper(i))
+ immediate_children.sort()
+ parent = f"#{self.parent_id}" if self.parent_id is not None else None
+ payments = list(self.payments.values())
+ return (f"Node(graph=..., "
+ f"id={_NodeSimpleReprWrapper(self)}, "
+ f"root={root}, "
+ f"parent={parent}, "
+ f"budget_excluding_subtasks={self.budget_excluding_subtasks}, "
+ f"budget_including_subtasks={self.budget_including_subtasks}, "
+ f"fixed_budget_excluding_subtasks={self.fixed_budget_excluding_subtasks}, "
+ f"fixed_budget_including_subtasks={self.fixed_budget_including_subtasks}, "
+ f"nlnet_milestone={self.nlnet_milestone!r}, "
+ f"immediate_children={immediate_children!r}, "
+ f"payments={payments!r}")
+
+
+class BudgetGraphError(BudgetGraphBaseError):
+ def __init__(self, bug_id: int, root_bug_id: int):
+ self.bug_id = bug_id
+ self.root_bug_id = root_bug_id
+
+
+class BudgetGraphMoneyWithNoMilestone(BudgetGraphError):
+ def __str__(self):
+ return (f"Bug assigned money but without"
+ f" any assigned milestone: #{self.bug_id}")
+
+
+class BudgetGraphMilestoneMismatch(BudgetGraphError):
+ def __str__(self):
+ return (f"Bug's assigned milestone doesn't match the milestone "
+ f"assigned to the root bug: descendant bug"
+ f" #{self.bug_id}, root bug"
+ f" #{self.root_bug_id}")
+
+
+class BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(BudgetGraphError):
+ def __init__(self, bug_id: int, root_bug_id: int,
+ expected_budget_excluding_subtasks: Money):
+ super().__init__(bug_id, root_bug_id)
+ self.expected_budget_excluding_subtasks = \
+ expected_budget_excluding_subtasks
+
+ def __str__(self):
+ return (f"Budget assigned to task excluding subtasks "
+ f"(cf_budget field) doesn't match calculated value: "
+ f"bug #{self.bug_id}, calculated value"
+ f" {self.expected_budget_excluding_subtasks}")
+
+
+class BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(BudgetGraphError):
+ def __init__(self, bug_id: int, root_bug_id: int,
+ expected_budget_including_subtasks: Money):
+ super().__init__(bug_id, root_bug_id)
+ self.expected_budget_including_subtasks = \
+ expected_budget_including_subtasks
+
+ def __str__(self):
+ return (f"Budget assigned to task including subtasks "
+ f"(cf_total_budget field) doesn't match calculated value: "
+ f"bug #{self.bug_id}, calculated value"
+ f" {self.expected_budget_including_subtasks}")
+
+
+class BudgetGraphNegativeMoney(BudgetGraphError):
+ def __str__(self):
+ return (f"Budget assigned to task is less than zero: "
+ f"bug #{self.bug_id}")
+
+
+class BudgetGraphPayeesMoneyMismatch(BudgetGraphError):
+ def __init__(self, bug_id: int, root_bug_id: int, payees_total: Money,
+ expected_payees_total: Money):
+ super().__init__(bug_id, root_bug_id)
+ self.payees_total = payees_total
+ self.expected_payees_total = expected_payees_total
+
+ def __str__(self):
+ return (f"Total budget assigned to payees (cf_payees_list) doesn't "
+ f"match expected value: bug #{self.bug_id}, calculated total "
+ f"{self.payees_total}, expected value "
+ f"{self.expected_payees_total}")
+
+
+class BudgetGraphNegativePayeeMoney(BudgetGraphError):
+ def __init__(self, bug_id: int, root_bug_id: int, payee_key: str):
+ super().__init__(bug_id, root_bug_id)
+ self.payee_key = payee_key
+
+ def __str__(self):
+ return (f"Budget assigned to payee for task is less than zero: "
+ f"bug #{self.bug_id}, payee {self.payee_key!r}")
+
class BudgetGraph:
nodes: Dict[int, Node]
def __init__(self, bugs: Iterable[Bug]):
self.nodes = {}
for bug in bugs:
- self.nodes[bug.id] = Node(bug)
- for bug_id, node in self.nodes.items():
- # if bug.
- pass
+ self.nodes[bug.id] = Node(self, bug)
+ for node in self.nodes.values():
+ if node.parent is None:
+ continue
+ node.parent.immediate_children.add(node)
+
+ @cached_property
+ def roots(self) -> Set[Node]:
+ roots = set()
+ for node in self.nodes.values():
+ # calling .root also checks for loop errors
+ root = node.root
+ roots.add(root)
+ return roots
+
+ def _get_node_errors(self, root: Node, node: Node,
+ errors: List[BudgetGraphBaseError]):
+ if node.nlnet_milestone is None:
+ if node.budget_including_subtasks != 0 \
+ or node.budget_excluding_subtasks != 0:
+ errors.append(BudgetGraphMoneyWithNoMilestone(
+ node.bug.id, root.bug.id))
+
+ if node.nlnet_milestone != root.nlnet_milestone:
+ errors.append(BudgetGraphMilestoneMismatch(
+ node.bug.id, root.bug.id))
+
+ if node.budget_excluding_subtasks < 0 \
+ or node.budget_including_subtasks < 0:
+ errors.append(BudgetGraphNegativeMoney(
+ node.bug.id, root.bug.id))
+
+ subtasks_total = Money(0)
+ for child in node.immediate_children:
+ subtasks_total += child.fixed_budget_including_subtasks
+
+ payees_total = Money(0)
+ for payment in node.payments.values():
+ if payment.amount < 0:
+ errors.append(BudgetGraphNegativePayeeMoney(
+ node.bug.id, root.bug.id, payment.payee_key))
+ payees_total += payment.amount
+
+ def set_including_from_excluding_and_error():
+ node.fixed_budget_including_subtasks = \
+ node.budget_excluding_subtasks + subtasks_total
+ errors.append(
+ BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+ node.bug.id, root.bug.id,
+ node.fixed_budget_including_subtasks))
+
+ def set_including_from_payees_and_error():
+ node.fixed_budget_including_subtasks = \
+ payees_total + subtasks_total
+ errors.append(
+ BudgetGraphMoneyMismatchForBudgetIncludingSubtasks(
+ node.bug.id, root.bug.id,
+ node.fixed_budget_including_subtasks))
+
+ def set_excluding_from_including_and_error():
+ node.fixed_budget_excluding_subtasks = \
+ node.budget_including_subtasks - subtasks_total
+ errors.append(
+ BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+ node.bug.id, root.bug.id,
+ node.fixed_budget_excluding_subtasks))
+
+ def set_excluding_from_payees_and_error():
+ node.fixed_budget_excluding_subtasks = \
+ payees_total
+ errors.append(
+ BudgetGraphMoneyMismatchForBudgetExcludingSubtasks(
+ node.bug.id, root.bug.id,
+ node.fixed_budget_excluding_subtasks))
+
+ def set_payees_from_including_and_error():
+ fixed_payees_total = \
+ node.budget_including_subtasks - subtasks_total
+ errors.append(BudgetGraphPayeesMoneyMismatch(
+ node.bug.id, root.bug.id, payees_total, fixed_payees_total))
+
+ def set_payees_from_excluding_and_error():
+ fixed_payees_total = \
+ node.budget_excluding_subtasks
+ errors.append(BudgetGraphPayeesMoneyMismatch(
+ node.bug.id, root.bug.id, payees_total, fixed_payees_total))
+
+ payees_matches_including = \
+ node.budget_including_subtasks - subtasks_total == payees_total
+ payees_matches_excluding = \
+ node.budget_excluding_subtasks == payees_total
+ including_matches_excluding = \
+ node.budget_including_subtasks - subtasks_total \
+ == node.budget_excluding_subtasks
+
+ if payees_matches_including \
+ and payees_matches_excluding \
+ and including_matches_excluding:
+ pass # no error
+ elif payees_matches_including:
+ # can't have 2 match without all 3 matching
+ assert not payees_matches_excluding
+ assert not including_matches_excluding
+ if node.budget_including_subtasks == 0 and len(node.payments) == 0:
+ set_including_from_excluding_and_error()
+ else:
+ set_excluding_from_including_and_error()
+ elif payees_matches_excluding:
+ # can't have 2 match without all 3 matching
+ assert not payees_matches_including
+ assert not including_matches_excluding
+ if node.budget_excluding_subtasks == 0 and len(node.payments) == 0:
+ if node.budget_including_subtasks == 0:
+ set_including_from_excluding_and_error()
+ else:
+ set_excluding_from_including_and_error()
+ else:
+ set_including_from_excluding_and_error()
+ elif including_matches_excluding:
+ # can't have 2 match without all 3 matching
+ assert not payees_matches_including
+ assert not payees_matches_excluding
+ if len(node.payments) == 0:
+ pass # no error -- payees is just not set
+ elif node.budget_excluding_subtasks == 0 \
+ and node.budget_including_subtasks == 0:
+ set_excluding_from_payees_and_error()
+ set_including_from_payees_and_error()
+ else:
+ set_payees_from_excluding_and_error()
+ else:
+ # nothing matches
+ if len(node.payments) == 0:
+ # payees unset -- don't need to set payees
+ if node.budget_including_subtasks == 0:
+ set_including_from_excluding_and_error()
+ else:
+ set_excluding_from_including_and_error()
+ elif node.budget_excluding_subtasks == 0 \
+ and node.budget_including_subtasks == 0:
+ set_excluding_from_payees_and_error()
+ set_including_from_payees_and_error()
+ elif node.budget_excluding_subtasks == 0:
+ set_excluding_from_including_and_error()
+ set_payees_from_including_and_error()
+ elif node.budget_including_subtasks == 0:
+ set_including_from_excluding_and_error()
+ set_payees_from_excluding_and_error()
+ else:
+ set_including_from_excluding_and_error()
+ set_payees_from_excluding_and_error()
+
+ def get_errors(self) -> List[BudgetGraphBaseError]:
+ errors = []
+ try:
+ roots = self.roots
+ except BudgetGraphBaseError as e:
+ errors.append(e)
+ return errors
+
+ for root in roots:
+ try:
+ for child in reversed(list(root.children_breadth_first())):
+ try:
+ self._get_node_errors(root, child, errors)
+ except BudgetGraphBaseError as e:
+ errors.append(e)
+ self._get_node_errors(root, root, errors)
+ except BudgetGraphBaseError as e:
+ errors.append(e)
+ return errors
+
+ @cached_property
+ def payee_keys(self) -> Set[str]:
+ retval = set()
+ for node in self.nodes.values():
+ for payee_key in node.payments.keys():
+ retval.add(payee_key)
+ return retval