Repository: EVEprosper/ProsperCommon

Issue:
Add make_gunicorn_config entry_point for Flask apps in Docker
Make https://sebest.github.io/post/protips-using-gunicorn-inside-a-docker-image/ easier to access by adding `make_gunicorn_config` to `entry_points`

Discussion:

Patch:
diff --git a/.coveragerc b/.coveragerc
index 4fe80c4..262aea2 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,5 +1,8 @@
 [report]
-    omit = prosper/common/_version.py
+    omit = 
+        prosper/common/_version.py
+        prosper/common/exceptions.py
+        prosper/common/__init__.py
 
 [paths]
     source = prosper/common
\ No newline at end of file
diff --git a/docs/flask_utils.rst b/docs/flask_utils.rst
new file mode 100644
index 0000000..e69de29
diff --git a/docs/prosper_cli.rst b/docs/prosper_cli.rst
index c29feee..adee102 100644
--- a/docs/prosper_cli.rst
+++ b/docs/prosper_cli.rst
@@ -7,6 +7,8 @@ Help create uniform templates and reduce boilerplate, PropsperCLI gives a common
 Using prosper_cli
 =================
 
+**Generic CLI**
+
 .. code-block:: python
 
     """my_app.py"""
@@ -31,6 +33,46 @@ Using prosper_cli
     if __name__ == '__main__':
         MyApplication.run()
 
+-----
+
+**Flask Launcher**
+
+.. code-block:: python
+
+    """my_app.py"""
+    from os import path
+    import prosper.common.prosper_cli as p_cli
+    from _version import __version__
+    from endpoints import APP
+
+    class MyFlaskApplication(p_cli.FlaskLauncher):
+        PROGNAME = 'my_app_name'  # REQUIRED
+        VERSION = __version__
+
+        config_path = path.join(
+            path.abspath(path.dirname(__file__)),
+            'my_config_file.cfg'
+        )
+
+        def main(self):
+            """actual logic goes here"""
+            self.notify_launch()
+            
+            APP.run(
+                host=self.get_host(),
+                port=self.port,
+                debug=self.debug,
+                threaded=self.threaded,
+                process=self.workers,
+            )
+
+    if __name__ == '__main__':
+        MyFlaskApplication.run()
+     
+Meant to be used with `ProsperCookiecutters`_ for debug launching Flask apps.  
+
+-----
+
 By using the Prosper framework, the following is handled automatically:
 
 - Help/version info handled by `Plumbum`_
@@ -44,4 +86,5 @@ By using the Prosper framework, the following is handled automatically:
     - Standardized log formatting
     - Platform and version information for webhook loggers
 
-.. _Plumbum: http://plumbum.readthedocs.io/en/latest/cli.html
\ No newline at end of file
+.. _Plumbum: http://plumbum.readthedocs.io/en/latest/cli.html
+.. _ProsperCookiecutters: https://github.com/EVEprosper/ProsperCookiecutters
diff --git a/prosper/common/flask_utils.py b/prosper/common/flask_utils.py
new file mode 100644
index 0000000..0fe68ce
--- /dev/null
+++ b/prosper/common/flask_utils.py
@@ -0,0 +1,34 @@
+"""flask_utils: general purpose Flask helpers for Docker/Debug"""
+from os import path, environ
+
+def make_gunicorn_config(
+        _gunicorn_config_path='',
+):
+    """makes gunicorn.conf file for launching in docker
+
+    Notes:
+        https://sebest.github.io/post/protips-using-gunicorn-inside-a-docker-image/
+        renders gunicorn.config (python) file in running dir
+        looks for GUNICORN_{option} in environment vars
+    Args:
+        _gunicorn_config_path (str): TEST HOOK, path to dump file
+
+    """
+    gunicorn_py = '''"""AUTOGENERATED BY: prosper.common.flask_utils:gunicorn_config
+Based off: https://sebest.github.io/post/protips-using-gunicorn-inside-a-docker-image/
+"""
+from os import environ
+
+for key, value in environ.items():
+    if key.startswith('GUNICORN_'):
+        gunicorn_key = key.split('_', 1)[1].lower()
+        locals()[gunicorn_key] = value
+
+'''
+
+    gunicorn_file = 'gunicorn.conf'
+    if _gunicorn_config_path:
+        gunicorn_file = _gunicorn_config_path
+
+    with open(gunicorn_file, 'w') as gunicorn_cfg:
+        gunicorn_cfg.write(gunicorn_py)
diff --git a/prosper/common/prosper_cli.py b/prosper/common/prosper_cli.py
index 701558b..8781f3f 100644
--- a/prosper/common/prosper_cli.py
+++ b/prosper/common/prosper_cli.py
@@ -1,4 +1,6 @@
 """Plumbum CLI wrapper for easier/common application writing"""
+import logging
+import os
 import platform
 
 from plumbum import cli
@@ -32,14 +34,14 @@ class ProsperApplication(cli.Application):
         ['--config'],
         str,
         help='Override default config')
-    def override_config(self, config_path):
+    def override_config(self, config_path):  # pragma: no cover
         """override config object with local version"""
         self.config_path = config_path
 
     @cli.switch(
         ['--dump-config'],
         help='Dump default config to stdout')
-    def dump_config(self):
+    def dump_config(self):  # pragma: no cover
         """dumps configfile to stdout so users can edit/implement their own"""
         with open(self.config_path, 'r') as cfg_fh:
             base_config = cfg_fh.read()
