Merge pull request #3711 from pallets/cli-loading-error

cleaner message when CLI can't load app
This commit is contained in:
David Lord 2020-07-30 18:48:45 -07:00 committed by GitHub
commit 6638432457
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 59 additions and 38 deletions

View file

@ -12,6 +12,8 @@ Unreleased
- Passing ``script_info`` to app factory functions is deprecated. This - Passing ``script_info`` to app factory functions is deprecated. This
was not portable outside the ``flask`` command. Use was not portable outside the ``flask`` command. Use
``click.get_current_context().obj`` if it's needed. :issue:`3552` ``click.get_current_context().obj`` if it's needed. :issue:`3552`
- The CLI shows better error messages when the app failed to load
when looking up commands. :issue:`2741`
- Add :meth:`sessions.SessionInterface.get_cookie_name` to allow - Add :meth:`sessions.SessionInterface.get_cookie_name` to allow
setting the session cookie name dynamically. :pr:`3369` setting the session cookie name dynamically. :pr:`3369`
- Add :meth:`Config.from_file` to load config using arbitrary file - Add :meth:`Config.from_file` to load config using arbitrary file

View file

@ -536,43 +536,41 @@ class FlaskGroup(AppGroup):
def get_command(self, ctx, name): def get_command(self, ctx, name):
self._load_plugin_commands() self._load_plugin_commands()
# Look up built-in and plugin commands, which should be
# available even if the app fails to load.
rv = super().get_command(ctx, name)
# We load built-in commands first as these should always be the
# same no matter what the app does. If the app does want to
# override this it needs to make a custom instance of this group
# and not attach the default commands.
#
# This also means that the script stays functional in case the
# application completely fails.
rv = AppGroup.get_command(self, ctx, name)
if rv is not None: if rv is not None:
return rv return rv
info = ctx.ensure_object(ScriptInfo) info = ctx.ensure_object(ScriptInfo)
# Look up commands provided by the app, showing an error and
# continuing if the app couldn't be loaded.
try: try:
rv = info.load_app().cli.get_command(ctx, name) return info.load_app().cli.get_command(ctx, name)
if rv is not None: except NoAppException as e:
return rv click.secho(f"Error: {e.format_message()}\n", err=True, fg="red")
except NoAppException:
pass
def list_commands(self, ctx): def list_commands(self, ctx):
self._load_plugin_commands() self._load_plugin_commands()
# Start with the built-in and plugin commands.
# The commands available is the list of both the application (if rv = set(super().list_commands(ctx))
# available) plus the builtin commands.
rv = set(click.Group.list_commands(self, ctx))
info = ctx.ensure_object(ScriptInfo) info = ctx.ensure_object(ScriptInfo)
# Add commands provided by the app, showing an error and
# continuing if the app couldn't be loaded.
try: try:
rv.update(info.load_app().cli.list_commands(ctx)) rv.update(info.load_app().cli.list_commands(ctx))
except NoAppException as e:
# When an app couldn't be loaded, show the error message
# without the traceback.
click.secho(f"Error: {e.format_message()}\n", err=True, fg="red")
except Exception: except Exception:
# Here we intentionally swallow all exceptions as we don't # When any other errors occurred during loading, show the
# want the help page to break if the app does not exist. # full traceback.
# If someone attempts to use the command we try to create click.secho(f"{traceback.format_exc()}\n", err=True, fg="red")
# the app again and this will give us the error.
# However, we will not do so silently because that would confuse
# users.
traceback.print_exc()
return sorted(rv) return sorted(rv)
def main(self, *args, **kwargs): def main(self, *args, **kwargs):

View file

@ -73,9 +73,15 @@ def client(app):
@pytest.fixture @pytest.fixture
def test_apps(monkeypatch): def test_apps(monkeypatch):
monkeypatch.syspath_prepend( monkeypatch.syspath_prepend(os.path.join(os.path.dirname(__file__), "test_apps"))
os.path.abspath(os.path.join(os.path.dirname(__file__), "test_apps")) original_modules = set(sys.modules.keys())
)
yield
# Remove any imports cached during the test. Otherwise "import app"
# will work in the next test even though it's no longer on the path.
for key in sys.modules.keys() - original_modules:
sys.modules.pop(key)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)

View file

@ -239,7 +239,7 @@ def test_locate_app_raises(test_apps, iname, aname):
locate_app(info, iname, aname) locate_app(info, iname, aname)
def test_locate_app_suppress_raise(): def test_locate_app_suppress_raise(test_apps):
info = ScriptInfo() info = ScriptInfo()
app = locate_app(info, "notanapp.py", None, raise_if_not_found=False) app = locate_app(info, "notanapp.py", None, raise_if_not_found=False)
assert app is None assert app is None
@ -396,21 +396,36 @@ def test_flaskgroup_debug(runner, set_debug_flag):
assert result.output == f"{not set_debug_flag}\n" assert result.output == f"{not set_debug_flag}\n"
def test_print_exceptions(runner): def test_no_command_echo_loading_error():
"""Print the stacktrace if the CLI.""" from flask.cli import cli
def create_app(): runner = CliRunner(mix_stderr=False)
raise Exception("oh no") result = runner.invoke(cli, ["missing"])
return Flask("flaskgroup") assert result.exit_code == 2
assert "FLASK_APP" in result.stderr
assert "Usage:" in result.stderr
@click.group(cls=FlaskGroup, create_app=create_app)
def cli(**params):
pass
def test_help_echo_loading_error():
from flask.cli import cli
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["--help"]) result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "Exception: oh no" in result.output assert "FLASK_APP" in result.stderr
assert "Traceback" in result.output assert "Usage:" in result.stdout
def test_help_echo_exception():
def create_app():
raise Exception("oh no")
cli = FlaskGroup(create_app=create_app)
runner = CliRunner(mix_stderr=False)
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "Exception: oh no" in result.stderr
assert "Usage:" in result.stdout
class TestRoutes: class TestRoutes: