Browse Source

First upload to the big WWW.

Mathew Guest 10 months ago
commit
485dafdfc4

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+build
+dist
+*.egg-info
+__pycache__
+

+ 64 - 0
README.md

@@ -0,0 +1,64 @@
+app_skellington
+===============
+Application framework for Python, features include:
+ * Pain-free multi-level command menu: Expose public class methods as commands available to user.
+ * Simple to define services and automatic dependency injection based on name (with custom invocation as an option). *WIP
+ * INI-style config and and validation (provided through ConfigObj).
+ * Colored logging (provided through colorlog)
+ * Works on Linux, Windows, and Mac.
+
+Principles:
+ * Lend to creating beautiful, easy to read and understand code in the application.
+ * Minimize coupling of applications to this framework.
+ * Compatable with Linux, Windows, and Mac. Try to be compatible as possible otherwise.
+ * Try to be compatible with alternate Python runtimes such as PyPy and older python environments. *WIP
+
+Application Configuration
+-------------------------
+Site configurations are supported through configobj. There is a config.spec
+in the src directory which is a validation file; it contains the accepted
+parameter names, types, and limits for configurable options in the
+application which is built on app_skellington. The format is multi-level .ini syntax.
+
+See the configobj documentation for more information.
+
+Site configuration files (config.ini) are created if they don't exit. The
+file always contains the full specification of parameters; i.e. even default
+parameters are added into the config file.
+
+Linux:
+  
+/home/\<user\>/.config/\<app_name\>/config.ini
+  
+/home/\<user\>/.cache/\<app_name\>/log/\<app_name\>.log
+
+Windows:
+  
+C:\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\config.ini
+  
+C:\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\Logs\\\<app_name\>.log
+
+Application configuration can be overridden ad-hoc through the --config <filename>
+argument.
+
+Debug - Turn on Logging
+---------------------------
+Set 'APPSKELLINGTON_ENABLE_LOGGING' environment variable to any value which turns
+on AppSkellington-level logging. For example,
+
+    APP_SKELLINGTON_DEBUG=1 <executable>
+
+or
+
+    export APP_SKELLINGTON_DEBUG=1
+    <executable>
+
+Tests
+-----
+Tests are a WIP. Recommendation is to run 'pytest' in the 'tests' directory.
+
+Notes
+-----
+See official website: https://zavage-software.com
+Please report bugs, improvements, or feedback! <contact>
+

+ 11 - 0
app_skellington/__init__.py

@@ -0,0 +1,11 @@
+import logging
+import sys
+
+APP_CONFIG_FILENAME = 'config.ini' # Relative to user directory on machine
+APP_CONFIGSPEC_FILENAME = 'config.spec' # Relative to module source directory
+
+from .app_container import *
+from .cfg import *
+from .cli import *
+from .log import *
+

+ 43 - 0
app_skellington/_bootstrap.py

@@ -0,0 +1,43 @@
+import logging
+import os
+import sys
+
+# Check and gracefully fail if the user needs to install a 3rd-party dep.
+libnames = ['appdirs', 'configobj', 'colorlog']
+def check_env_has_dependencies(libnames):
+    rc = True
+    for libname in libnames:
+        try:
+            __import__(libname)
+        except ModuleNotFoundError as ex:
+            print('missing third-part library: ', ex, file=sys.stderr)
+            rc = False
+    return rc
+if not check_env_has_dependencies(libnames):
+    print('refusing to load program without installed dependencies', file=sys.stderr)
+    raise ImportError('python environment needs third-party dependencies installed')
+
+# Logger for before the application and logging config is loaded
+# - used to log before logging is configured
+_log_fmt = '%(levelname)-7s:%(message)s'
+_logger_name = 'app_skellington'
+_bootstrap_logger = logging.getLogger(_logger_name)
+
+# Logging is manually switched on via environment variable:
+if os.environ.get('APP_SKELLINGTON_DEBUG', None):
+    _bootstrap_logger.setLevel(logging.DEBUG) # Don't filter any log levels
+    fmt = logging.Formatter(_log_fmt)
+    handler = logging.StreamHandler()
+    handler.setFormatter(fmt)
+    _bootstrap_logger.addHandler(handler)
+    _bootstrap_logger.debug('debug log enabled: APP_SKELLINGTON_DEBUG set in environment variables')
+
+# Logging is by default off, excepting CRITICAL
+else:   
+    _bootstrap_logger.setLevel(logging.CRITICAL)
+_bootstrap_logger.propagate = False
+
+# NOTE(MG) Pretty sure the logger has the default handler too at this point.
+# It's been related to some issues with the logger double-printing messages.
+_bootstrap_logger.addHandler(logging.NullHandler())
+

+ 116 - 0
app_skellington/_util.py

