I want to know if this is a good idea. It is definitely only a proof of concept at this point, but if it's a good idea I would pursue the development into a more mature product.
There are three main parts of this concept (maybe they should be separate questions, but I am asking about the concept as a whole and whether it conforms to acceptable standards or is just a bad idea):
Create a single file Python interpreter (sort of) which will be "compiled" with pyinstaller for various platforms and included with the distribution of the app. This allows a completely pluggable system of command line utilities written in Python.
Create a library which provides a decorator which creates a command line interface dynamically based on the function signature.
Provide a production-ready server based on Bottle and CherryPy which serves a Web GUI based on a very simple plugin system.
To this end I created a project on GitHub, and I would recommend looking at the structure and source code there, but I am including the most relevant pieces of code here as per the recommendations of the moderators.
This is the code in magic.py which executes the python scripts. Note that the main point of this is to "compile" this code with pyinstaller so there is a one-step build process and it provides a pluggable system of command line utilities (also note the ellipses):
# I want to make __future__ available, but it must be at the beginning.
import __future__
from config import get_config
import logging
import sys
import os
if False:
# The following modules (even though they are not actually imported)
# are meant to be available. When this module is packaged with pyinstaller
# It will make these modules available for import or in other words they
# will be bundled in the executable.
import re
import xml
...
import cherrypy
config = get_config("logging.conf")
log_level = config.getint('logging', "log_level")
logger = logging.getLogger('magic')
logger.setLevel(log_level)
formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(log_level)
handler.setFormatter(formatter)
logger.addHandler(handler)
# Only one parameter is needed for functionality, but I would like
# to add support for flags to simulate the python interpreter flags.
sys.argv = sys.argv[1:]
if not sys.argv:
sys.exit(-1)
_file = sys.argv[0]
if not _file.endswith('.py'):
# append .py to _file if not present
_file = '{}.py'.format(_file)
ran = False
config = get_config("magic.conf")
dirs = config.get("magic", "path").split()
logger.debug('Executing command "{}"'.format(' '.join(sys.argv)))
for _dir in dirs:
filename = os.path.join(_dir, _file)
if not os.path.exists(filename) or not os.path.isfile(filename):
continue
try:
execfile(filename)
ran = True
except Exception, e:
msg = "Failed to execute {}. Reason: {}".format(' '.join(sys.argv), e)
if hasattr(e, 'read'):
msg = '{}\n\t{}'.format(msg, e.read())
logger.error(msg)
# Here it ran, but raised an exception
raise
break
if not ran:
logger.debug(
"Failed to execute file: {0}. "
"{0} does not exist or is not a file".format(_file))
Now, for the dynamic creation of the command line interface. I use inspect to get at the function signature and argparse
to implement the CLI.
cli.py
import sys
import inspect
import argparse
class Cli(object):
def __init__(self, description=""):
self.parser = argparse.ArgumentParser(description=description,
formatter_class=argparse.RawDescriptionHelpFormatter)
self.subparsers = self.parser.add_subparsers()
self.functions = {}
def command(self):
def inner(fn):
"""collects information about decorated function, builds a
subparser then returns the function unchanged"""
name = fn.__name__
self.functions[name] = fn
desc = fn.__doc__
args, _, __, defaults = inspect.getargspec(fn)
if not args:
args = []
defaults = []
if len(args) != len(defaults):
print "All cli.command function arguments must have a default."
sys.exit(-1)
_parser = self.subparsers.add_parser(name, description=desc,
formatter_class=argparse.RawDescriptionHelpFormatter)
_parser.set_defaults(func=self.functions[name])
for arg, default in zip(args, defaults):
# Try the lower case first letter for the short option first
if '-{}'.format(arg[0]) not in _parser._option_string_actions:
flag = ('-{}'.format(arg[0]), '--{}'.format(arg))
# Then the upper-case first letter for the short option
elif '-{}'.format(arg[0]).upper() not in _parser._option_string_actions:
flag = ('-{}'.format(arg[0]).upper(), '--{}'.format(arg))
# otherwise no short option
else:
flag = ('--{}'.format(arg))
if isinstance(default, basestring):
_parser.add_argument(*flag, type=str, default=default)
elif isinstance(default, list):
_parser.add_argument(*flag, nargs='+')
elif isinstance(default, bool):
if default:
_parser.add_argument(
*flag, action='store_false', default=default)
else:
_parser.add_argument(
*flag, action='store_true', default=default)
elif isinstance(default, int):
_parser.add_argument(*flag, type=int, default=default)
return fn
return inner
def run(self):
"""Executes the function corresponding to the command line
arguments provided by the user"""
args = self.parser.parse_args()
func = args.func
_args, _, __, defaults = inspect.getargspec(func)
kwargs = {}
for arg in _args:
kwargs[arg] = getattr(args, arg)
func(**kwargs)
Now for the web GUI. Here is the script web.py which dynamically loads anything in our plugin directory as a plugin:
import os
import var.lib.bottle as bottle
def _get_plugins(app):
"""This function builds a list"""
ret = {}
dirs = [d for d in os.walk(app.PLUGIN_DIR).next()[1]]
dirs.sort()
for d in dirs:
# import the main function into a temporary variable
_main = __import__(
'var.www.plugins.{}.plugin'.format(d),
globals(),
locals(),
['main'])
# add plugin directory to TEMPLATE_DIR so they can house their
# own templates (this allows plugins to be self-contained)
bottle.TEMPLATE_PATH.append(os.path.join(app.PLUGIN_DIR, d))
# Route GET and POST requests to the main() method of plugin.py
app.route(
'/{}'.format(d),
['GET', 'POST', 'DELETE', 'PUT', 'PATCH'],
_main.main)
ret[d] = bottle.template('link', name=d)
# TODO: inspect function to allow for dynamic routing and
# service discovery
return ret
app = bottle.Bottle()
bottle.TEMPLATE_PATH = ['var/www/templates/']
app.PLUGIN_DIR = 'var/www/plugins/'
app.STATIC_DIR = 'var/www/static/'
@app.route('/')
@app.route('/index')
@app.route('/index.html')
@bottle.view('index')
def index():
"""Returns the index template with a list of templates(actually a
list of links to the plugins URIs)."""
return {'plugins': app.PLUGINS}
@app.route('/static/<filepath:path>')
def static(filepath):
"""Serves static files located in app.STATIC_DIR."""
return bottle.static_file(filepath, root=app.STATIC_DIR)
if __name__ == '__main__':
app.PLUGINS = _get_plugins(app)
app.run(server='cherrypy')
Is it a good idea to structure apps this way to provide a cross-platform application with as little boiler-plate code as possible?