Repository: fniessink/next-action

Issue:
Allow for excluding a project

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 347f866..471983a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
 ### Added
 
 - Allow for excluding contexts from which the next action is selected: `next-action -@office`. Closes #20.
+- Allow for excluding projects from which the next action is selected: `next-action -+DogHouse`. Closes #32.
 
 ## [0.1.0] - 2018-05-13
 
diff --git a/README.md b/README.md
index 4a84ea5..ce06c3f 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ Don't know what *Todo.txt* is? See <https://github.com/todotxt/todo.txt> for the
 $ next-action --help
 usage: next-action [-h] [--version] [-f FILE] [-n N | -a]
                    [@CONTEXT [@CONTEXT ...]] [+PROJECT [+PROJECT ...]] [-@CONTEXT [-@CONTEXT ...]]
+                   [-+PROJECT [-+PROJECT ...]]
 
 Show the next action in your todo.txt
 
@@ -36,6 +37,7 @@ positional arguments:
   @CONTEXT              show the next action in the specified contexts (default: None)
   +PROJECT              show the next action for the specified projects (default: None)
   -@CONTEXT             exclude actions in the specified contexts (default: None)
+  -+PROJECT             exclude actions for the specified projects (default: None)
 
 optional arguments:
   -h, --help            show this help message and exit
@@ -43,6 +45,7 @@ optional arguments:
   -f FILE, --file FILE  todo.txt file to read; argument can be repeated (default: ['todo.txt'])
   -n N, --number N      number of next actions to show (default: 1)
   -a, --all             show all next actions (default: False)
+
 ```
 
 Assuming your todo.txt file is in the current folder, running *Next-action* without arguments will show the next action you should do. Given this [todo.txt](todo.txt), calling mom would be the next action:
@@ -85,6 +88,13 @@ $ next-action +PaintHouse -@store
 Borrow ladder from the neighbors +PaintHouse @home
 ```
 
+And of course, in a similar vein, projects can be excluded:
+
+```console
+$ next-action -+PaintHouse @store
+(G) Buy wood for new +DogHouse @store
+```
+
 ### Extend next actions
 
 To show more than one next action, supply the number you think you can handle:
diff --git a/next_action/arguments.py b/next_action/arguments.py
index da9407a..8e8e96b 100644
--- a/next_action/arguments.py
+++ b/next_action/arguments.py
@@ -4,7 +4,7 @@ import argparse
 import os
 import shutil
 import sys
-from typing import Any, Sequence, Union
+from typing import Any, List, Sequence, Union
 
 import next_action
 
@@ -61,19 +61,10 @@ def parse_arguments() -> argparse.Namespace:
                         nargs="*", type=str, default=None, action=ContextProjectAction)
     parser.add_argument("excluded_contexts", metavar="-@CONTEXT", help="exclude actions in the specified contexts",
                         nargs="*", type=str, default=None)
+    parser.add_argument("excluded_projects", metavar="-+PROJECT", help="exclude actions for the specified projects",
+                        nargs="*", type=str, default=None)
     namespace, remaining = parser.parse_known_args()
-    # Get the excluded contexts from the remaining arguments
-    excluded_contexts = []
-    for argument in remaining:
-        if is_valid_prefixed_arg("context", "-@", argument, parser):
-            context = argument[len("-@"):]
-            if context in namespace.contexts:
-                parser.error("context {0} is both included and excluded".format(context))
-            else:
-                excluded_contexts.append(context)
-        else:
-            parser.error("unrecognized arguments: {0}".format(argument))
-    namespace.excluded_contexts = excluded_contexts
+    parse_remaining_args(parser, remaining, namespace)
     # Work around the issue that the "append" action doesn't overwrite defaults.
     # See https://bugs.python.org/issue16399.
     if default_filenames != namespace.filenames:
@@ -85,3 +76,26 @@ def parse_arguments() -> argparse.Namespace:
     if namespace.all:
         namespace.number = sys.maxsize
     return namespace
