mirror of https://github.com/pallets/flask.git
cleaner message when CLI can't load app
When loading the app fails for the --help command, only the error message is shown, then the help text. The full traceback is shown for other exceptions. Also show the message when loading fails while getting a command, instead of only "command not found". The error message goes to stderr to match other error behavior, and is in red with an extra newline to make it more obvious next to the help text. Also fixes an issue with the test_apps fixture that caused an imported app to still be importable after the test was over and the path was reset. Now the module cache is reset as well.
This commit is contained in:
parent
fd0a608449
commit
253570784c
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
Loading…
Reference in New Issue