Skip to content

Commit af45880

Browse files
committed
BREAKING CHANGE: adjust cli API on KegApp
- `KegApp.cli` is now a `keg.cli.KegAppGroup` instance, which is a subclass of `Click.Group` and knows how to instantiate the Keg app when needed. - `KegApp.command()` is no longer available. Use `.cli.command()` instead. - `KegApp.cli_run()` is no longer available. Use `.cli.main()` or it's alias `.cl()` instead. See keg_apps.cli2 for example usage on a minimal app.
2 parents d9f6e17 + a35a229 commit af45880

File tree

14 files changed

+207
-562
lines changed

14 files changed

+207
-562
lines changed

keg/_flask_cli.py

Lines changed: 0 additions & 506 deletions
This file was deleted.

keg/app.py

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@ class KegAppError(Exception):
2424

2525
class Keg(flask.Flask):
2626
import_name = None
27-
use_blueprints = []
28-
oauth_providers = []
27+
use_blueprints = ()
28+
oauth_providers = ()
2929
keyring_enabled = ConfigAttribute('KEG_KEYRING_ENABLE')
3030
config_class = keg.config.Config
3131
logging_class = keg.logging.Logging
3232
keyring_manager_class = None
3333

34+
_cli = None
35+
cli_loader_class = keg.cli.CLILoader
36+
3437
db_enabled = False
3538
db_visit_modules = ['.model.entities']
3639
db_manager = None
@@ -39,8 +42,8 @@ class Keg(flask.Flask):
3942
extensions=['jinja2.ext.autoescape', 'jinja2.ext.with_', AssetsExtension]
4043
)
4144

42-
template_filters = {}
43-
template_globals = {}
45+
template_filters = ImmutableDict()
46+
template_globals = ImmutableDict()
4447

4548
visit_modules = False
4649

@@ -194,23 +197,12 @@ def handle_server_error(self, error):
194197
def request_context(self, environ):
195198
return KegRequestContext(self, environ)
196199

197-
@classproperty
198-
def cli_group(cls): # noqa
199-
if not hasattr(cls, '_cli_group'):
200-
cls._cli_group = keg.cli.init_app_cli(cls)
201-
return cls._cli_group
202-
203-
@classmethod
204-
def command(cls, *args, **kwargs):
205-
return cls.cli_group.command(*args, **kwargs)
206-
207-
@classmethod
208-
def cli_run(cls):
209-
"""
210-
Convience function intended to be an entry point for an app's command. Sets up the
211-
app and kicks off the cli command processing.
212-
"""
213-
cls.cli_group()
200+
def _cli_getter(cls): # noqa: first argument is not self in this context due to @classproperty
201+
if cls._cli is None:
202+
cal = cls.cli_loader_class(cls)
203+
cls._cli = cal.create_group()
204+
return cls._cli
205+
cli = classproperty(_cli_getter, ignore_set=True)
214206

215207
@classmethod
216208
def environ_key(cls, key):

keg/cli.py

Lines changed: 127 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,60 @@
55

66
import click
77
import flask
8+
from flask.cli import AppGroup, with_appcontext, run_command, shell_command, ScriptInfo
89
from six.moves import urllib
910

1011
from keg import current_app
11-
from keg._flask_cli import FlaskGroup, script_info_option, with_appcontext, run_command, \
12-
shell_command
1312
from keg.keyring import keyring as keg_keyring
1413

1514

16-
class KegGroup(FlaskGroup):
17-
def __init__(self, add_default_commands=True, *args, **kwargs):
18-
FlaskGroup.__init__(self, add_default_commands=False, *args, **kwargs)
15+
class KegAppGroup(AppGroup):
16+
def __init__(self, create_app, add_default_commands=True, *args, **kwargs):
17+
self.create_app = create_app
18+
19+
AppGroup.__init__(self, *args, **kwargs)
1920
if add_default_commands:
2021
self.add_command(dev_command)
2122

23+
self._loaded_plugin_commands = False
24+
25+
def _load_plugin_commands(self):
26+
if self._loaded_plugin_commands:
27+
return
28+
try:
29+
import pkg_resources
30+
except ImportError:
31+
self._loaded_plugin_commands = True
32+
return
33+
34+
for ep in pkg_resources.iter_entry_points('flask.commands'):
35+
self.add_command(ep.load(), ep.name)
36+
for ep in pkg_resources.iter_entry_points('keg.commands'):
37+
self.add_command(ep.load(), ep.name)
38+
self._loaded_plugin_commands = True
39+
40+
def list_commands(self, ctx):
41+
self._load_plugin_commands()
42+
43+
info = ctx.ensure_object(ScriptInfo)
44+
info.load_app()
45+
rv = set(click.Group.list_commands(self, ctx))
46+
return sorted(rv)
47+
48+
def get_command(self, ctx, name):
49+
info = ctx.ensure_object(ScriptInfo)
50+
info.load_app()
51+
return click.Group.get_command(self, ctx, name)
52+
53+
def main(self, *args, **kwargs):
54+
obj = kwargs.get('obj')
55+
if obj is None:
56+
obj = ScriptInfo(create_app=self.create_app)
57+
kwargs['obj'] = obj
58+
# TODO: figure out if we want to use this next line.
59+
#kwargs.setdefault('auto_envvar_prefix', 'FLASK')
60+
return AppGroup.main(self, *args, **kwargs)
61+
2262

