Repository: fniessink/next-action

Issue:
Allow for showing multiple next actions
$ `next_action -n3  # Or --number 3`
(A) World peace
(B) End hunger
Call mom @home

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68770d2..684bdb0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
 and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
+## [Unreleased]
+
+### Added
+
+- Specify the number of next actions to show: `next-action --number 3`. Closes #7.
+
 ## [0.0.7] - 2018-05-12
 
 ### Added
diff --git a/README.md b/README.md
index 1454a95..1c06d62 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ Don't know what *Todo.txt* is? See <https://github.com/todotxt/todo.txt> for the
 
 ```console
 $ next-action --help
-usage: next-action [-h] [--version] [-f FILE] [@CONTEXT [@CONTEXT ...]] [+PROJECT [+PROJECT ...]]
+usage: next-action [-h] [--version] [-f FILE] [-n N] [@CONTEXT [@CONTEXT ...]] [+PROJECT [+PROJECT ...]]
 
 Show the next action in your todo.txt
 
@@ -35,6 +35,7 @@ optional arguments:
   -h, --help            show this help message and exit
   --version             show program's version number and exit
   -f FILE, --file FILE  filename of the todo.txt file to read (default: todo.txt)
+  -n N, --number N      number of next actions to show (default: 1)
 ```
 
 Assuming your todo.txt file is in the current folder, running *Next-action* without arguments will show the next action you should do based on your tasks' priorities:
@@ -64,6 +65,15 @@ $ next-action +DogHouse +PaintHouse @store @weekend
 (B) Buy paint to +PaintHouse @store @weekend
 ```
 
+To show more than one next action, supply the number you think you can handle:
+
+```console
+$ next-action --number 3
+(A) Call mom @phone
+(B) Buy paint to +PaintHouse @store @weekend
+(C) Finish proposal for important client @work
+```
+
 Since *Next-action* is still pre-alpha-stage, this is it for the moment. Stay tuned for more options.
 
 ## Develop
diff --git a/next_action/__init__.py b/next_action/__init__.py
index ca056b5..43f2b3c 100644
--- a/next_action/__init__.py
+++ b/next_action/__init__.py
@@ -1,7 +1,4 @@
 """ Main Next-action package. """
 
-from .pick_action import next_action_based_on_priority
-
-
 __title__ = "next-action"
 __version__ = "0.0.7"
diff --git a/next_action/arguments.py b/next_action/arguments.py
index 8a8dd6a..51249fc 100644
--- a/next_action/arguments.py
+++ b/next_action/arguments.py
@@ -47,8 +47,8 @@ def parse_arguments() -> argparse.Namespace:
     parser = argparse.ArgumentParser(description="Show the next action in your todo.txt",
                                      formatter_class=argparse.ArgumentDefaultsHelpFormatter)
     parser.add_argument("--version", action="version", version="%(prog)s {0}".format(next_action.__version__))
-    parser.add_argument("-f", "--file", help="filename of the todo.txt file to read",
-                        type=str, default="todo.txt")
+    parser.add_argument("-f", "--file", help="filename of the todo.txt file to read", type=str, default="todo.txt")
+    parser.add_argument("-n", "--number", metavar="N", help="number of next actions to show", type=int, default=1)
     parser.add_argument("contexts", metavar="@CONTEXT", help="show the next action in the specified contexts",
                         nargs="*", type=str, default=None, action=ContextProjectAction)
     parser.add_argument("projects", metavar="+PROJECT", help="show the next action for the specified projects",
diff --git a/next_action/cli.py b/next_action/cli.py
index 4618e10..93a7d57 100644
--- a/next_action/cli.py
+++ b/next_action/cli.py
@@ -1,7 +1,7 @@
 """ Entry point for Next-action's command-line interface. """
 
 from next_action.todotxt import Task
-from next_action.pick_action import next_action_based_on_priority
+from next_action.pick_action import next_actions
 from next_action.arguments import parse_arguments
 
 
@@ -11,7 +11,7 @@ def next_action() -> None:
         Basic recipe:
         1) parse command-line arguments,
         2) read todo.txt file,
-        3) determine the next action and display it.
+        3) determine the next action(s) and display them.
     """
     arguments = parse_arguments()
     filename: str = arguments.file
@@ -21,6 +21,6 @@ def next_action() -> None:
         print("Can't find {0}".format(filename))
         return
     with todotxt_file:
-        tasks = [Task(line.strip()) for line in todotxt_file.readlines()]
-    action = next_action_based_on_priority(tasks, set(arguments.contexts), set(arguments.projects))
-    print(action.text if action else "Nothing to do!")
+        tasks = [Task(line.strip()) for line in todotxt_file.readlines() if line.strip()]
+    actions = next_actions(tasks, set(arguments.contexts), set(arguments.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 e565186..68584cb 100644
--- a/next_action/pick_action.py
+++ b/next_action/pick_action.py
@@ -1,17 +1,13 @@
-""" Algorithm for deciding the next action. """
+""" Algorithm for deciding the next action(s). """
 
-from typing import Optional, Set, Sequence
+from typing import Set, Sequence
 
 from .todotxt import Task
 
 
-def next_action_based_on_priority(tasks: Sequence[Task], contexts: Set[str] = None,
-                                  projects: Set[str] = None) -> Optional[Task]:
-    """ Return the next action from the collection of tasks. """
-    contexts = contexts or set()
-    projects = projects or set()
+def next_actions(tasks: Sequence[Task], contexts: Set[str] = None, projects: Set[str] = None) -> Sequence[Task]:
+    """ Return the next action(s) from the collection of tasks. """
     uncompleted_tasks = [task for task in tasks if not task.is_completed()]
-    tasks_in_context = filter(lambda task: contexts <= task.contexts(), uncompleted_tasks)
+    tasks_in_context = filter(lambda task: contexts <= task.contexts() if contexts else True, uncompleted_tasks)
     tasks_in_project = filter(lambda task: projects & task.projects() if projects else True, tasks_in_context)
-    sorted_tasks = sorted(tasks_in_project, key=lambda task: task.priority() or "ZZZ")
-    return sorted_tasks[0] if sorted_tasks else None
+    return sorted(tasks_in_project, key=lambda task: task.priority() or "ZZZ")


Test patch:
diff --git a/tests/unittests/test_arguments.py b/tests/unittests/test_arguments.py
index a737329..25d20ee 100644
--- a/tests/unittests/test_arguments.py
+++ b/tests/unittests/test_arguments.py
@@ -11,6 +11,9 @@ from next_action.arguments import parse_arguments
 class ArgumentParserTest(unittest.TestCase):
     """ Unit tests for the argument parses. """
 
+    usage_message = "usage: next-action [-h] [--version] [-f FILE] [-n N] [@CONTEXT [@CONTEXT ...]] " \
+                    "[+PROJECT [+PROJECT ...]]\n"
+
     @patch.object(sys, "argv", ["next-action"])
     def test_default_filename(self):
         """ Test that the argument parser has a default filename. """
@@ -47,9 +50,7 @@ class ArgumentParserTest(unittest.TestCase):
         """ Test that the argument parser exits if the context is empty. """
         os.environ['COLUMNS'] = "120"  # Fake that the terminal is wide enough.
         self.assertRaises(SystemExit, parse_arguments)
-        self.assertEqual([call("usage: next-action [-h] [--version] [-f FILE] [@CONTEXT [@CONTEXT ...]] "
-                               "[+PROJECT [+PROJECT ...]]\n"),
-                          call("next-action: error: Context name cannot be empty.\n")],
+        self.assertEqual([call(self.usage_message), call("next-action: error: Context name cannot be empty.\n")],
                          mock_stderr_write.call_args_list)
 
     @patch.object(sys, "argv", ["next-action", "+DogHouse"])
@@ -68,9 +69,7 @@ class ArgumentParserTest(unittest.TestCase):
         """ Test that the argument parser exits if the project is empty. """
         os.environ['COLUMNS'] = "120"  # Fake that the terminal is wide enough.
         self.assertRaises(SystemExit, parse_arguments)
-        self.assertEqual([call("usage: next-action [-h] [--version] [-f FILE] [@CONTEXT [@CONTEXT ...]] "
-                               "[+PROJECT [+PROJECT ...]]\n"),
-                          call("next-action: error: Project name cannot be empty.\n")],
+        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", "@home", "+PaintHouse", "@weekend"])
@@ -85,7 +84,25 @@ class ArgumentParserTest(unittest.TestCase):
         """ Test that the argument parser exits if the option is faulty. """
         os.environ['COLUMNS'] = "120"  # Fake that the terminal is wide enough.
         self.assertRaises(SystemExit, parse_arguments)
-        self.assertEqual([call("usage: next-action [-h] [--version] [-f FILE] [@CONTEXT [@CONTEXT ...]] "
-                               "[+PROJECT [+PROJECT ...]]\n"),
-                          call("next-action: error: Unrecognized argument 'home'.\n")],
+        self.assertEqual([call(self.usage_message), call("next-action: error: Unrecognized argument 'home'.\n")],
+                         mock_stderr_write.call_args_list)
+
+    @patch.object(sys, "argv", ["next-action"])
+    def test_default_number(self):
+        """ Test that the argument parser has a default number of actions to return. """
+        self.assertEqual(1, parse_arguments().number)
+
+    @patch.object(sys, "argv", ["next-action", "--number", "3"])
+    def test_number(self):
+        """ Test that a number of actions to be shown can be passed. """
+        self.assertEqual(3, parse_arguments().number)
+
+    @patch.object(sys, "argv", ["next-action", "--number", "not_a_number"])
+    @patch.object(sys.stderr, "write")
+    def test_faulty_number(self, mock_stderr_write):
+        """ Test that the argument parser exits if the option is faulty. """
+        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: argument -n/--number: invalid int value: 'not_a_number'\n")],
                          mock_stderr_write.call_args_list)
diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py
index b517f34..8f8e68f 100644
--- a/tests/unittests/test_cli.py
+++ b/tests/unittests/test_cli.py
@@ -59,7 +59,7 @@ class CLITest(unittest.TestCase):
         """ Test the help message. """
         os.environ['COLUMNS'] = "120"  # Fake that the terminal is wide enough.
         self.assertRaises(SystemExit, next_action)
-        self.assertEqual(call("""usage: next-action [-h] [--version] [-f FILE] [@CONTEXT [@CONTEXT ...]] \
+        self.assertEqual(call("""usage: next-action [-h] [--version] [-f FILE] [-n N] [@CONTEXT [@CONTEXT ...]] \
 [+PROJECT [+PROJECT ...]]
 
 Show the next action in your todo.txt
@@ -72,6 +72,7 @@ optional arguments:
   -h, --help            show this help message and exit
   --version             show program's version number and exit
   -f FILE, --file FILE  filename of the todo.txt file to read (default: todo.txt)
+  -n N, --number N      number of next actions to show (default: 1)
 """),
                          mock_stdout_write.call_args_list[0])
 
@@ -81,3 +82,19 @@ optional arguments:
         """ Test that --version shows the version number. """
         self.assertRaises(SystemExit, next_action)
         self.assertEqual([call("next-action {0}\n".format(__version__))], mock_stdout_write.call_args_list)
+
+    @patch.object(sys, "argv", ["next-action", "--number", "2"])
+    @patch("next_action.cli.open", mock_open(read_data="Walk the dog @home\n(A) Buy wood +DogHouse\n(B) Call mom\n"))
+    @patch.object(sys.stdout, "write")
+    def test_number(self, mock_stdout_write):
+        """ Test that the number of next actions can be specified. """
+        next_action()
+        self.assertEqual([call("(A) Buy wood +DogHouse\n(B) Call mom"), call("\n")], mock_stdout_write.call_args_list)
+
+    @patch.object(sys, "argv", ["next-action", "--number", "3"])
+    @patch("next_action.cli.open", mock_open(read_data="\nWalk the dog @home\n   \n(B) Call mom\n"))
+    @patch.object(sys.stdout, "write")
+    def test_ignore_empty_lines(self, mock_stdout_write):
+        """ Test that empty lines in the todo.txt file are ignored. """
+        next_action()
+        self.assertEqual([call("(B) Call mom\nWalk the dog @home"), call("\n")], mock_stdout_write.call_args_list)
diff --git a/tests/unittests/test_pick_action.py b/tests/unittests/test_pick_action.py
index cf62887..9ad1364 100644
--- a/tests/unittests/test_pick_action.py
+++ b/tests/unittests/test_pick_action.py
@@ -10,18 +10,18 @@ class PickActionTest(unittest.TestCase):
 
     def test_no_tasks(self):
         """ Test that no tasks means no next action. """
-        self.assertEqual(None, pick_action.next_action_based_on_priority([]))
+        self.assertEqual([], pick_action.next_actions([]))
 
     def test_one_task(self):
         """ If there is one task, that one is the next action. """
         task = todotxt.Task("Todo")
-        self.assertEqual(task, pick_action.next_action_based_on_priority([task]))
+        self.assertEqual([task], pick_action.next_actions([task]))
 
     def test_multiple_tasks(self):
         """ If there are multiple tasks, the first is the next action. """
         task1 = todotxt.Task("Todo 1")
         task2 = todotxt.Task("Todo 2")
-        self.assertEqual(task1, pick_action.next_action_based_on_priority([task1, task2]))
+        self.assertEqual([task1, task2], pick_action.next_actions([task1, task2]))
 
     def test_higher_prio_goes_first(self):
         """ If there are multiple tasks with different priorities, the task with the
@@ -29,47 +29,45 @@ class PickActionTest(unittest.TestCase):
         task1 = todotxt.Task("Todo 1")
         task2 = todotxt.Task("(B) Todo 2")
         task3 = todotxt.Task("(A) Todo 3")
-        self.assertEqual(task3, pick_action.next_action_based_on_priority([task1, task2, task3]))
+        self.assertEqual([task3, task2, task1], pick_action.next_actions([task1, task2, task3]))
 
     def test_completed_task_is_ignored(self):
         """ If there's one completed and one uncompleted task, the uncompleted one is the next action. """
         completed_task = todotxt.Task("x Completed")
         uncompleted_task = todotxt.Task("Todo")
-        self.assertEqual(uncompleted_task,
-                         pick_action.next_action_based_on_priority([completed_task, uncompleted_task]))
+        self.assertEqual([uncompleted_task], pick_action.next_actions([completed_task, uncompleted_task]))
 
     def test_completed_tasks_only(self):
         """ If all tasks are completed, there's no next action. """
         completed_task1 = todotxt.Task("x Completed")
         completed_task2 = todotxt.Task("x Completed too")
-        self.assertEqual(None, pick_action.next_action_based_on_priority([completed_task1, completed_task2]))
+        self.assertEqual([], pick_action.next_actions([completed_task1, completed_task2]))
 
     def test_context(self):
         """ Test that the next action can be limited to a specific context. """
         task1 = todotxt.Task("Todo 1 @work")
         task2 = todotxt.Task("(B) Todo 2 @work")
         task3 = todotxt.Task("(A) Todo 3 @home")
-        self.assertEqual(task2, pick_action.next_action_based_on_priority([task1, task2, task3], contexts={"work"}))
+        self.assertEqual([task2, task1], pick_action.next_actions([task1, task2, task3], contexts={"work"}))
 
     def test_contexts(self):
         """ Test that the next action can be limited to a set of contexts. """
         task1 = todotxt.Task("Todo 1 @work @computer")
         task2 = todotxt.Task("(B) Todo 2 @work @computer")
         task3 = todotxt.Task("(A) Todo 3 @home @computer")
-        self.assertEqual(task2, pick_action.next_action_based_on_priority([task1, task2, task3],
-                                                                          contexts={"work", "computer"}))
+        self.assertEqual([task2, task1], pick_action.next_actions([task1, task2, task3], contexts={"work", "computer"}))
 
     def test_project(self):
         """ Test that the next action can be limited to a specific project. """
         task1 = todotxt.Task("Todo 1 +ProjectX")
         task2 = todotxt.Task("(B) Todo 2 +ProjectX")
         task3 = todotxt.Task("(A) Todo 3 +ProjectY")
-        self.assertEqual(task2, pick_action.next_action_based_on_priority([task1, task2, task3], projects={"ProjectX"}))
+        self.assertEqual([task2, task1], pick_action.next_actions([task1, task2, task3], projects={"ProjectX"}))
 
     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")
         task2 = todotxt.Task("(B) Todo 2 +ProjectX")
         task3 = todotxt.Task("(A) Todo 3 +ProjectY")
-        self.assertEqual(task1, pick_action.next_action_based_on_priority([task1, task2, task3], projects={"ProjectX"},
-                                                                          contexts={"office"}))
+        self.assertEqual([task1],
+                         pick_action.next_actions([task1, task2, task3], projects={"ProjectX"}, contexts={"office"}))