@@ -0,0 +1,116 @@
+from __future__ import print_function
+import inspect
+import os
+import sys
+
+from . import _util
+
+def eprint(*args, **kwargs):
+    """
+    Print to STDERR stream.
+    """
+    print(*args, file=sys.stderr, **kwargs)
+
+def filename_to_abspath(filename):
+    """
+    Converts a filename to it's absolute path. If it's already an
+    absolute path, do nothing.
+    """
+    return os.path.abspath(filename)
+
+def does_file_exist(filepath):
+    """
+    Because the file can be deleted or created immediately after execution of
+    this function, there cannot be guarantees made around the existence of
+    said file (race condition). This merely says if the file existed at this
+    instant in execution.
+    """
+    try:
+        fp = open(filepath, 'r')
+        return True
+    except FileNotFoundError as ex:
+        return False
+
+def ensure_dir_exists(dirpath):
+    if dirpath is None:
+        return
+    if dirpath == '':
+        return
+    os.makedirs(dirpath, exist_ok=True)
+
+def get_root_asset(filepath):
+    """
+    Attempts to locate a resource or asset shipped with the application.
+    Searches starting at the root module (__main__) which should be the
+    python file initially invoked.
+    """
+    module_root =\
+        os.path.abspath(
+        os.path.dirname(
+        sys.modules['__main__'].__file__))
+    path = os.path.join(module_root, filepath)
+    return path 
+
+def get_asset(module, filepath):
+    """
+    Attempts to locate a resource or asset shipped with the application.
+    Input filename is relative to the caller code, i.e. this starts
+    searching relative to the file that called this function.
+    
+    Returns the full absolute path of the located file if found or None
+
+    Args:
+        module: Pass in the module (or __name__) to search relative to module
+        filepath: the relative filepath of the file to look for in the
+            package directory.
+    """
+    if isinstance(module, str):
+        module_file = sys.modules[module].__file__
+    elif isinstance(module, module):
+        module_file = module.__file__
+    else:
+        raise Exception('Invalid Usage')
+
+    try:
+        root = module_file
+
+        if os.path.islink(root):
+            root = os.path.realpath(root)
+
+        root = os.path.dirname(os.path.abspath(root))
+    except Exception as ex:
+        raise
+
+    path = os.path.join(root, filepath)
+    return path 
+
+def register_class_as_commands(app, submenu, cls_object):
+    """
+    Registers commands for each class method. e.g.: pass in the CLI
+    object, the target submenu, and the class to be registered, and
+    this will create a command-line menu item for each method in
+    the class.
+
+    IMPORTANT: Currently, you need to pass in only a class and not
+    an object/instance of a class.
+    """
+    cls_constructor = cls_object
+    members = inspect.getmembers(cls_object)
+    for m in members:
+        name = m[0]
+        ref = m[1]
+        if inspect.isfunction(ref) and not name.startswith('_'):
+            cls_method = ref
+            constructor = app._inject_service_dependencies(cls_constructor)
+            sig = inspect.signature(cls_method)
+            func = create_func(constructor, cls_method)
+            # docstring = cls_method.__doc__
+            docstring = inspect.getdoc(cls_method)
+            submenu.register_command(func, name, sig, docstring)
+
+def create_func(constructor, cls_method):
+    def func(*args, **kwargs):
+        obj = constructor()
+        return cls_method(obj, *args, **kwargs)
+    return func
+

+ 201 - 0
app_skellington/app_container.py

@@ -0,0 +1,201 @@
+import appdirs
+import collections
+import functools
+import inspect
+import os
+import sys
+
+# Application scaffolding:
+from ._bootstrap import _bootstrap_logger
+from . import log
+from . import _util
+from . import cli
+from . import cfg
+
+DEFAULT_APP_NAME = 'python-app'
+DEFAULT_APP_AUTHOR = 'John Doe'
+
+
+# OPTIONAL: classes can sub-class from this?
+class Components:
+    def inject_dependencies_based_on_names_in_args(self):
+        pass
+
+    def inject_dependency(self, name):
+        pass
+
+    def register_dependency(self, service, name):
+        pass
+
+class ApplicationContext:
+    """
+    Container for application-wide state; i.e. app configuration and loggers.
+    """
+    def __init__(self, config, log):
+        self.config = config
+        self.log = log
+        self.parsed_argv = None
+        self.parsed_argv_unknown = None
+
+class ApplicationContainer:
+    """
+    Generalized application functionality. Used for linking components and modules of the application
+    together. Invokes runtime configuration reading from file, maintains the
+    object instances for services, passes off to the cli to determine what to
+    do, and then injects any necessary dependencies (e.g. database module)
+    and kicks off the functionality requested in the cli.
+    """
+    def __init__(
+        self,
+        configspec_filepath=None,
+        config_filepath=None,
+        *args, **kwargs
+    ):
+        # Instantiate root application context (container for globals)
+        if configspec_filepath is None:
+            configspec_filepath = self._get_configspec_filepath()
+
+        self.appname = kwargs.get('appname') or DEFAULT_APP_NAME 
+        self.appauthor = kwargs.get('appauthor') or DEFAULT_APP_AUTHOR
+
+        self._dependencies = {}
+
+        config = cfg.Config(configspec_filepath)
+        config.load_config_from_file(config_filepath)
+
+        logger = log.LoggingLayer(self.appname, self.appauthor)
+
+        # added here, is this okay to do twice?
+        logger.configure_logging()
+
+        self.ctx = ApplicationContext(config, logger)
+        self['ctx'] = lambda: self.ctx
+
+        self.cli = cli.CommandTree() # Command-line interface
+
+        if callable(getattr(self, '_cli_options', None)):
+            self._cli_options()
+        if callable(getattr(self, '_services', None)):
+            self._services()
+        if callable(getattr(self, '_command_menu', None)):
+            self._command_menu()
+
+    def __delitem__(self, service_name):
+        """
+        Deletes a service or dependency from the available dependencies.
+        """
+        try:
+            del self._dependencies[service_name]
+        except KeyError as ex:
+            pass
+
+    def __getitem__(self, service_name):
+        """
+        Returns a factory of a service or dependency. The factory is a function
+        that is called to return an instance of the service object.
+
+        app_container['netezza'] => returns the netezza service instance
+        """
+        try:
+            service_factory = self._dependencies[service_name] # Retrieve factory function
+            return service_factory() # Call factory() to return instance of service
+        except KeyError as ex:
+            msg = 'failed to inject service: {}'.format(service_name)
+            _bootstrap_logger.critical(msg)
+            _util.eprint(msg)
+            raise ServiceNotFound
+
+    def __setitem__(self, service_name, value):
+        """
+        Register a service or dependency factory to return a service.
+
+        The factory function is called to return an instance of a service object.
+        """
+        self._dependencies[service_name] = value
+
+    def _construct_model(self, model_constructor, *args):
+        """
+        Performs dependency resolution and instantiates an object of given type.
+        
+        This takes in the reference to a class constructor and a list of names
+        of the dependencies that need passed into it, constructs that object and
+        returns it. Models contain business logic and application functionality.
+
+        Args:
+            model_constructor: reference to object constructor.
+        """
+        dependency_names = args
+        dep_references = []
+        for dep_name in dependency_names:
+            dep_references.append(self[dep_name])
+        return model_constructor(*dep_references)
+
+    def _get_config_filepath(self, app_name, app_author, config_filename='config.ini'):
+        """
+        Attempt to find config.ini in the user's config directory.
+
+        On Linux, this will be /home/<user>/.config/<app>/config.ini
+        On Windows, this will be C:\\Users\\<user>\\AppData\\Local\\<app>\\config.ini
+        """
+        dirname = appdirs.user_config_dir(app_name, app_author)
+        filepath = os.path.join(dirname, config_filename)
+        _bootstrap_logger.info('default config filepath calculated to be: %s', filepath)
+        return filepath
+
+    def _get_configspec_filepath(self, configspec_filename='config.spec'):
+        """
+        Attempt to find config.spec inside the installed package directory.
+        """
+        return _util.get_root_asset(configspec_filename)
+
+    def _inject_service_dependencies(self, constructor):
+        """
+        Returns a function that, when called, constructs a new object for
+        business/application logic with the listed dependencies.
+
+        Args:
+            constructor: service class to be created object.
+        """
+        sig = inspect.signature(constructor.__init__)
+        params = sig.parameters
+        params = [params[paramname].name for paramname in params] # Convert Param() type => str
+        cls_dependencies = params[1:] # Skip 'self' parameter on class methods.
+
+        return functools.partial(self._construct_model, constructor, *cls_dependencies)
+
+    def load_command(self):
+        args, unk, success = self.cli.parse()
+        if not success:
+            return False
+        self.ctx.parsed_argv = args
+        self.ctx.parsed_argv_unknown = unk
+        return True
+
+    def invoke_command(self):
+        rc = self.load_command()
+        if not rc:
+            return False
+        try:
+            self.cli.run_command()
+        except NoCommandSpecified as ex:
+            print('Failure: No command specified.')
+
+    def interactive_shell(self):
+        pass
+
+    def invoke_from_cli(self):
+        self.invoke_command()
+
+    def usage(self):
+        pass
+        # Applications need a default usage
+
+class ServiceNotFound(Exception):
+    """
+    Application framework error: unable to find and inject dependency.
+    """
+    pass
+
+class NoCommandSpecified(Exception):
+    pass
+