2363
@click.group('develop', help='Developer info and utils.')
2464
def dev_command():
@@ -244,19 +284,85 @@ def keyring_delete(key):
244284
flask.current_app.keyring_manager.delete(key)
245285

246286

247-
def init_app_cli(appcls):
248-
249-
# this function will be used to initialize the app along, including the value of config_profile
250-
# which can be passed on the command line.
251-
def _create_app(script_info):
252-
app = appcls()
253-
app.init(config_profile=script_info.data['config_profile'])
254-
return app
255-
256-
@click.group(cls=KegGroup, create_app=_create_app)
257-
@script_info_option('--profile', script_info_key='config_profile', default=None,
258-
help='Name of the configuration profile to use.')
259-
def cli(**kwargs):
260-
pass
261-
262-
return cli
287+
class CLILoader(object):
288+
"""
289+
This loader takes care of the complexity of click object setup and instantiation in the
290+
correct order so that application level CLI options are available before the Keg app is
291+
instantiated (so the options can be used to configure the app).
292+
293+
The order of events is:
294+
295+
- instantiate KegAppGroup
296+
- KegAppGroup.main() is called
297+
1. the ScriptInfo object is instantiated
298+
2. normal click behavior starts, including argument parsing
299+
- arguments are always parsed due to invoke_without_command=True
300+
3. during argument parsing, option callbacks are excuted which will result in at least
301+
the profile name being saved in ScriptInfo().data
302+
4. Normal click .main() behavior will continue which could include processing commands
303+
decorated with flask.cli.with_appcontext() or calls to KegAppGroup.list_commands()
304+
or KegAppGroup.get_command().
305+
- ScriptInfo.init_app() will be called during any of these operations
306+
- ScriptInfo.init_app() will call self.create_app() below with the ScriptInfo
307+
instance
308+
"""
309+
def __init__(self, appcls):
310+
self.appcls = appcls
311+
# Don't store instance-level vars here. This object is only instantiated once per Keg
312+
# sub-class but can be used across multiple app instance creations. So, anything app
313+
# instance specific should go on the ScriptInfo instance (see self.options_callback()).
314+
315+
def create_group(self):
316+
""" Create the top most click Group instance which is the entry point for any Keg app
317+
being called in a CLI context.
318+
319+
The return value of this context gets set on Keg.cli
320+
"""
321+
322+
return KegAppGroup(
323+
self.create_app,
324+
params=self.create_script_options(),
325+
callback=self.main_callback,
326+
invoke_without_command=True,
327+
)
328+
329+
def create_app(self, script_info):
330+
""" Instantiate our app, sending CLI option values through as needed. """
331+
return self.appcls().init(config_profile=script_info.data['profile'])
332+
333+
def create_script_options(self):
334+
""" Create app level options, ideally that are used to configure the app itself. """
335+
return [
336+
click.Option(['--profile'], is_eager=True, default=None, callback=self.options_callback,
337+
help='Name of the configuration profile to use.')
338+
]
339+
340+
def options_callback(self, ctx, param, value):
341+
""" This method is called after argument parsing, after ScriptInfo instantiation but before
342+
create_app() is called. It's the only way to get the options into ScriptInfo.data
343+
before the Keg app instance is instantiated.
344+
"""
345+
si = ctx.ensure_object(ScriptInfo)
346+
si.data[param.name] = value
347+
348+
def main_callback(self, **kwargs):
349+
"""
350+
Default Click behavior is to call the help method if no arguments are given to the
351+
top-most command. That's good UX, but the way Click impliments it, the help is
352+
shown before argument parsing takes place. That's bad, because it means our
353+
options_callback() is never called and the config profile isn't available when the
354+
help command calls KegAppGroup.list_commands().
355+
356+
So, we force Click to always parse the args using `invoke_without_command=True` above,
357+
but when we do that, Click turns off the automatic display of help. So, we just
358+
impliment help-like behavior in this method, which gives us the same net result
359+
as default Click behavior.
360+
"""
361+
ctx = click.get_current_context()
362+
363+
if ctx.invoked_subcommand is not None:
364+
# A subcommand is present, so arguments were passed.
365+
return
366+
367+
click.echo(ctx.get_help(), color=ctx.color)
368+
ctx.exit()

