Repository: fniessink/next-action

Issue:
Allow for after:<id> key/value pairs to specify dependencies

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index df99d47..043401f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
 
 ### Added
 
-- Next to `p:parent_task` it's also possible to use `before:parent_task` for better readability.
+- Next to `p:parent_task` it's also possible to use `before:parent_task` to specifiy task dependencies.
+- In addition to `before:other_task`, it's also possible to use `after:other_task` to specify task dependencies.
 
 ## [1.2.0] - 2018-06-16
 
diff --git a/README.md b/README.md
index d58af14..a8d5551 100644
--- a/README.md
+++ b/README.md
@@ -214,33 +214,34 @@ Or show all next actions, e.g. for a specific context:
 $ next-action --all @store
 (B) Buy paint to +PaintHouse @store @weekend
 (G) Buy wood for new +DogHouse @store
-Buy groceries @store +DinnerParty p:meal
+Buy groceries @store +DinnerParty before:meal
 ```
 
 Note again that completed tasks, tasks with a future creation or threshold date, and blocked tasks are never shown since they can't be a next action.
 
 ### Task dependencies
 
-*Next-action* takes task dependencies into account when determining the next actions. For example, that cooking a meal depends on buying groceries can be specified in the todo.txt file as follows:
+*Next-action* takes task dependencies into account when determining the next actions. For example, that cooking a meal depends on buying groceries and that doing the dishes comes after cooking the meal can be specified as follows:
 
 ```text
-Buy groceries @store +DinnerParty p:meal
+Buy groceries @store +DinnerParty before:meal
 Cook meal @home +DinnerParty id:meal
+Do the dishes @home +DinnerParty after:meal
 ```
 
-`Buy groceries` has `Cook meal` as its `p`-parent task. This means that buying groceries blocks cooking the meal; cooking can't be done until buying the groceries has been completed:
+This means that buying groceries blocks cooking the meal; cooking, and thus doing the dishes as well, can't be done until buying the groceries has been completed:
 
 ```console
 $ next-action --all +DinnerParty
-Buy groceries @store +DinnerParty p:meal
+Buy groceries @store +DinnerParty before:meal
 ```
 
 Notes:
 
 - The ids can be any string without whitespace.
-- Instead of `p` you can also use `before` for better readability, e.g. `Do this before:that`.
-- A parent task can have multiple child tasks, meaning that the parent task remains blocked until all children are completed.
-- A child task can block multiple parents by repeating the parent, e.g. `Buy groceries before:cooking and before:invites`.
+- Instead of `before` you can also use `p` (for "parent") because some other tools that work with *Todo.txt* files use that.
+- A task can come before multiple other tasks by repeating the before key, e.g. `Buy groceries before:cooking and before:sending_invites`.
+- A task can come after multiple other tasks by repeating the after key, e.g. `Eat meal after:cooking and after:setting_the_table`.
 
 ### Styling the output
 
@@ -422,7 +423,7 @@ To run the unit tests:
 $ python -m unittest
 .............................................................................................................................................................................................................................
 ----------------------------------------------------------------------
-Ran 221 tests in 1.758s
+Ran 221 tests in 1.637s
 
 OK
 ```
@@ -435,7 +436,7 @@ To create the unit test coverage report run the unit tests under coverage with:
 $ coverage run --branch -m unittest
 .............................................................................................................................................................................................................................
 ----------------------------------------------------------------------
-Ran 221 tests in 4.562s
+Ran 221 tests in 2.085s
 
 OK
 ```
@@ -447,7 +448,7 @@ $ coverage report --fail-under=100 --omit=".venv/*" --skip-covered
 Name    Stmts   Miss Branch BrPart  Cover
 -----------------------------------------
 -----------------------------------------
-TOTAL    1175      0    150      0   100%
+TOTAL    1188      0    150      0   100%
 
 25 files skipped due to complete coverage.
 ```