+ 207 - 0
app_skellington/cfg.py

@@ -0,0 +1,207 @@
+from . import _util
+from ._bootstrap import _bootstrap_logger
+
+import appdirs
+import argparse
+import configobj
+import os
+import sys
+import validate
+
+class Config:
+    """
+    Structure to store application runtime configuration. Also contains
+    functionality to load configuration from local site file.
+    """
+
+    DEFAULT_CAPABILITIES = {
+        'allow_options_beyond_spec': True,
+    }
+
+    def __init__(self, configspec_filepath=None, capabilities=None):
+        self.config_obj = None # Reference to configobj.ConfigObj()
+        self._config_filepaths = []
+        self._configspec_filepath = None
+        # self.configspec_filepath = configspec_filepath
+
+    def __contains__(self, key):
+        try:
+            has_item = key in self.config_obj
+            return has_item
+        except KeyError as ex:
+            pass
+
+    def __delitem__(self, key):
+        """
+        Deletes the configuration item identified by <key> in the internal
+        configuration storage.
+        """
+        try:
+            del self[key]
+        except KeyError as ex:
+            pass
+
+    def __getitem__(self, key):
+        """
+        Returns the vaLue of the configuration item identified by <key>.
+        """
+        try:
+            return self.config_obj[key].dict()
+        except KeyError as ex:
+            # raise ConfigurationItemNotFoundError()
+            raise
+
+    def __setitem__(self, key, value):
+        """
+        Assigns the value of the configuration item
+        identified by <key> as <value>.
+        """
+        self[key] = value
+
+    @property
+    def config_filepath(self, idx=0):
+        """
+        Returns the config filepath (optionally specified by index
+        when using multiple config files).
+        """
+        assert idx>=0, 'invalid idx argument: index must be greater than 0'
+        if len(self._config_filepaths) > 0:
+            try:
+                return self._config_filepaths[idx]
+            except ValueError as ex:
+                return
+
+    @config_filepath.setter
+    def config_filepath(self, value, idx=0):
+        """
+        Assigns <value> as the config filepath (optionally specified by index
+        when using multiple config files).
+        """
+        assert idx>=0, 'invalid idx argument: index must be greater than 0'
+        self._config_filepaths[idx] = value
+
+    @property
+    def configspec_filepath(self):
+        return self._configspec_filepath
+
+    @configspec_filepath.setter
+    def configspec_filepath(self, filepath):
+        if _util.does_file_exist(filepath):
+            self._configspec_filepath = filepath
+        else:
+            _bootstrap_logger.error(
+                'failed to set config.spec: file not found '
+                '(%s)', filepath)
+
+    def load_config_from_file(self, config_filepath):
+        """
+        Loads configuration settings from file, overwritting all configuration.
+        """
+        # Record all config.ini files passed in
+        if config_filepath not in self._config_filepaths:
+            self._config_filepaths.append(config_filepath)
+
+        # Check for config.spec
+        if self.configspec_filepath:
+            _bootstrap_logger.info('using config.spec: %s', self.configspec_filepath)
+        else:
+            _bootstrap_logger.info('config.spec not defined')
+        _bootstrap_logger.info('using config file: %s', config_filepath)
+
+        # Pre-check for config.ini existence 
+        if _util.does_file_exist(config_filepath):
+            _bootstrap_logger.info('existing config file found')
+        else:
+            _bootstrap_logger.info('no config file found: using defaults')
+
+        # interpolation='template' changes config file variable replacement to
+        # use the form $var instead of %(var)s, which is useful to enable
+        # literal %(text)s values in the config.
+        try:
+            configspec_filepath = self.configspec_filepath
+            if configspec_filepath:
+                self.config_obj = configobj.ConfigObj(
+                    config_filepath,
+                    configspec=configspec_filepath,
+                    interpolation='template'
+                )
+            else:
+                self.config_obj = configobj.ConfigObj(
+                    config_filepath,
+                    # configspec=configspec_filepath,
+                    interpolation='template'
+                )
+        except configobj.ParseError as ex:
+            msg = 'failed to load config: error in config.spec configuration: {}'.format(config_filepath)
+            _bootstrap_logger.error(msg)
+            _util.eprint(msg)
+            return False
+        except OSError as ex:
+            msg = 'failed to load config: config.spec file not found'
+            _bootstrap_logger.error(msg)
+            _util.eprint(msg)
+            return False
+
+
+        # Hack the configobj module to alter the interpolation for validate.py:
+        configobj.DEFAULT_INTERPOLATION = 'template'
+        self.config_obj.filename = config_filepath
+
+        if self.configspec_filepath:
+            # Validate config.ini against config.spec
+            try:
+                _bootstrap_logger.info('validating config file against spec')
+                val = validate.Validator()
+                test_results = self.config_obj.validate(
+                    val, copy=True, preserve_errors=True
+                )
+                if test_results is True:
+                    _bootstrap_logger.info(
+                        'application configuration file passed validation. input = %s, validation spec = %s',
+                        config_filepath, configspec_filepath
+                        )
+
+                else:
+                    _bootstrap_logger.critical('config file failed validation')
+
+                    for (section_list, key, rslt) in configobj.flatten_errors(self.config_obj, test_results):
+                        _bootstrap_logger.critical('config error info: %s %s %s', section_list, key, rslt)
+                        if key is not None:
+                            _bootstrap_logger.critical('config failed validation: [%s].%s appears invalid. msg = %s', '.'.join(section_list), key, rslt)
+                        else:
+                            _bootstrap_logger.critical("config failed validation: missing section, name = '%s'. msg = %s", '.'.join(section_list), rslt)
+                    return False
+            except ValueError as ex:
+                _bootstrap_logger.error('failed validating configspec')
+                return False
+
+        # Create the config file if it doesn't exist
+        # if not _util.does_file_exist(config_filepath):
+        if True:
+            _bootstrap_logger.info('writing new config file: %s', config_filepath)
+            dirname = os.path.dirname(config_filepath)
+            _util.ensure_dir_exists(dirname)
+            self.config_obj.write()
+
+        _bootstrap_logger.info('done loading config file')
+        return True
+
+    def print_config(self):
+        """
+        Print configuration to stdout.
+        """
+        print('config:')
+
+        self.config_obj.walk(print)
+        for section in self.config_obj.sections:
+            print(section)
+            for key in self.config_obj[section]:
+                print('    ',  self.config_obj[section][key])
+
+class EnvironmentVariables:
+    def __init__(self):
+        raise NotImplementedError
+
+class ConfigurationItemNotFoundError(Exception):
+    pass
+

