Repository: fniessink/next-action

Issue:
Add option to read todo.txt from stdin
```console
$ cat todo.txt | next-action -
Buy newspaper
```

Use the fileinput module for iterating over the files, including stdin.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e0174c6..e107c92 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ 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
+
+- The `--file` argument accepts `-` to read input from standard input. Closes #42.
+
+### Changed
+
+- Give consistent error message when files can't be opened. Fixes #54.
+
 ## [0.2.1] - 2018-05-16
 
 ### Changed
diff --git a/README.md b/README.md
index 25fc8eb..e759dae 100644
--- a/README.md
+++ b/README.md
@@ -27,26 +27,26 @@ 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 <todo.txt>] [-n <number> | -a] [<context|project> ...]
+usage: next-action [-h] [--version] [-f <filename>] [-n <number> | -a] [<context|project> ...]
 
-Show the next action in your todo.txt. The next action is selected from the tasks in the todo.txt
-file based on priority, due date, creation date, and supplied filters.
+Show the next action in your todo.txt. The next action is selected from the tasks in the todo.txt file based on
+priority, due date, creation date, and supplied filters.
 
 optional arguments:
   -h, --help            show this help message and exit
   --version             show program's version number and exit
-  -f <todo.txt>, --file <todo.txt>
-                        todo.txt file to read; argument can be repeated to read tasks from multiple
-                        todo.txt files (default: ['todo.txt'])
+  -f <filename>, --file <filename>
+                        filename of todo.txt file to read; can be - to read from standard input; argument can be
+                        repeated to read tasks from multiple todo.txt files (default: ['todo.txt'])
   -n <number>, --number <number>
                         number of next actions to show (default: 1)
   -a, --all             show all next actions (default: False)
 
 optional context and project arguments; these can be repeated:
-  @<context>            context the next action must have (default: None)
-  +<project>            project the next action must be part of (default: None)
-  -@<context>           context the next action must not have (default: None)
-  -+<project>           project the next action must not be part of (default: None)
+  @<context>            context the next action must have
+  +<project>            project the next action must be part of
+  -@<context>           context the next action must not have
+  -+<project>           project the next action must not be part of
 ```
 
 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:
diff --git a/next_action/arguments/__init__.py b/next_action/arguments/__init__.py
index 73eb785..45faa29 100644
--- a/next_action/arguments/__init__.py
+++ b/next_action/arguments/__init__.py
@@ -4,7 +4,7 @@ import argparse
 import os
 import shutil
 import sys
-from typing import Dict, List, Set
+from typing import cast, Dict, List, Set, Tuple
 
 from .arguments import Arguments
 from .parser import build_parser, parse_remaining_args
@@ -15,8 +15,8 @@ def parse_arguments() -> Arguments:
     # Ensure that the help info is printed using all columns available
     os.environ['COLUMNS'] = str(shutil.get_terminal_size().columns)
     default_filenames = ["todo.txt"]
-    arguments = Arguments(default_filenames)
     parser = build_parser(default_filenames)
+    arguments = Arguments(parser, default_filenames)
     namespace, remaining = parser.parse_known_args()
     parse_remaining_args(parser, remaining, namespace)
     arguments.filenames = namespace.filenames
diff --git a/next_action/arguments/arguments.py b/next_action/arguments/arguments.py
index e06f4d5..afbe5ac 100644
--- a/next_action/arguments/arguments.py
+++ b/next_action/arguments/arguments.py
@@ -1,12 +1,14 @@
 """ Argument data class for transfering command line arguments. """
 
+import argparse
 import sys
 from typing import List, Set
 
 
 class Arguments(object):
     """ Argument data class. """
-    def __init__(self, default_filenames: List[str]) -> None:
+    def __init__(self, parser: argparse.ArgumentParser, default_filenames: List[str]) -> None:
+        self.parser = parser
         self.__default_filenames = default_filenames
         self.__filenames: List[str] = []
         self.__filters: List[Set[str]] = []
diff --git a/next_action/arguments/parser.py b/next_action/arguments/parser.py
index 18afb99..0d3ed9d 100644
--- a/next_action/arguments/parser.py
+++ b/next_action/arguments/parser.py
@@ -12,7 +12,7 @@ def build_parser(default_filenames: List[str]) -> argparse.ArgumentParser:
         description="Show the next action in your todo.txt. The next action is selected from the tasks in the todo.txt \
                      file based on priority, due date, creation date, and supplied filters.",
         formatter_class=argparse.ArgumentDefaultsHelpFormatter,
-        usage="%(prog)s [-h] [--version] [-f <todo.txt>] [-n <number> | -a] [<context|project> ...]")
+        usage="%(prog)s [-h] [--version] [-f <filename>] [-n <number> | -a] [<context|project> ...]")
     add_optional_arguments(parser, default_filenames)
     add_positional_arguments(parser)
     return parser
@@ -23,8 +23,9 @@ def add_optional_arguments(parser: argparse.ArgumentParser, default_filenames: L
     parser.add_argument(
         "--version", action="version", version="%(prog)s {0}".format(next_action.__version__))
     parser.add_argument(
-        "-f", "--file", action="append", dest="filenames", metavar="<todo.txt>", default=default_filenames, type=str,
-        help="todo.txt file to read; argument can be repeated to read tasks from multiple todo.txt files")
+        "-f", "--file", action="append", dest="filenames", metavar="<filename>", default=default_filenames, type=str,
+        help="filename of todo.txt file to read; can be - to read from standard input; argument can be repeated to "
+             "read tasks from multiple todo.txt files")
     number = parser.add_mutually_exclusive_group()
     number.add_argument(
         "-n", "--number", metavar="<number>", help="number of next actions to show", type=int, default=1)
diff --git a/next_action/cli.py b/next_action/cli.py
index 8e9888a..03cda4c 100644
--- a/next_action/cli.py
+++ b/next_action/cli.py
@@ -1,5 +1,7 @@
 """ Entry point for Next-action's command-line interface. """
 
+import fileinput
+
 from next_action.arguments import parse_arguments
 from next_action.pick_action import next_actions
 from next_action.todotxt import read_todotxt_file
@@ -14,13 +16,10 @@ def next_action() -> None:
         3) determine the next action(s) and display them.
     """
     arguments = parse_arguments()
-    tasks = []
-    for filename in arguments.filenames:
-        try:
-            todotxt_file = open(filename, "r")
-        except FileNotFoundError:
-            print("Can't find {0}".format(filename))
-            return
-        tasks.extend(read_todotxt_file(todotxt_file))
+    try:
+        with fileinput.input(arguments.filenames) as todotxt_file:
+            tasks = read_todotxt_file(todotxt_file)
+    except OSError as reason:
+        arguments.parser.error("can't open file: {0}".format(reason))
     actions = next_actions(tasks, *arguments.filters)
     print("\n".join(action.text for action in actions[:arguments.number]) if actions else "Nothing to do!")
diff --git a/next_action/todotxt/__init__.py b/next_action/todotxt/__init__.py
index 3faed18..1b2b424 100644
--- a/next_action/todotxt/__init__.py
+++ b/next_action/todotxt/__init__.py
@@ -8,4 +8,4 @@ from .task import Task
 def read_todotxt_file(todoxt_file: IO[str]) -> List[Task]:
     """ Read tasks from a Todo.txt file. """
     with todoxt_file:
-        return [Task(line.strip()) for line in todoxt_file.readlines() if line.strip()]
+        return [Task(line.strip()) for line in todoxt_file if line.strip()]


Test patch:
diff --git a/tests/unittests/test_arguments.py b/tests/unittests/test_arguments.py
index 626f557..7dc6ce6 100644
--- a/tests/unittests/test_arguments.py
+++ b/tests/unittests/test_arguments.py
@@ -8,7 +8,7 @@ from unittest.mock import patch, call
 from next_action.arguments import parse_arguments
 
 
-USAGE_MESSAGE = "usage: next-action [-h] [--version] [-f <todo.txt>] [-n <number> | -a] [<context|project> ...]\n"
+USAGE_MESSAGE = "usage: next-action [-h] [--version] [-f <filename>] [-n <number> | -a] [<context|project> ...]\n"
 
 
 class NoArgumentTest(unittest.TestCase):
diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py
index 80b4a5f..299b65d 100644
--- a/tests/unittests/test_cli.py
+++ b/tests/unittests/test_cli.py
@@ -8,12 +8,14 @@ from unittest.mock import patch, mock_open, call
 from next_action.cli import next_action
 from next_action import __version__
 
+from .test_arguments import USAGE_MESSAGE
+
 
 class CLITest(unittest.TestCase):
     """ Unit tests for the command-line interface. """
 
     @patch.object(sys, "argv", ["next-action"])
-    @patch("next_action.cli.open", mock_open(read_data=""))
+    @patch("fileinput.open", mock_open(read_data=""))
     @patch.object(sys.stdout, "write")
     def test_empty_task_file(self, mock_stdout_write):
         """ Test the response when the task file is empty. """
@@ -21,7 +23,7 @@ class CLITest(unittest.TestCase):
         self.assertEqual([call("Nothing to do!"), call("\n")], mock_stdout_write.call_args_list)
 
     @patch.object(sys, "argv", ["next-action"])
-    @patch("next_action.cli.open", mock_open(read_data="Todo\n"))
+    @patch("fileinput.open", mock_open(read_data="Todo\n"))
     @patch.object(sys.stdout, "write")
     def test_one_task(self, mock_stdout_write):
         """ Test the response when the task file has one task. """
@@ -29,7 +31,7 @@ class CLITest(unittest.TestCase):
         self.assertEqual([call("Todo"), call("\n")], mock_stdout_write.call_args_list)
 
     @patch.object(sys, "argv", ["next-action", "@work"])
-    @patch("next_action.cli.open", mock_open(read_data="Todo @home\nTodo @work\n"))
+    @patch("fileinput.open", mock_open(read_data="Todo @home\nTodo @work\n"))
     @patch.object(sys.stdout, "write")
     def test_context(self, mock_stdout_write):
         """ Test the response when the user passes a context. """
@@ -37,7 +39,7 @@ class CLITest(unittest.TestCase):
         self.assertEqual([call("Todo @work"), call("\n")], mock_stdout_write.call_args_list)
 
     @patch.object(sys, "argv", ["next-action", "+DogHouse"])
-    @patch("next_action.cli.open", mock_open(read_data="Walk the dog @home\nBuy wood +DogHouse\n"))
+    @patch("fileinput.open", mock_open(read_data="Walk the dog @home\nBuy wood +DogHouse\n"))
     @patch.object(sys.stdout, "write")
     def test_project(self, mock_stdout_write):
         """ Test the response when the user passes a project. """
@@ -45,13 +47,14 @@ class CLITest(unittest.TestCase):
         self.assertEqual([call("Buy wood +DogHouse"), call("\n")], mock_stdout_write.call_args_list)
 
     @patch.object(sys, "argv", ["next-action"])
-    @patch("next_action.cli.open")
-    @patch.object(sys.stdout, "write")
-    def test_missing_file(self, mock_stdout_write, mock_file_open):
+    @patch("fileinput.open")
+    @patch.object(sys.stderr, "write")
+    def test_missing_file(self, mock_stderr_write, mock_file_open):
         """ Test the response when the task file can't be found. """
-        mock_file_open.side_effect = FileNotFoundError
-        next_action()
-        self.assertEqual([call("Can't find todo.txt"), call("\n")], mock_stdout_write.call_args_list)
+        mock_file_open.side_effect = OSError("some problem")
+        self.assertRaises(SystemExit, next_action)
+        self.assertEqual([call(USAGE_MESSAGE), call("next-action: error: can't open file: some problem\n")],
+                         mock_stderr_write.call_args_list)
 
     @patch.object(sys, "argv", ["next-action", "--help"])
     @patch.object(sys.stdout, "write")
@@ -59,8 +62,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 <todo.txt>] [-n <number> | -a] \
-[<context|project> ...]
+        self.assertEqual(call("""usage: next-action [-h] [--version] [-f <filename>] [-n <number> | -a] [<context|project> ...]
 
 Show the next action in your todo.txt. The next action is selected from the tasks in the todo.txt file based on
 priority, due date, creation date, and supplied filters.
@@ -68,9 +70,9 @@ priority, due date, creation date, and supplied filters.
 optional arguments:
   -h, --help            show this help message and exit
   --version             show program's version number and exit
-  -f <todo.txt>, --file <todo.txt>
-                        todo.txt file to read; argument can be repeated to read tasks from multiple todo.txt files
-                        (default: ['todo.txt'])
+  -f <filename>, --file <filename>
+                        filename of todo.txt file to read; can be - to read from standard input; argument can be
+                        repeated to read tasks from multiple todo.txt files (default: ['todo.txt'])
   -n <number>, --number <number>
                         number of next actions to show (default: 1)
   -a, --all             show all next actions (default: False)
@@ -91,7 +93,7 @@ optional context and project arguments; these can be repeated:
         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("fileinput.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. """
@@ -99,9 +101,18 @@ optional context and project arguments; these can be repeated:
         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("fileinput.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)
+
+    @patch.object(sys, "argv", ["next-action", "--file", "-"])
+    @patch.object(sys.stdin, "readline")
+    @patch.object(sys.stdout, "write")
+    def test_reading_stdin(self, mock_stdout_write, mock_stdin_readline):
+        """ Test that tasks can be read from stdin works. """
+        mock_stdin_readline.side_effect = ["(B) Call mom\n", "Walk the dog\n", StopIteration]
+        next_action()
+        self.assertEqual([call("(B) Call mom"), call("\n")], mock_stdout_write.call_args_list)