Repository: RedHatInsights/insights-core

Issue:
Return a response on behalf of rules that return None
A rule that returns `None` has `SkipComponent` raised on its behalf, so it is silently ignored. Nothing indicating the skip is logged, and nothing shows up in any of the response documents, which makes debugging and accounting difficult.

The [rule plugin type](https://github.com/RedHatInsights/insights-core/blob/master/insights/core/plugins.py#L294) should return a [Response subclass](https://github.com/RedHatInsights/insights-core/blob/master/insights/core/plugins.py#L372) in these cases so that [execution counts](https://github.com/RedHatInsights/insights-core/blob/master/insights/formats/text.py#L91) can be corrected and [template based formatters](https://github.com/RedHatInsights/insights-core/blob/master/insights/formats/template.py#L56) have something to work with.


Discussion:

Resolving patch:
diff --git a/insights/__init__.py b/insights/__init__.py
--- a/insights/__init__.py
+++ b/insights/__init__.py
@@ -38,7 +38,7 @@
 from .core.plugins import combiner, fact, metadata, parser, rule  # noqa: F401
 from .core.plugins import datasource, condition, incident  # noqa: F401
 from .core.plugins import make_response, make_metadata, make_fingerprint  # noqa: F401
-from .core.plugins import make_pass, make_fail, make_info  # noqa: F401
+from .core.plugins import make_pass, make_fail, make_info, make_none  # noqa: F401
 from .core.filters import add_filter, apply_filters, get_filters  # noqa: F401
 from .formats import get_formatter
 from .parsers import get_active_lines  # noqa: F401
diff --git a/insights/core/plugins.py b/insights/core/plugins.py
--- a/insights/core/plugins.py
+++ b/insights/core/plugins.py
@@ -302,7 +302,7 @@ def process(self, broker):
             return _make_skip(dr.get_name(self.component), missing)
         r = self.invoke(broker)
         if r is None:
-            raise dr.SkipComponent()
+            return make_none()
         if not isinstance(r, Response):
             raise Exception("rules must return Response objects.")
         return r
@@ -660,3 +660,17 @@ def __init__(self, rule_fqdn, missing):
                                         rule_fqdn=rule_fqdn,
                                         reason="MISSING_REQUIREMENTS",
                                         details=details)
+
+
+class make_none(Response):
+    """
+    Used to create a response for a rule that returns None
+
+    This is not intended to be used by plugins, only infrastructure
+    but it not private so that we can easily add it to reporting.
+    """
+    response_type = "none"
+    key_name = "none_key"
+
+    def __init__(self):
+        super(make_none, self).__init__(key="NONE_KEY")
diff --git a/insights/formats/text.py b/insights/formats/text.py
--- a/insights/formats/text.py
+++ b/insights/formats/text.py
@@ -90,9 +90,11 @@ def __init__(self, broker,
             tracebacks=False,
             dropped=False,
             fail_only=False,
+            none=False,
             stream=sys.stdout):
         self.broker = broker
         self.missing = missing
+        self.none = none
         self.tracebacks = tracebacks
         self.dropped = dropped
         self.fail_only = fail_only
@@ -115,7 +117,8 @@ def preprocess(self):
                                   title="Fingerprint : "),
             'metadata': response(color=Fore.YELLOW, label="META", intl='M', title="Metadata    : "),
             'metadata_key': response(color=Fore.MAGENTA, label="META", intl='K', title="Metadata Key: "),
-            'exception': response(color=Fore.RED, label="EXCEPT", intl='E', title="Exceptions  : ")
+            'exception': response(color=Fore.RED, label="EXCEPT", intl='E', title="Exceptions  : "),
+            'none': response(color=Fore.BLUE, label="RETURNED NONE", intl='N', title="Ret'd None  : ")
         }
 
         self.counts = {}
@@ -177,19 +180,26 @@ def printit(c, v):
             name = "%s%s%s" % (resp.color, name, Style.RESET_ALL)
             print(name, file=self.stream)
             print(underline, file=self.stream)
-            print(render_links(c), file=self.stream)
-            print(render(c, v), file=self.stream)
+            if v.get('type') != 'none':
+                print(render(c, v), file=self.stream)
             print(file=self.stream)
 
         for c in sorted(self.broker.get_by_type(rule), key=dr.get_name):
             v = self.broker[c]
             _type = v.get('type')
+            if _type is None:
+                continue
+
             if _type in self.responses:
                 self.counts[_type] += 1
-            if (_type and ((self.fail_only and _type == 'rule') or
-                           ((self.missing and _type == 'skip') or
-                            (not self.fail_only and _type != 'skip')))):
+
+            if ((self.fail_only and _type == 'rule') or
+                (self.missing and _type == 'skip') or
+                    (self.none and _type == 'none')):
                 printit(c, v)
+            elif not self.fail_only and _type not in ['skip', 'none']:
+                printit(c, v)
+
         print(file=self.stream)
 
         self.print_header("Rule Execution Summary", Fore.CYAN)
@@ -214,23 +224,31 @@ class HumanReadableFormatAdapter(FormatterAdapter):
     @staticmethod
     def configure(p):
         p.add_argument("-m", "--missing", help="Show missing requirements.", action="store_true")
+        p.add_argument("-n", "--none", help="Show rules returning None", action="store_true")
         p.add_argument("-t", "--tracebacks", help="Show stack traces.", action="store_true")
         p.add_argument("-d", "--dropped", help="Show collected files that weren't processed.", action="store_true")
-        p.add_argument("-F", "--fail-only", help="Show FAIL results only. Conflict with '-m' or '-f', will be dropped when using them together", action="store_true")
+        p.add_argument("-F", "--fail-only", help="Show FAIL results only. Conflict with '-m' and '-n' or '-f', will be dropped when using them together", action="store_true")
 
     def __init__(self, args):
         self.missing = args.missing
+        self.none = args.none
         self.tracebacks = args.tracebacks
         self.dropped = args.dropped
         self.fail_only = args.fail_only
         self.formatter = None
-        if self.missing and self.fail_only:
-            print(Fore.YELLOW + 'Options conflict: -m and -F, drops -F', file=sys.stderr)
+        if (self.missing or self.none) and self.fail_only:
+            print(Fore.YELLOW + 'Options conflict: -m/-n and -F, drops -F', file=sys.stderr)
             self.fail_only = False
 
     def preprocess(self, broker):
-        self.formatter = HumanReadableFormat(broker,
-                self.missing, self.tracebacks, self.dropped, self.fail_only)
+        self.formatter = HumanReadableFormat(
+            broker,
+            self.missing,
+            self.tracebacks,
+            self.dropped,
+            self.fail_only,
+            self.none
+        )
         self.formatter.preprocess()
 
     def postprocess(self, broker):


Test patch:
diff --git a/insights/tests/test_rules_fixture.py b/insights/tests/test_rules_fixture.py
--- a/insights/tests/test_rules_fixture.py
+++ b/insights/tests/test_rules_fixture.py
@@ -1,4 +1,4 @@
-from insights.core.plugins import make_pass, make_fail
+from insights.core.plugins import make_pass, make_fail, make_none
 from insights.specs import Specs
 from insights.plugins import rules_fixture_plugin
 from insights.tests import InputData
@@ -37,4 +37,5 @@ def test_rules_fixture(run_rule):
 
     input_data = InputData('test_ret_none')
     results = run_rule(rules_fixture_plugin.report, input_data)
-    assert results is None
+    expected = make_none()
+    assert results == expected