diff --git a/docs/todo.txt b/docs/todo.txt
index 0d9176c..36c7342 100644
--- a/docs/todo.txt
+++ b/docs/todo.txt
@@ -7,8 +7,9 @@ Borrow ladder from the neighbors +PaintHouse @home
 Buy flowers due:2018-02-14
 (L) Pay June invoice @home due:2018-06-28
 (K) Pay July invoice @home due:2018-07-28
-Buy groceries @store +DinnerParty p:meal
+Buy groceries @store +DinnerParty before:meal
 Cook meal @home +DinnerParty id:meal
+Do the dishes @home +DinnerParty after:meal
 x This is a completed task
 9999-01-01 Start preparing for five-digit years
 Start preparing for emigration to Mars t:3000-01-01
diff --git a/next_action/todotxt/task.py b/next_action/todotxt/task.py
index 94afcd2..63eb8c9 100644
--- a/next_action/todotxt/task.py
+++ b/next_action/todotxt/task.py
@@ -85,20 +85,21 @@ class Task(object):
 
     def is_blocked(self, tasks: Iterable["Task"]) -> bool:
         """ Return whether a task is blocked, i.e. whether it has uncompleted child tasks. """
-        return any([task for task in tasks if not task.is_completed() and self.task_id() in task.parent_ids()])
+        return any([task for task in tasks if not task.is_completed() and
+                    (self.task_id() in task.parent_ids() or task.task_id() in self.child_ids())])
 
-    def task_id(self) -> str:
-        """ Return the id of the task. """
-        match = re.search(r"\bid:(\S+)\b", self.text)
-        return match.group(1) if match else ""
+    def child_ids(self) -> Set[str]:
+        """ Return the ids of the child tasks. """
+        return {match.group(1) for match in re.finditer(r"\bafter:(\S+)\b", self.text)}
 
     def parent_ids(self) -> Set[str]:
         """ Return the ids of the parent tasks. """
         return {match.group(2) for match in re.finditer(r"\b(p|before):(\S+)\b", self.text)}
 
-    def parents(self, tasks: Iterable["Task"]) -> Iterable["Task"]:
-        """ Return the parent tasks. """
-        return [task for task in tasks if task.task_id() in self.parent_ids()]
+    def task_id(self) -> str:
+        """ Return the id of the task. """
+        match = re.search(r"\bid:(\S+)\b", self.text)
+        return match.group(1) if match else ""
 
     def __prefixed_items(self, prefix: str) -> Set[str]:
         """ Return the prefixed items in the task. """


Test patch:
diff --git a/tests/unittests/todotxt/test_task.py b/tests/unittests/todotxt/test_task.py
index 670ed2a..38ab3eb 100644
--- a/tests/unittests/todotxt/test_task.py
+++ b/tests/unittests/todotxt/test_task.py
@@ -251,90 +251,103 @@ class ActionableTest(unittest.TestCase):
         self.assertFalse(todotxt.Task("x 2018-01-01 2018-01-01 Todo").is_actionable())
 
 