+
+
+def parse_remaining_args(parser: argparse.ArgumentParser, remaining: List[str], namespace: argparse.Namespace) -> None:
+    """ Parse the remaining command line arguments. """
+    excluded_contexts = []
+    excluded_projects = []
+    for argument in remaining:
+        if is_valid_prefixed_arg("context", "-@", argument, parser):
+            context = argument[len("-@"):]
+            if context in namespace.contexts:
+                parser.error("context {0} is both included and excluded".format(context))
+            else:
+                excluded_contexts.append(context)
+        elif is_valid_prefixed_arg("project", "-+", argument, parser):
+            project = argument[len("-+"):]
+            if project in namespace.projects:
+                parser.error("project {0} is both included and excluded".format(project))
+            else:
+                excluded_projects.append(project)
+        else:
+            parser.error("unrecognized arguments: {0}".format(argument))
+    namespace.excluded_contexts = excluded_contexts
+    namespace.excluded_projects = excluded_projects
diff --git a/next_action/cli.py b/next_action/cli.py
index 58cd0ed..b099f6f 100644
--- a/next_action/cli.py
+++ b/next_action/cli.py
@@ -23,5 +23,7 @@ def next_action() -> None:
             return
         with todotxt_file:
             tasks.extend([Task(line.strip()) for line in todotxt_file.readlines() if line.strip()])
-    actions = next_actions(tasks, set(arguments.contexts), set(arguments.projects), set(arguments.excluded_contexts))
+    actions = next_actions(tasks,
+                           set(arguments.contexts), set(arguments.projects),
+                           set(arguments.excluded_contexts), set(arguments.excluded_projects))
     print("\n".join(action.text for action in actions[:arguments.number]) if actions else "Nothing to do!")
diff --git a/next_action/pick_action.py b/next_action/pick_action.py
index 9207d54..2542cfb 100644
--- a/next_action/pick_action.py
+++ b/next_action/pick_action.py
@@ -12,13 +12,16 @@ def sort_key(task: Task) -> Tuple[str, datetime.date, datetime.date]:
 
 
 def next_actions(tasks: Sequence[Task], contexts: Set[str] = None, projects: Set[str] = None,
-                 excluded_contexts: Set[str] = None) -> Sequence[Task]:
+                 excluded_contexts: Set[str] = None, excluded_projects: Set[str] = None) -> Sequence[Task]:
     """ Return the next action(s) from the collection of tasks. """
     # First, get the potential next actions by filtering out completed tasks and tasks with a future creation date
     actionable_tasks = [task for task in tasks if task.is_actionable()]
     # Then, exclude tasks that have an excluded context
     eligible_tasks = filter(lambda task: not excluded_contexts & task.contexts() if excluded_contexts else True,
                             actionable_tasks)
+    # And, tasks that have an excluded project
+    eligible_tasks = filter(lambda task: not excluded_projects & task.projects() if excluded_projects else True,
+                            eligible_tasks)
     # Then, select the tasks that belong to all given contexts, if any
     tasks_in_context = filter(lambda task: contexts <= task.contexts() if contexts else True, eligible_tasks)
     # Next, select the tasks that belong to at least one of the given projects, if any


Test patch:
diff --git a/tests/unittests/test_arguments.py b/tests/unittests/test_arguments.py
index e35d00b..fe7b608 100644
--- a/tests/unittests/test_arguments.py
+++ b/tests/unittests/test_arguments.py
@@ -13,6 +13,7 @@ class ArgumentParserTest(unittest.TestCase):
 
     usage_message = """usage: next-action [-h] [--version] [-f FILE] [-n N | -a]
                    [@CONTEXT [@CONTEXT ...]] [+PROJECT [+PROJECT ...]] [-@CONTEXT [-@CONTEXT ...]]
+                   [-+PROJECT [-+PROJECT ...]]
 """
 
     @patch.object(sys, "argv", ["next-action"])
