Repository: ipython/traitlets
Pull request #362 (2017-01-20 09:19:16)

Support callable subcommands
Subcommands might be classes, strings (name of classes) and now Callables with a single argument: the parent Application instance (i.e. to read configs from).

That way it is possible to create different subcommands based on the same Application-class just with different constructor arguments.

I could not fins any test-cases about subcommands, so this PR is not considered finished - submitted for discussion.


Patch:
diff --git a/docs/source/config.rst b/docs/source/config.rst
index 90d75a9..f6186b5 100644
--- a/docs/source/config.rst
+++ b/docs/source/config.rst
@@ -324,6 +324,10 @@ that are single characters, in which case they can be specified with a single ``
 Flags and aliases are declared by specifying ``flags`` and ``aliases``
 attributes as dictionaries on subclasses of :class:`~traitlets.config.Application`.
 
+A key in both those dictionaries might be a string or tuple of strings.
+One-character strings are converted into "short" options (like ``-v``); longer strings
+are "long" options (like ``--verbose``).
+
 Aliases
 *******
 
@@ -338,6 +342,9 @@ to specify the whole class name:
     # are equivalent to
     $ ipython --BaseIPythonApplication.profile='myprofile'
 
+When specifying ``alias`` dictionary in code, the values might be the strings
+like ``'Class.trait'`` or two-tuples like ``('Class.trait', "Some help message")``.
+
 Flags
 *****
 