+ 541 - 0
app_skellington/cli.py

@@ -0,0 +1,541 @@
+import argparse
+import inspect
+import logging
+import re
+import sys
+
+import app_skellington
+from ._bootstrap import _bootstrap_logger
+from . import app_container
+
+# If explicit fail is enabled, any command with at least one unknown
+# argument will be rejected entirely. If not enabled, unknown arguments
+# will be ignored.
+EXPLICIT_FAIL_ON_UNKNOWN_ARGS = True
+
+class CommandTree:
+    """
+    Command-line interface to hold a menu of commands. You can register
+    commands (functions or methods) in a CommandTree which will generate
+    a corresponding argparse.ArgumentParser (and nested SubParsers) that
+    map function/method arguments into argparse Parameters. Then, you
+    can translate command-line arguments into invoking the function.
+
+    Commands must be registered before being invoked. You create nested
+    SubMenu(s). If function parameters have defaults, those will be
+    available for override else they use the function defaults.
+
+    Print helpful information:
+
+        ./scriptname -h                       # View tier-0 help and usage doc
+        ./scriptname [submenu] -h             # View submenu help and usage doc
+        ./scriptname [submenu] [command] -h   # View command documentation and parameters
+
+    argparse is finicky about argument placement:
+
+        ./scriptname
+            [application arguments]
+            [submenu] [submenu arguments]
+            [command] [command arguments]
+
+    For example,
+
+        ./scriptname --option="value" [submenu] [command]
+
+        is different than
+
+        ./scriptname [submenu] [command] --option="value"
+
+    in that option is being applied to the application in the first example and
+    applied to the refresh_datasets command (under the nhsn command group) in
+    the second. In the same way the -h, --help options print different docs
+    depending on where the help option was passed.
+    """
+    def __init__(self):
+        self.root_parser = argparse.ArgumentParser()
+        self.submenu_param = None # submenu_param is the variable name
+                                  # of the root submenu argument, i.e. the arg
+                                  # in root_parser which selects the submenu.
+        self.entries = {}
+        # NOTE(MG) Implementation note:
+        # CommandTree uses only one of these internal structures (i.e. mutually exclusive),
+        # 'entries' is used when there is a submenu linked to multiple commands.
+        # '_cmd_tree_is_single_command' and '_single_command' instead are used
+        # when the CommandTree is linked to one and only one command.
+        self._cmd_tree_is_single_command = False
+        self._single_command = None
+
+    def print_tree(self):
+        import pprint
+        pprint.pprint(self.entries)
+
+    def add_argument(self, *args, **kwargs):
+        """
+        Adds an argument to the root parser.
+        """
+        _bootstrap_logger.info('adding argument to root parser: %s and %s', args, kwargs)
+        self.root_parser.add_argument(*args, **kwargs)
+
+    def init_submenu(self, param_name, is_required=False):
+        """
+        Creates a root-level submenu with no entries. SubMenu node is
+        returned which can have submenus and commands attached to it.
+        """
+        # Creates an argument as a slot in the underlying argparse.
+        subparsers = self.root_parser.add_subparsers(
+            dest = param_name,
+            metavar = param_name,
+            required = is_required
+        )
+
+        submenu = SubMenu(self, subparsers, param_name)
+        submenu.submenu_path = ''
+        submenu.var_name = param_name
+
+        _bootstrap_logger.info('Initialized root-level submenu: Parameter = \'%s\'', param_name)
+        self.entries[param_name] = submenu
+        self.submenu_param = param_name
+
+        return submenu 
+
+    def register_command(
+        self, func, cmd_name=None, func_signature=None,
+        docstring=None
+    ):
+        """
+        When no submenu functionality is desired, this links a single
+        command into underlying argparse options.
+        """
+        # begin copy-paste from SubMenu.register_command
+        if inspect.isfunction(func):
+            # print('func is function')
+            pass
+        elif inspect.ismethod(func):
+            pass
+            # print('func is method')
+        else:
+            raise Exception('bad value passed in for function')
+
+        if not cmd_name:
+            # safe try/except
+            cmd_name = func.__name__
+
+        if func_signature is None:
+            func_signature = inspect.signature(func)
+
+        if docstring is None:
+            docstring = func.__doc__
+
+        sig = func_signature
+        params = sig.parameters
+
+        # help is displayed next to the command in the submenu enumeration or
+        # list of commands:
+        help_text = HelpGenerator.generate_help_from_sig(docstring)
+        # description is displayed when querying help for the specific command:
+        description_text = HelpGenerator.generate_description_from_sig(docstring)
+        # end copy-paste from SubMenu.register_command
+
+        # begin copy-paste then editted from SubMenu.register_command
+        # For each paramter in the function create an argparse argument in
+        # the child ArgumentParser created for this menu entry:
+        for key in params:
+            if key == 'self':
+                continue
+            param = params[key]
+
+            if '=' in str(param):
+                if param.default is None:
+                    helptext = 'default provided'
+                else:
+                    helptext = "default = '{}'".format(param.default)
+                self.root_parser.add_argument(
+                    key,
+                    help=helptext,
+                    nargs='?',
+                    default=param.default)
+            else:
+                helptext = 'required'
+                self.root_parser.add_argument(
+                    key,
+                    help=helptext)
+
+        # # Wrapper function that instantiates an object and runs a method
+        # # on-demand. The object is created, injected with necessary
+        # # dependencies or services, and the method is invoked.
+        # def func(*args, **kwargs):
+        #     obj = constructor()
+        #     return cls_method(obj, *args, **kwargs)
+
+        # Build the CommandEntry structure
+        cmd = CommandEntry()
+        cmd.argparse_node = self.root_parser
+        cmd.cmd_name = cmd_name 
+        cmd.func_signature = sig
+        # cmd.func_ref = None
+        cmd.callback = func
+
+        registered_name = cmd_name
+        _bootstrap_logger.info('registered command: %s', registered_name)
+        # end copy-paste then editted from SubMenu.register_command
+
+        self._cmd_tree_is_single_command = True
+        self._single_command = cmd
+        self._entries = None
+
+    # def _validate(self):
+    #     pass
+    #     # TODO(MG):
+    #     # subparser can not be empty, needs to have parsers attached
+
+    def parse(self, args=None):
+        if args is None:
+            args = sys.argv[1:]
+
+        try:
+            # on error, prints some argparse error messages:
+            pargs, unk = self.root_parser.parse_known_args(args)
+
+            # if len(unk) > 0:
+            #     _bootstrap_logger.error(
+            #         'failed to interpret argument(s) or command-line switch from shell: %s',
+            #         unk)
+
+            #     if EXPLICIT_FAIL_ON_UNKNOWN_ARGS:
+            #         _bootstrap_logger.warn(
+            #             'failed to parse arguments: explicitly failing to be safe')
+            #         return False, False
+
+            if hasattr(pargs, 'usage'):
+                pass
+                # print('found usage in app_skellington')
+
+            return pargs, unk, True
+
+        # Note: SystemExit is raised when '-h' argument is supplied.
+        except SystemExit as ex:
+            return None, None, False
+
+    def run_command(self, args=None):
+        args, unk, success = self.parse(args)
+        if not success:
+            _bootstrap_logger.info('SystemExit: Perhaps user invoked --help')
+            return
+
+        if args is False and unk is False:
+            _bootstrap_logger.error('failed parsing args')
+            return False
+        _bootstrap_logger.info('received args from shell: %s', args)
+
+        args = vars(args)
+
+        cmd = self._lookup_command(args)
+        if cmd is None:
+            print('cmd is None')
+            _bootstrap_logger.error('failed to find command')
+            return False
+
+        return self._invoke_command(cmd, args)
+
+    def _lookup_command(self, args):
+        keys = list(args.keys())
+
+        # In the case there is at-most one command registered in
+        # the CommandTree with no SubMenu (submenu will be disabled
+        # in this case):
+        if self._cmd_tree_is_single_command:
+            assert self._cmd_tree_is_single_command is True, 'corrupt data structure in CommandMenu'
+            assert self._entries is None, 'corrupt data structure in CommandMenu'
+            assert isinstance(self._single_command, CommandEntry), 'corrupt data structure in CommandMenu'
+            return self._single_command
+
+        # There is at least one submenu we need to go down:
+        else:
+
+            assert self._single_command is None, 'corrupt data structure in CommandMenu'
+            assert self._cmd_tree_is_single_command == False, 'corrupt data structure in CommandMenu'
+
+            # Key or variable name used by argparse to store the submenu options
+            argparse_param = self.submenu_param # e.g.: submenu_root
+            submenu = self.entries[argparse_param]
+
+            while True:
+                if argparse_param not in keys:
+                    print('root menu parameter not found in args:', argparse_param)
+                    input('<broken>')
+
+                val = args.get(argparse_param)
+                _bootstrap_logger.debug('argparse command is \'{}\' = {}'.format(argparse_param, val))
+
+                lookup = submenu.entries.get(val)
+                _bootstrap_logger.debug('lookup, entries[{}] = {}'.format(val, lookup))
+                # print(submenu.entries)
+
+                # pop value
+                del args[argparse_param]
+
+                if isinstance(lookup, SubMenu):
+                    submenu = lookup
+                    argparse_param = submenu.var_name
+                elif isinstance(lookup, CommandEntry):
+                    return lookup
+                    # return self._invoke_command(lookup, args)
+
+                else:
+                    raise app_container.NoCommandSpecified('No command specified.')
+
+    def _invoke_command(self, cmd, args):
+        func = cmd.callback
+        sig = cmd.func_signature
+        params = sig.parameters
+        params = [params[paramname] for paramname in params]
+        func_args = []
+        for param in params:
+            if param.name in args:
+                func_args.append(args[param.name])
+
+        _bootstrap_logger.info('function: %s', func)
+        _bootstrap_logger.info('function args: %s', func_args)
+        return func(*func_args)
+
+    def _get_subparser(self):
+        return self.root_parser._subparsers._actions[1]
+
+class SubMenu:
+    def __init__(self, parent, subparsers_obj, name):
+        self.parent = parent # Reference to root CommandTree
+        self.subparsers_obj = subparsers_obj
+        self.name = name
+        self.submenu_path = None
+
+        self.entries = {}
+
+    def register_command(
+        self, func, cmd_name=None, func_signature=None,
+        docstring=None
+    ):
+        """
+        Registers a command as an entry in this submenu. Provided function is
+        converted into argparse arguments and made available to the user.
+
+        Arguments
+        ---------
+        func:
+            Callback function which will be mapped
+            to the submenu entry.
+
+        cmd_name (optional):
+            User-facing entry name. By default will be the function name.
+            The user will be able to use [cmd_name] [arg, ...] to
+            invoke the callback function.
+
+        func_signature: optionally, you can pass in the
+        inspect.signature(). If None, will inspect the
+        incoming func. Note on internals: This is used
+        to pass the function signature of the command
+        function while having the callback point to a
+        function partial which executes some other code.
+        This hook is used to inject dependencies and then
+        execute the command function.
+        """
+        if inspect.isfunction(func):
+            # print('func is function')
+            pass
+        elif inspect.ismethod(func):
+            pass
+            # print('func is method')
+        else:
+            raise Exception('bad value passed in for function')
+
+        if not cmd_name:
+            # safe try/except
+            cmd_name = func.__name__
+
+        if func_signature is None:
+            func_signature = inspect.signature(func)
+
+        if docstring is None:
+            docstring = func.__doc__
+
+        sig = func_signature
+        params = sig.parameters
+
+        # help is displayed next to the command in the submenu enumeration or
+        # list of commands:
+        help_text = HelpGenerator.generate_help_from_sig(docstring)
+        # description is displayed when querying help for the specific command:
+        description_text = HelpGenerator.generate_description_from_sig(docstring)
+
+        # Entry in local argparse._SubParsersAction
+        # type = ArgumentParser
+        child_node = self.subparsers_obj.add_parser(
+            cmd_name, # Note: cmd_name here will be the VALUE
+                      # passed into the argparse arg VARIABLE NAME
+                      # created when the SubMenu/argparse.addZ_subparsers()
+                      # was created.
+            help=help_text,
+            description=description_text
+        )
+
+        # For each paramter in the function create an argparse argument in
+        # the child ArgumentParser created for this menu entry:
+        for key in params:
+            if key == 'self':
+                continue
+            param = params[key]
+
+            if '=' in str(param):
+                if param.default is None:
+                    helptext = 'default provided'
+                else:
+                    helptext = "default = '{}'".format(param.default)
+                child_node.add_argument(
+                    key,
+                    help=helptext,
+                    nargs='?',
+                    default=param.default)
+            else:
+                helptext = 'required'
+                child_node.add_argument(
+                    key,
+                    help=helptext)
+
+        # # Wrapper function that instantiates an object and runs a method
+        # # on-demand. The object is created, injected with necessary
+        # # dependencies or services, and the method is invoked.
+        # def func(*args, **kwargs):
+        #     obj = constructor()
+        #     return cls_method(obj, *args, **kwargs)
+
+        # Build the CommandEntry structure
+        cmd = CommandEntry()
+        cmd.argparse_node = child_node
+        cmd.cmd_name = cmd_name 
+        cmd.func_signature = sig
+        # cmd.func_ref = None
+        cmd.callback = func
+
+        registered_name = '{}.{}'.format(
+            self.submenu_path,
+            cmd_name)
+        _bootstrap_logger.info('registered command: %s', registered_name)
+        self.entries[cmd_name] = cmd
+
+    def create_submenu( 
+        self, var_name, cmd_entry_name=None, is_required=False
+    ):
+        """
+        Creates a child-submenu.
+
+        Arguments
+        ---------
+        var_name:
+            A code-facing argparse parameter used to store the
+            value/entry chosen by the user.
+
+        cmd_entry_name:
+            A user-facing name used to select created submenu.
+            If not provided, the user-facing command name defaults
+            to the same name as the code-facing argparse parameter
+
+        is_required:
+            Switches if a value must be selected in the created submenu.
+            If not, it's an optional positional argument.
+        """
+        if cmd_entry_name is None:
+            cmd_entry_name = var_name
+
+        # Create an entry in self's submenu:
+        # type = ArgumentParser
+        entry_node = self.subparsers_obj.add_parser(
+            cmd_entry_name,
+            help='sub-submenu help',
+            description='sub-sub description')
+
+        # Turn entry into a submenu of it's own:
+        # type = _SubParsersAction
+        subp_node = entry_node.add_subparsers(
+            dest = var_name,
+            metavar = var_name, 
+            required = is_required)
+    
+        submenu = SubMenu(
+            self.parent,
+            subp_node,
+            cmd_entry_name
+        )
+
+        submenu.var_name = var_name
+
+        submenu.submenu_path = '{}.{}'.format(self.submenu_path, cmd_entry_name)
+        submenu_name = submenu.submenu_path
+
+        _bootstrap_logger.info('registered submenu: %s', submenu_name)
+        self.entries[cmd_entry_name] = submenu
+        return submenu
+
+    def __repr__(self):
+        return 'SubMenu({})<{}>'.format(
+            self.name,
+            ','.join(['cmds'])
+        )
+
+class CommandEntry:
+    """
+    Structure for a command-entry in the CLI.
+
+    Stores the command-subcommand names, the function signature which contains
+    the original parameters of the function-to-be-invoked, a reference to the
+    original function, and a callback function wrapper which, by convention,
+    instantiates the necessary objects (injecting dependencies, etc.) and
+    executes the original function.
+
+    The CLI module has functionality to translate the original function
+    arguments into argparse options (creating the documentation also). Similary,
+    it can convert from argparse options into a function call.
+    """
+    def __init__(self):
+        self.argparse_node = None
+
+        self.cmd_name = None # Don't think we need. And needs to be changed
+                             # from SubMenu
+        self.menu_path = None
+        self.func_signature = None
+        self.func_ref = None
+        self.callback = None
+
+    def __repr__(self):
+        return 'CommandEntry<{}>'.format(self.cmd_name)
+
+class HelpGenerator:
+    def __init__(self):
+        pass
+
+    @staticmethod
+    def generate_help_from_sig(doctext):
+        """
+        The 'help' text is displayed next to the command when enumerating
+        the submenu commands. 
+        """
+        if doctext == None:
+            return doctext
+        regex = '(.*?)[.?!]'
+        match = re.match(regex, doctext, re.MULTILINE | re.DOTALL)
+        if match:
+            return match.group(1) + '.'
+        return doctext
+
+    @staticmethod
+    def generate_description_from_sig(doctext):
+        """
+        The 'description' paragraph is provided when the user requests help
+        on a specific command.
+        """
+        if doctext == None:
+            return doctext
+        regex = '(.*?)[.?!]'
+        match = re.match(regex, doctext, re.MULTILINE | re.DOTALL)
+        if match:
+            return match.group(1) + '.'
+        return doctext
+