@@ -67,15 +69,15 @@ class ProsperApplication(cli.Application):
                     platform=platform.node(),
                     version=self.VERSION
                 )
-                if self.config.get('LOGGING', 'discord_webhook'):
+                if self.config.get_option('LOGGING', 'discord_webhook'):
                     log_builder.configure_discord_logger(
                         custom_args=id_string
                     )
-                if self.config.get('LOGGING', 'slack_webhook'):
+                if self.config.get_option('LOGGING', 'slack_webhook'):
                     log_builder.configure_slack_logger(
                         custom_args=id_string
                     )
-                if self.config.get('LOGGING', 'hipchat_webhook'):
+                if self.config.get_option('LOGGING', 'hipchat_webhook'):
                     log_builder.configure_hipchat_logger(
                         custom_args=id_string
                     )
@@ -93,19 +95,78 @@ class ProsperApplication(cli.Application):
             self._config = p_config.ProsperConfig(self.config_path)
             return self._config
 
-class ProsperTESTApplication(ProsperApplication):
+
+OPTION_ARGS = (
+    'debug', 'port', 'threaded', 'workers'
+)
+class FlaskLauncher(ProsperApplication):
+    """wrapper for launching (DEBUG) Flask apps"""
+
+    port = cli.SwitchAttr(
+        ['p', '--port'],
+        int,
+        help='port to launch Flask app on',
+        default=int(os.environ.get('PROSPER_FLASK__port', 8000)),
+    )
+    threaded = cli.SwitchAttr(
+        ['t', '--threaded'],
+        bool,
+        help='Launch Werkzeug in threaded mode',
+        default=os.environ.get('PROSPER_FLASK__threadded', False),
+    )
+    workers = cli.SwitchAttr(
+        ['w', '--workers'],
+        int,
+        help='Launch Werkzeug with multiple worker threads',
+        default=int(os.environ.get('PROSPER_FLASK__workers', 1)),
+    )
+
+
+    def get_host(self):
+        """returns appropriate host configuration
+
+        Returns:
+            str: host IP (127.0.0.1 or 0.0.0.0)
+
+        """
+        if self.debug:
+            return '127.0.0.1'
+        else:
+            return '0.0.0.0'
+
+    def notify_launch(self, log_level='ERROR'):
+        """logs launcher message before startup
+
+        Args:
+            log_level (str): level to notify at
+
+        """
+        if not self.debug:
+            self.logger.log(
+                logging.getLevelName(log_level),
+                'LAUNCHING %s -- %s', self.PROGNAME, platform.node()
+            )
+        flask_options = {
+            key: getattr(self, key) for key in OPTION_ARGS
+        }
+        flask_options['host'] = self.get_host()
+
+        self.logger.info('OPTIONS: %s', flask_options)
+
+
+class ProsperTESTApplication(ProsperApplication):  # pragma: no cover
     """test wrapper for CLI tests"""
-    from os import path
     PROGNAME = 'CLITEST'
     VERSION = '0.0.0'
 
-    HERE = path.abspath(path.dirname(__file__))
+    HERE = os.path.abspath(os.path.dirname(__file__))
 
-    config_path = path.join(HERE, 'common_config.cfg')
+    config_path = os.path.join(HERE, 'common_config.cfg')
 
     def main(self):
         """do stuff"""
         self.logger.info('HELLO WORLD')
 
-if __name__ == '__main__':
+
+if __name__ == '__main__':  # pragma: no cover
     ProsperTESTApplication.run()  # test hook
diff --git a/prosper/common/prosper_config.py b/prosper/common/prosper_config.py
index efae88a..ef8810f 100644
--- a/prosper/common/prosper_config.py
+++ b/prosper/common/prosper_config.py
@@ -10,10 +10,6 @@ from configparser import ExtendedInterpolation
 import warnings
 import logging
 
-DEFAULT_LOGGER = logging.getLogger('NULL')
-DEFAULT_LOGGER.addHandler(logging.NullHandler())
-
-HERE = path.abspath(path.dirname(__file__))
 
 class ProsperConfig(object):
     """configuration handler for all prosper projects
@@ -26,19 +22,22 @@ class ProsperConfig(object):
     4. environment varabile
     5. args_default -- function default w/o global config
 
+    Args:
+        config_filename (str): path to config file
+        local_filepath_override (str): path modifier for private config file
+
     Attributes:
         global_config (:obj:`configparser.ConfigParser`)
         local_config (:obj:`configparser.ConfigParser`)
         config_filename (str): filename of global/tracked/default .cfg file
         local_config_filename (str): filename for local/custom .cfg file
+
     """
-    _debug_mode = False
+    logger = logging.getLogger('ProsperCommon')
     def __init__(
             self,
             config_filename,
-            local_filepath_override=None,
-            logger=DEFAULT_LOGGER,
-            debug_mode=_debug_mode
+            local_filepath_override='',
     ):
         """get the config filename for initializing data structures
 
@@ -49,7 +48,6 @@ class ProsperConfig(object):
             debug_mode (bool, optional): enable debug modes for config helper
 
         """
-        self.logger = logger
         self.config_filename = config_filename
         self.local_config_filename = get_local_config_filepath(config_filename)
         if local_filepath_override:
@@ -57,13 +55,13 @@ class ProsperConfig(object):
             #TODO: force filepaths to abspaths?
         self.global_config, self.local_config = get_configs(
             config_filename,
-            self.local_config_filename
+            self.local_config_filename,
         )
 
     def get(
             self,
             section_name,
-            key_name
+            key_name,
     ):
         """Replicate configparser.get() functionality
 
@@ -80,13 +78,13 @@ class ProsperConfig(object):
             value = self.local_config.get(section_name, key_name)
         except Exception as error_msg:
             self.logger.warning(
-                '{0}.{1} not found in local config'.format(section_name, key_name)
+                '%s.%s not found in local config', section_name, key_name
             )
             try:
                 value = self.global_config.get(section_name, key_name)
             except Exception as error_msg:
                 self.logger.error(
-                    '{0}.{1} not found in global config'.format(section_name, key_name)
+                    '%s.%s not found in global config', section_name, key_name
                 )
                 raise KeyError('Could not find option in local/global config')
 
@@ -97,7 +95,7 @@ class ProsperConfig(object):
             section_name,
             key_name,
             args_option=None,
-            args_default=None
+            args_default=None,
     ):
         """evaluates the requested option and returns the correct value
 
@@ -119,7 +117,6 @@ class ProsperConfig(object):
             (str) appropriate response as per priority order
 
         """
-        self.logger.debug('picking config')
         if args_option != args_default and\
            args_option is not None:
             self.logger.debug('-- using function args')
@@ -127,28 +124,28 @@ class ProsperConfig(object):
 
         section_info = section_name + '.' + key_name
 
-        local_option = None
+        option = None
         try:
-            local_option = self.local_config[section_name][key_name]
-        except KeyError:
-            self.logger.debug(section_info + 'not found in local config')
-        if local_option:
+            option = self.local_config[section_name][key_name]
             self.logger.debug('-- using local config')
-            return local_option
+            if option:
+                return option
+        except (KeyError, configparser.NoOptionError, configparser.NoSectionError):
+            self.logger.debug('`%s` not found in local config', section_info)
 
-        global_option = None
         try:
-            global_option = self.global_config[section_name][key_name]
-        except KeyError:# as error_msg:
-            self.logger.warning(section_info + 'not found in global config')
-        if global_option:
+            option = self.global_config[section_name][key_name]
             self.logger.debug('-- using global config')
-            return global_option
+            if option:
+                return option
+        except (KeyError, configparser.NoOptionError, configparser.NoSectionError):
+            self.logger.warning('`%s` not found in global config', section_info)
 
         env_option = get_value_from_environment(section_name, key_name, logger=self.logger)
         if env_option:
             self.logger.debug('-- using environment value')
             return env_option
+
         self.logger.debug('-- using default argument')
         return args_default #If all esle fails return the given default
 
@@ -161,18 +158,18 @@ def get_value_from_environment(
         section_name,
         key_name,
         envname_pad=ENVNAME_PAD,
-        logger=DEFAULT_LOGGER
+        logger=logging.getLogger('ProsperCommon'),
 ):
     """check environment for key/value pair
 
     Args:
         section_name (str): section name
         key_name (str): key to look up
-        envname_pad (str, optional): namespace padding
-        logger (:obj:`logging.logger`, optional): logging handle
+        envname_pad (str): namespace padding
+        logger (:obj:`logging.logger`): logging handle
 
     Returns:
-        (str) value in environment
+        str: value in environment
 
     """
     var_name = '{pad}_{section}__{key}'.format(
@@ -181,26 +178,25 @@ def get_value_from_environment(
         key=key_name
     )
 
-    logger.debug('var_name={0}'.format(var_name))
+    logger.debug('var_name=%s', var_name)
     value = getenv(var_name)
-    logger.debug('env value={0}'.format(value))
+    logger.debug('env value=%s', value)
 
     return value
 
 def get_configs(
         config_filepath,
-        local_filepath_override=None,
-        debug_mode=False
+        local_filepath_override='',
 ):
     """go and fetch the global/local configs from file and load them with configparser
 
     Args:
-        config_filename (str): path to config
-        debug_mode (bool, optional): enable debug modes for config helper
+        config_filepath (str): path to config
+        local_filepath_override (str): secondary place to locate config file
 
     Returns:
-        (:obj:`configparser.ConfigParser`) global_config
-        (:obj:`configparser.ConfigParser`) local_config
+        ConfigParser: global_config
+        ConfigParser: local_config
 
     """
     global_config = read_config(config_filepath)
@@ -214,13 +210,16 @@ def get_configs(
 
 def read_config(
         config_filepath,
-        logger=DEFAULT_LOGGER
+        logger=logging.getLogger('ProsperCommon'),
 ):
     """fetch and parse config file
 
     Args:
         config_filepath (str): path to config file.  abspath > relpath
