Repository: pallets/click

Issue:
standalone_mode does not affect the help option
If we use `standalone_mode=True` passing arguments to command execution will not call `sys.exit`, except if the argument to the command is `["--help"]`.

From inside ipython:

> In [12]: import click
> 
> In [13]: @click.command()
>     ...: def hello():
>     ...:     print("hello, world")
>     ...:
> 
> In [14]: hello.main([], standalone_mode=False)
> hello, world
> 
> In [15]: hello.main(['wrong_arg'], standalone_mode=False)
> ---
> 
> UsageError                                Traceback (most recent call last)
> <ipython-input-15-c2bf17950330> in <module>()
> ...
> UsageError: Got unexpected extra argument (wrong_arg)
> 
> In [16]: hello.main(['--help'], standalone_mode=False)
> Usage: ipython [OPTIONS]
> 
> Options:
>   --help  Show this message and exit.
> An exception has occurred, use %tb to see the full traceback.
> 
> SystemExit: 0
> 
> /Users/malkarouri/Library/Python/2.7/lib/python/site-packages/IPython/core/interactiveshell.py:2889: UserWarning: To exit: use 'exit', 'quit', or Ctrl-D.
>   warn("To exit: use 'exit', 'quit', or Ctrl-D.", stacklevel=1)

Expected:
`--help` parameter in standalone mode does not call `sys.exit`.



Discussion:

Resolving patch:
diff --git a/click/core.py b/click/core.py
--- a/click/core.py
+++ b/click/core.py
@@ -9,7 +9,7 @@
 from .types import convert_type, IntRange, BOOL
 from .utils import make_str, make_default_short_help, echo, get_os_args
 from .exceptions import ClickException, UsageError, BadParameter, Abort, \
-     MissingParameter
+     MissingParameter, Exit
 from .termui import prompt, confirm, style
 from .formatting import HelpFormatter, join_options
 from .parser import OptionParser, split_opt
@@ -498,7 +498,7 @@ def abort(self):
 
     def exit(self, code=0):
         """Exits the application with a given exit code."""
-        sys.exit(code)
+        raise Exit(code)
 
     def get_usage(self):
         """Helper method to get formatted usage string for the current
@@ -714,6 +714,13 @@ def main(self, args=None, prog_name=None, complete_var=None,
                     rv = self.invoke(ctx)
                     if not standalone_mode:
                         return rv
+                    # it's not safe to `ctx.exit(rv)` here!
+                    # note that `rv` may actually contain data like "1" which
+                    # has obvious effects
+                    # more subtle case: `rv=[None, None]` can come out of
+                    # chained commands which all returned `None` -- so it's not
+                    # even always obvious that `rv` indicates success/failure
+                    # by its truthiness/falsiness
                     ctx.exit()
             except (EOFError, KeyboardInterrupt):
                 echo(file=sys.stderr)
@@ -728,6 +735,19 @@ def main(self, args=None, prog_name=None, complete_var=None,
                     sys.exit(1)
                 else:
                     raise
+        except Exit as e:
+            if standalone_mode:
+                sys.exit(e.exit_code)
+            else:
+                # in non-standalone mode, return the exit code
+                # note that this is only reached if `self.invoke` above raises
+                # an Exit explicitly -- thus bypassing the check there which
+                # would return its result
+                # the results of non-standalone execution may therefore be
+                # somewhat ambiguous: if there are codepaths which lead to
+                # `ctx.exit(1)` and to `return 1`, the caller won't be able to
+                # tell the difference between the two
+                return e.exit_code
         except Abort:
             if not standalone_mode:
                 raise
diff --git a/click/exceptions.py b/click/exceptions.py
--- a/click/exceptions.py
+++ b/click/exceptions.py
@@ -223,3 +223,13 @@ def format_message(self):
 
 class Abort(RuntimeError):
     """An internal signalling exception that signals Click to abort."""
+
+
+class Exit(RuntimeError):
+    """An exception that indicates that the application should exit with some
+    status code.
+
+    :param code: the status code to exit with.
+    """
+    def __init__(self, code=0):
+        self.exit_code = code


Test patch:
diff --git a/tests/test_context.py b/tests/test_context.py
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -193,6 +193,7 @@ def test_close_before_pop(runner):
     @click.pass_context
     def cli(ctx):
         ctx.obj = 'test'
+
         @ctx.call_on_close
         def foo():
             assert click.get_current_context().obj == 'test'
@@ -239,3 +240,19 @@ def test2(ctx, foo):
     result = runner.invoke(cli, ['test2'])
     assert not result.exception
     assert result.output == 'foocmd\n'
+
+
+def test_exit_not_standalone():
+    @click.command()
+    @click.pass_context
+    def cli(ctx):
+        ctx.exit(1)
+
+    assert cli.main([], 'test_exit_not_standalone', standalone_mode=False) == 1
+
+    @click.command()
+    @click.pass_context
+    def cli(ctx):
+        ctx.exit(0)
+
+    assert cli.main([], 'test_exit_not_standalone', standalone_mode=False) == 0
diff --git a/tests/test_testing.py b/tests/test_testing.py
--- a/tests/test_testing.py
+++ b/tests/test_testing.py
@@ -153,16 +153,34 @@ def cli_string():
         click.echo('hello world')
         sys.exit('error')
 
+    @click.command()
+    @click.pass_context
+    def cli_string_ctx_exit(ctx):
+        click.echo('hello world')
+        ctx.exit('error')
+
     @click.command()
     def cli_int():
         click.echo('hello world')
         sys.exit(1)
 
+    @click.command()
+    @click.pass_context
+    def cli_int_ctx_exit(ctx):
+        click.echo('hello world')
+        ctx.exit(1)
+
     @click.command()
     def cli_float():
         click.echo('hello world')
         sys.exit(1.0)
 
+    @click.command()
+    @click.pass_context
+    def cli_float_ctx_exit(ctx):
+        click.echo('hello world')
+        ctx.exit(1.0)
+
     @click.command()
     def cli_no_error():
         click.echo('hello world')
@@ -173,14 +191,26 @@ def cli_no_error():
     assert result.exit_code == 1
     assert result.output == 'hello world\nerror\n'
 
+    result = runner.invoke(cli_string_ctx_exit)
+    assert result.exit_code == 1
+    assert result.output == 'hello world\nerror\n'
+
     result = runner.invoke(cli_int)
     assert result.exit_code == 1
     assert result.output == 'hello world\n'
 
+    result = runner.invoke(cli_int_ctx_exit)
+    assert result.exit_code == 1
+    assert result.output == 'hello world\n'
+
     result = runner.invoke(cli_float)
     assert result.exit_code == 1
     assert result.output == 'hello world\n1.0\n'
 
+    result = runner.invoke(cli_float_ctx_exit)
+    assert result.exit_code == 1
+    assert result.output == 'hello world\n1.0\n'
+
     result = runner.invoke(cli_no_error)
     assert result.exit_code == 0
     assert result.output == 'hello world\n'