Repository: fniessink/next-action

Issue:
Allow for configuring context or project filters in the configuration file
This would allow for hiding certain contexts by default:

```yaml
filter: -@waiting
```

As for other options, command-line options should override configured options.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66a03aa..38070b6 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] - 2018-06-09
+
+### Added
+
+- Default context and/or project filters can be configured in the configuration file. Closes #109.
+
 ## [0.17.0] - 2018-06-07
 
 ### Added
diff --git a/README.md b/README.md
index 1fa3797..e64a6e4 100644
--- a/README.md
+++ b/README.md
@@ -239,11 +239,11 @@ $ next-action --config-file docs/.next-action.cfg
 
 To skip reading the default configuration file, and also not read an alternative configuration file, use the `--config-file` option without arguments.
 
-The configuration file format is [YAML](http://yaml.org). The options currently supported are which todo.txt files must be read, how many next actions should be shown, and the styling.
+The configuration file format is [YAML](http://yaml.org). The options currently supported are which todo.txt files must be read, how many next actions should be shown, output styling, and context and/or project filters.
 
 #### Configuring a default todo.txt
 
-A default todo.txt file to use can be specified like this:
+A default todo.txt file to use can be specified like this in the configuration file:
 
 ```yaml
 file: ~/Dropbox/todo.txt
@@ -272,7 +272,30 @@ Or you can have *Next-action* show all next actions:
 all: True
 ```
 
-#### Configuring the minimum priority to show
+#### Limiting the tasks from which next actions are selected
+
+##### By contexts and/or projects
+
+You can limit the tasks from which the next action is selected by specifying contexts and/or projects to filter on, just like you would do on the command line:
+
+```yaml
+filters: -+FutureProject @work -@waiting
+```
+
+This would make *Next-action* by default select next actions from tasks with a `@work` context and without the `@waiting` context and not belonging to the `+FutureProject`.
+
+An alternative syntax is:
+
+```yaml
+filters:
+  - -+FutureProject
+  - '@work'
+  - -@waiting
+```
+
+Note that filters starting with `@` need to be in quotes. This is a [YAML restriction](http://yaml.org/spec/1.1/current.html#c-directive).
+
+##### By priority
 
 The minimum priority of next action to show can be specified as well:
 
@@ -347,9 +370,9 @@ To run the unit tests:
 
 ```console
 $ python -m unittest
-.........................................................................................................................................................................
+.....................................................................................................................................................................................
 ----------------------------------------------------------------------
-Ran 169 tests in 0.601s
+Ran 181 tests in 0.709s
 
 OK
 ```
diff --git a/next_action/arguments/config.py b/next_action/arguments/config.py
index 6c6acdc..5cf2f99 100644
--- a/next_action/arguments/config.py
+++ b/next_action/arguments/config.py
@@ -39,8 +39,8 @@ def validate_config_file(config, config_filename: str, error: Callable[[str], No
     schema = {
         "file": {
             "type": ["string", "list"],
-            "schema": {
-                "type": "string"}},
+            "schema": {"type": "string"}
+        },
         "number": {
             "type": "integer",
             "min": 1,
@@ -54,6 +54,11 @@ def validate_config_file(config, config_filename: str, error: Callable[[str], No
             "type": "string",
             "allowed": [letter for letter in string.ascii_uppercase]
         },
+        "filters": {
+            "type": ["string", "list"],
+            "regex": r"^\-?[@|\+]\S+(\s+\-?[@|\+]\S+)*",
+            "schema": {"type": "string", "regex": r"^\-?[@|\+]\S+"}
+        },
         "reference": {
             "type": "string",
             "allowed": ["always", "never", "multiple"]
diff --git a/next_action/arguments/parser.py b/next_action/arguments/parser.py
index 7805962..a112145 100644
--- a/next_action/arguments/parser.py
+++ b/next_action/arguments/parser.py
@@ -2,6 +2,7 @@
 
 import argparse
 import datetime
+import re
 import shutil
 import string
 import sys
@@ -170,6 +171,12 @@ class NextActionArgumentParser(argparse.ArgumentParser):
         if self.arguments_not_specified("-p", "--priority"):
             priority = config.get("priority", self.get_default("priority"))
             setattr(namespace, "priority", priority)
+        filters = config.get("filters", [])
+        if isinstance(filters, str):
+            filters = re.split(r"\s", filters)
+        for configured_filter in filters:
+            if self.filter_not_specified(configured_filter):
+                getattr(namespace, "filters").append(configured_filter)
 
     @staticmethod
     def arguments_not_specified(*arguments: str) -> bool:
@@ -177,6 +184,13 @@ class NextActionArgumentParser(argparse.ArgumentParser):
         return not any([command_line_arg.startswith(argument) for argument in arguments
                         for command_line_arg in sys.argv])
 
+    @staticmethod
+    def filter_not_specified(filtered: str) -> bool:
+        """ Return whether the context or project or its opposite were specified on the command line. """
+        prefix = "-"
+        opposite = filtered[len(prefix):] if filtered.startswith(prefix) else prefix + filtered
+        return not (filtered in sys.argv or opposite in sys.argv)
+
     def fix_filenames(self, namespace: argparse.Namespace) -> None:
         """ Fix the filenames. """
         # Work around the issue that the "append" action doesn't overwrite defaults.


Test patch:
diff --git a/tests/unittests/arguments/test_config.py b/tests/unittests/arguments/test_config.py
index 12e9a15..6fe669c 100644
--- a/tests/unittests/arguments/test_config.py
+++ b/tests/unittests/arguments/test_config.py
@@ -322,3 +322,91 @@ class ReferenceTest(ConfigTestCase):
             [call(USAGE_MESSAGE),
              call("next-action: error: ~/.next-action.cfg is invalid: reference: unallowed value invalid\n")],
             mock_stderr_write.call_args_list)
+
+
+class FiltersTest(ConfigTestCase):
+    """ Unit tests for the filters parameter. """
+
+    @patch.object(sys, "argv", ["next-action"])
+    @patch.object(config, "open", mock_open(read_data="filters: +ImportantProject"))
+    def test_project(self):
+        """ Test that a valid project changes the filters argument. """
+        self.assertEqual(["+ImportantProject"], parse_arguments()[1].filters)
+
+    @patch.object(sys, "argv", ["next-action"])
+    @patch.object(config, "open", mock_open(read_data="filters: +ImportantProject +SideProject"))
+    def test_projects(self):
+        """ Test that multiple valid projects change the filters argument. """
+        self.assertEqual(["+ImportantProject", "+SideProject"], parse_arguments()[1].filters)
+
+    @patch.object(sys, "argv", ["next-action"])
+    @patch.object(config, "open", mock_open(read_data="filters: '@work'"))
+    def test_context(self):
+        """ Test that a valid context changes the filters argument. """
+        self.assertEqual(["@work"], parse_arguments()[1].filters)
+
+    @patch.object(sys, "argv", ["next-action"])
+    @patch.object(config, "open", mock_open(read_data="filters: '@work @desk'"))
+    def test_contexts(self):
+        """ Test that valid contexts change the filters argument. """
+        self.assertEqual(["@work", "@desk"], parse_arguments()[1].filters)
+
+    @patch.object(sys, "argv", ["next-action"])
+    @patch.object(config, "open", mock_open(read_data="filters:\n- '@work'\n- -@waiting\n"))
+    def test_context_list(self):
+        """ Test that a valid context changes the filters argument. """
+        self.assertEqual(["@work", "-@waiting"], parse_arguments()[1].filters)
+
+    @patch.object(sys, "argv", ["next-action"])
+    @patch.object(config, "open", mock_open(read_data="filters: -+SideProject"))
+    def test_excluded_project(self):
+        """ Test that an excluded valid project changes the filters argument. """
+        self.assertEqual(["-+SideProject"], parse_arguments()[1].filters)
+
+    @patch.object(sys, "argv", ["next-action"])
+    @patch.object(config, "open", mock_open(read_data="filters: -@home"))
+    def test_excluded_context(self):
+        """ Test that an excluded valid context changes the filters argument. """
+        self.assertEqual(["-@home"], parse_arguments()[1].filters)
+
+    @patch.object(sys, "argv", ["next-action", "+ImportantProject"])
+    @patch.object(config, "open", mock_open(read_data="filters: +ImportantProject"))
+    def test_same_project(self):
+        """ Test that the configuration is ignored when the command line specifies the same project. """
+        self.assertEqual(["+ImportantProject"], parse_arguments()[1].filters)
+
+    @patch.object(sys, "argv", ["next-action", "+ImportantProject"])
+    @patch.object(config, "open", mock_open(read_data="filters: -+ImportantProject"))
+    def test_inverse_project(self):
+        """ Test that the configuration is ignored when the command line specifies the same project. """
+        self.assertEqual(["+ImportantProject"], parse_arguments()[1].filters)
+
+    @patch.object(sys, "argv", ["next-action", "+ImportantProject"])
+    @patch.object(config, "open", mock_open(read_data="filters: -+ImportantProject @work"))
+    def test_ignore_project_not_context(self):
+        """ Test that the configuration is ignored when the command line specifies the same project. """
+        self.assertEqual(["+ImportantProject", "@work"], parse_arguments()[1].filters)
+
+    @patch.object(sys, "argv", ["next-action"])
+    @patch.object(config, "open", mock_open(read_data="filters:\n- invalid\n"))
+    @patch.object(sys.stderr, "write")
+    def test_invalid_filter_list(self, mock_stderr_write):
+        """ Test that an invalid filter raises an error. """
+        self.assertRaises(SystemExit, parse_arguments)
+        regex = r"^\-?[@|\+]\S+"
+        self.assertEqual(
+            [call(USAGE_MESSAGE), call("next-action: error: ~/.next-action.cfg is invalid: filters: 0: "
+                                       "value does not match regex '{0}'\n".format(regex))],
+            mock_stderr_write.call_args_list)
+
+    @patch.object(sys, "argv", ["next-action"])
+    @patch.object(config, "open", mock_open(read_data="filters: invalid\n"))
+    @patch.object(sys.stderr, "write")
+    def test_invalid_filter_string(self, mock_stderr_write):
+        """ Test that an invalid filter raises an error. """
+        self.assertRaises(SystemExit, parse_arguments)
+        regex = r"^\-?[@|\+]\S+(\s+\-?[@|\+]\S+)*"
+        self.assertEqual(
+            [call(USAGE_MESSAGE), call("next-action: error: ~/.next-action.cfg is invalid: filters: "
+                                       "value does not match regex '{0}'\n".format(regex))],
+            mock_stderr_write.call_args_list)