diff --git a/CHANGES.rst b/CHANGES.rst index ce85e67340..84ffbf0618 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -25,6 +25,9 @@ Unreleased - Added the ``View.init_every_request`` class attribute. If a view subclass sets this to ``False``, the view will not create a new instance on every request. :issue:`2520`. +- A ``flask.cli.FlaskGroup`` Click group can be nested as a + sub-command in a custom CLI. :issue:`3263` + Version 2.1.3 ------------- diff --git a/src/flask/app.py b/src/flask/app.py index 65e95623d6..360916dbf5 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -10,6 +10,7 @@ from threading import Lock from types import TracebackType +import click from werkzeug.datastructures import Headers from werkzeug.datastructures import ImmutableDict from werkzeug.exceptions import Aborter @@ -23,6 +24,7 @@ from werkzeug.routing import RequestRedirect from werkzeug.routing import RoutingException from werkzeug.routing import Rule +from werkzeug.serving import is_running_from_reloader from werkzeug.urls import url_quote from werkzeug.utils import redirect as _wz_redirect from werkzeug.wrappers import Response as BaseResponse @@ -908,12 +910,18 @@ def run( The default port is now picked from the ``SERVER_NAME`` variable. """ - # Change this into a no-op if the server is invoked from the - # command line. Have a look at cli.py for more information. + # Ignore this call so that it doesn't start another server if + # the 'flask run' command is used. if os.environ.get("FLASK_RUN_FROM_CLI") == "true": - from .debughelpers import explain_ignored_app_run + if not is_running_from_reloader(): + click.secho( + " * Ignoring a call to 'app.run()', the server is" + " already being run with the 'flask run' command.\n" + " Only call 'app.run()' in an 'if __name__ ==" + ' "__main__"\' guard.', + fg="red", + ) - explain_ignored_app_run() return if get_load_dotenv(load_dotenv): diff --git a/src/flask/cli.py b/src/flask/cli.py index 77c1e25a9c..a4e366d7c7 100644 --- a/src/flask/cli.py +++ b/src/flask/cli.py @@ -5,12 +5,14 @@ import re import sys import traceback +import typing as t from functools import update_wrapper from operator import attrgetter from threading import Lock from threading import Thread import click +from werkzeug.serving import is_running_from_reloader from werkzeug.utils import import_string from .globals import current_app @@ -273,7 +275,7 @@ def __init__(self, loader, use_eager_loading=None): self._bg_loading_exc = None if use_eager_loading is None: - use_eager_loading = os.environ.get("WERKZEUG_RUN_MAIN") != "true" + use_eager_loading = not is_running_from_reloader() if use_eager_loading: self._load_unlocked() @@ -477,7 +479,13 @@ def __init__( if add_version_option: params.append(version_option) - AppGroup.__init__(self, params=params, **extra) + if "context_settings" not in extra: + extra["context_settings"] = {} + + extra["context_settings"].setdefault("auto_envvar_prefix", "FLASK") + + super().__init__(params=params, **extra) + self.create_app = create_app self.load_dotenv = load_dotenv self.set_debug_flag = set_debug_flag @@ -545,26 +553,22 @@ def list_commands(self, ctx): return sorted(rv) - def main(self, *args, **kwargs): - # Set a global flag that indicates that we were invoked from the - # command line interface. This is detected by Flask.run to make the - # call into a no-op. This is necessary to avoid ugly errors when the - # script that is loaded here also attempts to start a server. - os.environ["FLASK_RUN_FROM_CLI"] = "true" - + def make_context( + self, + info_name: t.Optional[str], + args: t.List[str], + parent: t.Optional[click.Context] = None, + **extra: t.Any, + ) -> click.Context: if get_load_dotenv(self.load_dotenv): load_dotenv() - obj = kwargs.get("obj") - - if obj is None: - obj = ScriptInfo( + if "obj" not in extra and "obj" not in self.context_settings: + extra["obj"] = ScriptInfo( create_app=self.create_app, set_debug_flag=self.set_debug_flag ) - kwargs["obj"] = obj - kwargs.setdefault("auto_envvar_prefix", "FLASK") - return super().main(*args, **kwargs) + return super().make_context(info_name, args, parent=parent, **extra) def _path_is_ancestor(path, other): @@ -637,7 +641,7 @@ def show_server_banner(env, debug, app_import_path, eager_loading): """Show extra startup messages the first time the server is run, ignoring the reloader. """ - if os.environ.get("WERKZEUG_RUN_MAIN") == "true": + if is_running_from_reloader(): return if app_import_path is not None: @@ -653,10 +657,10 @@ def show_server_banner(env, debug, app_import_path, eager_loading): if env == "production": click.secho( " WARNING: This is a development server. Do not use it in" - " a production deployment.", + " a production deployment.\n Use a production WSGI server" + " instead.", fg="red", ) - click.secho(" Use a production WSGI server instead.", dim=True) if debug is not None: click.echo(f" * Debug mode: {'on' if debug else 'off'}") @@ -963,6 +967,7 @@ def routes_command(sort: str, all_methods: bool) -> None: cli = FlaskGroup( + name="flask", help="""\ A general utility script for Flask applications. @@ -978,7 +983,7 @@ def routes_command(sort: str, all_methods: bool) -> None: """.format( cmd="export" if os.name == "posix" else "set", prefix="$ " if os.name == "posix" else "> ", - ) + ), ) diff --git a/src/flask/debughelpers.py b/src/flask/debughelpers.py index 27d378c24a..b1e3ce1bc7 100644 --- a/src/flask/debughelpers.py +++ b/src/flask/debughelpers.py @@ -1,6 +1,4 @@ -import os import typing as t -from warnings import warn from .app import Flask from .blueprints import Blueprint @@ -159,16 +157,3 @@ def explain_template_loading_attempts(app: Flask, template, attempts) -> None: info.append(" See https://flask.palletsprojects.com/blueprints/#templates") app.logger.info("\n".join(info)) - - -def explain_ignored_app_run() -> None: - if os.environ.get("WERKZEUG_RUN_MAIN") != "true": - warn( - Warning( - "Silently ignoring app.run() because the application is" - " run from the flask command line executable. Consider" - ' putting app.run() behind an if __name__ == "__main__"' - " guard to silence this warning." - ), - stacklevel=3, - ) diff --git a/tests/test_cli.py b/tests/test_cli.py index c9dd5ade06..7a8e9af9cb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -388,6 +388,19 @@ def test(): assert result.output == f"{not set_debug_flag}\n" +def test_flaskgroup_nested(app, runner): + cli = click.Group("cli") + flask_group = FlaskGroup(name="flask", create_app=lambda: app) + cli.add_command(flask_group) + + @flask_group.command() + def show(): + click.echo(current_app.name) + + result = runner.invoke(cli, ["flask", "show"]) + assert result.output == "flask_test\n" + + def test_no_command_echo_loading_error(): from flask.cli import cli