from bugzilla.bug import Bug
-from bugzilla import Bugzilla
from typing import Set, Dict, Iterable, Optional, List, Union, Any
-from budget_sync.util import all_bugs
+from budget_sync.util import BugStatus
from budget_sync.money import Money
+from budget_sync.config import Config, Person, Milestone
from functools import cached_property
import toml
import sys
import enum
from collections import deque
from datetime import date, time, datetime
-
+from collections import OrderedDict
class BudgetGraphBaseError(Exception):
pass
self.msg = msg
def __str__(self):
- return f"Failed to parse cf_payees_list field of bug #{self.bug_id}: {self.msg}"
+ return f"Failed to parse cf_payees_list field of " \
+ f"bug #{self.bug_id}: {self.msg}"
+
+
+class BudgetGraphUnknownAssignee(BudgetGraphParseError):
+ def __init__(self, bug_id: int, assignee: str):
+ super().__init__(bug_id)
+ self.assignee = assignee
+
+ def __str__(self):
+ return f"Bug #{self.bug_id} is assigned to an unknown person: " \
+ f"{self.assignee!r}"
class BudgetGraphLoopError(BudgetGraphBaseError):
self.paid = paid
self.submitted = submitted
+ @cached_property
+ def payee(self) -> Person:
+ try:
+ return self.node.graph.config.all_names[self.payee_key]
+ except KeyError:
+ msg = f"unknown payee name: {self.payee_key!r} is not the name " \
+ f"or an alias of any known person"
+ raise BudgetGraphPayeesParseError(self.node.bug.id, msg) \
+ .with_traceback(sys.exc_info()[2])
+
@property
def state(self):
if self.paid is not None:
return PayeeState.NotYetSubmitted
@staticmethod
- def from_toml(node: "Node", payee_key: str, toml_value: Any):
+ def _from_toml(node: "Node", payee_key: str, toml_value: Any) -> "Payment":
paid = None
submitted = None
known_keys = ("paid", "submitted", "amount")
paid=paid, submitted=submitted)
def __repr__(self):
+ try:
+ payee = f"Person<{self.payee.identifier!r}>"
+ except BudgetGraphBaseError:
+ payee = "<unknown person>"
return (f"Payment(node={_NodeSimpleReprWrapper(self.node)}, "
+ f"payee={payee}, "
f"payee_key={self.payee_key!r}, "
f"amount={self.amount}, "
f"state={self.state.name}, "
f"submitted={str(self.submitted)})")
+class BudgetGraphUnknownMilestone(BudgetGraphParseError):
+ def __init__(self, bug_id: int, milestone_str: str):
+ super().__init__(bug_id)
+ self.milestone_str = milestone_str
+
+ def __str__(self):
+ return f"failed to parse cf_nlnet_milestone field of bug " \
+ f"#{self.bug_id}: unknown milestone: {self.milestone_str!r}"
+
+
+class BudgetGraphUnknownStatus(BudgetGraphParseError):
+ def __init__(self, bug_id: int, status_str: str):
+ super().__init__(bug_id)
+ self.status_str = status_str
+
+ def __str__(self):
+ return f"failed to parse status field of bug " \
+ f"#{self.bug_id}: unknown status: {self.status_str!r}"
+
+
class Node:
graph: "BudgetGraph"
bug: Bug
budget_including_subtasks: Money
fixed_budget_excluding_subtasks: Money
fixed_budget_including_subtasks: Money
- nlnet_milestone: Optional[str]
+ milestone_str: Optional[str]
def __init__(self, graph: "BudgetGraph", bug: Bug):
self.graph = graph
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
+ self.milestone_str = bug.cf_nlnet_milestone
+ if self.milestone_str == "---":
+ self.milestone_str = None
+
+ @property
+ def status(self) -> BugStatus:
+ try:
+ return BugStatus.cast(self.bug.status)
+ except ValueError:
+ new_err = BudgetGraphUnknownStatus(self.bug.id, self.bug.status)
+ raise new_err.with_traceback(sys.exc_info()[2])
+
+ @cached_property
+ def assignee(self) -> Person:
+ try:
+ return self.graph.config.all_names[self.bug.assigned_to]
+ except KeyError:
+ raise BudgetGraphUnknownAssignee(self.bug.id,
+ self.bug.assigned_to) \
+ .with_traceback(sys.exc_info()[2])
+
+ @cached_property
+ def bug_url(self) -> str:
+ return f"{self.graph.config.bugzilla_url_stripped}/show_bug.cgi?" \
+ f"id={self.bug.id}"
+
+ @cached_property
+ def milestone(self) -> Optional[Milestone]:
+ try:
+ if self.milestone_str is not None:
+ return self.graph.config.milestones[self.milestone_str]
+ return None
+ except KeyError:
+ new_err = BudgetGraphUnknownMilestone(
+ self.bug.id, self.milestone_str)
+ raise new_err.with_traceback(sys.exc_info()[2])
@cached_property
def payments(self) -> Dict[str, Payment]:
new_err = BudgetGraphPayeesParseError(
self.bug.id, f"TOML parse error: {e}")
raise new_err.with_traceback(sys.exc_info()[2])
- retval = {}
+ retval = OrderedDict()
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)
+ retval[key] = Payment._from_toml(self, key, value)
return retval
@property
root = _NodeSimpleReprWrapper(self.root)
except BudgetGraphLoopError:
root = "<loop error>"
+ try:
+ milestone = repr(self.milestone)
+ except BudgetGraphBaseError:
+ milestone = "<unknown milestone>"
+ try:
+ status = repr(self.status)
+ except BudgetGraphBaseError:
+ status = f"<unknown status: {self.bug.status!r}>"
+ try:
+ assignee = f"Person<{self.assignee.identifier!r}>"
+ except BudgetGraphBaseError:
+ assignee = f"<unknown assignee: {self.bug.assigned_to!r}>"
immediate_children = []
for i in self.immediate_children:
immediate_children.append(_NodeSimpleReprWrapper(i))
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"milestone_str={self.milestone_str!r}, "
+ f"milestone={milestone}, "
f"immediate_children={immediate_children!r}, "
- f"payments={payments!r}")
+ f"payments={payments!r}, "
+ f"status={status}, "
+ f"assignee={assignee})")
class BudgetGraphError(BudgetGraphBaseError):
f"bug #{self.bug_id}, payee {self.payee_key!r}")
+class BudgetGraphDuplicatePayeesForTask(BudgetGraphError):
+ def __init__(self, bug_id: int, root_bug_id: int, payee1_key: str, payee2_key: str):
+ super().__init__(bug_id, root_bug_id)
+ self.payee1_key = payee1_key
+ self.payee2_key = payee2_key
+
+ def __str__(self):
+ return (f"Budget assigned to multiple aliases of the same person in "
+ f"a single task: bug #{self.bug_id}, budget assigned to both "
+ f"{self.payee1_key!r} and {self.payee2_key!r}")
+
+
+class BudgetGraphIncorrectRootForMilestone(BudgetGraphError):
+ def __init__(self, bug_id: int, milestone: str, milestone_canonical_bug_id: int):
+ super().__init__(bug_id, bug_id)
+ self.milestone = milestone
+ self.milestone_canonical_bug_id = milestone_canonical_bug_id
+
+ def __str__(self):
+ return (f"Bug #{self.bug_id} is not the canonical root bug for "
+ f"assigned milestone {self.milestone!r} but has no parent "
+ f"bug set: the milestone's canonical root bug is "
+ f"#{self.milestone_canonical_bug_id}")
+
+
class BudgetGraph:
nodes: Dict[int, Node]
- def __init__(self, bugs: Iterable[Bug]):
- self.nodes = {}
+ def __init__(self, bugs: Iterable[Bug], config: Config):
+ self.nodes = OrderedDict()
+ self.config = config
for bug in bugs:
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)
+ self.milestone_payments = OrderedDict()
+ # useful debug prints
+ #for bug in bugs:
+ # node = self.nodes[bug.id]
+ # print ("bug added", bug.id, node, node.parent.immediate_children)
@cached_property
def roots(self) -> Set[Node]:
def _get_node_errors(self, root: Node, node: Node,
errors: List[BudgetGraphBaseError]):
- if node.nlnet_milestone is None:
+ if node.milestone_str 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:
+ try:
+ # check for milestone errors
+ node.milestone
+ if root == node and node.milestone is not None \
+ and node.milestone.canonical_bug_id != node.bug.id:
+ if node.budget_including_subtasks != 0 \
+ or node.budget_excluding_subtasks != 0:
+ errors.append(BudgetGraphIncorrectRootForMilestone(
+ node.bug.id, node.milestone.identifier,
+ node.milestone.canonical_bug_id
+ ))
+ except BudgetGraphBaseError as e:
+ errors.append(e)
+
+ try:
+ # check for status errors
+ node.status
+ except BudgetGraphBaseError as e:
+ errors.append(e)
+
+ try:
+ # check for assignee errors
+ node.assignee
+ except BudgetGraphBaseError as e:
+ errors.append(e)
+
+ if node.milestone_str != root.milestone_str:
errors.append(BudgetGraphMilestoneMismatch(
node.bug.id, root.bug.id))
errors.append(BudgetGraphNegativeMoney(
node.bug.id, root.bug.id))
+ childlist = []
subtasks_total = Money(0)
for child in node.immediate_children:
subtasks_total += child.fixed_budget_including_subtasks
+ childlist.append(child.bug.id)
+ # useful debug prints
+ #print ("subtask total", node.bug.id, root.bug.id, subtasks_total,
+ # childlist)
payees_total = Money(0)
+ payee_payments = OrderedDict()
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
+ try:
+ # check for payee errors
+ payment.payee
+ previous_payment = payee_payments.get(payment.payee)
+ if previous_payment is not None:
+ # NOT AN ERROR
+ print ("NOT AN ERROR", BudgetGraphDuplicatePayeesForTask(
+ node.bug.id, root.bug.id,
+ previous_payment[-1].payee_key, payment.payee_key))
+ payee_payments[payment.payee].append(payment)
+ else:
+ payee_payments[payment.payee] = [payment]
+ except BudgetGraphBaseError as e:
+ errors.append(e)
def set_including_from_excluding_and_error():
node.fixed_budget_including_subtasks = \
return errors
@cached_property
- def payee_keys(self) -> Set[str]:
- retval = set()
+ def assigned_nodes(self) -> Dict[Person, List[Node]]:
+ retval = {person: [] for person in self.config.people.values()}
+ for node in self.nodes.values():
+ retval[node.assignee].append(node)
+ return retval
+
+ @cached_property
+ def payments(self) -> Dict[Person, Dict[Milestone, List[Payment]]]:
+ retval = OrderedDict()
+ for person in self.config.people.values():
+ milestone_payments = OrderedDict()
+ for milestone in self.config.milestones.values():
+ milestone_payments[milestone] = [] # per-person payments
+ self.milestone_payments[milestone] = [] # global payments
+ retval[person] = milestone_payments
for node in self.nodes.values():
- for payee_key in node.payments.keys():
- retval.add(payee_key)
+ if node.milestone is not None:
+ for payment in node.payments.values():
+ retval[payment.payee][node.milestone].append(payment)
+ # add to global payments as well
+ self.milestone_payments[node.milestone].append(payment)
return retval
+
+ def __repr__(self):
+ nodes = [*self.nodes.values()]
+ try:
+ roots = [_NodeSimpleReprWrapper(i) for i in self.roots]
+ roots.sort()
+ roots_str = repr(roots)
+ except BudgetGraphBaseError:
+ roots_str = "<failed>"
+ return f"BudgetGraph{{nodes={nodes!r}, roots={roots}}}"