from datetime import date, time, datetime
try:
from functools import cached_property
-except ImportError:
- # compatability with python < 3.8
- from cached_property import cached_property
+except ImportError: # :nocov:
+ # compatibility with python < 3.8
+ from cached_property import cached_property # :nocov:
class BudgetGraphBaseError(Exception):
fixed_budget_excluding_subtasks: Money
fixed_budget_including_subtasks: Money
milestone_str: Optional[str]
+ is_in_nlnet_mou: bool
def __init__(self, graph: "BudgetGraph", bug: Bug):
self.graph = graph
self.milestone_str = bug.cf_nlnet_milestone
if self.milestone_str == "---":
self.milestone_str = None
+ self.is_in_nlnet_mou = bug.cf_is_in_nlnet_mou2 == "Yes"
@property
def status(self) -> BugStatus:
self._raise_loop_error()
return retval
+ @cached_property
+ def closest_bug_in_mou(self) -> Optional["Node"]:
+ """returns the closest bug that is in a NLNet MoU, searching only in
+ this bug and parents.
+ """
+ if self.is_in_nlnet_mou:
+ return self
+ for parent in self.parents():
+ if parent.is_in_nlnet_mou:
+ return parent
+ return None
+
def children(self) -> Iterable["Node"]:
def visitor(node: Node) -> Iterable[Node]:
for i in node.immediate_children:
tpp.field("fixed_budget_including_subtasks",
self.fixed_budget_including_subtasks)
tpp.field("milestone_str", self.milestone_str)
+ tpp.field("is_in_nlnet_mou", self.is_in_nlnet_mou)
tpp.try_field("milestone", lambda: self.milestone,
BudgetGraphBaseError)
immediate_children = [_NodeSimpleReprWrapper(i)
f"fixed_budget_excluding_subtasks={self.fixed_budget_excluding_subtasks}, "
f"fixed_budget_including_subtasks={self.fixed_budget_including_subtasks}, "
f"milestone_str={self.milestone_str!r}, "
+ f"is_in_nlnet_mou={self.is_in_nlnet_mou!r}, "
f"milestone={milestone}, "
f"immediate_children={immediate_children!r}, "
f"payments={payments!r}, "
f"#{self.milestone_canonical_bug_id}")
+class BudgetGraphRootWithMilestoneNotInMoU(BudgetGraphError):
+ def __init__(self, bug_id: int, milestone: str):
+ super().__init__(bug_id, bug_id)
+ self.milestone = milestone
+
+ def __str__(self):
+ return (f"Bug #{self.bug_id} has no parent bug set and has an "
+ f"assigned milestone {self.milestone!r} but isn't set "
+ f"to be part of the signed MoU")
+
+
+class BudgetGraphInMoUButParentNotInMoU(BudgetGraphError):
+ def __init__(self, bug_id: int, parent_bug_id: int, root_bug_id: int,
+ milestone: str):
+ super().__init__(bug_id, root_bug_id)
+ self.parent_bug_id = parent_bug_id
+ self.milestone = milestone
+
+ def __str__(self):
+ return (f"Bug #{self.bug_id} is set to be part of the signed MoU for "
+ f"milestone {self.milestone!r}, but its parent bug isn't set "
+ f"to be part of the signed MoU")
+
+
+class BudgetGraphInMoUWithoutMilestone(BudgetGraphError):
+ def __str__(self):
+ return (f"Bug #{self.bug_id} is set to be part of a signed MoU but "
+ f"has no milestone set")
+
+
class BudgetGraph:
nodes: Dict[int, Node]
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
- ))
+ if root == node and node.milestone is not None:
+ if 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
+ ))
+ elif not node.is_in_nlnet_mou:
+ errors.append(BudgetGraphRootWithMilestoneNotInMoU(
+ node.bug.id, node.milestone_str))
except BudgetGraphBaseError as e:
errors.append(e)
errors.append(BudgetGraphMilestoneMismatch(
node.bug.id, root.bug.id))
+ if node.is_in_nlnet_mou:
+ if node.milestone_str is None:
+ errors.append(BudgetGraphInMoUWithoutMilestone(node.bug.id,
+ root.bug.id))
+ elif node.parent is not None and \
+ not node.parent.is_in_nlnet_mou:
+ errors.append(BudgetGraphInMoUButParentNotInMoU(
+ node.bug.id, node.parent.bug.id, root.bug.id,
+ node.milestone_str))
+
if node.budget_excluding_subtasks < 0 \
or node.budget_including_subtasks < 0:
errors.append(BudgetGraphNegativeMoney(
BudgetGraphNegativePayeeMoney, BudgetGraphPayeesParseError,
BudgetGraphPayeesMoneyMismatch, BudgetGraphUnknownMilestone,
BudgetGraphIncorrectRootForMilestone,
- BudgetGraphUnknownStatus, BudgetGraphUnknownAssignee)
+ BudgetGraphUnknownStatus, BudgetGraphUnknownAssignee,
+ BudgetGraphRootWithMilestoneNotInMoU, BudgetGraphInMoUButParentNotInMoU,
+ BudgetGraphInMoUWithoutMilestone)
from budget_sync.money import Money
from budget_sync.util import BugStatus
from typing import List, Type
"Total budget assigned to payees (cf_payees_list) doesn't match "
"expected value: bug #1, calculated total 123, expected value 456")
+ def test_budget_graph_root_with_milestone_not_in_mou(self):
+ self.assertEqual(str(
+ BudgetGraphRootWithMilestoneNotInMoU(1, "milestone 1")),
+ "Bug #1 has no parent bug set and has an assigned milestone "
+ "'milestone 1' but isn't set to be part of the signed MoU")
+
+ def test_budget_graph_in_mou_but_parent_not_in_mou(self):
+ self.assertEqual(str(
+ BudgetGraphInMoUButParentNotInMoU(5, 3, 1, "milestone 1")),
+ "Bug #5 is set to be part of the signed MoU for milestone "
+ "'milestone 1', but its parent bug isn't set to be part of "
+ "the signed MoU")
+
+ def test_budget_graph_in_mou_without_milestone(self):
+ self.assertEqual(str(
+ BudgetGraphInMoUWithoutMilestone(1, 5)),
+ "Bug #1 is set to be part of a signed MoU but has no "
+ "milestone set")
+
EXAMPLE_BUG1 = MockBug(bug_id=1,
cf_budget_parent=None,
cf_total_budget="20",
cf_nlnet_milestone="milestone 1",
cf_payees_list="",
- summary="")
+ summary="",
+ cf_is_in_nlnet_mou2="Yes")
EXAMPLE_CHILD_BUG2 = MockBug(bug_id=2,
cf_budget_parent=1,
cf_budget="10",
"budget_excluding_subtasks=10, budget_including_subtasks=20, "
"fixed_budget_excluding_subtasks=10, "
"fixed_budget_including_subtasks=20, milestone_str='milestone "
- "1', milestone=Milestone(config=..., identifier='milestone 1', "
+ "1', is_in_nlnet_mou=True, "
+ "milestone=Milestone(config=..., identifier='milestone 1', "
"canonical_bug_id=1), immediate_children=[#2], payments=[], "
"status=BugStatus.CONFIRMED, assignee=Person<'person3'>, "
"resolved_payments={}, payment_summaries={}), Node(graph=..., "
"budget_including_subtasks=10, "
"fixed_budget_excluding_subtasks=10, "
"fixed_budget_including_subtasks=10, milestone_str='milestone "
- "1', milestone=Milestone(config=..., identifier='milestone 1', "
+ "1', is_in_nlnet_mou=False, "
+ "milestone=Milestone(config=..., identifier='milestone 1', "
"canonical_bug_id=1), immediate_children=[], payments=[], "
"status=BugStatus.CONFIRMED, assignee=Person<'person3'>, "
"resolved_payments={}, payment_summaries={})], roots=[#1], "
"budget_excluding_subtasks=0, budget_including_subtasks=0, "
"fixed_budget_excluding_subtasks=0, "
"fixed_budget_including_subtasks=0, milestone_str=None, "
+ "is_in_nlnet_mou=False, "
"milestone=None, immediate_children=[], payments=[], "
"status=<unknown status: 'blah'>, assignee=<unknown assignee: "
"'unknown@example.com'>, resolved_payments={}, "
"budget_excluding_subtasks=0, budget_including_subtasks=0, "
"fixed_budget_excluding_subtasks=0, "
"fixed_budget_including_subtasks=0, milestone_str=None, "
+ "is_in_nlnet_mou=False, "
"milestone=None, immediate_children=[], "
"payments=[Payment(node=#1, payee=Person<'person1'>, "
"payee_key='person1', amount=5, state=Paid, paid=2020-03-15, "
cf_total_budget=total_budget,
cf_nlnet_milestone="milestone 1",
cf_payees_list=payees_list,
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
MockBug(bug_id=2,
cf_budget_parent=1,
cf_budget=child_budget,
cf_total_budget=child_budget,
cf_nlnet_milestone="milestone 1",
- cf_payees_list="",
summary=""),
], EXAMPLE_CONFIG)
node1: Node = bg.nodes[1]
node1.fixed_budget_including_subtasks),
cf_nlnet_milestone="milestone 1",
cf_payees_list=payees_list,
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
MockBug(bug_id=2,
cf_budget_parent=1,
cf_budget=child_budget,
cf_total_budget="-10",
cf_nlnet_milestone="milestone 1",
cf_payees_list="",
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
], EXAMPLE_CONFIG)
errors = bg.get_errors()
self.assertErrorTypesMatches(errors, [
cf_total_budget="0",
cf_nlnet_milestone="milestone 1",
cf_payees_list="",
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
], EXAMPLE_CONFIG)
errors = bg.get_errors()
self.assertErrorTypesMatches(errors, [
cf_total_budget="-10",
cf_nlnet_milestone="milestone 1",
cf_payees_list="",
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
], EXAMPLE_CONFIG)
errors = bg.get_errors()
self.assertErrorTypesMatches(errors,
cf_total_budget="0",
cf_nlnet_milestone="milestone 1",
cf_payees_list=cf_payees_list,
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
], EXAMPLE_CONFIG)
self.assertErrorTypesMatches(bg.get_errors(), error_types)
self.assertEqual(len(bg.nodes), 1)
cf_total_budget="10",
cf_nlnet_milestone="milestone 1",
cf_payees_list="person1 = 5\nperson2 = 10",
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
], EXAMPLE_CONFIG)
errors = bg.get_errors()
self.assertErrorTypesMatches(errors,
cf_total_budget="0",
cf_nlnet_milestone="milestone 1",
cf_payees_list=cf_payees_list,
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
], EXAMPLE_CONFIG).get_errors()
self.assertErrorTypesMatches(errors,
[BudgetGraphPayeesParseError])
cf_total_budget="10",
cf_nlnet_milestone="milestone 1",
cf_payees_list="""person1 = -10""",
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
], EXAMPLE_CONFIG)
errors = bg.get_errors()
self.assertErrorTypesMatches(errors,
person1 = 5
alias1 = 5
""",
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
], EXAMPLE_CONFIG)
errors = bg.get_errors()
self.assertErrorTypesMatches(errors, [])
cf_total_budget="10",
cf_nlnet_milestone="milestone 2",
cf_payees_list="",
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
], EXAMPLE_CONFIG)
errors = bg.get_errors()
self.assertErrorTypesMatches(errors,
cf_total_budget="0",
cf_nlnet_milestone="milestone 2",
cf_payees_list="",
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
], EXAMPLE_CONFIG)
errors = bg.get_errors()
self.assertErrorTypesMatches(errors, [])
cf_total_budget="10",
cf_nlnet_milestone="milestone 1",
cf_payees_list="person1 = 3\nperson2 = 7",
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
MockBug(bug_id=2,
cf_budget_parent=None,
cf_budget="10",
cf_total_budget="10",
cf_nlnet_milestone="milestone 2",
cf_payees_list="person3 = 5\nperson2 = 5",
- summary=""),
+ summary="",
+ cf_is_in_nlnet_mou2="Yes"),
], EXAMPLE_CONFIG)
self.assertErrorTypesMatches(bg.get_errors(), [])
person1 = EXAMPLE_CONFIG.people["person1"]
self.assertEqual(bg.nodes[1].assignee,
EXAMPLE_CONFIG.people["person2"])
+ def test_closest_bug_in_mou(self):
+ bg = BudgetGraph([
+ MockBug(bug_id=1, cf_nlnet_milestone="milestone 1",
+ cf_is_in_nlnet_mou2="Yes"),
+ MockBug(bug_id=2, cf_budget_parent=1,
+ cf_nlnet_milestone="milestone 1",
+ cf_is_in_nlnet_mou2="Yes"),
+ MockBug(bug_id=3, cf_budget_parent=2,
+ cf_nlnet_milestone="milestone 1",
+ cf_is_in_nlnet_mou2="Yes"),
+ MockBug(bug_id=4, cf_budget_parent=2,
+ cf_nlnet_milestone="milestone 1"),
+ MockBug(bug_id=5, cf_budget_parent=4,
+ cf_nlnet_milestone="milestone 1"),
+ MockBug(bug_id=6),
+ ], EXAMPLE_CONFIG)
+ errors = bg.get_errors()
+ self.assertErrorTypesMatches(errors, [])
+ self.assertEqual(bg.nodes[1].closest_bug_in_mou, bg.nodes[1])
+ self.assertEqual(bg.nodes[2].closest_bug_in_mou, bg.nodes[2])
+ self.assertEqual(bg.nodes[3].closest_bug_in_mou, bg.nodes[3])
+ self.assertEqual(bg.nodes[4].closest_bug_in_mou, bg.nodes[2])
+ self.assertEqual(bg.nodes[5].closest_bug_in_mou, bg.nodes[2])
+ self.assertEqual(bg.nodes[6].closest_bug_in_mou, None)
+
+ def test_root_with_milestone_not_in_mou(self):
+ bg = BudgetGraph([
+ MockBug(bug_id=1, cf_nlnet_milestone="milestone 1"),
+ ], EXAMPLE_CONFIG)
+ errors = bg.get_errors()
+ self.assertErrorTypesMatches(errors,
+ [BudgetGraphRootWithMilestoneNotInMoU])
+ self.assertEqual(errors[0].bug_id, 1)
+ self.assertEqual(errors[0].root_bug_id, 1)
+ self.assertEqual(errors[0].milestone, "milestone 1")
+
+ def test_budget_graph_in_mou_without_milestone(self):
+ bg = BudgetGraph([
+ MockBug(bug_id=1, cf_is_in_nlnet_mou2="Yes"),
+ ], EXAMPLE_CONFIG)
+ errors = bg.get_errors()
+ self.assertErrorTypesMatches(errors,
+ [BudgetGraphInMoUWithoutMilestone])
+ self.assertEqual(errors[0].bug_id, 1)
+ self.assertEqual(errors[0].root_bug_id, 1)
+
+ def test_in_mou_but_parent_not_in_mou(self):
+ bg = BudgetGraph([
+ MockBug(bug_id=1, cf_nlnet_milestone="milestone 1",
+ cf_is_in_nlnet_mou2="Yes"),
+ MockBug(bug_id=2, cf_nlnet_milestone="milestone 1",
+ cf_budget_parent=1),
+ MockBug(bug_id=3, cf_nlnet_milestone="milestone 1",
+ cf_budget_parent=2, cf_is_in_nlnet_mou2="Yes"),
+ ], EXAMPLE_CONFIG)
+ errors = bg.get_errors()
+ self.assertErrorTypesMatches(errors,
+ [BudgetGraphInMoUButParentNotInMoU])
+ self.assertEqual(errors[0].bug_id, 3)
+ self.assertEqual(errors[0].root_bug_id, 1)
+ self.assertEqual(errors[0].parent_bug_id, 2)
+
if __name__ == "__main__":
unittest.main()
import unittest
from budget_sync.config import Config
from budget_sync.test.mock_bug import MockBug
-from budget_sync.test.mock_path import MockPath, DIR
+from budget_sync.test.mock_path import MockFilesystem, MockPath, DIR
from budget_sync.test.test_mock_path import make_filesystem_and_report_if_error
from budget_sync.budget_graph import BudgetGraph
from budget_sync.write_budget_markdown import (
self.assertEqual(markdown_escape("abc * def_k < &k"),
r"abc \* def\_k < &k")
+ def format_files_dict(self, files):
+ assert isinstance(files, dict)
+ files_list: "list[str]" = []
+ for path, contents in files.items():
+ assert isinstance(path, str)
+ if contents is DIR:
+ files_list.append(f" {path!r}: DIR,")
+ continue
+ assert isinstance(contents, bytes)
+ lines: "list[str]" = []
+ for line in contents.splitlines(keepends=True):
+ lines.append(f" {line!r}")
+ if len(lines) == 0:
+ files_list.append(f" {path!r}: b'',")
+ else:
+ lines_str = '\n'.join(lines)
+ files_list.append(f" {path!r}: (\n{lines_str}\n ),")
+ if len(files_list) == 0:
+ return "{}"
+ return "{\n" + "\n".join(files_list) + "\n}"
+
+ def assertFiles(self, expected_files, filesystem: MockFilesystem):
+ files = filesystem.files
+ self.assertIsInstance(expected_files, dict)
+ if files == expected_files:
+ return
+ files_str = self.format_files_dict(files)
+ expected_files_str = self.format_files_dict(expected_files)
+ self.assertEqual(
+ files, expected_files,
+ msg=f"\nfiles:\n{files_str}\nexpected:\n{expected_files_str}")
+
def test(self):
config = Config.from_str(
"""
with make_filesystem_and_report_if_error(self) as filesystem:
output_dir = MockPath("/output_dir/", filesystem=filesystem)
write_budget_markdown(budget_graph, output_dir)
- self.assertEqual({
- "/": DIR,
- "/output_dir": DIR,
- '/output_dir/person1.mdwn': b'<!-- autogenerated by '
- b'budget-sync -->\n\n# Person One (person1)\n\n\n\n#'
- b' Status Tracking\n\n',
- '/output_dir/person2.mdwn': b'<!-- autogenerated by '
- b'budget-sync -->\n\n# Person Two (person2)\n\n\n\n#'
- b' Status Tracking\n\n',
- }, filesystem.files)
+ self.assertFiles({
+ '/': DIR,
+ '/output_dir': DIR,
+ '/output_dir/person1.mdwn': (
+ b'<!-- autogenerated by budget-sync -->\n'
+ b'\n'
+ b'# Person One (person1)\n'
+ b'\n'
+ b'\n'
+ b'\n'
+ b'# Status Tracking\n'
+ b'\n'
+ ),
+ '/output_dir/person2.mdwn': (
+ b'<!-- autogenerated by budget-sync -->\n'
+ b'\n'
+ b'# Person Two (person2)\n'
+ b'\n'
+ b'\n'
+ b'\n'
+ b'# Status Tracking\n'
+ b'\n'
+ ),
+ }, filesystem)
+
+ def test2(self):
+ config = Config.from_str(
+ """
+ bugzilla_url = "https://bugzilla.example.com/"
+ [milestones]
+ "milestone 1" = { canonical_bug_id = 1 }
+ [people."person1"]
+ email = "person1@example.com"
+ full_name = "Person One"
+ [people."person2"]
+ full_name = "Person Two"
+ """)
+ budget_graph = BudgetGraph([
+ MockBug(bug_id=1,
+ cf_budget_parent=None,
+ cf_budget="700",
+ cf_total_budget="1000",
+ cf_nlnet_milestone="milestone 1",
+ cf_payees_list="",
+ summary="",
+ assigned_to="person1@example.com",
+ cf_is_in_nlnet_mou2="Yes"),
+ MockBug(bug_id=2,
+ cf_budget_parent=1,
+ cf_budget="100",
+ cf_total_budget="300",
+ cf_nlnet_milestone="milestone 1",
+ cf_payees_list="person2 = 100",
+ summary="",
+ assigned_to="person1@example.com",
+ cf_is_in_nlnet_mou2="Yes"),
+ MockBug(bug_id=3,
+ cf_budget_parent=2,
+ cf_budget="100",
+ cf_total_budget="200",
+ cf_nlnet_milestone="milestone 1",
+ cf_payees_list="person1 = 100",
+ summary="",
+ assigned_to="person1@example.com"),
+ MockBug(bug_id=4,
+ cf_budget_parent=3,
+ cf_budget="100",
+ cf_total_budget="100",
+ cf_nlnet_milestone="milestone 1",
+ cf_payees_list="person2 = 100",
+ summary="",
+ assigned_to="person1@example.com"),
+ ], config)
+ self.assertEqual([], budget_graph.get_errors())
+ with make_filesystem_and_report_if_error(self) as filesystem:
+ output_dir = MockPath("/output_dir/", filesystem=filesystem)
+ write_budget_markdown(budget_graph, output_dir)
+ self.assertFiles({
+ '/': DIR,
+ '/output_dir': DIR,
+ '/output_dir/person1.mdwn': (
+ b'<!-- autogenerated by budget-sync -->\n'
+ b'\n'
+ b'# Person One (person1)\n'
+ b'\n'
+ b'\n'
+ b'\n'
+ b'# Status Tracking\n'
+ b'\n'
+ b'\n'
+ b'## Payment not yet submitted\n'
+ b'\n'
+ b'\n'
+ b'### milestone 1\n'
+ b'\n'
+ b'* [Bug #3](https://bugzilla.example.com/show_bug.cgi?id=3):\n'
+ b' \n'
+ b' * €100 which is the total amount\n'
+ b' * the closest parent task which is in the MoU is\n'
+ b' [Bug #2](https://bugzilla.example.com/show_bug.cgi?id=2)\n'
+ ),
+ '/output_dir/person2.mdwn': (
+ b'<!-- autogenerated by budget-sync -->\n'
+ b'\n'
+ b'# Person Two (person2)\n'
+ b'\n'
+ b'\n'
+ b'\n'
+ b'# Status Tracking\n'
+ b'\n'
+ b'\n'
+ b'## Payment not yet submitted\n'
+ b'\n'
+ b'\n'
+ b'### milestone 1\n'
+ b'\n'
+ b'* [Bug #2](https://bugzilla.example.com/show_bug.cgi?id=2):\n'
+ b' \n'
+ b' * €100 which is the total amount\n'
+ b' * this task is in the MoU\n'
+ b'* [Bug #4](https://bugzilla.example.com/show_bug.cgi?id=4):\n'
+ b' \n'
+ b' * €100 which is the total amount\n'
+ b' * the closest parent task which is in the MoU is\n'
+ b' [Bug #2](https://bugzilla.example.com/show_bug.cgi?id=2)\n'
+ ),
+ }, filesystem)
# TODO: add more test cases