+ 166 - 0
app_skellington/log.py

@@ -0,0 +1,166 @@
+from ._bootstrap import _bootstrap_logger
+from . import _util
+
+import appdirs
+import colorlog
+import logging
+import logging.config
+import os
+
+DEFAULT_LOG_SETTINGS = {
+    'formatters': {
+        'colored': {
+            'class': 'colorlog.ColoredFormatter',
+            # 'format': '%(log_color)s%(levelname)-8s%(reset)s:%(log_color)s%(name)-5s%(reset)s:%(white)s%(message)s'
+            'format': '%(white)s%(name)7s%(reset)s|%(log_color)s%(message)s',
+        }
+    },
+
+    'handlers': {
+        'stderr': {
+            'class': 'logging.StreamHandler',
+            'level': 'debug',
+            'formatter': 'colored'
+        }
+
+    },
+
+    'loggers': {
+        'root': {
+            'handlers': ['stderr',],
+            'level': 'debug'
+        },
+        'app_skellington': {
+            # 'handlers': ['stderr',],
+            'level': 'critical',
+            'propagate': 'false'
+        }
+    }
+}
+
+class LoggingLayer:
+    def __init__(self, appname, appauthor, config=None):
+        self.appname = appname
+        self.appauthor = appauthor
+        self.loggers = {}
+
+    def __getitem__(self, k):
+        """
+        Returns Logger object named <k>.
+
+        Example:
+            log = LoggingLayer(...)
+            log['db'].info('loaded database module')
+
+        Args:
+            k: the name of the logger to retrieve (k, i.e. key)
+        """
+        logger = self.loggers.get(k)
+        if not logger:
+            logger = logging.getLogger(k)
+            self.loggers[k] = logger
+        return logger
+
+    def configure_logging(self, config_dict=None):
+        """
+        Set the logging level for the process. Verbosity is controlled by a
+        parameter in the config.
+        
+        Advice: While DEBUG verbosity is useful to debug, it can produce too much
+        noise for typical operation.
+        """
+        if config_dict is None:
+            _bootstrap_logger.debug('No application logging configuration provided. Using default')
+            config_dict = DEFAULT_LOG_SETTINGS
+
+        self.transform_config(config_dict)
+
+        try:    
+            # TODO(MG) switch to pretty-print, as it'd be more human readable
+            _bootstrap_logger.debug('Log configuration: %s', config_dict)
+            logging.config.dictConfig(config_dict)
+            _bootstrap_logger.debug('Configured all logging')
+        except Exception as ex:
+            print('unable to configure logging:', ex, type(ex))
+
+    def transform_config(self, config_dict):
+        """
+        Fix some incompatibilities and differences between the config-file logging
+        parameters and the final config dictionary passed into the logging module.
+        """
+        # Version should be hard-coded 1, per Python docs
+        if 'version' in config_dict:
+            if config_dict['version'] != 1:
+                _bootstrap_logger.warn("logging['version'] must be '1' per Python docs")
+        config_dict['version'] = 1 
+
+        self._add_own_logconfig(config_dict)
+
+        # Replace logger level strings with value integers from module
+        for handler in config_dict['handlers']:
+            d = config_dict['handlers'][handler]
+            self._convert_str_to_loglevel(d, 'level')
+
+        # Replace logger level strings with value integers from module
+        for logger in config_dict['loggers']:
+            d = config_dict['loggers'][logger]
+            self._convert_str_to_loglevel(d, 'level')
+
+        # Replace 'root' logger with '', logging module convention for root handler
+        # Note: '' is disallowed in ConfigObj (hence the reason for this replacement)
+        config_dict['loggers'][''] = config_dict['loggers']['root']
+        del config_dict['loggers']['root']
+
+
+        # Evaluate the full filepath of the file handler
+        if 'file' not in config_dict['handlers']:
+            return
+
+        if os.path.abspath(config_dict['handlers']['file']['filename']) ==\
+                           config_dict['handlers']['file']['filename']:
+            # Path is already absolute
+            pass
+        else:
+            dirname = appdirs.user_log_dir(self.appname, self.appauthor)
+            _util.ensure_dir_exists(dirname)
+            log_filepath = os.path.join(dirname, config_dict['handlers']['file']['filename'])
+            config_dict['handlers']['file']['filename'] = log_filepath
+
+    def _add_own_logconfig(self, config_dict):
+        if os.environ.get('APP_SKELLINGTON_DEBUG', None):
+            if 'app_skellington' not in config_dict['loggers']:
+                config_dict['loggers']['app_skellington'] = {
+                    'level': 'debug', 'propagate': 'false'
+                }
+            else:
+                config_dict['loggers']['app_skellington']['level'] = 'debug'
+
+    def _convert_str_to_loglevel(self, dict_, key):
+        """
+        Convert a dictionary value from a string representation of a log level
+        into the numeric value of that log level. The value is modified in-place
+        and is passed in by a dictionary reference and a key name.
+
+        For example,
+          d = {'loggers': {'cas': {'level': 'critical'}}}
+          convert_str_to_loglevel(d['loggers']['cas'], 'level')
+            =>
+          d is now {'loggers': {'cas': {'level': logging.CRITICAL}}}
+        """
+        try:
+            s = dict_[key]
+        except KeyError as ex:
+            raise
+        if s == 'critical':
+            dict_[key] = logging.CRITICAL
+        elif s == 'error':
+            dict_[key] = logging.ERROR
+        elif s == 'warning':
+            dict_[key] = logging.WARNING
+        elif s == 'info':
+            dict_[key] = logging.INFO
+        elif s == 'debug':
+            dict_[key] = logging.DEBUG
+        elif s == 'all':
+            dict_[key] = logging.NOTSET
+

