Compare commits

..

7 Commits

19 changed files with 858 additions and 453 deletions

11
.flake8 Normal file

@ -0,0 +1,11 @@
[flake8]
max-line-length=120
ignore =
E121,
E123,
E126,
E226,
E24,
E704,
W605
exclude = ./tests

1
.gitignore vendored

@ -2,4 +2,5 @@ build
dist dist
*.egg-info *.egg-info
__pycache__ __pycache__
.idea

37
.pre-commit-config.yaml Normal file

@ -0,0 +1,37 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=10240']
- id: check-merge-conflict
- id: end-of-file-fixer
- id: trailing-whitespace
# isort -- sorts imports
- repo: https://github.com/timothycrosley/isort
rev: 5.13.2
hooks:
- id: isort
args: ["--profile", "black", "--filter-files"]
# 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
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.2.0
hooks:
- id: black
language_version: python3.8
# Poetry
- repo: https://github.com/python-poetry/poetry
rev: 1.8.2
hooks:
- id: poetry-lock

1
.python-version Normal file

@ -0,0 +1 @@
3.8.19

0
CHANGELOG.md Normal file

@ -1,6 +1,6 @@
App Skellington App Skellington
Copyright (c) 2020 Mathew Guest Copyright (c) 2024 Mathew Guest
Permission is hereby granted, free of charge, to any person Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation obtaining a copy of this software and associated documentation

@ -10,7 +10,7 @@ Application framework for Python, features include:
Principles: Principles:
- Lend to creating beautiful, easy to read and understand code in the application. - Lend to creating beautiful, easy to read and understand code in the application.
- Minimize coupling of applications to this framework. - Minimize coupling of applications to this framework.
- Compatable with Linux, Windows, and Mac. Try to be compatible as possible otherwise. - Compatible 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 - Try to be compatible with alternate Python runtimes such as PyPy and older python environments. \*WIP
Application Configuration Application Configuration
@ -20,7 +20,7 @@ in the src directory which is a validation file; it contains the accepted
parameter names, types, and limits for configurable options in the parameter names, types, and limits for configurable options in the
application which is built on app_skellington. The format is multi-level .ini syntax. application which is built on app_skellington. The format is multi-level .ini syntax.
Reference the ConfigObj documentation for config.ini and config.spec Reference the ConfigObj documentation for config.ini and config.spec
format. See: format. See:
- https://configobj.readthedocs.io/en/latest/configobj.html#the-config-file-format - https://configobj.readthedocs.io/en/latest/configobj.html#the-config-file-format
@ -31,15 +31,15 @@ file always contains the full specification of parameters; i.e. even default
parameters are added into the config file. parameters are added into the config file.
Linux: Linux:
/home/\<user\>/.config/\<app_name\>/config.ini /home/\<user\>/.config/\<app_name\>/config.ini
/home/\<user\>/.cache/\<app_name\>/log/\<app_name\>.log /home/\<user\>/.cache/\<app_name\>/log/\<app_name\>.log
Windows: Windows:
C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\config.ini C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\config.ini
C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\Logs\\\<app_name\>.log C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\Logs\\\<app_name\>.log
Application configuration can be overridden ad-hoc through the --config <filename> Application configuration can be overridden ad-hoc through the --config <filename>
@ -47,8 +47,8 @@ argument.
Debug - Turn on Logging Debug - Turn on Logging
----------------------- -----------------------
Set 'APPSKELLINGTON_ENABLE_LOGGING' environment variable to any value which turns Set 'APPSKELLINGTON_DEBUG' environment variable to any value which turns
on AppSkellington-level logging. For example, on AppSkellington logger. For example,
APPSKELLINGTON_DEBUG=1 <executable> APPSKELLINGTON_DEBUG=1 <executable>
@ -61,6 +61,28 @@ Tests
----- -----
Tests are a WIP. Recommendation is to run 'pytest' in the 'tests' directory. Tests are a WIP. Recommendation is to run 'pytest' in the 'tests' directory.
Development
-----------
Clone the repo:
```commandline
git clone https://git-mirror.zavage.net/zavage-software/app_skellington.git
```
Verify your desired python environment.
Poetry install to install dependencies and the program in your desired python environment
```commandline
poetry install
# verify no errors
```
Install pre-commit hooks:
```commandline
pre-commit install
```
Begin development.
License License
------- -------
I'm releasing this software under one of the most permissive I'm releasing this software under one of the most permissive
@ -72,4 +94,3 @@ Notes
----- -----
See official website: https://zavage-software.com See official website: https://zavage-software.com
Please report bugs, improvements, or feedback! Please report bugs, improvements, or feedback!

@ -1,8 +1,4 @@
import logging
import sys
from .app_container import * from .app_container import *
from .cfg import * from .cfg import *
from .cli import * from .cli import *
from .log import * from .log import *

@ -3,24 +3,28 @@ import os
import sys import sys
# Check and gracefully fail if the user needs to install a 3rd-party dep. # 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): def check_env_has_dependencies(libnames):
rc = True rc = True
for libname in libnames: for libname in libnames:
try: try:
__import__(libname) __import__(libname)
except ModuleNotFoundError as ex: except ModuleNotFoundError as ex:
print('Missing third-party library: ', ex, file=sys.stderr) print("Missing third-party library: ", ex, file=sys.stderr)
rc = False rc = False
return rc return rc
if not check_env_has_dependencies(libnames): if not check_env_has_dependencies(libnames):
print('Unable to load program without installed dependencies', file=sys.stderr) print("Unable to load program without installed dependencies", file=sys.stderr)
raise ImportError('python environment needs third-party dependencies installed') raise ImportError("python environment needs third-party dependencies installed")
# Logger for before the application and logging config is loaded # Logger for before the application and logging config is loaded
# - used to log before logging is configured # - used to log before logging is configured
_log_fmt = '%(levelname)-7s:%(message)s' _log_fmt = "%(levelname)-7s:%(message)s"
_logger_name = 'skell' _logger_name = "skell"
_bootstrap_logger = logging.getLogger(_logger_name) _bootstrap_logger = logging.getLogger(_logger_name)
# NOTE(MG) Logger monkey-patch: # NOTE(MG) Logger monkey-patch:
@ -29,20 +33,19 @@ _bootstrap_logger = logging.getLogger(_logger_name)
# configuration is reloaded. This catches APPSKELLINGTON_DEBUG # configuration is reloaded. This catches APPSKELLINGTON_DEBUG
# environment variable the first time, as app_skellington module # environment variable the first time, as app_skellington module
# is imported. See cfg.py # is imported. See cfg.py
if os.environ.get('APPSKELLINGTON_DEBUG', None): if os.environ.get("APPSKELLINGTON_DEBUG", None):
_bootstrap_logger.setLevel(logging.DEBUG) # Don't filter any log levels _bootstrap_logger.setLevel(logging.DEBUG) # Don't filter any log levels
fmt = logging.Formatter(_log_fmt) fmt = logging.Formatter(_log_fmt)
handler = logging.StreamHandler() handler = logging.StreamHandler()
handler.setFormatter(fmt) handler.setFormatter(fmt)
_bootstrap_logger.addHandler(handler) _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 # Logging is by default off, excepting CRITICAL
else: else:
_bootstrap_logger.setLevel(logging.CRITICAL) _bootstrap_logger.setLevel(logging.CRITICAL)
_bootstrap_logger.propagate = False _bootstrap_logger.propagate = False
# NOTE(MG) Pretty sure the logger has the default handler too at this point. # 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. # It's been related to some issues with the logger double-printing messages.
_bootstrap_logger.addHandler(logging.NullHandler()) _bootstrap_logger.addHandler(logging.NullHandler())