-class ParentTest(unittest.TestCase):
-    """ Unit tests for parent/child relations. """
-
-    parent_keys = strategies.sampled_from(["p", "before"])
+class DependenciesTest(unittest.TestCase):
+    """ Unit tests for dependency relations. """
+
+    before_keys = strategies.sampled_from(["p", "before"])
+
+    @given(strategies.sampled_from(["Todo", "Todo id:", "Todo id:id", "Todo p:", "Todo p:id",
+                                    "Todo before:", "Todo before:id", "Todo after:", "Todo after:id"]))
+    def test_task_without_dependencies(self, todo_text):
+        """ Test that a task without dependencies is not blocked. """
+        task = todotxt.Task(todo_text)
+        self.assertFalse(task.is_blocked([]))
+        self.assertFalse(task.is_blocked([task, todotxt.Task("Another task")]))
+
+    @given(before_keys)
+    def test_one_before_another(self, before_key):
+        """ Test that a task specified to be done before another task blocks the latter. """
+        self.assertTrue(todotxt.Task("After id:1").is_blocked([todotxt.Task("Before {0}:1".format(before_key))]))
+
+    def test_one_after_another(self):
+        """ Test that a task specified to be done after another task blocks the first. """
+        self.assertTrue(todotxt.Task("After after:1").is_blocked([todotxt.Task("Before id:1")]))
+
+    @given(before_keys)
+    def test_one_before_two(self, before_key):
+        """ Test that a task that is specified to be done before two other tasks blocks both tasks. """
+        before = todotxt.Task("Before {0}:1 {0}:2".format(before_key))
+        self.assertTrue(todotxt.Task("After id:1").is_blocked([before]))
+        self.assertTrue(todotxt.Task("After id:2").is_blocked([before]))
+
+    def test_one_after_two(self):
+        """ Test that a task that is specified to be done after two other tasks is blocked by both. """
+        before1 = todotxt.Task("Before 1 id:1")
+        before2 = todotxt.Task("Before 2 id:2")
+        after = todotxt.Task("After after:1 after:2")
+        self.assertTrue(after.is_blocked([before1, before2]))
+        self.assertTrue(after.is_blocked([before1]))
+        self.assertTrue(after.is_blocked([before2]))
+
+    @given(before_keys)
+    def test_two_before_one(self, before_key):
+        """ Test that a task that is specified to be done after two other tasks is blocked by both. """
+        before1 = todotxt.Task("Before 1 {0}:1".format(before_key))
+        before2 = todotxt.Task("Before 2 {0}:1".format(before_key))
+        after = todotxt.Task("After id:1")
+        self.assertTrue(after.is_blocked([before1, before2]))
+        self.assertTrue(after.is_blocked([before1]))
+        self.assertTrue(after.is_blocked([before2]))
+
+    def test_two_after_one(self):
+        """ Test that two tasks that are specified to be done after one other task are both blocked. """
+        before = todotxt.Task("Before id:before")
+        after1 = todotxt.Task("After 1 after:before")
+        after2 = todotxt.Task("After 2 after:before")
+        self.assertTrue(after1.is_blocked([before]))
+        self.assertTrue(after2.is_blocked([before]))
+
+    @given(before_keys)
+    def test_completed_before_task(self, before_key):
+        """ Test that a task is not blocked by a completed task. """
+        after = todotxt.Task("After id:1")
+        before = todotxt.Task("Before {0}:1".format(before_key))
+        completed_before = todotxt.Task("x Before {0}:1".format(before_key))
+        self.assertFalse(after.is_blocked([completed_before]))
+        self.assertTrue(after.is_blocked([completed_before, before]))
+
+    def test_after_completed_task(self):
+        """ Test that a task is not blocked if it is specified to be done after a completed task. """
+        after = todotxt.Task("After after:before after:completed_before")
+        before = todotxt.Task("Before id:before")
+        completed_before = todotxt.Task("x Before id:completed_before")
+        self.assertFalse(after.is_blocked([completed_before]))
+        self.assertTrue(after.is_blocked([completed_before, before]))
+
+    @given(before_keys)
+    def test_self_before_self(self, before_key):
+        """ Test that a task can be blocked by itself. This doesn't make sense, but we're not in the business
+            of validating todo.txt files. """
+        task = todotxt.Task("Todo id:1 {0}:1".format(before_key))
+        self.assertTrue(task.is_blocked([task]))
 
