diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c28c0f6..58734b9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,10 +17,10 @@ repos: args: ["--profile", "black", "--filter-files"] # Flake8 -- repo: https://github.com/pycqa/flake8 - rev: '7.0.0' - hooks: - - id: flake8 +#- repo: https://github.com/pycqa/flake8 +# rev: '7.0.0' +# hooks: +# - id: flake8 # Black # Using this mirror lets us use mypyc-compiled black, which is about 2x faster diff --git a/app_skellington/__init__.py b/app_skellington/__init__.py index bd9d6e0..8c1461f 100644 --- a/app_skellington/__init__.py +++ b/app_skellington/__init__.py @@ -5,4 +5,3 @@ from .app_container import * from .cfg import * from .cli import * from .log import * - diff --git a/app_skellington/_bootstrap.py b/app_skellington/_bootstrap.py index 1be9abb..9fcc6f7 100644 --- a/app_skellington/_bootstrap.py +++ b/app_skellington/_bootstrap.py @@ -3,24 +3,28 @@ import os import sys # Check and gracefully fail if the user needs to install a 3rd-party dep. -libnames = ['appdirs', 'configobj', 'colorlog'] +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-party library: ', ex, file=sys.stderr) + print("Missing third-party library: ", ex, file=sys.stderr) rc = False return rc + + if not check_env_has_dependencies(libnames): - print('Unable to load program without installed dependencies', file=sys.stderr) - raise ImportError('python environment needs third-party dependencies installed') + print("Unable 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 = 'skell' +_log_fmt = "%(levelname)-7s:%(message)s" +_logger_name = "skell" _bootstrap_logger = logging.getLogger(_logger_name) # NOTE(MG) Logger monkey-patch: @@ -29,20 +33,19 @@ _bootstrap_logger = logging.getLogger(_logger_name) # configuration is reloaded. This catches APPSKELLINGTON_DEBUG # environment variable the first time, as app_skellington module # is imported. See cfg.py -if os.environ.get('APPSKELLINGTON_DEBUG', None): - _bootstrap_logger.setLevel(logging.DEBUG) # Don't filter any log levels +if os.environ.get("APPSKELLINGTON_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('log - APPSKELLINGTON_DEBUG set in environment: Enabling verbose logging.') + _bootstrap_logger.debug("log - APPSKELLINGTON_DEBUG set in environment: Enabling verbose logging.") # Logging is by default off, excepting CRITICAL -else: +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()) - diff --git a/app_skellington/_util.py b/app_skellington/_util.py index 6f7320f..433b9da 100644 --- a/app_skellington/_util.py +++ b/app_skellington/_util.py @@ -1,16 +1,19 @@ 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 @@ -18,6 +21,7 @@ def filename_to_abspath(filename): """ return os.path.abspath(filename) + def does_file_exist(filepath): """ Because the file can be deleted or created immediately after execution of @@ -26,37 +30,37 @@ def does_file_exist(filepath): instant in execution. """ try: - fp = open(filepath, 'r') + fp = open(filepath, "r") return True except FileNotFoundError as ex: return False + def ensure_dir_exists(dirpath): if dirpath is None: return - if dirpath == '': + 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__)) + module_root = os.path.abspath(os.path.dirname(sys.modules["__main__"].__file__)) path = os.path.join(module_root, filepath) - return path + 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: @@ -75,7 +79,7 @@ def get_asset(module, filepath): elif isinstance(module, module): module_file = module.__file__ else: - raise Exception('Invalid Usage') + raise Exception("Invalid Usage") try: root = module_file @@ -88,7 +92,8 @@ def get_asset(module, filepath): raise path = os.path.join(root, filepath) - return path + return path + def register_class_as_commands(app, submenu, cls_object): """ @@ -105,7 +110,7 @@ def register_class_as_commands(app, submenu, cls_object): for m in members: name = m[0] ref = m[1] - if inspect.isfunction(ref) and not name.startswith('_'): + if inspect.isfunction(ref) and not name.startswith("_"): cls_method = ref constructor = app._inject_service_dependencies(cls_constructor) sig = inspect.signature(cls_method) @@ -114,9 +119,10 @@ def register_class_as_commands(app, submenu, cls_object): docstring = inspect.getdoc(cls_method) submenu.register_command(func, name, sig, docstring) + def create_func(constructor, cls_method): def func(*args, **kwargs): cmd_class_instance = constructor() return cls_method(cmd_class_instance, *args, **kwargs) - return func + return func diff --git a/app_skellington/app_container.py b/app_skellington/app_container.py index 425848b..e20d3d8 100644 --- a/app_skellington/app_container.py +++ b/app_skellington/app_container.py @@ -1,34 +1,40 @@ -import appdirs import collections import functools import inspect +import logging import os import sys +import appdirs + +from . import ( + _util, + cfg, + cli, + log, +) + # Application scaffolding: from ._bootstrap import _bootstrap_logger -from . import log -from . import _util -from . import cli -from . import cfg - -import logging # These two variables affect the directory paths for # config files and logging. -DEFAULT_APP_NAME = '' -DEFAULT_APP_AUTHOR = '' +DEFAULT_APP_NAME = "" +DEFAULT_APP_AUTHOR = "" + 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 @@ -40,14 +46,10 @@ class ApplicationContainer: Override appname and appauthor arguments to direct config and log directories. """ - def __init__( - self, - configspec_filepath=None, - configini_filepath=None, - *args, **kwargs - ): - self.appname = kwargs.get('appname') or DEFAULT_APP_NAME - self.appauthor = kwargs.get('appauthor') or DEFAULT_APP_AUTHOR + + def __init__(self, configspec_filepath=None, configini_filepath=None, *args, **kwargs): + self.appname = kwargs.get("appname") or DEFAULT_APP_NAME + self.appauthor = kwargs.get("appauthor") or DEFAULT_APP_AUTHOR # Instantiate application context which contains # global state, configuration, loggers, and runtime args. @@ -57,29 +59,28 @@ class ApplicationContainer: logger = log.LoggingLayer(self.appname, self.appauthor) # Try and load logging configuration if provided - log_config = config.get('logging') + log_config = config.get("logging") if log_config is not None: logger.configure_logging(log_config) else: logger.configure_logging() - self.ctx = ApplicationContext(config, logger) # Reference to root_app avail. in context self.ctx.root_app = self # Reference to context service avail. in root_app - self['ctx'] = lambda: self.ctx + self["ctx"] = lambda: self.ctx - self.cli = cli.CommandTree() # Command-line interface + self.cli = cli.CommandTree() # Command-line interface # Run methods if subclass implemented them: - if callable(getattr(self, '_cli_options', None)): + if callable(getattr(self, "_cli_options", None)): self._cli_options() - if callable(getattr(self, '_services', None)): + if callable(getattr(self, "_services", None)): self._services() - if callable(getattr(self, '_command_menu', None)): + if callable(getattr(self, "_command_menu", None)): self._command_menu() def __delitem__(self, service_name): @@ -99,10 +100,10 @@ class ApplicationContainer: app['datalayer'] => returns the made-up "datalayer" service. """ try: - service_factory = self._dependencies[service_name] # Retrieve factory function - return service_factory() # Call factory() to return instance of service + 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) + msg = "failed to inject service: {}".format(service_name) _bootstrap_logger.critical(msg) raise ServiceNotFound @@ -117,7 +118,7 @@ class ApplicationContainer: 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. @@ -131,9 +132,7 @@ class ApplicationContainer: dependencies.append(self[dep_name]) return model_constructor(*dependencies) - def _get_config_filepath( - self, app_name, app_author, config_filename='config.ini' - ): + def _get_config_filepath(self, app_name, app_author, config_filename="config.ini"): """ Attempt to find config.ini in the user's config directory. @@ -142,10 +141,10 @@ class ApplicationContainer: """ 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) + _bootstrap_logger.info("default config filepath calculated to be: %s", filepath) return filepath - def _get_configspec_filepath(self, configspec_filename='config.spec'): + def _get_configspec_filepath(self, configspec_filename="config.spec"): """ Attempt to find config.spec inside the installed package directory. """ @@ -161,8 +160,8 @@ class ApplicationContainer: """ 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. + 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) @@ -181,7 +180,7 @@ class ApplicationContainer: try: self.cli.run_command() except NoCommandSpecified as ex: - print('Failure: No command specified.') + print("Failure: No command specified.") def interactive_shell(self): pass @@ -193,12 +192,14 @@ class ApplicationContainer: pass # Applications need a default usage + class ServiceNotFound(Exception): """ Application framework error: unable to find and inject dependency. """ + pass + class NoCommandSpecified(Exception): pass - diff --git a/app_skellington/cfg.py b/app_skellington/cfg.py index aaf5a31..ac9baf7 100644 --- a/app_skellington/cfg.py +++ b/app_skellington/cfg.py @@ -4,15 +4,17 @@ # ConfigObj module and it's recommended to use config.spec files to define # your available configuration of the relevant application. +import argparse +import os +import sys + +import appdirs +import configobj +import validate + from . import _util from ._bootstrap import _bootstrap_logger -import appdirs -import argparse -import configobj -import os -import sys -import validate class Config: """ @@ -26,16 +28,11 @@ class Config: """ DEFAULT_CAPABILITIES = { - 'allow_options_beyond_spec': True, + "allow_options_beyond_spec": True, } - def __init__( - self, - configspec_filepath=None, - configini_filepath=None, - capabilities=None - ): - self._config_obj = None # must be type configobj.ConfigObj() + def __init__(self, configspec_filepath=None, configini_filepath=None, capabilities=None): + self._config_obj = None # must be type configobj.ConfigObj() self._configini_data = None self._configini_filepath = None self._configspec_data = None @@ -83,9 +80,7 @@ class Config: @configspec_filepath.setter def configspec_filepath(self, filepath): if filepath is None: - _bootstrap_logger.debug( - 'cfg - Clearing configspec' - ) + _bootstrap_logger.debug("cfg - Clearing configspec") self._configspec_filepath = None self._configspec_data = None self._has_changed_internally = True @@ -98,18 +93,12 @@ class Config: self._configspec_filepath = filepath self._configspec_data = data self._has_changed_internally = True - _bootstrap_logger.debug( - 'cfg - Set configspec and read contents: %s', - filepath - ) + _bootstrap_logger.debug("cfg - Set configspec and read contents: %s", filepath) self.load_config() return except OSError as ex: - _bootstrap_logger.critical( - 'cfg - Failed to find config.spec: file not found (%s)', - filepath - ) - raise OSError('Failed to read provided config.spec file') + _bootstrap_logger.critical("cfg - Failed to find config.spec: file not found (%s)", filepath) + raise OSError("Failed to read provided config.spec file") self.load_config() @@ -164,9 +153,7 @@ class Config: except KeyError as ex: return default - def load_config( - self, configspec_filepath=None, configini_filepath=None - ): + def load_config(self, configspec_filepath=None, configini_filepath=None): # Set new arguments if were passed in: if configspec_filepath is not None: self.configspec_filepath = configspec_filepath @@ -179,7 +166,7 @@ class Config: rc = self._validate_config_against_spec() if not rc: if self._capability_enforce_strict_spec_validation: - raise RuntimeError('Failed to validate config.ini against spec.') + raise RuntimeError("Failed to validate config.ini against spec.") return False return True @@ -196,21 +183,20 @@ class Config: # options configspec=config_spec, # encoding - interpolation='template' + interpolation="template", # raise_errors ) _bootstrap_logger.debug( - 'cfg - Parsed configuration. config.spec = %s, config.ini = %s', - config_spec, config_ini + "cfg - Parsed configuration. config.spec = %s, config.ini = %s", config_spec, config_ini ) return True except configobj.ParseError as ex: - msg = 'cfg - Failed to load config: error in config.spec configuration: {}'.format(config_filepath) + msg = "cfg - Failed to load config: error in config.spec configuration: {}".format(config_filepath) _bootstrap_logger.error(msg) return False except OSError as ex: - msg = 'cfg - Failed to load config: config.spec file not found.' + msg = "cfg - Failed to load config: config.spec file not found." _bootstrap_logger.error(msg) return False except Exception as ex: @@ -221,58 +207,66 @@ class Config: config_ini = self.configini_filepath # Hack the configobj module to alter the interpolation for validate.py: - configobj.DEFAULT_INTERPOLATION = 'template' + configobj.DEFAULT_INTERPOLATION = "template" # Validate config.ini against config.spec try: - _bootstrap_logger.info('cfg - Validating config file against spec') + _bootstrap_logger.info("cfg - Validating config file against spec") val = validate.Validator() - assert isinstance(self._config_obj, configobj.ConfigObj), 'expecting configobj.ConfigObj, received %s' % type(self._config_obj) + assert isinstance( + self._config_obj, configobj.ConfigObj + ), "expecting configobj.ConfigObj, received %s" % type(self._config_obj) # NOTE(MG) copy arg below instructs configobj to use defaults from spec file - test_results = self._config_obj.validate( - val, copy=True, preserve_errors=True - ) + test_results = self._config_obj.validate(val, copy=True, preserve_errors=True) if test_results is True: _bootstrap_logger.info( - 'cfg- Successfully validated configuration against spec. input = %s, validation spec = %s', - config_ini, config_spec + "cfg- Successfully validated configuration against spec. input = %s, validation spec = %s", + config_ini, + config_spec, ) return True elif test_results is False: - _bootstrap_logger.debug( - 'cfg - Potentially discovered invalid config.spec' - ) + _bootstrap_logger.debug("cfg - Potentially discovered invalid config.spec") else: self._validate_parse_errors(test_results) return False except ValueError as ex: - _bootstrap_logger.error('cfg - Failed while validating config against spec. ') + _bootstrap_logger.error("cfg - Failed while validating config against spec. ") return False def _validate_parse_errors(self, test_results): - _bootstrap_logger.critical('cfg - Config file failed validation.') - for (section_list, key, rslt) in configobj.flatten_errors(self._config_obj, test_results): - _bootstrap_logger.critical('cfg - Config error info: %s %s %s', section_list, key, rslt) + _bootstrap_logger.critical("cfg - Config file failed validation.") + for section_list, key, rslt in configobj.flatten_errors(self._config_obj, test_results): + _bootstrap_logger.critical("cfg - Config error info: %s %s %s", section_list, key, rslt) if key is not None: - _bootstrap_logger.critical('cfg - Config failed validation: [%s].%s appears invalid. msg = %s', '.'.join(section_list), key, rslt) + _bootstrap_logger.critical( + "cfg - Config failed validation: [%s].%s appears invalid. msg = %s", + ".".join(section_list), + key, + rslt, + ) else: - _bootstrap_logger.critical("cfg - Config failed validation: missing section, name = '%s'. msg = %s", '.'.join(section_list), rslt) + _bootstrap_logger.critical( + "cfg - Config failed validation: missing section, name = '%s'. msg = %s", + ".".join(section_list), + rslt, + ) def print_config(self): """ Print configuration to stdout. """ - print('config:') + 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]) + print(" ", self._config_obj[section][key]) + class EnvironmentVariables: def __init__(self): raise NotImplementedError - diff --git a/app_skellington/cli.py b/app_skellington/cli.py index 1a78f7e..b6b8680 100644 --- a/app_skellington/cli.py +++ b/app_skellington/cli.py @@ -5,14 +5,16 @@ import re import sys import app_skellington -from ._bootstrap import _bootstrap_logger + from . import app_container +from ._bootstrap import _bootstrap_logger # 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 @@ -51,11 +53,12 @@ class CommandTree: 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.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), @@ -72,7 +75,7 @@ class CommandTree: """ Adds an argument to the root parser. """ - _bootstrap_logger.info('adding argument to root parser: %s and %s', args, kwargs) + _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): @@ -83,39 +86,27 @@ class CommandTree: # NOTE(MG) Fix for Python>=3.7, # argparse.ArgumentParser added 'required' argument. # Must also be written into SubMenu.create_submenu. - func_args = { - 'dest': param_name, - 'metavar': param_name, - 'required': is_required - } - if ( - sys.version_info.major == 3 - and sys.version_info.minor < 7 - ): + func_args = {"dest": param_name, "metavar": param_name, "required": is_required} + if sys.version_info.major == 3 and sys.version_info.minor < 7: if is_required: - _bootstrap_logger.warn('Unable to enforce required submenu: Requires >= Python 3.7') - del func_args['required'] + _bootstrap_logger.warn("Unable to enforce required submenu: Requires >= Python 3.7") + del func_args["required"] # END fix for Python<3.7 # Creates an argument as a slot in the underlying argparse. - subparsers = self.root_parser.add_subparsers( - **func_args - ) + subparsers = self.root_parser.add_subparsers(**func_args) submenu = SubMenu(self, subparsers, param_name) - submenu.submenu_path = '' + submenu.submenu_path = "" submenu.var_name = param_name - _bootstrap_logger.info('Initialized root-level submenu: Parameter = \'%s\'', 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 + return submenu - def register_command( - self, func, cmd_name=None, func_signature=None, - docstring=None - ): + 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. @@ -128,7 +119,7 @@ class CommandTree: pass # print('func is method') else: - raise Exception('bad value passed in for function') + raise Exception("bad value passed in for function") if not cmd_name: # safe try/except @@ -154,37 +145,30 @@ class CommandTree: # 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': + if key == "self": continue param = params[key] - if '=' in str(param): + if "=" in str(param): if param.default is None: - helptext = 'default provided' + helptext = "default provided" else: helptext = "default = '{}'".format(param.default) - self.root_parser.add_argument( - key, - help=helptext, - nargs='?', - default=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) + helptext = "required" + self.root_parser.add_argument(key, help=helptext) # Build the CommandEntry structure cmd = CommandEntry() cmd.argparse_node = self.root_parser - cmd.cmd_name = cmd_name + 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) + _bootstrap_logger.info("registered command: %s", registered_name) # end copy-paste then editted from SubMenu.register_command self._cmd_tree_is_single_command = True @@ -209,7 +193,7 @@ class CommandTree: # 'failed to parse arguments: explicitly failing to be safe') # return False, False - if hasattr(pargs, 'usage'): + if hasattr(pargs, "usage"): pass # print('found usage in app_skellington') @@ -222,19 +206,19 @@ class CommandTree: def run_command(self, args=None): args, unk, success = self.parse(args) if not success: - _bootstrap_logger.info('cli - SystemExit: Perhaps user invoked --help') + _bootstrap_logger.info("cli - SystemExit: Perhaps user invoked --help") return if args is False and unk is False: - _bootstrap_logger.error('cli - Failed parsing args.') + _bootstrap_logger.error("cli - Failed parsing args.") return False - _bootstrap_logger.info('cli - Received args from shell: %s', args) + _bootstrap_logger.info("cli - Received args from shell: %s", args) args = vars(args) cmd = self._lookup_command(args) if cmd is None: - _bootstrap_logger.critical('cli - Failed to find command.') + _bootstrap_logger.critical("cli - Failed to find command.") return False return self._invoke_command(cmd, args) @@ -246,31 +230,31 @@ class CommandTree: # 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' + 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' + 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 + 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('') + print("root menu parameter not found in args:", argparse_param) + input("") val = args.get(argparse_param) - _bootstrap_logger.debug('cli - argparse command is \'{}\' = {}'.format(argparse_param, val)) + _bootstrap_logger.debug("cli - argparse command is '{}' = {}".format(argparse_param, val)) lookup = submenu.entries.get(val) - _bootstrap_logger.debug('cli - lookup, entries[{}] = {}'.format(val, lookup)) + _bootstrap_logger.debug("cli - lookup, entries[{}] = {}".format(val, lookup)) # pop value del args[argparse_param] @@ -283,7 +267,7 @@ class CommandTree: # return self._invoke_command(lookup, args) else: - raise app_container.NoCommandSpecified('No command specified.') + raise app_container.NoCommandSpecified("No command specified.") def _invoke_command(self, cmd, args): command_to_be_invoked = cmd.callback @@ -296,26 +280,24 @@ class CommandTree: if param.name in args: func_args.append(args[param.name]) - _bootstrap_logger.info('cli - function: %s', func) - _bootstrap_logger.info('cli - function args: %s', func_args) + _bootstrap_logger.info("cli - function: %s", func) + _bootstrap_logger.info("cli - function args: %s", func_args) return command_to_be_invoked(*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.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 - ): + 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. @@ -345,7 +327,7 @@ class SubMenu: elif inspect.ismethod(func): pass else: - raise Exception('bad value passed in for function') + raise Exception("bad value passed in for function") if not cmd_name: # TODO(MG) Safer sanitation @@ -369,56 +351,44 @@ class SubMenu: # 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.add_subparsers() - # was created. + cmd_name, # Note: cmd_name here will be the VALUE + # passed into the argparse arg VARIABLE NAME + # created when the SubMenu/argparse.add_subparsers() + # was created. help=help_text, - description=description_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': + if key == "self": continue param = params[key] - if '=' in str(param): + if "=" in str(param): if param.default is None: - helptext = 'default provided' + helptext = "default provided" else: helptext = "default = '{}'".format(param.default) - child_node.add_argument( - key, - help=helptext, - nargs='?', - default=param.default - ) + child_node.add_argument(key, help=helptext, nargs="?", default=param.default) else: - helptext = 'required' - child_node.add_argument( - key, - help=helptext - ) + helptext = "required" + child_node.add_argument(key, help=helptext) # Build the CommandEntry structure cmd = CommandEntry() cmd.argparse_node = child_node - cmd.cmd_name = cmd_name + 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('cli - registered command: %s', registered_name) + registered_name = "{}.{}".format(self.submenu_path, cmd_name) + _bootstrap_logger.info("cli - registered command: %s", registered_name) self.entries[cmd_name] = cmd - def create_submenu( - self, var_name, cmd_entry_name=None, is_required=False - ): + def create_submenu(self, var_name, cmd_entry_name=None, is_required=False): """ Creates a child-submenu. @@ -443,54 +413,37 @@ class SubMenu: # 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') + cmd_entry_name, help="sub-submenu help", description="sub-sub description" + ) # NOTE(MG) Fix for Python>=3.7, # argparse.ArgumentParser added 'required' argument. # Must also be written into CommandTree.init_submenu - func_args = { - 'dest': var_name, - 'metavar': var_name, - 'required': is_required - } - if ( - sys.version_info.major == 3 - and sys.version_info.minor < 7 - ): + func_args = {"dest": var_name, "metavar": var_name, "required": is_required} + if sys.version_info.major == 3 and sys.version_info.minor < 7: if is_required: - _bootstrap_logger.warn('Unable to enforce required submenu: Requires >= Python 3.7') - del func_args['required'] + _bootstrap_logger.warn("Unable to enforce required submenu: Requires >= Python 3.7") + del func_args["required"] # END fix for Python<3.7 - # Turn entry into a submenu of it's own: # type = _SubParsersAction - subp_node = entry_node.add_subparsers( - **func_args - ) - - submenu = SubMenu( - self.parent, - subp_node, - cmd_entry_name - ) + subp_node = entry_node.add_subparsers(**func_args) + + 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.submenu_path = "{}.{}".format(self.submenu_path, cmd_entry_name) submenu_name = submenu.submenu_path - _bootstrap_logger.info('cli - registered submenu: %s', submenu_name) + _bootstrap_logger.info("cli - registered submenu: %s", submenu_name) self.entries[cmd_entry_name] = submenu return submenu def __repr__(self): - return 'SubMenu({})<{}>'.format( - self.name, - ','.join(['cmds']) - ) + return "SubMenu({})<{}>".format(self.name, ",".join(["cmds"])) + class CommandEntry: """ @@ -506,18 +459,20 @@ class CommandEntry: 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.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) + return "CommandEntry<{}>".format(self.cmd_name) + class HelpGenerator: def __init__(self): @@ -527,14 +482,14 @@ class HelpGenerator: def generate_help_from_sig(doctext): """ The 'help' text is displayed next to the command when enumerating - the submenu commands. + the submenu commands. """ if doctext == None: return doctext - regex = '(.*?)[.?!]' + regex = "(.*?)[.?!]" match = re.match(regex, doctext, re.MULTILINE | re.DOTALL) if match: - return match.group(1) + '.' + return match.group(1) + "." return doctext @staticmethod @@ -545,9 +500,8 @@ class HelpGenerator: """ if doctext == None: return doctext - regex = '(.*?)[.?!]' + regex = "(.*?)[.?!]" match = re.match(regex, doctext, re.MULTILINE | re.DOTALL) if match: - return match.group(1) + '.' + return match.group(1) + "." return doctext - diff --git a/app_skellington/log.py b/app_skellington/log.py index 7ed6b2b..a134285 100644 --- a/app_skellington/log.py +++ b/app_skellington/log.py @@ -1,48 +1,42 @@ -from ._bootstrap import _bootstrap_logger, _logger_name -from . import _util - -import appdirs -import colorlog import logging import logging.config import os +import appdirs +import colorlog + +from . import _util +from ._bootstrap import _bootstrap_logger, _logger_name + DEFAULT_LOG_SETTINGS = { - 'formatters': { - 'colored': { - 'class': 'colorlog.ColoredFormatter', + "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', + "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' + "handlers": {"stderr": {"class": "logging.StreamHandler", "level": "debug", "formatter": "colored"}}, + "loggers": { + "root": { + "handlers": [ + "stderr", + ], + "level": "debug", }, - 'app_skellington': { + "app_skellington": { # 'handlers': ['stderr',], - 'level': 'critical', - 'propagate': 'false' - } - } + "level": "critical", + "propagate": "false", + }, + }, } + class LoggingLayer: - def __init__( - self, appname=None, appauthor=None - ): - self.appname = appname or '' - self.appauthor = appauthor or '' + def __init__(self, appname=None, appauthor=None): + self.appname = appname or "" + self.appauthor = appauthor or "" self.loggers = {} def __getitem__(self, k): @@ -66,22 +60,22 @@ class LoggingLayer: """ 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('log - No application logging configuration provided. Using default') + _bootstrap_logger.debug("log - No application logging configuration provided. Using default") config_dict = DEFAULT_LOG_SETTINGS self.transform_config(config_dict) - try: - _bootstrap_logger.debug('log - Log configuration: %s', config_dict) + try: + _bootstrap_logger.debug("log - Log configuration: %s", config_dict) logging.config.dictConfig(config_dict) - _bootstrap_logger.debug('log - Configured all logging') + _bootstrap_logger.debug("log - Configured all logging") except Exception as ex: - print('unable to configure logging:', ex, type(ex)) + print("unable to configure logging:", ex, type(ex)) def transform_config(self, config_dict): """ @@ -89,48 +83,45 @@ class LoggingLayer: 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: + 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 + 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') + 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') + for logger in config_dict["loggers"]: + d = config_dict["loggers"][logger] + self._convert_str_to_loglevel(d, "level") - - # Implementation note: + # Implementation note: # app_skellington expects root logger configuration to be under 'root' # instead of '' (python spec) because '' is not a valid name in ConfigObj. try: - if config_dict['loggers'].get('root') is not None: - config_dict['loggers'][''] = config_dict['loggers']['root'] - del config_dict['loggers']['root'] + if config_dict["loggers"].get("root") is not None: + config_dict["loggers"][""] = config_dict["loggers"]["root"] + del config_dict["loggers"]["root"] except Exception as ex: - _bootstrap_logger.warn('was not able to find and patch root logger configuration from arguments') - + _bootstrap_logger.warn("was not able to find and patch root logger configuration from arguments") # Evaluate the full filepath of the file handler - if 'file' not in config_dict['handlers']: + if "file" not in config_dict["handlers"]: return - if os.path.abspath(config_dict['handlers']['file']['filename']) ==\ - config_dict['handlers']['file']['filename']: + 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 + 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): # NOTE(MG) Monkey-patch logger @@ -140,13 +131,11 @@ class LoggingLayer: # variable the second time, when it's being reloaded as a # logging configuration is read from config file. # See _bootstrap.py - if os.environ.get('APPSKELLINGTON_DEBUG', None): - if _logger_name not in config_dict['loggers']: - config_dict['loggers'][_logger_name] = { - 'level': 'debug', 'propagate': 'false' - } + if os.environ.get("APPSKELLINGTON_DEBUG", None): + if _logger_name not in config_dict["loggers"]: + config_dict["loggers"][_logger_name] = {"level": "debug", "propagate": "false"} else: - config_dict['loggers'][_logger_name]['level'] = 'debug' + config_dict["loggers"][_logger_name]["level"] = "debug" def _convert_str_to_loglevel(self, dict_, key): """ @@ -164,16 +153,15 @@ class LoggingLayer: s = dict_[key] except KeyError as ex: raise - if s == 'critical': + if s == "critical": dict_[key] = logging.CRITICAL - elif s == 'error': + elif s == "error": dict_[key] = logging.ERROR - elif s == 'warning': + elif s == "warning": dict_[key] = logging.WARNING - elif s == 'info': + elif s == "info": dict_[key] = logging.INFO - elif s == 'debug': + elif s == "debug": dict_[key] = logging.DEBUG - elif s == 'all': + elif s == "all": dict_[key] = logging.NOTSET - diff --git a/setup.py b/setup.py index c487c5f..3e884b8 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/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) @@ -15,61 +15,52 @@ # $ pip uninstall app_skellington -from setuptools import setup import os -__project__ = 'app_skellington' -__version__ = '0.1.1' -__description__ = 'A high-powered command line menu framework.' +from setuptools import setup + +__project__ = "app_skellington" +__version__ = "0.1.1" +__description__ = "A high-powered command line menu framework." long_description = __description__ -readme_filepath = os.path.join( - os.path.abspath(os.path.dirname(__file__)), - 'README.md' -) -with open(readme_filepath, encoding='utf-8') as fp: +readme_filepath = os.path.join(os.path.abspath(os.path.dirname(__file__)), "README.md") +with open(readme_filepath, encoding="utf-8") as fp: long_description = fp.read() setup( - name = __project__, - version = __version__, - description = 'A high-powered command line menu framework.', - long_description = long_description, - author = 'Mathew Guest', - author_email = 't3h.zavage@gmail.com', - url = 'https://git-mirror.zavage.net/Mirror/app_skellington', - license = 'MIT', - - python_requires = '>=3', - - classifiers = [ - 'Development Status :: 3 - Alpha', - 'Environment :: Console', - 'Framework :: Pytest', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: MIT License', - 'Natural Language :: English', - 'Operating System :: MacOS', - 'Operating System :: Microsoft', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: OS Independent', - 'Operating System :: POSIX', - 'Operating System :: POSIX :: Linux', - 'Topic :: Software Development :: Libraries', - 'Topic :: Utilities' + name=__project__, + version=__version__, + description="A high-powered command line menu framework.", + long_description=long_description, + author="Mathew Guest", + author_email="t3h.zavage@gmail.com", + url="https://git-mirror.zavage.net/Mirror/app_skellington", + license="MIT", + python_requires=">=3", + classifiers=[ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Framework :: Pytest", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: MacOS", + "Operating System :: Microsoft", + "Operating System :: Microsoft :: Windows", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: POSIX :: Linux", + "Topic :: Software Development :: Libraries", + "Topic :: Utilities", ], - # Third-party dependencies; will be automatically installed - install_requires = ( - 'appdirs', - 'configobj', - 'colorlog', + install_requires=( + "appdirs", + "configobj", + "colorlog", ), - # Local packages to be installed (our packages) - packages = ( - 'app_skellington', - ), + packages=("app_skellington",), ) - diff --git a/tests/cfg/test_cfg.py b/tests/cfg/test_cfg.py index d6d8875..459dfe8 100644 --- a/tests/cfg/test_cfg.py +++ b/tests/cfg/test_cfg.py @@ -1,45 +1,43 @@ -from app_skellington.cfg import Config -from app_skellington import _util - import pytest +from app_skellington import _util +from app_skellington.cfg import Config + + @pytest.fixture def sample_configspec_filepath(): - return _util.get_asset(__name__, 'sample_config.spec') + return _util.get_asset(__name__, "sample_config.spec") + @pytest.fixture def sample_configini_filepath(): - return _util.get_asset(__name__, 'sample_config.ini') + return _util.get_asset(__name__, "sample_config.ini") + @pytest.fixture def sample_full_configspec_filepath(): - return _util.get_asset(__name__, 'sample_config_full.spec') + return _util.get_asset(__name__, "sample_config_full.spec") + @pytest.fixture def sample_full_configini_filepath(): - return _util.get_asset(__name__, 'sample_config_full.ini') + return _util.get_asset(__name__, "sample_config_full.ini") + @pytest.fixture def sample_invalid_configspec_filepath(): - return _util.get_asset(__name__, 'sample_config_invalid.spec') + return _util.get_asset(__name__, "sample_config_invalid.spec") + class TestConfig_e2e: - def test_allows_reading_ini_and_no_spec( - self, sample_configini_filepath - ): - cfg = Config( - configini_filepath=sample_configini_filepath - ) - assert cfg['root_option'] == 'root_option_val', 'expecting default from config.spec (didnt get)' - assert cfg['app']['sub_option'] == 'sub_option_val', 'expecting default for sub option' + def test_allows_reading_ini_and_no_spec(self, sample_configini_filepath): + cfg = Config(configini_filepath=sample_configini_filepath) + assert cfg["root_option"] == "root_option_val", "expecting default from config.spec (didnt get)" + assert cfg["app"]["sub_option"] == "sub_option_val", "expecting default for sub option" - def test_allows_reading_spec_and_no_ini( - self, sample_configspec_filepath - ): - cfg = Config( - configspec_filepath=sample_configspec_filepath - ) - assert cfg['root_option'] == 'def_string', 'expecting default from config.spec (didnt get)' + def test_allows_reading_spec_and_no_ini(self, sample_configspec_filepath): + cfg = Config(configspec_filepath=sample_configspec_filepath) + assert cfg["root_option"] == "def_string", "expecting default from config.spec (didnt get)" # NOTE(MG) Changed the functionality to not do it this way. # def test_constructor_fails_with_invalid_spec( @@ -50,48 +48,35 @@ class TestConfig_e2e: # configspec_filepath=sample_invalid_configspec_filepath # ) - def test_allows_options_beyond_spec( - self, sample_configspec_filepath - ): - cfg = Config( - configspec_filepath=sample_configspec_filepath - ) - cfg['foo'] = 'test my value' - assert cfg['foo'] == 'test my value' + def test_allows_options_beyond_spec(self, sample_configspec_filepath): + cfg = Config(configspec_filepath=sample_configspec_filepath) + cfg["foo"] = "test my value" + assert cfg["foo"] == "test my value" - cfg['app']['bar'] = 'another value' - assert cfg['app']['bar'] == 'another value' + cfg["app"]["bar"] = "another value" + assert cfg["app"]["bar"] == "another value" # def test_can_read_config_file_mutiple_times(self): # pass - def test_can_override_config_file_manually( - self, sample_configini_filepath - ): - cfg = Config( - configini_filepath=sample_configini_filepath - ) - cfg['root_option'] = 'newval' - assert cfg['root_option'] == 'newval' + def test_can_override_config_file_manually(self, sample_configini_filepath): + cfg = Config(configini_filepath=sample_configini_filepath) + cfg["root_option"] = "newval" + assert cfg["root_option"] == "newval" - cfg['app']['sub_option'] = 'another_new_val' - assert cfg['app']['sub_option'] == 'another_new_val', 'expecting default for sub option' + cfg["app"]["sub_option"] = "another_new_val" + assert cfg["app"]["sub_option"] == "another_new_val", "expecting default for sub option" def test_can_set_option_without_config(self): cfg = Config() - cfg['foo'] = 'test my value' - assert cfg['foo'] == 'test my value' + cfg["foo"] = "test my value" + assert cfg["foo"] == "test my value" - cfg['app'] = {} - cfg['app']['bar'] = 'another value' - assert cfg['app']['bar'] == 'another value' - - def test_uses_spec_as_defaults( - self, sample_configspec_filepath - ): - cfg = Config( - configspec_filepath=sample_configspec_filepath - ) - assert cfg['root_option'] == 'def_string', 'expecting default from config.spec (didnt get)' - assert cfg['app']['sub_option'] == 'def_sub', 'expecting default for sub option' + cfg["app"] = {} + cfg["app"]["bar"] = "another value" + assert cfg["app"]["bar"] == "another value" + def test_uses_spec_as_defaults(self, sample_configspec_filepath): + cfg = Config(configspec_filepath=sample_configspec_filepath) + assert cfg["root_option"] == "def_string", "expecting default from config.spec (didnt get)" + assert cfg["app"]["sub_option"] == "def_sub", "expecting default for sub option" diff --git a/tests/test_cli.py b/tests/test_cli.py index 316ae95..939c8e8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,8 +1,7 @@ from app_skellington.cli import CommandTree + class TestCli_e2e: def test_null_constructor_works(self): x = CommandTree() assert True == True - -