From ec1ccd753084c6ff3215b9a64ba46d6af56715bd Mon Sep 17 00:00:00 2001 From: Anthony Plunkett Date: Mon, 14 May 2018 22:05:54 -0400 Subject: [PATCH] Add Blueprint level cli command registration Implements #1357. Adds ability to register click cli commands onto blueprint. --- CHANGES.rst | 3 +++ docs/cli.rst | 56 +++++++++++++++++++++++++++++++++++++++++++++ flask/app.py | 10 +++----- flask/blueprints.py | 21 +++++++++++++++++ flask/helpers.py | 9 ++++++++ tests/test_cli.py | 45 +++++++++++++++++++++++++++++++++++- 6 files changed, 136 insertions(+), 8 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index b3c092b7..b006b732 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -56,6 +56,9 @@ Unreleased returning a string will produce a ``text/html`` response, returning a dict will call ``jsonify`` to produce a ``application/json`` response. :pr:`3111` +- Blueprints have a ``cli`` Click group like ``app.cli``. CLI commands + registered with a blueprint will be available as a group under the + ``flask`` command. :issue:`1357`. .. _#2935: https://github.com/pallets/flask/issues/2935 .. _#2957: https://github.com/pallets/flask/issues/2957 diff --git a/docs/cli.rst b/docs/cli.rst index 5a05be9f..211effb2 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -310,10 +310,66 @@ group. This is useful if you want to organize multiple related commands. :: $ flask user create demo + See :ref:`testing-cli` for an overview of how to test your custom commands. +Registering Commands with Blueprints +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If your application uses blueprints, you can optionally register CLI +commands directly onto them. When your blueprint is registered onto your +application, the associated commands will be available to the ``flask`` +command. By default, those commands will be nested in a group matching +the name of the blueprint. + +.. code-block:: python + + from flask import Blueprint + + bp = Blueprint('students', __name__) + + @bp.cli.command('create') + @click.argument('name') + def create(name): + ... + + app.register_blueprint(bp) + +.. code-block:: text + + $ flask students create alice + +You can alter the group name by specifying the ``cli_group`` parameter +when creating the :class:`Blueprint` object, or later with +:meth:`app.register_blueprint(bp, cli_group='...') `. +The following are equivalent: + +.. code-block:: python + + bp = Blueprint('students', __name__, cli_group='other') + # or + app.register_blueprint(bp, cli_group='other') + +.. code-block:: text + + $ flask other create alice + +Specifying ``cli_group=None`` will remove the nesting and merge the +commands directly to the application's level: + +.. code-block:: python + + bp = Blueprint('students', __name__, cli_group=None) + # or + app.register_blueprint(bp, cli_group=None) + +.. code-block:: text + + $ flask create alice + + Application Context ~~~~~~~~~~~~~~~~~~~ diff --git a/flask/app.py b/flask/app.py index b0b2bc26..7a2c7dc4 100644 --- a/flask/app.py +++ b/flask/app.py @@ -600,13 +600,9 @@ class Flask(_PackageBoundObject): view_func=self.send_static_file, ) - #: The click command line context for this application. Commands - #: registered here show up in the :command:`flask` command once the - #: application has been discovered. The default commands are - #: provided by Flask itself and can be overridden. - #: - #: This is an instance of a :class:`click.Group` object. - self.cli = cli.AppGroup(self.name) + # Set the name of the Click group in case someone wants to add + # the app's commands to another CLI tool. + self.cli.name = self.name @locked_cached_property def name(self): diff --git a/flask/blueprints.py b/flask/blueprints.py index aea8ecec..eb3ce7aa 100644 --- a/flask/blueprints.py +++ b/flask/blueprints.py @@ -13,6 +13,9 @@ from functools import update_wrapper from .helpers import _PackageBoundObject, _endpoint_from_view_func +# a singleton sentinel value for parameter defaults +_sentinel = object() + class BlueprintSetupState(object): """Temporary holder object for registering a blueprint with the @@ -90,6 +93,11 @@ class Blueprint(_PackageBoundObject): or other things on the main application. See :ref:`blueprints` for more information. + .. versionchanged:: 1.1.0 + Blueprints have a ``cli`` group to register nested CLI commands. + The ``cli_group`` parameter controls the name of the group under + the ``flask`` command. + .. versionadded:: 0.7 """ @@ -129,6 +137,7 @@ class Blueprint(_PackageBoundObject): subdomain=None, url_defaults=None, root_path=None, + cli_group=_sentinel, ): _PackageBoundObject.__init__( self, import_name, template_folder, root_path=root_path @@ -142,6 +151,7 @@ class Blueprint(_PackageBoundObject): if url_defaults is None: url_defaults = {} self.url_values_defaults = url_defaults + self.cli_group = cli_group def record(self, func): """Registers a function that is called when the blueprint is @@ -206,6 +216,17 @@ class Blueprint(_PackageBoundObject): for deferred in self.deferred_functions: deferred(state) + cli_resolved_group = options.get("cli_group", self.cli_group) + + if cli_resolved_group is None: + app.cli.commands.update(self.cli.commands) + elif cli_resolved_group is _sentinel: + self.cli.name = self.name + app.cli.add_command(self.cli) + else: + self.cli.name = cli_resolved_group + app.cli.add_command(self.cli) + def route(self, rule, **options): """Like :meth:`Flask.route` but for a blueprint. The endpoint for the :func:`url_for` function is prefixed with the name of the blueprint. diff --git a/flask/helpers.py b/flask/helpers.py index c5e85a02..33e87b70 100644 --- a/flask/helpers.py +++ b/flask/helpers.py @@ -942,6 +942,15 @@ class _PackageBoundObject(object): self._static_folder = None self._static_url_path = None + # circular import + from .cli import AppGroup + + #: The Click command group for registration of CLI commands + #: on the application and associated blueprints. These commands + #: are accessible via the :command:`flask` command once the + #: application has been discovered and blueprints registered. + self.cli = AppGroup() + def _get_static_folder(self): if self._static_folder is not None: return os.path.join(self.root_path, self._static_folder) diff --git a/tests/test_cli.py b/tests/test_cli.py index 25e7ab1a..2efcbaa8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,7 +23,7 @@ import pytest from _pytest.monkeypatch import notset from click.testing import CliRunner -from flask import Flask, current_app +from flask import Flask, current_app, Blueprint from flask.cli import ( AppGroup, FlaskGroup, @@ -609,3 +609,46 @@ def test_run_cert_import(monkeypatch): # no --key with SSLContext with pytest.raises(click.BadParameter): run_command.make_context("run", ["--cert", "ssl_context", "--key", __file__]) + + +def test_cli_blueprints(app): + """Test blueprint commands register correctly to the application""" + custom = Blueprint("custom", __name__, cli_group="customized") + nested = Blueprint("nested", __name__) + merged = Blueprint("merged", __name__, cli_group=None) + late = Blueprint("late", __name__) + + @custom.cli.command("custom") + def custom_command(): + click.echo("custom_result") + + @nested.cli.command("nested") + def nested_command(): + click.echo("nested_result") + + @merged.cli.command("merged") + def merged_command(): + click.echo("merged_result") + + @late.cli.command("late") + def late_command(): + click.echo("late_result") + + app.register_blueprint(custom) + app.register_blueprint(nested) + app.register_blueprint(merged) + app.register_blueprint(late, cli_group="late_registration") + + app_runner = app.test_cli_runner() + + result = app_runner.invoke(args=["customized", "custom"]) + assert "custom_result" in result.output + + result = app_runner.invoke(args=["nested", "nested"]) + assert "nested_result" in result.output + + result = app_runner.invoke(args=["merged"]) + assert "merged_result" in result.output + + result = app_runner.invoke(args=["late_registration", "late"]) + assert "late_result" in result.output