Repository: CORE-GATECH-GROUP/serpent-tools (python)

Issue:
Feature: Implement a messaging and exception framework
Implement a overarching data logger that controls warnings, errors, and debug statements that allows the user to set the verbosity through the `rc` system. Maybe piggyback off of the [logging module](https://docs.python.org/3.6/library/logging.html)

Create a `SerpentToolsError` that is the base type for all critical errors thrown during operation.

## Usage
```from serpentTools.settings import rc
rc['verbosity'] = 'debug'
# print all status updates, errors, and debug statements
rc['definitely not a setting'] = 'still not good'
# raises SerpentToolsError or some subclass thereof
rc['verbosity'] = 'quiet'
# print only critical errors, same as `critical`
```

Pull request:
Messages, logging, and exception
Closes #16 

Added a `messages` module that contains functions for various status updates.
A custom [`Logger`](https://docs.python.org/2/library/logging.html) is created, and all updates, warnings, and debug statements pass through this object.
This is hidden with double underscores, `__logger__`, as to be [super private](https://stackoverflow.com/questions/1301346/what-is-the-meaning-of-a-single-and-a-double-underscore-before-an-object-name).

All the updates are passed to one of four functions: `messages.debug`, `messages.info`, `messages.warnings`, and `messages.error`. 
If we decide to change the underlying error reporting, by adding some file logs, or something totally crazy, as long as every other program just uses these functions, nothing *should* break.

## Usage Examples
### In the project
From the depletion reader:
```
def read(self):
        """Read through the depletion file and store requested data."""
        messages.info('Preparing to read {}'.format(self.filePath))
        # reading
        messages.info('Done reading depletion file')
        messages.debug('  found {} materials'.format(len(self.materials)))
```
Currently, the depletion reader does not play well with materials that contain underscores, because of variables like `ING_TOX`.
A warning is printed to inform the user of this fact: 
```
if any(['_' in pat for pat in patterns]):
        messages.warning('Materials with underscores are not supported.')`
```
Any time a setting is changed, a debug statement is printed
```messages.debug('Updated setting {} to {}'.format(name, value))```

The `error` function has an additional setting: `fatal`.
If this value is true, a critical message is created, but also a `SerpentToolsException` is raised with the same message.
This may be redundant, but the exception can be caught and suppressed, while the message should still be raised.
If `fatal` is false, then an error message is posted.

### Outward facing
To the user, the notices look as follows:
```

[ajohnson400@core serpent-tools]$ python
Python 3.6.0 |Anaconda 4.3.1 (64-bit)| (default, Dec 23 2016, 12:22:00)
[GCC 4.4.7 20120313 (Red Hat 4.4.7-1)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import serpentTools
INFO    : serpentTools   : Using version 1.0b0+3.gb8a02ab
>>> from serpentTools.settings import rc
>>> rc['verbosity'] = 'debug'
DEBUG   : serpentTools   : Updated setting verbosity to debug
>>> rc['depletion.processTotal'] = False
DEBUG   : serpentTools   : Updated setting depletion.processTotal to False
```


## Exception
As mentioned above, a custom `SerpentToolsException` was created.
This is for any exception that is unique to this project and not covered by the [base exceptions](https://docs.python.org/2/library/exceptions.html).
This can be subclassed for unique exceptions, e.g.
```
class ReaderSpecificException(SerpentToolsException):
    pass
```

Patch:
diff --git a/serpentTools/__init__.py b/serpentTools/__init__.py
index fdaf895..8d07887 100644
--- a/serpentTools/__init__.py
+++ b/serpentTools/__init__.py
@@ -3,10 +3,6 @@ from serpentTools import parsers
 
 
 # List TODOS/feature requests here for now
-# Messages/Errors
-# TODO: Add verbosity control
-# TODO: Add specific exceptions and warnings
-# TODO: Add logging module to help with warnings/exceptions/info
 # Compatability
 # TODO: Python 2 support
 # TODO: Test compatability with earlier numpy releases
@@ -14,9 +10,10 @@ from serpentTools import parsers
 # TODO: Update rc with dictionary
 # TODO: Update rc with yaml file into dictionary
 # TODO: Capture materials with underscores for depletion
-# TODO: Better version string management
 
 
 from ._version import get_versions
 __version__ = get_versions()['version']
 del get_versions
+
+settings.messages.info('Using version {}'.format(__version__))
diff --git a/serpentTools/parsers/depletion.py b/serpentTools/parsers/depletion.py
index 002f516..6c2ff82 100644
--- a/serpentTools/parsers/depletion.py
+++ b/serpentTools/parsers/depletion.py
@@ -8,6 +8,8 @@ from drewtils.parsers import KeywordParser
 from serpentTools.objects.readers import MaterialReader
 from serpentTools.objects.materials import DepletedMaterial
 
+from serpentTools.settings import messages
+
 
 class DepletionReader(MaterialReader):
     """Parser responsible for reading and working with depletion files.
@@ -60,10 +62,13 @@ class DepletionReader(MaterialReader):
         """Return the patterns by which to find the requested materials."""
         patterns = self.settings['materials'] or ['.*']
         # match all materials if nothing given
+        if any(['_' in pat for pat in patterns]):
+            messages.warning('Materials with underscores are not supported.')
         return [re.compile(mat) for mat in patterns]
 
     def read(self):
         """Read through the depletion file and store requested data."""
+        messages.info('Preparing to read {}'.format(self.filePath))
         keys = ['MAT', 'TOT'] if self.settings['processTotal'] else ['MAT']
         keys.extend(self.settings['metadataKeys'])
         separators = ['\n', '];']
@@ -74,6 +79,8 @@ class DepletionReader(MaterialReader):
                 elif (('TOT' in chunk[0] and self.settings['processTotal'])
                       or 'MAT' in chunk[0]):
                     self._addMaterial(chunk)
+        messages.info('Done reading depletion file')
+        messages.debug('  found {} materials'.format(len(self.materials)))
 
     def _addMetadata(self, chunk):
         options = {'ZAI': 'zai', 'NAMES': 'names', 'DAYS': 'days',
diff --git a/serpentTools/settings/__init__.py b/serpentTools/settings/__init__.py
index 2223fc0..09f5fdb 100644
--- a/serpentTools/settings/__init__.py
+++ b/serpentTools/settings/__init__.py
@@ -1,4 +1,5 @@
 """Settings to yield control to the user."""
+from serpentTools.settings import messages
 
 defaultSettings = {
     'depletion.metadataKeys': {
@@ -23,6 +24,13 @@ defaultSettings = {
         'default': True,
         'description': 'Option to store the depletion data from the TOT block',
         'type': bool
+    },
+    'verbosity': {
+        'default': 'warning',
+        'options': messages.LOG_OPTS,
+        'type': str,
+        'description': 'Set the level of errors to be shown.',
+        'updater': messages.updateLevel
     }
 }
 
@@ -30,12 +38,13 @@ defaultSettings = {
 class DefaultSetting(object):
     """Store a single setting."""
 
-    def __init__(self, name, default, varType, description, options):
+    def __init__(self, name, default, varType, description, options, updater):
         self.name = name
         self.description = description
         self.default = default
         self.type = varType
         self.options = options
+        self.updater = updater
 
     def __repr__(self):
         return '<DefaultSetting {}: value: {}>'.format(self.name, self.default)
@@ -82,7 +91,8 @@ class DefaultSettingsLoader(dict):
         dict.__init__(self, self._load())
         self.__locked__ = True
 
-    def _load(self):
+    @staticmethod
+    def _load():
         """Load the default setting objects."""
         defaults = {}
         for name, value in defaultSettings.items():
@@ -95,7 +105,8 @@ class DefaultSettingsLoader(dict):
                                'default': value['default'],
                                'varType': value['type'],
                                'options': options,
-                               'description': value['description']
+                               'description': value['description'],
+                               'updater': value.get('updater', None)
                                }
             defaults[name] = DefaultSetting(**settingsOptions)
         return defaults
@@ -163,14 +174,17 @@ class UserSettingsLoader(dict):
             raise KeyError('Setting {} does not exist'.format(name))
         self._defaultLoader[name].validate(value)
         # if we've made it here, then the value is valid
+        if self._defaultLoader[name].updater is not None:
+            value = self._defaultLoader[name].updater(value)
         dict.__setitem__(self, name, value)
+        messages.debug('Updated setting {} to {}'.format(name, value))
 
     def getReaderSettings(self, readerName):
         """Get all module-wide and reader-specific settings.
 
         Parameters
         ----------
-        readerLevel: str
+        readerName: str
             Name of the specific reader.
             Will look for settings with lead with ``readerName``, e.g.
             ``depletion.metadataKeys``
diff --git a/serpentTools/settings/messages.py b/serpentTools/settings/messages.py
new file mode 100644
index 0000000..a0fa0b0
--- /dev/null
+++ b/serpentTools/settings/messages.py
@@ -0,0 +1,84 @@
+"""
+System-wide methods for producing status update and errors.
+
+See Also
+--------
+https://docs.python.org/2/library/logging.html
+https://www.python.org/dev/peps/pep-0391/
+http://docs.python-guide.org/en/latest/writing/logging/
+https://docs.python.org/2/howto/logging-cookbook.html#logging-cookbook
+https://fangpenlin.com/posts/2012/08/26/good-logging-practice-in-python/
+"""
+
+
+import logging
+from logging.config import dictConfig
+
+
+class SerpentToolsException(Exception):
+    """Base-class for all exceptions in this project"""
+    pass
+
+
+LOG_OPTS = ['critical', 'error', 'warning', 'info', 'debug']
+
+
+loggingConfig = {
+    'version': 1,
+    'formatters': {
+        'brief': {'format': '%(levelname)-8s: %(name)-15s: %(message)s'},
+        'precise': {
+            'format': '%(asctime)s %(name)-12s %(levelname)-8s %(message)s'
+        }
+    },
+    'handlers': {
+        'console': {
+            'class': 'logging.StreamHandler',
+            'formatter': 'brief',
+            'level': logging.DEBUG,
+            'stream': 'ext://sys.stdout'
+        }
+    },
+    'root': {
+        'handlers': ['console'],
+        'level': logging.INFO
+    }
+}
+
+dictConfig(loggingConfig)
+
+__logger__ = logging.getLogger('serpentTools')
+
+
+def debug(message):
+    """Log a debug message."""
+    __logger__.debug('%s', message)
+
+
+def info(message):
+    """Log an info message, e.g. status update."""
+    __logger__.info('%s', message)
+
+
+def warning(message):
+    """Log a warning that something that could go wrong or be avoided."""
+    __logger__.warning('%s', message)
+
+
+def error(message, fatal=True):
+    """Log that something went wrong."""
+    if fatal:
+        __logger__.critical('%s', message, exc_info=True)
+        raise SerpentToolsException('%s', message)
+    __logger__.error('%s', message)
+
+
+def updateLevel(level):
+    """Set the level of the logger."""
+    if level.lower() not in LOG_OPTS:
+        __logger__.setLevel('INFO')
+        warning('Logger option {} not in options. Set to warning.')
+        return 'warning'
+    else:
+        __logger__.setLevel(level.upper())
+        return level
diff --git a/setup.py b/setup.py
index fdb65d3..5dd24b4 100644
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,8 @@
 from setuptools import setup
 import versioneer
 
+with open('README.md') as readme:
+    longDesc = readme.read()
 
 classifiers = [
     'License :: OSI Approved :: MIT License',
@@ -23,6 +25,7 @@ setupArgs = {
     'url': 'https://github.com/CORE-GATECH-GROUP/serpent-tools',
     'description': ('A suite of parsers designed to make interacting with '
                     'SERPENT output files simple, scriptable, and flawless'),
+    'long_description': longDesc,
     'test_suite': 'serpentTools.tests',
     'author': 'Andrew Johnson',
     'author_email': 'ajohnson400@gatech.edu',


Test patch:
diff --git a/serpentTools/tests/test_loaders.py b/serpentTools/tests/test_loaders.py
index f6895e1..3bbd24e 100644
--- a/serpentTools/tests/test_loaders.py
+++ b/serpentTools/tests/test_loaders.py
@@ -57,7 +57,8 @@ class UserSettingsTester(unittest.TestCase):
             'metadataKeys': ['ZAI', 'NAMES', 'DAYS', 'BU'],
             'materialVariables': ['ADENS', 'MDENS', 'BURNUP'],
             'materials': [],
-            'processTotal': True
+            'processTotal': True,
+            'verbosity': 'warning'
         }
         actual = self.loader.getReaderSettings(readerName)
         self.assertDictEqual(expected, actual)