Skip to content

Commit e7bf6c1

Browse files
authored
Merge pull request #990 from python-cmd2/on_registered
Added callbacks to CommandSet
2 parents 36b0b75 + 85ad319 commit e7bf6c1

File tree

9 files changed

+111
-31
lines changed

9 files changed

+111
-31
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## 1.3.9 (September 03, 2020)
2+
* Breaking Changes
3+
* `CommandSet.on_unregister()` is now called as first step in unregistering a `CommandSet` and not
4+
the last. `CommandSet.on_unregistered()` is now the last step.
5+
* Enhancements
6+
* Added `CommandSet.on_registered()`. This is called by `cmd2.Cmd` after a `CommandSet` is registered
7+
and all its commands have been added to the CLI.
8+
* Added `CommandSet.on_unregistered()`. This is called by `cmd2.Cmd` after a `CommandSet` is unregistered
9+
and all its commands have been removed from the CLI.
10+
111
## 1.3.8 (August 28, 2020)
212
* Bug Fixes
313
* Fixed issue where subcommand added with `@as_subcommand_to` decorator did not display help

cmd2/cmd2.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,15 +503,17 @@ def register_command_set(self, cmdset: CommandSet) -> None:
503503
self._installed_command_sets.append(cmdset)
504504

505505
self._register_subcommands(cmdset)
506+
cmdset.on_registered()
506507
except Exception:
508+
cmdset.on_unregister()
507509
for attrib in installed_attributes:
508510
delattr(self, attrib)
509511
if cmdset in self._installed_command_sets:
510512
self._installed_command_sets.remove(cmdset)
511513
if cmdset in self._cmd_to_command_sets.values():
512514
self._cmd_to_command_sets = \
513515
{key: val for key, val in self._cmd_to_command_sets.items() if val is not cmdset}
514-
cmdset.on_unregister()
516+
cmdset.on_unregistered()
515517
raise
516518

517519
def _install_command_function(self, command: str, command_wrapper: Callable, context=''):
@@ -559,6 +561,7 @@ def unregister_command_set(self, cmdset: CommandSet):
559561
"""
560562
if cmdset in self._installed_command_sets:
561563
self._check_uninstallable(cmdset)
564+
cmdset.on_unregister()
562565
self._unregister_subcommands(cmdset)
563566

564567
methods = inspect.getmembers(
@@ -584,7 +587,7 @@ def unregister_command_set(self, cmdset: CommandSet):
584587
if hasattr(self, HELP_FUNC_PREFIX + cmd_name):
585588
delattr(self, HELP_FUNC_PREFIX + cmd_name)
586589

587-
cmdset.on_unregister()
590+
cmdset.on_unregistered()
588591
self._installed_command_sets.remove(cmdset)
589592

590593
def _check_uninstallable(self, cmdset: CommandSet):

cmd2/command_definition.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,11 @@ class CommandSet(object):
5353
def __init__(self):
5454
self._cmd = None # type: Optional[cmd2.Cmd]
5555

56-
def on_register(self, cmd):
56+
def on_register(self, cmd) -> None:
5757
"""
58-
Called by cmd2.Cmd when a CommandSet is registered. Subclasses can override this
59-
to perform an initialization requiring access to the Cmd object.
58+
Called by cmd2.Cmd as the first step to registering a CommandSet. The commands defined in this class have
59+
not be added to the CLI object at this point. Subclasses can override this to perform any initialization
60+
requiring access to the Cmd object (e.g. configure commands and their parsers based on CLI state data).
6061
6162
:param cmd: The cmd2 main application
6263
:type cmd: cmd2.Cmd
@@ -66,11 +67,24 @@ def on_register(self, cmd):
6667
else:
6768
raise CommandSetRegistrationError('This CommandSet has already been registered')
6869

69-
def on_unregister(self):
70+
def on_registered(self) -> None:
7071
"""
71-
Called by ``cmd2.Cmd`` when a CommandSet is unregistered and removed.
72+
Called by cmd2.Cmd after a CommandSet is registered and all its commands have been added to the CLI.
73+
Subclasses can override this to perform custom steps related to the newly added commands (e.g. setting
74+
them to a disabled state).
75+
"""
76+
pass
7277

