2

I'm looking for a good pattern for how to implement Python sub-commands, where the main command looks up the subcommand at run time (instead of knowing the list of all possible sub-commands; this allows the "application" to be easily extended with new sub-commands without having to change the main code)

E.g:

 topcmd.py foo

will look in /some/dir for foo.py and if it exists, run it. Or some variation of it.

The code invoked in foo.py should preferably be a well-defined function or method on a class or object.

4
  • Do you know about argparse ? Commented Jan 19, 2019 at 0:54
  • argparse requires that the top command knows about all the subcommands, no? I'd like it to discover them at run-time. Commented Jan 19, 2019 at 1:18
  • Mmmmm, this sound like a job for metaclasses. Commented Jan 19, 2019 at 1:19
  • This sounds like a job for dictionaries or os Commented Jan 19, 2019 at 2:27

3 Answers 3

6

While this question is actually quite broad, there are sufficient tools available within a typical default Python installation (i.e. with setuptools) that this is relatively achievable, in a way that is actually extensible so that other packages can be created/installed in a manner that provide new, discoverable subcommands for your main program.

Your base package can provide a standard entry_point in the form of console_scripts that point to your entry point that will feed all arguments into an instance of some argument parser (such as argparse), and some kind of registry which you might implement under a similar scheme as console_scripts, except under your specific entry_points group so that it would iterate through every entry and instantiate the objects that would also provide their own ArgumentParser instances which your main entry point would dynamically register to itself as a subcommand, thus showing your users what subcommands are actually available and what their invocation might be like.

To provide an example, in your main package's setup.py, you might have an entry like

setup(
    name='my.package',
    # ...
    entry_points={
        'console_scripts': [
            'topcmd = my.package.runtime:main',
        ],
        'my.package.subcmd': [
            'subcmd1 = my.package.commands:subprog1',
            'subcmd2 = my.package.commands:subprog2',
        ],
    },
    # ...
)

Inside the my/package/runtime.py source file, a main method will have to construct a new ArgumentParser instance, and while iterating through the entry points provided by pkg_resources.working_set, for example:

from pkg_resources import working_set

def init_parser(argparser):  # pass in the argparser provided by main
    commands = argparser.add_subparsers(dest='command')
    for entry_point in working_set.iter_entry_points('my.package.subcmd'):
        subparser = commands.add_parser(entry_point.name)
        # load can raise exception due to missing imports or error in object creation
        subcommand = entry_point.load()
        subcommand.init_parser(subparser) 

So in the main function, the argparser instance it created could be passed into a function like one in above, and the entry point 'subcmd1 = my.package.commands:subprog1' will be loaded. Inside my/package/command.py, an implemented init_parser method must be provided, which will take the provided subparser and populate it with the required arguments:

class SubProgram1(object):
    def init_parser(self, argparser)
        argparser.add_argument(...) 

subprog1 = SubProgram1() 

Oh, one final thing, after passing in the arguments to the main argparser.parse_args(...), the name of the command is provided to argparser.command. It should be possible to change that to the actual instance, but that may or may not achieve what you exactly want (because the main program might want to do further work/validation before actually using the command). That part is another complicated part, but at least the argument parser should contain the information required to actually run the correct subprogram.

Naturally, this includes absolutely no error checking, and it must be implemented in some form to prevent faulty subcommand classes from blowing up the main program. I have made use of a pattern like this one (albeit with a lot more complex implementation) that can support an arbitrary amount of nested subcommand. Also packages that want to implement custom commands can simply add their own entry to the entry point group (in this case, to my.package.subcmd) for their own setup.py. For example:

setup(
    name="some.other.package",
    # ...
    entry_points={
        'my.package.subcmd': [
            'extracmd = some.other.package.commands:extracmd',
        ],
    },
    # ...
)

Addendum:

As requested, an actual implementation that's used in production is in a package (calmjs) that I currently maintain. Installing that package (into a virtualenv) and running calmjs on the command line should show a listing of subcommands identical to the entries defined in the main package's entry points. Installing an additional package that extends the functionality (such as calmjs.webpack) and running calmjs again will now list calmjs.webpack as an additional subcommand.

The entry points references instances of subclasses to the Runtime class, and in it there is a place where the subparser is added and if satisfies registration requirements (many statements following that relate to various error/sanity checking, such as what to do when multiple packages define the same subcommand name for runtime instances, amongst other things), registered to the argparser instance on that particular runtime instance, and the subparser is passed into the init_argparser method of the runtime that encapsulates the subcommand. As an example, the calmjs webpack subcommand subparser is set up by its init_argparser method, and that package registers the webpack subcommand in its own setup.py. (To play with them, please just simply use pip to install the relevant packages).

Sign up to request clarification or add additional context in comments.

5 Comments

This solution would require changing the main code in setup.py when someone wanted to add a plugin wouldn't it?
@JoshJ Not necessary, the external package will add their own entry points to their own setup.py which the default iter_entry_points function will pick up, which means that topcmd will see the new command. Added an example to illustrate how this might look for the other package.
@JoshJ Also, I have actually used this pattern (though with a lot more code than this) in a number of packages to implement subcommands for a main program that I have on production on PyPI already, and end-users only get the additional commands if they also install the extra packages, and those entry points are defined only in the relevant packages. Not linking directly to not appear as self-promotion, but I will link if requested.
@metatoaster by all means, provide the link: I'm looking for code I don't have to write myself :-)
@JohannesErnst I don't have the related classes decoupled to a separate package, due to work/lack of time required for separating related code into a new package. Note that it has error checking for cases which may not be required your needs (like that recursive definition check; this set of classes is generic enough so that a system of nested of subcommands may be defined provided certain rules are followed). Please feel free to use the linked code as a guide on how you might want to build your system, should you wish to support a use case in a similar manner. Links appended as addendum.
2

You can use the __import__ function to dynamically import a module using a string name passed on the command line.

mod = sys.argv[1]
command =__import__(mod)
# assuming your pattern has a run method defined. 
command.run()

Error handling, etc left as an exercise for the reader

Edit: This would depend on user plugins being installed via pip. If you want users to drop plugins into a folder without installing, then you would have to add that folder to your python path.

1 Comment

In that direction, I discovered importlib: docs.python.org/3.5/library/…
1

The simplest answer seems to be, if all my commands are in foo.commands:

import foo.commands
import importlib

for importer, modname, ispkg in pkgutil.iter_modules(foo.commands.__path__):
    mod=importlib.import_module('foo.commands.' + cmd)
    mod.run()

This will run all the sub-commands. (Well, in the real code I will run just one. This is the howto.)

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.