Repository: pallets/click
Pull request #1621 (2020-07-06 16:09:17)

non-chained group invoked without subcommand invokes result callback
As mentioned in https://github.com/pallets/click/issues/1178 this behavior is actually desired. I believe that the changes to the comments seem to do the job but I am not 100%. Apart from that the simple code fix I made I'm not too sure if more changes are needed else where where result callbacks are referenced.

Added a simple test to show the functionality with my addition.

IamCathal: Rebased commits and added changelog entry

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 0fce1bc..adb5805 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -29,6 +29,8 @@ Unreleased
     ``Context.invoked_subcommand`` consistent when using patterns like
     ``AliasedGroup``. :issue:`1422`
 -   The ``BOOL`` type accepts the values "on" and "off". :issue:`1629`
+-   A ``Group`` with ``invoke_without_command=True`` will always invoke
+    its result callback. :issue:`1178`
 
 
 Version 7.1.2
diff --git a/src/click/core.py b/src/click/core.py
index 22f7420..9651deb 100644
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -1170,7 +1170,7 @@ class MultiCommand(Command):
         self.format_commands(ctx, formatter)
 
     def resultcallback(self, replace=False):
-        """Adds a result callback to the chain command.  By default if a
+        """Adds a result callback to the command.  By default if a
         result callback is already registered this will chain them but
         this can be disabled with the `replace` parameter.  The result
         callback is invoked with the return value of the subcommand
@@ -1189,10 +1189,10 @@ class MultiCommand(Command):
             def process_result(result, input):
                 return result + input
 
-        .. versionadded:: 3.0
-
         :param replace: if set to `True` an already existing result
                         callback will be removed.
+
+        .. versionadded:: 3.0
         """
 
         def decorator(f):
@@ -1258,18 +1258,13 @@ class MultiCommand(Command):
             return value
 
         if not ctx.protected_args:
-            # If we are invoked without command the chain flag controls
-            # how this happens.  If we are not in chain mode, the return
-            # value here is the return value of the command.
-            # If however we are in chain mode, the return value is the
-            # return value of the result processor invoked with an empty
-            # list (which means that no subcommand actually was executed).
             if self.invoke_without_command:
-                if not self.chain:
-                    return Command.invoke(self, ctx)
+                # No subcommand was invoked, so the result callback is
+                # invoked with None for regular groups, or an empty list
+                # for chained groups.
                 with ctx:
                     Command.invoke(self, ctx)
-                    return _process_result([])
+                    return _process_result([] if self.chain else None)
             ctx.fail("Missing command.")
 
         # Fetch args back out


Test patch:
diff --git a/tests/test_chain.py b/tests/test_chain.py
index 0462779..74a04f1 100644
--- a/tests/test_chain.py
+++ b/tests/test_chain.py
@@ -88,6 +88,25 @@ def test_chaining_with_options(runner):
     assert result.output.splitlines() == ["bdist called 1", "sdist called 2"]
 
 
+@pytest.mark.parametrize(("chain", "expect"), [(False, "None"), (True, "[]")])
+def test_no_command_result_callback(runner, chain, expect):
+    """When a group has ``invoke_without_command=True``, the result
+    callback is always invoked. A regular group invokes it with
+    ``None``, a chained group with ``[]``.
+    """
+
+    @click.group(invoke_without_command=True, chain=chain)
+    def cli():
+        pass
+
+    @cli.resultcallback()
+    def process_result(result):
+        click.echo(str(result), nl=False)
+
+    result = runner.invoke(cli, [])
+    assert result.output == expect
+
+
 def test_chaining_with_arguments(runner):
     @click.group(chain=True)
     def cli():