73-
:param cmd:
74-
:type cmd: cmd2.Cmd
78+
def on_unregister(self) -> None:
79+
"""
80+
Called by ``cmd2.Cmd`` as the first step to unregistering a CommandSet. Subclasses can override this to
81+
perform any cleanup steps which require their commands being registered in the CLI.
82+
"""
83+
pass
84+
85+
def on_unregistered(self) -> None:
86+
"""
87+
Called by ``cmd2.Cmd`` after a CommandSet has been unregistered and all its commands removed from the CLI.
88+
Subclasses can override this to perform remaining cleanup steps.
7589
"""
7690
self._cmd = None

docs/features/modular_commands.rst

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Modular Commands
44
Overview
55
--------
66

7-
Cmd2 also enables developers to modularize their command definitions into Command Sets. Command sets represent
7+
Cmd2 also enables developers to modularize their command definitions into ``CommandSet`` objects. CommandSets represent
88
a logical grouping of commands within an cmd2 application. By default, all CommandSets will be discovered and loaded
99
automatically when the cmd2.Cmd class is instantiated with this mixin. This also enables the developer to
1010
dynamically add/remove commands from the cmd2 application. This could be useful for loadable plugins that
@@ -21,10 +21,14 @@ Features
2121
* Dynamically Loadable/Unloadable Commands - Command functions and CommandSets can both be loaded and unloaded
2222
dynamically during application execution. This can enable features such as dynamically loaded modules that
2323
add additional commands.
24+
* Events handlers - Four event handlers are provided in ``CommandSet`` class for custom initialization
25+
and cleanup steps. See :ref:`features/modular_commands:Event Handlers`.
2426
* Subcommand Injection - Subcommands can be defined separately from the base command. This allows for a more
2527
action-centric instead of object-centric command system while still organizing your code and handlers around the
2628
objects being managed.
2729

30+
See API documentation for :attr:`cmd2.command_definition.CommandSet`
31+
2832
See the examples for more details: https://github.com/python-cmd2/cmd2/tree/master/plugins/command_sets/examples
2933

3034

@@ -171,7 +175,7 @@ You may need to disable command auto-loading if you need dynamically load comman
171175
self._fruits = LoadableFruits()
172176
self._vegetables = LoadableVegetables()
173177
174-
load_parser = cmd2.Cmd2ArgumentParser('load')
178+
load_parser = cmd2.Cmd2ArgumentParser()
175179
load_parser.add_argument('cmds', choices=['fruits', 'vegetables'])
176180
177181
@with_argparser(load_parser)
@@ -207,6 +211,30 @@ You may need to disable command auto-loading if you need dynamically load comman
207211
app.cmdloop()
208212
209213
214+
Event Handlers
215+
--------------
216+
The following functions are called at different points in the ``CommandSet`` life cycle.
217+
218+
``on_register(self, cmd) -> None`` - Called by cmd2.Cmd as the first step to
219+
registering a CommandSet. The commands defined in this class have not be
220+
added to the CLI object at this point. Subclasses can override this to
221+
perform any initialization requiring access to the Cmd object
222+
(e.g. configure commands and their parsers based on CLI state data).
223+
224+
``on_registered(self) -> None`` - Called by cmd2.Cmd after a CommandSet is
225+
registered and all its commands have been added to the CLI. Subclasses can
226+
override this to perform custom steps related to the newly added commands
227+
(e.g. setting them to a disabled state).
228+
229+
``on_unregister(self) -> None`` - Called by ``cmd2.Cmd`` as the first step to
230+
unregistering a CommandSet. Subclasses can override this to perform any cleanup
231+
steps which require their commands being registered in the CLI.
232+
233+
``on_unregistered(self) -> None`` - Called by ``cmd2.Cmd`` after a CommandSet
234+
has been unregistered and all its commands removed from the CLI. Subclasses can
235+
override this to perform remaining cleanup steps.
236+
237+
210238
Injecting Subcommands
211239
----------------------
212240

@@ -281,7 +309,7 @@ command and each CommandSet
281309
self._fruits = LoadableFruits()
282310
self._vegetables = LoadableVegetables()
283311
284-
load_parser = cmd2.Cmd2ArgumentParser('load')
312+
load_parser = cmd2.Cmd2ArgumentParser()
285313
load_parser.add_argument('cmds', choices=['fruits', 'vegetables'])
286314
287315
@with_argparser(load_parser)
@@ -311,8 +339,8 @@ command and each CommandSet
311339
self.unregister_command_set(self._vegetables)
312340
self.poutput('Vegetables unloaded')
313341
314-
cut_parser = cmd2.Cmd2ArgumentParser('cut')
315-
cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut', unloadable=True)
342+
cut_parser = cmd2.Cmd2ArgumentParser()
343+
cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
316344
317345
@with_argparser(cut_parser)
318346
def do_cut(self, ns: argparse.Namespace):