@ -1,9 +1,9 @@
from __future__ import print_function from __future__ import print_function
import inspect import inspect
import os import os
import sys import sys
from . import _util
def eprint(*args, **kwargs): def eprint(*args, **kwargs):
""" """
@ -11,6 +11,7 @@ def eprint(*args, **kwargs):
""" """
print(*args, file=sys.stderr, **kwargs) print(*args, file=sys.stderr, **kwargs)
def filename_to_abspath(filename): def filename_to_abspath(filename):
""" """
Converts a filename to it's absolute path. If it's already an Converts a filename to it's absolute path. If it's already an
@ -18,6 +19,7 @@ def filename_to_abspath(filename):
""" """
return os.path.abspath(filename) return os.path.abspath(filename)
def does_file_exist(filepath): def does_file_exist(filepath):
""" """
Because the file can be deleted or created immediately after execution of Because the file can be deleted or created immediately after execution of
@ -26,37 +28,37 @@ def does_file_exist(filepath):
instant in execution. instant in execution.
""" """
try: try:
fp = open(filepath, 'r') open(filepath, "r")
return True return True
except FileNotFoundError as ex: except FileNotFoundError as ex:
return False return False
def ensure_dir_exists(dirpath): def ensure_dir_exists(dirpath):
if dirpath is None: if dirpath is None:
return return
if dirpath == '': if dirpath == "":
return return
os.makedirs(dirpath, exist_ok=True) os.makedirs(dirpath, exist_ok=True)
def get_root_asset(filepath): def get_root_asset(filepath):
""" """
Attempts to locate a resource or asset shipped with the application. Attempts to locate a resource or asset shipped with the application.
Searches starting at the root module (__main__) which should be the Searches starting at the root module (__main__) which should be the
python file initially invoked. python file initially invoked.
""" """
module_root =\ module_root = os.path.abspath(os.path.dirname(sys.modules["__main__"].__file__))
os.path.abspath(
os.path.dirname(
sys.modules['__main__'].__file__))
path = os.path.join(module_root, filepath) path = os.path.join(module_root, filepath)
return path return path
def get_asset(module, filepath): def get_asset(module, filepath):
""" """
Attempts to locate a resource or asset shipped with the application. Attempts to locate a resource or asset shipped with the application.
Input filename is relative to the caller code, i.e. this starts Input filename is relative to the caller code, i.e. this starts
searching relative to the file that called this function. searching relative to the file that called this function.
Returns the full absolute path of the located file if found or None Returns the full absolute path of the located file if found or None
Args: Args:
@ -75,7 +77,7 @@ def get_asset(module, filepath):
elif isinstance(module, module): elif isinstance(module, module):
module_file = module.__file__ module_file = module.__file__
else: else:
raise Exception('Invalid Usage') raise Exception("Invalid Usage")
try: try:
root = module_file root = module_file
@ -88,7 +90,8 @@ def get_asset(module, filepath):
raise raise
path = os.path.join(root, filepath) path = os.path.join(root, filepath)
return path return path
def register_class_as_commands(app, submenu, cls_object): def register_class_as_commands(app, submenu, cls_object):
""" """
@ -105,7 +108,7 @@ def register_class_as_commands(app, submenu, cls_object):
for m in members: for m in members:
name = m[0] name = m[0]
ref = m[1] ref = m[1]
if inspect.isfunction(ref) and not name.startswith('_'): if inspect.isfunction(ref) and not name.startswith("_"):
cls_method = ref cls_method = ref
constructor = app._inject_service_dependencies(cls_constructor) constructor = app._inject_service_dependencies(cls_constructor)
sig = inspect.signature(cls_method) sig = inspect.signature(cls_method)
@ -114,9 +117,10 @@ def register_class_as_commands(app, submenu, cls_object):
docstring = inspect.getdoc(cls_method) docstring = inspect.getdoc(cls_method)
submenu.register_command(func, name, sig, docstring) submenu.register_command(func, name, sig, docstring)
def create_func(constructor, cls_method): def create_func(constructor, cls_method):
def func(*args, **kwargs): def func(*args, **kwargs):
cmd_class_instance = constructor() cmd_class_instance = constructor()
return cls_method(cmd_class_instance, *args, **kwargs) return cls_method(cmd_class_instance, *args, **kwargs)
return func
return func

