Skip to content

Commit fe4003b

Browse files
authored
Merge pull request #4642 from pallets/cli-nest
`FlaskGroup` can be nested
2 parents 97298e0 + aa801c4 commit fe4003b

File tree

5 files changed

+53
-39
lines changed

5 files changed

+53
-39
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ Unreleased
2525
- Added the ``View.init_every_request`` class attribute. If a view
2626
subclass sets this to ``False``, the view will not create a new
2727
instance on every request. :issue:`2520`.
28+
- A ``flask.cli.FlaskGroup`` Click group can be nested as a
29+
sub-command in a custom CLI. :issue:`3263`
30+
2831

2932
Version 2.1.3
3033
-------------

src/flask/app.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from threading import Lock
1111
from types import TracebackType
1212

13+
import click
1314
from werkzeug.datastructures import Headers
1415
from werkzeug.datastructures import ImmutableDict
1516
from werkzeug.exceptions import Aborter
@@ -23,6 +24,7 @@
2324
from werkzeug.routing import RequestRedirect
2425
from werkzeug.routing import RoutingException
2526
from werkzeug.routing import Rule
27+
from werkzeug.serving import is_running_from_reloader
2628
from werkzeug.urls import url_quote
2729
from werkzeug.utils import redirect as _wz_redirect
2830
from werkzeug.wrappers import Response as BaseResponse
@@ -908,12 +910,18 @@ def run(
908910
The default port is now picked from the ``SERVER_NAME``
909911
variable.
910912
"""
911-
# Change this into a no-op if the server is invoked from the
912-
# command line. Have a look at cli.py for more information.
913+
# Ignore this call so that it doesn't start another server if
914+
# the 'flask run' command is used.
913915
if os.environ.get("FLASK_RUN_FROM_CLI") == "true":
914-
from .debughelpers import explain_ignored_app_run
916+
if not is_running_from_reloader():
917+
click.secho(
918+
" * Ignoring a call to 'app.run()', the server is"
919+
" already being run with the 'flask run' command.\n"
920+
" Only call 'app.run()' in an 'if __name__ =="
921+
' "__main__"\' guard.',
922+
fg="red",
923+
)
915924

916-
explain_ignored_app_run()
917925
return
918926

919927
if get_load_dotenv(load_dotenv):

src/flask/cli.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import re
66
import sys
77
import traceback
8+
import typing as t
89
from functools import update_wrapper
910
from operator import attrgetter
1011
from threading import Lock
1112
from threading import Thread
1213

1314
import click
15+
from werkzeug.serving import is_running_from_reloader
1416
from werkzeug.utils import import_string
1517

1618
from .globals import current_app
@@ -273,7 +275,7 @@ def __init__(self, loader, use_eager_loading=None):
273275
self._bg_loading_exc = None
274276

275277
if use_eager_loading is None:
276-
use_eager_loading = os.environ.get("WERKZEUG_RUN_MAIN") != "true"
278+
use_eager_loading = not is_running_from_reloader()
277279

278280
if use_eager_loading:
279281
self._load_unlocked()
@@ -477,7 +479,13 @@ def __init__(
477479
if add_version_option:
478480
params.append(version_option)
479481

480-
AppGroup.__init__(self, params=params, **extra)
482+
if "context_settings" not in extra:
483+
extra["context_settings"] = {}
484+
485+
extra["context_settings"].setdefault("auto_envvar_prefix", "FLASK")
486+
487+
super().__init__(params=params, **extra)
488+
481489
self.create_app = create_app
482490
self.load_dotenv = load_dotenv
483491
self.set_debug_flag = set_debug_flag
@@ -545,26 +553,22 @@ def list_commands(self, ctx):
545553

546554
return sorted(rv)
547555

548-
def main(self, *args, **kwargs):
549-
# Set a global flag that indicates that we were invoked from the
550-
# command line interface. This is detected by Flask.run to make the
551-
# call into a no-op. This is necessary to avoid ugly errors when the
552-
# script that is loaded here also attempts to start a server.
553-
os.environ["FLASK_RUN_FROM_CLI"] = "true"
554-
556+
def make_context(
557+
self,
558+
info_name: t.Optional[str],
559+
args: t.List[str],
560+
parent: t.Optional[click.Context] = None,
561+
**extra: t.Any,
562+
) -> click.Context:
555563
if get_load_dotenv(self.load_dotenv):
556564
load_dotenv()
557565

558-
obj = kwargs.get("obj")
559-
560-
if obj is None:
561-
obj = ScriptInfo(
566+
if "obj" not in extra and "obj" not in self.context_settings:
567+
extra["obj"] = ScriptInfo(
562568
create_app=self.create_app, set_debug_flag=self.set_debug_flag
563569
)
564570

565-
kwargs["obj"] = obj
566-
kwargs.setdefault("auto_envvar_prefix", "FLASK")
567-
return super().main(*args, **kwargs)
571+
return super().make_context(info_name, args, parent=parent, **extra)
568572

569573

570574
def _path_is_ancestor(path, other):
@@ -637,7 +641,7 @@ def show_server_banner(env, debug, app_import_path, eager_loading):
637641
"""Show extra startup messages the first time the server is run,
638642
ignoring the reloader.
639643
"""
640-
if os.environ.get("WERKZEUG_RUN_MAIN") == "true":
644+
if is_running_from_reloader():
641645
return
642646

643647
if app_import_path is not None:
@@ -653,10 +657,10 @@ def show_server_banner(env, debug, app_import_path, eager_loading):
653657
if env == "production":
654658
click.secho(
655659
" WARNING: This is a development server. Do not use it in"
656-
" a production deployment.",
660+
" a production deployment.\n Use a production WSGI server"
661+
" instead.",
657662
fg="red",
658663
)
659-
click.secho(" Use a production WSGI server instead.", dim=True)
660664

661665
if debug is not None:
662666
click.echo(f" * Debug mode: {'on' if debug else 'off'}")
@@ -963,6 +967,7 @@ def routes_command(sort: str, all_methods: bool) -> None:
963967

964968

965969
cli = FlaskGroup(
970+
name="flask",
966971
help="""\
967972
A general utility script for Flask applications.
968973
@@ -978,7 +983,7 @@ def routes_command(sort: str, all_methods: bool) -> None:
978983
""".format(
979984
cmd="export" if os.name == "posix" else "set",
980985
prefix="$ " if os.name == "posix" else "> ",
981-
)
986+
),
982987
)
983988

984989

src/flask/debughelpers.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import os
21
import typing as t
3-
from warnings import warn
42

53
from .app import Flask
64
from .blueprints import Blueprint
@@ -159,16 +157,3 @@ def explain_template_loading_attempts(app: Flask, template, attempts) -> None:
159157
info.append(" See https://flask.palletsprojects.com/blueprints/#templates")
160158

161159
app.logger.info("\n".join(info))
162-
163-
164-
def explain_ignored_app_run() -> None:
165-
if os.environ.get("WERKZEUG_RUN_MAIN") != "true":
166-
warn(
167-
Warning(
168-
"Silently ignoring app.run() because the application is"
169-
" run from the flask command line executable. Consider"
170-
' putting app.run() behind an if __name__ == "__main__"'
171-
" guard to silence this warning."
172-
),
173-
stacklevel=3,
174-
)

tests/test_cli.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,19 @@ def test():
388388
assert result.output == f"{not set_debug_flag}\n"
389389

390390

391+
def test_flaskgroup_nested(app, runner):
392+
cli = click.Group("cli")
393+
flask_group = FlaskGroup(name="flask", create_app=lambda: app)
394+
cli.add_command(flask_group)
395+
396+
@flask_group.command()
397+
def show():
398+
click.echo(current_app.name)
399+
400+
result = runner.invoke(cli, ["flask", "show"])
401+
assert result.output == "flask_test\n"
402+
403+
391404
def test_no_command_echo_loading_error():
392405
from flask.cli import cli
393406

0 commit comments

Comments
 (0)