+ 45 - 0
setup.py

@@ -0,0 +1,45 @@
+#!/usr/bin/env python
+#
+# Usage:
+# 
+#   First, enable the python environment you want to install to, or if installing
+#   system-wide then ensure you're logged in with sufficient permissions
+#   (admin or root to install to system directories)
+#
+#   installation:
+#
+#       $ ./setup.py install
+#
+#   de-installation:
+#
+#       $ pip uninstall <app>
+
+
+from setuptools import setup
+
+__project__ = 'app_skellington'
+__version__ = '0.1.0'
+
+setup(
+    name = __project__,
+    version = __version__,
+    description = 'A high-powered 2-level CLI framework',
+    author = 'Mathew Guest',
+    author_email = 'mathewguest@gmail.com',
+    url = 'https://git-mirror.zavage-software.com',
+
+    # Third-party dependencies; will be automatically installed
+    install_requires = (
+      'appdirs',
+      'configobj',
+      'colorlog',
+      'pprint',
+    ),
+
+    # Local packages to be installed (our packages)
+    packages = (
+        'app_skellington',
+    ),
+
+)
+

+ 0 - 0
tests/__init__.py


+ 4 - 0
tests/pytest.ini

@@ -0,0 +1,4 @@
+[pytest]
+filterwarnings =
+    ignore::DeprecationWarning
+

+ 31 - 0
tests/test_cfg.py

@@ -0,0 +1,31 @@
+from app_skellington.cfg import Config
+
+class TestConfig_e2e:
+    def test_allows_reading_with_no_spec(self):
+        x = Config()
+        assert True == False
+
+    def test_allows_reading_with_sample_spec(self):
+        x = Config()
+        assert True == False
+
+    def test_constructor_fails_with_invalid_spec(self):
+        x = Config()
+        assert True == False
+
+    def test_allows_options_beyond_spec(self):
+        x = Config()
+        assert True == False
+
+    def test_can_read_config_correctly_from_file(self):
+        pass
+
+    def test_can_read_config_file_mutiple_times(self):
+        pass
+
+    def test_can_override_config_file_manually(self):
+        pass
+
+    def test_can_set_option_without_config(self):
+        pass
+

+ 8 - 0
tests/test_cli.py

@@ -0,0 +1,8 @@
+from app_skellington.cli import CommandTree
+
+class TestCli_e2e:
+    def test_null_constructor_works(self):
+        x = CommandTree()
+        assert True == False
+
+

+ 0 - 0
tests/test_log.py