@ -1,34 +1,37 @@
import appdirs
import collections
import functools import functools
import inspect import inspect
import os import os
import sys
import appdirs
from . import (
_util,
cfg,
cli,
log,
)
# Application scaffolding: # Application scaffolding:
from ._bootstrap import _bootstrap_logger 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 # These two variables affect the directory paths for
# config files and logging. # config files and logging.
DEFAULT_APP_NAME = '' DEFAULT_APP_NAME = ""
DEFAULT_APP_AUTHOR = '' DEFAULT_APP_AUTHOR = ""
class ApplicationContext: class ApplicationContext:
""" """
Container for application-wide state; i.e. app configuration and loggers. Container for application-wide state; i.e. app configuration and loggers.
""" """
def __init__(self, config, log): def __init__(self, config, log):
self.config = config self.config = config
self.log = log self.log = log
self.parsed_argv = None self.parsed_argv = None
self.parsed_argv_unknown = None self.parsed_argv_unknown = None
class ApplicationContainer: class ApplicationContainer:
""" """
Generalized application functionality. Used for linking components and modules of the application Generalized application functionality. Used for linking components and modules of the application
@ -40,14 +43,10 @@ class ApplicationContainer:
Override appname and appauthor arguments to direct config and log Override appname and appauthor arguments to direct config and log
directories. directories.
""" """
def __init__(
self, def __init__(self, configspec_filepath=None, configini_filepath=None, *args, **kwargs):
configspec_filepath=None, self.appname = kwargs.get("appname") or DEFAULT_APP_NAME
configini_filepath=None, self.appauthor = kwargs.get("appauthor") or DEFAULT_APP_AUTHOR
*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 # Instantiate application context which contains
# global state, configuration, loggers, and runtime args. # global state, configuration, loggers, and runtime args.
@ -57,29 +56,28 @@ class ApplicationContainer:
logger = log.LoggingLayer(self.appname, self.appauthor) logger = log.LoggingLayer(self.appname, self.appauthor)
# Try and load logging configuration if provided # Try and load logging configuration if provided
log_config = config.get('logging') log_config = config.get("logging")
if log_config is not None: if log_config is not None:
logger.configure_logging(log_config) logger.configure_logging(log_config)
else: else:
logger.configure_logging() logger.configure_logging()
self.ctx = ApplicationContext(config, logger) self.ctx = ApplicationContext(config, logger)
# Reference to root_app avail. in context # Reference to root_app avail. in context
self.ctx.root_app = self self.ctx.root_app = self
# Reference to context service avail. in root_app # 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: # Run methods if subclass implemented them:
if callable(getattr(self, '_cli_options', None)): if callable(getattr(self, "_cli_options", None)):
self._cli_options() self._cli_options()
if callable(getattr(self, '_services', None)): if callable(getattr(self, "_services", None)):
self._services() self._services()
if callable(getattr(self, '_command_menu', None)): if callable(getattr(self, "_command_menu", None)):
self._command_menu() self._command_menu()
def __delitem__(self, service_name): def __delitem__(self, service_name):
@ -99,11 +97,12 @@ class ApplicationContainer:
app['datalayer'] => returns the made-up "datalayer" service. app['datalayer'] => returns the made-up "datalayer" service.
""" """
try: try:
service_factory = self._dependencies[service_name] # Retrieve factory function service_factory = self._dependencies[service_name] # Retrieve factory function
return service_factory() # Call factory() to return instance of service return service_factory() # Call factory() to return instance of service
except KeyError as ex: except KeyError as ex:
msg = 'failed to inject service: {}'.format(service_name) msg = "failed to inject service: {}".format(service_name)
_bootstrap_logger.critical(msg) _bootstrap_logger.critical(msg)
_bootstrap_logger.debug(ex)
raise ServiceNotFound raise ServiceNotFound
def __setitem__(self, service_name, value): def __setitem__(self, service_name, value):
@ -117,7 +116,7 @@ class ApplicationContainer:
def _construct_model(self, model_constructor, *args): def _construct_model(self, model_constructor, *args):
""" """
Performs dependency resolution and instantiates an object of given type. Performs dependency resolution and instantiates an object of given type.
This takes in the reference to a class constructor and a list of names 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 of the dependencies that need passed into it, constructs that object and
returns it. Models contain business logic and application functionality. returns it. Models contain business logic and application functionality.
@ -131,9 +130,7 @@ class ApplicationContainer:
dependencies.append(self[dep_name]) dependencies.append(self[dep_name])
return model_constructor(*dependencies) return model_constructor(*dependencies)
def _get_config_filepath( def _get_config_filepath(self, app_name, app_author, config_filename="config.ini") -> str:
self, app_name, app_author, config_filename='config.ini'
):
""" """
Attempt to find config.ini in the user's config directory. Attempt to find config.ini in the user's config directory.
@ -142,10 +139,10 @@ class ApplicationContainer:
""" """
dirname = appdirs.user_config_dir(app_name, app_author) dirname = appdirs.user_config_dir(app_name, app_author)
filepath = os.path.join(dirname, config_filename) 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 return filepath
def _get_configspec_filepath(self, configspec_filename='config.spec'): def _get_configspec_filepath(self, configspec_filename: str = "config.spec") -> str:
""" """
Attempt to find config.spec inside the installed package directory. Attempt to find config.spec inside the installed package directory.
""" """
@ -161,12 +158,12 @@ class ApplicationContainer:
""" """
sig = inspect.signature(constructor.__init__) sig = inspect.signature(constructor.__init__)
params = sig.parameters params = sig.parameters
params = [params[paramname].name for paramname in params] # Convert Param() type => str params = [params[paramname].name for paramname in params] # Convert Param() type => str
cls_dependencies = params[1:] # Skip 'self' parameter on class methods. cls_dependencies = params[1:] # Skip 'self' parameter on class methods.
return functools.partial(self._construct_model, constructor, *cls_dependencies) return functools.partial(self._construct_model, constructor, *cls_dependencies)
def load_command(self): def load_command(self) -> bool:
args, unk, success = self.cli.parse() args, unk, success = self.cli.parse()
if not success: if not success:
return False return False
@ -181,24 +178,26 @@ class ApplicationContainer:
try: try:
self.cli.run_command() self.cli.run_command()
except NoCommandSpecified as ex: except NoCommandSpecified as ex:
print('Failure: No command specified.') print("Failure: No command specified.")
def interactive_shell(self): def interactive_shell(self):
pass raise NotImplementedError()
def invoke_from_cli(self): def invoke_from_cli(self):
self.invoke_command() self.invoke_command()
def usage(self): def usage(self):
pass raise NotImplementedError()
# Applications need a default usage # Applications need a default usage
class ServiceNotFound(Exception): class ServiceNotFound(Exception):
""" """
Application framework error: unable to find and inject dependency. Application framework error: unable to find and inject dependency.
""" """
pass pass
class NoCommandSpecified(Exception): class NoCommandSpecified(Exception):
pass pass

@ -4,15 +4,12 @@
# ConfigObj module and it's recommended to use config.spec files to define # ConfigObj module and it's recommended to use config.spec files to define
# your available configuration of the relevant application. # your available configuration of the relevant application.
from . import _util
import configobj
import validate
from ._bootstrap import _bootstrap_logger from ._bootstrap import _bootstrap_logger
import appdirs
import argparse
import configobj
import os
import sys
import validate
class Config: class Config:
""" """
@ -26,16 +23,11 @@ class Config:
""" """
DEFAULT_CAPABILITIES = { DEFAULT_CAPABILITIES = {
'allow_options_beyond_spec': True, "allow_options_beyond_spec": True,
} }
def __init__( def __init__(self, configspec_filepath=None, configini_filepath=None, capabilities=None):
self, self._config_obj = None # must be type configobj.ConfigObj()
configspec_filepath=None,
configini_filepath=None,
capabilities=None
):
self._config_obj = None # must be type configobj.ConfigObj()
self._configini_data = None self._configini_data = None
self._configini_filepath = None self._configini_filepath = None
self._configspec_data = None self._configspec_data = None
@ -83,9 +75,7 @@ class Config:
@configspec_filepath.setter @configspec_filepath.setter
def configspec_filepath(self, filepath): def configspec_filepath(self, filepath):
if filepath is None: if filepath is None:
_bootstrap_logger.debug( _bootstrap_logger.debug("cfg - Clearing configspec")
'cfg - Clearing configspec'
)
self._configspec_filepath = None self._configspec_filepath = None
self._configspec_data = None self._configspec_data = None
self._has_changed_internally = True self._has_changed_internally = True
@ -98,27 +88,22 @@ class Config:
self._configspec_filepath = filepath self._configspec_filepath = filepath
self._configspec_data = data self._configspec_data = data
self._has_changed_internally = True self._has_changed_internally = True
_bootstrap_logger.debug( _bootstrap_logger.debug("cfg - Set configspec and read contents: %s", filepath)
'cfg - Set configspec and read contents: %s',
filepath
)
self.load_config() self.load_config()
return return
except OSError as ex: except OSError as ex:
_bootstrap_logger.critical( _bootstrap_logger.critical("cfg - Failed to find config.spec: file not found (%s)", filepath)
'cfg - Failed to find config.spec: file not found (%s)', raise OSError("Failed to read provided config.spec file")
filepath
)
raise OSError('Failed to read provided config.spec file')
self.load_config() # TODO(MG) initial code was present but unreachable. is an error in coding.
# self.load_config()
def __contains__(self, key): def __contains__(self, key):
try: try:
has_item = key in self._config_obj has_item = key in self._config_obj
return has_item return has_item
except KeyError as ex: except KeyError as ex:
pass _bootstrap_logger.error("failed to __containers__ on key (%s): %s", key, ex)
def __delitem__(self, key): def __delitem__(self, key):
""" """
@ -128,7 +113,7 @@ class Config:
try: try:
del self[key] del self[key]
except KeyError as ex: except KeyError as ex:
pass _bootstrap_logger.error("failed to __delitem__ on key (%s): %s", key, ex)
def __getitem__(self, key): def __getitem__(self, key):
""" """
@ -142,6 +127,7 @@ class Config:
# return self._config_obj[key].dict() # return self._config_obj[key].dict()
return self._config_obj[key] return self._config_obj[key]
except KeyError as ex: except KeyError as ex:
_bootstrap_logger.error("failed to __getitem__ on key (%s): %s", key, ex)
raise raise
def __setitem__(self, key, value): def __setitem__(self, key, value):
@ -162,11 +148,16 @@ class Config:
v = self.__getitem__(key) v = self.__getitem__(key)
return v return v
except KeyError as ex: except KeyError as ex:
_bootstrap_logger.error("failed to retrieve config key (%s): %s", key, ex)
return default return default
def load_config( def load_config(self, configspec_filepath=None, configini_filepath=None) -> bool:
self, configspec_filepath=None, configini_filepath=None """
): Loads config from file, validating against configspec.
Returns:
bool: success status of command and validation.
"""
# Set new arguments if were passed in: # Set new arguments if were passed in:
if configspec_filepath is not None: if configspec_filepath is not None:
self.configspec_filepath = configspec_filepath self.configspec_filepath = configspec_filepath
@ -179,7 +170,7 @@ class Config:
rc = self._validate_config_against_spec() rc = self._validate_config_against_spec()
if not rc: if not rc:
if self._capability_enforce_strict_spec_validation: 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 False
return True return True
@ -196,83 +187,91 @@ class Config:
# options # options
configspec=config_spec, configspec=config_spec,
# encoding # encoding
interpolation='template' interpolation="template",
# raise_errors # raise_errors
) )
_bootstrap_logger.debug( _bootstrap_logger.debug(
'cfg - Parsed configuration. config.spec = %s, config.ini = %s', "cfg - Parsed configuration. config.spec = %s, config.ini = %s", config_spec, config_ini
config_spec, config_ini
) )
return True return True
except configobj.ParseError as ex: 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) _bootstrap_logger.error(msg)
return False return False
except OSError as ex: 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) _bootstrap_logger.error(msg)
return False return False
except Exception as ex: except Exception as ex:
print(ex) _bootstrap_logger.error(ex)
def _validate_config_against_spec(self): def _validate_config_against_spec(self):
config_spec = self.configspec_filepath config_spec = self.configspec_filepath
config_ini = self.configini_filepath config_ini = self.configini_filepath
# Hack the configobj module to alter the interpolation for validate.py: # 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 # Validate config.ini against config.spec
try: try:
_bootstrap_logger.info('cfg - Validating config file against spec') _bootstrap_logger.info("cfg - Validating config file against spec")
val = validate.Validator() 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 # NOTE(MG) copy arg below instructs configobj to use defaults from spec file
test_results = self._config_obj.validate( test_results = self._config_obj.validate(val, copy=True, preserve_errors=True)
val, copy=True, preserve_errors=True
)
if test_results is True: if test_results is True:
_bootstrap_logger.info( _bootstrap_logger.info(
'cfg- Successfully validated configuration against spec. input = %s, validation spec = %s', "cfg- Successfully validated configuration against spec. input = %s, validation spec = %s",
config_ini, config_spec config_ini,
config_spec,
) )
return True return True
elif test_results is False: elif test_results is False:
_bootstrap_logger.debug( _bootstrap_logger.debug("cfg - Potentially discovered invalid config.spec")
'cfg - Potentially discovered invalid config.spec'
)
else: else:
self._validate_parse_errors(test_results) self._validate_parse_errors(test_results)
return False return False
except ValueError as ex: except ValueError as ex:
_bootstrap_logger.error('cfg - Failed while validating config against spec. ') _bootstrap_logger.error("cfg - Failed while validating config against spec. ")
_bootstrap_logger.debug(ex)
return False return False
def _validate_parse_errors(self, test_results): def _validate_parse_errors(self, test_results):
_bootstrap_logger.critical('cfg - Config file failed validation.') _bootstrap_logger.critical("cfg - Config file failed validation.")
for (section_list, key, rslt) in configobj.flatten_errors(self._config_obj, test_results): 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 error info: %s %s %s", section_list, key, rslt)
if key is not None: 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: 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): def print_config(self):
""" """
Print configuration to stdout. Print configuration to stdout.
""" """
print('config:') print("config:")
self._config_obj.walk(print) self._config_obj.walk(print)
for section in self._config_obj.sections: for section in self._config_obj.sections:
print(section) print(section)
for key in self._config_obj[section]: for key in self._config_obj[section]:
print(' ', self._config_obj[section][key]) print(" ", self._config_obj[section][key])
class EnvironmentVariables: class EnvironmentVariables:
def __init__(self): def __init__(self):
raise NotImplementedError raise NotImplementedError