@@ -112,6 +113,21 @@ class ArgumentParserTest(unittest.TestCase):
         self.assertEqual([call(self.usage_message), call("next-action: error: project name cannot be empty\n")],
                          mock_stderr_write.call_args_list)
 
+    @patch.object(sys, "argv", ["next-action", "-+DogHouse"])
+    def test_exclude_project(self):
+        """ Test that projects can be excluded. """
+        self.assertEqual(["DogHouse"], parse_arguments().excluded_projects)
+
+    @patch.object(sys, "argv", ["next-action", "+DogHouse", "-+DogHouse"])
+    @patch.object(sys.stderr, "write")
+    def test_include_exclude_project(self, mock_stderr_write):
+        """ Test that projects cannot be included and excluded. """
+        os.environ['COLUMNS'] = "120"  # Fake that the terminal is wide enough.
+        self.assertRaises(SystemExit, parse_arguments)
+        self.assertEqual([call(self.usage_message),
+                          call("next-action: error: project DogHouse is both included and excluded\n")],
+                         mock_stderr_write.call_args_list)
+
     @patch.object(sys, "argv", ["next-action", "+DogHouse", "@home", "+PaintHouse", "@weekend"])
     def test_contexts_and_projects(self):
         """ Test that the argument parser returns the contexts and the projects, even when mixed. """
diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py
index d971c8e..4412083 100644
--- a/tests/unittests/test_cli.py
+++ b/tests/unittests/test_cli.py
@@ -61,6 +61,7 @@ class CLITest(unittest.TestCase):
         self.assertRaises(SystemExit, next_action)
         self.assertEqual(call("""usage: next-action [-h] [--version] [-f FILE] [-n N | -a]
                    [@CONTEXT [@CONTEXT ...]] [+PROJECT [+PROJECT ...]] [-@CONTEXT [-@CONTEXT ...]]
+                   [-+PROJECT [-+PROJECT ...]]
 
 Show the next action in your todo.txt
 
@@ -68,6 +69,7 @@ positional arguments:
   @CONTEXT              show the next action in the specified contexts (default: None)
   +PROJECT              show the next action for the specified projects (default: None)
   -@CONTEXT             exclude actions in the specified contexts (default: None)
+  -+PROJECT             exclude actions for the specified projects (default: None)
 
 optional arguments:
   -h, --help            show this help message and exit
diff --git a/tests/unittests/test_pick_action.py b/tests/unittests/test_pick_action.py
index a13ce1e..ba985d2 100644
--- a/tests/unittests/test_pick_action.py
+++ b/tests/unittests/test_pick_action.py
@@ -86,6 +86,21 @@ class PickActionTest(unittest.TestCase):
         task3 = todotxt.Task("(A) Todo 3 +ProjectY")
         self.assertEqual([task2, task1], pick_action.next_actions([task1, task2, task3], projects={"ProjectX"}))
 
+    def test_excluded_project(self):
+        """ Test that projects can be excluded. """
+        task = todotxt.Task("(A) Todo +DogHouse")
+        self.assertEqual([], pick_action.next_actions([task], excluded_projects={"DogHouse"}))
+
+    def test_excluded_projects(self):
+        """ Test that projects can be excluded. """
+        task = todotxt.Task("(A) Todo +DogHouse +PaintHouse")
+        self.assertEqual([], pick_action.next_actions([task], excluded_projects={"DogHouse"}))
+
+    def test_not_excluded_project(self):
+        """ Test that a task is not excluded if it doesn't belong to the excluded project. """
+        task = todotxt.Task("(A) Todo +DogHouse")
+        self.assertEqual([task], pick_action.next_actions([task], excluded_projects={"PaintHouse"}))
+
     def test_project_and_context(self):
         """ Test that the next action can be limited to a specific project and context. """
         task1 = todotxt.Task("Todo 1 +ProjectX @office")