Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

19 changed files with 531 additions and 724 deletions

11
.flake8

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

1
.gitignore vendored

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

@ -1,31 +0,0 @@
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

@ -1,2 +0,0 @@
3.8.20
3.8.8

@ -1,22 +0,0 @@
# Changelog
## [0.2.2] (2020-07-19)
Second release is focused on cleanup, documentation.
* flake8, black, isort - all warnings and errors fixed.
* Documentation improved.
* Build and deploy process documented for developers.
* Poetry removed, replaced with modern pyproject.toml and setuptools.build_meta
for automatic version management.
## [0.1.0] (2020-07-19)
First release to PyPi.
* Basic functionality. Probably not the best code.
* Sub-menus supported, multi-level through CLI.
* Config through ConfigObj ini. Define spec file and input config.ini
* Colored Logging
* Services can be defined and provided to classes

@ -1,20 +1,25 @@
App Skellington
MIT No Attribution
Copyright (c) 2020 Mathew Guest
Copyright (c) 2024 Mathew Guest
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so.
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
`NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

156
README.md

@ -1,10 +1,5 @@
---
gitea: none
include_toc: true
---
**app_skellington**
app_skellington
===============
Application framework for Python, features include:
- Pain-free multi-level command menu: Expose public class methods as commands available to user.
- Simple to define services and automatic dependency injection based on name (with custom invocation as an option). \*WIP
@ -15,31 +10,11 @@ Application framework for Python, features include:
Principles:
- Lend to creating beautiful, easy to read and understand code in the application.
- Minimize coupling of applications to this framework.
- Compatible with Linux, Windows, and Mac. Try to be compatible as possible otherwise.
- Compatable with Linux, Windows, and Mac. Try to be compatible as possible otherwise.
- Try to be compatible with alternate Python runtimes such as PyPy and older python environments. \*WIP
# Python Package Index (PyPi) - Installation Instruction
This is the project page for PyPi: The Python Package Index for community third-party libraries
https://pypi.org/project/app-skellington/
```
pip install app_skellington # install
pip uninstall app_skellington # uninstall
pip download app_skellington # download .whl wheel files for redistributable install
pip install -U app_skellington # upgrade
pip list # list install packages in environment
pip index version app_skellington # enumerate available distributions in pypi for package
```
# Description
This is a library. There is nothing to run by itself. It would be helpful to have a sample application that uses
it or something, but I don't have that ready at the moment.
# Application Configuration
Application Configuration
-------------------------
Site configurations are supported through ConfigObj. There is a config.spec
in the src directory which is a validation file; it contains the accepted
parameter names, types, and limits for configurable options in the
@ -57,19 +32,21 @@ parameters are added into the config file.
Linux:
* /home/\<user\>/.config/\<app_name\>/config.ini
* /home/\<user\>/.cache/\<app_name\>/log/\<app_name\>.log
/home/\<user\>/.config/\<app_name\>/config.ini
/home/\<user\>/.cache/\<app_name\>/log/\<app_name\>.log
Windows:
* C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\config.ini
* C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\Logs\\\<app_name\>.log
C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\config.ini
C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\Logs\\\<app_name\>.log
Application configuration can be overridden ad-hoc through the --config <filename>
argument.
# Debug - Turn on Logging
Debug - Turn on Logging
-----------------------
Set 'APPSKELLINGTON_ENABLE_LOGGING' environment variable to any value which turns
on AppSkellington-level logging. For example,
@ -80,101 +57,18 @@ or
export APPSKELLINGTON_DEBUG=1
<executable>
# Tests
Tests
-----
Tests are a WIP. Recommendation is to run 'pytest' in the 'tests' directory.
Tests are a WIP and not fully built out yet. Recommendation is to run 'pytest' in the 'tests' directory.
License
-------
I'm releasing this software under one of the most permissive
licenses, the MIT software license. This applies to this source repository
and all files within it.
# Development
Notes
-----
See official website: https://zavage-software.com
Please report bugs, improvements, or feedback!
I recommend pyenv to install a reliable, controlled python of preferred version locally.
```
curl https://pyenv.run | bash
# Add to .bashrc or similar for different shells:
tee -a "$HOME"/.profile <<'EOF'
export PYENV_ROOT="$HOME/.pyenv"
[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
EOF
```
* reference https://github.com/pyenv/pyenv
* Use pyenv to install desired python version, and/or create any virtual environments you desire
Clone the repo:
```commandline
git clone https://git-repos.zavage.net/zavage-software/app_skellington.git
```
Install pre-commit hooks:
```commandline
pre-commit install
```
Build:
```
python -m build
```
Install:
```
pip install .
```
Formatting and Linters:
```
black app_skellington
isort app_skellington
flake8 app_skellington
```
Publish:
```
# Push latest commit, or on commit ready to publish:
git push
# Create a tag with the desired version number and push:
git tag -a v0.2.0 -m "0.2.0 provides modern pyproject.toml build with setuptools, versioning, and publishing"
git push origin v0.2.0
# Build the wheel:
python -m build
# Publish to pypi:
twine check dist/*
twine upload dist/*
```
* Reference https://packaging.python.org/en/latest/overview/
# Version
setuptools_scm will infer the version based on the latest tag in your Git history.
Ensure you are tagging your commits with meaningful version numbers like v1.0.0, v1.1.0, etc.
You can view the current version number with the command:
python -m setuptools_scm
# License
See [license](LICENSE.txt)
MIT no attribution required - https://opensource.org/license/mit-0
* Allows commercial use.
* Allows modifications and closed-source derivatives.
* Fully interoperable with nearly all other open-source licenses, including GPL (when combined properly).
# See Also
* Project page: https://zavage-software.com/portfolio/app_skellington
* Please report bugs, improvements, or feedback!
* Contact: mat@zavage.net
* Packing and distribution conforms to PEP 621 https://peps.python.org/pep-0621/
* Reference https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/

@ -1,19 +1,8 @@
# flake8: noqa
import logging
import sys
from .cfg import Config, EnvironmentVariables
from .app_container import *
from .cfg import *
from .cli import *
from .log import *
from .app_container import (
DEFAULT_APP_AUTHOR,
DEFAULT_APP_NAME,
ApplicationContainer,
ApplicationContext,
)
from ._util import ServiceNotFound, NoCommandSpecified
from .cli import (
EXPLICIT_FAIL_ON_UNKNOWN_ARGS,
CommandEntry,
CommandTree,
HelpGenerator,
SubMenu,
)
from .log import LoggingLayer

@ -3,28 +3,24 @@ 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 = 'app_skellington'
_bootstrap_logger = logging.getLogger(_logger_name)
# NOTE(MG) Logger monkey-patch:
@ -33,15 +29,13 @@ _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):
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:
@ -51,3 +45,4 @@ _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())

@ -1,9 +1,9 @@
from __future__ import print_function
import inspect
import os
import sys
from . import _util
def eprint(*args, **kwargs):
"""
@ -11,7 +11,6 @@ def eprint(*args, **kwargs):
"""
print(*args, file=sys.stderr, **kwargs)
def filename_to_abspath(filename):
"""
Converts a filename to it's absolute path. If it's already an
@ -19,7 +18,6 @@ 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
@ -28,31 +26,31 @@ def does_file_exist(filepath):
instant in execution.
"""
try:
open(filepath, "r")
fp = open(filepath, 'r')
return True
except FileNotFoundError:
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
def get_asset(module, filepath):
"""
Attempts to locate a resource or asset shipped with the application.
@ -77,7 +75,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
@ -86,13 +84,12 @@ def get_asset(module, filepath):
root = os.path.realpath(root)
root = os.path.dirname(os.path.abspath(root))
except Exception:
except Exception as ex:
raise
path = os.path.join(root, filepath)
return path
def register_class_as_commands(app, submenu, cls_object):
"""
Registers commands for each class method. e.g.: pass in the CLI
@ -108,7 +105,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)
@ -117,22 +114,9 @@ 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
class ServiceNotFound(Exception):
"""
Application framework error: unable to find and inject dependency.
"""
pass
class NoCommandSpecified(Exception):
pass

@ -1,20 +0,0 @@
# file generated by setuptools-scm
# don't change, don't track in version control
__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Tuple, Union
VERSION_TUPLE = Tuple[Union[int, str], ...]
else:
VERSION_TUPLE = object
version: str
__version__: str
__version_tuple__: VERSION_TUPLE
version_tuple: VERSION_TUPLE
__version__ = version = "0.3.0.dev7+gd155176.d20250223"
__version_tuple__ = version_tuple = (0, 3, 0, "dev7", "gd155176.d20250223")

@ -1,35 +1,32 @@
import appdirs
import collections
import functools
import inspect
import os
import appdirs
from ._util import ServiceNotFound, NoCommandSpecified, get_root_asset
from . import log
from .cfg import Config
from .cli import CommandTree
import sys
# Application scaffolding:
from ._bootstrap import _bootstrap_logger
from . import log
from . import _util
from . import cli
from . import cfg
# 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
@ -41,43 +38,35 @@ 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,
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
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.
self._dependencies = {}
config = Config(configspec_filepath, configini_filepath)
config = cfg.Config(configspec_filepath, configini_filepath)
logger = log.LoggingLayer(self.appname, self.appauthor)
# Try and load logging configuration if provided
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)
self['ctx'] = lambda: self.ctx
# 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.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):
@ -86,7 +75,7 @@ class ApplicationContainer:
"""
try:
del self._dependencies[service_name]
except KeyError:
except KeyError as ex:
pass
def __getitem__(self, service_name):
@ -97,12 +86,10 @@ class ApplicationContainer:
app['datalayer'] => returns the made-up "datalayer" service.
"""
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
except KeyError:
msg = "failed to inject service: {}".format(service_name)
except KeyError as ex:
msg = 'failed to inject service: {}'.format(service_name)
_bootstrap_logger.critical(msg)
raise ServiceNotFound
@ -131,7 +118,9 @@ 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.
@ -140,14 +129,14 @@ 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.
"""
return get_root_asset(configspec_filename)
return _util.get_root_asset(configspec_filename)
def _inject_service_dependencies(self, constructor):
"""
@ -159,9 +148,7 @@ class ApplicationContainer:
"""
sig = inspect.signature(constructor.__init__)
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.
return functools.partial(self._construct_model, constructor, *cls_dependencies)
@ -180,8 +167,11 @@ class ApplicationContainer:
return False
try:
self.cli.run_command()
except NoCommandSpecified:
print("Failure: No command specified.")
except NoCommandSpecified as ex:
print('Failure: No command specified.')
def interactive_shell(self):
pass
def invoke_from_cli(self):
self.invoke_command()
@ -190,4 +180,12 @@ 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

@ -4,30 +4,31 @@
# ConfigObj module and it's recommended to use config.spec files to define
# your available configuration of the relevant application.
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:
"""
Structure to store application runtime configuration. Also contains
functionality to load configuration from local site file.
Provide config.spec - specification file which defines allowed parameters and types.
Provide config.ini - configuration instance which contains values for any
configuration arguments.
"""
DEFAULT_CAPABILITIES = {
"allow_options_beyond_spec": True,
'allow_options_beyond_spec': True,
}
def __init__(
self, configspec_filepath=None, configini_filepath=None, capabilities=None
self,
configspec_filepath=None,
configini_filepath=None,
capabilities=None
):
self._config_obj = None # must be type configobj.ConfigObj()
self._configini_data = None
@ -77,7 +78,9 @@ 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
@ -91,15 +94,17 @@ class Config:
self._configspec_data = data
self._has_changed_internally = True
_bootstrap_logger.debug(
"cfg - Set configspec and read contents: %s", filepath
'cfg - Set configspec and read contents: %s',
filepath
)
self.load_config()
return
except OSError:
except OSError as ex:
_bootstrap_logger.critical(
"cfg - Failed to find config.spec: file not found (%s)", filepath
'cfg - Failed to find config.spec: file not found (%s)',
filepath
)
raise OSError("Failed to read provided config.spec file")
raise OSError('Failed to read provided config.spec file')
self.load_config()
@ -107,7 +112,7 @@ class Config:
try:
has_item = key in self._config_obj
return has_item
except KeyError:
except KeyError as ex:
pass
def __delitem__(self, key):
@ -117,7 +122,7 @@ class Config:
"""
try:
del self[key]
except KeyError:
except KeyError as ex:
pass
def __getitem__(self, key):
@ -131,7 +136,7 @@ class Config:
else:
# return self._config_obj[key].dict()
return self._config_obj[key]
except KeyError:
except KeyError as ex:
raise
def __setitem__(self, key, value):
@ -141,20 +146,9 @@ class Config:
"""
self._config_obj[key] = value
def get(self, key, default=None):
"""
Attempt to retrieve configuration item, otherwise return default
provided value.
Similar to Dictionary.get()
"""
try:
v = self.__getitem__(key)
return v
except KeyError:
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
@ -167,7 +161,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
@ -184,103 +178,83 @@ 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:
msg = f"cfg - Failed to load config: error in config.spec configuration: {self.configspec_filepath}"
except configobj.ParseError as ex:
msg = 'cfg - Failed to load config: error in config.spec configuration: {}'.format(config_filepath)
_bootstrap_logger.error(msg)
return False
except OSError:
msg = "cfg - Failed to load config: config.spec file not found."
except OSError as ex:
msg = 'cfg - Failed to load config: config.spec file not found.'
_bootstrap_logger.error(msg)
return False
except Exception as ex:
_bootstrap_logger.error(ex)
return False
print(ex)
def _validate_config_against_spec(self):
config_spec = self.configspec_filepath
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
)
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"
'cfg - Potentially discovered invalid config.spec'
)
else:
self._validate_parse_errors(test_results)
return False
except ValueError:
_bootstrap_logger.error(
"cfg - Failed while validating config against spec. "
)
except ValueError as ex:
_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

@ -1,17 +1,18 @@
import argparse
import inspect
import logging
import re
import sys
from ._util import NoCommandSpecified
import app_skellington
from ._bootstrap import _bootstrap_logger
from . import app_container
# If explicit fail is enabled, any command with at least one unknown
# argument will be rejected entirely. If not enabled, unknown arguments
# will be ignored.
EXPLICIT_FAIL_ON_UNKNOWN_ARGS = True
class CommandTree:
"""
Command-line interface to hold a menu of commands. You can register
@ -41,16 +42,15 @@ class CommandTree:
./scriptname --option="value" [submenu] [command]
is different from
is different than
./scriptname [submenu] [command] --option="value"
in that option is being applied to the application in the first example and
applied to the command (under the submenu command group) in
applied to the refresh_datasets command (under the nhsn command group) in
the second. In the same way the -h, --help options print different docs
depending on where the help option was passed.
"""
def __init__(self):
self.root_parser = argparse.ArgumentParser()
self.submenu_param = None # submenu_param is the variable name
@ -66,15 +66,13 @@ class CommandTree:
self._single_command = None
def print_tree(self):
raise NotImplementedError
raise NotImplemented
def add_argument(self, *args, **kwargs):
"""
Adds an argument to the root parser.
"""
_bootstrap_logger.info(
"adding argument to root parser: %s and %s", args, kwargs
)
_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):
@ -82,25 +80,41 @@ class CommandTree:
Creates a root-level submenu with no entries. SubMenu node is
returned which can have submenus and commands attached to it.
"""
func_args = {"dest": param_name, "metavar": param_name, "required": is_required}
# NOTE(MG) Fix below strategizes whether to pass in 'required'
# paremter to ArgumentParser.add_subparsers()
# which was added in in Python3.7.
# 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 <= 6
):
if is_required:
_bootstrap_logger.warn('Unable to enforce required submenu: Requires >= Python 3.7')
del func_args['required']
# 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
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
@ -114,7 +128,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
@ -131,30 +145,35 @@ class CommandTree:
# help is displayed next to the command in the submenu enumeration or
# list of commands:
HelpGenerator.generate_help_from_sig(docstring)
help_text = HelpGenerator.generate_help_from_sig(docstring)
# description is displayed when querying help for the specific command:
HelpGenerator.generate_description_from_sig(docstring)
description_text = HelpGenerator.generate_description_from_sig(docstring)
# end copy-paste from SubMenu.register_command
# begin copy-paste then edited from SubMenu.register_command
# For each parameter in the function create an argparse argument in
# begin copy-paste then editted from SubMenu.register_command
# For each paramter in the function create an argparse argument in
# the child ArgumentParser created for this menu entry:
for key in params:
if key == "self":
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
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()
@ -165,8 +184,8 @@ class CommandTree:
cmd.callback = func
registered_name = cmd_name
_bootstrap_logger.info("registered command: %s", registered_name)
# end copy-paste then edited from SubMenu.register_command
_bootstrap_logger.info('registered command: %s', registered_name)
# end copy-paste then editted from SubMenu.register_command
self._cmd_tree_is_single_command = True
self._single_command = cmd
@ -190,39 +209,33 @@ 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')
return pargs, unk, True
# Note: SystemExit is raised when '-h' argument is supplied.
except SystemExit:
except SystemExit as ex:
return None, None, False
def run_command(self, args=None):
"""
Args:
args (list[str]): Direct from STDIN
Raises:
NoCommandSpecified for invalid command.
"""
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.")
raise NoCommandSpecified
_bootstrap_logger.critical('cli - Failed to find command.')
return False
return self._invoke_command(cmd, args)
@ -233,22 +246,16 @@ 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 is 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
@ -256,18 +263,14 @@ class CommandTree:
while True:
if argparse_param not in keys:
print("root menu parameter not found in args:", argparse_param)
input("<broken>")
print('root menu parameter not found in args:', argparse_param)
input('<broken>')
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]
@ -280,7 +283,7 @@ class CommandTree:
# return self._invoke_command(lookup, args)
else:
raise NoCommandSpecified("No command specified.")
raise app_container.NoCommandSpecified('No command specified.')
def _invoke_command(self, cmd, args):
command_to_be_invoked = cmd.callback
@ -293,14 +296,13 @@ 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
@ -311,7 +313,8 @@ class SubMenu:
self.entries = {}
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
@ -342,7 +345,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
@ -371,27 +374,33 @@ class SubMenu:
# 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
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()
@ -401,11 +410,15 @@ class SubMenu:
# 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.
@ -430,39 +443,52 @@ 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.
# NOTE(MG) Fix below strategizes whether to pass in 'required'
# paremter to ArgumentParser.add_subparsers()
# which was added in in Python3.7.
# 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 <= 6
):
if is_required:
_bootstrap_logger.warn(
"Unable to enforce required submenu: Requires >= Python 3.7"
)
del func_args["required"]
# END fix for Python<3.7
_bootstrap_logger.warn('Unable to enforce required submenu: Requires >= Python 3.7')
del func_args['required']
# Turn entry into a submenu of it's own:
# type = _SubParsersAction
subp_node = entry_node.add_subparsers(**func_args)
subp_node = entry_node.add_subparsers(
**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.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:
"""
@ -478,7 +504,6 @@ 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
@ -490,8 +515,7 @@ class CommandEntry:
self.callback = None
def __repr__(self):
return "CommandEntry<{}>".format(self.cmd_name)
return 'CommandEntry<{}>'.format(self.cmd_name)
class HelpGenerator:
def __init__(self):
@ -503,12 +527,12 @@ class HelpGenerator:
The 'help' text is displayed next to the command when enumerating
the submenu commands.
"""
if doctext is None:
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
@ -517,10 +541,11 @@ class HelpGenerator:
The 'description' paragraph is provided when the user requests help
on a specific command.
"""
if doctext is None:
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

@ -1,47 +1,49 @@
from ._bootstrap import _bootstrap_logger
from . import _util
import appdirs
import colorlog
import logging
import logging.config
import os
import appdirs
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",
},
"app_skellington": {
# 'handlers': ['stderr',],
"level": "critical",
"propagate": "false",
},
},
'handlers': {
'stderr': {
'class': 'logging.StreamHandler',
'level': 'debug',
'formatter': 'colored'
}
},
'loggers': {
'root': {
'handlers': ['stderr',],
'level': 'debug'
},
'app_skellington': {
# 'handlers': ['stderr',],
'level': 'critical',
'propagate': 'false'
}
}
}
class LoggingLayer:
def __init__(self, appname=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):
@ -70,19 +72,18 @@ class LoggingLayer:
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)
# TODO(MG) Pretty print
_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):
"""
@ -90,52 +91,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:
# app_skellington expects root logger configuration to be under 'root'
# instead of '' (python spec) because '' is not a valid name in ConfigObj.
# Replace 'root' logger with '', logging module convention for root handler
# Note: '' is disallowed in ConfigObj (hence the reason for this replacement)
try:
if config_dict["loggers"].get("root") is not None:
config_dict["loggers"][""] = config_dict["loggers"]["root"]
del config_dict["loggers"]["root"]
except Exception:
_bootstrap_logger.warn(
"was not able to find and patch root logger configuration from arguments"
)
config_dict['loggers'][''] = config_dict['loggers']['root']
del config_dict['loggers']['root']
except Exception as ex:
_bootstrap.logger.warn('internal failure patching root logger')
# 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
@ -145,14 +139,13 @@ 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 'app_skellington' not in config_dict['loggers']:
config_dict['loggers']['app_skellington'] = {
'level': 'debug', 'propagate': 'false'
}
else:
config_dict["loggers"][_logger_name]["level"] = "debug"
config_dict['loggers']['app_skellington']['level'] = 'debug'
def _convert_str_to_loglevel(self, dict_, key):
"""
@ -168,17 +161,18 @@ class LoggingLayer:
"""
try:
s = dict_[key]
except KeyError:
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

@ -1,45 +0,0 @@
[build-system]
requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"
[project]
name = "app_skellington"
dynamic = ["version"]
license = {file = "LICENSE.txt"}
readme = "README.md"
description = "app_skellington CLI framework"
requires-python = ">=3.8"
dependencies = [
"appdirs",
"configobj",
"colorlog"
]
authors = [
{name = "Mathew Guest", email = "mat@zavage.net"}
]
keywords = ["cli", "logging", "application"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent"
]
[project.urls]
homepage = "https://zavage-software.com/portfolio/app_skellington"
repository = "https://git-repos.zavage.net/Zavage-Software/app_skellington"
documentation = "https://git-repos.zavage.net/Zavage-Software/app_skellington"
[project.optional-dependencies]
dev = [
"black",
"pre-commit",
"isort",
"flake8"
]
[tool.setuptools_scm]
version_file = "app_skellington/_version.py"
version_scheme = "release-branch-semver"
local_scheme = "node-and-date"

75
setup.py Executable file

@ -0,0 +1,75 @@
#!/usr/bin/env python
#
# Usage:
#
# First, enable the python environment you want to install to, or if installing
# system-wide then ensure you're logged in with sufficient permissions
# (admin or root to install to system directories)
#
# installation:
#
# $ ./setup.py install
#
# de-installation:
#
# $ pip uninstall app_skellington
from setuptools import setup
import os
__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:
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'
],
# Third-party dependencies; will be automatically installed
install_requires = (
'appdirs',
'configobj',
'colorlog',
),
# Local packages to be installed (our packages)
packages = (
'app_skellington',
),
)

@ -1,49 +1,45 @@
import pytest
from app_skellington import _util
from app_skellington.cfg import Config
from app_skellington import _util
import pytest
@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(
@ -54,39 +50,48 @@ 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"
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'
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,7 +1,8 @@
from app_skellington.cli import CommandTree
class TestCli_e2e:
def test_null_constructor_works(self):
x = CommandTree()
assert True == True