@@ -372,11 +379,29 @@ after :command:`git`, and are called with the form :command:`command subcommand
 
     $ ipython qtconsole --profile myprofile
 
+
+.. currentmodule::  traitlets.config
+
 Subcommands are specified as a dictionary on :class:`~traitlets.config.Application`
-instances, mapping subcommand names to 2-tuples containing:
+instances, mapping *subcommand names* to two-tuples containing these:
+
+1. A subclass of :class:`Application` to handle the subcommand.
+   This can be specified as:
+   - simply as a class, where its :meth:`SingletonConfigurable.instance()`
+     will be invoked (straight-forward, but loads subclasses on import time);
+   - as a string which can be imported to produce the above class;
+   - as a factory function accepting a single argument like that::
+
+       app_factory(parent_app: Application) -> Application
+
+     .. note::
+        The return value of the facory above is an *instance*, not a class,
+        son the :meth:`SingletonConfigurable.instance()` is not invoked
+        in this case.
+
+   In all cases, the instanciated app is stored in :attr:`Application.subapp`
+   and its :meth:`Application.initialize()` is invoked.
 
-1. The application class for the subcommand, or a string which can be imported
-   to give this.
 2. A short description of the subcommand for use in help output.
 
 To see a list of the available aliases, flags, and subcommands for a configurable
diff --git a/traitlets/config/application.py b/traitlets/config/application.py
index 75aaeea..4bba244 100644
--- a/traitlets/config/application.py
+++ b/traitlets/config/application.py
@@ -227,7 +227,9 @@ class Application(SingletonConfigurable):
         log.addHandler(_log_handler)
         return log
 
-    # the alias map for configurables
+    #: the alias map for configurables
+    #: Keys might strings or tuples for additional options; single-letter alias accessed like `-v`.
+    #: Values might be like "Class.trait" strings of two-tuples: (Class.trait, help-text).
     aliases = Dict({'log-level' : 'Application.log_level'})
 
     # flags for loading Configurables or store_const style flags
@@ -317,11 +319,15 @@ class Application(SingletonConfigurable):
 
         for alias, longname in self.aliases.items():
             try:
-                classname, traitname = longname.split('.',1)
+                if isinstance(longname, tuple):
+                    longname, fhelp = longname
+                else:
+                    fhelp = None
+                classname, traitname = longname.split('.', 1)
                 cls = classdict[classname]
 
                 trait = cls.class_traits(config=True)[traitname]
-                fhelp = cls.class_get_trait_help(trait).splitlines()
+                fhelp = cls.class_get_trait_help(trait, helptext=fhelp).splitlines()
 
                 if not isinstance(alias, tuple):
                     alias = (alias, )
@@ -330,7 +336,7 @@ class Application(SingletonConfigurable):
                                   for m in alias)
 
                 # reformat first line
-                fhelp[0] = fhelp[0].replace('--'+longname, alias)
+                fhelp[0] = fhelp[0].replace('--' + longname, alias)
                 lines.extend(fhelp)
                 lines.append(indent("Equivalent to: [--%s]" % longname))
             except Exception as ex:
@@ -462,24 +468,33 @@ class Application(SingletonConfigurable):
     @catch_config_error
     def initialize_subcommand(self, subc, argv=None):
         """Initialize a subcommand with argv."""
-        subapp,help = self.subcommands.get(subc)
+        subapp, _ = self.subcommands.get(subc)
 
         if isinstance(subapp, six.string_types):
             subapp = import_item(subapp)
 
-        # clear existing instances
-        self.__class__.clear_instance()
-        # instantiate
-        self.subapp = subapp.instance(parent=self)
-        # and initialize subapp
+        ## Cannot issubclass() on a non-type (SOhttp://stackoverflow.com/questions/8692430)
+        if isinstance(subapp, type) and issubclass(subapp, Application):
+            # Clear existing instances before...
+            self.__class__.clear_instance()
+            # instantiating subapp...
+            self.subapp = subapp.instance(parent=self)
+        elif callable(subapp):
+            # or ask factory to create it...
+            self.subapp = subapp(self)
+        else:
+            raise AssertionError('Invalid mappings for subcommand %s!' % subc)
+
+        # ... and finally initialize subapp.
         self.subapp.initialize(argv)
 
     def flatten_flags(self):
-        """flatten flags and aliases, so cl-args override as expected.
+        """Flatten flags and aliases for loaders, so cl-args override as expected.
 
         This prevents issues such as an alias pointing to InteractiveShell,
         but a config file setting the same trait in TerminalInteraciveShell
         getting inappropriate priority over the command-line arg.
+        Also, loaders expect ``(key: longname)`` and not ````key: (longname, help)`` items.
 
         Only aliases with exactly one descendent in the class list
         will be promoted.
@@ -497,8 +512,10 @@ class Application(SingletonConfigurable):
         # flatten aliases, which have the form:
         # { 'alias' : 'Class.trait' }
         aliases = {}
-        for alias, cls_trait in self.aliases.items():
-            cls,trait = cls_trait.split('.',1)
+        for alias, longname in self.aliases.items():
+            if isinstance(longname, tuple):
+                longname, _ = longname
+            cls, trait = longname.split('.', 1)
             children = mro_tree[cls]
             if len(children) == 1:
                 # exactly one descendent, promote alias
diff --git a/traitlets/config/configurable.py b/traitlets/config/configurable.py
index c32c2f7..bb7ebad 100644
--- a/traitlets/config/configurable.py
+++ b/traitlets/config/configurable.py
@@ -227,11 +227,14 @@ class Configurable(HasTraits):
         return '\n'.join(final_help)
 
     @classmethod
-    def class_get_trait_help(cls, trait, inst=None):
-        """Get the help string for a single trait.
-
-        If `inst` is given, it's current trait values will be used in place of
-        the class default.
+    def class_get_trait_help(cls, trait, inst=None, helptext=None):
+        """Get the helptext string for a single trait.
+
+        :param inst:
+            If given, it's current trait values will be used in place of
+            the class default.
+        :param helptext:
+            If not given, uses the `help` attribute of the current trait.
         """
         assert inst is None or isinstance(inst, cls)
         lines = []
@@ -250,6 +253,13 @@ class Configurable(HasTraits):
             header = '%s=<%s>' % (header, trait.__class__.__name__)
         #header = "--%s.%s=<%s>" % (cls.__name__, trait.name, trait.__class__.__name__)
         lines.append(header)
+
+        if helptext is None:
+            helptext = trait.help
+        if helptext != '':
+            helptext = '\n'.join(wrap_paragraphs(helptext, 76))
+            lines.append(indent(helptext, 4))
+
         if inst is not None:
             lines.append(indent('Current: %r' % getattr(inst, trait.name), 4))
         else:
@@ -265,10 +275,6 @@ class Configurable(HasTraits):
             # include Enum choices
             lines.append(indent('Choices: %r' % (trait.values,)))
 
-        help = trait.help
-        if help != '':
-            help = '\n'.join(wrap_paragraphs(help, 76))
-            lines.append(indent(help, 4))
         return '\n'.join(lines)
 
     @classmethod


Test patch:
diff --git a/traitlets/config/tests/test_application.py b/traitlets/config/tests/test_application.py
index f9efe5c..eb05632 100644
--- a/traitlets/config/tests/test_application.py
+++ b/traitlets/config/tests/test_application.py
@@ -66,7 +66,7 @@ class MyApp(Application):
 
     aliases = Dict({
                     ('fooi', 'i') : 'Foo.i',
-                    ('j', 'fooj') : 'Foo.j',
+                    ('j', 'fooj') : ('Foo.j', "`j` terse help msg"),
                     'name' : 'Foo.name',
                     'la': 'Foo.la',
                     'tb': 'Bar.tb',
@@ -194,7 +194,7 @@ class TestApplication(TestCase):
         class TestApp(Application):
             value = Unicode().tag(config=True)
             config_file_loaded = Bool().tag(config=True)
-            aliases = {'v': 'TestApp.value'}
+            aliases = {'v': ('TestApp.value', 'some help')}
         app = TestApp()
         with TemporaryDirectory() as td:
             config_file = pjoin(td, name)
@@ -445,6 +445,59 @@ class TestApplication(TestCase):
             with self.assertRaises(SyntaxError):
                 app.load_config_file(name, path=[td])
 
+    def test_subcommands_instanciation(self):
+        """Try all ways to specify how to create sub-apps."""
+        app = Root.instance()
+        app.parse_command_line(['sub1'])
+
+        self.assertIsInstance(app.subapp, Sub1)
+        ## Check parent hierarchy.
+        self.assertIs(app.subapp.parent, app)
+
+        Root.clear_instance()
+        Sub1.clear_instance()  # Otherwise, replaced spuriously and hierarchy check fails.
+        app = Root.instance()
+
+        app.parse_command_line(['sub1', 'sub2'])
+        self.assertIsInstance(app.subapp, Sub1)
+        self.assertIsInstance(app.subapp.subapp, Sub2)
+        ## Check parent hierarchy.
+        self.assertIs(app.subapp.parent, app)
+        self.assertIs(app.subapp.subapp.parent, app.subapp)
+
+        Root.clear_instance()
+        Sub1.clear_instance()  # Otherwise, replaced spuriously and hierarchy check fails.
+        app = Root.instance()
+
+        app.parse_command_line(['sub1', 'sub3'])
+        self.assertIsInstance(app.subapp, Sub1)
+        self.assertIsInstance(app.subapp.subapp, Sub3)
+        self.assertTrue(app.subapp.subapp.flag)               # Set by factory.
+        ## Check parent hierarchy.
+#        self.assertIs(app.subapp.parent, app)
+#        self.assertIs(app.subapp.subapp.parent, app.subapp)     # Set by factory.
+
+
+class Root(Application):
+    subcommands = {
+        'sub1': ('traitlets.config.tests.test_application.Sub1', 'import string'),
+    }
+
+
+class Sub3(Application):
+    flag = Bool(False)
+
+
+class Sub2(Application):
+    pass
+
+
+class Sub1(Application):
+    subcommands = {
+        'sub2': (Sub2, 'Application class'),
+        'sub3': (lambda root: Sub3(parent=root, flag=True), 'factory'),
+    }
+
 
 class DeprecatedApp(Application):
     override_called = False
diff --git a/traitlets/config/tests/test_configurable.py b/traitlets/config/tests/test_configurable.py
index 97ece8e..8a95ca3 100644
--- a/traitlets/config/tests/test_configurable.py
+++ b/traitlets/config/tests/test_configurable.py
@@ -35,20 +35,20 @@ class MyConfigurable(Configurable):
 mc_help=u"""MyConfigurable(Configurable) options
 ------------------------------------
 --MyConfigurable.a=<Integer>
-    Default: 1
     The integer a.
+    Default: 1
 --MyConfigurable.b=<Float>
-    Default: 1.0
-    The integer b."""
+    The integer b.
+    Default: 1.0"""
 
 mc_help_inst=u"""MyConfigurable(Configurable) options
 ------------------------------------
 --MyConfigurable.a=<Integer>
-    Current: 5
     The integer a.
+    Current: 5
 --MyConfigurable.b=<Float>
-    Current: 4.0
-    The integer b."""
+    The integer b.
+    Current: 4.0"""
 
 # On Python 3, the Integer trait is a synonym for Int
 if PY3:
@@ -71,8 +71,8 @@ class Bar(Foo):
 foo_help=u"""Foo(Configurable) options
 -------------------------
 --Foo.a=<Int>
-    Default: 0
     The integer a.
+    Default: 0
 --Foo.b=<Unicode>
     Default: 'nope'
 --Foo.fdict=<key-1>=<value-1>...
@@ -83,15 +83,15 @@ foo_help=u"""Foo(Configurable) options
 bar_help=u"""Bar(Foo) options
 ----------------
 --Bar.a=<Int>
-    Default: 0
     The integer a.
+    Default: 0
 --Bar.bdict <key-1>=<value-1>...
     Default: {}
 --Bar.bset <set-item-1>...
     Default: set()
 --Bar.c=<Float>
-    Default: 0.0
     The string c.
+    Default: 0.0
 --Bar.fdict=<key-1>=<value-1>...
     Default: {}
 --Bar.flist=<list-item-1>...