keg/testing.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def invoke_command(app_cls, *args, **kwargs):
6666
if use_test_profile:
6767
app_key = app_cls.environ_key('USE_TEST_PROFILE')
6868
env[app_key] = 'true'
69-
result = runner.invoke(app_cls.cli_group, args, env=env, catch_exceptions=False)
69+
result = runner.invoke(app_cls.cli, args, env=env, catch_exceptions=False)
7070

7171
# if an exception was raised, make sure you output the output to make debugging easier
7272
# -1 as an exit code indicates a non SystemExit exception.
@@ -92,7 +92,11 @@ def setup_class(cls):
9292

9393
def invoke(self, *args, **kwargs):
9494
cmd_name = kwargs.pop('cmd_name', self.cmd_name)
95-
invoke_args = cmd_name.split(' ') + list(args)
95+
if cmd_name is None:
96+
cmd_name_args = []
97+
else:
98+
cmd_name_args = cmd_name.split(' ')
99+
invoke_args = cmd_name_args + list(args)
96100
kwargs['runner'] = self.runner
97101
return invoke_command(self.app_cls, *invoke_args, **kwargs)
98102

keg/tests/test_cli.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55

66
from keg_apps.cli import CLIApp
7+
from keg_apps.cli2.app import CLI2App
78
from keg_apps.db.app import DBApp
89
from keg.testing import CLIBase
910

@@ -36,6 +37,21 @@ def test_default_exception_handling(self):
3637
"""
3738

3839

40+
class TestCLI2(CLIBase):
41+
app_cls = CLI2App
42+
43+
def test_invoke(self):
44+
result = self.invoke('hello1')
45+
assert 'hello1' in result.output
46+
47+
def test_no_commands_help_message(self):
48+
result = self.invoke()
49+
assert 'Usage: ' in result.output
50+
assert '--profile TEXT Name of the configuration profile to use.' in result.output
51+
assert 'develop Developer info and utils.'
52+
assert 'hello1' in result.output
53+
54+
3955
class TestConfigCommand(CLIBase):
4056
app_cls = CLIApp
4157
cmd_name = 'develop config'

keg/utils.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,18 @@ class ClassProperty(property):
4343
on classes rather than instances.
4444
"""
4545
def __init__(self, fget, *arg, **kw):
46-
super(ClassProperty, self).__init__(fget, *arg, **kw)
46+
self.ignore_set = kw.pop('ignore_set', False)
4747
self.__doc__ = fget.__doc__
48+
super(ClassProperty, self).__init__(fget, *arg, **kw)
49+
50+
def __get__(self, obj, cls): # noqa
51+
return self.fget(cls)
4852

49-
def __get__(desc, self, cls): # noqa
50-
return desc.fget(cls)
53+
def __set__(self, cls, value): # noqa
54+
if self.fset is None and not self.ignore_set:
55+
raise AttributeError("can't set attribute")
56+
if not self.ignore_set:
57+
self.fset(cls, value)
5158

5259

5360
classproperty = ClassProperty

keg_apps/cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,21 @@ class CLIApp(Keg):
1515
keyring_enabled = False
1616

1717

18-
@CLIApp.command()
18+
@CLIApp.cli.command()
1919
def hello():
2020
print('hello keg test') # noqa
2121

2222

23-
@CLIApp.command()
23+
@CLIApp.cli.command()
2424
@click.argument('name', default='foo')
2525
def foo2(name):
2626
print(('hello {}'.format(name))) # noqa
2727

2828

29-
@CLIApp.command('catch-error')
29+
@CLIApp.cli.command('catch-error')
3030
def catch_error():
3131
raise Exception('deliberate exception for testing')
3232

3333

3434
if __name__ == '__main__':
35-
CLIApp.cli_run()
35+
CLIApp.cli.main()

keg_apps/cli2/__init__.py

Whitespace-only changes.

keg_apps/cli2/app.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import absolute_import
2+
3+
from keg.app import Keg
4+
5+
6+
class CLI2App(Keg):
7+
import_name = 'keg_apps.cli2'
8+
9+
def on_init_complete(self):
10+
# Want to test when the CLI command is registered after the app is initialized
11+
import keg_apps.cli2.cli # noqa
12+
13+
14+
if __name__ == '__main__':
15+
# This import prevents a nasty bug where there are actually two classes __main__.CLI2App
16+
# and the one with a full module path.
17+
import keg_apps.cli2.app as app
18+
app.CLI2App.cli.main()

keg_apps/cli2/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import click
2+
3+
from .app import CLI2App
4+
5+
6+
@CLI2App.cli.command('hello1')
7+
def hello1():
8+
click.echo('hello1')

0 commit comments

Comments
 (0)