examples/modular_commands/commandset_complex.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def do_banana(self, statement: cmd2.Statement):
2020
"""Banana Command"""
2121
self._cmd.poutput('Banana!!')
2222

23-
cranberry_parser = cmd2.Cmd2ArgumentParser('cranberry')
23+
cranberry_parser = cmd2.Cmd2ArgumentParser()
2424
cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce'])
2525

2626
@cmd2.with_argparser(cranberry_parser, with_unknown_args=True)
@@ -44,7 +44,7 @@ def do_durian(self, args: List[str]):
4444
def complete_durian(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
4545
return utils.basic_complete(text, line, begidx, endidx, ['stinks', 'smells', 'disgusting'])
4646

47-
elderberry_parser = cmd2.Cmd2ArgumentParser('elderberry')
47+
elderberry_parser = cmd2.Cmd2ArgumentParser()
4848
elderberry_parser.add_argument('arg1')
4949

5050
@cmd2.with_category('Alone')

examples/modular_commands_dynamic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def __init__(self, *args, **kwargs):
5050
self._fruits = LoadableFruits()
5151
self._vegetables = LoadableVegetables()
5252

53-
load_parser = cmd2.Cmd2ArgumentParser('load')
53+
load_parser = cmd2.Cmd2ArgumentParser()
5454
load_parser.add_argument('cmds', choices=['fruits', 'vegetables'])
5555

5656
@with_argparser(load_parser)

examples/modular_subcommands.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def __init__(self, *args, **kwargs):
6262
self._fruits = LoadableFruits()
6363
self._vegetables = LoadableVegetables()
6464

65-
load_parser = cmd2.Cmd2ArgumentParser('load')
65+
load_parser = cmd2.Cmd2ArgumentParser()
6666
load_parser.add_argument('cmds', choices=['fruits', 'vegetables'])
6767

6868
@with_argparser(load_parser)
@@ -92,7 +92,7 @@ def do_unload(self, ns: argparse.Namespace):
9292
self.unregister_command_set(self._vegetables)
9393
self.poutput('Vegetables unloaded')
9494

95-
cut_parser = cmd2.Cmd2ArgumentParser('cut')
95+
cut_parser = cmd2.Cmd2ArgumentParser()
9696
cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
9797

9898
@with_argparser(cut_parser)

tests/test_cmd2.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2247,15 +2247,15 @@ def test_disable_and_enable_category(disable_commands_app):
22472247

22482248
# Make sure neither function completes
22492249
text = ''
2250-
line = 'has_helper_funcs'
2250+
line = 'has_helper_funcs {}'.format(text)
22512251
endidx = len(line)
22522252
begidx = endidx - len(text)
22532253

22542254
first_match = complete_tester(text, line, begidx, endidx, disable_commands_app)
22552255
assert first_match is None
22562256

22572257
text = ''
2258-
line = 'has_no_helper_funcs'
2258+
line = 'has_no_helper_funcs {}'.format(text)
22592259
endidx = len(line)
22602260
begidx = endidx - len(text)
22612261

@@ -2291,7 +2291,7 @@ def test_disable_and_enable_category(disable_commands_app):
22912291

22922292
# has_helper_funcs should complete now
22932293
text = ''
2294-
line = 'has_helper_funcs'
2294+
line = 'has_helper_funcs {}'.format(text)
22952295
endidx = len(line)
22962296
begidx = endidx - len(text)
22972297

@@ -2300,7 +2300,7 @@ def test_disable_and_enable_category(disable_commands_app):
23002300

23012301
# has_no_helper_funcs had no completer originally, so there should be no results
23022302
text = ''
2303-
line = 'has_no_helper_funcs'
2303+
line = 'has_no_helper_funcs {}'.format(text)
23042304
endidx = len(line)
23052305
begidx = endidx - len(text)
23062306

tests_isolated/test_commandset/test_commandset.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,30 @@ class CommandSetBase(cmd2.CommandSet):
2121

2222
@cmd2.with_default_category('Fruits')
2323
class CommandSetA(CommandSetBase):
24+
def on_register(self, cmd) -> None:
25+
super().on_register(cmd)
26+
print("in on_register now")
27+
28+
def on_registered(self) -> None:
29+
super().on_registered()
30+
print("in on_registered now")
31+
32+
def on_unregister(self) -> None:
33+
super().on_unregister()
34+
print("in on_unregister now")
35+
36+
def on_unregistered(self) -> None:
37+
super().on_unregistered()
38+
print("in on_unregistered now")
39+
2440
def do_apple(self, statement: cmd2.Statement):
2541
self._cmd.poutput('Apple!')
2642

2743
def do_banana(self, statement: cmd2.Statement):
2844
"""Banana Command"""
2945
self._cmd.poutput('Banana!!')
3046

31-
cranberry_parser = cmd2.Cmd2ArgumentParser('cranberry')
47+
cranberry_parser = cmd2.Cmd2ArgumentParser()
3248
cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce'])
3349

3450
@cmd2.with_argparser(cranberry_parser, with_unknown_args=True)
@@ -53,7 +69,7 @@ def do_durian(self, args: List[str]):
5369
def complete_durian(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
5470
return utils.basic_complete(text, line, begidx, endidx, ['stinks', 'smells', 'disgusting'])
5571

56-
elderberry_parser = cmd2.Cmd2ArgumentParser('elderberry')
72+
elderberry_parser = cmd2.Cmd2ArgumentParser()
5773
elderberry_parser.add_argument('arg1')
5874

5975
@cmd2.with_category('Alone')
@@ -158,7 +174,7 @@ def test_custom_construct_commandsets():
158174
assert command_set_2 not in matches
159175

160176

161-
def test_load_commands(command_sets_manual):
177+
def test_load_commands(command_sets_manual, capsys):
162178

163179
# now install a command set and verify the commands are now present
164180
cmd_set = CommandSetA()
@@ -171,6 +187,11 @@ def test_load_commands(command_sets_manual):
171187
assert command_sets_manual.find_commandsets(CommandSetA)[0] is cmd_set
172188
assert command_sets_manual.find_commandset_for_command('elderberry') is cmd_set
173189

190+
# Make sure registration callbacks ran
191+
out, err = capsys.readouterr()
192+
assert "in on_register now" in out
193+
assert "in on_registered now" in out
194+
174195
cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info()
175196

176197
assert 'Alone' in cmds_cats
@@ -192,6 +213,11 @@ def test_load_commands(command_sets_manual):
192213
assert 'Alone' not in cmds_cats
193214
assert 'Fruits' not in cmds_cats
194215

216+
# Make sure unregistration callbacks ran
217+
out, err = capsys.readouterr()
218+
assert "in on_unregister now" in out
219+
assert "in on_unregistered now" in out
220+
195221
# uninstall a second time and verify no errors happen
196222
command_sets_manual.unregister_command_set(cmd_set)
197223

@@ -298,7 +324,7 @@ def __init__(self, dummy):
298324
self._dummy = dummy # prevents autoload
299325
self._cut_called = False
300326

301-
cut_parser = cmd2.Cmd2ArgumentParser('cut')
327+
cut_parser = cmd2.Cmd2ArgumentParser()
302328
cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
303329

304330
def namespace_provider(self) -> argparse.Namespace:
@@ -319,8 +345,7 @@ def do_cut(self, ns: argparse.Namespace):
319345
self._cmd.pwarning('This command does nothing without sub-parsers registered')
320346
self._cmd.do_help('cut')
321347

322-
323-
stir_parser = cmd2.Cmd2ArgumentParser('stir')
348+
stir_parser = cmd2.Cmd2ArgumentParser()
324349
stir_subparsers = stir_parser.add_subparsers(title='item', help='what to stir')
325350

326351
@cmd2.with_argparser(stir_parser, ns_provider=namespace_provider)
@@ -592,7 +617,7 @@ class AppWithSubCommands(cmd2.Cmd):
592617
def __init__(self, *args, **kwargs):
593618
super(AppWithSubCommands, self).__init__(*args, **kwargs)
594619

595-
cut_parser = cmd2.Cmd2ArgumentParser('cut')
620+
cut_parser = cmd2.Cmd2ArgumentParser()
596621
cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
597622

598623
@cmd2.with_argparser(cut_parser)
@@ -853,7 +878,7 @@ class BadSubcommandApp(cmd2.Cmd):
853878
def __init__(self, *args, **kwargs):
854879
super(BadSubcommandApp, self).__init__(*args, **kwargs)
855880

856-
cut_parser = cmd2.Cmd2ArgumentParser('cut')
881+
cut_parser = cmd2.Cmd2ArgumentParser()
857882
cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut')
858883

859884
@cmd2.with_argparser(cut_parser)

0 commit comments

Comments
 (0)