@ -1,18 +1,17 @@
import argparse import argparse
import inspect import inspect
import logging
import re import re
import sys import sys
import app_skellington
from ._bootstrap import _bootstrap_logger
from . import app_container from . import app_container
from ._bootstrap import _bootstrap_logger
# If explicit fail is enabled, any command with at least one unknown # If explicit fail is enabled, any command with at least one unknown
# argument will be rejected entirely. If not enabled, unknown arguments # argument will be rejected entirely. If not enabled, unknown arguments
# will be ignored. # will be ignored.
EXPLICIT_FAIL_ON_UNKNOWN_ARGS = True EXPLICIT_FAIL_ON_UNKNOWN_ARGS = True
class CommandTree: class CommandTree:
""" """
Command-line interface to hold a menu of commands. You can register Command-line interface to hold a menu of commands. You can register
@ -51,11 +50,12 @@ class CommandTree:
the second. In the same way the -h, --help options print different docs the second. In the same way the -h, --help options print different docs
depending on where the help option was passed. depending on where the help option was passed.
""" """
def __init__(self): def __init__(self):
self.root_parser = argparse.ArgumentParser() self.root_parser = argparse.ArgumentParser()
self.submenu_param = None # submenu_param is the variable name self.submenu_param = None # submenu_param is the variable name
# of the root submenu argument, i.e. the arg # of the root submenu argument, i.e. the arg
# in root_parser which selects the submenu. # in root_parser which selects the submenu.
self.entries = {} self.entries = {}
# NOTE(MG) Implementation note: # NOTE(MG) Implementation note:
# CommandTree uses only one of these internal structures (i.e. mutually exclusive), # CommandTree uses only one of these internal structures (i.e. mutually exclusive),
@ -66,13 +66,13 @@ class CommandTree:
self._single_command = None self._single_command = None
def print_tree(self): def print_tree(self):
raise NotImplemented raise NotImplementedError()
def add_argument(self, *args, **kwargs): def add_argument(self, *args, **kwargs):
""" """
Adds an argument to the root parser. 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) self.root_parser.add_argument(*args, **kwargs)
def init_submenu(self, param_name, is_required=False): def init_submenu(self, param_name, is_required=False):
@ -83,39 +83,27 @@ class CommandTree:
# NOTE(MG) Fix for Python>=3.7, # NOTE(MG) Fix for Python>=3.7,
# argparse.ArgumentParser added 'required' argument. # argparse.ArgumentParser added 'required' argument.
# Must also be written into SubMenu.create_submenu. # Must also be written into SubMenu.create_submenu.
func_args = { func_args = {"dest": param_name, "metavar": param_name, "required": is_required}
'dest': param_name, if sys.version_info.major == 3 and sys.version_info.minor < 7:
'metavar': param_name,
'required': is_required
}
if (
sys.version_info.major == 3
and sys.version_info.minor < 7
):
if is_required: if is_required:
_bootstrap_logger.warn('Unable to enforce required submenu: Requires >= Python 3.7') _bootstrap_logger.warn("Unable to enforce required submenu: Requires >= Python 3.7")
del func_args['required'] del func_args["required"]
# END fix for Python<3.7 # END fix for Python<3.7
# Creates an argument as a slot in the underlying argparse. # Creates an argument as a slot in the underlying argparse.
subparsers = self.root_parser.add_subparsers( subparsers = self.root_parser.add_subparsers(**func_args)
**func_args
)
submenu = SubMenu(self, subparsers, param_name) submenu = SubMenu(self, subparsers, param_name)
submenu.submenu_path = '' submenu.submenu_path = ""
submenu.var_name = param_name 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.entries[param_name] = submenu
self.submenu_param = param_name self.submenu_param = param_name
return submenu return submenu
def register_command( def register_command(self, func, cmd_name=None, func_signature=None, docstring=None):
self, func, cmd_name=None, func_signature=None,
docstring=None
):
""" """
When no submenu functionality is desired, this links a single When no submenu functionality is desired, this links a single
command into underlying argparse options. command into underlying argparse options.
@ -128,7 +116,7 @@ class CommandTree:
pass pass
# print('func is method') # print('func is method')
else: else:
raise Exception('bad value passed in for function') raise Exception("bad value passed in for function")
if not cmd_name: if not cmd_name:
# safe try/except # safe try/except
@ -154,37 +142,30 @@ class CommandTree:
# For each paramter in the function create an argparse argument in # For each paramter in the function create an argparse argument in
# the child ArgumentParser created for this menu entry: # the child ArgumentParser created for this menu entry:
for key in params: for key in params:
if key == 'self': if key == "self":
continue continue
param = params[key] param = params[key]
if '=' in str(param): if "=" in str(param):
if param.default is None: if param.default is None:
helptext = 'default provided' helptext = "default provided"
else: else:
helptext = "default = '{}'".format(param.default) helptext = "default = '{}'".format(param.default)
self.root_parser.add_argument( self.root_parser.add_argument(key, help=helptext, nargs="?", default=param.default)
key,
help=helptext,
nargs='?',
default=param.default
)
else: else:
helptext = 'required' helptext = "required"
self.root_parser.add_argument( self.root_parser.add_argument(key, help=helptext)
key,
help=helptext)
# Build the CommandEntry structure # Build the CommandEntry structure
cmd = CommandEntry() cmd = CommandEntry()
cmd.argparse_node = self.root_parser cmd.argparse_node = self.root_parser
cmd.cmd_name = cmd_name cmd.cmd_name = cmd_name
cmd.func_signature = sig cmd.func_signature = sig
# cmd.func_ref = None # cmd.func_ref = None
cmd.callback = func cmd.callback = func
registered_name = cmd_name 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 # end copy-paste then editted from SubMenu.register_command
self._cmd_tree_is_single_command = True self._cmd_tree_is_single_command = True
@ -209,7 +190,7 @@ class CommandTree:
# 'failed to parse arguments: explicitly failing to be safe') # 'failed to parse arguments: explicitly failing to be safe')
# return False, False # return False, False
if hasattr(pargs, 'usage'): if hasattr(pargs, "usage"):
pass pass
# print('found usage in app_skellington') # print('found usage in app_skellington')
@ -222,19 +203,19 @@ class CommandTree:
def run_command(self, args=None): def run_command(self, args=None):
args, unk, success = self.parse(args) args, unk, success = self.parse(args)
if not success: if not success:
_bootstrap_logger.info('cli - SystemExit: Perhaps user invoked --help') _bootstrap_logger.info("cli - SystemExit: Perhaps user invoked --help")
return return
if args is False and unk is False: if args is False and unk is False:
_bootstrap_logger.error('cli - Failed parsing args.') _bootstrap_logger.error("cli - Failed parsing args.")
return False 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) args = vars(args)
cmd = self._lookup_command(args) cmd = self._lookup_command(args)
if cmd is None: if cmd is None:
_bootstrap_logger.critical('cli - Failed to find command.') _bootstrap_logger.critical("cli - Failed to find command.")
return False return False
return self._invoke_command(cmd, args) return self._invoke_command(cmd, args)
@ -246,31 +227,31 @@ class CommandTree:
# the CommandTree with no SubMenu (submenu will be disabled # the CommandTree with no SubMenu (submenu will be disabled
# in this case): # in this case):
if self._cmd_tree_is_single_command: if self._cmd_tree_is_single_command:
assert self._cmd_tree_is_single_command is True, '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 self._entries is None, "corrupt data structure in CommandMenu"
assert isinstance(self._single_command, CommandEntry), 'corrupt data structure in CommandMenu' assert isinstance(self._single_command, CommandEntry), "corrupt data structure in CommandMenu"
return self._single_command return self._single_command
# There is at least one submenu we need to go down: # There is at least one submenu we need to go down:
else: else:
assert self._single_command is None, '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' 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 # 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] submenu = self.entries[argparse_param]
while True: while True:
if argparse_param not in keys: if argparse_param not in keys:
print('root menu parameter not found in args:', argparse_param) print("root menu parameter not found in args:", argparse_param)
input('<broken>') input("<broken>")
val = args.get(argparse_param) 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) 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 # pop value
del args[argparse_param] del args[argparse_param]
@ -283,7 +264,7 @@ class CommandTree:
# return self._invoke_command(lookup, args) # return self._invoke_command(lookup, args)
else: else:
raise app_container.NoCommandSpecified('No command specified.') raise app_container.NoCommandSpecified("No command specified.")
def _invoke_command(self, cmd, args): def _invoke_command(self, cmd, args):
command_to_be_invoked = cmd.callback command_to_be_invoked = cmd.callback
@ -296,26 +277,24 @@ class CommandTree:
if param.name in args: if param.name in args:
func_args.append(args[param.name]) func_args.append(args[param.name])
_bootstrap_logger.info('cli - function: %s', func) _bootstrap_logger.info("cli - function: %s", func)
_bootstrap_logger.info('cli - function args: %s', func_args) _bootstrap_logger.info("cli - function args: %s", func_args)
return command_to_be_invoked(*func_args) return command_to_be_invoked(*func_args)
def _get_subparser(self): def _get_subparser(self):
return self.root_parser._subparsers._actions[1] return self.root_parser._subparsers._actions[1]
class SubMenu: class SubMenu:
def __init__(self, parent, subparsers_obj, name): 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.subparsers_obj = subparsers_obj
self.name = name self.name = name
self.submenu_path = None self.submenu_path = None
self.entries = {} self.entries = {}
def register_command( def register_command(self, func, cmd_name=None, func_signature=None, docstring=None):
self, func, cmd_name=None, func_signature=None,
docstring=None
):
""" """
Registers a command as an entry in this submenu. Provided function is Registers a command as an entry in this submenu. Provided function is
converted into argparse arguments and made available to the user. converted into argparse arguments and made available to the user.
@ -345,7 +324,7 @@ class SubMenu:
elif inspect.ismethod(func): elif inspect.ismethod(func):
pass pass
else: else:
raise Exception('bad value passed in for function') raise Exception("bad value passed in for function")
if not cmd_name: if not cmd_name:
# TODO(MG) Safer sanitation # TODO(MG) Safer sanitation
@ -369,56 +348,44 @@ class SubMenu:
# Entry in local argparse._SubParsersAction # Entry in local argparse._SubParsersAction
# type = ArgumentParser # type = ArgumentParser
child_node = self.subparsers_obj.add_parser( child_node = self.subparsers_obj.add_parser(
cmd_name, # Note: cmd_name here will be the VALUE cmd_name, # Note: cmd_name here will be the VALUE
# passed into the argparse arg VARIABLE NAME # passed into the argparse arg VARIABLE NAME
# created when the SubMenu/argparse.add_subparsers() # created when the SubMenu/argparse.add_subparsers()
# was created. # was created.
help=help_text, help=help_text,
description=description_text description=description_text,
) )
# For each paramter in the function create an argparse argument in # For each paramter in the function create an argparse argument in
# the child ArgumentParser created for this menu entry: # the child ArgumentParser created for this menu entry:
for key in params: for key in params:
if key == 'self': if key == "self":
continue continue
param = params[key] param = params[key]
if '=' in str(param): if "=" in str(param):
if param.default is None: if param.default is None:
helptext = 'default provided' helptext = "default provided"
else: else:
helptext = "default = '{}'".format(param.default) helptext = "default = '{}'".format(param.default)
child_node.add_argument( child_node.add_argument(key, help=helptext, nargs="?", default=param.default)
key,
help=helptext,
nargs='?',
default=param.default
)
else: else:
helptext = 'required' helptext = "required"
child_node.add_argument( child_node.add_argument(key, help=helptext)
key,
help=helptext
)
# Build the CommandEntry structure # Build the CommandEntry structure
cmd = CommandEntry() cmd = CommandEntry()
cmd.argparse_node = child_node cmd.argparse_node = child_node
cmd.cmd_name = cmd_name cmd.cmd_name = cmd_name
cmd.func_signature = sig cmd.func_signature = sig
# cmd.func_ref = None # cmd.func_ref = None
cmd.callback = func cmd.callback = func
registered_name = '{}.{}'.format( registered_name = "{}.{}".format(self.submenu_path, cmd_name)
self.submenu_path, _bootstrap_logger.info("cli - registered command: %s", registered_name)
cmd_name)
_bootstrap_logger.info('cli - registered command: %s', registered_name)
self.entries[cmd_name] = cmd self.entries[cmd_name] = cmd
def create_submenu( def create_submenu(self, var_name, cmd_entry_name=None, is_required=False):
self, var_name, cmd_entry_name=None, is_required=False
):
""" """
Creates a child-submenu. Creates a child-submenu.
@ -443,54 +410,37 @@ class SubMenu:
# Create an entry in self's submenu: # Create an entry in self's submenu:
# type = ArgumentParser # type = ArgumentParser
entry_node = self.subparsers_obj.add_parser( entry_node = self.subparsers_obj.add_parser(
cmd_entry_name, cmd_entry_name, help="sub-submenu help", description="sub-sub description"
help='sub-submenu help', )
description='sub-sub description')
# NOTE(MG) Fix for Python>=3.7, # NOTE(MG) Fix for Python>=3.7,
# argparse.ArgumentParser added 'required' argument. # argparse.ArgumentParser added 'required' argument.
# Must also be written into CommandTree.init_submenu # Must also be written into CommandTree.init_submenu
func_args = { func_args = {"dest": var_name, "metavar": var_name, "required": is_required}
'dest': var_name, if sys.version_info.major == 3 and sys.version_info.minor < 7:
'metavar': var_name,
'required': is_required
}
if (
sys.version_info.major == 3
and sys.version_info.minor < 7
):
if is_required: if is_required:
_bootstrap_logger.warn('Unable to enforce required submenu: Requires >= Python 3.7') _bootstrap_logger.warn("Unable to enforce required submenu: Requires >= Python 3.7")
del func_args['required'] del func_args["required"]
# END fix for Python<3.7 # END fix for Python<3.7
# Turn entry into a submenu of it's own: # Turn entry into a submenu of it's own:
# type = _SubParsersAction # type = _SubParsersAction
subp_node = entry_node.add_subparsers( subp_node = entry_node.add_subparsers(**func_args)
**func_args
) submenu = SubMenu(self.parent, subp_node, cmd_entry_name)
submenu = SubMenu(
self.parent,
subp_node,
cmd_entry_name
)
submenu.var_name = var_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 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 self.entries[cmd_entry_name] = submenu
return submenu return submenu
def __repr__(self): def __repr__(self):
return 'SubMenu({})<{}>'.format( return "SubMenu({})<{}>".format(self.name, ",".join(["cmds"]))
self.name,
','.join(['cmds'])
)
class CommandEntry: class CommandEntry:
""" """
@ -506,18 +456,20 @@ class CommandEntry:
arguments into argparse options (creating the documentation also). Similary, arguments into argparse options (creating the documentation also). Similary,
it can convert from argparse options into a function call. it can convert from argparse options into a function call.
""" """
def __init__(self): def __init__(self):
self.argparse_node = None self.argparse_node = None
self.cmd_name = None # Don't think we need. And needs to be changed self.cmd_name = None # Don't think we need. And needs to be changed
# from SubMenu # from SubMenu
self.menu_path = None self.menu_path = None
self.func_signature = None self.func_signature = None
self.func_ref = None self.func_ref = None
self.callback = None self.callback = None
def __repr__(self): def __repr__(self):
return 'CommandEntry<{}>'.format(self.cmd_name) return "CommandEntry<{}>".format(self.cmd_name)
class HelpGenerator: class HelpGenerator:
def __init__(self): def __init__(self):
@ -527,14 +479,14 @@ class HelpGenerator:
def generate_help_from_sig(doctext): def generate_help_from_sig(doctext):
""" """
The 'help' text is displayed next to the command when enumerating The 'help' text is displayed next to the command when enumerating
the submenu commands. the submenu commands.
""" """
if doctext == None: if doctext == None:
return doctext return doctext
regex = '(.*?)[.?!]' regex = "(.*?)[.?!]"
match = re.match(regex, doctext, re.MULTILINE | re.DOTALL) match = re.match(regex, doctext, re.MULTILINE | re.DOTALL)
if match: if match:
return match.group(1) + '.' return match.group(1) + "."
return doctext return doctext
@staticmethod @staticmethod
@ -545,9 +497,8 @@ class HelpGenerator:
""" """
if doctext == None: if doctext == None:
return doctext return doctext
regex = '(.*?)[.?!]' regex = "(.*?)[.?!]"
match = re.match(regex, doctext, re.MULTILINE | re.DOTALL) match = re.match(regex, doctext, re.MULTILINE | re.DOTALL)
if match: if match:
return match.group(1) + '.' return match.group(1) + "."
return doctext return doctext