-        logger (:obj:`logging.Logger`, optional): logger to catch error msgs
+        logger (:obj:`logging.Logger`): logger to catch error msgs
+
+    Raises:
+        FileNotFound: file access issues
 
     """
     config_parser = configparser.ConfigParser(
@@ -229,57 +228,30 @@ def read_config(
         delimiters=('='),
         inline_comment_prefixes=('#')
     )
-    logger.debug('config_filepath={0}'.format(config_filepath))
+    logger.debug('config_filepath=%s', config_filepath)
     try:
         with open(config_filepath, 'r') as filehandle:
             config_parser.read_file(filehandle)
     except Exception as error_msg:
         logger.error(
-            'EXCEPTION - Unable to parse config file' +
-            '\r\texception={0}'.format(error_msg) +
-            '\r\tconfig_filepath{0}'.format(config_filepath)
+            'Unable to parse config file: %s', config_filepath, exc_info=True
         )
         raise error_msg
 
     return config_parser
 
-def get_config(
-        config_filepath,
-        local_override=False
-):
-    """DEPRECATED: classic v1 config parser.  Obsolete by v0.3.0"""
-    warnings.warn(
-        __name__ + 'replaced with ProsperConfig',
-        DeprecationWarning
-    )
-    config = configparser.ConfigParser(
-        interpolation=ExtendedInterpolation(),
-        allow_no_value=True,
-        delimiters=('='),
-        inline_comment_prefixes=('#')
-    )
-
-    real_config_filepath = get_local_config_filepath(config_filepath)
-
-    if local_override:  #force lookup tracked config
-        real_config_filepath = config_filepath
-
-    with open(real_config_filepath, 'r') as filehandle:
-        config.read_file(filehandle)
-    return config
-
 def get_local_config_filepath(
         config_filepath,
-        force_local=False
+        force_local=False,
 ):
     """helper for finding local filepath for config
 
     Args:
         config_filepath (str): path to local config abspath > relpath
-        force_local (bool, optional): force return of _local.cfg version
+        force_local (bool): force return of _local.cfg version
 
     Returns:
-        (str): Path to local config, or global if path DNE
+        str: Path to local config, or global if path DNE
 
     """
     local_config_filepath = config_filepath.replace('.cfg', '_local.cfg')
diff --git a/prosper/common/prosper_logging.py b/prosper/common/prosper_logging.py
index 17d6f40..a1bf657 100644
--- a/prosper/common/prosper_logging.py
+++ b/prosper/common/prosper_logging.py
@@ -31,7 +31,6 @@ import re
 import requests
 
 import prosper.common.prosper_config as p_config
-import prosper.common.prosper_utilities as p_utils
 import prosper.common.exceptions as exceptions
 
 HERE = path.abspath(path.dirname(__file__))
@@ -284,21 +283,18 @@ class ProsperLogger(object):
 
         # vv TODO vv: Test review #
         if discord_obj.can_query:
-            try:
-                discord_handler = HackyDiscordHandler(
-                    discord_obj,
-                    discord_recipient
-                )
-                self._configure_common(
-                    'discord_',
-                    log_level,
-                    log_format,
-                    'Discord',
-                    discord_handler,
-                    custom_args=custom_args
-                )
-            except Exception as error_msg:  # FIXME: remove this, if we're just re-throwing?
-                raise error_msg
+            discord_handler = HackyDiscordHandler(
+                discord_obj,
+                discord_recipient
+            )
+            self._configure_common(
+                'discord_',
+                log_level,
+                log_format,
+                'Discord',
+                discord_handler,
+                custom_args=custom_args
+            )
         else:
             warnings.warn(
                 'Unable to execute webhook',
@@ -338,20 +334,17 @@ class ProsperLogger(object):
 
         # Actually build slack logging handler #
         # vv TODO vv: Test review #
-        try:
-            slack_handler = HackySlackHandler(
-                slack_webhook
-            )
-            self._configure_common(
-                'slack_',
-                log_level,
-                log_format,
-                'Slack',
-                slack_handler,
-                custom_args=custom_args
-            )
-        except Exception as error_msg:
-            raise error_msg
+        slack_handler = HackySlackHandler(
+            slack_webhook
+        )
+        self._configure_common(
+            'slack_',
+            log_level,
+            log_format,
+            'Slack',
+            slack_handler,
+            custom_args=custom_args
+        )
         # ^^ TODO ^^ #
 
     def configure_hipchat_logger(
@@ -659,7 +652,7 @@ class HackySlackHandler(logging.Handler):
             log_msg (str): actual log message
 
         """