-    def test_default(self):
-        """ Test that a default task has no parents. """
-        default_task = todotxt.Task("Todo")
-        self.assertEqual(set(), default_task.parent_ids())
-        self.assertEqual("", default_task.task_id())
-        self.assertFalse(default_task.is_blocked([]))
-        self.assertFalse(default_task.is_blocked([default_task]))
-
-    def test_missing_id(self):
-        """ Test id key without ids. """
-        self.assertEqual("", todotxt.Task("Todo id:").task_id())
-
-    @given(parent_keys)
-    def test_missing_values(self, parent_key):
-        """ Test parent key without id. """
-        self.assertEqual(set(), todotxt.Task("Todo {0}:".format(parent_key)).parent_ids())
-
-    @given(parent_keys)
-    def test_one_parent(self, parent_key):
-        """ Test that a task with a parent id return the correct id. """
-        self.assertEqual({"1"}, todotxt.Task("Todo {0}:1".format(parent_key)).parent_ids())
-
-    @given(parent_keys)
-    def test_two_parents(self, parent_key):
-        """ Test that a task with two parent ids return all ids. """
-        self.assertEqual({"1", "123a"}, todotxt.Task("Todo {0}:1 {0}:123a".format(parent_key)).parent_ids())
-
-    def test_task_id(self):
-        """ Test a task id. """
-        self.assertEqual("foo", todotxt.Task("Todo id:foo").task_id())
-
-    @given(parent_keys)
-    def test_get_parent(self, parent_key):
-        """ Test getting a task's parent. """
-        parent = todotxt.Task("Parent id:1")
-        child = todotxt.Task("Child {0}:1".format(parent_key))
-        self.assertEqual([parent], child.parents([child, parent]))
-
-    @given(parent_keys)
-    def test_get_multiple_parents(self, parent_key):
-        """ Test getting a task's mutiple parents. """
-        parent1 = todotxt.Task("Parent 1 id:1")
-        parent2 = todotxt.Task("Parent 2 id:2")
-        child = todotxt.Task("Child {0}:1 {0}:2".format(parent_key))
-        self.assertEqual([parent1, parent2], child.parents([child, parent1, parent2]))
-
-    @given(parent_keys)
-    def test_is_blocked(self, parent_key):
-        """ Test that a task with children is blocked. """
-        parent = todotxt.Task("Parent id:1")
-        child = todotxt.Task("Child {0}:1".format(parent_key))
-        self.assertTrue(parent.is_blocked([child, parent]))
-
-    @given(parent_keys)
-    def test_completed_child(self, parent_key):
-        """ Test that a task with completed children only is not blocked. """
-        parent = todotxt.Task("Parent id:1")
-        child = todotxt.Task("x Child {0}:1".format(parent_key))
-        self.assertFalse(parent.is_blocked([child, parent]))
-
-    @given(parent_keys)
-    def test_is_blocked_by_mix(self, parent_key):
-        """ Test that a task with completed children and uncompleted children is blocked. """
-        parent = todotxt.Task("Parent id:1")
-        child1 = todotxt.Task("Child 1 {0}:1".format(parent_key))
-        child2 = todotxt.Task("x Child 2 {0}:1".format(parent_key))
-        self.assertTrue(parent.is_blocked([child1, child2, parent]))
-
-    @given(parent_keys)
-    def test_is_blocked_by_self(self, parent_key):
+    def test_self_after_self(self):
         """ Test that a task can be blocked by itself. This doesn't make sense, but we're not in the business
             of validating todo.txt files. """
-        parent = todotxt.Task("Parent id:1 {0}:1".format(parent_key))
-        self.assertTrue(parent.is_blocked([parent]))
+        task = todotxt.Task("Todo id:1 after:1")
+        self.assertTrue(task.is_blocked([task]))
+
+    @given(before_keys)
+    def test_self_before_self_indirect(self, before_key):
+        """ Test that a task can be blocked by a second task that is blocked by the first task. This doesn't make sense,
+            but we're not in the business of validating todo.txt files. """
+        task1 = todotxt.Task("Task 1 id:1 {0}:2".format(before_key))
+        task2 = todotxt.Task("Task 2 id:2 {0}:1".format(before_key))
+        self.assertTrue(task1.is_blocked([task1, task2]))
 
-    @given(parent_keys)
-    def test_block_circle(self, parent_key):
-        """ Test that a task can be blocked by its child who is blocked by the task. This doesn't make sense,
+    def test_self_after_self_indirect(self):
+        """ Test that a task can be blocked by a second task that is blocked by the first task. This doesn't make sense,
             but we're not in the business of validating todo.txt files. """
-        task1 = todotxt.Task("Task 1 id:1 {0}:2".format(parent_key))
-        task2 = todotxt.Task("Task 2 id:2 {0}:1".format(parent_key))
+        task1 = todotxt.Task("Task 1 id:1 after:2")
+        task2 = todotxt.Task("Task 2 id:2 after:1")
         self.assertTrue(task1.is_blocked([task1, task2]))