@ -1,48 +1,41 @@
from ._bootstrap import _bootstrap_logger, _logger_name
from . import _util
import appdirs
import colorlog
import logging import logging
import logging.config import logging.config
import os import os
import appdirs
from . import _util
from ._bootstrap import _bootstrap_logger, _logger_name
DEFAULT_LOG_SETTINGS = { DEFAULT_LOG_SETTINGS = {
'formatters': { "formatters": {
'colored': { "colored": {
'class': 'colorlog.ColoredFormatter', "class": "colorlog.ColoredFormatter",
# 'format': '%(log_color)s%(levelname)-8s%(reset)s:%(log_color)s%(name)-5s%(reset)s:%(white)s%(message)s' # '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"}},
'handlers': { "loggers": {
'stderr': { "root": {
'class': 'logging.StreamHandler', "handlers": [
'level': 'debug', "stderr",
'formatter': 'colored' ],
} "level": "debug",
},
'loggers': {
'root': {
'handlers': ['stderr',],
'level': 'debug'
}, },
'app_skellington': { "app_skellington": {
# 'handlers': ['stderr',], # 'handlers': ['stderr',],
'level': 'critical', "level": "critical",
'propagate': 'false' "propagate": "false",
} },
} },
} }
class LoggingLayer: class LoggingLayer:
def __init__( def __init__(self, appname=None, appauthor=None):
self, appname=None, appauthor=None self.appname = appname or ""
): self.appauthor = appauthor or ""
self.appname = appname or ''
self.appauthor = appauthor or ''
self.loggers = {} self.loggers = {}
def __getitem__(self, k): def __getitem__(self, k):
@ -66,22 +59,22 @@ class LoggingLayer:
""" """
Set the logging level for the process. Verbosity is controlled by a Set the logging level for the process. Verbosity is controlled by a
parameter in the config. parameter in the config.
Advice: While DEBUG verbosity is useful to debug, it can produce too much Advice: While DEBUG verbosity is useful to debug, it can produce too much
noise for typical operation. noise for typical operation.
""" """
if config_dict is None: 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 config_dict = DEFAULT_LOG_SETTINGS
self.transform_config(config_dict) self.transform_config(config_dict)
try: try:
_bootstrap_logger.debug('log - Log configuration: %s', config_dict) _bootstrap_logger.debug("log - Log configuration: %s", config_dict)
logging.config.dictConfig(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: 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): def transform_config(self, config_dict):
""" """
@ -89,48 +82,45 @@ class LoggingLayer:
parameters and the final config dictionary passed into the logging module. parameters and the final config dictionary passed into the logging module.
""" """
# Version should be hard-coded 1, per Python docs # Version should be hard-coded 1, per Python docs
if 'version' in config_dict: if "version" in config_dict:
if config_dict['version'] != 1: if config_dict["version"] != 1:
_bootstrap_logger.warn("logging['version'] must be '1' per Python docs") _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) self._add_own_logconfig(config_dict)
# Replace logger level strings with value integers from module # Replace logger level strings with value integers from module
for handler in config_dict['handlers']: for handler in config_dict["handlers"]:
d = config_dict['handlers'][handler] d = config_dict["handlers"][handler]
self._convert_str_to_loglevel(d, 'level') self._convert_str_to_loglevel(d, "level")
# Replace logger level strings with value integers from module # Replace logger level strings with value integers from module
for logger in config_dict['loggers']: for logger in config_dict["loggers"]:
d = config_dict['loggers'][logger] d = config_dict["loggers"][logger]
self._convert_str_to_loglevel(d, 'level') self._convert_str_to_loglevel(d, "level")
# Implementation note:
# Implementation note:
# app_skellington expects root logger configuration to be under 'root' # app_skellington expects root logger configuration to be under 'root'
# instead of '' (python spec) because '' is not a valid name in ConfigObj. # instead of '' (python spec) because '' is not a valid name in ConfigObj.
try: try:
if config_dict['loggers'].get('root') is not None: if config_dict["loggers"].get("root") is not None:
config_dict['loggers'][''] = config_dict['loggers']['root'] config_dict["loggers"][""] = config_dict["loggers"]["root"]
del config_dict['loggers']['root'] del config_dict["loggers"]["root"]
except Exception as ex: 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 # Evaluate the full filepath of the file handler
if 'file' not in config_dict['handlers']: if "file" not in config_dict["handlers"]:
return return
if os.path.abspath(config_dict['handlers']['file']['filename']) ==\ if os.path.abspath(config_dict["handlers"]["file"]["filename"]) == config_dict["handlers"]["file"]["filename"]:
config_dict['handlers']['file']['filename']:
# Path is already absolute # Path is already absolute
pass pass
else: else:
dirname = appdirs.user_log_dir(self.appname, self.appauthor) dirname = appdirs.user_log_dir(self.appname, self.appauthor)
_util.ensure_dir_exists(dirname) _util.ensure_dir_exists(dirname)
log_filepath = os.path.join(dirname, config_dict['handlers']['file']['filename']) log_filepath = os.path.join(dirname, config_dict["handlers"]["file"]["filename"])
config_dict['handlers']['file']['filename'] = log_filepath config_dict["handlers"]["file"]["filename"] = log_filepath
def _add_own_logconfig(self, config_dict): def _add_own_logconfig(self, config_dict):
# NOTE(MG) Monkey-patch logger # NOTE(MG) Monkey-patch logger
@ -140,13 +130,11 @@ class LoggingLayer:
# variable the second time, when it's being reloaded as a # variable the second time, when it's being reloaded as a
# logging configuration is read from config file. # logging configuration is read from config file.
# See _bootstrap.py # See _bootstrap.py
if os.environ.get('APPSKELLINGTON_DEBUG', None): if os.environ.get("APPSKELLINGTON_DEBUG", None):
if _logger_name not in config_dict['loggers']: if _logger_name not in config_dict["loggers"]:
config_dict['loggers'][_logger_name] = { config_dict["loggers"][_logger_name] = {"level": "debug", "propagate": "false"}
'level': 'debug', 'propagate': 'false'
}
else: else:
config_dict['loggers'][_logger_name]['level'] = 'debug' config_dict["loggers"][_logger_name]["level"] = "debug"
def _convert_str_to_loglevel(self, dict_, key): def _convert_str_to_loglevel(self, dict_, key):
""" """
@ -164,16 +152,15 @@ class LoggingLayer:
s = dict_[key] s = dict_[key]
except KeyError as ex: except KeyError as ex:
raise raise
if s == 'critical': if s == "critical":
dict_[key] = logging.CRITICAL dict_[key] = logging.CRITICAL
elif s == 'error': elif s == "error":
dict_[key] = logging.ERROR dict_[key] = logging.ERROR
elif s == 'warning': elif s == "warning":
dict_[key] = logging.WARNING dict_[key] = logging.WARNING
elif s == 'info': elif s == "info":
dict_[key] = logging.INFO dict_[key] = logging.INFO
elif s == 'debug': elif s == "debug":
dict_[key] = logging.DEBUG dict_[key] = logging.DEBUG
elif s == 'all': elif s == "all":
dict_[key] = logging.NOTSET dict_[key] = logging.NOTSET

371
poetry.lock generated Normal file

@ -0,0 +1,371 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "black"
version = "24.4.2"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.8"
files = [
{file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"},
{file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"},
{file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"},
{file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"},
{file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"},
{file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"},
{file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"},
{file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"},
{file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"},
{file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"},
{file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"},
{file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"},
{file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"},
{file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"},
{file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"},
{file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"},
{file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"},
{file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"},
{file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"},
{file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"},
{file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"},
{file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"},
]
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=0.9.0"
platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""}
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "cfgv"
version = "3.4.0"
description = "Validate configuration and produce human readable error messages."
optional = false
python-versions = ">=3.8"
files = [
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
]
[[package]]
name = "click"
version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "distlib"
version = "0.3.8"
description = "Distribution utilities"
optional = false
python-versions = "*"
files = [
{file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
{file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
]
[[package]]
name = "filelock"
version = "3.15.4"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.8"
files = [
{file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"},
{file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"]
typing = ["typing-extensions (>=4.8)"]
[[package]]
name = "flake8"
version = "5.0.4"
description = "the modular source code checker: pep8 pyflakes and co"
optional = false
python-versions = ">=3.6.1"
files = [
{file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"},
{file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"},
]
[package.dependencies]
mccabe = ">=0.7.0,<0.8.0"
pycodestyle = ">=2.9.0,<2.10.0"
pyflakes = ">=2.5.0,<2.6.0"
[[package]]
name = "identify"
version = "2.6.0"
description = "File identification library for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"},
{file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"},
]
[package.extras]
license = ["ukkonen"]
[[package]]
name = "isort"
version = "5.13.2"
description = "A Python utility / library to sort Python imports."
optional = false
python-versions = ">=3.8.0"
files = [
{file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
{file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
]
[package.extras]
colors = ["colorama (>=0.4.6)"]
[[package]]
name = "mccabe"
version = "0.7.0"
description = "McCabe checker, plugin for flake8"
optional = false
python-versions = ">=3.6"
files = [
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.5"
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
[[package]]
name = "nodeenv"
version = "1.9.1"
description = "Node.js virtual environment builder"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
]
[[package]]
name = "packaging"
version = "24.1"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"},
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
]
[[package]]
name = "pathspec"
version = "0.12.1"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.8"
files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
]
[[package]]
name = "platformdirs"
version = "4.2.2"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
type = ["mypy (>=1.8)"]
[[package]]
name = "pre-commit"
version = "3.5.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.8"
files = [
{file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"},
{file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"},
]
[package.dependencies]
cfgv = ">=2.0.0"
identify = ">=1.0.0"
nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
virtualenv = ">=20.10.0"
[[package]]
name = "pycodestyle"
version = "2.9.1"
description = "Python style guide checker"
optional = false
python-versions = ">=3.6"
files = [
{file = "pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"},
{file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"},
]
[[package]]
name = "pyflakes"
version = "2.5.0"
description = "passive checker of Python programs"
optional = false
python-versions = ">=3.6"
files = [
{file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"},
{file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"},
]
[[package]]
name = "pyyaml"
version = "6.0.1"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.6"
files = [
{file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
{file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
{file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
{file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
{file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
{file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
{file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
{file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
]
[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.7"
files = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
[[package]]
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[[package]]
name = "virtualenv"
version = "20.26.3"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.7"
files = [
{file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"},
{file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"},
]
[package.dependencies]
distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4"
platformdirs = ">=3.9.1,<5"
[package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
content-hash = "c20dac4ba2095b38a3009c1520b6cd1e9865cc50dff8559ddd767403898406c6"

49
pyproject.toml Normal file

@ -0,0 +1,49 @@
[tool.poetry]
name = "app_skellington"
version = "0.1.0"
description = "app_skellington CLI framework"
authors = [
"Mathew Guest <mathew.guest@davita.com>",
]
license = "Creative Commons"
readme = "README.md"
homepage = "https://zavage-software.com/portfolio/app_skellington"
repository = "https://git-mirror.zavage.net/zavage-software/app_skellington"
documentation = "https://git-mirror.zavage.net/zavage-software/app_skellington"
keywords = [""]
packages = [{ include = "app_skellington" }]
include = [
"README.md",
]
# [tool.poetry.scripts]
[tool.poetry.dependencies]
python = "^3.8"
# psycopg2-binary = "^2.9"
[tool.poetry.group.dev.dependencies]
black = "*"
pre-commit = "*"
isort = "*"
flake8 = "*"
#pytest = "^7.2"
#Sphinx = "^5.3.0"
#sphinx-rtd-theme = "^1.3.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 120
target-version = ['py38']
[tool.isort]
multi_line_output = 3
combine_as_imports = true
include_trailing_comma = true
force_grid_wrap = 3
ensure_newline_before_comments = true

@ -1,7 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
# #
# Usage: # Usage:
# #
# First, enable the python environment you want to install to, or if installing # First, enable the python environment you want to install to, or if installing
# system-wide then ensure you're logged in with sufficient permissions # system-wide then ensure you're logged in with sufficient permissions
# (admin or root to install to system directories) # (admin or root to install to system directories)
@ -15,61 +15,52 @@
# $ pip uninstall app_skellington # $ pip uninstall app_skellington
from setuptools import setup
import os import os
__project__ = 'app_skellington' from setuptools import setup
__version__ = '0.1.1'
__description__ = 'A high-powered command line menu framework.' __project__ = "app_skellington"
__version__ = "0.1.1"
__description__ = "A high-powered command line menu framework."
long_description = __description__ long_description = __description__
readme_filepath = os.path.join( readme_filepath = os.path.join(os.path.abspath(os.path.dirname(__file__)), "README.md")
os.path.abspath(os.path.dirname(__file__)), with open(readme_filepath, encoding="utf-8") as fp:
'README.md'
)
with open(readme_filepath, encoding='utf-8') as fp:
long_description = fp.read() long_description = fp.read()
setup( setup(
name = __project__, name=__project__,
version = __version__, version=__version__,
description = 'A high-powered command line menu framework.', description="A high-powered command line menu framework.",
long_description = long_description, long_description=long_description,
author = 'Mathew Guest', author="Mathew Guest",
author_email = 't3h.zavage@gmail.com', author_email="mat@zavage.net",
url = 'https://git-mirror.zavage.net/Mirror/app_skellington', url="https://git-mirror.zavage.net/zavage-software/app_skellington",
license = 'MIT', license="MIT",
python_requires=">=3",
python_requires = '>=3', classifiers=[
"Development Status :: 3 - Alpha",
classifiers = [ "Environment :: Console",
'Development Status :: 3 - Alpha', "Framework :: Pytest",
'Environment :: Console', "Intended Audience :: Developers",
'Framework :: Pytest', "Intended Audience :: System Administrators",
'Intended Audience :: Developers', "License :: OSI Approved :: MIT License",
'Intended Audience :: System Administrators', "Natural Language :: English",
'License :: OSI Approved :: MIT License', "Operating System :: MacOS",
'Natural Language :: English', "Operating System :: Microsoft",
'Operating System :: MacOS', "Operating System :: Microsoft :: Windows",
'Operating System :: Microsoft', "Operating System :: OS Independent",
'Operating System :: Microsoft :: Windows', "Operating System :: POSIX",
'Operating System :: OS Independent', "Operating System :: POSIX :: Linux",
'Operating System :: POSIX', "Topic :: Software Development :: Libraries",
'Operating System :: POSIX :: Linux', "Topic :: Utilities",
'Topic :: Software Development :: Libraries',
'Topic :: Utilities'
], ],
# Third-party dependencies; will be automatically installed # Third-party dependencies; will be automatically installed
install_requires = ( install_requires=(
'appdirs', "appdirs",
'configobj', "configobj",
'colorlog', "colorlog",
), ),
# Local packages to be installed (our packages) # Local packages to be installed (our packages)
packages = ( packages=("app_skellington",),
'app_skellington',
),
) )

@ -1,45 +1,43 @@
from app_skellington.cfg import Config
from app_skellington import _util
import pytest import pytest
from app_skellington import _util
from app_skellington.cfg import Config
@pytest.fixture @pytest.fixture
def sample_configspec_filepath(): def sample_configspec_filepath():
return _util.get_asset(__name__, 'sample_config.spec') return _util.get_asset(__name__, "sample_config.spec")
@pytest.fixture @pytest.fixture
def sample_configini_filepath(): def sample_configini_filepath():
return _util.get_asset(__name__, 'sample_config.ini') return _util.get_asset(__name__, "sample_config.ini")
@pytest.fixture @pytest.fixture
def sample_full_configspec_filepath(): 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 @pytest.fixture
def sample_full_configini_filepath(): 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 @pytest.fixture
def sample_invalid_configspec_filepath(): 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: class TestConfig_e2e:
def test_allows_reading_ini_and_no_spec( def test_allows_reading_ini_and_no_spec(self, sample_configini_filepath):
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)"
cfg = Config( assert cfg["app"]["sub_option"] == "sub_option_val", "expecting default for sub option"
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( def test_allows_reading_spec_and_no_ini(self, sample_configspec_filepath):
self, sample_configspec_filepath cfg = Config(configspec_filepath=sample_configspec_filepath)
): assert cfg["root_option"] == "def_string", "expecting default from config.spec (didnt get)"
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. # NOTE(MG) Changed the functionality to not do it this way.
# def test_constructor_fails_with_invalid_spec( # def test_constructor_fails_with_invalid_spec(
@ -50,48 +48,35 @@ class TestConfig_e2e:
# configspec_filepath=sample_invalid_configspec_filepath # configspec_filepath=sample_invalid_configspec_filepath
# ) # )
def test_allows_options_beyond_spec( def test_allows_options_beyond_spec(self, sample_configspec_filepath):
self, sample_configspec_filepath cfg = Config(configspec_filepath=sample_configspec_filepath)
): cfg["foo"] = "test my value"
cfg = Config( assert cfg["foo"] == "test my value"
configspec_filepath=sample_configspec_filepath
)
cfg['foo'] = 'test my value'
assert cfg['foo'] == 'test my value'
cfg['app']['bar'] = 'another value' cfg["app"]["bar"] = "another value"
assert cfg['app']['bar'] == 'another value' assert cfg["app"]["bar"] == "another value"
# def test_can_read_config_file_mutiple_times(self): # def test_can_read_config_file_mutiple_times(self):
# pass # pass
def test_can_override_config_file_manually( def test_can_override_config_file_manually(self, sample_configini_filepath):
self, sample_configini_filepath cfg = Config(configini_filepath=sample_configini_filepath)
): cfg["root_option"] = "newval"
cfg = Config( assert cfg["root_option"] == "newval"
configini_filepath=sample_configini_filepath
)
cfg['root_option'] = 'newval'
assert cfg['root_option'] == 'newval'
cfg['app']['sub_option'] = 'another_new_val' cfg["app"]["sub_option"] = "another_new_val"
assert cfg['app']['sub_option'] == 'another_new_val', 'expecting default for sub option' assert cfg["app"]["sub_option"] == "another_new_val", "expecting default for sub option"
def test_can_set_option_without_config(self): def test_can_set_option_without_config(self):
cfg = Config() cfg = Config()
cfg['foo'] = 'test my value' cfg["foo"] = "test my value"
assert cfg['foo'] == 'test my value' assert cfg["foo"] == "test my value"
cfg['app'] = {} cfg["app"] = {}
cfg['app']['bar'] = 'another value' cfg["app"]["bar"] = "another value"
assert 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'
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"

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