-        if SILENCE_OVERRIDE:
+        if SILENCE_OVERRIDE:  # pragma: no cover
             return
 
         payload = {
diff --git a/prosper/common/prosper_version.py b/prosper/common/prosper_version.py
index 5382d9e..22edc04 100644
--- a/prosper/common/prosper_version.py
+++ b/prosper/common/prosper_version.py
@@ -10,21 +10,20 @@ import warnings
 
 import semantic_version
 
-import prosper.common.exceptions as exceptions
+from . import exceptions
 
 DEFAULT_VERSION = '0.0.0'
-DEFAULT_BRANCH = 'master'
 TEST_MODE = False
 
 def get_version(
         here_path,
-        default_version=DEFAULT_VERSION
+        default_version=DEFAULT_VERSION,
 ):
     """tries to resolve version number
 
     Args:
         here_path (str): path to project local dir
-        default_version (str, optional): what version to return if all else fails
+        default_version (str): what version to return if all else fails
 
     Returns:
         str: semantic_version information for library
@@ -59,7 +58,7 @@ def get_version(
 
 def _read_git_tags(
         default_version=DEFAULT_VERSION,
-        git_command=['git', 'tag']
+        git_command=('git', 'tag'),
 ):
     """tries to find current git tag
 
@@ -67,19 +66,19 @@ def _read_git_tags(
         git_command exposed for testing null case
 
     Args:
-        default_version (str, optional): what version to make
-        git_command (:obj:`list`, optional): subprocess command
+        default_version (str): what version to make
+        git_command (:obj:`list`): subprocess command
 
     Retruns:
         str: latest version found, or default
 
-    Raises:
+    Warns:
         exceptions.ProsperDefaultVersionWarning: git version not found
 
     """
-    try:  # pragma: no cover
+    try:
         current_tags = check_output(git_command).splitlines()
-    except Exception:
+    except Exception:  # pragma: no cover
         raise
 
     if not current_tags[0]:
@@ -103,13 +102,13 @@ def _read_git_tags(
 
 def _version_from_file(
         path_to_version,
-        default_version=DEFAULT_VERSION
+        default_version=DEFAULT_VERSION,
 ):
     """for PyPI installed versions, just get data from file
 
     Args:
         path_to_version (str): abspath to dir where version.txt exists
-        default_version (str, optional): fallback version in case of error
+        default_version (str): fallback version in case of error
 
     Returns:
         str: current working version
diff --git a/setup.py b/setup.py
index f135241..1b4bddc 100644
--- a/setup.py
+++ b/setup.py
@@ -33,7 +33,7 @@ def hack_find_packages(include_str):
     setuptools.find_packages(path='') doesn't work as intended
 
     Returns:
-        (:obj:`list` :obj:`str`) append <include_str>. onto every element of setuptools.find_pacakges() call
+        list: append <include_str>. onto every element of setuptools.find_pacakges() call
 
     """
     new_list = [include_str]
@@ -49,7 +49,7 @@ def include_all_subfiles(*args):
         Not recursive, only includes flat files
 
     Returns:
-        (:obj:`list` :obj:`str`) list of all non-directories in a file
+        list: list of all non-directories in a file
 
     """
     file_list = []
@@ -70,7 +70,7 @@ class PyTest(TestCommand):
     http://doc.pytest.org/en/latest/goodpractices.html#manual-integration
 
     """
-    user_options = [('pytest-args=', 'a', "Arguments to pass to pytest")]
+    user_options = [('pytest-args=', 'a', 'Arguments to pass to pytest')]
 
     def initialize_options(self):
         TestCommand.initialize_options(self)
@@ -81,35 +81,34 @@ class PyTest(TestCommand):
             'no:logging',
             '--cov=prosper/' + __library_name__,
             '--cov-report=term-missing',
-            '--cov-config=.coveragerc'
-        ]    #load defaults here
+            '--cov-config=.coveragerc',
+        ]
 
     def run_tests(self):
         import shlex
-        #import here, cause outside the eggs aren't loaded
         import pytest
         pytest_commands = []
-        try:    #read commandline
+        try:
             pytest_commands = shlex.split(self.pytest_args)
-        except AttributeError:  #use defaults
+        except AttributeError:
             pytest_commands = self.pytest_args
         errno = pytest.main(pytest_commands)
         exit(errno)
 
 class QuietTest(PyTest):
     """overrides to prevent webhook spam while developing"""
-    user_options = [('pytest-args=', 'a', "Arguments to pass to pytest")]
-
     def initialize_options(self):
         TestCommand.initialize_options(self)
         self.pytest_args = [
             'tests',
             '-rx',
+            '-p',
+            'no:logging',
             '-m',
             'not loud',
             '--cov=prosper/' + __library_name__,
             '--cov-report=term-missing',
-            '--cov-config=.coveragerc'
+            '--cov-config=.coveragerc',
         ]
 
 with open('README.rst', 'r', 'utf-8') as f:
@@ -125,34 +124,46 @@ setup(
     url='https://github.com/EVEprosper/' + __package_name__,
     license='MIT',
     classifiers=[
-        'Programming Language :: Python :: 3.5'
+        'Programming Language :: Python :: 3.5',
+        'Programming Language :: Python :: 3.6',
     ],
     keywords='prosper eve-online webhooks logging configuration-management',
     packages=hack_find_packages('prosper'),
     include_package_data=True,
     package_data={
-        '': ['LICENSE', 'README.rst']
+        '': ['LICENSE', 'README.rst'],
+    },
+    entry_points={
+        'console_scripts': [
+            'make_gunicorn_config=prosper.common.flask_utils:make_gunicorn_config',
+        ],
     },
     install_requires=[
         'requests',
         'semantic_version',
-        'plumbum'
+        'plumbum',
     ],
     tests_require=[
         'pytest>=3.3.0',
         'testfixtures',
         'pytest_cov',
         'mock',
-        'yolk3k'
+        'yolk3k',
+        'coverage',
+        'docker',
     ],
     extras_require={
         'dev':[
             'sphinx',
             'sphinxcontrib-napoleon',
-        ]
+        ],
+        'test':[
+            'plumbum',
+            'docker',
+        ],
     },
     cmdclass={
         'test':PyTest,
-        'quiettest': QuietTest
-    }
+        'quiet': QuietTest,
+    },
 )
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..e69de29
diff --git a/prosper/common/prosper_utilities.py b/tests/helpers.py
similarity index 66%
rename from prosper/common/prosper_utilities.py
rename to tests/helpers.py
index be00090..c993169 100644
--- a/prosper/common/prosper_utilities.py
+++ b/tests/helpers.py
@@ -1,13 +1,35 @@
-'''utilities.py: worker functions for CREST calls'''
-
+"""TEMPORARY: ported config helpers to ProsperTestHelpers"""
+import configparser
 from os import path
-import logging
 from datetime import datetime
+import warnings
+
+import prosper.common.prosper_config as p_config
+
+def get_config(
+        config_filepath,
+        local_override=False
+):
+    """DEPRECATED: classic v1 config parser.  Obsolete by v0.3.0"""
+    warnings.warn(
+        __name__ + 'replaced with ProsperConfig',
+        DeprecationWarning
+    )
+    config = configparser.ConfigParser(
+        interpolation=configparser.ExtendedInterpolation(),
+        allow_no_value=True,
+        delimiters=('='),
+        inline_comment_prefixes=('#')
+    )
+
+    real_config_filepath = p_config.get_local_config_filepath(config_filepath)
 
-from prosper.common.prosper_config import get_config, get_local_config_filepath
+    if local_override:  #force lookup tracked config
+        real_config_filepath = config_filepath
 
-DEFAULT_LOGGER = logging.getLogger('NULL')
-DEFAULT_LOGGER.addHandler(logging.NullHandler())
+    with open(real_config_filepath, 'r') as filehandle:
+        config.read_file(filehandle)
+    return config
 
 def compare_config_files(config_filepath):
     """compares prod config file vs local version
@@ -16,7 +38,7 @@ def compare_config_files(config_filepath):
         config_filepath (str): path to config file
 
     Returns:
-        (:obj:`dict`) description of unique keys between both configs
+        dict: description of unique keys between both configs
 
     """
     tracked_config = get_config(config_filepath, True)
@@ -24,7 +46,7 @@ def compare_config_files(config_filepath):
 
     unique_values = {}
 
-    if not path.isfile(get_local_config_filepath(config_filepath)): #pragma: no cover
+    if not path.isfile(p_config.get_local_config_filepath(config_filepath)): #pragma: no cover
         #pytest.skip('no local .cfg found, skipping')
         return None
 
@@ -65,8 +87,8 @@ def find_unique_keys(base_config, comp_config, base_name):
         base_name (str): name to tag mismatches with
 
     Returns:
-        (:obj:`list`): unique sections from ConfigParser
-        (:obj:`list`): unique keys from ConfigParser sections
+        list: unique sections from ConfigParser
+        list: unique keys from ConfigParser sections
 
     """
     unique_keys = []


Test patch:
diff --git a/tests/test_flask_utils.py b/tests/test_flask_utils.py
new file mode 100644
index 0000000..748a6f7
--- /dev/null
+++ b/tests/test_flask_utils.py
@@ -0,0 +1,64 @@
+"""test_flask_utils: validates expected behavior for prosper.common.flask_utils"""
+import atexit
+import importlib.util
+from os import path, environ, remove
+import platform
+
+import pytest
+from plumbum import local
+
+import prosper.common.flask_utils as flask_utils
+
+HERE = path.abspath(path.dirname(__file__))
+ROOT = path.dirname(HERE)
+
+python = local['python']
+if platform.system() == 'Windows':
+    which = local['where']
+else:
+    which = local['which']
+
+
+def atexit_remove_file(filepath):
+    """atexit handler to remove tempfiles and avoid clutter"""
+    print('ATEXIT removing: ' + path.abspath(filepath))
+    remove(filepath)
+    assert not path.isfile(filepath)
+
+def test_cli():
+    """make sure entry_point/console_script does what it says"""
+    # TODO: local.cwd() swapping to test dirs
+    gunicorn_conf = local[which('make_gunicorn_config').rstrip()]
+    if path.isfile('gunicorn.conf'):
+        remove('gunicorn.conf')
+
+    gunicorn_conf()
+
+    assert path.isfile('gunicorn.conf')
+    atexit.register(atexit_remove_file, 'gunicorn.conf')
+
+def test_gunicorn_conf():
+    """make sure gunicorn contents works as expected"""
+    # Prep Test
+    environ['GUNICORN_TEST1'] = 'hello'
+    environ['GUNICORN_TEST2'] = 'world'
+    gunicorn_filename = path.join(HERE, '_gunicorn.py')
+    if path.isfile(gunicorn_filename):
+        remove(gunicorn_filename)
+
+    # Create gunicorn config file (.py)
+    flask_utils.make_gunicorn_config(_gunicorn_config_path=gunicorn_filename)
+    assert path.isfile(gunicorn_filename)
+
+    # use importlib to load _gunicorn.py and make sure expected values are there
+    spec = importlib.util.spec_from_file_location('_gunicorn', gunicorn_filename)
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+
+    assert module.test1 == 'hello'
+    assert module.test2 == 'world'
+
+    # Sanitize after test
+    del environ['GUNICORN_TEST1']
+    del environ['GUNICORN_TEST2']
+    atexit.register(atexit_remove_file, gunicorn_filename)
diff --git a/tests/test_prosper_cli.py b/tests/test_prosper_cli.py
index e25fe5b..00dbe04 100644
--- a/tests/test_prosper_cli.py
+++ b/tests/test_prosper_cli.py
@@ -4,12 +4,16 @@ Pytest functions for exercising prosper.common.cli
 
 """
 import configparser
+import logging
 from os import path
 
 import pytest
 from plumbum import local
+from testfixtures import LogCapture
 
 import prosper.common.prosper_cli as p_cli
+import prosper.common.prosper_config as p_config
+import prosper.common.prosper_logging as p_logging
 
 HERE = path.abspath(path.dirname(__file__))
 ROOT = path.join(
@@ -39,6 +43,107 @@ class TestMetaClasses:
         with pytest.raises(NotImplementedError):
             dummy = DummyApplication()
 
+    def test_config_path_happypath(self):
+        """make sure __new__ works as expected"""
+        class DummyApplication(p_cli.ProsperApplication):
+            PROGNAME = 'DUMMY'
+            VERSION = '0.0.0'
+
+            here_path = HERE
+            config_path = LOCAL_CONFIG_PATH
+
+            def main(self):
+                return 'yes'
+        dummy = DummyApplication(__file__)
+
+    def test_app_properties_logger_verbose(self):
+        """make sure properties work as expected"""
+        class DummyVerboseApplication(p_cli.ProsperApplication):
+            PROGNAME = 'DUMMYVERBOSE'
+            VERSION = '0.0.0'
+
+            here_path = HERE
+            config_path = LOCAL_CONFIG_PATH
+
+            def main(self):
+                return 'yes'
+
+        dummy_v = DummyVerboseApplication(__file__)
+        dummy_v.verbose = True
+
+        assert dummy_v._logger is None
+        assert isinstance(dummy_v.logger, logging.Logger)
+
+        handler_types = [type(handler) for handler in dummy_v.logger.handlers]
+        assert logging.StreamHandler in handler_types
+        assert p_logging.HackyDiscordHandler not in handler_types
+        assert p_logging.HackySlackHandler not in handler_types
+        assert p_logging.HackyHipChatHandler not in handler_types
+
+    def test_app_properties_logger_normal(self):
+        """make sure properties work as expected"""
+        class DummyApplication(p_cli.ProsperApplication):
+            PROGNAME = 'DUMMY'
+            VERSION = '0.0.0'
+
+            here_path = HERE
+            config_path = LOCAL_CONFIG_PATH
+
+            def main(self):
+                return 'yes'
+
+        dummy = DummyApplication(__file__)
+
+        assert dummy._logger is None
+        assert isinstance(dummy.logger, logging.Logger)
+
+        handler_types = [type(handler) for handler in dummy.logger.handlers]
+        assert p_logging.HackyDiscordHandler in handler_types
+        assert p_logging.HackySlackHandler in handler_types
+        # assert p_logging.HackyHipChatHandler in handler_types  # TODO: need hipchat test endpoint
+        assert logging.StreamHandler not in handler_types
+
+    def test_app_properties_config(self):
+        """make sure properties work as expected"""
+        class DummyApplication(p_cli.ProsperApplication):
+            PROGNAME = 'DUMMY'
+            VERSION = '0.0.0'
+
+            here_path = HERE
+            config_path = LOCAL_CONFIG_PATH
+
+            def main(self):
+                return 'yes'
+
+        dummy = DummyApplication(__file__)
+        assert isinstance(dummy.config, p_config.ProsperConfig)
+
+
+class TestFlaskLauncher:
+    """validate meta behavior of FlaskLauncher framework"""
+    class DummyFlaskLauncher(p_cli.FlaskLauncher):
+        PROGNAME = 'FLASK_LAUNCHER'
+        VERSION = '0.0.0'
+
+        here_path = HERE
+        config_path = LOCAL_CONFIG_PATH
+
+        def main(self):
+            self.verbose = True
+            self.logger('hello_world')
+
+            self.notify_launch()
+
+
+    def test_get_host(self):
+        """validate get_host method"""
+        dummy = self.DummyFlaskLauncher(__file__)
+        dummy.debug = True
+        assert dummy.get_host() == '127.0.0.1'
+
+        dummy.debug = False
+        assert dummy.get_host() == '0.0.0.0'
+
 
 class TestCLI:
     """validate basic args work as expected"""
@@ -48,7 +153,7 @@ class TestCLI:
     def test_happypath(self):
         """validate output is output"""
         # TODO: test isn't working, but OK?
-        result = self.cli('-v')
+        result = self.cli('--verbose')
         if not result:
             pytest.xfail('expected output?  `{}`'.format(result))
 
diff --git a/tests/test_prosper_config.py b/tests/test_prosper_config.py
index ffe2f46..50432f7 100644
--- a/tests/test_prosper_config.py
+++ b/tests/test_prosper_config.py
@@ -9,7 +9,7 @@ import json
 import pytest
 
 import prosper.common.prosper_config as prosper_config
-import prosper.common.prosper_utilities as prosper_utilities
+import helpers
 
 HERE = path.abspath(path.dirname(__file__))
 ROOT = path.dirname(HERE)
@@ -88,7 +88,7 @@ def test_local_filepath_helper():
 
 def test_config_file():
     """Test makes sure tracked/local configs have all matching keys"""
-    unique_values = prosper_utilities.compare_config_files(LOCAL_CONFIG_PATH)
+    unique_values = helpers.compare_config_files(LOCAL_CONFIG_PATH)
 
     message = ''
     if unique_values:
diff --git a/tests/test_prosper_logging.py b/tests/test_prosper_logging.py
index 2af8220..92e9d35 100644
--- a/tests/test_prosper_logging.py
+++ b/tests/test_prosper_logging.py
@@ -5,9 +5,9 @@ Pytest functions for exercising prosper.common.prosper_logging
 """
 
 from os import path, listdir, remove, makedirs, rmdir
-import configparser
 import logging
 from datetime import datetime
+import platform
 from warnings import warn
 
 import pytest
@@ -71,7 +71,8 @@ def test_cleanup_log_directory(
     for log_file in log_list:
         if '.log' in log_file:  #mac adds .DS_Store and gets cranky about deleting
             log_abspath = path.join(LOG_PATH, log_file)
-            remove(log_abspath)
+            if not platform.system() == 'Windows':
+                remove(log_abspath)
 
 def test_rotating_file_handle(config=TEST_CONFIG):
     """Exercise TimedRotatingFileHandler to make sure logs are generating as expected
@@ -145,7 +146,7 @@ def test_discord_webhook(config_override=TEST_CONFIG):
 
 
     if not DISCORD_WEBHOOK: #FIXME: commenting doesn't work in config file?
-        pytest.skip('discord_webhook is blank')
+        pytest.xfail('discord_webhook is blank')
 
     webhook_obj = prosper_logging.DiscordWebhook()
     webhook_obj.webhook(DISCORD_WEBHOOK)
@@ -158,7 +159,7 @@ SLACK_WEBHOOK = TEST_CONFIG.get_option('LOGGING', 'slack_webhook', None)
 def test_slack_webhook(config_override=TEST_CONFIG):
     """push 'hello world' message through Slack webhook"""
     if not SLACK_WEBHOOK:
-        pytest.skip('slack_webhook is blank')
+        pytest.xfail('slack_webhook is blank')
 
     test_payload = {
         'fallback': 'hello world',
@@ -174,7 +175,7 @@ HIPCHAT_WEBHOOK = TEST_CONFIG.get_option('LOGGING', 'hipchat_webhook', None)
 def test_hipchat_webhook(config_override=TEST_CONFIG):
     """push 'hello world' message through Slack webhook"""
     if not HIPCHAT_WEBHOOK:
-        pytest.skip('hipchat_webhook is blank')
+        pytest.xfail('hipchat_webhook is blank')
 
     test_payload = {
         'color': 'green',
@@ -285,7 +286,7 @@ REQUEST_POST_ENDPOINT  = TEST_CONFIG.get_option('TEST', 'request_POST_endpoint',
 def test_discord_logger(config=TEST_CONFIG):
     """Execute LogCapture on Discord logger object"""
     if not DISCORD_WEBHOOK: #FIXME: commenting doesn't work in config file?
-        pytest.skip('discord_webhook is blank')
+        pytest.xfail('discord_webhook is blank')
 
     test_logname = 'discord_logger'
     log_builder = prosper_logging.ProsperLogger(
@@ -323,7 +324,7 @@ SLACK_POST_ENDPOINT = TEST_CONFIG.get_option('TEST', 'slack_POST_endpoint', None
 def test_slack_logger(config=TEST_CONFIG):
     """Execute LogCapture on Slack logger object"""
     if not SLACK_WEBHOOK:
-        pytest.skip('slack_webhook is blank')
+        pytest.xfail('slack_webhook is blank')
 
     test_logname = 'slack_logger'
     log_builder = prosper_logging.ProsperLogger(
@@ -359,7 +360,7 @@ HIPCHAT_PORT = TEST_CONFIG.get_option('TEST', 'hipchat_port', None)
 def test_hipchat_logger(config=TEST_CONFIG):
     """Execute LogCapture on Slack logger object"""
     if not HIPCHAT_WEBHOOK:
-        pytest.skip('hipchat_webhook is blank')
+        pytest.xfail('hipchat_webhook is blank')
 
     test_logname = 'hipchat_logger'
     log_builder = prosper_logging.ProsperLogger(
diff --git a/tests/test_prosper_version.py b/tests/test_prosper_version.py
index 49b4615..df323db 100644
--- a/tests/test_prosper_version.py
+++ b/tests/test_prosper_version.py
@@ -7,6 +7,7 @@ from codecs import decode
 from os import path
 import os
 import shutil
+import sys
 from subprocess import check_output
 
 import pytest
@@ -75,6 +76,10 @@ def test_read_git_tags_default():
 def test_read_git_tags_happypath():
     """validate version matches expectation"""
 
+    tag_version = semantic_version.Version(p_version._read_git_tags())
+    if tag_version.prerelease:
+        pytest.xfail('PyPI prerelease formatting not compatable with `semantic_version`')
+
     released_versions_report = check_output(['yolk', '-V', 'prospercommon']).splitlines()
 
     released_versions = []
@@ -83,7 +88,6 @@ def test_read_git_tags_happypath():
 
     current_version = max([semantic_version.Version(line) for line in released_versions])
 
-    tag_version = semantic_version.Version(p_version._read_git_tags())
 
     tag_status = tag_version <= current_version   #expect equal-to or less-than current release
 
@@ -95,6 +99,7 @@ def test_read_git_tags_happypath():
         pytest.xfail(
             'Expected release mismatch -- tag={} yolk={}'.format(tag_version, current_version))
         #assert tag_version <= current_version   #expect equal-to or less-than current release
+
 def test_version_from_file_default():
     """validate default version returns from _version_from_file()"""
     with pytest.warns(exceptions.ProsperDefaultVersionWarning):
@@ -131,8 +136,12 @@ def test_version_installed_as_dep():
     # Prep a dummy version
     virtualenv_name = 'DUMMY_VENV'
     dummy_version = '9.9.9'
+    python_version = 'python{major}.{minor}'.format(
+        major=sys.version_info[0],
+        minor=sys.version_info[1]
+    )
     virtualenv_path = path.join(
-        HERE, virtualenv_name, 'lib/python3.6/site-packages/prosper/common')
+        HERE, virtualenv_name, 'lib', python_version, 'site-packages/prosper/common')
     os.makedirs(virtualenv_path, exist_ok=True)
     with open(path.join(virtualenv_path, 'version.txt'), 'w') as dummy_fh:
         dummy_fh.write(dummy_version)